@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,120 @@
1
+ import type { z } from 'zod';
2
+ import { AdrFrontmatter, type Adr } from '../schema/adr.schema.ts';
3
+ import type { ParsedAdrFile } from '../load/corpus.ts';
4
+ import type { Finding } from './findings.ts';
5
+
6
+ export interface ContractValidationResult {
7
+ record?: Adr;
8
+ findings: Finding[];
9
+ }
10
+
11
+ function fieldPath(path: readonly PropertyKey[]): string | undefined {
12
+ return path.length === 0 ? undefined : path.map(String).join('.');
13
+ }
14
+
15
+ function rawId(data: unknown): string | undefined {
16
+ if (data && typeof data === 'object' && 'id' in data) {
17
+ const id = (data as { id?: unknown }).id;
18
+ return typeof id === 'string' ? id : undefined;
19
+ }
20
+ return undefined;
21
+ }
22
+
23
+ function ruleForIssue(issue: z.core.$ZodIssue): string {
24
+ if (issue.code === 'unrecognized_keys') {
25
+ return 'strict-unknown-key';
26
+ }
27
+
28
+ if (issue.code === 'custom') {
29
+ if (issue.message === 'status "superseded" requires supersededBy') {
30
+ return 'superseded-requires-supersededBy';
31
+ }
32
+ if (issue.message === 'supersededBy is set but status is not "superseded"') {
33
+ return 'supersededBy-requires-superseded-status';
34
+ }
35
+ if (issue.message === 'an accepted decision must name at least one decider, unless it was imported') {
36
+ return 'accepted-requires-decider-unless-imported';
37
+ }
38
+ if (
39
+ issue.message ===
40
+ 'an agent-authored record cannot reach "accepted" without a named human ratifier'
41
+ ) {
42
+ return 'agent-accepted-requires-ratifier';
43
+ }
44
+ if (issue.message === 'one-way-door decisions may not take the auto-approve fast path') {
45
+ return 'one-way-door-disallows-auto';
46
+ }
47
+ if (issue.message === 'Items must be unique') {
48
+ return 'unique-items';
49
+ }
50
+ return 'contract-refinement';
51
+ }
52
+
53
+ if (issue.code === 'invalid_type') {
54
+ return issue.message.includes('undefined') ? 'required-field' : 'invalid-type';
55
+ }
56
+
57
+ if (issue.code === 'invalid_value') {
58
+ return 'invalid-enum-value';
59
+ }
60
+
61
+ if (issue.code === 'invalid_format') {
62
+ return 'invalid-format';
63
+ }
64
+
65
+ if (issue.code === 'too_small' || issue.code === 'too_big') {
66
+ return 'invalid-size';
67
+ }
68
+
69
+ return `contract-${issue.code}`;
70
+ }
71
+
72
+ function issueFindings(issue: z.core.$ZodIssue, path: string, id?: string): Finding[] {
73
+ if (issue.code === 'unrecognized_keys') {
74
+ return issue.keys.map((key) => {
75
+ const field = fieldPath([...issue.path, key]) ?? key;
76
+ return {
77
+ rule: 'strict-unknown-key',
78
+ severity: 'error' as const,
79
+ message: `Unknown frontmatter field "${field}" is not allowed`,
80
+ path,
81
+ id,
82
+ field,
83
+ };
84
+ });
85
+ }
86
+
87
+ return [
88
+ {
89
+ rule: ruleForIssue(issue),
90
+ severity: 'error',
91
+ message: issue.message,
92
+ path,
93
+ id,
94
+ field: fieldPath(issue.path),
95
+ },
96
+ ];
97
+ }
98
+
99
+ export function validateParsedAdr(parsed: ParsedAdrFile): ContractValidationResult {
100
+ const result = AdrFrontmatter.safeParse(parsed.data);
101
+ if (result.success) {
102
+ return {
103
+ findings: [],
104
+ record: {
105
+ frontmatter: result.data,
106
+ body: parsed.body,
107
+ path: parsed.path,
108
+ },
109
+ };
110
+ }
111
+
112
+ const id = rawId(parsed.data);
113
+ return {
114
+ findings: result.error.issues.flatMap((issue) => issueFindings(issue, parsed.path, id)),
115
+ };
116
+ }
117
+
118
+ export function validateAdrFrontmatter(data: unknown, path: string): ContractValidationResult {
119
+ return validateParsedAdr({ data, body: '', path, absolutePath: path });
120
+ }
@@ -0,0 +1,77 @@
1
+ import type { Adr } from '../schema/adr.schema.ts';
2
+ import type { Finding, FindingSeverity } from './findings.ts';
3
+
4
+ function addReferenceFinding(
5
+ findings: Finding[],
6
+ record: Adr,
7
+ field: string,
8
+ ref: string,
9
+ severity: FindingSeverity,
10
+ ): void {
11
+ findings.push({
12
+ rule: `dangling-${field}`,
13
+ severity,
14
+ message: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`,
15
+ path: record.path,
16
+ id: record.frontmatter.id,
17
+ field,
18
+ });
19
+ }
20
+
21
+ export function validateCorpusInvariants(records: readonly Adr[]): Finding[] {
22
+ const findings: Finding[] = [];
23
+ const byId = new Map<string, Adr[]>();
24
+
25
+ for (const record of records) {
26
+ const recordsForId = byId.get(record.frontmatter.id) ?? [];
27
+ recordsForId.push(record);
28
+ byId.set(record.frontmatter.id, recordsForId);
29
+ }
30
+
31
+ for (const [id, duplicates] of byId) {
32
+ if (duplicates.length <= 1) continue;
33
+ const paths = duplicates.map((record) => record.path).sort();
34
+ for (const record of duplicates) {
35
+ findings.push({
36
+ rule: 'unique-id',
37
+ severity: 'error',
38
+ message: `ADR id "${id}" is used by multiple records: ${paths.join(', ')}`,
39
+ path: record.path,
40
+ id,
41
+ field: 'id',
42
+ });
43
+ }
44
+ }
45
+
46
+ const ids = new Set(byId.keys());
47
+ for (const record of records) {
48
+ for (const ref of record.frontmatter.supersedes) {
49
+ if (!ids.has(ref)) addReferenceFinding(findings, record, 'supersedes', ref, 'error');
50
+ }
51
+
52
+ if (record.frontmatter.supersededBy && !ids.has(record.frontmatter.supersededBy)) {
53
+ addReferenceFinding(findings, record, 'supersededBy', record.frontmatter.supersededBy, 'error');
54
+ }
55
+
56
+ for (const ref of record.frontmatter.relatesTo) {
57
+ if (!ids.has(ref)) addReferenceFinding(findings, record, 'relatesTo', ref, 'error');
58
+ }
59
+
60
+ for (const ref of record.frontmatter.conflictsWith) {
61
+ if (!ids.has(ref)) {
62
+ addReferenceFinding(findings, record, 'conflictsWith', ref, 'warn');
63
+ } else if (record.frontmatter.status === 'accepted') {
64
+ findings.push({
65
+ rule: 'accepted-conflictsWith',
66
+ severity: 'warn',
67
+ message: `Accepted ADR declares a known conflict with "${ref}"`,
68
+ path: record.path,
69
+ id: record.frontmatter.id,
70
+ field: 'conflictsWith',
71
+ });
72
+ }
73
+ }
74
+ }
75
+
76
+ return findings;
77
+ }
@@ -0,0 +1,51 @@
1
+ export const IMPORT_FINDING_RULES = [
2
+ 'import-incomplete',
3
+ 'import-status-unrecognized',
4
+ 'import-not-madr',
5
+ ] as const;
6
+
7
+ export type ImportFindingRule = (typeof IMPORT_FINDING_RULES)[number];
8
+
9
+ export type FindingSeverity = 'error' | 'warn' | 'info';
10
+
11
+ export interface Finding {
12
+ rule: string;
13
+ severity: FindingSeverity;
14
+ message: string;
15
+ path?: string;
16
+ id?: string;
17
+ field?: string;
18
+ pattern?: string;
19
+ }
20
+
21
+ function compareOptional(a: string | undefined, b: string | undefined): number {
22
+ return (a ?? '').localeCompare(b ?? '');
23
+ }
24
+
25
+ export function sortFindings(findings: readonly Finding[]): Finding[] {
26
+ return [...findings].sort(
27
+ (a, b) =>
28
+ a.rule.localeCompare(b.rule) ||
29
+ compareOptional(a.id, b.id) ||
30
+ compareOptional(a.pattern, b.pattern) ||
31
+ compareOptional(a.path, b.path) ||
32
+ compareOptional(a.field, b.field) ||
33
+ a.message.localeCompare(b.message),
34
+ );
35
+ }
36
+
37
+ export function countFindings(findings: readonly Finding[]): { errors: number; warnings: number; infos: number } {
38
+ let errors = 0;
39
+ let warnings = 0;
40
+ let infos = 0;
41
+ for (const finding of findings) {
42
+ if (finding.severity === 'error') errors += 1;
43
+ if (finding.severity === 'warn') warnings += 1;
44
+ if (finding.severity === 'info') infos += 1;
45
+ }
46
+ return { errors, warnings, infos };
47
+ }
48
+
49
+ export function exitCodeForFindings(findings: readonly Finding[]): 0 | 1 {
50
+ return findings.some((finding) => finding.severity === 'error') ? 1 : 0;
51
+ }
@@ -0,0 +1,23 @@
1
+ import type { Adr } from '../schema/adr.schema.ts';
2
+ import type { Finding } from './findings.ts';
3
+
4
+ export function validateImportIncomplete(records: readonly Adr[]): Finding[] {
5
+ const findings: Finding[] = [];
6
+
7
+ for (const record of records) {
8
+ if (!record.frontmatter.provenance?.importedFrom) continue;
9
+
10
+ if (record.frontmatter.status === 'accepted' && record.frontmatter.deciders.length === 0) {
11
+ findings.push({
12
+ rule: 'import-incomplete',
13
+ severity: 'info',
14
+ message: 'Imported accepted decision has no deciders; provenance explains the gap, but it should be backfilled when known',
15
+ path: record.path,
16
+ id: record.frontmatter.id,
17
+ field: 'deciders',
18
+ });
19
+ }
20
+ }
21
+
22
+ return findings;
23
+ }
@@ -0,0 +1,68 @@
1
+ import { parseAdrFile, expandRecordInputs, normalizeDisplayPath } from '../load/corpus.ts';
2
+ import type { Adr } from '../schema/adr.schema.ts';
3
+ import { FrontmatterError } from '../parse/frontmatter.ts';
4
+ import { validateParsedAdr } from './contract.ts';
5
+ import { validateCorpusInvariants } from './corpus-invariants.ts';
6
+ import { validateImportIncomplete } from './import-incomplete.ts';
7
+ import { sortFindings, type Finding } from './findings.ts';
8
+
9
+ export interface LintCorpusOptions {
10
+ dir?: string;
11
+ paths?: string[];
12
+ cwd?: string;
13
+ }
14
+
15
+ export interface LintCorpusResult {
16
+ checked: number;
17
+ findings: Finding[];
18
+ records: Adr[];
19
+ }
20
+
21
+ function parseErrorFinding(error: unknown, path: string): Finding {
22
+ if (error instanceof FrontmatterError) {
23
+ return {
24
+ rule: error.code === 'invalid-yaml' ? 'frontmatter-parse' : 'frontmatter-fence',
25
+ severity: 'error',
26
+ message: error.message,
27
+ path,
28
+ field: 'frontmatter',
29
+ };
30
+ }
31
+
32
+ return {
33
+ rule: 'file-read',
34
+ severity: 'error',
35
+ message: error instanceof Error ? error.message : String(error),
36
+ path,
37
+ };
38
+ }
39
+
40
+ export async function lintCorpus(options: LintCorpusOptions = {}): Promise<LintCorpusResult> {
41
+ const cwd = options.cwd ?? process.cwd();
42
+ const files = await expandRecordInputs(options.paths, options.dir ?? 'docs/adr', cwd);
43
+ const records: Adr[] = [];
44
+ const findings: Finding[] = [];
45
+
46
+ for (const file of files) {
47
+ const displayPath = normalizeDisplayPath(file, cwd);
48
+ try {
49
+ const parsed = await parseAdrFile(file, cwd);
50
+ const result = validateParsedAdr(parsed);
51
+ findings.push(...result.findings);
52
+ if (result.record) {
53
+ records.push(result.record);
54
+ }
55
+ } catch (error) {
56
+ findings.push(parseErrorFinding(error, displayPath));
57
+ }
58
+ }
59
+
60
+ findings.push(...validateImportIncomplete(records));
61
+ findings.push(...validateCorpusInvariants(records));
62
+
63
+ return {
64
+ checked: files.length,
65
+ findings: sortFindings(findings),
66
+ records: [...records].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id)),
67
+ };
68
+ }