Create changetool script for validating change-notes

This commit is contained in:
Mario Campos
2026-08-24 23:08:05 -05:00
parent 4d40c93072
commit 426c10420b
8 changed files with 2000 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import {
isValidChangenoteContent,
isValidChangenoteFile,
isValidChangenoteFilename,
hasValidChangenoteCategory,
VALID_CHANGE_NOTE_CATEGORIES,
} from "./validate.ts";
async function withTmpFile<T>(
baseFileName: string,
contents: string,
body: (filePath: string) => Promise<T>,
): Promise<T> {
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), "changetool-validate-test-"),
);
try {
const filePath = path.join(tmpDir, baseFileName);
fs.writeFileSync(filePath, contents);
return await body(filePath);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
await describe("isValidChangenoteContent", async () => {
await it("recognizes an unordered Markdown list", async () => {
const inputs = [
"- One changenote entry",
"- First item\n- Second item",
"\n\n\n\n- Fixed a bug\n- Added a feature",
];
for (const input of inputs) {
assert.equal(isValidChangenoteContent(input), true);
}
});
await it("does not recognize non-Markdown text", async () => {
const inputs = [
"This is not a list.",
'["this", "is", "JSON"]',
"---",
"***",
"___",
"paragraph",
];
for (const input of inputs) {
assert.equal(isValidChangenoteContent(input), false);
}
});
await it("does not recognize ordered Markdown lists", async () => {
const inputs = [
"1. First item\n2. Second item",
"\n\n\n1. First item\n1. Second item",
];
for (const input of inputs) {
assert.equal(isValidChangenoteContent(input), false);
}
});
await it("requires all list items to use a hyphen bullet", async () => {
const inputs = [
"* Fixed a bug\n* Added feature",
"+ Fixed a bug\n+ Added feature",
"- Fixed a bug\n* Added feature",
"- Fixed a bug\n+ Added feature",
"\n\n\n* Fixed a bug",
"\n\n\n+ Fixed a bug",
"---\n* Fixed a bug\n* Added feature",
] as const;
for (const input of inputs) {
assert.equal(isValidChangenoteContent(input), false);
}
});
await it("does not contain other Markdown elements", async () => {
const inputs = [
"- Fixed a bug\n\nParagraph of text",
"- Fixed a bug\n\n* Added a feature",
"# Header\n- Fixed a bug",
"- Fixed a bug\n## Subheader",
];
for (const input of inputs) {
assert.equal(isValidChangenoteContent(input), false);
}
});
});
await describe("isValidChangenoteFilename", async () => {
await it("accepts valid filenames", async () => {
const inputs = [
"2023-01-01-fix-bug.md",
"2023-12-31-add-feature.md",
"2023-06-15-update-docs.md",
];
for (const input of inputs) {
assert.equal(isValidChangenoteFilename(input), true);
}
});
await it("rejects invalid filenames", async () => {
const inputs = [
"missing-date-from-filename.md",
"2021-01-01.md",
"2026-12-19-wrong-file-name-extension.txt",
];
for (const input of inputs) {
assert.equal(isValidChangenoteFilename(input), false);
}
});
});
await describe("hasValidChangenoteCategory", async () => {
await it("accepts valid categories", async () => {
for (const category of Object.keys(VALID_CHANGE_NOTE_CATEGORIES)) {
const frontmatter = { category };
assert.equal(hasValidChangenoteCategory(frontmatter), true);
}
});
await it("rejects invalid categories", async () => {
const inputs = [
"",
"invalid-category",
"bug-fix",
"new-feature",
"security-patch",
"miscellaneous",
"documentation",
];
for (const category of inputs) {
const frontmatter = { category };
assert.equal(hasValidChangenoteCategory(frontmatter), false);
}
});
await it("reject missing category", async () => {
assert.equal(hasValidChangenoteCategory({}), false);
assert.equal(hasValidChangenoteCategory({ category: null }), false);
assert.equal(hasValidChangenoteCategory({ category: undefined }), false);
});
});
await describe("isValidChangenoteFile", async () => {
await it("accepts a valid change-note file", async () => {
await withTmpFile(
"2026-01-01-fix-bug.md",
"---\ncategory: fix\n---\n- Fixed a bug\n",
async (filePath) => {
assert.equal(isValidChangenoteFile(filePath), true);
},
);
});
await it("rejects invalid filename", async () => {
await withTmpFile(
"fix-bug.md",
"---\ncategory: fix\n---\n- Fixed a bug\n",
async (filePath) => {
assert.equal(isValidChangenoteFile(filePath), false);
},
);
});
await it("rejects missing frontmatter", async () => {
await withTmpFile(
"2026-01-01-fix-bug.md",
"- Fixed a bug\n",
async (filePath) => {
assert.equal(isValidChangenoteFile(filePath), false);
},
);
});
await it("rejects invalid Markdown", async () => {
await withTmpFile(
"2026-01-01-fix-bug.md",
"---\ncategory: fix\n---\n* Fixed a bug\n",
async (filePath) => {
assert.equal(isValidChangenoteFile(filePath), false);
},
);
});
});

View File

@@ -0,0 +1,104 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { matter } from "lite-matter";
import { fromMarkdown } from "mdast-util-from-markdown";
// Regex for filename: YYYY-MM-DD-id.md
const VALID_CHANGE_NOTE_FILENAME_PATTERN =
/^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
export const VALID_CHANGE_NOTE_CATEGORIES = {
breaking: "Breaking Changes",
feature: "New Features",
improvement: "Improvements",
securityFix: "Security Fixes",
fix: "Bug Fixes",
unship: "Removed Features",
deprecation: "Deprecations",
knownIssue: "Known Issues",
misc: "Miscellaneous",
};
/**
* Validates that the given Markdown string meets the criteria for a change-note, which is:
* - A single unordered list
* - Each list item must start with a hyphen (-)
* - No other Markdown elements are allowed
* @param content The Markdown string to validate
* @returns True if the string is a valid change-note, false otherwise
*/
export function isValidChangenoteContent(content: string): boolean {
const ast = fromMarkdown(content);
if (
ast.children.length !== 1 ||
ast.children[0].type !== "list" ||
ast.children[0].ordered === true
) {
return false;
}
const lines = content.split("\n");
return ast.children[0].children.every((listItem) => {
return lines[listItem.position!.start.line - 1].startsWith("-");
});
}
/**
* Validates that the given filename meets the criteria for a change-note filename.
* @param filename The name of the change-note file to validate.
* @returns True if the filename is valid, false otherwise.
*/
export function isValidChangenoteFilename(filename: string): boolean {
return filename.match(VALID_CHANGE_NOTE_FILENAME_PATTERN) !== null;
}
/**
* Validates that the given frontmatter has a valid change-note category.
* @param frontmatter The frontmatter object to validate.
* @returns True if the frontmatter has a valid category, false otherwise.
*/
export function hasValidChangenoteCategory(
frontmatter: Record<string, unknown>,
): boolean {
const category = frontmatter["category"];
return (
typeof category === "string" && category in VALID_CHANGE_NOTE_CATEGORIES
);
}
/**
* Validates that the given change-note file meets all of the criteria for a change-note.
* @param filename The name of the change-note file to validate.
* @returns True if the file is a valid change-note, false otherwise.
*/
export function isValidChangenoteFile(filename: string): boolean {
let isValid: boolean = true;
const { data: frontmatter, content } = matter(
fs.readFileSync(filename, "utf8"),
);
if (!isValidChangenoteFilename(path.basename(filename))) {
isValid = false;
console.error(
`${filename}: invalid filename; must match pattern YYYY-MM-DD-id.md`,
);
}
if (!hasValidChangenoteCategory(frontmatter)) {
isValid = false;
const categories = Object.keys(VALID_CHANGE_NOTE_CATEGORIES).join(", ");
console.error(
`${filename}: invalid category; must be one of: ${categories}`,
);
}
if (!isValidChangenoteContent(content)) {
isValid = false;
console.error(
`${filename}: invalid Markdown; content must be a single unordered list with hyphen bullets and no other Markdown elements`,
);
}
return isValid;
}

View File

@@ -0,0 +1,45 @@
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import { isValidChangenoteFile } from "./cli/validate.ts";
const entryPoint = process.argv[1];
if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) {
try {
process.exit(main());
} catch (error) {
console.error(error);
process.exit(1);
}
}
function main(): number {
const { positionals } = parseArgs({
allowPositionals: true,
strict: true,
});
const [command, ...paths] = positionals;
switch (command) {
case undefined:
case "help":
return usage();
case "validate":
return validate(paths);
default:
console.error(`Unknown command: ${command}`);
return 1;
}
}
function usage(): number {
console.log("Usage: changetool validate <path> [<path> ...]");
return 0;
}
function validate(paths: string[]): number {
if (paths.length === 0) {
console.error("error: no paths provided (see 'help' command for usage)");
return 1;
}
return paths.every((path) => isValidChangenoteFile(path)) ? 0 : 1;
}

1548
scripts/changetool/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"name": "changetool",
"version": "1.0.0",
"private": true,
"description": "Validates change-notes and merges them into CHANGELOG.md",
"license": "MIT",
"type": "module",
"scripts": {
"start": "tsx index.ts",
"test": "node --test --experimental-strip-types --experimental-test-module-mocks cli/*.test.ts",
"transpile": "tsc --build tsconfig.json",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^26.2.0",
"tsx": "^4.23.12",
"typescript": "^7.0.2"
},
"dependencies": {
"lite-matter": "^0.1.2",
"mdast-util-from-markdown": "^2.0.3"
}
}

View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
/* Basic Options */
"lib": ["ES2022"],
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"outDir": "./build",
"rootDir": ".",
"sourceMap": false,
"types": ["node"],
/* Strict Type-Checking Options */
"strict": true,
"noImplicitAny": false,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
/* Additional Checks */
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
/* Module Resolution Options */
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["./**/*.ts"],
"exclude": ["build", "node_modules"]
}