@intentset/core 0.1.0 → 0.2.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,892 @@
1
+ /**
2
+ * Reading an export as a consumer must (spec/export.md §5): the checks a
3
+ * connector runs before it lets an envelope replace the snapshot it holds.
4
+ *
5
+ * not-json the bytes are not UTF-8 JSON, or are over the size limit
6
+ * unsupported-contract `contract` is missing or names a version this reader does not know
7
+ * malformed the value does not have the envelope's shape (spec/export.schema.json)
8
+ * identity-mismatch the repository or product is not the one the connection was made for
9
+ * mixed-snapshot a report was computed at another commit or graph hash than the envelope's
10
+ * forbidden-content the envelope holds what its own withholding says it left out
11
+ * inconsistent-report counts, statuses or IDs contradict each other or the artifacts
12
+ *
13
+ * The first three stop the read; the rest are all collected, and the result's
14
+ * category is the first in that order. The shape checks are typed code that
15
+ * mirrors the schema (ADR 0002), and a test holds the two in agreement over
16
+ * every consumer fixture. Nothing here trusts the producer to have been this
17
+ * implementation: a consumer in another language makes the same checks.
18
+ */
19
+ import { compareStrings, isRecord } from "./report.ts";
20
+ import {
21
+ CLAIM_KINDS,
22
+ EXPORT_EVIDENCE_STATUSES,
23
+ EXPORT_REPORTS,
24
+ type ExportEnvelope,
25
+ type ExportReportName,
26
+ ID_PATTERN,
27
+ LEVELS,
28
+ LINK_KINDS,
29
+ OWNERSHIP_REGIONS,
30
+ STATUSES,
31
+ SUPPORTED_EXPORT_CONTRACTS,
32
+ ARTIFACT_TYPES,
33
+ VISIBILITIES,
34
+ } from "./types.ts";
35
+
36
+ export const EXPORT_PROBLEM_CATEGORIES = [
37
+ "not-json",
38
+ "unsupported-contract",
39
+ "malformed",
40
+ "identity-mismatch",
41
+ "mixed-snapshot",
42
+ "forbidden-content",
43
+ "inconsistent-report",
44
+ ] as const;
45
+ export type ExportProblemCategory = (typeof EXPORT_PROBLEM_CATEGORIES)[number];
46
+
47
+ export interface ExportProblem {
48
+ category: ExportProblemCategory;
49
+ /** JSON pointer to the offending value; "" for the whole input. */
50
+ pointer: string;
51
+ /** One sentence, safe to show: it quotes structure, never artifact text. */
52
+ message: string;
53
+ }
54
+
55
+ export interface ReadExportOptions {
56
+ /** The repository the connection was made for: the envelope's `repository` must equal it. */
57
+ repository?: string;
58
+ /** The product the connection was made for: it must be one of the envelope's `products`. */
59
+ product?: string;
60
+ /** Largest input accepted, in bytes, for a string or byte input. Default 64 MiB. */
61
+ maxBytes?: number;
62
+ }
63
+
64
+ export type ReadExportResult =
65
+ | {
66
+ ok: true;
67
+ envelope: ExportEnvelope;
68
+ /** The report sections present, in EXPORT_REPORTS order. Any other is not supplied, never zero or pass. */
69
+ supplied: ExportReportName[];
70
+ }
71
+ | { ok: false; category: ExportProblemCategory; problems: ExportProblem[] };
72
+
73
+ export const EXPORT_MAX_BYTES = 64 * 1024 * 1024;
74
+
75
+ const SHA256 = /^[0-9a-f]{64}$/;
76
+ const COMMIT = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
77
+ const GENERATED_AT = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$/;
78
+ const TIMESTAMP =
79
+ /^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]{1,9})?Z$/;
80
+ const CODE = /^[A-Z]+[0-9]{3}$/;
81
+ const POINTER = /^(?:\/.*)?$/;
82
+ const PROFILE = /^intentset\/[a-z]+\/0\.1$/;
83
+ const EXTENSION_KEY = /^[^/]+\/.+$/;
84
+ const TOKEN = /^\S+$/;
85
+ const TEXT = /\S/;
86
+ const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
87
+
88
+ /** spec/export.md §5: read an envelope as a consumer must, from text, bytes or an already parsed value. */
89
+ export function readExport(input: unknown, options: ReadExportOptions = {}): ReadExportResult {
90
+ const maxBytes = options.maxBytes ?? EXPORT_MAX_BYTES;
91
+ let value: unknown = input;
92
+ if (typeof input === "string" || input instanceof Uint8Array) {
93
+ const bytes = typeof input === "string" ? new TextEncoder().encode(input).length : input.length;
94
+ if (bytes > maxBytes) {
95
+ return failed("not-json", "", `The export is ${bytes} bytes, over the ${maxBytes}-byte limit.`);
96
+ }
97
+ let text: string;
98
+ try {
99
+ text = typeof input === "string" ? input : new TextDecoder("utf-8", { fatal: true }).decode(input);
100
+ } catch {
101
+ return failed("not-json", "", "The export is not valid UTF-8.");
102
+ }
103
+ try {
104
+ value = JSON.parse(text);
105
+ } catch (error) {
106
+ return failed("not-json", "", `The export is not JSON: ${(error as Error).message}`);
107
+ }
108
+ }
109
+
110
+ if (!isRecord(value) || typeof value.contract !== "string") {
111
+ return failed("unsupported-contract", "/contract", "The export names no contract, so its version is unknown.");
112
+ }
113
+ if (!SUPPORTED_EXPORT_CONTRACTS.includes(value.contract)) {
114
+ return failed(
115
+ "unsupported-contract",
116
+ "/contract",
117
+ `The export's contract is ${JSON.stringify(value.contract).slice(0, 80)}; this reader supports ${SUPPORTED_EXPORT_CONTRACTS.join(", ")}.`,
118
+ );
119
+ }
120
+
121
+ const shape = new Shape();
122
+ shape.envelope(value);
123
+ if (shape.problems.length > 0) {
124
+ return { ok: false, category: "malformed", problems: shape.problems };
125
+ }
126
+ const envelope = value as unknown as ExportEnvelope;
127
+ const problems = [
128
+ ...identity(envelope, options),
129
+ ...snapshots(envelope),
130
+ ...forbidden(envelope),
131
+ ...consistency(envelope),
132
+ ];
133
+ if (problems.length > 0) {
134
+ const rank = (p: ExportProblem) => EXPORT_PROBLEM_CATEGORIES.indexOf(p.category);
135
+ problems.sort((a, b) => rank(a) - rank(b) || compareStrings(a.pointer, b.pointer));
136
+ return { ok: false, category: problems[0].category, problems };
137
+ }
138
+ return { ok: true, envelope, supplied: EXPORT_REPORTS.filter((name) => envelope.reports[name] !== undefined) };
139
+ }
140
+
141
+ function failed(category: ExportProblemCategory, pointer: string, message: string): ReadExportResult {
142
+ return { ok: false, category, problems: [{ category, pointer, message }] };
143
+ }
144
+
145
+ /** The shape checks: spec/export.schema.json in typed code. Every problem is `malformed`. */
146
+ class Shape {
147
+ problems: ExportProblem[] = [];
148
+
149
+ fail(pointer: string, message: string): void {
150
+ // A broken input can repeat one mistake thousands of times; the first hundred say enough.
151
+ if (this.problems.length < 100) this.problems.push({ category: "malformed", pointer, message });
152
+ }
153
+
154
+ /** An object with exactly these members (optional ones may be absent); null when it is not an object. */
155
+ object(
156
+ value: unknown,
157
+ at: string,
158
+ required: readonly string[],
159
+ optional: readonly string[] = [],
160
+ ): Record<string, unknown> | null {
161
+ if (!isRecord(value)) {
162
+ this.fail(at, "Expected an object.");
163
+ return null;
164
+ }
165
+ for (const key of required)
166
+ if (!Object.hasOwn(value, key)) this.fail(`${at}/${key}`, `Missing required member ${key}.`);
167
+ for (const key of Object.keys(value)) {
168
+ if (!required.includes(key) && !optional.includes(key))
169
+ this.fail(`${at}/${pointerToken(key)}`, `Unknown member ${key}.`);
170
+ }
171
+ return value;
172
+ }
173
+
174
+ string(value: unknown, at: string, pattern?: RegExp, what = "a string"): value is string {
175
+ if (typeof value !== "string" || value.length === 0) {
176
+ this.fail(at, `Expected ${what}.`);
177
+ return false;
178
+ }
179
+ if (pattern !== undefined && !pattern.test(value)) {
180
+ this.fail(at, `Expected ${what}.`);
181
+ return false;
182
+ }
183
+ return true;
184
+ }
185
+
186
+ nullable(value: unknown, at: string, check: (value: unknown, at: string) => void): void {
187
+ if (value !== null) check(value, at);
188
+ }
189
+
190
+ id(value: unknown, at: string): void {
191
+ this.string(value, at, ID_PATTERN, "an artifact ID");
192
+ }
193
+
194
+ integer(value: unknown, at: string, minimum = 0): void {
195
+ if (typeof value !== "number" || !Number.isInteger(value) || value < minimum) {
196
+ this.fail(at, `Expected an integer of at least ${minimum}.`);
197
+ }
198
+ }
199
+
200
+ boolean(value: unknown, at: string): void {
201
+ if (typeof value !== "boolean") this.fail(at, "Expected true or false.");
202
+ }
203
+
204
+ oneOf(value: unknown, at: string, options: readonly unknown[]): void {
205
+ if (!options.includes(value)) this.fail(at, `Expected one of ${options.join(", ")}.`);
206
+ }
207
+
208
+ /** An array whose items pass `item`; `unique` compares items as JSON, `sorted` as strings or by `key`. */
209
+ array(
210
+ value: unknown,
211
+ at: string,
212
+ item: (value: unknown, at: string) => void,
213
+ rules: { unique?: boolean; minItems?: number; sortedBy?: (value: unknown) => string } = {},
214
+ ): unknown[] {
215
+ if (!Array.isArray(value)) {
216
+ this.fail(at, "Expected an array.");
217
+ return [];
218
+ }
219
+ for (const [i, entry] of value.entries()) item(entry, `${at}/${i}`);
220
+ if (rules.minItems !== undefined && value.length < rules.minItems) {
221
+ this.fail(at, `Expected at least ${rules.minItems} item${rules.minItems === 1 ? "" : "s"}.`);
222
+ }
223
+ if (rules.unique === true && new Set(value.map((v) => JSON.stringify(v))).size !== value.length) {
224
+ this.fail(at, "Expected no repeated items.");
225
+ }
226
+ if (rules.sortedBy !== undefined) {
227
+ const keys = value.map(rules.sortedBy);
228
+ for (let i = 1; i < keys.length; i++) {
229
+ if (compareStrings(keys[i - 1], keys[i]) >= 0) {
230
+ this.fail(`${at}/${i}`, "Expected items sorted and unique.");
231
+ break;
232
+ }
233
+ }
234
+ }
235
+ return value;
236
+ }
237
+
238
+ strings(value: unknown, at: string, rules: { minItems?: number } = {}): void {
239
+ this.array(value, at, (v, p) => this.string(v, p), { unique: true, ...rules });
240
+ }
241
+
242
+ ids(value: unknown, at: string): void {
243
+ this.array(value, at, (v, p) => this.id(v, p), { unique: true });
244
+ }
245
+
246
+ extensions(value: unknown, at: string): void {
247
+ if (!isRecord(value)) {
248
+ this.fail(at, "Expected an object.");
249
+ return;
250
+ }
251
+ for (const key of Object.keys(value)) {
252
+ if (!EXTENSION_KEY.test(key))
253
+ this.fail(`${at}/${pointerToken(key)}`, "Expected a namespaced key such as example.org/name.");
254
+ }
255
+ }
256
+
257
+ envelope(value: Record<string, unknown>): void {
258
+ const v = this.object(value, "", [
259
+ "contract",
260
+ "spec",
261
+ "generatedAt",
262
+ "repository",
263
+ "products",
264
+ "source",
265
+ "graphHash",
266
+ "release",
267
+ "validation",
268
+ "withholding",
269
+ "registries",
270
+ "artifacts",
271
+ "reports",
272
+ ]);
273
+ if (v === null) return;
274
+ if (v.spec !== "0.1") this.fail("/spec", "Expected 0.1.");
275
+ this.string(v.generatedAt, "/generatedAt", GENERATED_AT, "an ISO 8601 time");
276
+ this.string(v.repository, "/repository");
277
+ this.array(v.products, "/products", (p, at) => this.id(p, at), { sortedBy: String });
278
+ this.source(v.source);
279
+ this.string(v.graphHash, "/graphHash", SHA256, "a SHA-256 in lowercase hex");
280
+ this.nullable(v.release, "/release", (release, at) => {
281
+ const r = this.object(release, at, ["product", "label"]);
282
+ if (r === null) return;
283
+ this.id(r.product, `${at}/product`);
284
+ this.string(r.label, `${at}/label`);
285
+ });
286
+ this.validation(v.validation);
287
+ const w = this.object(v.withholding, "/withholding", ["visibilities", "artifacts", "diagnostics"]);
288
+ if (w !== null) {
289
+ this.array(w.visibilities, "/withholding/visibilities", (x, at) => this.oneOf(x, at, VISIBILITIES), {
290
+ unique: true,
291
+ });
292
+ this.integer(w.artifacts, "/withholding/artifacts");
293
+ this.integer(w.diagnostics, "/withholding/diagnostics");
294
+ }
295
+ this.registries(v.registries);
296
+ this.array(v.artifacts, "/artifacts", (a, at) => this.artifact(a, at), {
297
+ sortedBy: (a) => (isRecord(a) && typeof a.id === "string" ? a.id : ""),
298
+ });
299
+ this.reports(v.reports);
300
+ }
301
+
302
+ source(value: unknown): void {
303
+ const s = this.object(value, "/source", ["commit", "uncommitted"], ["commitUnavailable"]);
304
+ if (s === null) return;
305
+ this.boolean(s.uncommitted, "/source/uncommitted");
306
+ if (s.commit === null) {
307
+ if (!Object.hasOwn(s, "commitUnavailable"))
308
+ this.fail("/source/commitUnavailable", "Expected a reason when commit is null.");
309
+ else this.string(s.commitUnavailable, "/source/commitUnavailable");
310
+ if (s.uncommitted === true) this.fail("/source/uncommitted", "Expected false when commit is null.");
311
+ } else {
312
+ this.string(s.commit, "/source/commit", COMMIT, "a full commit ID");
313
+ if (Object.hasOwn(s, "commitUnavailable"))
314
+ this.fail("/source/commitUnavailable", "Expected no reason when there is a commit.");
315
+ }
316
+ }
317
+
318
+ validation(value: unknown): void {
319
+ const v = this.object(value, "/validation", ["level", "scope", "status", "errors", "warnings", "diagnostics"]);
320
+ if (v === null) return;
321
+ this.oneOf(v.level, "/validation/level", LEVELS);
322
+ this.array(v.scope, "/validation/scope", (s, at) => this.string(s, at));
323
+ this.oneOf(v.status, "/validation/status", ["pass", "fail"]);
324
+ this.integer(v.errors, "/validation/errors");
325
+ this.integer(v.warnings, "/validation/warnings");
326
+ this.array(v.diagnostics, "/validation/diagnostics", (d, at) => this.diagnostic(d, at));
327
+ }
328
+
329
+ diagnostic(value: unknown, at: string): void {
330
+ const d = this.object(
331
+ value,
332
+ at,
333
+ ["code", "severity", "origin", "artifact", "path", "message", "remediation"],
334
+ ["location", "field"],
335
+ );
336
+ if (d === null) return;
337
+ this.string(d.code, `${at}/code`, CODE, "a diagnostic code such as CORE003");
338
+ this.oneOf(d.severity, `${at}/severity`, ["error", "warning"]);
339
+ this.oneOf(d.origin, `${at}/origin`, [
340
+ "syntax",
341
+ "profile",
342
+ "graph",
343
+ "architecture",
344
+ "evidence",
345
+ "publication",
346
+ "render",
347
+ ]);
348
+ if (d.artifact !== null && typeof d.artifact !== "string")
349
+ this.fail(`${at}/artifact`, "Expected a string or null.");
350
+ if (d.path !== null && typeof d.path !== "string") this.fail(`${at}/path`, "Expected a string or null.");
351
+ if (Object.hasOwn(d, "location")) {
352
+ const l = this.object(d.location, `${at}/location`, ["line"], ["column"]);
353
+ if (l !== null) {
354
+ this.integer(l.line, `${at}/location/line`, 1);
355
+ if (Object.hasOwn(l, "column")) this.integer(l.column, `${at}/location/column`, 1);
356
+ }
357
+ }
358
+ if (Object.hasOwn(d, "field") && (typeof d.field !== "string" || !POINTER.test(d.field))) {
359
+ this.fail(`${at}/field`, "Expected a JSON pointer.");
360
+ }
361
+ this.string(d.message, `${at}/message`);
362
+ this.string(d.remediation, `${at}/remediation`);
363
+ }
364
+
365
+ registries(value: unknown): void {
366
+ const at = "/registries";
367
+ const r = this.object(value, at, ["owners", "audiences", "releases", "roles", "editions", "flags", "resources"]);
368
+ if (r === null) return;
369
+ for (const key of ["owners", "audiences", "releases", "roles", "editions", "flags"])
370
+ this.strings(r[key], `${at}/${key}`);
371
+ this.array(r.resources, `${at}/resources`, (resource, p) => {
372
+ const x = this.object(resource, p, ["id", "path", "owner", "consumers"]);
373
+ if (x === null) return;
374
+ this.id(x.id, `${p}/id`);
375
+ this.string(x.path, `${p}/path`);
376
+ this.string(x.owner, `${p}/owner`);
377
+ this.strings(x.consumers, `${p}/consumers`);
378
+ });
379
+ }
380
+
381
+ artifact(value: unknown, at: string): void {
382
+ const a = this.object(
383
+ value,
384
+ at,
385
+ [
386
+ "id",
387
+ "type",
388
+ "title",
389
+ "status",
390
+ "owner",
391
+ "visibility",
392
+ "audiences",
393
+ "profile",
394
+ "revision",
395
+ "path",
396
+ "sourceHash",
397
+ "parent",
398
+ "links",
399
+ "derived",
400
+ "withheldLinks",
401
+ "availability",
402
+ "slice",
403
+ "verification",
404
+ "extensions",
405
+ ],
406
+ ["body"],
407
+ );
408
+ if (a === null) return;
409
+ this.id(a.id, `${at}/id`);
410
+ this.oneOf(a.type, `${at}/type`, ARTIFACT_TYPES);
411
+ this.string(a.title, `${at}/title`);
412
+ this.oneOf(a.status, `${at}/status`, STATUSES);
413
+ this.string(a.owner, `${at}/owner`);
414
+ this.oneOf(a.visibility, `${at}/visibility`, VISIBILITIES);
415
+ this.strings(a.audiences, `${at}/audiences`, { minItems: 1 });
416
+ this.string(a.profile, `${at}/profile`, PROFILE, "a profile such as intentset/behavior/0.1");
417
+ this.nullable(a.revision, `${at}/revision`, (r, p) => this.integer(r, p, 1));
418
+ this.string(a.path, `${at}/path`);
419
+ this.string(a.sourceHash, `${at}/sourceHash`, SHA256, "a SHA-256 in lowercase hex");
420
+ this.nullable(a.parent, `${at}/parent`, (p, q) => this.id(p, q));
421
+ const kinds = LINK_KINDS.filter((kind) => kind !== "parent");
422
+ const links = this.object(a.links, `${at}/links`, [], kinds);
423
+ if (links !== null)
424
+ for (const key of Object.keys(links))
425
+ if (kinds.includes(key as never)) this.ids(links[key], `${at}/links/${key}`);
426
+ const derived = this.object(a.derived, `${at}/derived`, [], LINK_KINDS);
427
+ if (derived !== null) {
428
+ for (const key of Object.keys(derived))
429
+ if (LINK_KINDS.includes(key as never)) this.ids(derived[key], `${at}/derived/${key}`);
430
+ }
431
+ this.integer(a.withheldLinks, `${at}/withheldLinks`);
432
+ this.nullable(a.availability, `${at}/availability`, (x, p) => {
433
+ const av = this.object(x, p, ["products", "releases", "roles", "editions", "flags"]);
434
+ if (av === null) return;
435
+ for (const key of ["products", "releases", "roles", "editions"])
436
+ this.strings(av[key], `${p}/${key}`, { minItems: 1 });
437
+ this.strings(av.flags, `${p}/flags`);
438
+ });
439
+ this.nullable(a.slice, `${at}/slice`, (x, p) => this.slice(x, p));
440
+ this.nullable(a.verification, `${at}/verification`, (x, p) => {
441
+ const v = this.object(x, p, ["method", "locator", "selector"]);
442
+ if (v === null) return;
443
+ this.oneOf(v.method, `${p}/method`, ["automated", "manual"]);
444
+ this.string(v.locator, `${p}/locator`);
445
+ this.string(v.selector, `${p}/selector`);
446
+ });
447
+ this.nullable(a.extensions, `${at}/extensions`, (x, p) => this.extensions(x, p));
448
+ if (Object.hasOwn(a, "body") && typeof a.body !== "string") this.fail(`${at}/body`, "Expected a string.");
449
+ }
450
+
451
+ slice(value: unknown, at: string): void {
452
+ const s = this.object(
453
+ value,
454
+ at,
455
+ ["kind", "domain", "entrypoints", "layers", "claims", "usesResources"],
456
+ ["rationale"],
457
+ );
458
+ if (s === null) return;
459
+ this.oneOf(s.kind, `${at}/kind`, ["product", "technical"]);
460
+ if (Object.hasOwn(s, "rationale") && typeof s.rationale !== "string")
461
+ this.fail(`${at}/rationale`, "Expected a string.");
462
+ if (typeof s.domain !== "string") this.fail(`${at}/domain`, "Expected a string.");
463
+ this.strings(s.entrypoints, `${at}/entrypoints`, { minItems: 1 });
464
+ if (isRecord(s.layers)) {
465
+ for (const [layer, files] of Object.entries(s.layers)) this.strings(files, `${at}/layers/${pointerToken(layer)}`);
466
+ } else {
467
+ this.fail(`${at}/layers`, "Expected an object.");
468
+ }
469
+ this.array(s.claims, `${at}/claims`, (claim, p) => {
470
+ const c = this.object(claim, p, ["kind", "path"]);
471
+ if (c === null) return;
472
+ this.oneOf(c.kind, `${p}/kind`, CLAIM_KINDS);
473
+ this.string(c.path, `${p}/path`);
474
+ });
475
+ this.ids(s.usesResources, `${at}/usesResources`);
476
+ }
477
+
478
+ reports(value: unknown): void {
479
+ const r = this.object(value, "/reports", [], EXPORT_REPORTS);
480
+ if (r === null) return;
481
+ if (Object.hasOwn(r, "evidence")) this.evidence(r.evidence, "/reports/evidence");
482
+ if (Object.hasOwn(r, "knowledge")) this.knowledge(r.knowledge, "/reports/knowledge");
483
+ if (Object.hasOwn(r, "impact")) this.impact(r.impact, "/reports/impact");
484
+ if (Object.hasOwn(r, "ownership")) this.ownership(r.ownership, "/reports/ownership");
485
+ }
486
+
487
+ snapshotCommit(value: unknown, at: string): void {
488
+ this.nullable(value, at, (c, p) => this.string(c, p, COMMIT, "a full commit ID"));
489
+ }
490
+
491
+ evidence(value: unknown, at: string): void {
492
+ const e = this.object(value, at, ["commit", "graphHash", "scope", "records", "verifications", "claims"]);
493
+ if (e === null) return;
494
+ this.snapshotCommit(e.commit, `${at}/commit`);
495
+ this.string(e.graphHash, `${at}/graphHash`, SHA256, "a SHA-256 in lowercase hex");
496
+ this.nullable(e.scope, `${at}/scope`, (x, p) => this.runScope(x, p));
497
+ this.integer(e.records, `${at}/records`);
498
+ const byId = (x: unknown) => (isRecord(x) && typeof x.id === "string" ? x.id : "");
499
+ this.array(
500
+ e.verifications,
501
+ `${at}/verifications`,
502
+ (x, p) => {
503
+ const v = this.object(x, p, ["id", "method", "status", "note", "latest", "atSnapshot", "runs", "outOfScope"]);
504
+ if (v === null) return;
505
+ this.id(v.id, `${p}/id`);
506
+ this.oneOf(v.method, `${p}/method`, ["automated", "manual"]);
507
+ this.oneOf(v.status, `${p}/status`, EXPORT_EVIDENCE_STATUSES);
508
+ this.nullable(v.note, `${p}/note`, (n, q) => this.string(n, q));
509
+ this.nullable(v.latest, `${p}/latest`, (r, q) => this.runRecord(r, q));
510
+ const counts = this.object(v.atSnapshot, `${p}/atSnapshot`, ["pass", "fail", "skip", "error"]);
511
+ if (counts !== null)
512
+ for (const key of ["pass", "fail", "skip", "error"]) this.integer(counts[key], `${p}/atSnapshot/${key}`);
513
+ this.integer(v.runs, `${p}/runs`);
514
+ this.integer(v.outOfScope, `${p}/outOfScope`);
515
+ },
516
+ { sortedBy: byId },
517
+ );
518
+ this.array(
519
+ e.claims,
520
+ `${at}/claims`,
521
+ (x, p) => {
522
+ const c = this.object(x, p, [
523
+ "id",
524
+ "type",
525
+ "lifecycle",
526
+ "required",
527
+ "linked",
528
+ "verified",
529
+ "verifications",
530
+ "withheld",
531
+ ]);
532
+ if (c === null) return;
533
+ this.id(c.id, `${p}/id`);
534
+ this.oneOf(c.type, `${p}/type`, ["behavior", "rule", "scenario"]);
535
+ this.oneOf(c.lifecycle, `${p}/lifecycle`, STATUSES);
536
+ this.boolean(c.required, `${p}/required`);
537
+ this.boolean(c.linked, `${p}/linked`);
538
+ this.boolean(c.verified, `${p}/verified`);
539
+ this.array(c.verifications, `${p}/verifications`, (id, q) => this.id(id, q), { sortedBy: String });
540
+ this.integer(c.withheld, `${p}/withheld`);
541
+ },
542
+ { sortedBy: byId },
543
+ );
544
+ }
545
+
546
+ runScope(value: unknown, at: string): void {
547
+ const s = this.object(value, at, ["product", "release"]);
548
+ if (s === null) return;
549
+ this.id(s.product, `${at}/product`);
550
+ this.string(s.release, `${at}/release`, TEXT);
551
+ }
552
+
553
+ runRecord(value: unknown, at: string): void {
554
+ const r = this.object(
555
+ value,
556
+ at,
557
+ [
558
+ "evidenceId",
559
+ "verificationId",
560
+ "commit",
561
+ "graphHash",
562
+ "environment",
563
+ "scope",
564
+ "tool",
565
+ "reviewer",
566
+ "startedAt",
567
+ "finishedAt",
568
+ "result",
569
+ "uri",
570
+ ],
571
+ ["rationale", "extensions"],
572
+ );
573
+ if (r === null) return;
574
+ this.string(r.evidenceId, `${at}/evidenceId`, TOKEN);
575
+ this.id(r.verificationId, `${at}/verificationId`);
576
+ this.string(r.commit, `${at}/commit`, TOKEN);
577
+ if (r.graphHash !== "@current") this.string(r.graphHash, `${at}/graphHash`, SHA256, "a SHA-256 in lowercase hex");
578
+ this.string(r.environment, `${at}/environment`, TEXT);
579
+ this.runScope(r.scope, `${at}/scope`);
580
+ this.nullable(r.tool, `${at}/tool`, (x, p) => {
581
+ const t = this.object(x, p, ["name", "version"]);
582
+ if (t === null) return;
583
+ this.string(t.name, `${p}/name`, TEXT);
584
+ this.string(t.version, `${p}/version`, TEXT);
585
+ });
586
+ this.nullable(r.reviewer, `${at}/reviewer`, (x, p) => this.string(x, p, TEXT));
587
+ this.string(r.startedAt, `${at}/startedAt`, TIMESTAMP, "a UTC timestamp");
588
+ this.string(r.finishedAt, `${at}/finishedAt`, TIMESTAMP, "a UTC timestamp");
589
+ this.oneOf(r.result, `${at}/result`, ["pass", "fail", "skip", "error"]);
590
+ this.string(r.uri, `${at}/uri`, URI, "a URI");
591
+ if (Object.hasOwn(r, "rationale")) this.string(r.rationale, `${at}/rationale`, TEXT);
592
+ if (Object.hasOwn(r, "extensions")) this.extensions(r.extensions, `${at}/extensions`);
593
+ if (typeof r.reviewer === "string" && !Object.hasOwn(r, "rationale")) {
594
+ this.fail(`${at}/rationale`, "Expected a rationale for a manual review.");
595
+ }
596
+ if (r.reviewer === null && !isRecord(r.tool)) this.fail(`${at}/tool`, "Expected the tool of an automated run.");
597
+ }
598
+
599
+ knowledge(value: unknown, at: string): void {
600
+ const k = this.object(value, at, ["graphHash", "artifacts"]);
601
+ if (k === null) return;
602
+ this.string(k.graphHash, `${at}/graphHash`, SHA256, "a SHA-256 in lowercase hex");
603
+ this.array(
604
+ k.artifacts,
605
+ `${at}/artifacts`,
606
+ (x, p) => {
607
+ const e = this.object(x, p, [
608
+ "id",
609
+ "lifecycle",
610
+ "status",
611
+ "reviewer",
612
+ "reviewedAt",
613
+ "sources",
614
+ "changed",
615
+ "missing",
616
+ "withheld",
617
+ ]);
618
+ if (e === null) return;
619
+ this.id(e.id, `${p}/id`);
620
+ this.oneOf(e.lifecycle, `${p}/lifecycle`, STATUSES);
621
+ this.oneOf(e.status, `${p}/status`, ["current", "needs-review"]);
622
+ this.nullable(e.reviewer, `${p}/reviewer`, (r, q) => this.string(r, q));
623
+ this.nullable(e.reviewedAt, `${p}/reviewedAt`, (r, q) => this.string(r, q));
624
+ for (const key of ["sources", "changed", "missing"]) {
625
+ this.array(e[key], `${p}/${key}`, (id, q) => this.id(id, q), { sortedBy: String });
626
+ }
627
+ this.integer(e.withheld, `${p}/withheld`);
628
+ },
629
+ { sortedBy: (x) => (isRecord(x) && typeof x.id === "string" ? x.id : "") },
630
+ );
631
+ }
632
+
633
+ impact(value: unknown, at: string): void {
634
+ const i = this.object(value, at, ["graphHash", "note", "starts"]);
635
+ if (i === null) return;
636
+ this.string(i.graphHash, `${at}/graphHash`, SHA256, "a SHA-256 in lowercase hex");
637
+ this.string(i.note, `${at}/note`);
638
+ const hits = (list: unknown, p: string) =>
639
+ this.array(list, p, (x, q) => {
640
+ const h = this.object(x, q, ["id", "path"]);
641
+ if (h === null) return;
642
+ this.id(h.id, `${q}/id`);
643
+ this.array(
644
+ h.path,
645
+ `${q}/path`,
646
+ (step, r) => {
647
+ const s = this.object(step, r, ["id", "via", "reason"]);
648
+ if (s === null) return;
649
+ this.id(s.id, `${r}/id`);
650
+ this.string(s.via, `${r}/via`);
651
+ this.string(s.reason, `${r}/reason`);
652
+ },
653
+ { minItems: 1 },
654
+ );
655
+ });
656
+ this.array(
657
+ i.starts,
658
+ `${at}/starts`,
659
+ (x, p) => {
660
+ const e = this.object(x, p, ["start", "direct", "candidates", "context", "ancestors", "withheld"]);
661
+ if (e === null) return;
662
+ this.id(e.start, `${p}/start`);
663
+ hits(e.direct, `${p}/direct`);
664
+ hits(e.candidates, `${p}/candidates`);
665
+ hits(e.context, `${p}/context`);
666
+ this.ids(e.ancestors, `${p}/ancestors`);
667
+ this.integer(e.withheld, `${p}/withheld`);
668
+ },
669
+ { sortedBy: (x) => (isRecord(x) && typeof x.start === "string" ? x.start : "") },
670
+ );
671
+ }
672
+
673
+ ownership(value: unknown, at: string): void {
674
+ const o = this.object(value, at, ["commit", "graphHash", "files", "withheld"]);
675
+ if (o === null) return;
676
+ this.snapshotCommit(o.commit, `${at}/commit`);
677
+ this.string(o.graphHash, `${at}/graphHash`, SHA256, "a SHA-256 in lowercase hex");
678
+ this.array(
679
+ o.files,
680
+ `${at}/files`,
681
+ (x, p) => {
682
+ const f = this.object(x, p, ["path", "region", "owner"]);
683
+ if (f === null) return;
684
+ this.string(f.path, `${p}/path`);
685
+ this.oneOf(f.region, `${p}/region`, OWNERSHIP_REGIONS);
686
+ this.nullable(f.owner, `${p}/owner`, (id, q) => this.id(id, q));
687
+ },
688
+ { sortedBy: (x) => (isRecord(x) && typeof x.path === "string" ? x.path : "") },
689
+ );
690
+ this.integer(o.withheld, `${at}/withheld`);
691
+ }
692
+ }
693
+
694
+ function pointerToken(key: string): string {
695
+ return key.replaceAll("~", "~0").replaceAll("/", "~1");
696
+ }
697
+
698
+ function identity(envelope: ExportEnvelope, options: ReadExportOptions): ExportProblem[] {
699
+ const problems: ExportProblem[] = [];
700
+ if (options.repository !== undefined && envelope.repository !== options.repository) {
701
+ problems.push({
702
+ category: "identity-mismatch",
703
+ pointer: "/repository",
704
+ message: `The export is from repository ${envelope.repository}, and the connection is for ${options.repository}.`,
705
+ });
706
+ }
707
+ if (options.product !== undefined && !envelope.products.includes(options.product)) {
708
+ problems.push({
709
+ category: "identity-mismatch",
710
+ pointer: "/products",
711
+ message: `The export does not contain product ${options.product}, which the connection is for.`,
712
+ });
713
+ }
714
+ return problems;
715
+ }
716
+
717
+ /** Every report names the snapshot it was computed at, and it must be the envelope's: no partial snapshots mixed in. */
718
+ function snapshots(envelope: ExportEnvelope): ExportProblem[] {
719
+ const problems: ExportProblem[] = [];
720
+ const commit = envelope.source.commit;
721
+ for (const name of EXPORT_REPORTS) {
722
+ const report = envelope.reports[name];
723
+ if (report === undefined) continue;
724
+ if (report.graphHash !== envelope.graphHash) {
725
+ problems.push({
726
+ category: "mixed-snapshot",
727
+ pointer: `/reports/${name}/graphHash`,
728
+ message: `The ${name} report was computed at graph ${report.graphHash.slice(0, 12)}, not the export's ${envelope.graphHash.slice(0, 12)}.`,
729
+ });
730
+ }
731
+ if ("commit" in report && report.commit !== commit) {
732
+ problems.push({
733
+ category: "mixed-snapshot",
734
+ pointer: `/reports/${name}/commit`,
735
+ message: `The ${name} report was computed at commit ${report.commit ?? "null"}, not the export's ${commit ?? "null"}.`,
736
+ });
737
+ }
738
+ }
739
+ return problems;
740
+ }
741
+
742
+ /** What the envelope says it withheld must not be in it. */
743
+ function forbidden(envelope: ExportEnvelope): ExportProblem[] {
744
+ const withheld = new Set<string>(envelope.withholding.visibilities);
745
+ const problems: ExportProblem[] = [];
746
+ envelope.artifacts.forEach((artifact, i) => {
747
+ if (withheld.has(artifact.visibility)) {
748
+ problems.push({
749
+ category: "forbidden-content",
750
+ pointer: `/artifacts/${i}/visibility`,
751
+ message: `Artifact ${artifact.id} is ${artifact.visibility}, a visibility the export says it withholds.`,
752
+ });
753
+ }
754
+ });
755
+ return problems;
756
+ }
757
+
758
+ function consistency(envelope: ExportEnvelope): ExportProblem[] {
759
+ const problems: ExportProblem[] = [];
760
+ const fail = (pointer: string, message: string) =>
761
+ problems.push({ category: "inconsistent-report", pointer, message });
762
+ const types = new Map(envelope.artifacts.map((a) => [a.id, a.type]));
763
+ const { validation, withholding } = envelope;
764
+
765
+ if (validation.status !== (validation.errors > 0 ? "fail" : "pass")) {
766
+ fail("/validation/status", `The status is ${validation.status} with ${validation.errors} errors.`);
767
+ }
768
+ const listedErrors = validation.diagnostics.filter((d) => d.severity === "error").length;
769
+ if (
770
+ validation.diagnostics.length + withholding.diagnostics !== validation.errors + validation.warnings ||
771
+ listedErrors > validation.errors ||
772
+ validation.diagnostics.length - listedErrors > validation.warnings
773
+ ) {
774
+ fail(
775
+ "/validation/diagnostics",
776
+ "The diagnostics listed and withheld do not add up to the error and warning counts.",
777
+ );
778
+ }
779
+ envelope.products.forEach((id, i) => {
780
+ if (types.get(id) !== "product") fail(`/products/${i}`, `${id} is not a product artifact in the export.`);
781
+ });
782
+
783
+ const evidence = envelope.reports.evidence;
784
+ if (evidence !== undefined) {
785
+ const at = (r: { commit: string; graphHash: string }) =>
786
+ r.graphHash === envelope.graphHash && (envelope.source.commit === null || r.commit === envelope.source.commit);
787
+ const statusOf = new Map(evidence.verifications.map((v) => [v.id, v.status]));
788
+ for (const artifact of envelope.artifacts) {
789
+ if (artifact.type === "verification" && !statusOf.has(artifact.id)) {
790
+ fail("/reports/evidence/verifications", `Verification ${artifact.id} is exported but has no evidence entry.`);
791
+ }
792
+ }
793
+ evidence.verifications.forEach((v, i) => {
794
+ const p = `/reports/evidence/verifications/${i}`;
795
+ if (types.get(v.id) !== "verification") fail(`${p}/id`, `${v.id} is not a verification artifact in the export.`);
796
+ if (v.latest !== null && v.latest.verificationId !== v.id) {
797
+ fail(`${p}/latest/verificationId`, `The latest record of ${v.id} is a run of ${v.latest.verificationId}.`);
798
+ }
799
+ if (v.status.startsWith("current-") || v.status === "skip" || v.status === "error") {
800
+ const result = v.status.replace("current-", "");
801
+ if (v.latest === null || !at(v.latest) || v.latest.result !== result) {
802
+ fail(
803
+ `${p}/status`,
804
+ `${v.id} is ${v.status}, but its latest record is not a ${result} at the export's snapshot.`,
805
+ );
806
+ }
807
+ } else if (v.status === "stale") {
808
+ if (v.latest === null || at(v.latest))
809
+ fail(`${p}/status`, `${v.id} is stale, but has no record from another snapshot.`);
810
+ } else if (v.status === "missing" && (v.latest !== null || v.runs !== 0)) {
811
+ fail(`${p}/status`, `${v.id} is missing, but has run records.`);
812
+ }
813
+ });
814
+ evidence.claims.forEach((claim, i) => {
815
+ const p = `/reports/evidence/claims/${i}`;
816
+ if (types.get(claim.id) !== claim.type)
817
+ fail(`${p}/id`, `${claim.id} is not a ${claim.type} artifact in the export.`);
818
+ if (claim.linked !== claim.verifications.length + claim.withheld > 0) {
819
+ fail(
820
+ `${p}/linked`,
821
+ `${claim.id} says linked is ${claim.linked} with ${claim.verifications.length + claim.withheld} verifications.`,
822
+ );
823
+ }
824
+ if (claim.verified && (!claim.linked || claim.verifications.some((id) => statusOf.get(id) !== "current-pass"))) {
825
+ fail(
826
+ `${p}/verified`,
827
+ `${claim.id} is marked verified, but not every verification naming it is a current pass.`,
828
+ );
829
+ }
830
+ });
831
+ }
832
+
833
+ const knowledge = envelope.reports.knowledge;
834
+ if (knowledge !== undefined) {
835
+ knowledge.artifacts.forEach((entry, i) => {
836
+ const p = `/reports/knowledge/artifacts/${i}`;
837
+ if (types.get(entry.id) !== "knowledge")
838
+ fail(`${p}/id`, `${entry.id} is not a knowledge artifact in the export.`);
839
+ if (
840
+ entry.status === "current" &&
841
+ (entry.changed.length > 0 || entry.missing.length > 0 || entry.reviewer === null || entry.reviewedAt === null)
842
+ ) {
843
+ fail(
844
+ `${p}/status`,
845
+ `${entry.id} is current, but a source changed or is unpinned, or no reviewer or time is named.`,
846
+ );
847
+ }
848
+ });
849
+ }
850
+
851
+ const impact = envelope.reports.impact;
852
+ if (impact !== undefined) {
853
+ impact.starts.forEach((entry, i) => {
854
+ const p = `/reports/impact/starts/${i}`;
855
+ if (!types.has(entry.start)) fail(`${p}/start`, `${entry.start} is not an artifact in the export.`);
856
+ for (const group of ["direct", "candidates", "context"] as const) {
857
+ entry[group].forEach((hit, j) => {
858
+ const last = hit.path[hit.path.length - 1];
859
+ if (!types.has(hit.id) || last?.id !== hit.id || hit.path.some((step) => !types.has(step.id))) {
860
+ fail(
861
+ `${p}/${group}/${j}`,
862
+ `The ${group} hit ${hit.id} names an artifact the export lacks, or its path does not end at it.`,
863
+ );
864
+ } else if ((group === "candidates") !== hit.path.length > 1) {
865
+ fail(
866
+ `${p}/${group}/${j}`,
867
+ `A ${group} hit has a path of ${hit.path.length} step${hit.path.length === 1 ? "" : "s"}.`,
868
+ );
869
+ }
870
+ });
871
+ }
872
+ });
873
+ }
874
+
875
+ const ownership = envelope.reports.ownership;
876
+ if (ownership !== undefined) {
877
+ ownership.files.forEach((file, i) => {
878
+ const p = `/reports/ownership/files/${i}`;
879
+ const slice = file.region === "slice" || file.region === "test";
880
+ if (
881
+ (file.region === "slice" && file.owner === null) ||
882
+ (slice && file.owner !== null && types.get(file.owner) !== "slice")
883
+ ) {
884
+ fail(`${p}/owner`, `${file.path} is in a slice region, but its owner is not a slice in the export.`);
885
+ }
886
+ if (!slice && file.region !== "resource" && file.owner !== null) {
887
+ fail(`${p}/owner`, `${file.path} is in the ${file.region} region, which has no owner.`);
888
+ }
889
+ });
890
+ }
891
+ return problems;
892
+ }