Skip to content

Commit bd48df9

Browse files
committed
Add CI validation for the mods registry
1 parent 630bbf4 commit bd48df9

4 files changed

Lines changed: 193 additions & 1 deletion

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Validate mods registry
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "mods/**"
7+
- ".github/workflows/validate-mods.yml"
8+
push:
9+
branches:
10+
- main
11+
paths:
12+
- "mods/**"
13+
- ".github/workflows/validate-mods.yml"
14+
15+
permissions:
16+
contents: read
17+
18+
jobs:
19+
validate:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
- uses: actions/setup-node@v4
24+
with:
25+
node-version: 22
26+
- run: node mods/validate_registry.mjs
27+
- run: node --test mods/validate_registry.test.mjs

‎mods/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ an npm review or a website deployment.
1313
## Add your mod
1414

1515
Append one entry to the `mods` array in [`registry.json`](./registry.json). Editors validate it
16-
against [`registry.schema.json`](./registry.schema.json).
16+
against [`registry.schema.json`](./registry.schema.json). Pull requests also run the
17+
dependency-free validator in [`validate_registry.mjs`](./validate_registry.mjs), which checks
18+
the checked-in registry and catches duplicate names before they reach the hub.
1719

1820
```json
1921
{

‎mods/validate_registry.mjs‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { readFile } from "node:fs/promises";
2+
import process from "node:process";
3+
4+
const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
5+
const TAG_PATTERN = /^[a-z0-9-]+$/;
6+
const KIND_VALUES = new Set(["tool", "command", "hook", "provider", "renderer", "flag"]);
7+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
8+
const URI_PATTERN = /^[a-z][a-z\d+.-]*:[^\s]+$/i;
9+
10+
function isObject(value) {
11+
return value !== null && typeof value === "object" && !Array.isArray(value);
12+
}
13+
14+
function addError(errors, path, message) {
15+
errors.push(`${path}: ${message}`);
16+
}
17+
18+
function validateRegistry(registry) {
19+
const errors = [];
20+
21+
if (!isObject(registry)) {
22+
return ["registry: expected an object"];
23+
}
24+
25+
if (registry.version !== 1) {
26+
addError(errors, "version", "must be 1");
27+
}
28+
29+
if (!Array.isArray(registry.mods)) {
30+
return [...errors, "mods: expected an array"];
31+
}
32+
33+
if ("hidden" in registry) {
34+
if (!Array.isArray(registry.hidden)) {
35+
addError(errors, "hidden", "expected an array");
36+
} else if (new Set(registry.hidden).size !== registry.hidden.length) {
37+
addError(errors, "hidden", "must contain unique values");
38+
}
39+
}
40+
41+
const names = new Set();
42+
for (const [index, mod] of registry.mods.entries()) {
43+
const path = `mods[${index}]`;
44+
if (!isObject(mod)) {
45+
addError(errors, path, "expected an object");
46+
continue;
47+
}
48+
49+
for (const field of ["name", "source", "description", "author", "kinds"]) {
50+
if (!(field in mod)) {
51+
addError(errors, `${path}.${field}`, "is required");
52+
}
53+
}
54+
55+
if (typeof mod.name === "string") {
56+
if (!NAME_PATTERN.test(mod.name)) {
57+
addError(errors, `${path}.name`, "must use lowercase letters, numbers, '.', '_' or '-'");
58+
}
59+
if (names.has(mod.name)) {
60+
addError(errors, `${path}.name`, `duplicates "${mod.name}"`);
61+
}
62+
names.add(mod.name);
63+
} else if ("name" in mod) {
64+
addError(errors, `${path}.name`, "expected a string");
65+
}
66+
67+
if (typeof mod.source !== "string") {
68+
addError(errors, `${path}.source`, "expected a string");
69+
}
70+
71+
if (typeof mod.description !== "string") {
72+
addError(errors, `${path}.description`, "expected a string");
73+
} else if (mod.description.length > 240) {
74+
addError(errors, `${path}.description`, "must be at most 240 characters");
75+
}
76+
77+
if (!isObject(mod.author)) {
78+
addError(errors, `${path}.author`, "expected an object");
79+
} else if (typeof mod.author.name !== "string") {
80+
addError(errors, `${path}.author.name`, "expected a string");
81+
}
82+
83+
if (!Array.isArray(mod.kinds) || mod.kinds.length === 0) {
84+
addError(errors, `${path}.kinds`, "must contain at least one kind");
85+
} else {
86+
if (new Set(mod.kinds).size !== mod.kinds.length) {
87+
addError(errors, `${path}.kinds`, "must contain unique values");
88+
}
89+
for (const kind of mod.kinds) {
90+
if (!KIND_VALUES.has(kind)) {
91+
addError(errors, `${path}.kinds`, `contains unsupported kind "${kind}"`);
92+
}
93+
}
94+
}
95+
96+
if (mod.tags !== undefined) {
97+
if (!Array.isArray(mod.tags)) {
98+
addError(errors, `${path}.tags`, "expected an array");
99+
} else {
100+
if (new Set(mod.tags).size !== mod.tags.length) {
101+
addError(errors, `${path}.tags`, "must contain unique values");
102+
}
103+
for (const tag of mod.tags) {
104+
if (typeof tag !== "string" || !TAG_PATTERN.test(tag)) {
105+
addError(errors, `${path}.tags`, `contains invalid tag "${tag}"`);
106+
}
107+
}
108+
}
109+
}
110+
111+
for (const field of ["repo", "author.url"]) {
112+
const value = field === "repo" ? mod.repo : mod.author?.url;
113+
if (value !== undefined && (typeof value !== "string" || !URI_PATTERN.test(value))) {
114+
addError(errors, `${path}.${field}`, "must be a URI");
115+
}
116+
}
117+
118+
if (mod.addedAt !== undefined && (typeof mod.addedAt !== "string" || !DATE_PATTERN.test(mod.addedAt))) {
119+
addError(errors, `${path}.addedAt`, "must use YYYY-MM-DD format");
120+
}
121+
}
122+
123+
return errors;
124+
}
125+
126+
export { validateRegistry };
127+
128+
if (process.argv[1] === new URL(import.meta.url).pathname.replaceAll("\\", "/")) {
129+
const file = process.argv[2] ?? new URL("./registry.json", import.meta.url);
130+
const registry = JSON.parse(await readFile(file, "utf8"));
131+
const errors = validateRegistry(registry);
132+
133+
if (errors.length > 0) {
134+
console.error(errors.map((error) => `- ${error}`).join("\n"));
135+
process.exitCode = 1;
136+
} else {
137+
console.log(`Validated ${registry.mods.length} mod entries.`);
138+
}
139+
}

‎mods/validate_registry.test.mjs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { readFile } from "node:fs/promises";
4+
import { validateRegistry } from "./validate_registry.mjs";
5+
6+
const registry = JSON.parse(await readFile(new URL("./registry.json", import.meta.url), "utf8"));
7+
8+
test("the checked-in registry is valid", () => {
9+
assert.deepEqual(validateRegistry(registry), []);
10+
});
11+
12+
test("duplicate names are rejected", () => {
13+
const candidate = structuredClone(registry);
14+
candidate.mods.push({ ...candidate.mods[0] });
15+
16+
assert.match(validateRegistry(candidate).join("\n"), /duplicates/);
17+
});
18+
19+
test("overlong descriptions are rejected", () => {
20+
const candidate = structuredClone(registry);
21+
candidate.mods[0].description = "x".repeat(241);
22+
23+
assert.match(validateRegistry(candidate).join("\n"), /at most 240/);
24+
});

0 commit comments

Comments
 (0)