From e730502dcd88344cd1e601b5cbc47b93023187fd Mon Sep 17 00:00:00 2001 From: Remon Panman <228601219+Tradebaas@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:15:04 +0200 Subject: [PATCH] feat(checks): a project with a stack says whether its own gates are armed Nothing in checks/ knows anything about a product's code. The gates next door prove documents, budgets, traces and secrets, and every one of them stays green on a repo whose TypeScript does not compile. The tools that do know are the ecosystem's own, and the skill stack wires them into CI at the moment the stack is chosen: typecheck, lint, tests, build, audit, replacing the commented placeholder stage the workflow ships with. Until that happens there is a window nobody was watching. enforcement.mjs reports CI as armed the moment any workflow file exists, so a project can choose a stack, write its standards file, pass all 21 Groundwork gates, read "CI armed" in its own report, and have not one line of its own code checked by anything. That is the same family as the two defects fixed on 2026-08-01: a gate that is off without anyone deciding it should be. The new gate fires on exactly that window: a standards file that is not the cross-stack floor, plus a workflow that still carries commented-out stack gates. It asserts one mechanical thing, that the placeholders were dealt with, and deliberately not which tools were wired. Naming the blessed commands per ecosystem is an allowance list, and an allowance list is the thing that rots; proving the wired gates actually bite stays where stack section 3 already puts it, at introduce a violation and watch it fail. Three cases stay quiet on purpose and each has a test: a fresh copy that has not picked a stack, GLOBAL.md on its own, and a project whose CI lives on another host, which stack section 3 explicitly allows and enforcement.mjs already reports on. It lands in its own file because adding it to check.mjs took that file to 527 lines against its own 500-line cap. Splitting by responsibility is what PR #61 did for the same reason, so the suite mirrors the source here too: check-stack.mjs and check-stack.test.mjs, composed into the one registry like the code and trace families beside them. The registry is the source of the explainer's gate count, which moves 21 to 22 and was caught by its own gate rather than by me. Traces-to: SC-5 --- .github/workflows/ci.yml | 1 + checks/check-stack.mjs | 48 ++++++++++++++++++++++++++ checks/check-stack.test.mjs | 67 +++++++++++++++++++++++++++++++++++++ checks/check.mjs | 2 ++ checks/check.test.mjs | 4 +-- checks/drill.mjs | 7 ++-- index.html | 2 +- 7 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 checks/check-stack.mjs create mode 100644 checks/check-stack.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7c1d06..fcd8743 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: node checks/check.test.mjs node checks/check-code.test.mjs node checks/check-trace.test.mjs + node checks/check-stack.test.mjs node checks/progress.test.mjs node checks/links.test.mjs node checks/cockpit-path.test.mjs diff --git a/checks/check-stack.mjs b/checks/check-stack.mjs new file mode 100644 index 0000000..a8f444b --- /dev/null +++ b/checks/check-stack.mjs @@ -0,0 +1,48 @@ +// The gate that asks whether this project's OWN quality gates are armed, as opposed to +// Groundwork's. Part of checks/check.mjs, which composes it into its registry and owns the run. +// +// Nothing else in checks/ knows anything about a product's code: the gates next door prove +// documents, budgets, traces and secrets, and they would all stay green on a repo whose +// TypeScript does not compile. The tools that do know are the ecosystem's own, and `stack` +// section 3 wires them into CI at the moment the stack is chosen. Until that happens the +// workflow ships a commented placeholder stage, and enforcement.mjs reports CI as armed the +// moment any workflow file exists. So between choosing a stack and wiring its gates there is a +// window where every signal reads green and not one line of the project's code is checked. +// This gate closes that window. + +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +// A stack file is any standards document that is not the cross-stack floor and not a template: +// `stack` section 2 writes exactly one, named for the stack (docs/standards/.md). +const stackFiles = (standards) => readdirSync(standards, { withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith('.md') + && e.name !== 'GLOBAL.md' && !e.name.startsWith('TEMPLATE-')) + .map((e) => e.name); + +export const stackChecks = ({ root, fail, lines }) => ({ + 'stack-gates'() { + const standards = join(root, 'docs', 'standards'); + if (!existsSync(standards)) return; + const stacks = stackFiles(standards); + if (!stacks.length) return; + + // Another CI host is explicitly allowed (`stack` section 3: "or this host's equivalent"), + // and whether CI exists at all is enforcement.mjs's report to make. One fact, one place. + const wfDir = join(root, '.github', 'workflows'); + if (!existsSync(wfDir)) return; + + for (const name of readdirSync(wfDir).filter((n) => /\.ya?ml$/.test(n))) { + lines(join(wfDir, name)).forEach((line, i) => { + if (!/^\s*#\s*(-\s*name:|---\s*Stack gates)/.test(line)) return; + fail(`.github/workflows/${name}:${i + 1} still carries a commented-out stack gate while docs/standards/ names a stack (${stacks.join(', ')}). Until that stage is filled in, CI proves Groundwork's own rules and nothing about this project's code. Replace the placeholders with this stack's real gates per the skill \`stack\` section 3, and delete the ones this stack has no equivalent for instead of leaving them commented.`); + }); + } + }, +}); + +// What this gate deliberately does not do: name the tools it expects to find. A list of blessed +// commands per ecosystem is the kind of allowance list that rots, and it would turn every new +// language into a change here. So the mechanical half is "the placeholders were dealt with", +// and proving the wired gates actually bite stays where `stack` section 3 already puts it: +// introduce a violation, watch the gate fail, revert. diff --git a/checks/check-stack.test.mjs b/checks/check-stack.test.mjs new file mode 100644 index 0000000..d380c97 --- /dev/null +++ b/checks/check-stack.test.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// Self-test for checks/check-stack.mjs: the gate that asks whether this project's own quality +// gates are armed. It must fire on the window it exists for (a stack chosen, the workflow's +// placeholder stage untouched) and stay quiet everywhere else, above all on a fresh copy that +// has not chosen a stack yet: an untested gate is false confidence (decision 0005). +// Run: node checks/check-stack.test.mjs + +import { expectClean, expectFail, report } from './check-fixture.mjs'; + +// The manifest row keeps docs-manifest quiet, so only the gate under test speaks. +const manifest = '# manifest\n\n| `state/STATE.md` | LIVE | state |\n| `standards/**` | LIVE | standards |\n'; +const stack = ({ put }) => { + put('docs/README.md', manifest); + put('docs/standards/typescript.md', '# TypeScript\n\n- Platform: no\n'); +}; + +const PLACEHOLDER_CI = `name: ci +jobs: + gate: + steps: + - name: Groundwork checks + run: node checks/check.mjs + + # --- Stack gates (added by the \`stack\` skill) --- + # - name: Typecheck + # - name: Tests +`; + +const ARMED_CI = `name: ci +jobs: + gate: + steps: + - name: Groundwork checks + run: node checks/check.mjs + - name: Typecheck + run: npm run typecheck +`; + +// The window this gate exists for: a stack is chosen and CI still checks nothing of its code. +expectFail('stack-gates', (fx) => { + stack(fx); + fx.put('.github/workflows/ci.yml', PLACEHOLDER_CI); +}); + +// A fresh copy ships those same placeholders and must still pass. If this one ever goes red, +// every new project starts on a red gate for a stack it has not picked yet. +expectClean('stack-gates-quiet-before-a-stack', ({ put }) => + put('.github/workflows/ci.yml', PLACEHOLDER_CI)); + +// GLOBAL.md is the cross-stack floor, which every copy carries from the start. It is not a +// stack, so its presence alone must not arm this gate. +expectClean('stack-gates-floor-is-not-a-stack', ({ put }) => { + put('docs/README.md', manifest); + put('docs/standards/GLOBAL.md', '# the cross-stack floor\n'); + put('.github/workflows/ci.yml', PLACEHOLDER_CI); +}); + +expectClean('stack-gates-armed', (fx) => { + stack(fx); + fx.put('.github/workflows/ci.yml', ARMED_CI); +}); + +// `stack` section 3 allows another CI host, and whether CI exists at all is enforcement.mjs's +// report to make. A project on GitLab must not be failed here for not being on GitHub. +expectClean('stack-gates-another-ci-host', stack); + +report('stack-gate'); diff --git a/checks/check.mjs b/checks/check.mjs index 98cf932..68305d7 100644 --- a/checks/check.mjs +++ b/checks/check.mjs @@ -22,6 +22,7 @@ import { enforcementReport, formatReport } from './enforcement.mjs'; // file may contain and how long it may be, and the trace chain from brief to commit. import { codeChecks } from './check-code.mjs'; import { checkCommitMessage, traceChecks } from './check-trace.mjs'; +import { stackChecks } from './check-stack.mjs'; // The commit-msg hook and the self-test have always imported this from here; it is authored in // check-trace.mjs with the rest of the chain, and stays reachable at its published address. @@ -350,6 +351,7 @@ export function runChecks(root) { ...codeChecks(ctx), ...traceChecks(ctx), + ...stackChecks(ctx), 'explainer-stats'() { // The explainer page states counts of what this repo holds. A typed count goes stale the diff --git a/checks/check.test.mjs b/checks/check.test.mjs index 2371839..d6e36f7 100644 --- a/checks/check.test.mjs +++ b/checks/check.test.mjs @@ -2,8 +2,8 @@ // Self-test for checks/check.mjs: the document, rulebook and config gates it still owns, plus // the runner's own wiring (hooks, the enforcement self-report, the handoff nudge). Every check // must prove it FAILS on a real violation and stays quiet on a clean repo: an untested gate is -// false confidence (decision 0005). The two gate families that live in their own files are -// proven next door, by check-code.test.mjs and check-trace.test.mjs. +// false confidence (decision 0005). The three gate families that live in their own files are +// proven next door, by check-code.test.mjs, check-trace.test.mjs and check-stack.test.mjs. // Run: node checks/check.test.mjs import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, unlinkSync, readFileSync } from 'node:fs'; diff --git a/checks/drill.mjs b/checks/drill.mjs index d096a10..17a261d 100644 --- a/checks/drill.mjs +++ b/checks/drill.mjs @@ -28,10 +28,11 @@ function resolveSource() { return dirname(dirname(fileURLToPath(import.meta.url))); } -// The five suites CI proves before it trusts any gate, run here inside the copy so it is the -// copy's own code under test and never this working tree's. +// The suites CI proves before it trusts any gate, run here inside the copy so it is the copy's +// own code under test and never this working tree's. Keep in step with the `gate` job in +// .github/workflows/ci.yml, which is the list this one mirrors. const SUITES = [ - 'check.test.mjs', 'check-code.test.mjs', 'check-trace.test.mjs', + 'check.test.mjs', 'check-code.test.mjs', 'check-trace.test.mjs', 'check-stack.test.mjs', 'progress.test.mjs', 'links.test.mjs', 'cockpit-path.test.mjs', 'cockpit.test.mjs', ]; diff --git a/index.html b/index.html index 99c7179..9ae9ba9 100644 --- a/index.html +++ b/index.html @@ -463,7 +463,7 @@

From idea to product, without the mess

claims, not counts. Copied this repo to start a product? `begin` drops these markers: the numbers then describe the framework you copied, not what you are building. -->
21
Skills on demand
-
21
Automated checks
+
22
Automated checks
19
Recorded decisions
1
Rulebook, every tool
0
Tokens for the checks