@adrkit/core 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.
Files changed (60) hide show
  1. package/README.md +22 -0
  2. package/dist/LICENSE +201 -0
  3. package/dist/NOTICE +11 -0
  4. package/dist/affects/catalog.d.ts +14 -0
  5. package/dist/affects/index.d.ts +30 -0
  6. package/dist/affects/inert.d.ts +8 -0
  7. package/dist/affects/matchers/package.d.ts +20 -0
  8. package/dist/affects/matchers/path.d.ts +5 -0
  9. package/dist/check/index.d.ts +46 -0
  10. package/dist/graph/build.d.ts +18 -0
  11. package/dist/import/classify.d.ts +13 -0
  12. package/dist/import/fingerprint.d.ts +2 -0
  13. package/dist/import/index.d.ts +32 -0
  14. package/dist/import/madr.d.ts +21 -0
  15. package/dist/import/merge.d.ts +21 -0
  16. package/dist/import/status.d.ts +11 -0
  17. package/dist/index.d.ts +13 -0
  18. package/dist/index.js +1768 -0
  19. package/dist/load/corpus.d.ts +20 -0
  20. package/dist/parse/frontmatter.d.ts +10 -0
  21. package/dist/scaffold/new.d.ts +26 -0
  22. package/dist/schema/adr.schema.d.ts +416 -0
  23. package/dist/schema/adr.schema.js +182 -0
  24. package/dist/schema/emit.cli.d.ts +1 -0
  25. package/dist/schema/emit.d.ts +6 -0
  26. package/dist/schema/emit.js +200 -0
  27. package/dist/schema/index.d.ts +2 -0
  28. package/dist/schema/index.js +220 -0
  29. package/dist/validate/contract.d.ts +9 -0
  30. package/dist/validate/corpus-invariants.d.ts +3 -0
  31. package/dist/validate/findings.d.ts +19 -0
  32. package/dist/validate/import-incomplete.d.ts +3 -0
  33. package/dist/validate/index.d.ts +13 -0
  34. package/package.json +76 -0
  35. package/src/affects/catalog.ts +17 -0
  36. package/src/affects/index.ts +240 -0
  37. package/src/affects/inert.ts +63 -0
  38. package/src/affects/matchers/package.ts +102 -0
  39. package/src/affects/matchers/path.ts +37 -0
  40. package/src/check/index.ts +121 -0
  41. package/src/graph/build.ts +94 -0
  42. package/src/import/classify.ts +53 -0
  43. package/src/import/fingerprint.ts +10 -0
  44. package/src/import/index.ts +240 -0
  45. package/src/import/madr.ts +117 -0
  46. package/src/import/merge.ts +321 -0
  47. package/src/import/status.ts +48 -0
  48. package/src/index.ts +13 -0
  49. package/src/load/corpus.ts +101 -0
  50. package/src/parse/frontmatter.ts +73 -0
  51. package/src/scaffold/new.ts +208 -0
  52. package/src/schema/adr.schema.ts +338 -0
  53. package/src/schema/emit.cli.ts +9 -0
  54. package/src/schema/emit.ts +55 -0
  55. package/src/schema/index.ts +2 -0
  56. package/src/validate/contract.ts +120 -0
  57. package/src/validate/corpus-invariants.ts +77 -0
  58. package/src/validate/findings.ts +51 -0
  59. package/src/validate/import-incomplete.ts +23 -0
  60. package/src/validate/index.ts +68 -0
