@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
@@ -0,0 +1,208 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, resolve } from 'node:path';
3
+ import { Status, SCHEMA_VERSION, AdrFrontmatter } from '../schema/adr.schema.ts';
4
+ import { discoverAdrFiles, normalizeDisplayPath } from '../load/corpus.ts';
5
+
6
+ export interface NewAdrOptions {
7
+ title: string;
8
+ status?: string;
9
+ dir?: string;
10
+ cwd?: string;
11
+ date?: string;
12
+ write?: boolean;
13
+ }
14
+
15
+ export interface NewAdrResult {
16
+ id: string;
17
+ path: string;
18
+ content: string;
19
+ }
20
+
21
+ export class ScaffoldError extends Error {
22
+ readonly code: 'usage' | 'exists';
23
+
24
+ constructor(code: 'usage' | 'exists', message: string) {
25
+ super(message);
26
+ this.name = 'ScaffoldError';
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ function todayIsoDate(): string {
32
+ return new Date().toISOString().slice(0, 10);
33
+ }
34
+
35
+ export function slugifyTitle(title: string): string {
36
+ const slug = title
37
+ .normalize('NFKD')
38
+ .replace(/[\u0300-\u036f]/g, '')
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, '-')
41
+ .replace(/^-+|-+$/g, '')
42
+ .replace(/-{2,}/g, '-');
43
+
44
+ if (!slug) {
45
+ throw new ScaffoldError('usage', 'Title must contain at least one ASCII letter or digit');
46
+ }
47
+
48
+ return slug.slice(0, 80).replace(/-+$/g, '');
49
+ }
50
+
51
+ function statusForInput(input: string | undefined): string {
52
+ const status = input ?? 'draft';
53
+ const parsed = Status.safeParse(status);
54
+ if (!parsed.success) {
55
+ throw new ScaffoldError('usage', `Invalid status "${status}"`);
56
+ }
57
+ if (status === 'accepted' || status === 'superseded') {
58
+ throw new ScaffoldError(
59
+ 'usage',
60
+ `adr new cannot scaffold status "${status}" without additional required fields`,
61
+ );
62
+ }
63
+ return status;
64
+ }
65
+
66
+ export async function nextSequentialId(dir: string, cwd: string): Promise<string> {
67
+ const files = await discoverAdrFiles(dir, cwd).catch(() => []);
68
+ let max = 0;
69
+ for (const file of files) {
70
+ const match = /^([0-9]+)-/.exec(basename(file));
71
+ if (match?.[1]) {
72
+ max = Math.max(max, Number(match[1]));
73
+ }
74
+ }
75
+ return String(max + 1).padStart(4, '0');
76
+ }
77
+
78
+ export function renderAdrRecord(options: {
79
+ id: string;
80
+ title: string;
81
+ status: string;
82
+ date: string;
83
+ }): string {
84
+ const frontmatter = {
85
+ schemaVersion: SCHEMA_VERSION,
86
+ id: options.id,
87
+ title: options.title,
88
+ status: options.status,
89
+ date: options.date,
90
+ deciders: [],
91
+ tags: [],
92
+ scope: 'component',
93
+ reversibility: 'unknown',
94
+ blastRadius: 'component',
95
+ relatesTo: [],
96
+ affects: [],
97
+ provenance: {
98
+ authoredBy: 'human',
99
+ },
100
+ };
101
+
102
+ const validation = AdrFrontmatter.safeParse(frontmatter);
103
+ if (!validation.success) {
104
+ throw new ScaffoldError('usage', validation.error.issues.map((issue) => issue.message).join('; '));
105
+ }
106
+
107
+ return `---
108
+ schemaVersion: ${frontmatter.schemaVersion}
109
+ id: "${frontmatter.id}"
110
+ title: ${JSON.stringify(frontmatter.title)}
111
+ status: ${frontmatter.status}
112
+ date: ${frontmatter.date}
113
+ deciders: []
114
+ tags: []
115
+ scope: ${frontmatter.scope}
116
+ reversibility: ${frontmatter.reversibility}
117
+ blastRadius: ${frontmatter.blastRadius}
118
+ relatesTo: []
119
+ affects: []
120
+ provenance:
121
+ authoredBy: human
122
+ ---
123
+
124
+ # ADR-${frontmatter.id}: ${frontmatter.title}
125
+
126
+ ## Context
127
+
128
+ What forces are at play? What makes this a decision rather than a preference?
129
+ Why now — what changed?
130
+
131
+ State the problem, not the solution. If this section is a restatement of the
132
+ option you already picked, the record scores 2 at best on D1.
133
+
134
+ ## Decision
135
+
136
+ What we are doing, in the active voice. "We will…"
137
+
138
+ ## Options considered
139
+
140
+ At least two genuine alternatives, including doing nothing. An option no
141
+ competent engineer would choose is a straw man and scores zero.
142
+
143
+ ### Option A: <chosen>
144
+
145
+ | Dimension | Assessment |
146
+ |---|---|
147
+ | | |
148
+
149
+ ### Option B: <alternative>
150
+
151
+ **Pros:**
152
+ **Cons:**
153
+
154
+ ### Option C: Do nothing
155
+
156
+ ## Trade-offs
157
+
158
+ What the chosen option costs. State the downsides as plainly as the benefits —
159
+ a decision whose chosen option has no listed downsides is not a decision.
160
+
161
+ ## Consequences
162
+
163
+ - Easier:
164
+ - Harder:
165
+ - **How we would know this was wrong:** a metric, a threshold, an exit
166
+ condition, or a review date. This is the field that most separates records
167
+ that stay alive from records that rot.
168
+ - Revisit if:
169
+
170
+ ## Action items
171
+
172
+ 1. [ ]
173
+ `;
174
+ }
175
+
176
+ export async function createAdr(options: NewAdrOptions): Promise<NewAdrResult> {
177
+ const cwd = options.cwd ?? process.cwd();
178
+ const dir = options.dir ?? 'docs/adr';
179
+ const title = options.title.trim();
180
+ const status = statusForInput(options.status);
181
+
182
+ if (title.length < 3 || title.length > 120) {
183
+ throw new ScaffoldError('usage', 'Title must be between 3 and 120 characters');
184
+ }
185
+ if (/[\r\n]/.test(title)) {
186
+ throw new ScaffoldError('usage', 'Title must fit on a single line');
187
+ }
188
+
189
+ const id = await nextSequentialId(dir, cwd);
190
+ const fileName = `${id}-${slugifyTitle(title)}.md`;
191
+ const path = resolve(cwd, dir, fileName);
192
+ const displayPath = normalizeDisplayPath(path, cwd);
193
+ const content = renderAdrRecord({ id, title, status, date: options.date ?? todayIsoDate() });
194
+
195
+ if (options.write !== false) {
196
+ await mkdir(dirname(path), { recursive: true });
197
+ try {
198
+ await writeFile(path, content, { encoding: 'utf8', flag: 'wx' });
199
+ } catch (error) {
200
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
201
+ throw new ScaffoldError('exists', `Refusing to overwrite existing ADR file ${displayPath}`);
202
+ }
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ return { id, path: displayPath, content };
208
+ }
@@ -0,0 +1,338 @@
1
+ /**
2
+ * @adrkit/core — ADR frontmatter schema
3
+ *
4
+ * Single source of truth for the typed half of a decision record. The JSON
5
+ * Schema in ./adr.schema.json is generated from this file (`bun run schema:emit`)
6
+ * so non-TS consumers — editors, CI in other languages, IDP plugins — get the
7
+ * same contract.
8
+ *
9
+ * Targets Zod 4. Deliberately avoids `.brand()` and other Zod-only constructs
10
+ * in the public shapes so the JSON Schema emit stays lossless.
11
+ */
12
+
13
+ import { z } from 'zod';
14
+
15
+ export const SCHEMA_VERSION = '0.1.0' as const;
16
+
17
+ /* ------------------------------------------------------------------ *
18
+ * Primitives
19
+ * ------------------------------------------------------------------ */
20
+
21
+ /** `@handle`, `team:platform`, or an email address. */
22
+ export const Identity = z
23
+ .string()
24
+ .regex(
25
+ /^(@[A-Za-z0-9-]+|team:[a-z0-9-]+|[^@\s]+@[^@\s]+\.[^@\s]+)$/,
26
+ 'Expected @handle, team:slug, or an email address',
27
+ );
28
+
29
+ /** `0042` (same log) or `payments:0012` (federated). */
30
+ export const AdrRef = z
31
+ .string()
32
+ .regex(
33
+ /^(([a-z0-9][a-z0-9-]*):)?([0-9]{4,}|[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26})$/,
34
+ 'Expected an ADR id, optionally prefixed with a log name',
35
+ );
36
+
37
+ /** Calendar-correct `YYYY-MM-DD`; rejects impossible dates like `2026-02-31`. */
38
+ const IsoDate = z.string().date();
39
+ const IsoDateTime = z.string().datetime({ offset: true });
40
+
41
+ const Slug = z.string().regex(/^[a-z0-9][a-z0-9-]*$/);
42
+
43
+ const strictObject = <T extends z.ZodRawShape>(shape: T) => z.strictObject(shape);
44
+
45
+ /**
46
+ * Array whose items must be unique. Mirrors JSON Schema `uniqueItems: true`
47
+ * so TS and non-TS consumers reject duplicate entries identically.
48
+ */
49
+ const uniqueArray = <T extends z.ZodTypeAny>(item: T) =>
50
+ z
51
+ .array(item)
52
+ .refine((xs) => new Set(xs.map((x) => JSON.stringify(x))).size === xs.length, {
53
+ message: 'Items must be unique',
54
+ })
55
+ .meta({ uniqueItems: true });
56
+
57
+ /* ------------------------------------------------------------------ *
58
+ * Enums
59
+ * ------------------------------------------------------------------ */
60
+
61
+ /**
62
+ * `rejected` is retained on purpose. The decision *not* to do something is the
63
+ * one most often re-litigated, and the graveyard is where "we tried that in
64
+ * 2023" knowledge lives.
65
+ */
66
+ export const Status = z.enum([
67
+ 'draft',
68
+ 'proposed',
69
+ 'accepted',
70
+ 'rejected',
71
+ 'superseded',
72
+ 'deprecated',
73
+ ]);
74
+
75
+ export const Scope = z.enum(['component', 'domain', 'org']);
76
+ export const Reversibility = z.enum(['two-way-door', 'one-way-door', 'unknown']);
77
+ export const BlastRadius = z.enum(['component', 'team', 'cross-team', 'org']);
78
+ export const ReviewTier = z.enum(['auto', 'async', 'arb']);
79
+ export const Severity = z.enum(['error', 'warn', 'info']);
80
+
81
+ export const EscalationReason = z.enum([
82
+ 'one-way-door',
83
+ 'cost-threshold',
84
+ 'security-surface',
85
+ 'data-residency',
86
+ 'regulatory',
87
+ 'contradicts-accepted-adr',
88
+ 'low-confidence',
89
+ 'pass-disagreement',
90
+ 'agent-authored-production',
91
+ 'novel-no-precedent',
92
+ 'human-requested',
93
+ ]);
94
+
95
+ /* ------------------------------------------------------------------ *
96
+ * affects — the hinge
97
+ * ------------------------------------------------------------------ *
98
+ * Without this, an ADR is a document. With it, CI can answer "which
99
+ * decisions govern the code in this PR?" and put the answer where the
100
+ * next decision is actually being made.
101
+ */
102
+
103
+ export const AffectsType = z.enum([
104
+ 'path', // repo-relative glob, picomatch semantics
105
+ 'entity', // IDP catalog ref, Backstage-compatible: component:default/payments-api
106
+ 'package', // dependency, optional semver range: react@>=19
107
+ 'resource', // IaC resource type: azurerm_storage_account
108
+ 'api', // OpenAPI/AsyncAPI path or operationId
109
+ 'data', // dataset / table identifier
110
+ ]);
111
+
112
+ export const AffectsMatcher = strictObject({
113
+ type: AffectsType,
114
+ pattern: z.string().min(1),
115
+ /** Qualifier for federated logs. Omit for same-repo. */
116
+ repo: z.string().optional(),
117
+ /** Exclusions are evaluated after includes. */
118
+ negate: z.boolean().default(false),
119
+ note: z.string().optional(),
120
+ });
121
+
122
+ /* ------------------------------------------------------------------ *
123
+ * assertions — optional executable guardrails
124
+ * ------------------------------------------------------------------ */
125
+
126
+ export const Assertion = strictObject({
127
+ id: Slug,
128
+ description: z.string().optional(),
129
+ engine: z.enum(['rego', 'jsonpath', 'grep', 'custom']),
130
+ expression: z.string().optional(),
131
+ expressionFile: z.string().optional(),
132
+ input: z
133
+ .enum(['source', 'iac-plan', 'sbom', 'openapi', 'catalog', 'custom'])
134
+ .default('source'),
135
+ severity: Severity,
136
+ });
137
+
138
+ /* ------------------------------------------------------------------ *
139
+ * provenance — capture from day one; cannot be backfilled
140
+ * ------------------------------------------------------------------ */
141
+
142
+ export const Provenance = strictObject({
143
+ authoredBy: z.enum(['human', 'agent', 'agent-drafted']).default('human'),
144
+ agent: strictObject({
145
+ name: z.string().optional(),
146
+ model: z.string().optional(),
147
+ /** e.g. spec-kit, claude-code, copilot */
148
+ harness: z.string().optional(),
149
+ runId: z.string().optional(),
150
+ }).optional(),
151
+ ratifiedBy: Identity.optional(),
152
+ /** Path/URI of the plan or spec this record was derived from. */
153
+ sourceArtifact: z.string().optional(),
154
+ /**
155
+ * Present when an importer created this record rather than an author here.
156
+ * Its presence exempts the record from the deciders-required invariant: the
157
+ * decision was made elsewhere, and fabricating a decider from git blame is
158
+ * worse than an empty field.
159
+ */
160
+ importedFrom: strictObject({
161
+ sourceKind: z.enum(['madr', 'agent-log', 'plan-artifact', 'other']),
162
+ /** Source-local identifier: file path, entry id, or URI. */
163
+ sourceRef: z.string(),
164
+ /** Content hash of the source entry at import time; drives re-import classification. */
165
+ fingerprint: z.string(),
166
+ importedAt: IsoDateTime.optional(),
167
+ }).optional(),
168
+ });
169
+
170
+ /* ------------------------------------------------------------------ *
171
+ * review — ARB routing state
172
+ * ------------------------------------------------------------------ */
173
+
174
+ export const Objection = strictObject({
175
+ by: Identity,
176
+ summary: z.string().optional(),
177
+ resolved: z.boolean().default(false),
178
+ });
179
+
180
+ export const Review = strictObject({
181
+ tier: ReviewTier.optional(),
182
+ /** Required when a human overrides the router's tier. */
183
+ tierReason: z.string().optional(),
184
+ queuedAt: IsoDateTime.optional(),
185
+ slaDays: z.number().int().min(0).optional(),
186
+ escalatedAt: IsoDateTime.optional(),
187
+ decidedAt: IsoDateTime.optional(),
188
+ quorum: z.number().int().min(1).optional(),
189
+ approvals: z.array(Identity).default([]),
190
+ objections: z.array(Objection).default([]),
191
+ });
192
+
193
+ /* ------------------------------------------------------------------ *
194
+ * evaluation — written by tooling, never by hand
195
+ * ------------------------------------------------------------------ */
196
+
197
+ export const DeterministicFinding = strictObject({
198
+ rule: z.string(),
199
+ severity: Severity,
200
+ message: z.string().optional(),
201
+ adr: AdrRef.optional(),
202
+ });
203
+
204
+ export const Evaluation = strictObject({
205
+ ranAt: IsoDateTime.optional(),
206
+ evaluatorVersion: z.string().optional(),
207
+ rubricVersion: z.string().optional(),
208
+ /** Per-dimension 0–4, keyed by rubric dimension id. */
209
+ scores: z.record(z.string(), z.number().min(0).max(4)).optional(),
210
+ confidence: z.number().min(0).max(1).optional(),
211
+ escalate: z.boolean().optional(),
212
+ escalationReasons: z.array(EscalationReason).default([]),
213
+ deterministicFindings: z.array(DeterministicFinding).default([]),
214
+ });
215
+
216
+ export const ExternalRef = strictObject({
217
+ type: z.enum(['issue', 'pr', 'rfc', 'incident', 'doc', 'meeting', 'control', 'other']),
218
+ id: z.string().optional(),
219
+ url: z.string().url(),
220
+ label: z.string().optional(),
221
+ });
222
+
223
+ /* ------------------------------------------------------------------ *
224
+ * The record
225
+ * ------------------------------------------------------------------ */
226
+
227
+ export const AdrFrontmatter = strictObject({
228
+ schemaVersion: z
229
+ .string()
230
+ .regex(/^\d+\.\d+\.\d+$/)
231
+ .default(SCHEMA_VERSION),
232
+
233
+ id: z.string().regex(/^([0-9]{4,}|[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26})$/),
234
+ /** Imperative phrase naming the decision, not the problem. */
235
+ title: z.string().min(3).max(120),
236
+ status: Status,
237
+ date: IsoDate,
238
+ created: IsoDate.optional(),
239
+
240
+ deciders: z.array(Identity).default([]),
241
+ consulted: z.array(Identity).default([]),
242
+ informed: z.array(Identity).default([]),
243
+
244
+ tags: uniqueArray(Slug).default([]),
245
+ scope: Scope.default('component'),
246
+ domain: z.string().optional(),
247
+
248
+ reversibility: Reversibility.default('unknown'),
249
+ blastRadius: BlastRadius.default('component'),
250
+
251
+ supersedes: uniqueArray(AdrRef).default([]),
252
+ supersededBy: AdrRef.optional(),
253
+ relatesTo: uniqueArray(AdrRef).default([]),
254
+ /** Knowingly-held tension. A lint warning on accepted records, not an error. */
255
+ conflictsWith: z.array(AdrRef).default([]),
256
+
257
+ affects: z.array(AffectsMatcher).default([]),
258
+ assertions: z.array(Assertion).default([]),
259
+
260
+ provenance: Provenance.optional(),
261
+ review: Review.optional(),
262
+ evaluation: Evaluation.optional(),
263
+
264
+ externalRefs: z.array(ExternalRef).default([]),
265
+ /** e.g. SOC2 CC8.1, ISO 27001 A.8.25 — audit evidence as a byproduct. */
266
+ complianceControls: uniqueArray(z.string()).default([]),
267
+ /** Decisions with an expiry get maintained. Decisions without one rot. */
268
+ reviewBy: IsoDate.optional(),
269
+ })
270
+ /* --- cross-field invariants ------------------------------------- */
271
+ .refine((a) => (a.status === 'superseded' ? Boolean(a.supersededBy) : true), {
272
+ message: 'status "superseded" requires supersededBy',
273
+ path: ['supersededBy'],
274
+ })
275
+ .refine((a) => (a.supersededBy ? a.status === 'superseded' : true), {
276
+ message: 'supersededBy is set but status is not "superseded"',
277
+ path: ['status'],
278
+ })
279
+ .refine(
280
+ (a) =>
281
+ a.status !== 'accepted' ||
282
+ a.deciders.length > 0 ||
283
+ Boolean(a.provenance?.importedFrom),
284
+ {
285
+ message:
286
+ 'an accepted decision must name at least one decider, unless it was imported',
287
+ path: ['deciders'],
288
+ },
289
+ )
290
+ .refine(
291
+ (a) =>
292
+ a.status !== 'accepted' ||
293
+ a.provenance?.authoredBy !== 'agent' ||
294
+ Boolean(a.provenance?.ratifiedBy),
295
+ {
296
+ message:
297
+ 'an agent-authored record cannot reach "accepted" without a named human ratifier',
298
+ path: ['provenance', 'ratifiedBy'],
299
+ },
300
+ )
301
+ .refine((a) => !(a.reversibility === 'one-way-door' && a.review?.tier === 'auto'), {
302
+ message: 'one-way-door decisions may not take the auto-approve fast path',
303
+ path: ['review', 'tier'],
304
+ });
305
+
306
+ export type AdrFrontmatter = z.infer<typeof AdrFrontmatter>;
307
+
308
+ export interface Adr {
309
+ frontmatter: AdrFrontmatter;
310
+ /** Raw markdown body below the frontmatter. */
311
+ body: string;
312
+ /** Repo-relative path, e.g. docs/adr/0042-use-postgres-for-the-index.md */
313
+ path: string;
314
+ /** Log name for federated corpora; undefined for single-repo. */
315
+ log?: string;
316
+ }
317
+
318
+ /* ------------------------------------------------------------------ *
319
+ * Type-only inference aliases (no runtime / JSON-schema change)
320
+ * ------------------------------------------------------------------ *
321
+ * These `z.infer` aliases sit beside their existing Zod values so first-party
322
+ * consumers (notably `@adrkit/evaluator`) can reuse the committed contract shapes
323
+ * by name instead of redefining them. A value and a type may share a name in
324
+ * TypeScript, so `AdrRef` (value) and `AdrRef` (type) coexist. Adding these
325
+ * changes no Zod schema and no emitted JSON Schema (data-model §1).
326
+ */
327
+ export type AdrRef = z.infer<typeof AdrRef>;
328
+ export type AffectsType = z.infer<typeof AffectsType>;
329
+ export type AffectsMatcher = z.infer<typeof AffectsMatcher>;
330
+ export type Assertion = z.infer<typeof Assertion>;
331
+ export type Severity = z.infer<typeof Severity>;
332
+ export type EscalationReason = z.infer<typeof EscalationReason>;
333
+ export type DeterministicFinding = z.infer<typeof DeterministicFinding>;
334
+ export type Evaluation = z.infer<typeof Evaluation>;
335
+ export type Status = z.infer<typeof Status>;
336
+ export type Scope = z.infer<typeof Scope>;
337
+ export type Reversibility = z.infer<typeof Reversibility>;
338
+ export type BlastRadius = z.infer<typeof BlastRadius>;
@@ -0,0 +1,9 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { emitJsonSchema, stringifyJsonSchema } from './emit.ts';
5
+
6
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
7
+ const outputPath = resolve(repoRoot, 'schema/adr.schema.json');
8
+
9
+ await writeFile(outputPath, stringifyJsonSchema(emitJsonSchema()), 'utf8');
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod';
2
+ import { AdrFrontmatter, SCHEMA_VERSION } from './adr.schema.ts';
3
+
4
+ export const ADR_SCHEMA_ID = `https://adrkit.dev/schema/adr/v${SCHEMA_VERSION}/adr.schema.json`;
5
+
6
+ export type JsonValue =
7
+ | null
8
+ | boolean
9
+ | number
10
+ | string
11
+ | JsonValue[]
12
+ | { [key: string]: JsonValue };
13
+
14
+ function sortJson(value: unknown): JsonValue {
15
+ if (Array.isArray(value)) {
16
+ return value.map((item) => sortJson(item));
17
+ }
18
+
19
+ if (value && typeof value === 'object') {
20
+ const input = value as Record<string, unknown>;
21
+ const output: Record<string, JsonValue> = {};
22
+ for (const key of Object.keys(input).sort()) {
23
+ output[key] = sortJson(input[key]);
24
+ }
25
+ return output;
26
+ }
27
+
28
+ if (
29
+ value === null ||
30
+ typeof value === 'boolean' ||
31
+ typeof value === 'number' ||
32
+ typeof value === 'string'
33
+ ) {
34
+ return value;
35
+ }
36
+
37
+ throw new TypeError(`Cannot serialize JSON schema value of type ${typeof value}`);
38
+ }
39
+
40
+ export function emitJsonSchema(): JsonValue {
41
+ const schema = z.toJSONSchema(AdrFrontmatter, { io: 'input' });
42
+ const withMetadata = {
43
+ ...schema,
44
+ $id: ADR_SCHEMA_ID,
45
+ title: 'Architecture Decision Record',
46
+ description:
47
+ '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.',
48
+ };
49
+
50
+ return sortJson(withMetadata);
51
+ }
52
+
53
+ export function stringifyJsonSchema(schema: JsonValue = emitJsonSchema()): string {
54
+ return `${JSON.stringify(schema, null, 2)}\n`;
55
+ }
@@ -0,0 +1,2 @@
1
+ export * from './adr.schema.ts';
2
+ export * from './emit.ts';