diff --git a/docs/ADR_TEMPLATE.md b/docs/ADR_TEMPLATE.md new file mode 100644 index 00000000000..8bf99eff8e6 --- /dev/null +++ b/docs/ADR_TEMPLATE.md @@ -0,0 +1,59 @@ +# + +Date: YYYY-MM-DD + +Status: + +Decision Owner: + +Source: + +Supersedes: + +Superseded by: + +## Context + + + +## Decision + + + +## Alternatives Considered + + + +## Consequences + + + +## Links + +RFC: + +Documentation: + +Related ADRs: diff --git a/packages/testing/code-health/src/rules/adr-conventions-files.test.ts b/packages/testing/code-health/src/rules/adr-conventions-files.test.ts index 245624136b5..485622565c4 100644 --- a/packages/testing/code-health/src/rules/adr-conventions-files.test.ts +++ b/packages/testing/code-health/src/rules/adr-conventions-files.test.ts @@ -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, '# \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, + }), ]), ); }); diff --git a/packages/testing/code-health/src/rules/adr-conventions-files.ts b/packages/testing/code-health/src/rules/adr-conventions-files.ts index 87ebf6a8aeb..2160f32c9c9 100644 --- a/packages/testing/code-health/src/rules/adr-conventions-files.ts +++ b/packages/testing/code-health/src/rules/adr-conventions-files.ts @@ -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 { - const filePaths = await fileAccess.glob('**/ADR-*.md', { + const filePaths = await fileAccess.glob(ADR_FILE_PATTERNS, { cwd: rootDir, absolute: true, onlyFiles: true, diff --git a/packages/testing/code-health/src/rules/adr-conventions.rule.test.ts b/packages/testing/code-health/src/rules/adr-conventions.rule.test.ts index ee54ae44421..95078b86f64 100644 --- a/packages/testing/code-health/src/rules/adr-conventions.rule.test.ts +++ b/packages/testing/code-health/src/rules/adr-conventions.rule.test.ts @@ -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 `# + +Date: YYYY-MM-DD + +Status: + +Decision Owner: + +Source: + +Supersedes: + +Superseded by: + +## Context + + + +## Decision + + + +## Alternatives Considered + + + +## Consequences + + + +## Links + +RFC: + +Documentation: + +Related ADRs: +`; +} + 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(); diff --git a/packages/testing/code-health/src/rules/adr-conventions.rule.ts b/packages/testing/code-health/src/rules/adr-conventions.rule.ts index c378b4aaba3..87430b77949 100644 --- a/packages/testing/code-health/src/rules/adr-conventions.rule.ts +++ b/packages/testing/code-health/src/rules/adr-conventions.rule.ts @@ -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 { readonly id = 'adr-conventions'; @@ -90,6 +91,7 @@ export class AdrConventionsRule extends BaseRule { ): 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 { ); } - 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 { ); } - if (!file.fileId) { + if (!isTemplate && !file.fileId) { violations.push( this.createViolation( filePath, @@ -124,7 +126,7 @@ export class AdrConventionsRule extends BaseRule { '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 { 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 { } 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 { '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 { } 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 { } 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 { } 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 { } 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 { 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 { return ( block.line === linksIndex + 2 + index * 2 && field?.key === linkPattern[index] && - isMeaningfulLinkValue(field.value) + (allowPlaceholders || isMeaningfulLinkValue(field.value)) ); }) && hasOnlyParagraphsAfterHeading(file.tokens, linksIndex);