@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,321 @@
|
|
|
1
|
+
import { stringify } from 'yaml';
|
|
2
|
+
import { AdrFrontmatter, SCHEMA_VERSION, type Adr } from '../schema/adr.schema.ts';
|
|
3
|
+
import type { Finding } from '../validate/findings.ts';
|
|
4
|
+
import type { MadrSourceFile } from './madr.ts';
|
|
5
|
+
import { mapMadrStatus } from './status.ts';
|
|
6
|
+
|
|
7
|
+
export class MadrMergeError extends Error {
|
|
8
|
+
readonly findings: Finding[];
|
|
9
|
+
|
|
10
|
+
constructor(message: string, findings: Finding[]) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'MadrMergeError';
|
|
13
|
+
this.findings = findings;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MergeMadrOptions {
|
|
18
|
+
source: MadrSourceFile;
|
|
19
|
+
id: string;
|
|
20
|
+
sourceRef: string;
|
|
21
|
+
fingerprint: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MergeMadrResult {
|
|
25
|
+
frontmatter: AdrFrontmatter;
|
|
26
|
+
content: string;
|
|
27
|
+
findings: Finding[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type MutableRecord = Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
const FALLBACKS: Record<string, unknown> = {
|
|
33
|
+
deciders: [],
|
|
34
|
+
consulted: [],
|
|
35
|
+
informed: [],
|
|
36
|
+
tags: [],
|
|
37
|
+
scope: 'component',
|
|
38
|
+
reversibility: 'unknown',
|
|
39
|
+
blastRadius: 'component',
|
|
40
|
+
supersedes: [],
|
|
41
|
+
relatesTo: [],
|
|
42
|
+
conflictsWith: [],
|
|
43
|
+
affects: [],
|
|
44
|
+
assertions: [],
|
|
45
|
+
externalRefs: [],
|
|
46
|
+
complianceControls: [],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const OPTIONAL_COPY_KEYS = [
|
|
50
|
+
'created',
|
|
51
|
+
'deciders',
|
|
52
|
+
'consulted',
|
|
53
|
+
'informed',
|
|
54
|
+
'tags',
|
|
55
|
+
'scope',
|
|
56
|
+
'domain',
|
|
57
|
+
'reversibility',
|
|
58
|
+
'blastRadius',
|
|
59
|
+
'supersedes',
|
|
60
|
+
'supersededBy',
|
|
61
|
+
'relatesTo',
|
|
62
|
+
'conflictsWith',
|
|
63
|
+
'affects',
|
|
64
|
+
'assertions',
|
|
65
|
+
'review',
|
|
66
|
+
'evaluation',
|
|
67
|
+
'externalRefs',
|
|
68
|
+
'complianceControls',
|
|
69
|
+
'reviewBy',
|
|
70
|
+
] as const;
|
|
71
|
+
|
|
72
|
+
const OPTIONAL_REMOVE_KEYS = new Set<string>([
|
|
73
|
+
'created',
|
|
74
|
+
'domain',
|
|
75
|
+
'supersededBy',
|
|
76
|
+
'review',
|
|
77
|
+
'evaluation',
|
|
78
|
+
'reviewBy',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const FRONTMATTER_ORDER = [
|
|
82
|
+
'schemaVersion',
|
|
83
|
+
'id',
|
|
84
|
+
'title',
|
|
85
|
+
'status',
|
|
86
|
+
'date',
|
|
87
|
+
'created',
|
|
88
|
+
'deciders',
|
|
89
|
+
'consulted',
|
|
90
|
+
'informed',
|
|
91
|
+
'tags',
|
|
92
|
+
'scope',
|
|
93
|
+
'domain',
|
|
94
|
+
'reversibility',
|
|
95
|
+
'blastRadius',
|
|
96
|
+
'supersedes',
|
|
97
|
+
'supersededBy',
|
|
98
|
+
'relatesTo',
|
|
99
|
+
'conflictsWith',
|
|
100
|
+
'affects',
|
|
101
|
+
'assertions',
|
|
102
|
+
'provenance',
|
|
103
|
+
'review',
|
|
104
|
+
'evaluation',
|
|
105
|
+
'externalRefs',
|
|
106
|
+
'complianceControls',
|
|
107
|
+
'reviewBy',
|
|
108
|
+
] as const;
|
|
109
|
+
|
|
110
|
+
function isRecord(value: unknown): value is MutableRecord {
|
|
111
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isValidId(value: unknown): value is string {
|
|
115
|
+
return typeof value === 'string' && /^([0-9]{4,}|[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26})$/.test(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isValidSchemaVersion(value: unknown): value is string {
|
|
119
|
+
return typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function dateFrom(value: unknown): string | undefined {
|
|
123
|
+
if (value instanceof Date && !Number.isNaN(value.valueOf())) {
|
|
124
|
+
return value.toISOString().slice(0, 10);
|
|
125
|
+
}
|
|
126
|
+
if (typeof value !== 'string') return undefined;
|
|
127
|
+
const trimmed = value.trim();
|
|
128
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return undefined;
|
|
129
|
+
const parsed = new Date(`${trimmed}T00:00:00.000Z`);
|
|
130
|
+
return parsed.toISOString().slice(0, 10) === trimmed ? trimmed : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function titleFinding(path: string, id?: string): Finding {
|
|
134
|
+
return {
|
|
135
|
+
rule: 'import-not-madr',
|
|
136
|
+
severity: 'warn',
|
|
137
|
+
message: 'MADR source is missing a usable title',
|
|
138
|
+
path,
|
|
139
|
+
id,
|
|
140
|
+
field: 'title',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function importedAtFrom(value: unknown): string | undefined {
|
|
145
|
+
if (!isRecord(value)) return undefined;
|
|
146
|
+
const importedFrom = value.importedFrom;
|
|
147
|
+
if (!isRecord(importedFrom)) return undefined;
|
|
148
|
+
return typeof importedFrom.importedAt === 'string' ? importedFrom.importedAt : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function provenanceFrom(raw: MutableRecord, sourceRef: string, fingerprint: string): MutableRecord {
|
|
152
|
+
const sourceProvenance = isRecord(raw.provenance) ? { ...raw.provenance } : {};
|
|
153
|
+
const authoredBy = sourceProvenance.authoredBy;
|
|
154
|
+
if (authoredBy !== 'human' && authoredBy !== 'agent' && authoredBy !== 'agent-drafted') {
|
|
155
|
+
sourceProvenance.authoredBy = 'human';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const importedFrom: MutableRecord = {
|
|
159
|
+
sourceKind: 'madr',
|
|
160
|
+
sourceRef,
|
|
161
|
+
fingerprint,
|
|
162
|
+
};
|
|
163
|
+
const importedAt = importedAtFrom(sourceProvenance);
|
|
164
|
+
if (importedAt) importedFrom.importedAt = importedAt;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
...sourceProvenance,
|
|
168
|
+
importedFrom,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function initialCandidate(options: MergeMadrOptions, findings: Finding[]): MutableRecord {
|
|
173
|
+
const raw = options.source.frontmatter;
|
|
174
|
+
const id = isValidId(raw.id) ? raw.id : options.id;
|
|
175
|
+
const title = options.source.title;
|
|
176
|
+
if (!title || title.length < 3 || title.length > 120) {
|
|
177
|
+
throw new MadrMergeError('MADR source is missing a usable title', [titleFinding(options.source.path, id)]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const status = mapMadrStatus(raw.status, { path: options.source.path, id });
|
|
181
|
+
findings.push(...status.findings);
|
|
182
|
+
|
|
183
|
+
const candidate: MutableRecord = {};
|
|
184
|
+
for (const key of OPTIONAL_COPY_KEYS) {
|
|
185
|
+
if (key in raw) candidate[key] = raw[key];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
candidate.schemaVersion = isValidSchemaVersion(raw.schemaVersion) ? raw.schemaVersion : SCHEMA_VERSION;
|
|
189
|
+
candidate.id = id;
|
|
190
|
+
candidate.title = title;
|
|
191
|
+
candidate.status = status.status;
|
|
192
|
+
candidate.date = dateFrom(raw.date) ?? dateFrom(raw.created) ?? '1970-01-01';
|
|
193
|
+
|
|
194
|
+
for (const [key, value] of Object.entries(FALLBACKS)) {
|
|
195
|
+
if (!(key in candidate) || candidate[key] === undefined || candidate[key] === null) {
|
|
196
|
+
candidate[key] = Array.isArray(value) ? [...value] : value;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
candidate.provenance = provenanceFrom(raw, options.sourceRef, options.fingerprint);
|
|
201
|
+
return candidate;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function safeParseCandidate(candidate: MutableRecord, sourcePath: string): AdrFrontmatter {
|
|
205
|
+
const working = { ...candidate };
|
|
206
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
207
|
+
const parsed = AdrFrontmatter.safeParse(working);
|
|
208
|
+
if (parsed.success) return parsed.data;
|
|
209
|
+
|
|
210
|
+
let changed = false;
|
|
211
|
+
for (const issue of parsed.error.issues) {
|
|
212
|
+
const top = issue.path[0];
|
|
213
|
+
if (typeof top !== 'string') continue;
|
|
214
|
+
|
|
215
|
+
if (top in FALLBACKS) {
|
|
216
|
+
const fallback = FALLBACKS[top];
|
|
217
|
+
working[top] = Array.isArray(fallback) ? [...fallback] : fallback;
|
|
218
|
+
changed = true;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (OPTIONAL_REMOVE_KEYS.has(top)) {
|
|
223
|
+
delete working[top];
|
|
224
|
+
changed = true;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (top === 'provenance') {
|
|
229
|
+
working.provenance = candidate.provenance;
|
|
230
|
+
changed = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!changed) {
|
|
235
|
+
throw new MadrMergeError(
|
|
236
|
+
`Migrated frontmatter for ${sourcePath} would not satisfy the adrkit schema`,
|
|
237
|
+
parsed.error.issues.map((issue) => ({
|
|
238
|
+
rule: 'import-not-madr',
|
|
239
|
+
severity: 'warn' as const,
|
|
240
|
+
message: issue.message,
|
|
241
|
+
path: sourcePath,
|
|
242
|
+
id: typeof working.id === 'string' ? working.id : undefined,
|
|
243
|
+
field: issue.path.length > 0 ? issue.path.map(String).join('.') : 'frontmatter',
|
|
244
|
+
})),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
throw new MadrMergeError(`Unable to produce valid frontmatter for ${sourcePath}`, [
|
|
250
|
+
{
|
|
251
|
+
rule: 'import-not-madr',
|
|
252
|
+
severity: 'warn',
|
|
253
|
+
message: 'Unable to produce valid frontmatter after removing invalid optional fields',
|
|
254
|
+
path: sourcePath,
|
|
255
|
+
id: typeof working.id === 'string' ? working.id : undefined,
|
|
256
|
+
field: 'frontmatter',
|
|
257
|
+
},
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function orderedObject(value: MutableRecord, order: readonly string[]): MutableRecord {
|
|
262
|
+
const output: MutableRecord = {};
|
|
263
|
+
for (const key of order) {
|
|
264
|
+
if (value[key] !== undefined) output[key] = value[key];
|
|
265
|
+
}
|
|
266
|
+
for (const key of Object.keys(value).sort()) {
|
|
267
|
+
if (!(key in output) && value[key] !== undefined) output[key] = value[key];
|
|
268
|
+
}
|
|
269
|
+
return output;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function orderProvenance(provenance: NonNullable<AdrFrontmatter['provenance']>): MutableRecord {
|
|
273
|
+
const output: MutableRecord = {};
|
|
274
|
+
for (const key of ['authoredBy', 'agent', 'ratifiedBy', 'sourceArtifact'] as const) {
|
|
275
|
+
if (provenance[key] !== undefined) output[key] = provenance[key];
|
|
276
|
+
}
|
|
277
|
+
if (provenance.importedFrom) {
|
|
278
|
+
const importedFrom: MutableRecord = {
|
|
279
|
+
sourceKind: provenance.importedFrom.sourceKind,
|
|
280
|
+
sourceRef: provenance.importedFrom.sourceRef,
|
|
281
|
+
fingerprint: provenance.importedFrom.fingerprint,
|
|
282
|
+
};
|
|
283
|
+
if (provenance.importedFrom.importedAt) importedFrom.importedAt = provenance.importedFrom.importedAt;
|
|
284
|
+
output.importedFrom = importedFrom;
|
|
285
|
+
}
|
|
286
|
+
return output;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function orderedFrontmatter(frontmatter: AdrFrontmatter): MutableRecord {
|
|
290
|
+
const raw = frontmatter as unknown as MutableRecord;
|
|
291
|
+
const output = orderedObject(raw, FRONTMATTER_ORDER);
|
|
292
|
+
if (frontmatter.provenance) output.provenance = orderProvenance(frontmatter.provenance);
|
|
293
|
+
return output;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function renderMigratedContent(frontmatter: AdrFrontmatter, body: string): string {
|
|
297
|
+
const yaml = stringify(orderedFrontmatter(frontmatter), {
|
|
298
|
+
lineWidth: 0,
|
|
299
|
+
sortMapEntries: false,
|
|
300
|
+
}).trimEnd();
|
|
301
|
+
return `---\n${yaml}\n---\n${body}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function mergeMadr(options: MergeMadrOptions): MergeMadrResult {
|
|
305
|
+
const findings: Finding[] = [];
|
|
306
|
+
const candidate = initialCandidate(options, findings);
|
|
307
|
+
const frontmatter = safeParseCandidate(candidate, options.source.path);
|
|
308
|
+
return {
|
|
309
|
+
frontmatter,
|
|
310
|
+
content: renderMigratedContent(frontmatter, options.source.body),
|
|
311
|
+
findings,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function recordFromMerge(result: MergeMadrResult, source: MadrSourceFile): Adr {
|
|
316
|
+
return {
|
|
317
|
+
frontmatter: result.frontmatter,
|
|
318
|
+
body: source.body,
|
|
319
|
+
path: source.path,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { AdrFrontmatter } from '../schema/adr.schema.ts';
|
|
2
|
+
import type { Finding } from '../validate/findings.ts';
|
|
3
|
+
|
|
4
|
+
export const MADR_RECOGNIZED_STATUSES = [
|
|
5
|
+
'draft',
|
|
6
|
+
'proposed',
|
|
7
|
+
'accepted',
|
|
8
|
+
'rejected',
|
|
9
|
+
'superseded',
|
|
10
|
+
'deprecated',
|
|
11
|
+
] as const satisfies readonly AdrFrontmatter['status'][];
|
|
12
|
+
|
|
13
|
+
const STATUS_SET = new Set<string>(MADR_RECOGNIZED_STATUSES);
|
|
14
|
+
|
|
15
|
+
export interface MadrStatusMapping {
|
|
16
|
+
status: AdrFrontmatter['status'];
|
|
17
|
+
findings: Finding[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rawStatusText(status: unknown): string | undefined {
|
|
21
|
+
if (typeof status !== 'string') return undefined;
|
|
22
|
+
const trimmed = status.trim();
|
|
23
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function mapMadrStatus(status: unknown, context: { path: string; id?: string }): MadrStatusMapping {
|
|
27
|
+
const raw = rawStatusText(status);
|
|
28
|
+
const normalized = raw?.toLowerCase();
|
|
29
|
+
if (normalized && STATUS_SET.has(normalized)) {
|
|
30
|
+
return { status: normalized as AdrFrontmatter['status'], findings: [] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
status: 'proposed',
|
|
35
|
+
findings: [
|
|
36
|
+
{
|
|
37
|
+
rule: 'import-status-unrecognized',
|
|
38
|
+
severity: 'warn',
|
|
39
|
+
message: raw
|
|
40
|
+
? `MADR status "${raw}" is not recognized; using "proposed"`
|
|
41
|
+
: 'MADR status is missing; using "proposed"',
|
|
42
|
+
path: context.path,
|
|
43
|
+
id: context.id,
|
|
44
|
+
field: 'status',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './schema/index.ts';
|
|
2
|
+
export * from './parse/frontmatter.ts';
|
|
3
|
+
export * from './load/corpus.ts';
|
|
4
|
+
export * from './validate/findings.ts';
|
|
5
|
+
export * from './validate/contract.ts';
|
|
6
|
+
export * from './validate/corpus-invariants.ts';
|
|
7
|
+
export * from './validate/import-incomplete.ts';
|
|
8
|
+
export * from './validate/index.ts';
|
|
9
|
+
export * from './scaffold/new.ts';
|
|
10
|
+
export * from './graph/build.ts';
|
|
11
|
+
export * from './affects/index.ts';
|
|
12
|
+
export * from './check/index.ts';
|
|
13
|
+
export * from './import/index.ts';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { AdrFrontmatter, type Adr } from '../schema/adr.schema.ts';
|
|
4
|
+
import { parseFrontmatter } from '../parse/frontmatter.ts';
|
|
5
|
+
|
|
6
|
+
export const RECORD_FILE_PATTERN = /^[0-9]{4,}-.+\.md$/;
|
|
7
|
+
export const TEMPLATE_FILE_NAME = '0000-template.md';
|
|
8
|
+
|
|
9
|
+
export interface ParsedAdrFile {
|
|
10
|
+
data: unknown;
|
|
11
|
+
body: string;
|
|
12
|
+
path: string;
|
|
13
|
+
absolutePath: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Corpus {
|
|
17
|
+
records: Adr[];
|
|
18
|
+
byId: Map<string, Adr>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isRecordFileName(fileName: string): boolean {
|
|
22
|
+
return fileName !== TEMPLATE_FILE_NAME && RECORD_FILE_PATTERN.test(fileName);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeDisplayPath(path: string, cwd = process.cwd()): string {
|
|
26
|
+
const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
|
|
27
|
+
const displayPath = relative(cwd, absolutePath) || basename(absolutePath);
|
|
28
|
+
return displayPath.split(sep).join('/');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toAbsolutePath(path: string, cwd = process.cwd()): string {
|
|
32
|
+
return isAbsolute(path) ? path : resolve(cwd, path);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function discoverAdrFiles(dir = 'docs/adr', cwd = process.cwd()): Promise<string[]> {
|
|
36
|
+
const absoluteDir = toAbsolutePath(dir, cwd);
|
|
37
|
+
const entries = await readdir(absoluteDir, { withFileTypes: true });
|
|
38
|
+
return entries
|
|
39
|
+
.filter((entry) => entry.isFile() && isRecordFileName(entry.name))
|
|
40
|
+
.map((entry) => join(absoluteDir, entry.name))
|
|
41
|
+
.sort((a, b) => normalizeDisplayPath(a, cwd).localeCompare(normalizeDisplayPath(b, cwd)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function expandRecordInputs(
|
|
45
|
+
paths: string[] | undefined,
|
|
46
|
+
dir = 'docs/adr',
|
|
47
|
+
cwd = process.cwd(),
|
|
48
|
+
): Promise<string[]> {
|
|
49
|
+
if (!paths || paths.length === 0) {
|
|
50
|
+
return discoverAdrFiles(dir, cwd);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const expanded: string[] = [];
|
|
54
|
+
for (const inputPath of paths) {
|
|
55
|
+
const absolutePath = toAbsolutePath(inputPath, cwd);
|
|
56
|
+
try {
|
|
57
|
+
const inputStat = await stat(absolutePath);
|
|
58
|
+
if (inputStat.isDirectory()) {
|
|
59
|
+
expanded.push(...(await discoverAdrFiles(absolutePath, cwd)));
|
|
60
|
+
} else if (inputStat.isFile()) {
|
|
61
|
+
expanded.push(absolutePath);
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
expanded.push(absolutePath);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Array.from(new Set(expanded)).sort((a, b) =>
|
|
69
|
+
normalizeDisplayPath(a, cwd).localeCompare(normalizeDisplayPath(b, cwd)),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function parseAdrFile(path: string, cwd = process.cwd()): Promise<ParsedAdrFile> {
|
|
74
|
+
const absolutePath = toAbsolutePath(path, cwd);
|
|
75
|
+
const source = await readFile(absolutePath, 'utf8');
|
|
76
|
+
const parsed = parseFrontmatter(source);
|
|
77
|
+
return {
|
|
78
|
+
...parsed,
|
|
79
|
+
absolutePath,
|
|
80
|
+
path: normalizeDisplayPath(absolutePath, cwd),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function loadAdrFile(path: string, cwd = process.cwd()): Promise<Adr> {
|
|
85
|
+
const parsed = await parseAdrFile(path, cwd);
|
|
86
|
+
return {
|
|
87
|
+
frontmatter: AdrFrontmatter.parse(parsed.data),
|
|
88
|
+
body: parsed.body,
|
|
89
|
+
path: parsed.path,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function loadCorpus(dir = 'docs/adr', cwd = process.cwd()): Promise<Corpus> {
|
|
94
|
+
const files = await discoverAdrFiles(dir, cwd);
|
|
95
|
+
const records = await Promise.all(files.map((file) => loadAdrFile(file, cwd)));
|
|
96
|
+
const byId = new Map<string, Adr>();
|
|
97
|
+
for (const record of records) {
|
|
98
|
+
byId.set(record.frontmatter.id, record);
|
|
99
|
+
}
|
|
100
|
+
return { records, byId };
|
|
101
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { parseDocument } from 'yaml';
|
|
2
|
+
|
|
3
|
+
export type FrontmatterErrorCode =
|
|
4
|
+
| 'missing-frontmatter'
|
|
5
|
+
| 'unterminated-frontmatter'
|
|
6
|
+
| 'invalid-yaml';
|
|
7
|
+
|
|
8
|
+
export class FrontmatterError extends Error {
|
|
9
|
+
readonly code: FrontmatterErrorCode;
|
|
10
|
+
|
|
11
|
+
constructor(code: FrontmatterErrorCode, message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'FrontmatterError';
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ParsedFrontmatter {
|
|
19
|
+
data: unknown;
|
|
20
|
+
body: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function lineContent(source: string, start: number, end: number): string {
|
|
24
|
+
const raw = source.slice(start, end);
|
|
25
|
+
return raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseFrontmatter(source: string): ParsedFrontmatter {
|
|
29
|
+
const firstLineEnd = source.indexOf('\n');
|
|
30
|
+
const firstLine = firstLineEnd === -1 ? source : source.slice(0, firstLineEnd);
|
|
31
|
+
|
|
32
|
+
if ((firstLine.endsWith('\r') ? firstLine.slice(0, -1) : firstLine) !== '---') {
|
|
33
|
+
throw new FrontmatterError(
|
|
34
|
+
'missing-frontmatter',
|
|
35
|
+
'ADR files must start with a leading --- YAML frontmatter fence',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (firstLineEnd === -1) {
|
|
40
|
+
throw new FrontmatterError(
|
|
41
|
+
'unterminated-frontmatter',
|
|
42
|
+
'ADR frontmatter is missing its closing --- fence',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let lineStart = firstLineEnd + 1;
|
|
47
|
+
while (lineStart <= source.length) {
|
|
48
|
+
const nextNewline = source.indexOf('\n', lineStart);
|
|
49
|
+
const lineEnd = nextNewline === -1 ? source.length : nextNewline;
|
|
50
|
+
if (lineContent(source, lineStart, lineEnd) === '---') {
|
|
51
|
+
const yamlSource = source.slice(firstLineEnd + 1, lineStart);
|
|
52
|
+
const bodyStart = nextNewline === -1 ? source.length : nextNewline + 1;
|
|
53
|
+
const document = parseDocument(yamlSource, { strict: true, prettyErrors: false });
|
|
54
|
+
if (document.errors.length > 0) {
|
|
55
|
+
throw new FrontmatterError(
|
|
56
|
+
'invalid-yaml',
|
|
57
|
+
document.errors.map((error) => error.message).join('; '),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return { data: document.toJS(), body: source.slice(bodyStart) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (nextNewline === -1) {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
lineStart = nextNewline + 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw new FrontmatterError(
|
|
70
|
+
'unterminated-frontmatter',
|
|
71
|
+
'ADR frontmatter is missing its closing --- fence',
|
|
72
|
+
);
|
|
73
|
+
}
|