@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.
- package/README.md +22 -0
- package/dist/LICENSE +201 -0
- package/dist/NOTICE +11 -0
- package/dist/affects/catalog.d.ts +14 -0
- package/dist/affects/index.d.ts +30 -0
- package/dist/affects/inert.d.ts +8 -0
- package/dist/affects/matchers/package.d.ts +20 -0
- package/dist/affects/matchers/path.d.ts +5 -0
- package/dist/check/index.d.ts +46 -0
- package/dist/graph/build.d.ts +18 -0
- package/dist/import/classify.d.ts +13 -0
- package/dist/import/fingerprint.d.ts +2 -0
- package/dist/import/index.d.ts +32 -0
- package/dist/import/madr.d.ts +21 -0
- package/dist/import/merge.d.ts +21 -0
- package/dist/import/status.d.ts +11 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1768 -0
- package/dist/load/corpus.d.ts +20 -0
- package/dist/parse/frontmatter.d.ts +10 -0
- package/dist/scaffold/new.d.ts +26 -0
- package/dist/schema/adr.schema.d.ts +416 -0
- package/dist/schema/adr.schema.js +182 -0
- package/dist/schema/emit.cli.d.ts +1 -0
- package/dist/schema/emit.d.ts +6 -0
- package/dist/schema/emit.js +200 -0
- package/dist/schema/index.d.ts +2 -0
- package/dist/schema/index.js +220 -0
- package/dist/validate/contract.d.ts +9 -0
- package/dist/validate/corpus-invariants.d.ts +3 -0
- package/dist/validate/findings.d.ts +19 -0
- package/dist/validate/import-incomplete.d.ts +3 -0
- package/dist/validate/index.d.ts +13 -0
- package/package.json +76 -0
- package/src/affects/catalog.ts +17 -0
- package/src/affects/index.ts +240 -0
- package/src/affects/inert.ts +63 -0
- package/src/affects/matchers/package.ts +102 -0
- package/src/affects/matchers/path.ts +37 -0
- package/src/check/index.ts +121 -0
- package/src/graph/build.ts +94 -0
- package/src/import/classify.ts +53 -0
- package/src/import/fingerprint.ts +10 -0
- package/src/import/index.ts +240 -0
- package/src/import/madr.ts +117 -0
- package/src/import/merge.ts +321 -0
- package/src/import/status.ts +48 -0
- package/src/index.ts +13 -0
- package/src/load/corpus.ts +101 -0
- package/src/parse/frontmatter.ts +73 -0
- package/src/scaffold/new.ts +208 -0
- package/src/schema/adr.schema.ts +338 -0
- package/src/schema/emit.cli.ts +9 -0
- package/src/schema/emit.ts +55 -0
- package/src/schema/index.ts +2 -0
- package/src/validate/contract.ts +120 -0
- package/src/validate/corpus-invariants.ts +77 -0
- package/src/validate/findings.ts +51 -0
- package/src/validate/import-incomplete.ts +23 -0
- 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
|
+
}
|