@adrkit/cli 0.1.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 @@
1
+ export declare function isMainModule(moduleUrl: string, argvPath: string | undefined): boolean;
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@adrkit/cli",
3
+ "version": "0.1.0",
4
+ "description": "Git-native architecture decision record tooling from adrkit.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://adrkit.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/mbeacom/adrkit.git",
11
+ "directory": "packages/cli"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/mbeacom/adrkit/issues"
15
+ },
16
+ "keywords": [
17
+ "adr",
18
+ "architecture-decision-records",
19
+ "cli",
20
+ "governance"
21
+ ],
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "bin": {
29
+ "adr": "./dist/index.js"
30
+ },
31
+ "exports": {
32
+ ".": {
33
+ "bun": "./src/index.ts",
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js",
36
+ "default": "./dist/index.js"
37
+ }
38
+ },
39
+ "scripts": {
40
+ "adr": "bun ./src/index.ts",
41
+ "build": "rm -rf dist && bun build ./src/index.ts --target=node --outdir=dist --packages=external && tsc --project tsconfig.build.json && bun ../../scripts/normalize-dts-imports.ts dist && cp ../../LICENSE ../../NOTICE dist/ && chmod +x dist/index.js",
42
+ "lint": "bun run typecheck",
43
+ "prepack": "bun run build",
44
+ "typecheck": "tsc --noEmit --customConditions bun --project ../../tsconfig.json"
45
+ },
46
+ "dependencies": {
47
+ "@adrkit/core": "0.1.0",
48
+ "@adrkit/evaluator": "0.1.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/bun": "latest"
52
+ },
53
+ "files": [
54
+ "dist",
55
+ "README.md",
56
+ "src"
57
+ ]
58
+ }
@@ -0,0 +1,607 @@
1
+ /**
2
+ * @adrkit/cli — strict `adrkit.pass0.snapshot/v1` bundle validation + normalization.
3
+ *
4
+ * Snapshot JSON is DATA ONLY. It can never select, import, or execute a registry,
5
+ * module, command, or port (data-model §2.1). This module parses the bundle with a
6
+ * duplicate-key-aware JSON reader, validates the strict v1 shape (rejecting unknown
7
+ * keys, wrong types, noncanonical assertion/target keys, and duplicate identities),
8
+ * and normalizes omitted optional backing to empty/unavailable runtime containers so
9
+ * the affected rule reports inert. Malformed present data is an exit-2 error; the CLI
10
+ * never invents defaults for a broken bundle.
11
+ */
12
+
13
+ import {
14
+ canonicalTargetKey,
15
+ isCanonicalAssertionKey,
16
+ makeTargetId,
17
+ validateRegoWasmPolicyEnvelopeV1,
18
+ type AffectsType,
19
+ type AssertionInputSnapshot,
20
+ type FederatedLogSnapshot,
21
+ type IdentityDirectorySnapshot,
22
+ type JsonValue,
23
+ type RegoWasmPolicyEnvelopeV1,
24
+ type ResolvedAssertionInput,
25
+ type ResolvedAssertionSource,
26
+ type RoutingTriggerEvidence,
27
+ type ScopeContradictionEvidence,
28
+ type TargetInventorySnapshots,
29
+ } from '@adrkit/evaluator';
30
+
31
+ export class SnapshotContractError extends Error {
32
+ constructor(message: string) {
33
+ super(message);
34
+ this.name = 'SnapshotContractError';
35
+ }
36
+ }
37
+
38
+ /** Normalized, immutable snapshot pieces ready to assemble a Pass0Input. */
39
+ export interface NormalizedSnapshot {
40
+ readonly federatedLogs?: readonly FederatedLogSnapshot[];
41
+ readonly resolutionLog?: string;
42
+ readonly targets: TargetInventorySnapshots;
43
+ readonly assertionInputs: AssertionInputSnapshot;
44
+ readonly identity?: IdentityDirectorySnapshot;
45
+ readonly scopeEvidence?: ScopeContradictionEvidence;
46
+ readonly routingEvidence?: RoutingTriggerEvidence;
47
+ }
48
+
49
+ /* ------------------------------------------------------------------ *
50
+ * Duplicate-key-aware JSON reader
51
+ * ------------------------------------------------------------------ */
52
+
53
+ class StrictJsonReader {
54
+ private static readonly MAX_DEPTH = 128;
55
+ private i = 0;
56
+ constructor(private readonly s: string) {}
57
+
58
+ read(): JsonValue {
59
+ this.ws();
60
+ const value = this.value(0);
61
+ this.ws();
62
+ if (this.i !== this.s.length) this.fail('unexpected trailing content');
63
+ return value;
64
+ }
65
+
66
+ private fail(message: string): never {
67
+ throw new SnapshotContractError(`Malformed snapshot JSON: ${message} (offset ${this.i})`);
68
+ }
69
+
70
+ private ws(): void {
71
+ while (this.i < this.s.length) {
72
+ const c = this.s.charCodeAt(this.i);
73
+ if (c === 32 || c === 9 || c === 10 || c === 13) this.i += 1;
74
+ else break;
75
+ }
76
+ }
77
+
78
+ private value(depth: number): JsonValue {
79
+ if (depth > StrictJsonReader.MAX_DEPTH) {
80
+ this.fail(`nesting exceeds ${StrictJsonReader.MAX_DEPTH}`);
81
+ }
82
+ const c = this.s[this.i];
83
+ if (c === '{') return this.object(depth);
84
+ if (c === '[') return this.array(depth);
85
+ if (c === '"') return this.string();
86
+ if (c === '-' || (c !== undefined && c >= '0' && c <= '9')) return this.number();
87
+ if (this.s.startsWith('true', this.i)) return (this.i += 4), true;
88
+ if (this.s.startsWith('false', this.i)) return (this.i += 5), false;
89
+ if (this.s.startsWith('null', this.i)) return (this.i += 4), null;
90
+ this.fail('unexpected token');
91
+ }
92
+
93
+ private object(depth: number): JsonValue {
94
+ this.i += 1;
95
+ // Null-prototype dictionary so a smuggled `__proto__`/`constructor` key becomes a
96
+ // real own property (retained for unknown-key validation, hashing, and sizing)
97
+ // instead of being swallowed by the object prototype (finding #1).
98
+ const obj: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>;
99
+ const seen = new Set<string>();
100
+ this.ws();
101
+ if (this.s[this.i] === '}') return (this.i += 1), obj;
102
+ for (;;) {
103
+ this.ws();
104
+ if (this.s[this.i] !== '"') this.fail('expected string key');
105
+ const key = this.string();
106
+ if (seen.has(key)) throw new SnapshotContractError(`Duplicate key "${key}" in snapshot bundle`);
107
+ seen.add(key);
108
+ this.ws();
109
+ if (this.s[this.i] !== ':') this.fail('expected ":"');
110
+ this.i += 1;
111
+ this.ws();
112
+ obj[key] = this.value(depth + 1);
113
+ this.ws();
114
+ const ch = this.s[this.i];
115
+ if (ch === ',') {
116
+ this.i += 1;
117
+ continue;
118
+ }
119
+ if (ch === '}') {
120
+ this.i += 1;
121
+ break;
122
+ }
123
+ this.fail('expected "," or "}"');
124
+ }
125
+ return obj;
126
+ }
127
+
128
+ private array(depth: number): JsonValue {
129
+ this.i += 1;
130
+ const arr: JsonValue[] = [];
131
+ this.ws();
132
+ if (this.s[this.i] === ']') return (this.i += 1), arr;
133
+ for (;;) {
134
+ this.ws();
135
+ arr.push(this.value(depth + 1));
136
+ this.ws();
137
+ const ch = this.s[this.i];
138
+ if (ch === ',') {
139
+ this.i += 1;
140
+ continue;
141
+ }
142
+ if (ch === ']') {
143
+ this.i += 1;
144
+ break;
145
+ }
146
+ this.fail('expected "," or "]"');
147
+ }
148
+ return arr;
149
+ }
150
+
151
+ private string(): string {
152
+ const start = this.i;
153
+ this.i += 1;
154
+ while (this.i < this.s.length) {
155
+ const ch = this.s[this.i];
156
+ if (ch === '\\') {
157
+ this.i += 2;
158
+ continue;
159
+ }
160
+ if (ch === '"') {
161
+ this.i += 1;
162
+ break;
163
+ }
164
+ this.i += 1;
165
+ }
166
+ const raw = this.s.slice(start, this.i);
167
+ try {
168
+ const parsed: unknown = JSON.parse(raw);
169
+ if (typeof parsed !== 'string') this.fail('invalid string literal');
170
+ return parsed;
171
+ } catch {
172
+ this.fail('invalid string literal');
173
+ }
174
+ }
175
+
176
+ private number(): number {
177
+ // Enforce the exact RFC 8259 number grammar before Number() conversion:
178
+ // number = [ "-" ] int [ frac ] [ exp ]
179
+ // int = "0" / ( digit1-9 *DIGIT ) ; no leading zeros, no "+"
180
+ // frac = "." 1*DIGIT
181
+ // exp = ("e"/"E") ["+"/"-"] 1*DIGIT
182
+ const start = this.i;
183
+ const isDigit = (c: string | undefined): boolean => c !== undefined && c >= '0' && c <= '9';
184
+
185
+ if (this.s[this.i] === '-') this.i += 1; // optional minus (never plus)
186
+
187
+ if (this.s[this.i] === '0') {
188
+ this.i += 1; // a leading zero must stand alone (no "01")
189
+ } else if (this.s[this.i] !== undefined && this.s[this.i]! >= '1' && this.s[this.i]! <= '9') {
190
+ this.i += 1;
191
+ while (isDigit(this.s[this.i])) this.i += 1;
192
+ } else {
193
+ this.fail('invalid number: expected an integer part');
194
+ }
195
+
196
+ if (this.s[this.i] === '.') {
197
+ this.i += 1;
198
+ if (!isDigit(this.s[this.i])) this.fail('invalid number: expected a digit after the decimal point');
199
+ while (isDigit(this.s[this.i])) this.i += 1;
200
+ }
201
+
202
+ if (this.s[this.i] === 'e' || this.s[this.i] === 'E') {
203
+ this.i += 1;
204
+ if (this.s[this.i] === '+' || this.s[this.i] === '-') this.i += 1;
205
+ if (!isDigit(this.s[this.i])) this.fail('invalid number: expected a digit in the exponent');
206
+ while (isDigit(this.s[this.i])) this.i += 1;
207
+ }
208
+
209
+ const raw = this.s.slice(start, this.i);
210
+ const n = Number(raw);
211
+ if (raw.length === 0 || !Number.isFinite(n)) this.fail('invalid number');
212
+ return n;
213
+ }
214
+ }
215
+
216
+ export function parseSnapshotJson(text: string): JsonValue {
217
+ return new StrictJsonReader(text).read();
218
+ }
219
+
220
+ /* ------------------------------------------------------------------ *
221
+ * Shape validation helpers
222
+ * ------------------------------------------------------------------ */
223
+
224
+ const AFFECTS_TYPES = ['path', 'entity', 'package', 'resource', 'api', 'data'] as const;
225
+
226
+ function parseAffectsType(value: string): AffectsType | undefined {
227
+ return AFFECTS_TYPES.find((type) => type === value);
228
+ }
229
+
230
+ function isPlainObject(value: JsonValue): value is { readonly [key: string]: JsonValue } {
231
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
232
+ }
233
+
234
+ function requireObject(value: JsonValue, where: string): { readonly [key: string]: JsonValue } {
235
+ if (!isPlainObject(value)) throw new SnapshotContractError(`${where} must be an object`);
236
+ return value;
237
+ }
238
+
239
+ function requireString(value: JsonValue, where: string): string {
240
+ if (typeof value !== 'string') throw new SnapshotContractError(`${where} must be a string`);
241
+ return value;
242
+ }
243
+
244
+ function requireBoolean(value: JsonValue, where: string): boolean {
245
+ if (typeof value !== 'boolean') throw new SnapshotContractError(`${where} must be a boolean`);
246
+ return value;
247
+ }
248
+
249
+ function requireNumber(value: JsonValue, where: string): number {
250
+ if (typeof value !== 'number') throw new SnapshotContractError(`${where} must be a number`);
251
+ return value;
252
+ }
253
+
254
+ function requireStringArray(value: JsonValue, where: string): readonly string[] {
255
+ if (!Array.isArray(value)) throw new SnapshotContractError(`${where} must be an array`);
256
+ return value.map((item, idx) => requireString(item, `${where}[${idx}]`));
257
+ }
258
+
259
+ function rejectUnknownKeys(obj: { readonly [key: string]: JsonValue }, allowed: readonly string[], where: string): void {
260
+ const allowedSet = new Set(allowed);
261
+ for (const key of Object.keys(obj)) {
262
+ if (!allowedSet.has(key)) throw new SnapshotContractError(`Unknown key "${key}" in ${where}`);
263
+ }
264
+ }
265
+
266
+ function isCanonicalTargetKey(key: string): boolean {
267
+ const idx = key.indexOf(':');
268
+ if (idx <= 0) return false;
269
+ const kind = parseAffectsType(key.slice(0, idx));
270
+ if (!kind) return false;
271
+ const id = key.slice(idx + 1);
272
+ if (id.length === 0) return false;
273
+ // Require full canonical normalization + byte equality using the evaluator's own
274
+ // helpers, so a noncanonical spelling (`path:./src/a.ts`, `path:src\a.ts`) that would
275
+ // silently MISS a resolved target is rejected rather than accepted (finding #3).
276
+ return canonicalTargetKey(makeTargetId(kind, id)) === key;
277
+ }
278
+
279
+ function requireCanonicalTargetKeys(value: JsonValue, where: string): readonly string[] {
280
+ const keys = requireStringArray(value, where);
281
+ for (const key of keys) {
282
+ if (!isCanonicalTargetKey(key)) throw new SnapshotContractError(`"${key}" in ${where} is not a canonical target key`);
283
+ }
284
+ return keys;
285
+ }
286
+
287
+ function requireCanonicalAssertionKeys(obj: { readonly [key: string]: JsonValue }, where: string): void {
288
+ for (const key of Object.keys(obj)) {
289
+ if (!isCanonicalAssertionKey(key)) {
290
+ throw new SnapshotContractError(`Assertion key "${key}" in ${where} is not canonical`);
291
+ }
292
+ }
293
+ }
294
+
295
+ /* ------------------------------------------------------------------ *
296
+ * Section validators
297
+ * ------------------------------------------------------------------ */
298
+
299
+ function validateFederatedLogs(value: JsonValue): readonly FederatedLogSnapshot[] {
300
+ if (!Array.isArray(value)) throw new SnapshotContractError('federatedLogs must be an array');
301
+ return value.map((item, idx) => {
302
+ const obj = requireObject(item, `federatedLogs[${idx}]`);
303
+ rejectUnknownKeys(obj, ['log', 'adrIds', 'sourceRef'], `federatedLogs[${idx}]`);
304
+ const log = requireString(obj.log ?? null, `federatedLogs[${idx}].log`);
305
+ const adrIds = requireStringArray(obj.adrIds ?? null, `federatedLogs[${idx}].adrIds`);
306
+ return {
307
+ log,
308
+ adrIds,
309
+ ...(obj.sourceRef !== undefined ? { sourceRef: requireString(obj.sourceRef, `federatedLogs[${idx}].sourceRef`) } : {}),
310
+ };
311
+ });
312
+ }
313
+
314
+ function validateTargets(value: JsonValue): TargetInventorySnapshots {
315
+ const obj = requireObject(value, 'targets');
316
+ rejectUnknownKeys(obj, ['trackedPaths', 'dependencies', 'entities', 'resources', 'apis', 'data'], 'targets');
317
+ const targets: {
318
+ trackedPaths?: readonly string[];
319
+ dependencies?: readonly { name: string; version?: string }[];
320
+ entities?: readonly { id: string; owner?: string }[];
321
+ resources?: readonly { id: string; securitySurface?: boolean; production?: boolean; regulated?: boolean }[];
322
+ apis?: readonly { id: string; securitySurface?: boolean; production?: boolean }[];
323
+ data?: readonly { id: string; residency?: string; regulated?: boolean }[];
324
+ } = {};
325
+
326
+ if (obj.trackedPaths !== undefined) targets.trackedPaths = requireStringArray(obj.trackedPaths, 'targets.trackedPaths');
327
+ if (obj.dependencies !== undefined) {
328
+ if (!Array.isArray(obj.dependencies)) throw new SnapshotContractError('targets.dependencies must be an array');
329
+ targets.dependencies = obj.dependencies.map((item, idx) => {
330
+ const dep = requireObject(item, `targets.dependencies[${idx}]`);
331
+ rejectUnknownKeys(dep, ['name', 'version'], `targets.dependencies[${idx}]`);
332
+ return {
333
+ name: requireString(dep.name ?? null, `targets.dependencies[${idx}].name`),
334
+ ...(dep.version !== undefined ? { version: requireString(dep.version, `targets.dependencies[${idx}].version`) } : {}),
335
+ };
336
+ });
337
+ }
338
+ if (obj.entities !== undefined) {
339
+ if (!Array.isArray(obj.entities)) throw new SnapshotContractError('targets.entities must be an array');
340
+ targets.entities = obj.entities.map((item, idx) => {
341
+ const ent = requireObject(item, `targets.entities[${idx}]`);
342
+ rejectUnknownKeys(ent, ['id', 'owner'], `targets.entities[${idx}]`);
343
+ return {
344
+ id: requireString(ent.id ?? null, `targets.entities[${idx}].id`),
345
+ ...(ent.owner !== undefined ? { owner: requireString(ent.owner, `targets.entities[${idx}].owner`) } : {}),
346
+ };
347
+ });
348
+ }
349
+ if (obj.resources !== undefined) {
350
+ if (!Array.isArray(obj.resources)) throw new SnapshotContractError('targets.resources must be an array');
351
+ targets.resources = obj.resources.map((item, idx) => {
352
+ const r = requireObject(item, `targets.resources[${idx}]`);
353
+ rejectUnknownKeys(r, ['id', 'securitySurface', 'production', 'regulated'], `targets.resources[${idx}]`);
354
+ return {
355
+ id: requireString(r.id ?? null, `targets.resources[${idx}].id`),
356
+ ...(r.securitySurface !== undefined ? { securitySurface: requireBoolean(r.securitySurface, `targets.resources[${idx}].securitySurface`) } : {}),
357
+ ...(r.production !== undefined ? { production: requireBoolean(r.production, `targets.resources[${idx}].production`) } : {}),
358
+ ...(r.regulated !== undefined ? { regulated: requireBoolean(r.regulated, `targets.resources[${idx}].regulated`) } : {}),
359
+ };
360
+ });
361
+ }
362
+ if (obj.apis !== undefined) {
363
+ if (!Array.isArray(obj.apis)) throw new SnapshotContractError('targets.apis must be an array');
364
+ targets.apis = obj.apis.map((item, idx) => {
365
+ const a = requireObject(item, `targets.apis[${idx}]`);
366
+ rejectUnknownKeys(a, ['id', 'securitySurface', 'production'], `targets.apis[${idx}]`);
367
+ return {
368
+ id: requireString(a.id ?? null, `targets.apis[${idx}].id`),
369
+ ...(a.securitySurface !== undefined ? { securitySurface: requireBoolean(a.securitySurface, `targets.apis[${idx}].securitySurface`) } : {}),
370
+ ...(a.production !== undefined ? { production: requireBoolean(a.production, `targets.apis[${idx}].production`) } : {}),
371
+ };
372
+ });
373
+ }
374
+ if (obj.data !== undefined) {
375
+ if (!Array.isArray(obj.data)) throw new SnapshotContractError('targets.data must be an array');
376
+ targets.data = obj.data.map((item, idx) => {
377
+ const d = requireObject(item, `targets.data[${idx}]`);
378
+ rejectUnknownKeys(d, ['id', 'residency', 'regulated'], `targets.data[${idx}]`);
379
+ return {
380
+ id: requireString(d.id ?? null, `targets.data[${idx}].id`),
381
+ ...(d.residency !== undefined ? { residency: requireString(d.residency, `targets.data[${idx}].residency`) } : {}),
382
+ ...(d.regulated !== undefined ? { regulated: requireBoolean(d.regulated, `targets.data[${idx}].regulated`) } : {}),
383
+ };
384
+ });
385
+ }
386
+ return targets;
387
+ }
388
+
389
+ function validateAssertionSource(value: JsonValue, where: string): ResolvedAssertionSource {
390
+ const obj = requireObject(value, where);
391
+ rejectUnknownKeys(obj, ['fileContent', 'sourceRef', 'compiledArtifact'], where);
392
+ const source: { fileContent?: string; sourceRef?: string; compiledArtifact?: RegoWasmPolicyEnvelopeV1 } = {};
393
+ if (obj.fileContent !== undefined) source.fileContent = requireString(obj.fileContent, `${where}.fileContent`);
394
+ if (obj.sourceRef !== undefined) source.sourceRef = requireString(obj.sourceRef, `${where}.sourceRef`);
395
+ if (obj.compiledArtifact !== undefined) {
396
+ // The envelope is data only. Validate it deeply here so a malformed caller artifact
397
+ // is an exit-2 bundle error, not a silent inert. A registered engine re-validates
398
+ // before it evaluates; adrkit never executes the module.
399
+ const validation = validateRegoWasmPolicyEnvelopeV1(obj.compiledArtifact);
400
+ if (!validation.ok) throw new SnapshotContractError(`${where}.compiledArtifact: ${validation.message}`);
401
+ source.compiledArtifact = validation.envelope;
402
+ }
403
+ return source;
404
+ }
405
+
406
+ function validateAssertionInputs(value: JsonValue): AssertionInputSnapshot {
407
+ const obj = requireObject(value, 'assertionInputs');
408
+ rejectUnknownKeys(obj, ['sources', 'inputs'], 'assertionInputs');
409
+ const sources: Record<string, ResolvedAssertionSource> = {};
410
+ const inputs: Record<string, ResolvedAssertionInput> = {};
411
+ if (obj.sources !== undefined) {
412
+ const src = requireObject(obj.sources, 'assertionInputs.sources');
413
+ requireCanonicalAssertionKeys(src, 'assertionInputs.sources');
414
+ for (const [key, val] of Object.entries(src)) {
415
+ sources[key] = validateAssertionSource(val, `assertionInputs.sources["${key}"]`);
416
+ }
417
+ }
418
+ if (obj.inputs !== undefined) {
419
+ const inp = requireObject(obj.inputs, 'assertionInputs.inputs');
420
+ requireCanonicalAssertionKeys(inp, 'assertionInputs.inputs');
421
+ for (const [key, val] of Object.entries(inp)) {
422
+ const entry = requireObject(val, `assertionInputs.inputs["${key}"]`);
423
+ rejectUnknownKeys(entry, ['document'], `assertionInputs.inputs["${key}"]`);
424
+ if (entry.document === undefined) throw new SnapshotContractError(`assertionInputs.inputs["${key}"].document is required`);
425
+ inputs[key] = { document: entry.document };
426
+ }
427
+ }
428
+ return { sources, inputs };
429
+ }
430
+
431
+ function validateIdentity(value: JsonValue): IdentityDirectorySnapshot {
432
+ const obj = requireObject(value, 'identity');
433
+ rejectUnknownKeys(obj, ['principals', 'teams', 'codeowners', 'catalogOwners'], 'identity');
434
+ if (!Array.isArray(obj.principals)) throw new SnapshotContractError('identity.principals must be an array');
435
+ const seenPrincipals = new Set<string>();
436
+ const principals = obj.principals.map((item, idx) => {
437
+ const p = requireObject(item, `identity.principals[${idx}]`);
438
+ rejectUnknownKeys(p, ['id', 'active', 'kind'], `identity.principals[${idx}]`);
439
+ const id = requireString(p.id ?? null, `identity.principals[${idx}].id`);
440
+ if (seenPrincipals.has(id)) throw new SnapshotContractError(`Duplicate principal id "${id}"`);
441
+ seenPrincipals.add(id);
442
+ const kind = requireString(p.kind ?? null, `identity.principals[${idx}].kind`);
443
+ if (kind !== 'human' && kind !== 'team') throw new SnapshotContractError(`identity.principals[${idx}].kind must be "human" or "team"`);
444
+ return { id, active: requireBoolean(p.active ?? null, `identity.principals[${idx}].active`), kind: kind as 'human' | 'team' };
445
+ });
446
+ const teams = Array.isArray(obj.teams)
447
+ ? obj.teams.map((item, idx) => {
448
+ const t = requireObject(item, `identity.teams[${idx}]`);
449
+ rejectUnknownKeys(t, ['id', 'members'], `identity.teams[${idx}]`);
450
+ return {
451
+ id: requireString(t.id ?? null, `identity.teams[${idx}].id`),
452
+ members: requireStringArray(t.members ?? null, `identity.teams[${idx}].members`),
453
+ };
454
+ })
455
+ : obj.teams === undefined
456
+ ? []
457
+ : (() => {
458
+ throw new SnapshotContractError('identity.teams must be an array');
459
+ })();
460
+
461
+ const identity: {
462
+ principals: readonly { id: string; active: boolean; kind: 'human' | 'team' }[];
463
+ teams: readonly { id: string; members: readonly string[] }[];
464
+ codeowners?: readonly { pattern: string; owners: readonly string[] }[];
465
+ catalogOwners?: Readonly<Record<string, readonly string[]>>;
466
+ } = { principals, teams };
467
+
468
+ // Team/principal consistency (finding #4): duplicate team ids let the last write win in
469
+ // the index Map and could bypass the ambiguity barrier; a team id must be a principal
470
+ // of kind "team", and a "team" principal must have a membership entry.
471
+ const principalKindById = new Map(principals.map((p) => [p.id, p.kind]));
472
+ const teamIds = new Set<string>();
473
+ for (const team of teams) {
474
+ if (teamIds.has(team.id)) throw new SnapshotContractError(`Duplicate team id "${team.id}"`);
475
+ teamIds.add(team.id);
476
+ const kind = principalKindById.get(team.id);
477
+ if (kind === undefined) throw new SnapshotContractError(`Team "${team.id}" has no matching principal`);
478
+ if (kind !== 'team') throw new SnapshotContractError(`Team "${team.id}" collides with a non-team principal`);
479
+ }
480
+ for (const p of principals) {
481
+ if (p.kind === 'team' && !teamIds.has(p.id)) {
482
+ throw new SnapshotContractError(`Team principal "${p.id}" has no membership entry`);
483
+ }
484
+ }
485
+
486
+ if (obj.codeowners !== undefined) {
487
+ if (!Array.isArray(obj.codeowners)) throw new SnapshotContractError('identity.codeowners must be an array');
488
+ identity.codeowners = obj.codeowners.map((item, idx) => {
489
+ const rule = requireObject(item, `identity.codeowners[${idx}]`);
490
+ rejectUnknownKeys(rule, ['pattern', 'owners'], `identity.codeowners[${idx}]`);
491
+ return {
492
+ pattern: requireString(rule.pattern ?? null, `identity.codeowners[${idx}].pattern`),
493
+ owners: requireStringArray(rule.owners ?? null, `identity.codeowners[${idx}].owners`),
494
+ };
495
+ });
496
+ }
497
+ if (obj.catalogOwners !== undefined) {
498
+ const co = requireObject(obj.catalogOwners, 'identity.catalogOwners');
499
+ // Null-prototype map: entity ids are arbitrary, so a literal `__proto__` key must be
500
+ // retained as data rather than mutating a prototype (finding #1).
501
+ const map: Record<string, readonly string[]> = Object.create(null) as Record<string, readonly string[]>;
502
+ for (const [key, owners] of Object.entries(co)) {
503
+ map[key] = requireStringArray(owners, `identity.catalogOwners["${key}"]`);
504
+ }
505
+ identity.catalogOwners = map;
506
+ }
507
+ return identity;
508
+ }
509
+
510
+ function validateScopeEvidence(value: JsonValue): ScopeContradictionEvidence {
511
+ const obj = requireObject(value, 'scopeEvidence');
512
+ rejectUnknownKeys(obj, ['baseInputs'], 'scopeEvidence');
513
+ if (obj.baseInputs === undefined) return {};
514
+ const base = requireObject(obj.baseInputs, 'scopeEvidence.baseInputs');
515
+ requireCanonicalAssertionKeys(base, 'scopeEvidence.baseInputs');
516
+ const baseInputs: Record<string, ResolvedAssertionInput> = {};
517
+ for (const [key, val] of Object.entries(base)) {
518
+ const entry = requireObject(val, `scopeEvidence.baseInputs["${key}"]`);
519
+ rejectUnknownKeys(entry, ['document'], `scopeEvidence.baseInputs["${key}"]`);
520
+ if (entry.document === undefined) throw new SnapshotContractError(`scopeEvidence.baseInputs["${key}"].document is required`);
521
+ baseInputs[key] = { document: entry.document };
522
+ }
523
+ return { baseInputs };
524
+ }
525
+
526
+ function validateRoutingEvidence(value: JsonValue): RoutingTriggerEvidence {
527
+ const obj = requireObject(value, 'routingEvidence');
528
+ rejectUnknownKeys(
529
+ obj,
530
+ ['costEvidence', 'dataResidency', 'humanRequested', 'securitySurfaceTargetKeys', 'regulatedTargetKeys', 'productionTargetKeys'],
531
+ 'routingEvidence',
532
+ );
533
+ const evidence: {
534
+ costEvidence?: { normalizedCost: number; threshold: number };
535
+ dataResidency?: { present: boolean };
536
+ humanRequested?: { requester: string };
537
+ securitySurfaceTargets?: ReadonlySet<string>;
538
+ regulatedTargets?: ReadonlySet<string>;
539
+ productionTargets?: ReadonlySet<string>;
540
+ } = {};
541
+ if (obj.costEvidence !== undefined) {
542
+ const c = requireObject(obj.costEvidence, 'routingEvidence.costEvidence');
543
+ rejectUnknownKeys(c, ['normalizedCost', 'threshold'], 'routingEvidence.costEvidence');
544
+ evidence.costEvidence = {
545
+ normalizedCost: requireNumber(c.normalizedCost ?? null, 'routingEvidence.costEvidence.normalizedCost'),
546
+ threshold: requireNumber(c.threshold ?? null, 'routingEvidence.costEvidence.threshold'),
547
+ };
548
+ }
549
+ if (obj.dataResidency !== undefined) {
550
+ const d = requireObject(obj.dataResidency, 'routingEvidence.dataResidency');
551
+ rejectUnknownKeys(d, ['present'], 'routingEvidence.dataResidency');
552
+ evidence.dataResidency = { present: requireBoolean(d.present ?? null, 'routingEvidence.dataResidency.present') };
553
+ }
554
+ if (obj.humanRequested !== undefined) {
555
+ const h = requireObject(obj.humanRequested, 'routingEvidence.humanRequested');
556
+ rejectUnknownKeys(h, ['requester'], 'routingEvidence.humanRequested');
557
+ evidence.humanRequested = { requester: requireString(h.requester ?? null, 'routingEvidence.humanRequested.requester') };
558
+ }
559
+ if (obj.securitySurfaceTargetKeys !== undefined) {
560
+ evidence.securitySurfaceTargets = new Set(requireCanonicalTargetKeys(obj.securitySurfaceTargetKeys, 'routingEvidence.securitySurfaceTargetKeys'));
561
+ }
562
+ if (obj.regulatedTargetKeys !== undefined) {
563
+ evidence.regulatedTargets = new Set(requireCanonicalTargetKeys(obj.regulatedTargetKeys, 'routingEvidence.regulatedTargetKeys'));
564
+ }
565
+ if (obj.productionTargetKeys !== undefined) {
566
+ evidence.productionTargets = new Set(requireCanonicalTargetKeys(obj.productionTargetKeys, 'routingEvidence.productionTargetKeys'));
567
+ }
568
+ return evidence;
569
+ }
570
+
571
+ /**
572
+ * Parse + strictly validate + normalize a snapshot bundle. Throws SnapshotContractError
573
+ * (⇒ CLI exit 2) on any malformed present data.
574
+ */
575
+ export function loadSnapshotBundle(text: string): NormalizedSnapshot {
576
+ const raw = parseSnapshotJson(text);
577
+ const bundle = requireObject(raw, 'snapshot bundle');
578
+ rejectUnknownKeys(
579
+ bundle,
580
+ ['schemaVersion', 'federatedLogs', 'log', 'targets', 'assertionInputs', 'identity', 'scopeEvidence', 'routingEvidence'],
581
+ 'snapshot bundle',
582
+ );
583
+ if (bundle.schemaVersion !== 'adrkit.pass0.snapshot/v1') {
584
+ throw new SnapshotContractError('snapshot schemaVersion must be exactly "adrkit.pass0.snapshot/v1"');
585
+ }
586
+
587
+ const normalized: {
588
+ federatedLogs?: readonly FederatedLogSnapshot[];
589
+ resolutionLog?: string;
590
+ targets: TargetInventorySnapshots;
591
+ assertionInputs: AssertionInputSnapshot;
592
+ identity?: IdentityDirectorySnapshot;
593
+ scopeEvidence?: ScopeContradictionEvidence;
594
+ routingEvidence?: RoutingTriggerEvidence;
595
+ } = {
596
+ targets: bundle.targets !== undefined ? validateTargets(bundle.targets) : {},
597
+ assertionInputs: bundle.assertionInputs !== undefined ? validateAssertionInputs(bundle.assertionInputs) : { sources: {}, inputs: {} },
598
+ };
599
+
600
+ if (bundle.log !== undefined) normalized.resolutionLog = requireString(bundle.log, 'log');
601
+ if (bundle.federatedLogs !== undefined) normalized.federatedLogs = validateFederatedLogs(bundle.federatedLogs);
602
+ if (bundle.identity !== undefined) normalized.identity = validateIdentity(bundle.identity);
603
+ if (bundle.scopeEvidence !== undefined) normalized.scopeEvidence = validateScopeEvidence(bundle.scopeEvidence);
604
+ if (bundle.routingEvidence !== undefined) normalized.routingEvidence = validateRoutingEvidence(bundle.routingEvidence);
605
+
606
+ return normalized;
607
+ }