Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions checks/check-stack.mjs
Original file line number Diff line number Diff line change
@@ -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/<stack>.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.
67 changes: 67 additions & 0 deletions checks/check-stack.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
2 changes: 2 additions & 0 deletions checks/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions checks/check.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 4 additions & 3 deletions checks/drill.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ <h1 class="rise d1">From idea to product, without the mess</h1>
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. -->
<div class="stat"><div class="n" data-derive="skills">21</div><div class="l">Skills on demand</div></div>
<div class="stat"><div class="n" data-derive="gates">21</div><div class="l">Automated checks</div></div>
<div class="stat"><div class="n" data-derive="gates">22</div><div class="l">Automated checks</div></div>
<div class="stat"><div class="n" data-derive="decisions">19</div><div class="l">Recorded decisions</div></div>
<div class="stat"><div class="n">1</div><div class="l">Rulebook, every tool</div></div>
<div class="stat"><div class="n"><em>0</em></div><div class="l">Tokens for the checks</div></div>
Expand Down
Loading