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