ci: Add ADR template (#39358)
Some checks failed
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.18.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (26.5.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Verify single-instance deps (npm-install) (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Build: Benchmark Image / build (push) Has been cancelled

This commit is contained in:
Tomi Turtiainen 2026-09-23 08:30:55 +00:00 committed by GitHub
parent db1e08fa66
commit aa4bd2cc11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 157 additions and 18 deletions

59
docs/ADR_TEMPLATE.md Normal file
View File

@ -0,0 +1,59 @@
# <Decision title>
Date: YYYY-MM-DD
Status: <!-- Active / Superseded / Deprecated -->
Decision Owner: <!-- Owning team -->
Source: <!-- RFC, issue, project, incident, or other source -->
Supersedes: <!-- Omit if not relevant -->
Superseded by: <!-- Omit if not relevant -->
## Context
<!--
Explain the problem and the important constraints.
Keep this section short, aim for less than 150 words.
-->
## Decision
<!--
State the decision clearly.
Use active language.
Example:
"We use PostgreSQL advisory locks for distributed coordination of X."
Explain the main reason for the decision.
-->
## Alternatives Considered
<!--
List the main alternatives.
Explain briefly why they were not selected.
-->
## Consequences
<!--
List the important consequences.
Include important benefits, trade-offs, constraints, or operational effects.
-->
## Links
RFC: <!-- RFC from which this originated. '-' if none -->
Documentation: <!-- Links to documents related to this ADR. '-' if none -->
Related ADRs: <!-- Links to related ADRs. '-' if none -->

View File

@ -8,21 +8,23 @@ describe('ADR convention files', () => {
const rootDir = path.resolve('/repo');
describe('findAdrFiles', () => {
it('finds and parses ADR files outside generated directories', async () => {
it('finds and parses ADR files and the template outside generated directories', async () => {
const validPath = path.join(rootDir, 'docs/adr/ADR-20260922-use-a-parser.md');
const invalidPath = path.join(rootDir, 'docs/adr/ADR-invalid.md');
const templatePath = path.join(rootDir, 'docs/ADR_TEMPLATE.md');
const sources = new Map([
[validPath, '# Use a parser\r\n'],
[invalidPath, '# Invalid name\n'],
[templatePath, '# <Decision title>\n'],
]);
const fileAccess: AdrFileAccess = {
glob: vi.fn(async () => [validPath, invalidPath]),
glob: vi.fn(async () => [validPath, invalidPath, templatePath]),
readFile: vi.fn((filePath: string) => sources.get(filePath) ?? ''),
};
const files = await findAdrFiles(rootDir, fileAccess);
expect(fileAccess.glob).toHaveBeenCalledWith('**/ADR-*.md', {
expect(fileAccess.glob).toHaveBeenCalledWith(['**/ADR-*.md', 'docs/ADR_TEMPLATE.md'], {
cwd: rootDir,
absolute: true,
onlyFiles: true,
@ -37,7 +39,7 @@ describe('ADR convention files', () => {
'.git/**',
],
});
expect(files).toHaveLength(2);
expect(files).toHaveLength(3);
expect(files).toEqual(
expect.arrayContaining([
expect.objectContaining({
@ -51,6 +53,11 @@ describe('ADR convention files', () => {
fileName: 'ADR-invalid.md',
fileId: undefined,
}),
expect.objectContaining({
fileName: 'ADR_TEMPLATE.md',
relativePath: 'docs/ADR_TEMPLATE.md',
fileId: undefined,
}),
]),
);
});

View File

@ -8,6 +8,7 @@ import { parseMarkdown } from './adr-conventions-markdown.js';
import type { ParsedMarkdown } from './adr-conventions-markdown.js';
const ADR_FILENAME = /^ADR-(\d{8})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
const ADR_FILE_PATTERNS = ['**/ADR-*.md', 'docs/ADR_TEMPLATE.md'];
const GENERATED_DIRS = [
'**/node_modules/**',
'**/dist/**',
@ -54,7 +55,7 @@ export async function findAdrFiles(
rootDir: string,
fileAccess: AdrFileAccess = defaultFileAccess,
): Promise<AdrFile[]> {
const filePaths = await fileAccess.glob('**/ADR-*.md', {
const filePaths = await fileAccess.glob(ADR_FILE_PATTERNS, {
cwd: rootDir,
absolute: true,
onlyFiles: true,

View File

@ -27,8 +27,11 @@ function createFileAccess(rootDir: string): AdrFileAccess {
return {
glob: vi.fn(async (patterns) => {
const filePaths = [...testFiles.keys()];
if (typeof patterns === 'string') {
return filePaths.filter((filePath) => path.basename(filePath).startsWith('ADR-'));
if (Array.isArray(patterns) && patterns.includes('**/ADR-*.md')) {
return filePaths.filter((filePath) => {
const fileName = path.basename(filePath);
return fileName.startsWith('ADR-') || fileName === 'ADR_TEMPLATE.md';
});
}
return filePaths.filter(
(filePath) => filePath === path.join(rootDir, 'packages/engine/package.json'),
@ -42,6 +45,47 @@ function createFileAccess(rootDir: string): AdrFileAccess {
};
}
function validAdrTemplate(): string {
return `# <Decision title>
Date: YYYY-MM-DD
Status: <!-- Active / Superseded / Deprecated -->
Decision Owner: <!-- Owning team -->
Source: <!-- RFC, issue, project, incident, or other source -->
Supersedes: <!-- Omit if not relevant -->
Superseded by: <!-- Omit if not relevant -->
## Context
<!-- Explain the problem and the important constraints. -->
## Decision
<!-- State the decision clearly. -->
## Alternatives Considered
<!-- List the main alternatives. -->
## Consequences
<!-- List the important consequences. -->
## Links
RFC: <!-- RFC from which this originated. '-' if none -->
Documentation: <!-- Links to documents related to this ADR. '-' if none -->
Related ADRs: <!-- Links to related ADRs. '-' if none -->
`;
}
function validAdr(
options: {
title?: string;
@ -155,6 +199,26 @@ describe('AdrConventionsRule', () => {
}
});
it('accepts the root ADR template', async () => {
addTestFile(tmpDir, 'docs/ADR_TEMPLATE.md', validAdrTemplate());
await expect(rule.analyze(context())).resolves.toEqual([]);
});
it('checks the root ADR template structure', async () => {
addTestFile(
tmpDir,
'docs/ADR_TEMPLATE.md',
validAdrTemplate().replace('## Consequences', '## Effects'),
);
const violations = await rule.analyze(context());
expect(violations.some((violation) => violation.message.includes('required H2 sections'))).toBe(
true,
);
});
it('discovers invalid locations, malformed names, and duplicate IDs on disk', async () => {
const diskRoot = createTempDir();

View File

@ -39,6 +39,7 @@ const MAX_PARAGRAPH_LINE_LENGTH = 100;
* - Add the required RFC, Documentation, and Related ADRs link fields.
* - Resolve local ADR references and allow references in external URLs.
* - Limit paragraph lines to 100 characters.
* - Apply structural rules to the root ADR template.
*/
export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
readonly id = 'adr-conventions';
@ -90,6 +91,7 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
): Violation[] {
const violations: Violation[] = [];
const { filePath, relativePath, fileName } = file;
const isTemplate = relativePath === 'docs/ADR_TEMPLATE.md';
for (const { line, length } of findLongParagraphLines(file, MAX_PARAGRAPH_LINE_LENGTH)) {
violations.push(
@ -102,7 +104,7 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
);
}
if (!allowedDirectories.has(normalizePath(path.dirname(filePath)))) {
if (!isTemplate && !allowedDirectories.has(normalizePath(path.dirname(filePath)))) {
violations.push(
this.createViolation(
filePath,
@ -114,7 +116,7 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
);
}
if (!file.fileId) {
if (!isTemplate && !file.fileId) {
violations.push(
this.createViolation(
filePath,
@ -124,7 +126,7 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
'Rename the file with a valid date and kebab-case title.',
),
);
} else if ((ids.get(file.fileId)?.length ?? 0) > 1) {
} else if (file.fileId && (ids.get(file.fileId)?.length ?? 0) > 1) {
violations.push(
this.createViolation(
filePath,
@ -142,7 +144,7 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
violations.push(
this.createViolation(filePath, 1, 1, 'The ADR must start with one nonempty H1 title.'),
);
} else if (file.fileSlug && kebabCase(title) !== file.fileSlug) {
} else if (!isTemplate && file.fileSlug && kebabCase(title) !== file.fileSlug) {
violations.push(
this.createViolation(
filePath,
@ -205,7 +207,7 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
}
const date = metadata.values.get('Date');
if (!date || !isValidIsoDate(date)) {
if (!isTemplate && (!date || !isValidIsoDate(date))) {
violations.push(
this.createViolation(
filePath,
@ -214,7 +216,7 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
'Date must be a valid calendar date in YYYY-MM-DD format.',
),
);
} else if (file.fileDate && date.replaceAll('-', '') !== file.fileDate) {
} else if (!isTemplate && file.fileDate && date?.replaceAll('-', '') !== file.fileDate) {
violations.push(
this.createViolation(
filePath,
@ -226,7 +228,7 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
}
const status = metadata.values.get('Status');
if (!status || !ALLOWED_STATUSES.has(status)) {
if (!isTemplate && (!status || !ALLOWED_STATUSES.has(status))) {
violations.push(
this.createViolation(
filePath,
@ -238,7 +240,7 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
}
const owner = metadata.values.get('Decision Owner');
if (!owner || !owners.has(owner)) {
if (!isTemplate && (!owner || !owners.has(owner))) {
violations.push(
this.createViolation(
filePath,
@ -251,6 +253,8 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
}
for (const field of OPTIONAL_METADATA) {
if (isTemplate) continue;
if (metadata.values.has(field) && !isMeaningfulValue(metadata.values.get(field))) {
violations.push(
this.createViolation(
@ -279,7 +283,7 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
}
if (headingResult.valid) {
violations.push(...this.validateSections(file, headingResult.indexes));
violations.push(...this.validateSections(file, headingResult.indexes, isTemplate));
}
violations.push(...this.validateReferences(file, ids));
@ -287,7 +291,11 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
return violations;
}
private validateSections(file: AdrFile, headingIndexes: number[]): Violation[] {
private validateSections(
file: AdrFile,
headingIndexes: number[],
allowPlaceholders: boolean,
): Violation[] {
const violations: Violation[] = [];
const { filePath, lines } = file;
@ -334,7 +342,7 @@ export class AdrConventionsRule extends BaseRule<CodeHealthContext> {
return (
block.line === linksIndex + 2 + index * 2 &&
field?.key === linkPattern[index] &&
isMeaningfulLinkValue(field.value)
(allowPlaceholders || isMeaningfulLinkValue(field.value))
);
}) &&
hasOnlyParagraphsAfterHeading(file.tokens, linksIndex);