From 565963256a9ba99a4b68ea8da085ecf64b6c6a45 Mon Sep 17 00:00:00 2001 From: quality Date: Wed, 7 Oct 2026 18:24:40 -0400 Subject: [PATCH] test: gate links.yml step list, script naming and README Checks against scripts/ links.yml runs node gates through one glob but wires each shell gate and self-test by hand, so a new check-*.sh or *.test.sh can be committed, documented and never run in CI. Add scripts/ci-wiring.test.mjs: every scripts/*.sh gate and *.test.sh self-test must be invoked by an unconditional job, every gate must have a self-test, the node glob step must exist unconditionally, no .mjs importing node:test may sit outside the glob's name, the workflow must run on pull_request and push to main, shell scripts must be executable, and README Checks must name every gate. Fixture self-tests for each rule; README entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: quality --- README.md | 8 + scripts/ci-wiring.test.mjs | 289 +++++++++++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 scripts/ci-wiring.test.mjs diff --git a/README.md b/README.md index 9e061a9..ab96535 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,14 @@ red PR job against. - `scripts/check-redirects.sh` — redirect-page drift gate described above. - `scripts/check-redirects.test.sh` — fixture-based self-test of the drift gate; runs offline against throwaway sites with a two-entry generator. +- `node --test scripts/ci-wiring.test.mjs` — static gate keeping `links.yml`, `scripts/` and + this list in step: every `scripts/*.sh` gate and `*.test.sh` self-test is run by an + unconditional `run:` step (they are listed by hand, unlike the `node --test + scripts/*.test.mjs` glob, which must itself be present and unconditional); every + `scripts/*.sh` gate has a sibling self-test; no `.mjs` that imports `node:test` sits + outside the glob's `*.test.mjs` name; the workflow runs on `pull_request` and on push + to `main`; every shell script is executable; and this `## Checks` section names every + gate and self-test. Each rule has a fixture self-test. Zero dependencies. ## Availability monitoring diff --git a/scripts/ci-wiring.test.mjs b/scripts/ci-wiring.test.mjs new file mode 100644 index 0000000..9f5e816 --- /dev/null +++ b/scripts/ci-wiring.test.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node +// Static gate keeping .github/workflows/links.yml and README.md in step with the +// gates that live under scripts/. The workflow's `test` job picks up every +// `*.test.mjs` through one glob, but the shell gates and their self-tests are +// listed by hand, one `run:` step each — so a new `check-*.sh` or `*.test.sh` +// that nobody wires in is committed, documented, green locally and never run +// in CI. Likewise a `.mjs` file that uses node:test but is not named +// `*.test.mjs` is skipped by the glob without a word, a shell script whose +// executable bit was lost fails only at run time, and README `## Checks` is the +// only inventory a contributor reads. Each rule has a fixture self-test. +// Zero dependencies. +// Usage: node --test scripts/ci-wiring.test.mjs +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, ".."); +const WORKFLOW = join(ROOT, ".github", "workflows", "links.yml"); +const README = join(ROOT, "README.md"); +const NODE_GLOB = "node --test scripts/*.test.mjs"; + +// --- parsing --------------------------------------------------------------- + +// The `jobs:` block split into { name, body } entries, each body the text +// indented under the job name. +export function workflowJobs(yaml) { + const jobs = /^jobs:\s*$([\s\S]*)/m.exec(yaml); + if (!jobs) return []; + const out = []; + for (const m of jobs[1].matchAll(/^\s{2}([A-Za-z0-9_-]+):\s*$([\s\S]*?)(?=^\s{2}[A-Za-z0-9_-]+:\s*$|(?![\s\S]))/gm)) { + out.push({ name: m[1], body: m[2] }); + } + return out; +} + +// Every `run:` command in a job body: single-line values and `run: |` blocks, +// each block returned as one string. +export function runCommands(body) { + const out = []; + for (const m of body.matchAll(/^(\s*)(?:-\s+)?run:[ \t]*(\|-?|>-?)?[ \t]*(.*)$/gm)) { + if (m[2]) { + const after = body.slice(m.index + m[0].length); + const block = /^((?:[ \t]*\r?\n|[ \t]+\S.*\r?\n?)*)/.exec(after)?.[1] ?? ""; + const lines = block.split(/\r?\n/).filter((l) => l.trim()); + const indent = Math.min(...lines.map((l) => /^\s*/.exec(l)[0].length)); + out.push(lines.map((l) => l.slice(indent)).join("\n")); + } else { + out.push(m[3].trim()); + } + } + return out; +} + +export function isGatedByCondition(body) { + return /^\s{4}if:\s*\S/m.test(body); +} + +// Scripts under scripts/, classified. +export function classifyScripts(names) { + return { + shellTests: names.filter((n) => n.endsWith(".test.sh")).sort(), + shellGates: names.filter((n) => n.endsWith(".sh") && !n.endsWith(".test.sh")).sort(), + nodeTests: names.filter((n) => n.endsWith(".test.mjs")).sort(), + otherNode: names.filter((n) => n.endsWith(".mjs") && !n.endsWith(".test.mjs")).sort(), + }; +} + +// --- rules ----------------------------------------------------------------- + +// Which `run:` commands, across all unconditional jobs, mention a script. +function invocations(jobs, script) { + const out = []; + for (const job of jobs) { + for (const cmd of runCommands(job.body)) { + if (new RegExp(`(^|[\\s"'=])scripts/${script.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=$|[\\s"'])`, "m").test(cmd)) out.push({ job: job.name, gated: isGatedByCondition(job.body) }); + } + } + return out; +} + +export function wiringProblems(yaml, { scripts, readme, nodeSources = {} }) { + const problems = []; + const jobs = workflowJobs(yaml); + if (!jobs.length) return ["links.yml has no jobs"]; + const { shellTests, shellGates, nodeTests, otherNode } = classifyScripts(scripts); + + const globRuns = jobs.flatMap((j) => runCommands(j.body).filter((c) => c.split("\n").some((l) => l.trim() === NODE_GLOB)).map(() => j)); + if (!globRuns.length) problems.push(`no job runs \`${NODE_GLOB}\` (every scripts/*.test.mjs gate depends on that one step)`); + else if (globRuns.some((j) => isGatedByCondition(j.body))) problems.push(`the job running \`${NODE_GLOB}\` carries an \`if:\`, so node gates can be skipped`); + + for (const gate of shellGates) { + const runs = invocations(jobs, gate); + if (!runs.length) problems.push(`scripts/${gate} is a gate but no links.yml step runs it`); + else if (runs.every((r) => r.gated)) problems.push(`scripts/${gate} runs only in conditional jobs (${runs.map((r) => r.job).join(", ")})`); + const selfTest = gate.replace(/\.sh$/, ".test.sh"); + if (!shellTests.includes(selfTest)) problems.push(`scripts/${gate} has no self-test scripts/${selfTest}`); + } + for (const t of shellTests) { + const runs = invocations(jobs, t); + if (!runs.length) problems.push(`scripts/${t} is a self-test but no links.yml step runs it`); + else if (runs.every((r) => r.gated)) problems.push(`scripts/${t} runs only in conditional jobs (${runs.map((r) => r.job).join(", ")})`); + } + for (const n of otherNode) { + if (/from\s+["']node:test["']/.test(nodeSources[n] ?? "")) problems.push(`scripts/${n} imports node:test but is not named *.test.mjs, so \`${NODE_GLOB}\` never runs it`); + } + for (const n of nodeTests) { + if (!/from\s+["']node:test["']/.test(nodeSources[n] ?? "")) problems.push(`scripts/${n} is named like a gate but does not import node:test`); + } + + if (readme !== undefined) { + const checks = /^## Checks\s*$([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(readme)?.[1]; + if (checks === undefined) problems.push("README.md has no `## Checks` section"); + else { + for (const s of [...shellGates, ...shellTests, ...nodeTests]) { + if (!checks.includes(`scripts/${s}`)) problems.push(`README.md \`## Checks\` does not describe scripts/${s}`); + } + } + } + return problems; +} + +export function triggerProblems(yaml) { + const problems = []; + const on = /^on:\s*$([\s\S]*?)(?=^\S)/m.exec(yaml)?.[1] ?? ""; + if (!/^\s{2}pull_request:/m.test(on)) problems.push("links.yml does not run on pull_request"); + const push = /^\s{2}push:\s*$([\s\S]*?)(?=^\s{2}\S|(?![\s\S]))/m.exec(on)?.[1] ?? ""; + if (!/branches:\s*\[\s*main\s*\]/.test(push) && !/branches:\s*\n\s*-\s*main\b/.test(push)) problems.push("links.yml does not run on push to main (README promises a same-named baseline there)"); + return problems; +} + +export function modeProblems(entries) { + // entries: [{ name, mode }], mode from fs.statSync(...).mode + return entries.filter((e) => e.name.endsWith(".sh") && !(e.mode & 0o111)).map((e) => `${e.name} is not executable (CI runs it directly)`); +} + +// --- fixtures --------------------------------------------------------------- + +const YAML = `name: Link check + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "17 9 * * 1" + +jobs: + availability: + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + steps: + - name: probe + run: | + for url in https://a.test/ \\ + https://a.test/x/; do + curl "$url" + done + + links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@sha + - name: Check links + run: scripts/check-links.sh \${{ 'x' || '--external-warn' }} + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@sha + - name: Self-test check-links + run: scripts/check-links.test.sh + - name: Self-test check-redirects + run: scripts/check-redirects.test.sh + - name: Self-test page scripts + run: node --test scripts/*.test.mjs + + redirects: + runs-on: ubuntu-latest + steps: + - name: Check redirects + run: scripts/check-redirects.sh +`; + +const SCRIPTS = ["check-links.sh", "check-links.test.sh", "check-redirects.sh", "check-redirects.test.sh", "page-x.test.mjs"]; +const SOURCES = { "page-x.test.mjs": 'import { test } from "node:test";\n' }; +const README_OK = `# Site\n\n## Checks\n\n- \`scripts/check-links.sh\` — a\n- \`scripts/check-links.test.sh\` — b\n- \`scripts/check-redirects.sh\` — c\n- \`scripts/check-redirects.test.sh\` — d\n- \`node --test scripts/page-x.test.mjs\` — e\n\n## Next\n`; + +test("fixture: jobs, run commands (inline and block) and if: gating are parsed", () => { + const jobs = workflowJobs(YAML); + assert.deepEqual(jobs.map((j) => j.name), ["availability", "links", "test", "redirects"]); + assert.ok(isGatedByCondition(jobs[0].body)); + assert.ok(!isGatedByCondition(jobs[2].body)); + assert.deepEqual(runCommands(jobs[2].body), ["scripts/check-links.test.sh", "scripts/check-redirects.test.sh", "node --test scripts/*.test.mjs"]); + const block = runCommands(jobs[0].body); + assert.equal(block.length, 1); + assert.match(block[0], /^for url in https:\/\/a\.test\/ \\\n\s+https:\/\/a\.test\/x\/; do\n\s+curl "\$url"\ndone$/, "block scalar keeps relative indentation, drops the YAML indent"); + assert.deepEqual(runCommands(jobs[1].body), ["scripts/check-links.sh ${{ 'x' || '--external-warn' }}"]); + assert.deepEqual(workflowJobs("name: x\n"), []); +}); + +test("fixture: a fully wired workflow, script set and README pass", () => { + assert.deepEqual(wiringProblems(YAML, { scripts: SCRIPTS, readme: README_OK, nodeSources: SOURCES }), []); + assert.deepEqual(triggerProblems(YAML), []); +}); + +test("fixture: an unwired gate, an unwired self-test and a gate without a self-test are reported", () => { + const scripts = [...SCRIPTS, "check-fonts.sh", "check-fonts.test.sh", "check-orphans.sh"]; + const problems = wiringProblems(YAML, { scripts, readme: undefined, nodeSources: SOURCES }); + assert.ok(problems.includes("scripts/check-fonts.sh is a gate but no links.yml step runs it")); + assert.ok(problems.includes("scripts/check-fonts.test.sh is a self-test but no links.yml step runs it")); + assert.ok(problems.includes("scripts/check-orphans.sh has no self-test scripts/check-orphans.test.sh")); + assert.ok(problems.includes("scripts/check-orphans.sh is a gate but no links.yml step runs it")); +}); + +test("fixture: a script mentioned only as a substring of another path does not count as wired", () => { + const yaml = YAML.replace("run: scripts/check-redirects.sh", "run: scripts/check-redirects.sh.bak"); + const problems = wiringProblems(yaml, { scripts: SCRIPTS, nodeSources: SOURCES }); + assert.ok(problems.includes("scripts/check-redirects.sh is a gate but no links.yml step runs it"), problems.join("\n")); +}); + +test("fixture: a gate that runs only inside a conditional job is reported", () => { + const yaml = YAML.replace(" redirects:\n runs-on", " redirects:\n if: github.event_name == 'schedule'\n runs-on"); + const problems = wiringProblems(yaml, { scripts: SCRIPTS, nodeSources: SOURCES }); + assert.ok(problems.includes("scripts/check-redirects.sh runs only in conditional jobs (redirects)"), problems.join("\n")); +}); + +test("fixture: the node glob step is required and must be unconditional", () => { + const gone = YAML.replace(" run: node --test scripts/*.test.mjs\n", ""); + assert.ok(wiringProblems(gone, { scripts: SCRIPTS, nodeSources: SOURCES }).some((p) => p.startsWith("no job runs `node --test scripts/*.test.mjs`"))); + const gated = YAML.replace(" test:\n runs-on", " test:\n if: github.event_name == 'push'\n runs-on"); + assert.ok(wiringProblems(gated, { scripts: SCRIPTS, nodeSources: SOURCES }).some((p) => p.includes("carries an `if:`"))); + const enumerated = YAML.replace("node --test scripts/*.test.mjs", "node --test scripts/page-x.test.mjs"); + assert.ok(wiringProblems(enumerated, { scripts: SCRIPTS, nodeSources: SOURCES }).some((p) => p.startsWith("no job runs `node --test scripts/*.test.mjs`"))); +}); + +test("fixture: node:test files outside the glob, and glob-named files without node:test, are reported", () => { + const scripts = [...SCRIPTS, "helpers.mjs", "page-y.test.mjs"]; + const sources = { ...SOURCES, "helpers.mjs": 'import { test } from "node:test";\n', "page-y.test.mjs": "export const x = 1;\n" }; + const problems = wiringProblems(YAML, { scripts, nodeSources: sources }); + assert.ok(problems.some((p) => p.startsWith("scripts/helpers.mjs imports node:test but is not named *.test.mjs"))); + assert.ok(problems.includes("scripts/page-y.test.mjs is named like a gate but does not import node:test")); + const plainHelper = wiringProblems(YAML, { scripts: [...SCRIPTS, "helpers.mjs"], nodeSources: { ...SOURCES, "helpers.mjs": "export const x = 1;\n" } }); + assert.deepEqual(plainHelper, []); +}); + +test("fixture: README Checks must exist and name every gate and self-test", () => { + const noSection = wiringProblems(YAML, { scripts: SCRIPTS, readme: "# Site\n", nodeSources: SOURCES }); + assert.ok(noSection.includes("README.md has no `## Checks` section")); + const missing = wiringProblems(YAML, { scripts: SCRIPTS, readme: README_OK.replace("- `node --test scripts/page-x.test.mjs` — e\n", ""), nodeSources: SOURCES }); + assert.deepEqual(missing, ["README.md `## Checks` does not describe scripts/page-x.test.mjs"]); + const elsewhere = wiringProblems(YAML, { scripts: SCRIPTS, readme: README_OK.replace("- `node --test scripts/page-x.test.mjs` — e\n", "") + "\n`scripts/page-x.test.mjs`\n", nodeSources: SOURCES }); + assert.equal(elsewhere.length, 1, "a mention outside ## Checks does not count"); +}); + +test("fixture: triggers must include pull_request and push to main", () => { + assert.ok(triggerProblems(YAML.replace(" pull_request:\n", "")).includes("links.yml does not run on pull_request")); + assert.ok(triggerProblems(YAML.replace("branches: [main]", "branches: [release]")).some((p) => p.includes("push to main"))); + assert.deepEqual(triggerProblems(YAML.replace("branches: [main]", "branches:\n - main")), []); +}); + +test("fixture: shell scripts without an executable bit are reported", () => { + assert.deepEqual(modeProblems([{ name: "a.sh", mode: 0o100755 }, { name: "b.mjs", mode: 0o100644 }]), []); + assert.deepEqual(modeProblems([{ name: "a.sh", mode: 0o100644 }]), ["a.sh is not executable (CI runs it directly)"]); +}); + +// --- live checks over the committed tree ------------------------------------ + +const yaml = readFileSync(WORKFLOW, "utf8"); +const names = readdirSync(HERE).filter((n) => statSync(join(HERE, n)).isFile()); +const nodeSources = Object.fromEntries(names.filter((n) => n.endsWith(".mjs")).map((n) => [n, readFileSync(join(HERE, n), "utf8")])); + +test("links.yml: every scripts/ gate and self-test is run by an unconditional step, node gates run through the glob, README Checks lists them all", () => { + assert.deepEqual(wiringProblems(yaml, { scripts: names, readme: readFileSync(README, "utf8"), nodeSources }), [], "fixing a missing step needs a workflow edit; fixing a README gap does not"); +}); + +test("links.yml: runs on pull_request and on push to main", () => { + assert.deepEqual(triggerProblems(yaml), []); +}); + +test("scripts/*.sh and make-redirects.sh are executable", () => { + const entries = [ + ...names.map((n) => ({ name: `scripts/${n}`, mode: statSync(join(HERE, n)).mode })), + { name: "make-redirects.sh", mode: statSync(join(ROOT, "make-redirects.sh")).mode }, + ]; + assert.deepEqual(modeProblems(entries), []); +});