@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,53 @@
1
+ import type { Adr } from '../schema/adr.schema.ts';
2
+
3
+ export type ReimportBucket = 'new' | 'updated' | 'diverged' | 'unchanged';
4
+
5
+ export interface ReimportSourceEntry {
6
+ sourceRef: string;
7
+ fingerprint: string;
8
+ path?: string;
9
+ }
10
+
11
+ export interface ReimportClassification {
12
+ sourceRef: string;
13
+ bucket: ReimportBucket;
14
+ recordId?: string;
15
+ }
16
+
17
+ function importedMadrSourceRef(record: Adr): string | undefined {
18
+ const importedFrom = record.frontmatter.provenance?.importedFrom;
19
+ if (importedFrom?.sourceKind !== 'madr') return undefined;
20
+ return importedFrom.sourceRef;
21
+ }
22
+
23
+ export function classifyReimport(
24
+ sourceEntries: readonly ReimportSourceEntry[],
25
+ existingRecords: readonly Adr[],
26
+ recordEdited: (id: string) => boolean = () => false,
27
+ ): ReimportClassification[] {
28
+ const recordsBySourceRef = new Map<string, Adr>();
29
+ for (const record of [...existingRecords].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id))) {
30
+ const sourceRef = importedMadrSourceRef(record);
31
+ if (sourceRef && !recordsBySourceRef.has(sourceRef)) {
32
+ recordsBySourceRef.set(sourceRef, record);
33
+ }
34
+ }
35
+
36
+ return [...sourceEntries]
37
+ .sort((a, b) => a.sourceRef.localeCompare(b.sourceRef))
38
+ .map((entry) => {
39
+ const record = recordsBySourceRef.get(entry.sourceRef);
40
+ if (!record) return { sourceRef: entry.sourceRef, bucket: 'new' as const };
41
+
42
+ const storedFingerprint = record.frontmatter.provenance?.importedFrom?.fingerprint;
43
+ if (storedFingerprint === entry.fingerprint) {
44
+ return { sourceRef: entry.sourceRef, bucket: 'unchanged' as const, recordId: record.frontmatter.id };
45
+ }
46
+
47
+ return {
48
+ sourceRef: entry.sourceRef,
49
+ bucket: recordEdited(record.frontmatter.id) ? ('diverged' as const) : ('updated' as const),
50
+ recordId: record.frontmatter.id,
51
+ };
52
+ });
53
+ }
@@ -0,0 +1,10 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function normalizeSourceBody(sourceBody: string): string {
4
+ const normalizedLines = sourceBody.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
5
+ return `${normalizedLines.replace(/\n*$/g, '')}\n`;
6
+ }
7
+
8
+ export function fingerprintSourceBody(sourceBody: string): string {
9
+ return createHash('sha256').update(normalizeSourceBody(sourceBody), 'utf8').digest('hex');
10
+ }
@@ -0,0 +1,240 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+ import { parseFrontmatter } from '../parse/frontmatter.ts';
4
+ import { normalizeDisplayPath } from '../load/corpus.ts';
5
+ import { AdrFrontmatter, type Adr } from '../schema/adr.schema.ts';
6
+ import { nextSequentialId } from '../scaffold/new.ts';
7
+ import { sortFindings, type Finding } from '../validate/findings.ts';
8
+ import { validateImportIncomplete } from '../validate/import-incomplete.ts';
9
+ import { classifyReimport, type ReimportBucket, type ReimportClassification } from './classify.ts';
10
+ import { fingerprintSourceBody } from './fingerprint.ts';
11
+ import { discoverMadrCandidateFiles, fileNameId, readMadrFile, type MadrSourceFile } from './madr.ts';
12
+ import { MadrMergeError, mergeMadr, recordFromMerge } from './merge.ts';
13
+
14
+ export { classifyReimport } from './classify.ts';
15
+ export type { ReimportBucket, ReimportClassification, ReimportSourceEntry } from './classify.ts';
16
+ export { fingerprintSourceBody, normalizeSourceBody } from './fingerprint.ts';
17
+ export { readMadrFile, discoverMadrCandidateFiles } from './madr.ts';
18
+ export { mapMadrStatus } from './status.ts';
19
+ export { mergeMadr, renderMigratedContent } from './merge.ts';
20
+
21
+ export type MigrateOutcome = 'migrated' | 'updated' | 'unchanged' | 'diverged' | 'skipped';
22
+
23
+ export interface MigrateMadrInput {
24
+ dir?: string;
25
+ files?: string[];
26
+ existingRecords?: Adr[];
27
+ recordEdited?: (id: string) => boolean;
28
+ cwd?: string;
29
+ write?: boolean;
30
+ }
31
+
32
+ export interface MigrateMadrResultItem {
33
+ path: string;
34
+ outcome: MigrateOutcome;
35
+ frontmatter?: AdrFrontmatter;
36
+ }
37
+
38
+ export interface MigrateMadrDivergenceItem {
39
+ path: string;
40
+ sourceRef: string;
41
+ }
42
+
43
+ export interface MigrateMadrResult {
44
+ results: MigrateMadrResultItem[];
45
+ divergence: MigrateMadrDivergenceItem[];
46
+ findings: Finding[];
47
+ }
48
+
49
+ interface PreparedSource {
50
+ source: MadrSourceFile;
51
+ sourceRef: string;
52
+ fingerprint: string;
53
+ }
54
+
55
+ function toAbsolutePath(path: string, cwd: string): string {
56
+ return isAbsolute(path) ? path : resolve(cwd, path);
57
+ }
58
+
59
+ function notMadrFinding(path: string, reason: string): Finding {
60
+ return {
61
+ rule: 'import-not-madr',
62
+ severity: 'warn',
63
+ message: reason,
64
+ path,
65
+ };
66
+ }
67
+
68
+ async function existingRecordsFromFiles(files: readonly string[], cwd: string): Promise<Adr[]> {
69
+ const records: Adr[] = [];
70
+ for (const file of files) {
71
+ const absolutePath = toAbsolutePath(file, cwd);
72
+ try {
73
+ const source = await readFile(absolutePath, 'utf8');
74
+ const parsed = parseFrontmatter(source);
75
+ const frontmatter = AdrFrontmatter.safeParse(parsed.data);
76
+ if (frontmatter.success) {
77
+ records.push({
78
+ frontmatter: frontmatter.data,
79
+ body: parsed.body,
80
+ path: normalizeDisplayPath(absolutePath, cwd),
81
+ });
82
+ }
83
+ } catch {
84
+ // Non-ADR sources are handled by the MADR reader; this loader is best-effort.
85
+ }
86
+ }
87
+ return records.sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id));
88
+ }
89
+
90
+ function recordById(records: readonly Adr[]): Map<string, Adr> {
91
+ return new Map(records.map((record) => [record.frontmatter.id, record]));
92
+ }
93
+
94
+ function classifyBySourceRef(classifications: readonly ReimportClassification[]): Map<string, ReimportClassification> {
95
+ return new Map(classifications.map((classification) => [classification.sourceRef, classification]));
96
+ }
97
+
98
+ function numericId(id: string): number | undefined {
99
+ return /^\d+$/.test(id) ? Number(id) : undefined;
100
+ }
101
+
102
+ function rawId(source: MadrSourceFile): string | undefined {
103
+ const id = source.frontmatter.id;
104
+ return typeof id === 'string' && /^([0-9]{4,}|[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26})$/.test(id) ? id : undefined;
105
+ }
106
+
107
+ function createIdAllocator(seed: string, usedIds: Iterable<string>): (source: MadrSourceFile, classification?: ReimportClassification) => string {
108
+ const used = new Set(usedIds);
109
+ let next = numericId(seed) ?? 1;
110
+
111
+ return (source, classification) => {
112
+ const existing = classification?.recordId;
113
+ if (existing) return existing;
114
+
115
+ const sourceId = rawId(source);
116
+ if (sourceId) {
117
+ used.add(sourceId);
118
+ return sourceId;
119
+ }
120
+
121
+ const fileId = fileNameId(source.absolutePath);
122
+ if (fileId && !used.has(fileId)) {
123
+ used.add(fileId);
124
+ return fileId;
125
+ }
126
+
127
+ for (;;) {
128
+ const id = String(next).padStart(4, '0');
129
+ next += 1;
130
+ if (!used.has(id)) {
131
+ used.add(id);
132
+ return id;
133
+ }
134
+ }
135
+ };
136
+ }
137
+
138
+ function importIncompleteRecords(records: readonly Adr[]): Adr[] {
139
+ const seen = new Set<string>();
140
+ const unique: Adr[] = [];
141
+ for (const record of records) {
142
+ const key = `${record.frontmatter.id}\0${record.path}`;
143
+ if (seen.has(key)) continue;
144
+ seen.add(key);
145
+ unique.push(record);
146
+ }
147
+ return unique;
148
+ }
149
+
150
+ export async function migrateMadr(input: MigrateMadrInput): Promise<MigrateMadrResult> {
151
+ const cwd = input.cwd ?? process.cwd();
152
+ const dir = input.dir ?? 'docs/adr';
153
+ const write = input.write !== false;
154
+ const files = input.files
155
+ ? input.files.map((file) => toAbsolutePath(file, cwd)).sort((a, b) => normalizeDisplayPath(a, cwd).localeCompare(normalizeDisplayPath(b, cwd)))
156
+ : await discoverMadrCandidateFiles(dir, cwd);
157
+
158
+ const findings: Finding[] = [];
159
+ const results: MigrateMadrResultItem[] = [];
160
+ const divergence: MigrateMadrDivergenceItem[] = [];
161
+ const prepared: PreparedSource[] = [];
162
+
163
+ for (const file of files) {
164
+ const read = await readMadrFile(file, cwd);
165
+ if (read.kind === 'not-madr') {
166
+ results.push({ path: read.path, outcome: 'skipped' });
167
+ findings.push(notMadrFinding(read.path, read.reason));
168
+ continue;
169
+ }
170
+ const sourceRef = read.path;
171
+ prepared.push({ source: read, sourceRef, fingerprint: fingerprintSourceBody(read.body) });
172
+ }
173
+
174
+ const existingRecords = input.existingRecords ?? (await existingRecordsFromFiles(files, cwd));
175
+ const classifications = classifyReimport(
176
+ prepared.map((entry) => ({ sourceRef: entry.sourceRef, fingerprint: entry.fingerprint, path: entry.source.path })),
177
+ existingRecords,
178
+ input.recordEdited ?? (() => false),
179
+ );
180
+ const classificationForSource = classifyBySourceRef(classifications);
181
+ const existingById = recordById(existingRecords);
182
+ const seed = await nextSequentialId(dir, cwd).catch(() => '0001');
183
+ const allocateId = createIdAllocator(seed, [
184
+ ...existingRecords.map((record) => record.frontmatter.id),
185
+ ...prepared.map((entry) => rawId(entry.source)).filter((id): id is string => Boolean(id)),
186
+ ]);
187
+ const recordsForIncomplete: Adr[] = [];
188
+
189
+ for (const entry of prepared.sort((a, b) => a.source.path.localeCompare(b.source.path))) {
190
+ const classification = classificationForSource.get(entry.sourceRef);
191
+ const bucket: ReimportBucket = classification?.bucket ?? 'new';
192
+
193
+ if (bucket === 'diverged') {
194
+ results.push({ path: entry.source.path, outcome: 'diverged' });
195
+ divergence.push({ path: entry.source.path, sourceRef: entry.sourceRef });
196
+ const existing = classification?.recordId ? existingById.get(classification.recordId) : undefined;
197
+ if (existing) recordsForIncomplete.push(existing);
198
+ continue;
199
+ }
200
+
201
+ if (bucket === 'unchanged') {
202
+ results.push({ path: entry.source.path, outcome: 'unchanged' });
203
+ const existing = classification?.recordId ? existingById.get(classification.recordId) : undefined;
204
+ if (existing) recordsForIncomplete.push(existing);
205
+ continue;
206
+ }
207
+
208
+ try {
209
+ const id = allocateId(entry.source, classification);
210
+ const merged = mergeMadr({
211
+ source: entry.source,
212
+ id,
213
+ sourceRef: entry.sourceRef,
214
+ fingerprint: entry.fingerprint,
215
+ });
216
+ findings.push(...merged.findings);
217
+ const outcome = bucket === 'updated' ? 'updated' : 'migrated';
218
+ if (write && merged.content !== entry.source.source) {
219
+ await writeFile(entry.source.absolutePath, merged.content, 'utf8');
220
+ }
221
+ results.push({ path: entry.source.path, outcome, frontmatter: merged.frontmatter });
222
+ recordsForIncomplete.push(recordFromMerge(merged, entry.source));
223
+ } catch (error) {
224
+ if (error instanceof MadrMergeError) {
225
+ results.push({ path: entry.source.path, outcome: 'skipped' });
226
+ findings.push(...error.findings);
227
+ continue;
228
+ }
229
+ throw error;
230
+ }
231
+ }
232
+
233
+ findings.push(...validateImportIncomplete(importIncompleteRecords(recordsForIncomplete)));
234
+
235
+ return {
236
+ results: results.sort((a, b) => a.path.localeCompare(b.path)),
237
+ divergence: divergence.sort((a, b) => a.path.localeCompare(b.path) || a.sourceRef.localeCompare(b.sourceRef)),
238
+ findings: sortFindings(findings),
239
+ };
240
+ }
@@ -0,0 +1,117 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { basename, isAbsolute, join, resolve } from 'node:path';
3
+ import { FrontmatterError, parseFrontmatter } from '../parse/frontmatter.ts';
4
+ import { normalizeDisplayPath } from '../load/corpus.ts';
5
+
6
+ export interface MadrSourceFile {
7
+ kind: 'madr';
8
+ path: string;
9
+ absolutePath: string;
10
+ frontmatter: Record<string, unknown>;
11
+ body: string;
12
+ source: string;
13
+ title?: string;
14
+ }
15
+
16
+ export interface NotMadrFile {
17
+ kind: 'not-madr';
18
+ path: string;
19
+ absolutePath: string;
20
+ reason: string;
21
+ }
22
+
23
+ export type ReadMadrFileResult = MadrSourceFile | NotMadrFile;
24
+
25
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
26
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
27
+ }
28
+
29
+ function firstLine(source: string): string {
30
+ const lineEnd = source.indexOf('\n');
31
+ const line = lineEnd === -1 ? source : source.slice(0, lineEnd);
32
+ return line.endsWith('\r') ? line.slice(0, -1) : line;
33
+ }
34
+
35
+ export function extractMadrTitle(frontmatter: Record<string, unknown>, body: string): string | undefined {
36
+ const frontmatterTitle = frontmatter.title;
37
+ if (typeof frontmatterTitle === 'string' && frontmatterTitle.trim().length > 0) {
38
+ return frontmatterTitle.trim();
39
+ }
40
+
41
+ const heading = /^#\s+(.+?)\s*#*\s*$/m.exec(body);
42
+ const title = heading?.[1]?.trim();
43
+ return title && title.length > 0 ? title : undefined;
44
+ }
45
+
46
+ function notMadr(path: string, absolutePath: string, reason: string): NotMadrFile {
47
+ return { kind: 'not-madr', path, absolutePath, reason };
48
+ }
49
+
50
+ export async function readMadrFile(path: string, cwd = process.cwd()): Promise<ReadMadrFileResult> {
51
+ const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
52
+ const displayPath = normalizeDisplayPath(absolutePath, cwd);
53
+ const source = await readFile(absolutePath, 'utf8');
54
+ const hasLeadingFence = firstLine(source) === '---';
55
+
56
+ if (hasLeadingFence) {
57
+ try {
58
+ const parsed = parseFrontmatter(source);
59
+ const frontmatter = isPlainRecord(parsed.data) ? parsed.data : {};
60
+ return {
61
+ kind: 'madr',
62
+ path: displayPath,
63
+ absolutePath,
64
+ frontmatter,
65
+ body: parsed.body,
66
+ source,
67
+ title: extractMadrTitle(frontmatter, parsed.body),
68
+ };
69
+ } catch (error) {
70
+ const reason = error instanceof FrontmatterError ? error.message : String(error);
71
+ return notMadr(displayPath, absolutePath, reason);
72
+ }
73
+ }
74
+
75
+ const title = extractMadrTitle({}, source);
76
+ if (!title) {
77
+ return notMadr(displayPath, absolutePath, 'File has no leading YAML frontmatter or top-level title');
78
+ }
79
+
80
+ return {
81
+ kind: 'madr',
82
+ path: displayPath,
83
+ absolutePath,
84
+ frontmatter: {},
85
+ body: source,
86
+ source,
87
+ title,
88
+ };
89
+ }
90
+
91
+ async function discoverMarkdownFilesInDir(dir: string): Promise<string[]> {
92
+ const entries = await readdir(dir, { withFileTypes: true });
93
+ const files: string[] = [];
94
+ for (const entry of entries) {
95
+ const path = join(dir, entry.name);
96
+ if (entry.isDirectory()) {
97
+ files.push(...(await discoverMarkdownFilesInDir(path)));
98
+ } else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== '0000-template.md') {
99
+ files.push(path);
100
+ }
101
+ }
102
+ return files;
103
+ }
104
+
105
+ export async function discoverMadrCandidateFiles(dir = 'docs/adr', cwd = process.cwd()): Promise<string[]> {
106
+ const absoluteDir = isAbsolute(dir) ? dir : resolve(cwd, dir);
107
+ const files = await discoverMarkdownFilesInDir(absoluteDir).catch(() => []);
108
+ return files.sort((a, b) => normalizeDisplayPath(a, cwd).localeCompare(normalizeDisplayPath(b, cwd)));
109
+ }
110
+
111
+ export function sourceRefForPath(path: string, cwd = process.cwd()): string {
112
+ return normalizeDisplayPath(path, cwd);
113
+ }
114
+
115
+ export function fileNameId(path: string): string | undefined {
116
+ return /^([0-9]{4,})-/.exec(basename(path))?.[1];
117
+ }