mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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
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:
parent
db1e08fa66
commit
aa4bd2cc11
59
docs/ADR_TEMPLATE.md
Normal file
59
docs/ADR_TEMPLATE.md
Normal 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 -->
|
||||
|
|
@ -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,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user