@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.
- package/README.md +23 -0
- package/dist/LICENSE +201 -0
- package/dist/NOTICE +11 -0
- package/dist/evaluate-snapshot.d.ts +31 -0
- package/dist/evaluate.d.ts +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1037 -0
- package/dist/main-module.d.ts +1 -0
- package/package.json +58 -0
- package/src/evaluate-snapshot.ts +607 -0
- package/src/evaluate.ts +157 -0
- package/src/index.ts +409 -0
- package/src/main-module.ts +7 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import {
|
|
6
|
+
buildAdrGraph,
|
|
7
|
+
checkChanges,
|
|
8
|
+
countFindings,
|
|
9
|
+
createAdr,
|
|
10
|
+
exitCodeForFindings,
|
|
11
|
+
lintCorpus as lintCorpus2,
|
|
12
|
+
migrateMadr,
|
|
13
|
+
resolveAffects,
|
|
14
|
+
renderDotGraph,
|
|
15
|
+
renderJsonGraph,
|
|
16
|
+
ScaffoldError,
|
|
17
|
+
sortFindings
|
|
18
|
+
} from "@adrkit/core";
|
|
19
|
+
|
|
20
|
+
// src/evaluate.ts
|
|
21
|
+
import { readFile } from "node:fs/promises";
|
|
22
|
+
import { dirname } from "node:path";
|
|
23
|
+
import { lintCorpus, normalizeDisplayPath } from "@adrkit/core";
|
|
24
|
+
import {
|
|
25
|
+
canonicalBytes,
|
|
26
|
+
createAssertionEngineRegistry,
|
|
27
|
+
createJsonPathEngine,
|
|
28
|
+
createPackageTargetResolver,
|
|
29
|
+
createPathTargetResolver,
|
|
30
|
+
createTargetResolutionRegistry,
|
|
31
|
+
evaluatePass0
|
|
32
|
+
} from "@adrkit/evaluator";
|
|
33
|
+
|
|
34
|
+
// src/evaluate-snapshot.ts
|
|
35
|
+
import {
|
|
36
|
+
canonicalTargetKey,
|
|
37
|
+
isCanonicalAssertionKey,
|
|
38
|
+
makeTargetId,
|
|
39
|
+
validateRegoWasmPolicyEnvelopeV1
|
|
40
|
+
} from "@adrkit/evaluator";
|
|
41
|
+
|
|
42
|
+
class SnapshotContractError extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "SnapshotContractError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class StrictJsonReader {
|
|
50
|
+
s;
|
|
51
|
+
static MAX_DEPTH = 128;
|
|
52
|
+
i = 0;
|
|
53
|
+
constructor(s) {
|
|
54
|
+
this.s = s;
|
|
55
|
+
}
|
|
56
|
+
read() {
|
|
57
|
+
this.ws();
|
|
58
|
+
const value = this.value(0);
|
|
59
|
+
this.ws();
|
|
60
|
+
if (this.i !== this.s.length)
|
|
61
|
+
this.fail("unexpected trailing content");
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
fail(message) {
|
|
65
|
+
throw new SnapshotContractError(`Malformed snapshot JSON: ${message} (offset ${this.i})`);
|
|
66
|
+
}
|
|
67
|
+
ws() {
|
|
68
|
+
while (this.i < this.s.length) {
|
|
69
|
+
const c = this.s.charCodeAt(this.i);
|
|
70
|
+
if (c === 32 || c === 9 || c === 10 || c === 13)
|
|
71
|
+
this.i += 1;
|
|
72
|
+
else
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
value(depth) {
|
|
77
|
+
if (depth > StrictJsonReader.MAX_DEPTH) {
|
|
78
|
+
this.fail(`nesting exceeds ${StrictJsonReader.MAX_DEPTH}`);
|
|
79
|
+
}
|
|
80
|
+
const c = this.s[this.i];
|
|
81
|
+
if (c === "{")
|
|
82
|
+
return this.object(depth);
|
|
83
|
+
if (c === "[")
|
|
84
|
+
return this.array(depth);
|
|
85
|
+
if (c === '"')
|
|
86
|
+
return this.string();
|
|
87
|
+
if (c === "-" || c !== undefined && c >= "0" && c <= "9")
|
|
88
|
+
return this.number();
|
|
89
|
+
if (this.s.startsWith("true", this.i))
|
|
90
|
+
return this.i += 4, true;
|
|
91
|
+
if (this.s.startsWith("false", this.i))
|
|
92
|
+
return this.i += 5, false;
|
|
93
|
+
if (this.s.startsWith("null", this.i))
|
|
94
|
+
return this.i += 4, null;
|
|
95
|
+
this.fail("unexpected token");
|
|
96
|
+
}
|
|
97
|
+
object(depth) {
|
|
98
|
+
this.i += 1;
|
|
99
|
+
const obj = Object.create(null);
|
|
100
|
+
const seen = new Set;
|
|
101
|
+
this.ws();
|
|
102
|
+
if (this.s[this.i] === "}")
|
|
103
|
+
return this.i += 1, obj;
|
|
104
|
+
for (;; ) {
|
|
105
|
+
this.ws();
|
|
106
|
+
if (this.s[this.i] !== '"')
|
|
107
|
+
this.fail("expected string key");
|
|
108
|
+
const key = this.string();
|
|
109
|
+
if (seen.has(key))
|
|
110
|
+
throw new SnapshotContractError(`Duplicate key "${key}" in snapshot bundle`);
|
|
111
|
+
seen.add(key);
|
|
112
|
+
this.ws();
|
|
113
|
+
if (this.s[this.i] !== ":")
|
|
114
|
+
this.fail('expected ":"');
|
|
115
|
+
this.i += 1;
|
|
116
|
+
this.ws();
|
|
117
|
+
obj[key] = this.value(depth + 1);
|
|
118
|
+
this.ws();
|
|
119
|
+
const ch = this.s[this.i];
|
|
120
|
+
if (ch === ",") {
|
|
121
|
+
this.i += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (ch === "}") {
|
|
125
|
+
this.i += 1;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
this.fail('expected "," or "}"');
|
|
129
|
+
}
|
|
130
|
+
return obj;
|
|
131
|
+
}
|
|
132
|
+
array(depth) {
|
|
133
|
+
this.i += 1;
|
|
134
|
+
const arr = [];
|
|
135
|
+
this.ws();
|
|
136
|
+
if (this.s[this.i] === "]")
|
|
137
|
+
return this.i += 1, arr;
|
|
138
|
+
for (;; ) {
|
|
139
|
+
this.ws();
|
|
140
|
+
arr.push(this.value(depth + 1));
|
|
141
|
+
this.ws();
|
|
142
|
+
const ch = this.s[this.i];
|
|
143
|
+
if (ch === ",") {
|
|
144
|
+
this.i += 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (ch === "]") {
|
|
148
|
+
this.i += 1;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
this.fail('expected "," or "]"');
|
|
152
|
+
}
|
|
153
|
+
return arr;
|
|
154
|
+
}
|
|
155
|
+
string() {
|
|
156
|
+
const start = this.i;
|
|
157
|
+
this.i += 1;
|
|
158
|
+
while (this.i < this.s.length) {
|
|
159
|
+
const ch = this.s[this.i];
|
|
160
|
+
if (ch === "\\") {
|
|
161
|
+
this.i += 2;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (ch === '"') {
|
|
165
|
+
this.i += 1;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
this.i += 1;
|
|
169
|
+
}
|
|
170
|
+
const raw = this.s.slice(start, this.i);
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(raw);
|
|
173
|
+
if (typeof parsed !== "string")
|
|
174
|
+
this.fail("invalid string literal");
|
|
175
|
+
return parsed;
|
|
176
|
+
} catch {
|
|
177
|
+
this.fail("invalid string literal");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
number() {
|
|
181
|
+
const start = this.i;
|
|
182
|
+
const isDigit = (c) => c !== undefined && c >= "0" && c <= "9";
|
|
183
|
+
if (this.s[this.i] === "-")
|
|
184
|
+
this.i += 1;
|
|
185
|
+
if (this.s[this.i] === "0") {
|
|
186
|
+
this.i += 1;
|
|
187
|
+
} else if (this.s[this.i] !== undefined && this.s[this.i] >= "1" && this.s[this.i] <= "9") {
|
|
188
|
+
this.i += 1;
|
|
189
|
+
while (isDigit(this.s[this.i]))
|
|
190
|
+
this.i += 1;
|
|
191
|
+
} else {
|
|
192
|
+
this.fail("invalid number: expected an integer part");
|
|
193
|
+
}
|
|
194
|
+
if (this.s[this.i] === ".") {
|
|
195
|
+
this.i += 1;
|
|
196
|
+
if (!isDigit(this.s[this.i]))
|
|
197
|
+
this.fail("invalid number: expected a digit after the decimal point");
|
|
198
|
+
while (isDigit(this.s[this.i]))
|
|
199
|
+
this.i += 1;
|
|
200
|
+
}
|
|
201
|
+
if (this.s[this.i] === "e" || this.s[this.i] === "E") {
|
|
202
|
+
this.i += 1;
|
|
203
|
+
if (this.s[this.i] === "+" || this.s[this.i] === "-")
|
|
204
|
+
this.i += 1;
|
|
205
|
+
if (!isDigit(this.s[this.i]))
|
|
206
|
+
this.fail("invalid number: expected a digit in the exponent");
|
|
207
|
+
while (isDigit(this.s[this.i]))
|
|
208
|
+
this.i += 1;
|
|
209
|
+
}
|
|
210
|
+
const raw = this.s.slice(start, this.i);
|
|
211
|
+
const n = Number(raw);
|
|
212
|
+
if (raw.length === 0 || !Number.isFinite(n))
|
|
213
|
+
this.fail("invalid number");
|
|
214
|
+
return n;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function parseSnapshotJson(text) {
|
|
218
|
+
return new StrictJsonReader(text).read();
|
|
219
|
+
}
|
|
220
|
+
var AFFECTS_TYPES = ["path", "entity", "package", "resource", "api", "data"];
|
|
221
|
+
function parseAffectsType(value) {
|
|
222
|
+
return AFFECTS_TYPES.find((type) => type === value);
|
|
223
|
+
}
|
|
224
|
+
function isPlainObject(value) {
|
|
225
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
226
|
+
}
|
|
227
|
+
function requireObject(value, where) {
|
|
228
|
+
if (!isPlainObject(value))
|
|
229
|
+
throw new SnapshotContractError(`${where} must be an object`);
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
function requireString(value, where) {
|
|
233
|
+
if (typeof value !== "string")
|
|
234
|
+
throw new SnapshotContractError(`${where} must be a string`);
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
function requireBoolean(value, where) {
|
|
238
|
+
if (typeof value !== "boolean")
|
|
239
|
+
throw new SnapshotContractError(`${where} must be a boolean`);
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
function requireNumber(value, where) {
|
|
243
|
+
if (typeof value !== "number")
|
|
244
|
+
throw new SnapshotContractError(`${where} must be a number`);
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
function requireStringArray(value, where) {
|
|
248
|
+
if (!Array.isArray(value))
|
|
249
|
+
throw new SnapshotContractError(`${where} must be an array`);
|
|
250
|
+
return value.map((item, idx) => requireString(item, `${where}[${idx}]`));
|
|
251
|
+
}
|
|
252
|
+
function rejectUnknownKeys(obj, allowed, where) {
|
|
253
|
+
const allowedSet = new Set(allowed);
|
|
254
|
+
for (const key of Object.keys(obj)) {
|
|
255
|
+
if (!allowedSet.has(key))
|
|
256
|
+
throw new SnapshotContractError(`Unknown key "${key}" in ${where}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function isCanonicalTargetKey(key) {
|
|
260
|
+
const idx = key.indexOf(":");
|
|
261
|
+
if (idx <= 0)
|
|
262
|
+
return false;
|
|
263
|
+
const kind = parseAffectsType(key.slice(0, idx));
|
|
264
|
+
if (!kind)
|
|
265
|
+
return false;
|
|
266
|
+
const id = key.slice(idx + 1);
|
|
267
|
+
if (id.length === 0)
|
|
268
|
+
return false;
|
|
269
|
+
return canonicalTargetKey(makeTargetId(kind, id)) === key;
|
|
270
|
+
}
|
|
271
|
+
function requireCanonicalTargetKeys(value, where) {
|
|
272
|
+
const keys = requireStringArray(value, where);
|
|
273
|
+
for (const key of keys) {
|
|
274
|
+
if (!isCanonicalTargetKey(key))
|
|
275
|
+
throw new SnapshotContractError(`"${key}" in ${where} is not a canonical target key`);
|
|
276
|
+
}
|
|
277
|
+
return keys;
|
|
278
|
+
}
|
|
279
|
+
function requireCanonicalAssertionKeys(obj, where) {
|
|
280
|
+
for (const key of Object.keys(obj)) {
|
|
281
|
+
if (!isCanonicalAssertionKey(key)) {
|
|
282
|
+
throw new SnapshotContractError(`Assertion key "${key}" in ${where} is not canonical`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function validateFederatedLogs(value) {
|
|
287
|
+
if (!Array.isArray(value))
|
|
288
|
+
throw new SnapshotContractError("federatedLogs must be an array");
|
|
289
|
+
return value.map((item, idx) => {
|
|
290
|
+
const obj = requireObject(item, `federatedLogs[${idx}]`);
|
|
291
|
+
rejectUnknownKeys(obj, ["log", "adrIds", "sourceRef"], `federatedLogs[${idx}]`);
|
|
292
|
+
const log = requireString(obj.log ?? null, `federatedLogs[${idx}].log`);
|
|
293
|
+
const adrIds = requireStringArray(obj.adrIds ?? null, `federatedLogs[${idx}].adrIds`);
|
|
294
|
+
return {
|
|
295
|
+
log,
|
|
296
|
+
adrIds,
|
|
297
|
+
...obj.sourceRef !== undefined ? { sourceRef: requireString(obj.sourceRef, `federatedLogs[${idx}].sourceRef`) } : {}
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
function validateTargets(value) {
|
|
302
|
+
const obj = requireObject(value, "targets");
|
|
303
|
+
rejectUnknownKeys(obj, ["trackedPaths", "dependencies", "entities", "resources", "apis", "data"], "targets");
|
|
304
|
+
const targets = {};
|
|
305
|
+
if (obj.trackedPaths !== undefined)
|
|
306
|
+
targets.trackedPaths = requireStringArray(obj.trackedPaths, "targets.trackedPaths");
|
|
307
|
+
if (obj.dependencies !== undefined) {
|
|
308
|
+
if (!Array.isArray(obj.dependencies))
|
|
309
|
+
throw new SnapshotContractError("targets.dependencies must be an array");
|
|
310
|
+
targets.dependencies = obj.dependencies.map((item, idx) => {
|
|
311
|
+
const dep = requireObject(item, `targets.dependencies[${idx}]`);
|
|
312
|
+
rejectUnknownKeys(dep, ["name", "version"], `targets.dependencies[${idx}]`);
|
|
313
|
+
return {
|
|
314
|
+
name: requireString(dep.name ?? null, `targets.dependencies[${idx}].name`),
|
|
315
|
+
...dep.version !== undefined ? { version: requireString(dep.version, `targets.dependencies[${idx}].version`) } : {}
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (obj.entities !== undefined) {
|
|
320
|
+
if (!Array.isArray(obj.entities))
|
|
321
|
+
throw new SnapshotContractError("targets.entities must be an array");
|
|
322
|
+
targets.entities = obj.entities.map((item, idx) => {
|
|
323
|
+
const ent = requireObject(item, `targets.entities[${idx}]`);
|
|
324
|
+
rejectUnknownKeys(ent, ["id", "owner"], `targets.entities[${idx}]`);
|
|
325
|
+
return {
|
|
326
|
+
id: requireString(ent.id ?? null, `targets.entities[${idx}].id`),
|
|
327
|
+
...ent.owner !== undefined ? { owner: requireString(ent.owner, `targets.entities[${idx}].owner`) } : {}
|
|
328
|
+
};
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (obj.resources !== undefined) {
|
|
332
|
+
if (!Array.isArray(obj.resources))
|
|
333
|
+
throw new SnapshotContractError("targets.resources must be an array");
|
|
334
|
+
targets.resources = obj.resources.map((item, idx) => {
|
|
335
|
+
const r = requireObject(item, `targets.resources[${idx}]`);
|
|
336
|
+
rejectUnknownKeys(r, ["id", "securitySurface", "production", "regulated"], `targets.resources[${idx}]`);
|
|
337
|
+
return {
|
|
338
|
+
id: requireString(r.id ?? null, `targets.resources[${idx}].id`),
|
|
339
|
+
...r.securitySurface !== undefined ? { securitySurface: requireBoolean(r.securitySurface, `targets.resources[${idx}].securitySurface`) } : {},
|
|
340
|
+
...r.production !== undefined ? { production: requireBoolean(r.production, `targets.resources[${idx}].production`) } : {},
|
|
341
|
+
...r.regulated !== undefined ? { regulated: requireBoolean(r.regulated, `targets.resources[${idx}].regulated`) } : {}
|
|
342
|
+
};
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (obj.apis !== undefined) {
|
|
346
|
+
if (!Array.isArray(obj.apis))
|
|
347
|
+
throw new SnapshotContractError("targets.apis must be an array");
|
|
348
|
+
targets.apis = obj.apis.map((item, idx) => {
|
|
349
|
+
const a = requireObject(item, `targets.apis[${idx}]`);
|
|
350
|
+
rejectUnknownKeys(a, ["id", "securitySurface", "production"], `targets.apis[${idx}]`);
|
|
351
|
+
return {
|
|
352
|
+
id: requireString(a.id ?? null, `targets.apis[${idx}].id`),
|
|
353
|
+
...a.securitySurface !== undefined ? { securitySurface: requireBoolean(a.securitySurface, `targets.apis[${idx}].securitySurface`) } : {},
|
|
354
|
+
...a.production !== undefined ? { production: requireBoolean(a.production, `targets.apis[${idx}].production`) } : {}
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
if (obj.data !== undefined) {
|
|
359
|
+
if (!Array.isArray(obj.data))
|
|
360
|
+
throw new SnapshotContractError("targets.data must be an array");
|
|
361
|
+
targets.data = obj.data.map((item, idx) => {
|
|
362
|
+
const d = requireObject(item, `targets.data[${idx}]`);
|
|
363
|
+
rejectUnknownKeys(d, ["id", "residency", "regulated"], `targets.data[${idx}]`);
|
|
364
|
+
return {
|
|
365
|
+
id: requireString(d.id ?? null, `targets.data[${idx}].id`),
|
|
366
|
+
...d.residency !== undefined ? { residency: requireString(d.residency, `targets.data[${idx}].residency`) } : {},
|
|
367
|
+
...d.regulated !== undefined ? { regulated: requireBoolean(d.regulated, `targets.data[${idx}].regulated`) } : {}
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return targets;
|
|
372
|
+
}
|
|
373
|
+
function validateAssertionSource(value, where) {
|
|
374
|
+
const obj = requireObject(value, where);
|
|
375
|
+
rejectUnknownKeys(obj, ["fileContent", "sourceRef", "compiledArtifact"], where);
|
|
376
|
+
const source = {};
|
|
377
|
+
if (obj.fileContent !== undefined)
|
|
378
|
+
source.fileContent = requireString(obj.fileContent, `${where}.fileContent`);
|
|
379
|
+
if (obj.sourceRef !== undefined)
|
|
380
|
+
source.sourceRef = requireString(obj.sourceRef, `${where}.sourceRef`);
|
|
381
|
+
if (obj.compiledArtifact !== undefined) {
|
|
382
|
+
const validation = validateRegoWasmPolicyEnvelopeV1(obj.compiledArtifact);
|
|
383
|
+
if (!validation.ok)
|
|
384
|
+
throw new SnapshotContractError(`${where}.compiledArtifact: ${validation.message}`);
|
|
385
|
+
source.compiledArtifact = validation.envelope;
|
|
386
|
+
}
|
|
387
|
+
return source;
|
|
388
|
+
}
|
|
389
|
+
function validateAssertionInputs(value) {
|
|
390
|
+
const obj = requireObject(value, "assertionInputs");
|
|
391
|
+
rejectUnknownKeys(obj, ["sources", "inputs"], "assertionInputs");
|
|
392
|
+
const sources = {};
|
|
393
|
+
const inputs = {};
|
|
394
|
+
if (obj.sources !== undefined) {
|
|
395
|
+
const src = requireObject(obj.sources, "assertionInputs.sources");
|
|
396
|
+
requireCanonicalAssertionKeys(src, "assertionInputs.sources");
|
|
397
|
+
for (const [key, val] of Object.entries(src)) {
|
|
398
|
+
sources[key] = validateAssertionSource(val, `assertionInputs.sources["${key}"]`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (obj.inputs !== undefined) {
|
|
402
|
+
const inp = requireObject(obj.inputs, "assertionInputs.inputs");
|
|
403
|
+
requireCanonicalAssertionKeys(inp, "assertionInputs.inputs");
|
|
404
|
+
for (const [key, val] of Object.entries(inp)) {
|
|
405
|
+
const entry = requireObject(val, `assertionInputs.inputs["${key}"]`);
|
|
406
|
+
rejectUnknownKeys(entry, ["document"], `assertionInputs.inputs["${key}"]`);
|
|
407
|
+
if (entry.document === undefined)
|
|
408
|
+
throw new SnapshotContractError(`assertionInputs.inputs["${key}"].document is required`);
|
|
409
|
+
inputs[key] = { document: entry.document };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return { sources, inputs };
|
|
413
|
+
}
|
|
414
|
+
function validateIdentity(value) {
|
|
415
|
+
const obj = requireObject(value, "identity");
|
|
416
|
+
rejectUnknownKeys(obj, ["principals", "teams", "codeowners", "catalogOwners"], "identity");
|
|
417
|
+
if (!Array.isArray(obj.principals))
|
|
418
|
+
throw new SnapshotContractError("identity.principals must be an array");
|
|
419
|
+
const seenPrincipals = new Set;
|
|
420
|
+
const principals = obj.principals.map((item, idx) => {
|
|
421
|
+
const p = requireObject(item, `identity.principals[${idx}]`);
|
|
422
|
+
rejectUnknownKeys(p, ["id", "active", "kind"], `identity.principals[${idx}]`);
|
|
423
|
+
const id = requireString(p.id ?? null, `identity.principals[${idx}].id`);
|
|
424
|
+
if (seenPrincipals.has(id))
|
|
425
|
+
throw new SnapshotContractError(`Duplicate principal id "${id}"`);
|
|
426
|
+
seenPrincipals.add(id);
|
|
427
|
+
const kind = requireString(p.kind ?? null, `identity.principals[${idx}].kind`);
|
|
428
|
+
if (kind !== "human" && kind !== "team")
|
|
429
|
+
throw new SnapshotContractError(`identity.principals[${idx}].kind must be "human" or "team"`);
|
|
430
|
+
return { id, active: requireBoolean(p.active ?? null, `identity.principals[${idx}].active`), kind };
|
|
431
|
+
});
|
|
432
|
+
const teams = Array.isArray(obj.teams) ? obj.teams.map((item, idx) => {
|
|
433
|
+
const t = requireObject(item, `identity.teams[${idx}]`);
|
|
434
|
+
rejectUnknownKeys(t, ["id", "members"], `identity.teams[${idx}]`);
|
|
435
|
+
return {
|
|
436
|
+
id: requireString(t.id ?? null, `identity.teams[${idx}].id`),
|
|
437
|
+
members: requireStringArray(t.members ?? null, `identity.teams[${idx}].members`)
|
|
438
|
+
};
|
|
439
|
+
}) : obj.teams === undefined ? [] : (() => {
|
|
440
|
+
throw new SnapshotContractError("identity.teams must be an array");
|
|
441
|
+
})();
|
|
442
|
+
const identity = { principals, teams };
|
|
443
|
+
const principalKindById = new Map(principals.map((p) => [p.id, p.kind]));
|
|
444
|
+
const teamIds = new Set;
|
|
445
|
+
for (const team of teams) {
|
|
446
|
+
if (teamIds.has(team.id))
|
|
447
|
+
throw new SnapshotContractError(`Duplicate team id "${team.id}"`);
|
|
448
|
+
teamIds.add(team.id);
|
|
449
|
+
const kind = principalKindById.get(team.id);
|
|
450
|
+
if (kind === undefined)
|
|
451
|
+
throw new SnapshotContractError(`Team "${team.id}" has no matching principal`);
|
|
452
|
+
if (kind !== "team")
|
|
453
|
+
throw new SnapshotContractError(`Team "${team.id}" collides with a non-team principal`);
|
|
454
|
+
}
|
|
455
|
+
for (const p of principals) {
|
|
456
|
+
if (p.kind === "team" && !teamIds.has(p.id)) {
|
|
457
|
+
throw new SnapshotContractError(`Team principal "${p.id}" has no membership entry`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (obj.codeowners !== undefined) {
|
|
461
|
+
if (!Array.isArray(obj.codeowners))
|
|
462
|
+
throw new SnapshotContractError("identity.codeowners must be an array");
|
|
463
|
+
identity.codeowners = obj.codeowners.map((item, idx) => {
|
|
464
|
+
const rule = requireObject(item, `identity.codeowners[${idx}]`);
|
|
465
|
+
rejectUnknownKeys(rule, ["pattern", "owners"], `identity.codeowners[${idx}]`);
|
|
466
|
+
return {
|
|
467
|
+
pattern: requireString(rule.pattern ?? null, `identity.codeowners[${idx}].pattern`),
|
|
468
|
+
owners: requireStringArray(rule.owners ?? null, `identity.codeowners[${idx}].owners`)
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (obj.catalogOwners !== undefined) {
|
|
473
|
+
const co = requireObject(obj.catalogOwners, "identity.catalogOwners");
|
|
474
|
+
const map = Object.create(null);
|
|
475
|
+
for (const [key, owners] of Object.entries(co)) {
|
|
476
|
+
map[key] = requireStringArray(owners, `identity.catalogOwners["${key}"]`);
|
|
477
|
+
}
|
|
478
|
+
identity.catalogOwners = map;
|
|
479
|
+
}
|
|
480
|
+
return identity;
|
|
481
|
+
}
|
|
482
|
+
function validateScopeEvidence(value) {
|
|
483
|
+
const obj = requireObject(value, "scopeEvidence");
|
|
484
|
+
rejectUnknownKeys(obj, ["baseInputs"], "scopeEvidence");
|
|
485
|
+
if (obj.baseInputs === undefined)
|
|
486
|
+
return {};
|
|
487
|
+
const base = requireObject(obj.baseInputs, "scopeEvidence.baseInputs");
|
|
488
|
+
requireCanonicalAssertionKeys(base, "scopeEvidence.baseInputs");
|
|
489
|
+
const baseInputs = {};
|
|
490
|
+
for (const [key, val] of Object.entries(base)) {
|
|
491
|
+
const entry = requireObject(val, `scopeEvidence.baseInputs["${key}"]`);
|
|
492
|
+
rejectUnknownKeys(entry, ["document"], `scopeEvidence.baseInputs["${key}"]`);
|
|
493
|
+
if (entry.document === undefined)
|
|
494
|
+
throw new SnapshotContractError(`scopeEvidence.baseInputs["${key}"].document is required`);
|
|
495
|
+
baseInputs[key] = { document: entry.document };
|
|
496
|
+
}
|
|
497
|
+
return { baseInputs };
|
|
498
|
+
}
|
|
499
|
+
function validateRoutingEvidence(value) {
|
|
500
|
+
const obj = requireObject(value, "routingEvidence");
|
|
501
|
+
rejectUnknownKeys(obj, ["costEvidence", "dataResidency", "humanRequested", "securitySurfaceTargetKeys", "regulatedTargetKeys", "productionTargetKeys"], "routingEvidence");
|
|
502
|
+
const evidence = {};
|
|
503
|
+
if (obj.costEvidence !== undefined) {
|
|
504
|
+
const c = requireObject(obj.costEvidence, "routingEvidence.costEvidence");
|
|
505
|
+
rejectUnknownKeys(c, ["normalizedCost", "threshold"], "routingEvidence.costEvidence");
|
|
506
|
+
evidence.costEvidence = {
|
|
507
|
+
normalizedCost: requireNumber(c.normalizedCost ?? null, "routingEvidence.costEvidence.normalizedCost"),
|
|
508
|
+
threshold: requireNumber(c.threshold ?? null, "routingEvidence.costEvidence.threshold")
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
if (obj.dataResidency !== undefined) {
|
|
512
|
+
const d = requireObject(obj.dataResidency, "routingEvidence.dataResidency");
|
|
513
|
+
rejectUnknownKeys(d, ["present"], "routingEvidence.dataResidency");
|
|
514
|
+
evidence.dataResidency = { present: requireBoolean(d.present ?? null, "routingEvidence.dataResidency.present") };
|
|
515
|
+
}
|
|
516
|
+
if (obj.humanRequested !== undefined) {
|
|
517
|
+
const h = requireObject(obj.humanRequested, "routingEvidence.humanRequested");
|
|
518
|
+
rejectUnknownKeys(h, ["requester"], "routingEvidence.humanRequested");
|
|
519
|
+
evidence.humanRequested = { requester: requireString(h.requester ?? null, "routingEvidence.humanRequested.requester") };
|
|
520
|
+
}
|
|
521
|
+
if (obj.securitySurfaceTargetKeys !== undefined) {
|
|
522
|
+
evidence.securitySurfaceTargets = new Set(requireCanonicalTargetKeys(obj.securitySurfaceTargetKeys, "routingEvidence.securitySurfaceTargetKeys"));
|
|
523
|
+
}
|
|
524
|
+
if (obj.regulatedTargetKeys !== undefined) {
|
|
525
|
+
evidence.regulatedTargets = new Set(requireCanonicalTargetKeys(obj.regulatedTargetKeys, "routingEvidence.regulatedTargetKeys"));
|
|
526
|
+
}
|
|
527
|
+
if (obj.productionTargetKeys !== undefined) {
|
|
528
|
+
evidence.productionTargets = new Set(requireCanonicalTargetKeys(obj.productionTargetKeys, "routingEvidence.productionTargetKeys"));
|
|
529
|
+
}
|
|
530
|
+
return evidence;
|
|
531
|
+
}
|
|
532
|
+
function loadSnapshotBundle(text) {
|
|
533
|
+
const raw = parseSnapshotJson(text);
|
|
534
|
+
const bundle = requireObject(raw, "snapshot bundle");
|
|
535
|
+
rejectUnknownKeys(bundle, ["schemaVersion", "federatedLogs", "log", "targets", "assertionInputs", "identity", "scopeEvidence", "routingEvidence"], "snapshot bundle");
|
|
536
|
+
if (bundle.schemaVersion !== "adrkit.pass0.snapshot/v1") {
|
|
537
|
+
throw new SnapshotContractError('snapshot schemaVersion must be exactly "adrkit.pass0.snapshot/v1"');
|
|
538
|
+
}
|
|
539
|
+
const normalized = {
|
|
540
|
+
targets: bundle.targets !== undefined ? validateTargets(bundle.targets) : {},
|
|
541
|
+
assertionInputs: bundle.assertionInputs !== undefined ? validateAssertionInputs(bundle.assertionInputs) : { sources: {}, inputs: {} }
|
|
542
|
+
};
|
|
543
|
+
if (bundle.log !== undefined)
|
|
544
|
+
normalized.resolutionLog = requireString(bundle.log, "log");
|
|
545
|
+
if (bundle.federatedLogs !== undefined)
|
|
546
|
+
normalized.federatedLogs = validateFederatedLogs(bundle.federatedLogs);
|
|
547
|
+
if (bundle.identity !== undefined)
|
|
548
|
+
normalized.identity = validateIdentity(bundle.identity);
|
|
549
|
+
if (bundle.scopeEvidence !== undefined)
|
|
550
|
+
normalized.scopeEvidence = validateScopeEvidence(bundle.scopeEvidence);
|
|
551
|
+
if (bundle.routingEvidence !== undefined)
|
|
552
|
+
normalized.routingEvidence = validateRoutingEvidence(bundle.routingEvidence);
|
|
553
|
+
return normalized;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// src/evaluate.ts
|
|
557
|
+
var TARGET_REGISTRY = createTargetResolutionRegistry([createPathTargetResolver(), createPackageTargetResolver()]);
|
|
558
|
+
var ASSERTION_ENGINES = createAssertionEngineRegistry({ jsonpath: createJsonPathEngine() });
|
|
559
|
+
var ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
560
|
+
function isValidIsoDate(value) {
|
|
561
|
+
if (!ISO_DATE.test(value))
|
|
562
|
+
return false;
|
|
563
|
+
const [y, m, d] = value.split("-").map(Number);
|
|
564
|
+
if (m < 1 || m > 12 || d < 1 || d > 31)
|
|
565
|
+
return false;
|
|
566
|
+
const date = new Date(Date.UTC(y, m - 1, d));
|
|
567
|
+
return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
|
|
568
|
+
}
|
|
569
|
+
function buildInput(proposalPath, corpus, snapshot, date) {
|
|
570
|
+
return {
|
|
571
|
+
corpus,
|
|
572
|
+
proposalPath,
|
|
573
|
+
...snapshot.federatedLogs ? { federatedLogs: snapshot.federatedLogs } : {},
|
|
574
|
+
...snapshot.resolutionLog !== undefined ? { resolutionLog: snapshot.resolutionLog } : {},
|
|
575
|
+
targets: snapshot.targets,
|
|
576
|
+
targetRegistry: TARGET_REGISTRY,
|
|
577
|
+
assertionInputs: snapshot.assertionInputs,
|
|
578
|
+
assertionEngines: ASSERTION_ENGINES,
|
|
579
|
+
...snapshot.identity ? { identity: snapshot.identity } : {},
|
|
580
|
+
...snapshot.scopeEvidence ? { scopeEvidence: snapshot.scopeEvidence } : {},
|
|
581
|
+
...snapshot.routingEvidence ? { routingEvidence: snapshot.routingEvidence } : {},
|
|
582
|
+
evaluationDate: date
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function renderHuman(report) {
|
|
586
|
+
const lines = [`Pass 0 evaluation of ${report.proposalPath} — outcome: ${report.outcome}`];
|
|
587
|
+
for (const result of report.results) {
|
|
588
|
+
const severity = result.status === "fail" && result.severity ? ` (${result.severity})` : "";
|
|
589
|
+
lines.push(` ${result.rule}: ${result.status}${severity} — ${result.reason}`);
|
|
590
|
+
}
|
|
591
|
+
const { routing } = report;
|
|
592
|
+
const target = routing.target.kind === "resolved" ? `${routing.target.human} (via ${routing.target.via})` : routing.target.kind;
|
|
593
|
+
lines.push(` routing: ${routing.escalate ? `escalate [${routing.reasons.join(", ")}]` : "no escalation"}, target: ${target}`);
|
|
594
|
+
return `${lines.join(`
|
|
595
|
+
`)}
|
|
596
|
+
`;
|
|
597
|
+
}
|
|
598
|
+
async function evaluate(options) {
|
|
599
|
+
const cwd = options.cwd ?? process.cwd();
|
|
600
|
+
if (!isValidIsoDate(options.date)) {
|
|
601
|
+
return { exitCode: 2, stdout: "", stderr: `adr evaluate: --date must be a valid YYYY-MM-DD (got "${options.date}")
|
|
602
|
+
` };
|
|
603
|
+
}
|
|
604
|
+
let snapshot;
|
|
605
|
+
try {
|
|
606
|
+
const text = await readFile(options.snapshotPath, "utf8");
|
|
607
|
+
snapshot = loadSnapshotBundle(text);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (error instanceof SnapshotContractError) {
|
|
610
|
+
return { exitCode: 2, stdout: "", stderr: `adr evaluate: ${error.message}
|
|
611
|
+
` };
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
exitCode: 2,
|
|
615
|
+
stdout: "",
|
|
616
|
+
stderr: `adr evaluate: could not read snapshot "${options.snapshotPath}": ${error instanceof Error ? error.message : String(error)}
|
|
617
|
+
`
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
const dir = options.dir ?? dirname(options.proposalPath);
|
|
621
|
+
const corpus = await lintCorpus({ paths: [dir, options.proposalPath], cwd });
|
|
622
|
+
const proposalPath = normalizeDisplayPath(options.proposalPath, cwd);
|
|
623
|
+
const outcome = evaluatePass0(buildInput(proposalPath, corpus, snapshot, options.date));
|
|
624
|
+
if (outcome.kind === "input-error") {
|
|
625
|
+
return {
|
|
626
|
+
exitCode: 2,
|
|
627
|
+
stdout: "",
|
|
628
|
+
stderr: `adr evaluate: ${outcome.error.code} — "${proposalPath}" has status "${outcome.error.actualStatus}", not draft/proposed
|
|
629
|
+
`
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
const { report, patch } = outcome.result;
|
|
633
|
+
const exitCode = report.outcome === "returned" ? 1 : 0;
|
|
634
|
+
if (options.json) {
|
|
635
|
+
const envelope = {
|
|
636
|
+
result: { report, patch },
|
|
637
|
+
metadata: { evaluatorVersion: "0.1.0" }
|
|
638
|
+
};
|
|
639
|
+
return { exitCode, stdout: canonicalBytes(envelope), stderr: "" };
|
|
640
|
+
}
|
|
641
|
+
return { exitCode, stdout: renderHuman(report), stderr: "" };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// src/main-module.ts
|
|
645
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
646
|
+
import { fileURLToPath } from "node:url";
|
|
647
|
+
function isMainModule(moduleUrl, argvPath) {
|
|
648
|
+
if (!argvPath || !existsSync(argvPath))
|
|
649
|
+
return false;
|
|
650
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argvPath);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/index.ts
|
|
654
|
+
function writeStdout(text) {
|
|
655
|
+
process.stdout.write(text);
|
|
656
|
+
}
|
|
657
|
+
function writeStderr(text) {
|
|
658
|
+
process.stderr.write(text);
|
|
659
|
+
}
|
|
660
|
+
function usage(message) {
|
|
661
|
+
if (message)
|
|
662
|
+
writeStderr(`${message}
|
|
663
|
+
`);
|
|
664
|
+
writeStderr(`Usage:
|
|
665
|
+
adr lint [paths...] [--json] [--dir docs/adr]
|
|
666
|
+
adr migrate --from madr [--dir docs/adr] [--dry-run] [--json]
|
|
667
|
+
adr new <title> [--status draft] [--dir docs/adr] [--json]
|
|
668
|
+
adr graph [--dir docs/adr] [--format dot|json]
|
|
669
|
+
adr explain <path> [--dir docs/adr] [--json]
|
|
670
|
+
adr check <files...> [--dir docs/adr] [--json]
|
|
671
|
+
adr evaluate <proposal-path> --snapshot <bundle.json> --date YYYY-MM-DD [--json] [--dir docs/adr]
|
|
672
|
+
|
|
673
|
+
Round-trip sync is explicitly unsupported (ADR-0008); migrate is one-way and non-destructive.
|
|
674
|
+
`);
|
|
675
|
+
return 2;
|
|
676
|
+
}
|
|
677
|
+
function parseCommandArgs(args, options) {
|
|
678
|
+
return parseArgs({ args, options, allowPositionals: true, strict: true });
|
|
679
|
+
}
|
|
680
|
+
function renderFinding(finding) {
|
|
681
|
+
const field = finding.field ? ` ${finding.field}` : "";
|
|
682
|
+
const id = finding.id ? ` ${finding.id}` : "";
|
|
683
|
+
const pattern = finding.pattern ? ` ${finding.pattern}` : "";
|
|
684
|
+
return ` ${finding.severity} ${finding.rule}${id}${field}${pattern}: ${finding.message}
|
|
685
|
+
`;
|
|
686
|
+
}
|
|
687
|
+
function renderHumanLint(findings) {
|
|
688
|
+
const grouped = new Map;
|
|
689
|
+
for (const finding of findings) {
|
|
690
|
+
const group = finding.path ?? "(corpus)";
|
|
691
|
+
let list = grouped.get(group);
|
|
692
|
+
if (!list) {
|
|
693
|
+
list = [];
|
|
694
|
+
grouped.set(group, list);
|
|
695
|
+
}
|
|
696
|
+
list.push(finding);
|
|
697
|
+
}
|
|
698
|
+
let output = "";
|
|
699
|
+
for (const [path, groupFindings] of [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
700
|
+
output += `${path}
|
|
701
|
+
`;
|
|
702
|
+
for (const finding of groupFindings) {
|
|
703
|
+
output += renderFinding(finding);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return output;
|
|
707
|
+
}
|
|
708
|
+
async function runLint(args) {
|
|
709
|
+
let parsed;
|
|
710
|
+
try {
|
|
711
|
+
parsed = parseCommandArgs(args, {
|
|
712
|
+
json: { type: "boolean", default: false },
|
|
713
|
+
dir: { type: "string", default: "docs/adr" }
|
|
714
|
+
});
|
|
715
|
+
} catch (error) {
|
|
716
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
717
|
+
}
|
|
718
|
+
const result = await lintCorpus2({
|
|
719
|
+
dir: String(parsed.values.dir),
|
|
720
|
+
paths: parsed.positionals
|
|
721
|
+
});
|
|
722
|
+
const findings = sortFindings(result.findings);
|
|
723
|
+
const counts = countFindings(findings);
|
|
724
|
+
if (parsed.values.json) {
|
|
725
|
+
writeStdout(`${JSON.stringify({ checked: result.checked, findings }, null, 2)}
|
|
726
|
+
`);
|
|
727
|
+
} else {
|
|
728
|
+
const humanFindings = renderHumanLint(findings);
|
|
729
|
+
if (humanFindings)
|
|
730
|
+
writeStderr(humanFindings);
|
|
731
|
+
writeStdout(`checked ${result.checked} records, ${counts.errors} errors, ${counts.warnings} warnings
|
|
732
|
+
`);
|
|
733
|
+
}
|
|
734
|
+
return exitCodeForFindings(findings);
|
|
735
|
+
}
|
|
736
|
+
function renderHumanMigrate(result) {
|
|
737
|
+
const counts = {
|
|
738
|
+
migrated: 0,
|
|
739
|
+
updated: 0,
|
|
740
|
+
unchanged: 0,
|
|
741
|
+
diverged: 0,
|
|
742
|
+
skipped: 0
|
|
743
|
+
};
|
|
744
|
+
let output = "";
|
|
745
|
+
for (const item of result.results) {
|
|
746
|
+
counts[item.outcome] += 1;
|
|
747
|
+
output += `${item.outcome} ${item.path}
|
|
748
|
+
`;
|
|
749
|
+
}
|
|
750
|
+
output += `summary: migrated ${counts.migrated}, updated ${counts.updated}, unchanged ${counts.unchanged}, diverged ${counts.diverged}, skipped ${counts.skipped}
|
|
751
|
+
`;
|
|
752
|
+
output += `Divergence (report only):
|
|
753
|
+
`;
|
|
754
|
+
if (result.divergence.length === 0) {
|
|
755
|
+
output += ` none
|
|
756
|
+
`;
|
|
757
|
+
} else {
|
|
758
|
+
for (const item of result.divergence) {
|
|
759
|
+
output += ` ${item.path} sourceRef=${item.sourceRef}
|
|
760
|
+
`;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (result.findings.length > 0) {
|
|
764
|
+
output += `Findings:
|
|
765
|
+
`;
|
|
766
|
+
for (const finding of result.findings) {
|
|
767
|
+
output += renderFinding(finding);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return output;
|
|
771
|
+
}
|
|
772
|
+
async function runMigrate(args) {
|
|
773
|
+
let parsed;
|
|
774
|
+
try {
|
|
775
|
+
parsed = parseCommandArgs(args, {
|
|
776
|
+
from: { type: "string" },
|
|
777
|
+
dir: { type: "string", default: "docs/adr" },
|
|
778
|
+
"dry-run": { type: "boolean", default: false },
|
|
779
|
+
json: { type: "boolean", default: false }
|
|
780
|
+
});
|
|
781
|
+
} catch (error) {
|
|
782
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
783
|
+
}
|
|
784
|
+
if (parsed.positionals.length > 0)
|
|
785
|
+
return usage("adr migrate does not accept positional arguments");
|
|
786
|
+
const from = parsed.values.from;
|
|
787
|
+
if (from !== "madr") {
|
|
788
|
+
return usage(from ? `adr migrate --from ${String(from)} is not supported yet; only --from madr is available, and round-trip sync is unsupported (ADR-0008)` : "adr migrate requires --from madr; non-MADR sources and round-trip sync are unsupported in this phase (ADR-0008)");
|
|
789
|
+
}
|
|
790
|
+
const result = await migrateMadr({
|
|
791
|
+
dir: String(parsed.values.dir),
|
|
792
|
+
write: parsed.values["dry-run"] !== true
|
|
793
|
+
});
|
|
794
|
+
if (parsed.values.json) {
|
|
795
|
+
writeStdout(`${JSON.stringify(result, null, 2)}
|
|
796
|
+
`);
|
|
797
|
+
} else {
|
|
798
|
+
writeStdout(renderHumanMigrate(result));
|
|
799
|
+
}
|
|
800
|
+
return 0;
|
|
801
|
+
}
|
|
802
|
+
async function runNew(args) {
|
|
803
|
+
let parsed;
|
|
804
|
+
try {
|
|
805
|
+
parsed = parseCommandArgs(args, {
|
|
806
|
+
json: { type: "boolean", default: false },
|
|
807
|
+
dir: { type: "string", default: "docs/adr" },
|
|
808
|
+
status: { type: "string", default: "draft" }
|
|
809
|
+
});
|
|
810
|
+
} catch (error) {
|
|
811
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
812
|
+
}
|
|
813
|
+
const title = parsed.positionals.join(" ").trim();
|
|
814
|
+
if (!title)
|
|
815
|
+
return usage("adr new requires a title");
|
|
816
|
+
try {
|
|
817
|
+
const result = await createAdr({
|
|
818
|
+
title,
|
|
819
|
+
status: String(parsed.values.status),
|
|
820
|
+
dir: String(parsed.values.dir)
|
|
821
|
+
});
|
|
822
|
+
if (parsed.values.json) {
|
|
823
|
+
writeStdout(`${JSON.stringify({ id: result.id, path: result.path }, null, 2)}
|
|
824
|
+
`);
|
|
825
|
+
} else {
|
|
826
|
+
writeStdout(`${result.path}
|
|
827
|
+
`);
|
|
828
|
+
}
|
|
829
|
+
return 0;
|
|
830
|
+
} catch (error) {
|
|
831
|
+
if (error instanceof ScaffoldError) {
|
|
832
|
+
writeStderr(`${error.message}
|
|
833
|
+
`);
|
|
834
|
+
return error.code === "exists" ? 1 : 2;
|
|
835
|
+
}
|
|
836
|
+
throw error;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
async function runGraph(args) {
|
|
840
|
+
let parsed;
|
|
841
|
+
try {
|
|
842
|
+
parsed = parseCommandArgs(args, {
|
|
843
|
+
dir: { type: "string", default: "docs/adr" },
|
|
844
|
+
format: { type: "string", default: "dot" }
|
|
845
|
+
});
|
|
846
|
+
} catch (error) {
|
|
847
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
848
|
+
}
|
|
849
|
+
if (parsed.positionals.length > 0)
|
|
850
|
+
return usage("adr graph does not accept positional arguments");
|
|
851
|
+
const format = String(parsed.values.format);
|
|
852
|
+
if (format !== "dot" && format !== "json")
|
|
853
|
+
return usage("adr graph --format must be dot or json");
|
|
854
|
+
const result = await lintCorpus2({ dir: String(parsed.values.dir) });
|
|
855
|
+
const graph = buildAdrGraph(result.records);
|
|
856
|
+
writeStdout(format === "json" ? renderJsonGraph(graph) : renderDotGraph(graph));
|
|
857
|
+
return 0;
|
|
858
|
+
}
|
|
859
|
+
async function runExplain(args) {
|
|
860
|
+
let parsed;
|
|
861
|
+
try {
|
|
862
|
+
parsed = parseCommandArgs(args, {
|
|
863
|
+
json: { type: "boolean", default: false },
|
|
864
|
+
dir: { type: "string", default: "docs/adr" }
|
|
865
|
+
});
|
|
866
|
+
} catch (error) {
|
|
867
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
868
|
+
}
|
|
869
|
+
if (parsed.positionals.length !== 1)
|
|
870
|
+
return usage("adr explain requires exactly one path");
|
|
871
|
+
const path = parsed.positionals[0];
|
|
872
|
+
if (!path)
|
|
873
|
+
return usage("adr explain requires exactly one path");
|
|
874
|
+
const corpus = await lintCorpus2({ dir: String(parsed.values.dir) });
|
|
875
|
+
const corpusFindings = sortFindings(corpus.findings);
|
|
876
|
+
if (exitCodeForFindings(corpusFindings) !== 0) {
|
|
877
|
+
if (parsed.values.json) {
|
|
878
|
+
writeStdout(`${JSON.stringify({ path, governedBy: [], findings: corpusFindings }, null, 2)}
|
|
879
|
+
`);
|
|
880
|
+
} else {
|
|
881
|
+
const humanFindings = renderHumanLint(corpusFindings);
|
|
882
|
+
if (humanFindings)
|
|
883
|
+
writeStderr(humanFindings);
|
|
884
|
+
}
|
|
885
|
+
return 1;
|
|
886
|
+
}
|
|
887
|
+
const recordsById = new Map(corpus.records.map((record) => [record.frontmatter.id, record]));
|
|
888
|
+
const resolution = resolveAffects({ records: corpus.records, changedFiles: [path] });
|
|
889
|
+
const governedBy = resolution.matches.map((match) => ({
|
|
890
|
+
recordId: match.recordId,
|
|
891
|
+
title: recordsById.get(match.recordId)?.frontmatter.title ?? "",
|
|
892
|
+
firedMatchers: match.firedMatchers
|
|
893
|
+
}));
|
|
894
|
+
const findings = sortFindings(resolution.findings);
|
|
895
|
+
if (parsed.values.json) {
|
|
896
|
+
writeStdout(`${JSON.stringify({ path, governedBy, findings }, null, 2)}
|
|
897
|
+
`);
|
|
898
|
+
return 0;
|
|
899
|
+
}
|
|
900
|
+
if (governedBy.length === 0) {
|
|
901
|
+
writeStdout(`No decision governs ${path}.
|
|
902
|
+
`);
|
|
903
|
+
} else {
|
|
904
|
+
for (const match of governedBy) {
|
|
905
|
+
writeStdout(`${match.recordId} ${match.title}
|
|
906
|
+
`);
|
|
907
|
+
for (const matcher of match.firedMatchers) {
|
|
908
|
+
writeStdout(` via ${matcher.type}: ${matcher.pattern}
|
|
909
|
+
`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (findings.length > 0) {
|
|
914
|
+
writeStdout(`Findings:
|
|
915
|
+
`);
|
|
916
|
+
for (const finding of findings) {
|
|
917
|
+
writeStdout(renderFinding(finding));
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return 0;
|
|
921
|
+
}
|
|
922
|
+
function renderHumanCheck(outcome) {
|
|
923
|
+
let output = "";
|
|
924
|
+
if (outcome.governedBy.length === 0) {
|
|
925
|
+
output += `No decisions govern the changed files.
|
|
926
|
+
`;
|
|
927
|
+
} else {
|
|
928
|
+
output += `Decisions governing this change:
|
|
929
|
+
`;
|
|
930
|
+
for (const decision of outcome.governedBy) {
|
|
931
|
+
output += ` ${decision.recordId} ${decision.title}
|
|
932
|
+
`;
|
|
933
|
+
for (const matcher of decision.firedMatchers) {
|
|
934
|
+
output += ` via ${matcher.type}: ${matcher.pattern}
|
|
935
|
+
`;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (outcome.findings.length > 0) {
|
|
940
|
+
output += `Findings:
|
|
941
|
+
`;
|
|
942
|
+
output += renderHumanLint(outcome.findings);
|
|
943
|
+
}
|
|
944
|
+
const changedRecordErrors = outcome.findings.filter((finding) => finding.severity === "error" && finding.path && outcome.changedRecords.includes(finding.path)).length;
|
|
945
|
+
output += `checked: ${outcome.governedBy.length} governing, ${outcome.changedRecords.length} changed records, ${changedRecordErrors} changed-record errors
|
|
946
|
+
`;
|
|
947
|
+
return output;
|
|
948
|
+
}
|
|
949
|
+
async function runCheck(args) {
|
|
950
|
+
let parsed;
|
|
951
|
+
try {
|
|
952
|
+
parsed = parseCommandArgs(args, {
|
|
953
|
+
json: { type: "boolean", default: false },
|
|
954
|
+
dir: { type: "string", default: "docs/adr" }
|
|
955
|
+
});
|
|
956
|
+
} catch (error) {
|
|
957
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
958
|
+
}
|
|
959
|
+
const dir = String(parsed.values.dir);
|
|
960
|
+
const lint = await lintCorpus2({ dir });
|
|
961
|
+
const outcome = checkChanges({ lint, changedFiles: parsed.positionals, dir });
|
|
962
|
+
if (parsed.values.json) {
|
|
963
|
+
writeStdout(`${JSON.stringify(outcome, null, 2)}
|
|
964
|
+
`);
|
|
965
|
+
} else {
|
|
966
|
+
writeStdout(renderHumanCheck(outcome));
|
|
967
|
+
}
|
|
968
|
+
return outcome.ok ? 0 : 1;
|
|
969
|
+
}
|
|
970
|
+
async function runEvaluate(args) {
|
|
971
|
+
let parsed;
|
|
972
|
+
try {
|
|
973
|
+
parsed = parseCommandArgs(args, {
|
|
974
|
+
snapshot: { type: "string" },
|
|
975
|
+
date: { type: "string" },
|
|
976
|
+
json: { type: "boolean", default: false },
|
|
977
|
+
dir: { type: "string" }
|
|
978
|
+
});
|
|
979
|
+
} catch (error) {
|
|
980
|
+
return usage(error instanceof Error ? error.message : String(error));
|
|
981
|
+
}
|
|
982
|
+
if (parsed.positionals.length !== 1)
|
|
983
|
+
return usage("adr evaluate requires exactly one proposal path");
|
|
984
|
+
const proposalPath = parsed.positionals[0];
|
|
985
|
+
if (!proposalPath)
|
|
986
|
+
return usage("adr evaluate requires a proposal path");
|
|
987
|
+
const snapshot = parsed.values.snapshot;
|
|
988
|
+
if (typeof snapshot !== "string" || snapshot.length === 0) {
|
|
989
|
+
return usage("adr evaluate requires --snapshot <bundle.json>");
|
|
990
|
+
}
|
|
991
|
+
const date = parsed.values.date;
|
|
992
|
+
if (typeof date !== "string" || date.length === 0) {
|
|
993
|
+
return usage("adr evaluate requires --date YYYY-MM-DD");
|
|
994
|
+
}
|
|
995
|
+
const result = await evaluate({
|
|
996
|
+
proposalPath,
|
|
997
|
+
snapshotPath: snapshot,
|
|
998
|
+
date,
|
|
999
|
+
json: parsed.values.json === true,
|
|
1000
|
+
...typeof parsed.values.dir === "string" ? { dir: parsed.values.dir } : {}
|
|
1001
|
+
});
|
|
1002
|
+
if (result.stderr)
|
|
1003
|
+
writeStderr(result.stderr);
|
|
1004
|
+
if (result.stdout)
|
|
1005
|
+
writeStdout(result.stdout);
|
|
1006
|
+
return result.exitCode;
|
|
1007
|
+
}
|
|
1008
|
+
async function main(argv = process.argv.slice(2)) {
|
|
1009
|
+
const [command, ...args] = argv;
|
|
1010
|
+
try {
|
|
1011
|
+
if (command === "lint")
|
|
1012
|
+
return await runLint(args);
|
|
1013
|
+
if (command === "migrate")
|
|
1014
|
+
return await runMigrate(args);
|
|
1015
|
+
if (command === "new")
|
|
1016
|
+
return await runNew(args);
|
|
1017
|
+
if (command === "graph")
|
|
1018
|
+
return await runGraph(args);
|
|
1019
|
+
if (command === "explain")
|
|
1020
|
+
return await runExplain(args);
|
|
1021
|
+
if (command === "check")
|
|
1022
|
+
return await runCheck(args);
|
|
1023
|
+
if (command === "evaluate")
|
|
1024
|
+
return await runEvaluate(args);
|
|
1025
|
+
return usage(command ? `Unknown command "${command}"` : undefined);
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
writeStderr(`${error instanceof Error ? error.message : String(error)}
|
|
1028
|
+
`);
|
|
1029
|
+
return 1;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (isMainModule(import.meta.url, process.argv[1])) {
|
|
1033
|
+
process.exitCode = await main();
|
|
1034
|
+
}
|
|
1035
|
+
export {
|
|
1036
|
+
main
|
|
1037
|
+
};
|