Skip to content

Commit d14c10d

Browse files
committed
Forgot to push
1 parent 37de182 commit d14c10d

2 files changed

Lines changed: 287 additions & 0 deletions

File tree

packages/core/src/templates.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { ArchitecturalDecisionRecord } from "./classes";
2+
import { adr2md, md2adr } from "./parser";
3+
import { adr2md400, md2adr400 } from "./madr400";
4+
import type { Adr2MdOptions, Md2AdrOptions } from "./parser";
5+
import type { FieldKey } from "./fields";
6+
import type { ConsequenceKind, MadrTemplateVersion } from "./types";
7+
8+
export interface MadrTemplateField {
9+
key: FieldKey;
10+
label: string;
11+
}
12+
13+
export type MadrTemplatePeopleFields = "deciders" | "decisionMakersConsultedInformed";
14+
15+
export interface MadrTemplateAdapter {
16+
version: MadrTemplateVersion;
17+
label: string;
18+
subLabel: string;
19+
description: string;
20+
fields: readonly MadrTemplateField[];
21+
peopleFields: MadrTemplatePeopleFields;
22+
optionArgumentKinds: readonly ConsequenceKind[];
23+
detect(markdown: string): boolean;
24+
parse(markdown: string, options?: Md2AdrOptions): ArchitecturalDecisionRecord;
25+
serialize(adr: ArchitecturalDecisionRecord, options?: Adr2MdOptions): string;
26+
carryOverOnSwitch(record: ArchitecturalDecisionRecord, from: MadrTemplateVersion): void;
27+
}
28+
29+
export interface MadrRoundTripOptions {
30+
parse?: Md2AdrOptions;
31+
serialize?: Adr2MdOptions;
32+
compare?: (original: string, serialized: string) => boolean;
33+
}
34+
35+
const FIELDS_212: readonly MadrTemplateField[] = [
36+
{ key: "date", label: "Date" },
37+
{ key: "status", label: "Status" },
38+
{ key: "deciders", label: "Deciders" },
39+
{ key: "technicalStory", label: "Technical Story" },
40+
{ key: "decisionDrivers", label: "Decision Drivers" },
41+
{ key: "optionDescription", label: "Option Description" },
42+
{ key: "optionProsAndCons", label: "Option Pros & Cons" },
43+
{ key: "positiveConsequences", label: "Positive Consequences" },
44+
{ key: "negativeConsequences", label: "Negative Consequences" },
45+
{ key: "links", label: "Links" },
46+
{ key: "relevantFiles", label: "Relevant Files" }
47+
];
48+
49+
const FIELDS_400: readonly MadrTemplateField[] = [
50+
{ key: "date", label: "Date" },
51+
{ key: "status", label: "Status" },
52+
{ key: "deciders", label: "Decision-makers" },
53+
{ key: "consulted", label: "Consulted" },
54+
{ key: "informed", label: "Informed" },
55+
{ key: "decisionDrivers", label: "Decision Drivers" },
56+
{ key: "optionDescription", label: "Option Description" },
57+
{ key: "optionProsAndCons", label: "Option Pros & Cons" },
58+
{ key: "consequences", label: "Consequences" },
59+
{ key: "confirmation", label: "Confirmation" },
60+
{ key: "moreInformation", label: "More Information" },
61+
{ key: "relevantFiles", label: "Relevant Files" }
62+
];
63+
64+
function detectsMadr400(markdown: string): boolean {
65+
const normalized = markdown.replace(/\r\n/g, "\n");
66+
return (
67+
normalized.startsWith("---\n") ||
68+
/^### Consequences$/m.test(normalized) ||
69+
/^### Confirmation$/m.test(normalized) ||
70+
/^## More Information$/m.test(normalized) ||
71+
/^\* Neutral, because /m.test(normalized)
72+
);
73+
}
74+
75+
const MADR_400_ADAPTER: MadrTemplateAdapter = {
76+
version: "4.0.0",
77+
label: "MADR 4.0.0",
78+
subLabel: "latest",
79+
description:
80+
"Decision-makers / consulted / informed, combined Consequences, Confirmation, neutral arguments and a More Information section.",
81+
fields: FIELDS_400,
82+
peopleFields: "decisionMakersConsultedInformed",
83+
optionArgumentKinds: ["good", "neutral", "bad"],
84+
detect: detectsMadr400,
85+
parse: (markdown) => md2adr400(markdown),
86+
serialize: (adr) => adr2md400(adr),
87+
carryOverOnSwitch: (record, from) => {
88+
if (from === "2.1.2" && record.decisionMakers === "" && record.deciders !== "") {
89+
record.decisionMakers = record.deciders;
90+
}
91+
}
92+
};
93+
94+
const MADR_212_ADAPTER: MadrTemplateAdapter = {
95+
version: "2.1.2",
96+
label: "MADR 2.1.2",
97+
subLabel: "classic",
98+
description: "The classic template: deciders, Technical Story, separate Positive / Negative Consequences and Links.",
99+
fields: FIELDS_212,
100+
peopleFields: "deciders",
101+
optionArgumentKinds: ["good", "bad"],
102+
detect: () => true,
103+
parse: (markdown, options) => md2adr(markdown, options),
104+
serialize: (adr, options) => adr2md(adr, options),
105+
carryOverOnSwitch: (record, from) => {
106+
if (from !== "2.1.2" && record.deciders === "" && record.decisionMakers !== "") {
107+
record.deciders = record.decisionMakers;
108+
}
109+
}
110+
};
111+
112+
export const MADR_TEMPLATE_ADAPTERS = [MADR_400_ADAPTER, MADR_212_ADAPTER] as const;
113+
114+
export function getMadrTemplateAdapter(version: MadrTemplateVersion): MadrTemplateAdapter {
115+
const adapter = MADR_TEMPLATE_ADAPTERS.find((candidate) => candidate.version === version);
116+
if (!adapter) {
117+
throw new Error(`Unsupported MADR template version: ${version}`);
118+
}
119+
return adapter;
120+
}
121+
122+
export function hasMadrTemplateField(adapter: MadrTemplateAdapter, key: FieldKey): boolean {
123+
return adapter.fields.some((field) => field.key === key);
124+
}
125+
126+
export function detectMadrVersion(markdown: string): MadrTemplateVersion {
127+
return MADR_TEMPLATE_ADAPTERS.find((adapter) => adapter.detect(markdown))?.version ?? "2.1.2";
128+
}
129+
130+
export function parseMadr(
131+
markdown: string,
132+
version: MadrTemplateVersion = detectMadrVersion(markdown),
133+
options?: Md2AdrOptions
134+
): ArchitecturalDecisionRecord {
135+
return getMadrTemplateAdapter(version).parse(markdown, options);
136+
}
137+
138+
export function serializeMadr(
139+
adr: ArchitecturalDecisionRecord,
140+
version: MadrTemplateVersion,
141+
options?: Adr2MdOptions
142+
): string {
143+
return getMadrTemplateAdapter(version).serialize(adr, options);
144+
}
145+
146+
export function roundTripsMadr(
147+
markdown: string,
148+
version: MadrTemplateVersion = detectMadrVersion(markdown),
149+
options: MadrRoundTripOptions = {}
150+
): boolean {
151+
const serialized = serializeMadr(parseMadr(markdown, version, options.parse), version, options.serialize);
152+
return (options.compare ?? ((original, next) => original === next))(markdown, serialized);
153+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, expect, test } from "vitest";
2+
import {
3+
ArchitecturalDecisionRecord,
4+
MADR_TEMPLATE_ADAPTERS,
5+
adr2md,
6+
adr2md400,
7+
detectMadrVersion,
8+
getMadrTemplateAdapter,
9+
hasMadrTemplateField,
10+
md2adr,
11+
md2adr400,
12+
parseMadr,
13+
roundTripsMadr,
14+
serializeMadr
15+
} from "../src/index";
16+
17+
const MADR_212 = `# Use PostgreSQL
18+
19+
* Status: accepted
20+
* Deciders: Jane Doe
21+
* Date: 2026-06-10
22+
23+
## Context and Problem Statement
24+
25+
We need a durable system of record.
26+
27+
## Considered Options
28+
29+
* PostgreSQL
30+
* DynamoDB
31+
32+
## Decision Outcome
33+
34+
Chosen option: "PostgreSQL", because it fits.
35+
`;
36+
37+
const MADR_400 = `---
38+
status: "accepted"
39+
date: 2026-06-10
40+
decision-makers: Jane Doe
41+
consulted: Data Guild
42+
informed: Backend Chapter
43+
---
44+
45+
# Use PostgreSQL
46+
47+
## Context and Problem Statement
48+
49+
We need a durable system of record.
50+
51+
## Considered Options
52+
53+
* PostgreSQL
54+
* DynamoDB
55+
56+
## Decision Outcome
57+
58+
Chosen option: "PostgreSQL", because it fits.
59+
60+
### Consequences
61+
62+
* Good, because the data model is familiar
63+
* Bad, because failover needs care
64+
65+
### Confirmation
66+
67+
Reviewed by the architecture group.
68+
69+
## More Information
70+
71+
Revisit after the first scaling incident.
72+
`;
73+
74+
describe("MADR template registry", () => {
75+
test("exposes unique template versions", () => {
76+
const versions = MADR_TEMPLATE_ADAPTERS.map((adapter) => adapter.version);
77+
expect(versions).toStrictEqual(["4.0.0", "2.1.2"]);
78+
expect(new Set(versions).size).toBe(versions.length);
79+
});
80+
81+
test("describes template-specific editor semantics", () => {
82+
const classic = getMadrTemplateAdapter("2.1.2");
83+
const modern = getMadrTemplateAdapter("4.0.0");
84+
85+
expect(classic.peopleFields).toBe("deciders");
86+
expect(classic.optionArgumentKinds).toStrictEqual(["good", "bad"]);
87+
expect(hasMadrTemplateField(classic, "positiveConsequences")).toBe(true);
88+
expect(hasMadrTemplateField(classic, "consequences")).toBe(false);
89+
90+
expect(modern.peopleFields).toBe("decisionMakersConsultedInformed");
91+
expect(modern.optionArgumentKinds).toStrictEqual(["good", "neutral", "bad"]);
92+
expect(hasMadrTemplateField(modern, "positiveConsequences")).toBe(false);
93+
expect(hasMadrTemplateField(modern, "consequences")).toBe(true);
94+
});
95+
96+
test("detects supported versions through the registry", () => {
97+
expect(detectMadrVersion(MADR_212)).toBe("2.1.2");
98+
expect(detectMadrVersion(MADR_400)).toBe("4.0.0");
99+
});
100+
101+
test("dispatches 2.1.2 parsing and serialization to the classic parser", () => {
102+
const parsed = parseMadr(MADR_212, "2.1.2");
103+
expect(parsed).toStrictEqual(md2adr(MADR_212));
104+
expect(serializeMadr(parsed, "2.1.2")).toBe(adr2md(parsed));
105+
});
106+
107+
test("dispatches 4.0.0 parsing and serialization to the 4.0.0 reader", () => {
108+
const parsed = parseMadr(MADR_400, "4.0.0");
109+
expect(parsed).toStrictEqual(md2adr400(MADR_400));
110+
expect(serializeMadr(parsed, "4.0.0")).toBe(adr2md400(parsed));
111+
});
112+
113+
test("round-trips documents with the requested adapter", () => {
114+
const record = new ArchitecturalDecisionRecord({
115+
title: "Use PostgreSQL",
116+
decisionMakers: "Jane Doe",
117+
consideredOptions: [{ title: "PostgreSQL" }],
118+
decisionOutcome: { chosenOption: "PostgreSQL", explanation: "it fits" },
119+
confirmation: "Reviewed by the architecture group."
120+
});
121+
const markdown = serializeMadr(record, "4.0.0");
122+
expect(roundTripsMadr(markdown, "4.0.0")).toBe(true);
123+
});
124+
125+
test("carries people fields when switching templates", () => {
126+
const record = new ArchitecturalDecisionRecord({ deciders: "Jane Doe" });
127+
getMadrTemplateAdapter("4.0.0").carryOverOnSwitch(record, "2.1.2");
128+
expect(record.decisionMakers).toBe("Jane Doe");
129+
130+
record.deciders = "";
131+
getMadrTemplateAdapter("2.1.2").carryOverOnSwitch(record, "4.0.0");
132+
expect(record.deciders).toBe("Jane Doe");
133+
});
134+
});

0 commit comments

Comments
 (0)