package/dist/index.js ADDED
@@ -0,0 +1,1768 @@
1
+ // src/schema/adr.schema.ts
2
+ import { z } from "zod";
3
+ var SCHEMA_VERSION = "0.1.0";
4
+ var Identity = z.string().regex(/^(@[A-Za-z0-9-]+|team:[a-z0-9-]+|[^@\s]+@[^@\s]+\.[^@\s]+)$/, "Expected @handle, team:slug, or an email address");
5
+ var AdrRef = z.string().regex(/^(([a-z0-9][a-z0-9-]*):)?([0-9]{4,}|[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26})$/, "Expected an ADR id, optionally prefixed with a log name");
6
+ var IsoDate = z.string().date();
7
+ var IsoDateTime = z.string().datetime({ offset: true });
8
+ var Slug = z.string().regex(/^[a-z0-9][a-z0-9-]*$/);
9
+ var strictObject = (shape) => z.strictObject(shape);
10
+ var uniqueArray = (item) => z.array(item).refine((xs) => new Set(xs.map((x) => JSON.stringify(x))).size === xs.length, {
11
+ message: "Items must be unique"
12
+ }).meta({ uniqueItems: true });
13
+ var Status = z.enum([
14
+ "draft",
15
+ "proposed",
16
+ "accepted",
17
+ "rejected",
18
+ "superseded",
19
+ "deprecated"
20
+ ]);
21
+ var Scope = z.enum(["component", "domain", "org"]);
22
+ var Reversibility = z.enum(["two-way-door", "one-way-door", "unknown"]);
23
+ var BlastRadius = z.enum(["component", "team", "cross-team", "org"]);
24
+ var ReviewTier = z.enum(["auto", "async", "arb"]);
25
+ var Severity = z.enum(["error", "warn", "info"]);
26
+ var EscalationReason = z.enum([
27
+ "one-way-door",
28
+ "cost-threshold",
29
+ "security-surface",
30
+ "data-residency",
31
+ "regulatory",
32
+ "contradicts-accepted-adr",
33
+ "low-confidence",
34
+ "pass-disagreement",
35
+ "agent-authored-production",
36
+ "novel-no-precedent",
37
+ "human-requested"
38
+ ]);
39
+ var AffectsType = z.enum([
40
+ "path",
41
+ "entity",
42
+ "package",
43
+ "resource",
44
+ "api",
45
+ "data"
46
+ ]);
47
+ var AffectsMatcher = strictObject({
48
+ type: AffectsType,
49
+ pattern: z.string().min(1),
50
+ repo: z.string().optional(),
51
+ negate: z.boolean().default(false),
52
+ note: z.string().optional()
53
+ });
54
+ var Assertion = strictObject({
55
+ id: Slug,
56
+ description: z.string().optional(),
57
+ engine: z.enum(["rego", "jsonpath", "grep", "custom"]),
58
+ expression: z.string().optional(),
59
+ expressionFile: z.string().optional(),
60
+ input: z.enum(["source", "iac-plan", "sbom", "openapi", "catalog", "custom"]).default("source"),
61
+ severity: Severity
62
+ });
63
+ var Provenance = strictObject({
64
+ authoredBy: z.enum(["human", "agent", "agent-drafted"]).default("human"),
65
+ agent: strictObject({
66
+ name: z.string().optional(),
67
+ model: z.string().optional(),
68
+ harness: z.string().optional(),
69
+ runId: z.string().optional()
70
+ }).optional(),
71
+ ratifiedBy: Identity.optional(),
72
+ sourceArtifact: z.string().optional(),
73
+ importedFrom: strictObject({
74
+ sourceKind: z.enum(["madr", "agent-log", "plan-artifact", "other"]),
75
+ sourceRef: z.string(),
76
+ fingerprint: z.string(),
77
+ importedAt: IsoDateTime.optional()
78
+ }).optional()
79
+ });
80
+ var Objection = strictObject({
81
+ by: Identity,
82
+ summary: z.string().optional(),
83
+ resolved: z.boolean().default(false)
84
+ });
85
+ var Review = strictObject({
86
+ tier: ReviewTier.optional(),
87
+ tierReason: z.string().optional(),
88
+ queuedAt: IsoDateTime.optional(),
89
+ slaDays: z.number().int().min(0).optional(),
90
+ escalatedAt: IsoDateTime.optional(),
91
+ decidedAt: IsoDateTime.optional(),
92
+ quorum: z.number().int().min(1).optional(),
93
+ approvals: z.array(Identity).default([]),
94
+ objections: z.array(Objection).default([])
95
+ });
96
+ var DeterministicFinding = strictObject({
97
+ rule: z.string(),
98
+ severity: Severity,
99
+ message: z.string().optional(),
100
+ adr: AdrRef.optional()
101
+ });
102
+ var Evaluation = strictObject({
103
+ ranAt: IsoDateTime.optional(),
104
+ evaluatorVersion: z.string().optional(),
105
+ rubricVersion: z.string().optional(),
106
+ scores: z.record(z.string(), z.number().min(0).max(4)).optional(),
107
+ confidence: z.number().min(0).max(1).optional(),
108
+ escalate: z.boolean().optional(),
109
+ escalationReasons: z.array(EscalationReason).default([]),
110
+ deterministicFindings: z.array(DeterministicFinding).default([])
111
+ });
112
+ var ExternalRef = strictObject({
113
+ type: z.enum(["issue", "pr", "rfc", "incident", "doc", "meeting", "control", "other"]),
114
+ id: z.string().optional(),
115
+ url: z.string().url(),
116
+ label: z.string().optional()
117
+ });
118
+ var AdrFrontmatter = strictObject({
119
+ schemaVersion: z.string().regex(/^\d+\.\d+\.\d+$/).default(SCHEMA_VERSION),
120
+ id: z.string().regex(/^([0-9]{4,}|[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26})$/),
121
+ title: z.string().min(3).max(120),
122
+ status: Status,
123
+ date: IsoDate,
124
+ created: IsoDate.optional(),
125
+ deciders: z.array(Identity).default([]),
126
+ consulted: z.array(Identity).default([]),
127
+ informed: z.array(Identity).default([]),
128
+ tags: uniqueArray(Slug).default([]),
129
+ scope: Scope.default("component"),
130
+ domain: z.string().optional(),
131
+ reversibility: Reversibility.default("unknown"),
132
+ blastRadius: BlastRadius.default("component"),
133
+ supersedes: uniqueArray(AdrRef).default([]),
134
+ supersededBy: AdrRef.optional(),
135
+ relatesTo: uniqueArray(AdrRef).default([]),
136
+ conflictsWith: z.array(AdrRef).default([]),
137
+ affects: z.array(AffectsMatcher).default([]),
138
+ assertions: z.array(Assertion).default([]),
139
+ provenance: Provenance.optional(),
140
+ review: Review.optional(),
141
+ evaluation: Evaluation.optional(),
142
+ externalRefs: z.array(ExternalRef).default([]),
143
+ complianceControls: uniqueArray(z.string()).default([]),
144
+ reviewBy: IsoDate.optional()
145
+ }).refine((a) => a.status === "superseded" ? Boolean(a.supersededBy) : true, {
146
+ message: 'status "superseded" requires supersededBy',
147
+ path: ["supersededBy"]
148
+ }).refine((a) => a.supersededBy ? a.status === "superseded" : true, {
149
+ message: 'supersededBy is set but status is not "superseded"',
150
+ path: ["status"]
151
+ }).refine((a) => a.status !== "accepted" || a.deciders.length > 0 || Boolean(a.provenance?.importedFrom), {
152
+ message: "an accepted decision must name at least one decider, unless it was imported",
153
+ path: ["deciders"]
154
+ }).refine((a) => a.status !== "accepted" || a.provenance?.authoredBy !== "agent" || Boolean(a.provenance?.ratifiedBy), {
155
+ message: 'an agent-authored record cannot reach "accepted" without a named human ratifier',
156
+ path: ["provenance", "ratifiedBy"]
157
+ }).refine((a) => !(a.reversibility === "one-way-door" && a.review?.tier === "auto"), {
158
+ message: "one-way-door decisions may not take the auto-approve fast path",
159
+ path: ["review", "tier"]
160
+ });
161
+
162
+ // src/schema/emit.ts
163
+ import { z as z2 } from "zod";
164
+ var ADR_SCHEMA_ID = `https://adrkit.dev/schema/adr/v${SCHEMA_VERSION}/adr.schema.json`;
165
+ function sortJson(value) {
166
+ if (Array.isArray(value)) {
167
+ return value.map((item) => sortJson(item));
168
+ }
169
+ if (value && typeof value === "object") {
170
+ const input = value;
171
+ const output = {};
172
+ for (const key of Object.keys(input).sort()) {
173
+ output[key] = sortJson(input[key]);
174
+ }
175
+ return output;
176
+ }
177
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
178
+ return value;
179
+ }
180
+ throw new TypeError(`Cannot serialize JSON schema value of type ${typeof value}`);
181
+ }
182
+ function emitJsonSchema() {
183
+ const schema = z2.toJSONSchema(AdrFrontmatter, { io: "input" });
184
+ const withMetadata = {
185
+ ...schema,
186
+ $id: ADR_SCHEMA_ID,
187
+ title: "Architecture Decision Record",
188
+ description: "Typed frontmatter for a decision record. A superset of MADR: every MADR field is present or derivable, plus governance, routing, provenance, and enforcement metadata. The markdown body below the frontmatter carries the prose."
189
+ };
190
+ return sortJson(withMetadata);
191
+ }
192
+ function stringifyJsonSchema(schema = emitJsonSchema()) {
193
+ return `${JSON.stringify(schema, null, 2)}
194
+ `;
195
+ }
196
+ // src/parse/frontmatter.ts
197
+ import { parseDocument } from "yaml";
198
+
199
+ class FrontmatterError extends Error {
200
+ code;
201
+ constructor(code, message) {
202
+ super(message);
203
+ this.name = "FrontmatterError";
204
+ this.code = code;
205
+ }
206
+ }
207
+ function lineContent(source, start, end) {
208
+ const raw = source.slice(start, end);
209
+ return raw.endsWith("\r") ? raw.slice(0, -1) : raw;
210
+ }
211
+ function parseFrontmatter(source) {
212
+ const firstLineEnd = source.indexOf(`
213
+ `);
214
+ const firstLine = firstLineEnd === -1 ? source : source.slice(0, firstLineEnd);
215
+ if ((firstLine.endsWith("\r") ? firstLine.slice(0, -1) : firstLine) !== "---") {
216
+ throw new FrontmatterError("missing-frontmatter", "ADR files must start with a leading --- YAML frontmatter fence");
217
+ }
218
+ if (firstLineEnd === -1) {
219
+ throw new FrontmatterError("unterminated-frontmatter", "ADR frontmatter is missing its closing --- fence");
220
+ }
221
+ let lineStart = firstLineEnd + 1;
222
+ while (lineStart <= source.length) {
223
+ const nextNewline = source.indexOf(`
224
+ `, lineStart);
225
+ const lineEnd = nextNewline === -1 ? source.length : nextNewline;
226
+ if (lineContent(source, lineStart, lineEnd) === "---") {
227
+ const yamlSource = source.slice(firstLineEnd + 1, lineStart);
228
+ const bodyStart = nextNewline === -1 ? source.length : nextNewline + 1;
229
+ const document = parseDocument(yamlSource, { strict: true, prettyErrors: false });
230
+ if (document.errors.length > 0) {
231
+ throw new FrontmatterError("invalid-yaml", document.errors.map((error) => error.message).join("; "));
232
+ }
233
+ return { data: document.toJS(), body: source.slice(bodyStart) };
234
+ }
235
+ if (nextNewline === -1) {
236
+ break;
237
+ }
238
+ lineStart = nextNewline + 1;
239
+ }
240
+ throw new FrontmatterError("unterminated-frontmatter", "ADR frontmatter is missing its closing --- fence");
241
+ }
242
+ // src/load/corpus.ts
243
+ import { readdir, readFile, stat } from "node:fs/promises";
244
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
245
+ var RECORD_FILE_PATTERN = /^[0-9]{4,}-.+\.md$/;
246
+ var TEMPLATE_FILE_NAME = "0000-template.md";
247
+ function isRecordFileName(fileName) {
248
+ return fileName !== TEMPLATE_FILE_NAME && RECORD_FILE_PATTERN.test(fileName);
249
+ }
250
+ function normalizeDisplayPath(path, cwd = process.cwd()) {
251
+ const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
252
+ const displayPath = relative(cwd, absolutePath) || basename(absolutePath);
253
+ return displayPath.split(sep).join("/");
254
+ }
255
+ function toAbsolutePath(path, cwd = process.cwd()) {
256
+ return isAbsolute(path) ? path : resolve(cwd, path);
257
+ }
258
+ async function discoverAdrFiles(dir = "docs/adr", cwd = process.cwd()) {
259
+ const absoluteDir = toAbsolutePath(dir, cwd);
260
+ const entries = await readdir(absoluteDir, { withFileTypes: true });
261
+ return entries.filter((entry) => entry.isFile() && isRecordFileName(entry.name)).map((entry) => join(absoluteDir, entry.name)).sort((a, b) => normalizeDisplayPath(a, cwd).localeCompare(normalizeDisplayPath(b, cwd)));
262
+ }
263
+ async function expandRecordInputs(paths, dir = "docs/adr", cwd = process.cwd()) {
264
+ if (!paths || paths.length === 0) {
265
+ return discoverAdrFiles(dir, cwd);
266
+ }
267
+ const expanded = [];
268
+ for (const inputPath of paths) {
269
+ const absolutePath = toAbsolutePath(inputPath, cwd);
270
+ try {
271
+ const inputStat = await stat(absolutePath);
272
+ if (inputStat.isDirectory()) {
273
+ expanded.push(...await discoverAdrFiles(absolutePath, cwd));
274
+ } else if (inputStat.isFile()) {
275
+ expanded.push(absolutePath);
276
+ }
277
+ } catch {
278
+ expanded.push(absolutePath);
279
+ }
280
+ }
281
+ return Array.from(new Set(expanded)).sort((a, b) => normalizeDisplayPath(a, cwd).localeCompare(normalizeDisplayPath(b, cwd)));
282
+ }
283
+ async function parseAdrFile(path, cwd = process.cwd()) {
284
+ const absolutePath = toAbsolutePath(path, cwd);
285
+ const source = await readFile(absolutePath, "utf8");
286
+ const parsed = parseFrontmatter(source);
287
+ return {
288
+ ...parsed,
289
+ absolutePath,
290
+ path: normalizeDisplayPath(absolutePath, cwd)
291
+ };
292
+ }
293
+ async function loadAdrFile(path, cwd = process.cwd()) {
294
+ const parsed = await parseAdrFile(path, cwd);
295
+ return {
296
+ frontmatter: AdrFrontmatter.parse(parsed.data),
297
+ body: parsed.body,
298
+ path: parsed.path
299
+ };
300
+ }
301
+ async function loadCorpus(dir = "docs/adr", cwd = process.cwd()) {
302
+ const files = await discoverAdrFiles(dir, cwd);
303
+ const records = await Promise.all(files.map((file) => loadAdrFile(file, cwd)));
304
+ const byId = new Map;
305
+ for (const record of records) {
306
+ byId.set(record.frontmatter.id, record);
307
+ }
308
+ return { records, byId };
309
+ }
310
+ // src/validate/findings.ts
311
+ var IMPORT_FINDING_RULES = [
312
+ "import-incomplete",
313
+ "import-status-unrecognized",
314
+ "import-not-madr"
315
+ ];
316
+ function compareOptional(a, b) {
317
+ return (a ?? "").localeCompare(b ?? "");
318
+ }
319
+ function sortFindings(findings) {
320
+ return [...findings].sort((a, b) => a.rule.localeCompare(b.rule) || compareOptional(a.id, b.id) || compareOptional(a.pattern, b.pattern) || compareOptional(a.path, b.path) || compareOptional(a.field, b.field) || a.message.localeCompare(b.message));
321
+ }
322
+ function countFindings(findings) {
323
+ let errors = 0;
324
+ let warnings = 0;
325
+ let infos = 0;
326
+ for (const finding of findings) {
327
+ if (finding.severity === "error")
328
+ errors += 1;
329
+ if (finding.severity === "warn")
330
+ warnings += 1;
331
+ if (finding.severity === "info")
332
+ infos += 1;
333
+ }
334
+ return { errors, warnings, infos };
335
+ }
336
+ function exitCodeForFindings(findings) {
337
+ return findings.some((finding) => finding.severity === "error") ? 1 : 0;
338
+ }
339
+ // src/validate/contract.ts
340
+ function fieldPath(path) {
341
+ return path.length === 0 ? undefined : path.map(String).join(".");
342
+ }
343
+ function rawId(data) {
344
+ if (data && typeof data === "object" && "id" in data) {
345
+ const id = data.id;
346
+ return typeof id === "string" ? id : undefined;
347
+ }
348
+ return;
349
+ }
350
+ function ruleForIssue(issue) {
351
+ if (issue.code === "unrecognized_keys") {
352
+ return "strict-unknown-key";
353
+ }
354
+ if (issue.code === "custom") {
355
+ if (issue.message === 'status "superseded" requires supersededBy') {
356
+ return "superseded-requires-supersededBy";
357
+ }
358
+ if (issue.message === 'supersededBy is set but status is not "superseded"') {
359
+ return "supersededBy-requires-superseded-status";
360
+ }
361
+ if (issue.message === "an accepted decision must name at least one decider, unless it was imported") {
362
+ return "accepted-requires-decider-unless-imported";
363
+ }
364
+ if (issue.message === 'an agent-authored record cannot reach "accepted" without a named human ratifier') {
365
+ return "agent-accepted-requires-ratifier";
366
+ }
367
+ if (issue.message === "one-way-door decisions may not take the auto-approve fast path") {
368
+ return "one-way-door-disallows-auto";
369
+ }
370
+ if (issue.message === "Items must be unique") {
371
+ return "unique-items";
372
+ }
373
+ return "contract-refinement";
374
+ }
375
+ if (issue.code === "invalid_type") {
376
+ return issue.message.includes("undefined") ? "required-field" : "invalid-type";
377
+ }
378
+ if (issue.code === "invalid_value") {
379
+ return "invalid-enum-value";
380
+ }
381
+ if (issue.code === "invalid_format") {
382
+ return "invalid-format";
383
+ }
384
+ if (issue.code === "too_small" || issue.code === "too_big") {
385
+ return "invalid-size";
386
+ }
387
+ return `contract-${issue.code}`;
388
+ }
389
+ function issueFindings(issue, path, id) {
390
+ if (issue.code === "unrecognized_keys") {
391
+ return issue.keys.map((key) => {
392
+ const field = fieldPath([...issue.path, key]) ?? key;
393
+ return {
394
+ rule: "strict-unknown-key",
395
+ severity: "error",
396
+ message: `Unknown frontmatter field "${field}" is not allowed`,
397
+ path,
398
+ id,
399
+ field
400
+ };
401
+ });
402
+ }
403
+ return [
404
+ {
405
+ rule: ruleForIssue(issue),
406
+ severity: "error",
407
+ message: issue.message,
408
+ path,
409
+ id,
410
+ field: fieldPath(issue.path)
411
+ }
412
+ ];
413
+ }
414
+ function validateParsedAdr(parsed) {
415
+ const result = AdrFrontmatter.safeParse(parsed.data);
416
+ if (result.success) {
417
+ return {
418
+ findings: [],
419
+ record: {
420
+ frontmatter: result.data,
421
+ body: parsed.body,
422
+ path: parsed.path
423
+ }
424
+ };
425
+ }
426
+ const id = rawId(parsed.data);
427
+ return {
428
+ findings: result.error.issues.flatMap((issue) => issueFindings(issue, parsed.path, id))
429
+ };
430
+ }
431
+ function validateAdrFrontmatter(data, path) {
432
+ return validateParsedAdr({ data, body: "", path, absolutePath: path });
433
+ }
434
+ // src/validate/corpus-invariants.ts
435
+ function addReferenceFinding(findings, record, field, ref, severity) {
436
+ findings.push({
437
+ rule: `dangling-${field}`,
438
+ severity,
439
+ message: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`,
440
+ path: record.path,
441
+ id: record.frontmatter.id,
442
+ field
443
+ });
444
+ }
445
+ function validateCorpusInvariants(records) {
446
+ const findings = [];
447
+ const byId = new Map;
448
+ for (const record of records) {
449
+ const recordsForId = byId.get(record.frontmatter.id) ?? [];
450
+ recordsForId.push(record);
451
+ byId.set(record.frontmatter.id, recordsForId);
452
+ }
453
+ for (const [id, duplicates] of byId) {
454
+ if (duplicates.length <= 1)
455
+ continue;
456
+ const paths = duplicates.map((record) => record.path).sort();
457
+ for (const record of duplicates) {
458
+ findings.push({
459
+ rule: "unique-id",
460
+ severity: "error",
461
+ message: `ADR id "${id}" is used by multiple records: ${paths.join(", ")}`,
462
+ path: record.path,
463
+ id,
464
+ field: "id"
465
+ });
466
+ }
467
+ }
468
+ const ids = new Set(byId.keys());
469
+ for (const record of records) {
470
+ for (const ref of record.frontmatter.supersedes) {
471
+ if (!ids.has(ref))
472
+ addReferenceFinding(findings, record, "supersedes", ref, "error");
473
+ }
474
+ if (record.frontmatter.supersededBy && !ids.has(record.frontmatter.supersededBy)) {
475
+ addReferenceFinding(findings, record, "supersededBy", record.frontmatter.supersededBy, "error");
476
+ }
477
+ for (const ref of record.frontmatter.relatesTo) {
478
+ if (!ids.has(ref))
479
+ addReferenceFinding(findings, record, "relatesTo", ref, "error");
480
+ }
481
+ for (const ref of record.frontmatter.conflictsWith) {
482
+ if (!ids.has(ref)) {
483
+ addReferenceFinding(findings, record, "conflictsWith", ref, "warn");
484
+ } else if (record.frontmatter.status === "accepted") {
485
+ findings.push({
486
+ rule: "accepted-conflictsWith",
487
+ severity: "warn",
488
+ message: `Accepted ADR declares a known conflict with "${ref}"`,
489
+ path: record.path,
490
+ id: record.frontmatter.id,
491
+ field: "conflictsWith"
492
+ });
493
+ }
494
+ }
495
+ }
496
+ return findings;
497
+ }
498
+ // src/validate/import-incomplete.ts
499
+ function validateImportIncomplete(records) {
500
+ const findings = [];
501
+ for (const record of records) {
502
+ if (!record.frontmatter.provenance?.importedFrom)
503
+ continue;
504
+ if (record.frontmatter.status === "accepted" && record.frontmatter.deciders.length === 0) {
505
+ findings.push({
506
+ rule: "import-incomplete",
507
+ severity: "info",
508
+ message: "Imported accepted decision has no deciders; provenance explains the gap, but it should be backfilled when known",
509
+ path: record.path,
510
+ id: record.frontmatter.id,
511
+ field: "deciders"
512
+ });
513
+ }
514
+ }
515
+ return findings;
516
+ }
517
+ // src/validate/index.ts
518
+ function parseErrorFinding(error, path) {
519
+ if (error instanceof FrontmatterError) {
520
+ return {
521
+ rule: error.code === "invalid-yaml" ? "frontmatter-parse" : "frontmatter-fence",
522
+ severity: "error",
523
+ message: error.message,
524
+ path,
525
+ field: "frontmatter"
526
+ };
527
+ }
528
+ return {
529
+ rule: "file-read",
530
+ severity: "error",
531
+ message: error instanceof Error ? error.message : String(error),
532
+ path
533
+ };
534
+ }
535
+ async function lintCorpus(options = {}) {
536
+ const cwd = options.cwd ?? process.cwd();
537
+ const files = await expandRecordInputs(options.paths, options.dir ?? "docs/adr", cwd);
538
+ const records = [];
539
+ const findings = [];
540
+ for (const file of files) {
541
+ const displayPath = normalizeDisplayPath(file, cwd);
542
+ try {
543
+ const parsed = await parseAdrFile(file, cwd);
544
+ const result = validateParsedAdr(parsed);
545
+ findings.push(...result.findings);
546
+ if (result.record) {
547
+ records.push(result.record);
548
+ }
549
+ } catch (error) {
550
+ findings.push(parseErrorFinding(error, displayPath));
551
+ }
552
+ }
553
+ findings.push(...validateImportIncomplete(records));
554
+ findings.push(...validateCorpusInvariants(records));
555
+ return {
556
+ checked: files.length,
557
+ findings: sortFindings(findings),
558
+ records: [...records].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id))
559
+ };
560
+ }
561
+ // src/scaffold/new.ts
562
+ import { mkdir, writeFile } from "node:fs/promises";
563
+ import { basename as basename2, dirname, resolve as resolve2 } from "node:path";
564
+ class ScaffoldError extends Error {
565
+ code;
566
+ constructor(code, message) {
567
+ super(message);
568
+ this.name = "ScaffoldError";
569
+ this.code = code;
570
+ }
571
+ }
572
+ function todayIsoDate() {
573
+ return new Date().toISOString().slice(0, 10);
574
+ }
575
+ function slugifyTitle(title) {
576
+ const slug = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
577
+ if (!slug) {
578
+ throw new ScaffoldError("usage", "Title must contain at least one ASCII letter or digit");
579
+ }
580
+ return slug.slice(0, 80).replace(/-+$/g, "");
581
+ }
582
+ function statusForInput(input) {
583
+ const status = input ?? "draft";
584
+ const parsed = Status.safeParse(status);
585
+ if (!parsed.success) {
586
+ throw new ScaffoldError("usage", `Invalid status "${status}"`);
587
+ }
588
+ if (status === "accepted" || status === "superseded") {
589
+ throw new ScaffoldError("usage", `adr new cannot scaffold status "${status}" without additional required fields`);
590
+ }
591
+ return status;
592
+ }
593
+ async function nextSequentialId(dir, cwd) {
594
+ const files = await discoverAdrFiles(dir, cwd).catch(() => []);
595
+ let max = 0;
596
+ for (const file of files) {
597
+ const match = /^([0-9]+)-/.exec(basename2(file));
598
+ if (match?.[1]) {
599
+ max = Math.max(max, Number(match[1]));
600
+ }
601
+ }
602
+ return String(max + 1).padStart(4, "0");
603
+ }
604
+ function renderAdrRecord(options) {
605
+ const frontmatter = {
606
+ schemaVersion: SCHEMA_VERSION,
607
+ id: options.id,
608
+ title: options.title,
609
+ status: options.status,
610
+ date: options.date,
611
+ deciders: [],
612
+ tags: [],
613
+ scope: "component",
614
+ reversibility: "unknown",
615
+ blastRadius: "component",
616
+ relatesTo: [],
617
+ affects: [],
618
+ provenance: {
619
+ authoredBy: "human"
620
+ }
621
+ };
622
+ const validation = AdrFrontmatter.safeParse(frontmatter);
623
+ if (!validation.success) {
624
+ throw new ScaffoldError("usage", validation.error.issues.map((issue) => issue.message).join("; "));
625
+ }
626
+ return `---
627
+ schemaVersion: ${frontmatter.schemaVersion}
628
+ id: "${frontmatter.id}"
629
+ title: ${JSON.stringify(frontmatter.title)}
630
+ status: ${frontmatter.status}
631
+ date: ${frontmatter.date}
632
+ deciders: []
633
+ tags: []
634
+ scope: ${frontmatter.scope}
635
+ reversibility: ${frontmatter.reversibility}
636
+ blastRadius: ${frontmatter.blastRadius}
637
+ relatesTo: []
638
+ affects: []
639
+ provenance:
640
+ authoredBy: human
641
+ ---
642
+
643
+ # ADR-${frontmatter.id}: ${frontmatter.title}
644
+
645
+ ## Context
646
+
647
+ What forces are at play? What makes this a decision rather than a preference?
648
+ Why now — what changed?
649
+
650
+ State the problem, not the solution. If this section is a restatement of the
651
+ option you already picked, the record scores 2 at best on D1.
652
+
653
+ ## Decision
654
+
655
+ What we are doing, in the active voice. "We will…"
656
+
657
+ ## Options considered
658
+
659
+ At least two genuine alternatives, including doing nothing. An option no
660
+ competent engineer would choose is a straw man and scores zero.
661
+
662
+ ### Option A: <chosen>
663
+
664
+ | Dimension | Assessment |
665
+ |---|---|
666
+ | | |
667
+
668
+ ### Option B: <alternative>
669
+
670
+ **Pros:**
671
+ **Cons:**
672
+
673
+ ### Option C: Do nothing
674
+
675
+ ## Trade-offs
676
+
677
+ What the chosen option costs. State the downsides as plainly as the benefits —
678
+ a decision whose chosen option has no listed downsides is not a decision.
679
+
680
+ ## Consequences
681
+
682
+ - Easier:
683
+ - Harder:
684
+ - **How we would know this was wrong:** a metric, a threshold, an exit
685
+ condition, or a review date. This is the field that most separates records
686
+ that stay alive from records that rot.
687
+ - Revisit if:
688
+
689
+ ## Action items
690
+
691
+ 1. [ ]
692
+ `;
693
+ }
694
+ async function createAdr(options) {
695
+ const cwd = options.cwd ?? process.cwd();
696
+ const dir = options.dir ?? "docs/adr";
697
+ const title = options.title.trim();
698
+ const status = statusForInput(options.status);
699
+ if (title.length < 3 || title.length > 120) {
700
+ throw new ScaffoldError("usage", "Title must be between 3 and 120 characters");
701
+ }
702
+ if (/[\r\n]/.test(title)) {
703
+ throw new ScaffoldError("usage", "Title must fit on a single line");
704
+ }
705
+ const id = await nextSequentialId(dir, cwd);
706
+ const fileName = `${id}-${slugifyTitle(title)}.md`;
707
+ const path = resolve2(cwd, dir, fileName);
708
+ const displayPath = normalizeDisplayPath(path, cwd);
709
+ const content = renderAdrRecord({ id, title, status, date: options.date ?? todayIsoDate() });
710
+ if (options.write !== false) {
711
+ await mkdir(dirname(path), { recursive: true });
712
+ try {
713
+ await writeFile(path, content, { encoding: "utf8", flag: "wx" });
714
+ } catch (error) {
715
+ if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
716
+ throw new ScaffoldError("exists", `Refusing to overwrite existing ADR file ${displayPath}`);
717
+ }
718
+ throw error;
719
+ }
720
+ }
721
+ return { id, path: displayPath, content };
722
+ }
723
+ // src/graph/build.ts
724
+ function edgeKey(edge) {
725
+ return `${edge.from}\x00${edge.to}\x00${edge.kind}`;
726
+ }
727
+ function pushEdge(edges, ids, edge) {
728
+ if (!ids.has(edge.from) || !ids.has(edge.to))
729
+ return;
730
+ edges.set(edgeKey(edge), edge);
731
+ }
732
+ function buildAdrGraph(records) {
733
+ const sortedRecords = [...records].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id));
734
+ const ids = new Set(sortedRecords.map((record) => record.frontmatter.id));
735
+ const edges = new Map;
736
+ for (const record of sortedRecords) {
737
+ for (const superseded of record.frontmatter.supersedes) {
738
+ pushEdge(edges, ids, { from: record.frontmatter.id, to: superseded, kind: "supersedes" });
739
+ }
740
+ if (record.frontmatter.supersededBy) {
741
+ pushEdge(edges, ids, {
742
+ from: record.frontmatter.supersededBy,
743
+ to: record.frontmatter.id,
744
+ kind: "supersedes"
745
+ });
746
+ }
747
+ for (const related of record.frontmatter.relatesTo) {
748
+ pushEdge(edges, ids, { from: record.frontmatter.id, to: related, kind: "relatesTo" });
749
+ }
750
+ for (const conflict of record.frontmatter.conflictsWith) {
751
+ pushEdge(edges, ids, { from: record.frontmatter.id, to: conflict, kind: "conflictsWith" });
752
+ }
753
+ }
754
+ return {
755
+ nodes: sortedRecords.map((record) => ({
756
+ id: record.frontmatter.id,
757
+ title: record.frontmatter.title,
758
+ status: record.frontmatter.status
759
+ })),
760
+ edges: [...edges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind))
761
+ };
762
+ }
763
+ function dotString(value) {
764
+ return `"${value.replace(/\\/g, "\\\\").replace(/\r/g, "\\n").replace(/\n/g, "\\n").replace(/"/g, "\\\"")}"`;
765
+ }
766
+ function renderDotGraph(graph) {
767
+ const lines = ["digraph adr {", " rankdir=LR;"];
768
+ for (const node of graph.nodes) {
769
+ lines.push(` ${dotString(node.id)} [label=${dotString(`${node.id}: ${node.title}`)}, status=${dotString(node.status)}];`);
770
+ }
771
+ for (const edge of graph.edges) {
772
+ lines.push(` ${dotString(edge.from)} -> ${dotString(edge.to)} [label=${dotString(edge.kind)}];`);
773
+ }
774
+ lines.push("}");
775
+ return `${lines.join(`
776
+ `)}
777
+ `;
778
+ }
779
+ function renderJsonGraph(graph) {
780
+ return `${JSON.stringify(graph, null, 2)}
781
+ `;
782
+ }
783
+ // src/affects/inert.ts
784
+ import picomatch from "picomatch";
785
+ function compilePattern(pattern) {
786
+ return picomatch(pattern, { dot: false, nocase: false, nonegate: true });
787
+ }
788
+ function resolveEntityIds(pattern, catalog) {
789
+ const ids = new Set;
790
+ const isMatch = compilePattern(pattern);
791
+ for (const entity of catalog.entities) {
792
+ const refs = [entity.id, ...entity.refs ?? []];
793
+ if (refs.some((ref) => isMatch(ref))) {
794
+ ids.add(entity.id);
795
+ }
796
+ }
797
+ return ids;
798
+ }
799
+ function entitiesForPaths(changedFiles, catalog) {
800
+ const ids = new Set;
801
+ for (const entity of catalog.entities) {
802
+ for (const entityPath of entity.paths ?? []) {
803
+ const isMatch = compilePattern(entityPath);
804
+ if (changedFiles.some((path) => isMatch(path))) {
805
+ ids.add(entity.id);
806
+ break;
807
+ }
808
+ }
809
+ }
810
+ return ids;
811
+ }
812
+ function matchEntityPattern(pattern, changedFiles, catalog) {
813
+ if (!catalog) {
814
+ return { matched: false, unresolvable: true };
815
+ }
816
+ const entityIds = resolveEntityIds(pattern, catalog);
817
+ if (entityIds.size === 0) {
818
+ return { matched: false };
819
+ }
820
+ const changedEntityIds = entitiesForPaths(changedFiles, catalog);
821
+ return {
822
+ matched: [...entityIds].some((id) => changedEntityIds.has(id))
823
+ };
824
+ }
825
+ function isAlwaysInertType(type) {
826
+ return type === "resource" || type === "api" || type === "data";
827
+ }
828
+
829
+ // src/affects/matchers/package.ts
830
+ import semver from "semver";
831
+ function parsePackagePattern(pattern) {
832
+ const splitAt = pattern.lastIndexOf("@");
833
+ const hasRange = splitAt > 0;
834
+ const name = hasRange ? pattern.slice(0, splitAt) : pattern;
835
+ const range = hasRange ? pattern.slice(splitAt + 1).trim() : undefined;
836
+ if (!name.trim() || hasRange && !range) {
837
+ return;
838
+ }
839
+ if (range && !semver.validRange(range)) {
840
+ return;
841
+ }
842
+ return range ? { name, range } : { name };
843
+ }
844
+ function matchPackagePattern(pattern, changedDependencies) {
845
+ const parsed = parsePackagePattern(pattern);
846
+ if (!parsed) {
847
+ return { matched: false, badPattern: true };
848
+ }
849
+ if (!changedDependencies) {
850
+ return { matched: false, unresolvable: true };
851
+ }
852
+ return {
853
+ matched: changedDependencies.some((dependency) => dependency.name === parsed.name && (!parsed.range || semver.satisfies(dependency.version, parsed.range)))
854
+ };
855
+ }
856
+ function parseBunLockPackageLine(line) {
857
+ const match = line.match(/^\s*"([^"]+)":\s*\["([^"]+)"/);
858
+ if (!match)
859
+ return;
860
+ const [, name, descriptor] = match;
861
+ if (!name || !descriptor?.startsWith(`${name}@`))
862
+ return;
863
+ const version = descriptor.slice(name.length + 1);
864
+ if (!semver.valid(version))
865
+ return;
866
+ return { name, version };
867
+ }
868
+ function deriveChangedDependenciesFromBunLockDiff(diff) {
869
+ const added = new Map;
870
+ const removed = new Map;
871
+ for (const line of diff.split(/\r?\n/)) {
872
+ if (line.startsWith("+++") || line.startsWith("---"))
873
+ continue;
874
+ const bucket = line.startsWith("+") ? added : line.startsWith("-") ? removed : undefined;
875
+ if (!bucket)
876
+ continue;
877
+ const dependency = parseBunLockPackageLine(line.slice(1));
878
+ if (!dependency)
879
+ continue;
880
+ let versions = bucket.get(dependency.name);
881
+ if (!versions) {
882
+ versions = new Set;
883
+ bucket.set(dependency.name, versions);
884
+ }
885
+ versions.add(dependency.version);
886
+ }
887
+ const changedNames = new Set([...added.keys(), ...removed.keys()]);
888
+ return [...changedNames].flatMap((name) => {
889
+ const versions = new Set([...added.get(name) ?? [], ...removed.get(name) ?? []]);
890
+ return [...versions].map((version) => ({ name, version }));
891
+ }).sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
892
+ }
893
+
894
+ // src/affects/matchers/path.ts
895
+ import picomatch2 from "picomatch";
896
+ function normalizeCandidate(path) {
897
+ return path.replace(/\\/g, "/").replace(/^\.\//, "");
898
+ }
899
+ function hasDotSegment(path) {
900
+ return path.split("/").some((segment) => segment.startsWith(".") && segment !== "." && segment !== "..");
901
+ }
902
+ function patternAllowsDotSegment(pattern) {
903
+ return pattern.split("/").some((segment) => segment.startsWith(".") && segment !== "." && segment !== "..");
904
+ }
905
+ function matchPathPattern(pattern, changedFiles) {
906
+ if (pattern.startsWith("/")) {
907
+ return { matched: false, badPattern: "leading-slash" };
908
+ }
909
+ try {
910
+ const isMatch = picomatch2(pattern, { dot: false, nocase: false, nonegate: true });
911
+ const allowsDot = patternAllowsDotSegment(pattern);
912
+ return {
913
+ matched: changedFiles.some((path) => {
914
+ const candidate = normalizeCandidate(path);
915
+ return (!hasDotSegment(candidate) || allowsDot) && isMatch(candidate);
916
+ })
917
+ };
918
+ } catch {
919
+ return { matched: false, badPattern: "invalid-glob" };
920
+ }
921
+ }
922
+
923
+ // src/affects/index.ts
924
+ function compareFiredMatcher(a, b) {
925
+ return a.type.localeCompare(b.type) || a.pattern.localeCompare(b.pattern);
926
+ }
927
+ function uniqueSortedFiredMatchers(matchers) {
928
+ const byKey = new Map;
929
+ for (const matcher of matchers) {
930
+ byKey.set(`${matcher.type}\x00${matcher.pattern}`, matcher);
931
+ }
932
+ return [...byKey.values()].sort(compareFiredMatcher);
933
+ }
934
+ function affectsFinding(record, matcher, rule, severity, message) {
935
+ return {
936
+ rule,
937
+ severity,
938
+ message,
939
+ id: record.frontmatter.id,
940
+ path: record.path,
941
+ field: `affects.${matcher.type}`,
942
+ pattern: matcher.pattern
943
+ };
944
+ }
945
+ function matcherAppliesToLog(matcher, log) {
946
+ return !matcher.repo || matcher.repo === log;
947
+ }
948
+ function evaluateMatcher(record, matcher, changedFiles, snapshots) {
949
+ if (matcher.type === "path") {
950
+ const result = matchPathPattern(matcher.pattern, changedFiles);
951
+ if (result.badPattern) {
952
+ return {
953
+ matched: false,
954
+ findings: [
955
+ affectsFinding(record, matcher, "affects-bad-pattern", "warn", result.badPattern === "leading-slash" ? `Path matcher "${matcher.pattern}" must be repo-relative and must not start with "/".` : `Path matcher "${matcher.pattern}" is not a valid glob pattern.`)
956
+ ]
957
+ };
958
+ }
959
+ return { matched: result.matched, findings: [] };
960
+ }
961
+ if (matcher.type === "package") {
962
+ const result = matchPackagePattern(matcher.pattern, snapshots?.changedDependencies);
963
+ if (result.unresolvable) {
964
+ return {
965
+ matched: false,
966
+ findings: [
967
+ affectsFinding(record, matcher, "affects-unresolvable", "info", `Package matcher "${matcher.pattern}" requires a changed-dependency snapshot and is inert.`)
968
+ ]
969
+ };
970
+ }
971
+ if (result.badPattern) {
972
+ return {
973
+ matched: false,
974
+ findings: [
975
+ affectsFinding(record, matcher, "affects-bad-pattern", "warn", `Package matcher "${matcher.pattern}" must be "name" or "name@<valid semver range>".`)
976
+ ]
977
+ };
978
+ }
979
+ return { matched: result.matched, findings: [] };
980
+ }
981
+ if (matcher.type === "entity") {
982
+ const result = matchEntityPattern(matcher.pattern, changedFiles, snapshots?.catalog);
983
+ if (result.unresolvable) {
984
+ return {
985
+ matched: false,
986
+ findings: [
987
+ affectsFinding(record, matcher, "affects-unresolvable", "info", `Entity matcher "${matcher.pattern}" has no catalog snapshot and is inert.`)
988
+ ]
989
+ };
990
+ }
991
+ return { matched: result.matched, findings: [] };
992
+ }
993
+ if (isAlwaysInertType(matcher.type)) {
994
+ return {
995
+ matched: false,
996
+ findings: [
997
+ affectsFinding(record, matcher, "affects-unresolvable", "info", `${matcher.type} matcher "${matcher.pattern}" has no backing snapshot in this phase and is inert.`)
998
+ ]
999
+ };
1000
+ }
1001
+ return {
1002
+ matched: false,
1003
+ findings: [
1004
+ affectsFinding(record, matcher, "affects-unknown-type", "warn", `Affects matcher type "${matcher.type}" is not recognized by this adrkit version and was ignored.`)
1005
+ ]
1006
+ };
1007
+ }
1008
+ function resolveAffects(input) {
1009
+ const matches = [];
1010
+ const findings = [];
1011
+ for (const record of input.records) {
1012
+ const firedMatchers = [];
1013
+ let suppressed = false;
1014
+ for (const matcher of record.frontmatter.affects ?? []) {
1015
+ if (!matcherAppliesToLog(matcher, input.log))
1016
+ continue;
1017
+ const result = evaluateMatcher(record, matcher, input.changedFiles, input.snapshots);
1018
+ findings.push(...result.findings);
1019
+ if (!result.matched)
1020
+ continue;
1021
+ if (matcher.negate) {
1022
+ suppressed = true;
1023
+ } else {
1024
+ firedMatchers.push({ type: matcher.type, pattern: matcher.pattern });
1025
+ }
1026
+ }
1027
+ if (!suppressed && firedMatchers.length > 0) {
1028
+ matches.push({
1029
+ recordId: record.frontmatter.id,
1030
+ firedMatchers: uniqueSortedFiredMatchers(firedMatchers)
1031
+ });
1032
+ }
1033
+ }
1034
+ return {
1035
+ matches: matches.sort((a, b) => a.recordId.localeCompare(b.recordId)),
1036
+ findings: sortFindings(findings)
1037
+ };
1038
+ }
1039
+ // src/check/index.ts
1040
+ var RECORD_BASENAME = /^\d{4,}-.+\.md$/;
1041
+ var TEMPLATE_BASENAME = "0000-template.md";
1042
+ function normalizeDir(dir) {
1043
+ const forward = (dir ?? "docs/adr").replace(/\\/g, "/");
1044
+ let end = forward.length;
1045
+ while (end > 0 && forward.charCodeAt(end - 1) === 47)
1046
+ end -= 1;
1047
+ const stripped = forward.slice(0, end);
1048
+ return stripped === "." ? "" : stripped;
1049
+ }
1050
+ function toForwardSlash(path) {
1051
+ return path.replace(/\\/g, "/");
1052
+ }
1053
+ function isCorpusRecordPath(file, dir) {
1054
+ const prefix = dir ? `${dir}/` : "";
1055
+ if (!file.startsWith(prefix))
1056
+ return false;
1057
+ const rest = file.slice(prefix.length);
1058
+ if (rest.length === 0 || rest.includes("/"))
1059
+ return false;
1060
+ return rest !== TEMPLATE_BASENAME && RECORD_BASENAME.test(rest);
1061
+ }
1062
+ function uniqueSorted(values) {
1063
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
1064
+ }
1065
+ function checkChanges(input) {
1066
+ const dir = normalizeDir(input.dir);
1067
+ const changedFiles = uniqueSorted(input.changedFiles.map(toForwardSlash));
1068
+ const changedRecords = changedFiles.filter((file) => isCorpusRecordPath(file, dir));
1069
+ const changedRecordSet = new Set(changedRecords);
1070
+ const resolution = resolveAffects({
1071
+ records: input.lint.records,
1072
+ changedFiles,
1073
+ snapshots: input.snapshots,
1074
+ log: input.log
1075
+ });
1076
+ const titleById = new Map(input.lint.records.map((record) => [record.frontmatter.id, record.frontmatter.title]));
1077
+ const governedBy = resolution.matches.map((match) => ({
1078
+ recordId: match.recordId,
1079
+ title: titleById.get(match.recordId) ?? "",
1080
+ firedMatchers: match.firedMatchers
1081
+ }));
1082
+ const changedRecordFindings = input.lint.findings.filter((finding) => finding.path !== undefined && changedRecordSet.has(finding.path));
1083
+ const findings = sortFindings([...resolution.findings, ...changedRecordFindings]);
1084
+ const ok = !changedRecordFindings.some((finding) => finding.severity === "error");
1085
+ return { changedFiles, governedBy, changedRecords, findings, ok };
1086
+ }
1087
+ // src/import/index.ts
1088
+ import { readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
1089
+ import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
1090
+
1091
+ // src/import/classify.ts
1092
+ function importedMadrSourceRef(record) {
1093
+ const importedFrom = record.frontmatter.provenance?.importedFrom;
1094
+ if (importedFrom?.sourceKind !== "madr")
1095
+ return;
1096
+ return importedFrom.sourceRef;
1097
+ }
1098
+ function classifyReimport(sourceEntries, existingRecords, recordEdited = () => false) {
1099
+ const recordsBySourceRef = new Map;
1100
+ for (const record of [...existingRecords].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id))) {
1101
+ const sourceRef = importedMadrSourceRef(record);
1102
+ if (sourceRef && !recordsBySourceRef.has(sourceRef)) {
1103
+ recordsBySourceRef.set(sourceRef, record);
1104
+ }
1105
+ }
1106
+ return [...sourceEntries].sort((a, b) => a.sourceRef.localeCompare(b.sourceRef)).map((entry) => {
1107
+ const record = recordsBySourceRef.get(entry.sourceRef);
1108
+ if (!record)
1109
+ return { sourceRef: entry.sourceRef, bucket: "new" };
1110
+ const storedFingerprint = record.frontmatter.provenance?.importedFrom?.fingerprint;
1111
+ if (storedFingerprint === entry.fingerprint) {
1112
+ return { sourceRef: entry.sourceRef, bucket: "unchanged", recordId: record.frontmatter.id };
1113
+ }
1114
+ return {
1115
+ sourceRef: entry.sourceRef,
1116
+ bucket: recordEdited(record.frontmatter.id) ? "diverged" : "updated",
1117
+ recordId: record.frontmatter.id
1118
+ };
1119
+ });
1120
+ }
1121
+
1122
+ // src/import/fingerprint.ts
1123
+ import { createHash } from "node:crypto";
1124
+ function normalizeSourceBody(sourceBody) {
1125
+ const normalizedLines = sourceBody.replace(/\r\n/g, `
1126
+ `).replace(/\r/g, `
1127
+ `);
1128
+ return `${normalizedLines.replace(/\n*$/g, "")}
1129
+ `;
1130
+ }
1131
+ function fingerprintSourceBody(sourceBody) {
1132
+ return createHash("sha256").update(normalizeSourceBody(sourceBody), "utf8").digest("hex");
1133
+ }
1134
+
1135
+ // src/import/madr.ts
1136
+ import { readdir as readdir2, readFile as readFile2 } from "node:fs/promises";
1137
+ import { basename as basename3, isAbsolute as isAbsolute2, join as join2, resolve as resolve3 } from "node:path";
1138
+ function isPlainRecord(value) {
1139
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1140
+ }
1141
+ function firstLine(source) {
1142
+ const lineEnd = source.indexOf(`
1143
+ `);
1144
+ const line = lineEnd === -1 ? source : source.slice(0, lineEnd);
1145
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
1146
+ }
1147
+ function extractMadrTitle(frontmatter, body) {
1148
+ const frontmatterTitle = frontmatter.title;
1149
+ if (typeof frontmatterTitle === "string" && frontmatterTitle.trim().length > 0) {
1150
+ return frontmatterTitle.trim();
1151
+ }
1152
+ const heading = /^#\s+(.+?)\s*#*\s*$/m.exec(body);
1153
+ const title = heading?.[1]?.trim();
1154
+ return title && title.length > 0 ? title : undefined;
1155
+ }
1156
+ function notMadr(path, absolutePath, reason) {
1157
+ return { kind: "not-madr", path, absolutePath, reason };
1158
+ }
1159
+ async function readMadrFile(path, cwd = process.cwd()) {
1160
+ const absolutePath = isAbsolute2(path) ? path : resolve3(cwd, path);
1161
+ const displayPath = normalizeDisplayPath(absolutePath, cwd);
1162
+ const source = await readFile2(absolutePath, "utf8");
1163
+ const hasLeadingFence = firstLine(source) === "---";
1164
+ if (hasLeadingFence) {
1165
+ try {
1166
+ const parsed = parseFrontmatter(source);
1167
+ const frontmatter = isPlainRecord(parsed.data) ? parsed.data : {};
1168
+ return {
1169
+ kind: "madr",
1170
+ path: displayPath,
1171
+ absolutePath,
1172
+ frontmatter,
1173
+ body: parsed.body,
1174
+ source,
1175
+ title: extractMadrTitle(frontmatter, parsed.body)
1176
+ };
1177
+ } catch (error) {
1178
+ const reason = error instanceof FrontmatterError ? error.message : String(error);
1179
+ return notMadr(displayPath, absolutePath, reason);
1180
+ }
1181
+ }
1182
+ const title = extractMadrTitle({}, source);
1183
+ if (!title) {
1184
+ return notMadr(displayPath, absolutePath, "File has no leading YAML frontmatter or top-level title");
1185
+ }
1186
+ return {
1187
+ kind: "madr",
1188
+ path: displayPath,
1189
+ absolutePath,
1190
+ frontmatter: {},
1191
+ body: source,
1192
+ source,
1193
+ title
1194
+ };
1195
+ }
1196
+ async function discoverMarkdownFilesInDir(dir) {
1197
+ const entries = await readdir2(dir, { withFileTypes: true });
1198
+ const files = [];
1199
+ for (const entry of entries) {
1200
+ const path = join2(dir, entry.name);
1201
+ if (entry.isDirectory()) {
1202
+ files.push(...await discoverMarkdownFilesInDir(path));
1203
+ } else if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "0000-template.md") {
1204
+ files.push(path);
1205
+ }
1206
+ }
1207
+ return files;
1208
+ }
1209
+ async function discoverMadrCandidateFiles(dir = "docs/adr", cwd = process.cwd()) {
1210
+ const absoluteDir = isAbsolute2(dir) ? dir : resolve3(cwd, dir);
1211
+ const files = await discoverMarkdownFilesInDir(absoluteDir).catch(() => []);
1212
+ return files.sort((a, b) => normalizeDisplayPath(a, cwd).localeCompare(normalizeDisplayPath(b, cwd)));
1213
+ }
1214
+ function fileNameId(path) {
1215
+ return /^([0-9]{4,})-/.exec(basename3(path))?.[1];
1216
+ }
1217
+
1218
+ // src/import/merge.ts
1219
+ import { stringify } from "yaml";
1220
+
1221
+ // src/import/status.ts
1222
+ var MADR_RECOGNIZED_STATUSES = [
1223
+ "draft",
1224
+ "proposed",
1225
+ "accepted",
1226
+ "rejected",
1227
+ "superseded",
1228
+ "deprecated"
1229
+ ];
1230
+ var STATUS_SET = new Set(MADR_RECOGNIZED_STATUSES);
1231
+ function rawStatusText(status) {
1232
+ if (typeof status !== "string")
1233
+ return;
1234
+ const trimmed = status.trim();
1235
+ return trimmed.length > 0 ? trimmed : undefined;
1236
+ }
1237
+ function mapMadrStatus(status, context) {
1238
+ const raw = rawStatusText(status);
1239
+ const normalized = raw?.toLowerCase();
1240
+ if (normalized && STATUS_SET.has(normalized)) {
1241
+ return { status: normalized, findings: [] };
1242
+ }
1243
+ return {
1244
+ status: "proposed",
1245
+ findings: [
1246
+ {
1247
+ rule: "import-status-unrecognized",
1248
+ severity: "warn",
1249
+ message: raw ? `MADR status "${raw}" is not recognized; using "proposed"` : 'MADR status is missing; using "proposed"',
1250
+ path: context.path,
1251
+ id: context.id,
1252
+ field: "status"
1253
+ }
1254
+ ]
1255
+ };
1256
+ }
1257
+
1258
+ // src/import/merge.ts
1259
+ class MadrMergeError extends Error {
1260
+ findings;
1261
+ constructor(message, findings) {
1262
+ super(message);
1263
+ this.name = "MadrMergeError";
1264
+ this.findings = findings;
1265
+ }
1266
+ }
1267
+ var FALLBACKS = {
1268
+ deciders: [],
1269
+ consulted: [],
1270
+ informed: [],
1271
+ tags: [],
1272
+ scope: "component",
1273
+ reversibility: "unknown",
1274
+ blastRadius: "component",
1275
+ supersedes: [],
1276
+ relatesTo: [],
1277
+ conflictsWith: [],
1278
+ affects: [],
1279
+ assertions: [],
1280
+ externalRefs: [],
1281
+ complianceControls: []
1282
+ };
1283
+ var OPTIONAL_COPY_KEYS = [
1284
+ "created",
1285
+ "deciders",
1286
+ "consulted",
1287
+ "informed",
1288
+ "tags",
1289
+ "scope",
1290
+ "domain",
1291
+ "reversibility",
1292
+ "blastRadius",
1293
+ "supersedes",
1294
+ "supersededBy",
1295
+ "relatesTo",
1296
+ "conflictsWith",
1297
+ "affects",
1298
+ "assertions",
1299
+ "review",
1300
+ "evaluation",
1301
+ "externalRefs",
1302
+ "complianceControls",
1303
+ "reviewBy"
1304
+ ];
1305
+ var OPTIONAL_REMOVE_KEYS = new Set([
1306
+ "created",
1307
+ "domain",
1308
+ "supersededBy",
1309
+ "review",
1310
+ "evaluation",
1311
+ "reviewBy"
1312
+ ]);
1313
+ var FRONTMATTER_ORDER = [
1314
+ "schemaVersion",
1315
+ "id",
1316
+ "title",
1317
+ "status",
1318
+ "date",
1319
+ "created",
1320
+ "deciders",
1321
+ "consulted",
1322
+ "informed",
1323
+ "tags",
1324
+ "scope",
1325
+ "domain",
1326
+ "reversibility",
1327
+ "blastRadius",
1328
+ "supersedes",
1329
+ "supersededBy",
1330
+ "relatesTo",
1331
+ "conflictsWith",
1332
+ "affects",
1333
+ "assertions",
1334
+ "provenance",
1335
+ "review",
1336
+ "evaluation",
1337
+ "externalRefs",
1338
+ "complianceControls",
1339
+ "reviewBy"
1340
+ ];
1341
+ function isRecord(value) {
1342
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1343
+ }
1344
+ function isValidId(value) {
1345
+ return typeof value === "string" && /^([0-9]{4,}|[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26})$/.test(value);
1346
+ }
1347
+ function isValidSchemaVersion(value) {
1348
+ return typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value);
1349
+ }
1350
+ function dateFrom(value) {
1351
+ if (value instanceof Date && !Number.isNaN(value.valueOf())) {
1352
+ return value.toISOString().slice(0, 10);
1353
+ }
1354
+ if (typeof value !== "string")
1355
+ return;
1356
+ const trimmed = value.trim();
1357
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed))
1358
+ return;
1359
+ const parsed = new Date(`${trimmed}T00:00:00.000Z`);
1360
+ return parsed.toISOString().slice(0, 10) === trimmed ? trimmed : undefined;
1361
+ }
1362
+ function titleFinding(path, id) {
1363
+ return {
1364
+ rule: "import-not-madr",
1365
+ severity: "warn",
1366
+ message: "MADR source is missing a usable title",
1367
+ path,
1368
+ id,
1369
+ field: "title"
1370
+ };
1371
+ }
1372
+ function importedAtFrom(value) {
1373
+ if (!isRecord(value))
1374
+ return;
1375
+ const importedFrom = value.importedFrom;
1376
+ if (!isRecord(importedFrom))
1377
+ return;
1378
+ return typeof importedFrom.importedAt === "string" ? importedFrom.importedAt : undefined;
1379
+ }
1380
+ function provenanceFrom(raw, sourceRef, fingerprint) {
1381
+ const sourceProvenance = isRecord(raw.provenance) ? { ...raw.provenance } : {};
1382
+ const authoredBy = sourceProvenance.authoredBy;
1383
+ if (authoredBy !== "human" && authoredBy !== "agent" && authoredBy !== "agent-drafted") {
1384
+ sourceProvenance.authoredBy = "human";
1385
+ }
1386
+ const importedFrom = {
1387
+ sourceKind: "madr",
1388
+ sourceRef,
1389
+ fingerprint
1390
+ };
1391
+ const importedAt = importedAtFrom(sourceProvenance);
1392
+ if (importedAt)
1393
+ importedFrom.importedAt = importedAt;
1394
+ return {
1395
+ ...sourceProvenance,
1396
+ importedFrom
1397
+ };
1398
+ }
1399
+ function initialCandidate(options, findings) {
1400
+ const raw = options.source.frontmatter;
1401
+ const id = isValidId(raw.id) ? raw.id : options.id;
1402
+ const title = options.source.title;
1403
+ if (!title || title.length < 3 || title.length > 120) {
1404
+ throw new MadrMergeError("MADR source is missing a usable title", [titleFinding(options.source.path, id)]);
1405
+ }
1406
+ const status = mapMadrStatus(raw.status, { path: options.source.path, id });
1407
+ findings.push(...status.findings);
1408
+ const candidate = {};
1409
+ for (const key of OPTIONAL_COPY_KEYS) {
1410
+ if (key in raw)
1411
+ candidate[key] = raw[key];
1412
+ }
1413
+ candidate.schemaVersion = isValidSchemaVersion(raw.schemaVersion) ? raw.schemaVersion : SCHEMA_VERSION;
1414
+ candidate.id = id;
1415
+ candidate.title = title;
1416
+ candidate.status = status.status;
1417
+ candidate.date = dateFrom(raw.date) ?? dateFrom(raw.created) ?? "1970-01-01";
1418
+ for (const [key, value] of Object.entries(FALLBACKS)) {
1419
+ if (!(key in candidate) || candidate[key] === undefined || candidate[key] === null) {
1420
+ candidate[key] = Array.isArray(value) ? [...value] : value;
1421
+ }
1422
+ }
1423
+ candidate.provenance = provenanceFrom(raw, options.sourceRef, options.fingerprint);
1424
+ return candidate;
1425
+ }
1426
+ function safeParseCandidate(candidate, sourcePath) {
1427
+ const working = { ...candidate };
1428
+ for (let attempt = 0;attempt < 20; attempt += 1) {
1429
+ const parsed = AdrFrontmatter.safeParse(working);
1430
+ if (parsed.success)
1431
+ return parsed.data;
1432
+ let changed = false;
1433
+ for (const issue of parsed.error.issues) {
1434
+ const top = issue.path[0];
1435
+ if (typeof top !== "string")
1436
+ continue;
1437
+ if (top in FALLBACKS) {
1438
+ const fallback = FALLBACKS[top];
1439
+ working[top] = Array.isArray(fallback) ? [...fallback] : fallback;
1440
+ changed = true;
1441
+ continue;
1442
+ }
1443
+ if (OPTIONAL_REMOVE_KEYS.has(top)) {
1444
+ delete working[top];
1445
+ changed = true;
1446
+ continue;
1447
+ }
1448
+ if (top === "provenance") {
1449
+ working.provenance = candidate.provenance;
1450
+ changed = true;
1451
+ }
1452
+ }
1453
+ if (!changed) {
1454
+ throw new MadrMergeError(`Migrated frontmatter for ${sourcePath} would not satisfy the adrkit schema`, parsed.error.issues.map((issue) => ({
1455
+ rule: "import-not-madr",
1456
+ severity: "warn",
1457
+ message: issue.message,
1458
+ path: sourcePath,
1459
+ id: typeof working.id === "string" ? working.id : undefined,
1460
+ field: issue.path.length > 0 ? issue.path.map(String).join(".") : "frontmatter"
1461
+ })));
1462
+ }
1463
+ }
1464
+ throw new MadrMergeError(`Unable to produce valid frontmatter for ${sourcePath}`, [
1465
+ {
1466
+ rule: "import-not-madr",
1467
+ severity: "warn",
1468
+ message: "Unable to produce valid frontmatter after removing invalid optional fields",
1469
+ path: sourcePath,
1470
+ id: typeof working.id === "string" ? working.id : undefined,
1471
+ field: "frontmatter"
1472
+ }
1473
+ ]);
1474
+ }
1475
+ function orderedObject(value, order) {
1476
+ const output = {};
1477
+ for (const key of order) {
1478
+ if (value[key] !== undefined)
1479
+ output[key] = value[key];
1480
+ }
1481
+ for (const key of Object.keys(value).sort()) {
1482
+ if (!(key in output) && value[key] !== undefined)
1483
+ output[key] = value[key];
1484
+ }
1485
+ return output;
1486
+ }
1487
+ function orderProvenance(provenance) {
1488
+ const output = {};
1489
+ for (const key of ["authoredBy", "agent", "ratifiedBy", "sourceArtifact"]) {
1490
+ if (provenance[key] !== undefined)
1491
+ output[key] = provenance[key];
1492
+ }
1493
+ if (provenance.importedFrom) {
1494
+ const importedFrom = {
1495
+ sourceKind: provenance.importedFrom.sourceKind,
1496
+ sourceRef: provenance.importedFrom.sourceRef,
1497
+ fingerprint: provenance.importedFrom.fingerprint
1498
+ };
1499
+ if (provenance.importedFrom.importedAt)
1500
+ importedFrom.importedAt = provenance.importedFrom.importedAt;
1501
+ output.importedFrom = importedFrom;
1502
+ }
1503
+ return output;
1504
+ }
1505
+ function orderedFrontmatter(frontmatter) {
1506
+ const raw = frontmatter;
1507
+ const output = orderedObject(raw, FRONTMATTER_ORDER);
1508
+ if (frontmatter.provenance)
1509
+ output.provenance = orderProvenance(frontmatter.provenance);
1510
+ return output;
1511
+ }
1512
+ function renderMigratedContent(frontmatter, body) {
1513
+ const yaml = stringify(orderedFrontmatter(frontmatter), {
1514
+ lineWidth: 0,
1515
+ sortMapEntries: false
1516
+ }).trimEnd();
1517
+ return `---
1518
+ ${yaml}
1519
+ ---
1520
+ ${body}`;
1521
+ }
1522
+ function mergeMadr(options) {
1523
+ const findings = [];
1524
+ const candidate = initialCandidate(options, findings);
1525
+ const frontmatter = safeParseCandidate(candidate, options.source.path);
1526
+ return {
1527
+ frontmatter,
1528
+ content: renderMigratedContent(frontmatter, options.source.body),
1529
+ findings
1530
+ };
1531
+ }
1532
+ function recordFromMerge(result, source) {
1533
+ return {
1534
+ frontmatter: result.frontmatter,
1535
+ body: source.body,
1536
+ path: source.path
1537
+ };
1538
+ }
1539
+
1540
+ // src/import/index.ts
1541
+ function toAbsolutePath2(path, cwd) {
1542
+ return isAbsolute3(path) ? path : resolve4(cwd, path);
1543
+ }
1544
+ function notMadrFinding(path, reason) {
1545
+ return {
1546
+ rule: "import-not-madr",
1547
+ severity: "warn",
1548
+ message: reason,
1549
+ path
1550
+ };
1551
+ }
1552
+ async function existingRecordsFromFiles(files, cwd) {
1553
+ const records = [];
1554
+ for (const file of files) {
1555
+ const absolutePath = toAbsolutePath2(file, cwd);
1556
+ try {
1557
+ const source = await readFile3(absolutePath, "utf8");
1558
+ const parsed = parseFrontmatter(source);
1559
+ const frontmatter = AdrFrontmatter.safeParse(parsed.data);
1560
+ if (frontmatter.success) {
1561
+ records.push({
1562
+ frontmatter: frontmatter.data,
1563
+ body: parsed.body,
1564
+ path: normalizeDisplayPath(absolutePath, cwd)
1565
+ });
1566
+ }
1567
+ } catch {}
1568
+ }
1569
+ return records.sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id));
1570
+ }
1571
+ function recordById(records) {
1572
+ return new Map(records.map((record) => [record.frontmatter.id, record]));
1573
+ }
1574
+ function classifyBySourceRef(classifications) {
1575
+ return new Map(classifications.map((classification) => [classification.sourceRef, classification]));
1576
+ }
1577
+ function numericId(id) {
1578
+ return /^\d+$/.test(id) ? Number(id) : undefined;
1579
+ }
1580
+ function rawId2(source) {
1581
+ const id = source.frontmatter.id;
1582
+ return typeof id === "string" && /^([0-9]{4,}|[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26})$/.test(id) ? id : undefined;
1583
+ }
1584
+ function createIdAllocator(seed, usedIds) {
1585
+ const used = new Set(usedIds);
1586
+ let next = numericId(seed) ?? 1;
1587
+ return (source, classification) => {
1588
+ const existing = classification?.recordId;
1589
+ if (existing)
1590
+ return existing;
1591
+ const sourceId = rawId2(source);
1592
+ if (sourceId) {
1593
+ used.add(sourceId);
1594
+ return sourceId;
1595
+ }
1596
+ const fileId = fileNameId(source.absolutePath);
1597
+ if (fileId && !used.has(fileId)) {
1598
+ used.add(fileId);
1599
+ return fileId;
1600
+ }
1601
+ for (;; ) {
1602
+ const id = String(next).padStart(4, "0");
1603
+ next += 1;
1604
+ if (!used.has(id)) {
1605
+ used.add(id);
1606
+ return id;
1607
+ }
1608
+ }
1609
+ };
1610
+ }
1611
+ function importIncompleteRecords(records) {
1612
+ const seen = new Set;
1613
+ const unique = [];
1614
+ for (const record of records) {
1615
+ const key = `${record.frontmatter.id}\x00${record.path}`;
1616
+ if (seen.has(key))
1617
+ continue;
1618
+ seen.add(key);
1619
+ unique.push(record);
1620
+ }
1621
+ return unique;
1622
+ }
1623
+ async function migrateMadr(input) {
1624
+ const cwd = input.cwd ?? process.cwd();
1625
+ const dir = input.dir ?? "docs/adr";
1626
+ const write = input.write !== false;
1627
+ const files = input.files ? input.files.map((file) => toAbsolutePath2(file, cwd)).sort((a, b) => normalizeDisplayPath(a, cwd).localeCompare(normalizeDisplayPath(b, cwd))) : await discoverMadrCandidateFiles(dir, cwd);
1628
+ const findings = [];
1629
+ const results = [];
1630
+ const divergence = [];
1631
+ const prepared = [];
1632
+ for (const file of files) {
1633
+ const read = await readMadrFile(file, cwd);
1634
+ if (read.kind === "not-madr") {
1635
+ results.push({ path: read.path, outcome: "skipped" });
1636
+ findings.push(notMadrFinding(read.path, read.reason));
1637
+ continue;
1638
+ }
1639
+ const sourceRef = read.path;
1640
+ prepared.push({ source: read, sourceRef, fingerprint: fingerprintSourceBody(read.body) });
1641
+ }
1642
+ const existingRecords = input.existingRecords ?? await existingRecordsFromFiles(files, cwd);
1643
+ const classifications = classifyReimport(prepared.map((entry) => ({ sourceRef: entry.sourceRef, fingerprint: entry.fingerprint, path: entry.source.path })), existingRecords, input.recordEdited ?? (() => false));
1644
+ const classificationForSource = classifyBySourceRef(classifications);
1645
+ const existingById = recordById(existingRecords);
1646
+ const seed = await nextSequentialId(dir, cwd).catch(() => "0001");
1647
+ const allocateId = createIdAllocator(seed, [
1648
+ ...existingRecords.map((record) => record.frontmatter.id),
1649
+ ...prepared.map((entry) => rawId2(entry.source)).filter((id) => Boolean(id))
1650
+ ]);
1651
+ const recordsForIncomplete = [];
1652
+ for (const entry of prepared.sort((a, b) => a.source.path.localeCompare(b.source.path))) {
1653
+ const classification = classificationForSource.get(entry.sourceRef);
1654
+ const bucket = classification?.bucket ?? "new";
1655
+ if (bucket === "diverged") {
1656
+ results.push({ path: entry.source.path, outcome: "diverged" });
1657
+ divergence.push({ path: entry.source.path, sourceRef: entry.sourceRef });
1658
+ const existing = classification?.recordId ? existingById.get(classification.recordId) : undefined;
1659
+ if (existing)
1660
+ recordsForIncomplete.push(existing);
1661
+ continue;
1662
+ }
1663
+ if (bucket === "unchanged") {
1664
+ results.push({ path: entry.source.path, outcome: "unchanged" });
1665
+ const existing = classification?.recordId ? existingById.get(classification.recordId) : undefined;
1666
+ if (existing)
1667
+ recordsForIncomplete.push(existing);
1668
+ continue;
1669
+ }
1670
+ try {
1671
+ const id = allocateId(entry.source, classification);
1672
+ const merged = mergeMadr({
1673
+ source: entry.source,
1674
+ id,
1675
+ sourceRef: entry.sourceRef,
1676
+ fingerprint: entry.fingerprint
1677
+ });
1678
+ findings.push(...merged.findings);
1679
+ const outcome = bucket === "updated" ? "updated" : "migrated";
1680
+ if (write && merged.content !== entry.source.source) {
1681
+ await writeFile2(entry.source.absolutePath, merged.content, "utf8");
1682
+ }
1683
+ results.push({ path: entry.source.path, outcome, frontmatter: merged.frontmatter });
1684
+ recordsForIncomplete.push(recordFromMerge(merged, entry.source));
1685
+ } catch (error) {
1686
+ if (error instanceof MadrMergeError) {
1687
+ results.push({ path: entry.source.path, outcome: "skipped" });
1688
+ findings.push(...error.findings);
1689
+ continue;
1690
+ }
1691
+ throw error;
1692
+ }
1693
+ }
1694
+ findings.push(...validateImportIncomplete(importIncompleteRecords(recordsForIncomplete)));
1695
+ return {
1696
+ results: results.sort((a, b) => a.path.localeCompare(b.path)),
1697
+ divergence: divergence.sort((a, b) => a.path.localeCompare(b.path) || a.sourceRef.localeCompare(b.sourceRef)),
1698
+ findings: sortFindings(findings)
1699
+ };
1700
+ }
1701
+ export {
1702
+ validateParsedAdr,
1703
+ validateImportIncomplete,
1704
+ validateCorpusInvariants,
1705
+ validateAdrFrontmatter,
1706
+ stringifyJsonSchema,
1707
+ sortFindings,
1708
+ slugifyTitle,
1709
+ resolveAffects,
1710
+ renderMigratedContent,
1711
+ renderJsonGraph,
1712
+ renderDotGraph,
1713
+ renderAdrRecord,
1714
+ readMadrFile,
1715
+ parsePackagePattern,
1716
+ parseFrontmatter,
1717
+ parseAdrFile,
1718
+ normalizeSourceBody,
1719
+ normalizeDisplayPath,
1720
+ nextSequentialId,
1721
+ migrateMadr,
1722
+ mergeMadr,
1723
+ matchPathPattern,
1724
+ matchPackagePattern,
1725
+ mapMadrStatus,
1726
+ loadCorpus,
1727
+ loadAdrFile,
1728
+ lintCorpus,
1729
+ isRecordFileName,
1730
+ fingerprintSourceBody,
1731
+ expandRecordInputs,
1732
+ exitCodeForFindings,
1733
+ emitJsonSchema,
1734
+ discoverMadrCandidateFiles,
1735
+ discoverAdrFiles,
1736
+ deriveChangedDependenciesFromBunLockDiff,
1737
+ createAdr,
1738
+ countFindings,
1739
+ classifyReimport,
1740
+ checkChanges,
1741
+ buildAdrGraph,
1742
+ TEMPLATE_FILE_NAME,
1743
+ Status,
1744
+ Severity,
1745
+ Scope,
1746
+ ScaffoldError,
1747
+ SCHEMA_VERSION,
1748
+ ReviewTier,
1749
+ Review,
1750
+ Reversibility,
1751
+ RECORD_FILE_PATTERN,
1752
+ Provenance,
1753
+ Objection,
1754
+ Identity,
1755
+ IMPORT_FINDING_RULES,
1756
+ FrontmatterError,
1757
+ ExternalRef,
1758
+ Evaluation,
1759
+ EscalationReason,
1760
+ DeterministicFinding,
1761
+ BlastRadius,
1762
+ Assertion,
1763
+ AffectsType,
1764
+ AffectsMatcher,
1765
+ AdrRef,
1766
+ AdrFrontmatter,
1767
+ ADR_SCHEMA_ID
1768
+ };