diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 5a68a36..3a05600 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -65,6 +65,40 @@ command to bundle the TypeScript code into JavaScript:
npm run bundle
```
+## Coverage Badge
+
+**CRITICAL: Before making ANY commit that includes code changes (changes to
+files in the `src` directory), you MUST ensure the coverage badge is up to date.
+This is mandatory for EVERY commit that includes code changes.**
+
+The coverage badge in `badges/coverage.svg` must accurately reflect the current
+test coverage. The CI workflow includes a check that will fail if the coverage
+badge is out of date, making the PR unmergeable.
+
+### Updating the Coverage Badge
+
+After making code changes and running tests:
+
+1. Run `npm run coverage` to update the coverage badge
+1. Verify the badge is correct by running
+ `npx tsx script/check-coverage-badge.ts`
+1. Commit the updated badge along with your code changes
+
+### Checking Coverage Badge Accuracy
+
+To verify the coverage badge is up to date:
+
+```bash
+npx tsx script/check-coverage-badge.ts
+```
+
+This script compares the coverage percentage in `badges/coverage.svg` with the
+actual coverage from `coverage/coverage-summary.json`. If they don't match, the
+script will fail with an error message.
+
+The `npm run ci-test` script automatically runs this check, so PRs with
+inaccurate coverage badges will fail CI and be unmergeable.
+
## General Coding Guidelines
- Follow standard TypeScript and JavaScript coding conventions and best
diff --git a/badges/coverage.svg b/badges/coverage.svg
index 1b3eb31..a2166f5 100644
--- a/badges/coverage.svg
+++ b/badges/coverage.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/package.json b/package.json
index a3689ef..fc7af0f 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
},
"scripts": {
"bundle": "npm run format:write && npm run package",
- "ci-test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest",
+ "ci-test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest && npx tsx script/check-coverage-badge.ts",
"coverage": "npx make-coverage-badge --output-path ./badges/coverage.svg",
"format:write": "npx prettier --write .",
"format:check": "npx prettier --check .",
diff --git a/script/check-coverage-badge.ts b/script/check-coverage-badge.ts
new file mode 100644
index 0000000..90a418e
--- /dev/null
+++ b/script/check-coverage-badge.ts
@@ -0,0 +1,97 @@
+/**
+ * Utility script to verify that the coverage badge is up to date
+ * This compares the coverage percentage in the badge SVG with the actual coverage from Jest
+ */
+
+import { readFileSync, existsSync } from 'fs'
+import { dirname, resolve } from 'path'
+import { fileURLToPath } from 'url'
+
+/**
+ * Extracts the coverage percentage from the coverage-summary.json file
+ * @returns The statement coverage percentage as a number
+ * @throws Error if coverage file cannot be found or parsed
+ */
+export function getCoverageFromSummary(): number {
+ const scriptDir = dirname(fileURLToPath(import.meta.url))
+ const coveragePath = resolve(scriptDir, '../coverage/coverage-summary.json')
+
+ if (!existsSync(coveragePath)) {
+ throw new Error(
+ 'Coverage summary not found. Run "npm run test" to generate coverage data.'
+ )
+ }
+
+ const coverageData = JSON.parse(readFileSync(coveragePath, 'utf-8'))
+
+ if (!coverageData.total || !coverageData.total.statements) {
+ throw new Error('Invalid coverage summary format')
+ }
+
+ return coverageData.total.statements.pct
+}
+
+/**
+ * Extracts the coverage percentage from the coverage badge SVG file
+ * @returns The coverage percentage as a number
+ * @throws Error if badge file cannot be found or parsed
+ */
+export function getCoverageFromBadge(): number {
+ const scriptDir = dirname(fileURLToPath(import.meta.url))
+ const badgePath = resolve(scriptDir, '../badges/coverage.svg')
+
+ if (!existsSync(badgePath)) {
+ throw new Error(
+ 'Coverage badge not found. Run "npm run coverage" to generate the badge.'
+ )
+ }
+
+ const badgeContent = readFileSync(badgePath, 'utf-8')
+
+ // Extract the coverage percentage from the badge SVG
+ // The format is: XX.XX%
+ const match = badgeContent.match(/(\d+\.?\d*)%<\/text>/)
+
+ if (!match) {
+ throw new Error('Could not extract coverage percentage from badge')
+ }
+
+ return parseFloat(match[1])
+}
+
+/**
+ * Validates that the coverage badge matches the actual coverage
+ * @throws Error if badge is out of date
+ */
+export function validateCoverageBadge(): void {
+ const actualCoverage = getCoverageFromSummary()
+ const badgeCoverage = getCoverageFromBadge()
+
+ console.log(`Actual coverage: ${actualCoverage}%`)
+ console.log(`Badge coverage: ${badgeCoverage}%`)
+
+ if (actualCoverage !== badgeCoverage) {
+ throw new Error(
+ `Coverage badge is out of date!\n` +
+ ` Actual coverage: ${actualCoverage}%\n` +
+ ` Badge coverage: ${badgeCoverage}%\n` +
+ ` Run "npm run coverage" to update the badge.`
+ )
+ }
+
+ console.log('✓ Coverage badge is up to date')
+}
+
+// If run directly as a script, validate the badge
+const isMainModule =
+ fileURLToPath(import.meta.url) === process.argv[1] ||
+ fileURLToPath(import.meta.url) === resolve(process.argv[1])
+
+if (isMainModule) {
+ try {
+ validateCoverageBadge()
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : error)
+ process.exit(1)
+ }
+}