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
11 changes: 11 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -456,11 +456,22 @@ jobs:
git fetch --no-tags --depth=1 origin "${{ github.base_ref || github.event.repository.default_branch }}"
node .harness/scripts/ci/49-validate-gap-id-allocation.mjs --verbose

# GT-639: a board row carries a STATUS but not who is working it or where, so
# two sessions can both read `PENDING` and both start. On 2026-07-30 the same
# work was done twice, three times over. The claim set is DERIVED from open
# pull requests — never hand-written, which would go stale in the direction
# that matters — and an id claimed by two open PRs fails here, naming both.
- name: One gap, one claim
env:
GH_TOKEN: ${{ github.token }}
run: node .harness/scripts/ci/50-validate-gap-claim.mjs

- name: Self-tests for the governance guards
run: |
node --test .harness/scripts/ci/42-validate-guard-denominators.test.mjs
node --test .harness/scripts/ci/48-validate-security-publish-lag.test.mjs
node --test .harness/scripts/ci/49-validate-gap-id-allocation.test.mjs
node --test .harness/scripts/ci/50-validate-gap-claim.test.mjs
node --test .harness/scripts/ci/41-validate-evidence-commands.test.mjs
node --test .harness/scripts/ci/43-validate-guard-negative-fixtures.test.mjs
node --test .harness/scripts/ci/44-validate-adr-implementation-status.test.mjs
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/docker-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,17 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }}
# `type=ref,event=tag` publishes the git tag VERBATIM — `v1.1.0`, with
# the leading v. The Helm charts ask for the bare semver
# (`evolith-mcp` → `1.1.0`), so no release this workflow has ever run
# could satisfy `helm install` with the default values: the chart named
# a tag the pipeline is structurally incapable of producing. Adding
# `type=semver` publishes `1.1.0` and `1.1` alongside `v1.1.0`, which
# is what the charts reference.
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=,format=short
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
Expand Down
199 changes: 199 additions & 0 deletions .harness/scripts/ci/50-validate-gap-claim.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/usr/bin/env node

/**
* GT-639 — two sessions must not work the same gap without knowing.
*
* ## The defect
*
* On 2026-07-30 the same work was done twice, three separate times, by sessions
* that could not see each other: GT-640 (then GT-633) was fixed by a standalone
* capture script on `main` and by an independent in-spec renderer on `develop`;
* the evidence guard's parser was fixed on both branches; and a GT id was
* allocated twice. The cost was not the merge conflict — it was the duplicated
* work before anyone reached it, plus a reconciliation that introduced a defect
* of its own.
*
* A board row carries a STATUS but not who is working it or where. Two sessions
* can both read `GT-640 · PENDING` and both start, correctly.
*
* ## What it checks
*
* The claim set is DERIVED from open pull requests — never hand-written, because
* a hand-written claim is stale in the direction that matters (someone forgets to
* remove it, and the next session works around a claim nobody holds). Every open
* PR claims the `GT-*` ids that appear in its title, its body or its branch name.
*
* An id claimed by MORE THAN ONE open pull request is a contested claim, and this
* guard fails naming both claimants.
*
* ## What it deliberately does NOT do
*
* It does not write a committed claim list. Such a file would be derived from
* live GitHub state, so it would be stale the moment anyone opened a PR, and the
* repository already has a guard (46) whose whole premise is that derived
* artifacts must reach a fixed point. A perpetually-stale artifact in that chain
* would be worse than none. The live view is this check's output, on the PR where
* it matters.
*
* It also cannot see work that has no open PR. A branch someone is working on
* privately claims nothing, and the guard says so rather than implying coverage.
*
* ## Anti-vacuous pass
*
* Zero open pull requests examined is reported, not silently passed: with no PRs
* there is nothing to contest, and a run that found none because the query broke
* looks identical unless it says which happened. A query that fails outright is a
* hard failure — unable to answer is not the same as nothing to report.
*
* USAGE
* node .harness/scripts/ci/50-validate-gap-claim.mjs
* node .harness/scripts/ci/50-validate-gap-claim.mjs --json
* node .harness/scripts/ci/50-validate-gap-claim.mjs --fixture <file> # offline
*
* EXIT CODES
* 0 no id is claimed by more than one open pull request
* 1 a contested claim, or the pull-request query could not be answered
*/

import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const GUARD = '50-validate-gap-claim';

// ---------------------------------------------------------------------------
// Pure core — takes pull requests as data, returns the claim map. No network.
// ---------------------------------------------------------------------------

/** Every `GT-NNN` mentioned in a pull request's title, body or branch name. */
export function claimedIds(pr) {
const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`;
return [...new Set(haystack.match(/GT-\d+/g) ?? [])].sort();
}

/**
* id -> the open pull requests claiming it.
*
* @param {Array<{number:number,title?:string,body?:string,headRefName?:string,url?:string}>} prs
* @returns {Map<string, Array<object>>}
*/
export function buildClaimMap(prs) {
const map = new Map();
for (const pr of prs) {
for (const id of claimedIds(pr)) {
if (!map.has(id)) map.set(id, []);
map.get(id).push(pr);
}
}
return map;
}

/** Ids claimed by more than one open pull request. */
export function findContested(claimMap) {
return [...claimMap.entries()]
.filter(([, prs]) => prs.length > 1)
.map(([id, prs]) => ({ id, prs }))
.sort((a, b) => a.id.localeCompare(b.id));
}

// ---------------------------------------------------------------------------
// I/O edges
// ---------------------------------------------------------------------------

function fail(lines) {
console.error(`\n✗ ${GUARD}: ${lines[0]}`);
for (const l of lines.slice(1)) console.error(` ${l}`);
process.exit(1);
}

/** Open pull requests, from `gh`. Returns null when the query cannot be answered. */
export function fetchOpenPullRequests() {
try {
const raw = execFileSync(
'gh',
['pr', 'list', '--state', 'open', '--limit', '200', '--json', 'number,title,body,headRefName,url'],
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] },
);
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}

function main(argv) {
const asJson = argv.includes('--json');
const fixtureIdx = argv.indexOf('--fixture');

const prs = fixtureIdx !== -1
? JSON.parse(fs.readFileSync(path.resolve(process.cwd(), argv[fixtureIdx + 1]), 'utf8'))
: fetchOpenPullRequests();

if (prs === null) {
fail([
'could not read the open pull requests, so no claim was checked.',
' `gh pr list` failed — in CI that usually means GH_TOKEN is not set on the step.',
'',
' Unable to answer is not the same as nothing to report. This guard exists',
' because two sessions could not see each other; a version of it that stays',
' quiet when it cannot look would reproduce exactly that.',
]);
}

const claimMap = buildClaimMap(prs);
const contested = findContested(claimMap);

if (asJson) {
process.stdout.write(JSON.stringify({
pullRequests: prs.length,
claims: Object.fromEntries([...claimMap].map(([id, list]) => [id, list.map((p) => p.number)])),
contested: contested.map((c) => ({ id: c.id, prs: c.prs.map((p) => p.number) })),
}) + '\n');
return contested.length > 0 ? 1 : 0;
}

console.log(`${GUARD} — one gap, one claim`);
console.log(` open pull requests .. ${prs.length}`);
console.log(` ids claimed ......... ${claimMap.size}`);
console.log(` contested ........... ${contested.length}`);

if (prs.length === 0) {
// Said out loud: with no open PRs there is nothing to contest, which is a
// legitimate state and looks identical to a broken query unless named.
console.log('\n No pull request is open, so nothing is claimed and nothing can be contested.');
}

for (const [id, list] of [...claimMap].sort()) {
if (list.length === 1) console.log(` · ${id} — #${list[0].number} ${list[0].headRefName ?? ''}`);
}

if (contested.length > 0) {
fail([
`${contested.length} gap id(s) are claimed by more than one open pull request:`,
...contested.flatMap((c) => [
` • ${c.id}`,
...c.prs.map((p) => ` #${p.number} ${p.headRefName ?? '?'} ${p.title ?? ''}`),
]),
'',
' Two sessions are working the same gap. That is not a merge conflict yet, and',
' it is much cheaper to resolve now than after both have landed: on 2026-07-30',
' the same fix was written twice and a third session spent a full reconciliation',
' collapsing them (GT-639).',
'',
' Decide which pull request owns the id, and say so in the other one.',
'',
' NOT COVERED by this check: a branch with no open pull request claims nothing.',
' Opening the PR early — draft is enough — is what makes the claim visible.',
]);
}

console.log(
`\n✓ ${GUARD}: ${claimMap.size} claimed id(s) across ${prs.length} open pull request(s), none contested.`,
);
return 0;
}

const invokedDirectly =
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) process.exit(main(process.argv.slice(2)));
136 changes: 136 additions & 0 deletions .harness/scripts/ci/50-validate-gap-claim.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env node

/**
* GT-639 — fixtures for the gap-claim guard.
*
* The case that matters is the one this repository lived through: two open pull
* requests working the same gap, neither aware of the other. If that fixture ever
* goes green the guard is decoration — which is why GT-639's last criterion asks
* for a fixture OBSERVED red rather than a guard someone believes works.
*
* The pull requests are supplied as data through `--fixture`, so nothing here
* touches the network: the guard's core is pure by construction and the I/O edge
* is one function.
*/

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';

import { claimedIds, buildClaimMap, findContested } from './50-validate-gap-claim.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));
const GUARD = resolve(__dirname, '50-validate-gap-claim.mjs');

let sandbox;
before(() => { sandbox = mkdtempSync(join(tmpdir(), 'gt639-')); });
after(() => { if (sandbox) rmSync(sandbox, { recursive: true, force: true }); });

const runWith = (prs, extra = []) => {
const file = join(sandbox, `prs-${Math.abs(JSON.stringify(prs).length)}-${prs.length}.json`);
writeFileSync(file, JSON.stringify(prs));
const r = spawnSync(process.execPath, [GUARD, '--fixture', file, ...extra], {
encoding: 'utf8', timeout: 60000,
});
return { status: r.status, out: `${r.stdout}\n${r.stderr}` };
};

describe('claimedIds', () => {
it('reads a claim from the title, the body or the branch name', () => {
assert.deepEqual(claimedIds({ title: 'fix(x): close GT-100' }), ['GT-100']);
assert.deepEqual(claimedIds({ body: 'refs GT-101 and GT-102' }), ['GT-101', 'GT-102']);
assert.deepEqual(claimedIds({ headRefName: 'feat/gt-103-thing' }), []); // lowercase is not an id
assert.deepEqual(claimedIds({ headRefName: 'docs/GT-104-rows' }), ['GT-104']);
});

it('does not claim the same id twice from one pull request', () => {
assert.deepEqual(claimedIds({ title: 'GT-105', body: 'GT-105 again', headRefName: 'x/GT-105' }), ['GT-105']);
});

it('claims nothing when a pull request names no gap', () => {
assert.deepEqual(claimedIds({ title: 'chore: tidy', body: '', headRefName: 'chore/tidy' }), []);
});
});

describe('findContested', () => {
it('THE CASE: one id, two open pull requests', () => {
const contested = findContested(buildClaimMap([
{ number: 1, title: 'fix: GT-200 via a capture script' },
{ number: 2, title: 'fix: GT-200 via an in-spec renderer' },
{ number: 3, title: 'docs: GT-201' },
]));
assert.equal(contested.length, 1);
assert.equal(contested[0].id, 'GT-200');
assert.deepEqual(contested[0].prs.map((p) => p.number), [1, 2]);
});

it('one pull request claiming many ids is not contested', () => {
assert.deepEqual(findContested(buildClaimMap([{ number: 1, title: 'GT-300 GT-301 GT-302' }])), []);
});
});

describe('the guard end to end', () => {
it('THE FIXTURE: two open PRs on one gap — RED, naming both', () => {
const { status, out } = runWith([
{ number: 276, title: 'fix(standards): capture script', headRefName: 'claude/fervent', body: 'closes GT-640' },
{ number: 279, title: 'fix(standards): in-spec renderer for GT-640', headRefName: 'claude/nice-cohen', body: '' },
]);
assert.equal(status, 1, out);
assert.match(out, /1 gap id\(s\) are claimed by more than one open pull request/);
assert.match(out, /#276/);
assert.match(out, /#279/);
// It must say why this is worth stopping for, and what to do.
assert.match(out, /much cheaper to resolve now than after both have landed/);
assert.match(out, /Decide which pull request owns the id/);
// And it must not imply coverage it does not have.
assert.match(out, /a branch with no open pull request claims nothing/);
});

it('distinct gaps across many PRs are green, and each claim is listed', () => {
const { status, out } = runWith([
{ number: 10, title: 'GT-400', headRefName: 'a' },
{ number: 11, title: 'GT-401', headRefName: 'b' },
]);
assert.equal(status, 0, out);
assert.match(out, /ids claimed \.+ 2/);
assert.match(out, /GT-400 — #10/);
});

it('says out loud when there is nothing to check', () => {
// Zero open PRs is a legitimate state that looks identical to a broken query
// unless it is named.
const { status, out } = runWith([]);
assert.equal(status, 0, out);
assert.match(out, /No pull request is open, so nothing is claimed/);
});

it('--json reports the claim map and exits non-zero when contested', () => {
const { status, out } = runWith(
[{ number: 1, title: 'GT-500' }, { number: 2, title: 'GT-500' }],
['--json'],
);
assert.equal(status, 1, out);
const payload = JSON.parse(out.trim().split('\n')[0]);
assert.deepEqual(payload.contested, [{ id: 'GT-500', prs: [1, 2] }]);
});
});

describe('anti-vacuous floor', () => {
it('refuses to pass when the pull-request query cannot be answered', () => {
// No --fixture, and `gh` unusable: the guard must fail rather than report a
// clean run over a list it never got.
const r = spawnSync(process.execPath, [GUARD], {
encoding: 'utf8',
timeout: 60000,
env: { ...process.env, PATH: '/nonexistent' },
});
assert.equal(r.status, 1, r.stdout + r.stderr);
const out = `${r.stdout}\n${r.stderr}`;
assert.match(out, /could not read the open pull requests/);
assert.match(out, /Unable to answer is not the same as nothing to report/);
});
});
9 changes: 9 additions & 0 deletions .harness/scripts/lib/guard-classification.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ export const CALLS_COVERAGE = /\b(?:assertScanned|assertScannedPerSource|scanned
* deleting the check and leaving the exemption behind.
*/
export const SELF_GUARDED = [
{
file: '50-validate-gap-claim.mjs',
proof: /could not read the open pull requests/,
reason:
'GT-639 gap-claim guard; refuses to pass when the pull-request query cannot be answered — ' +
'the guard exists because two sessions could not see each other, so one that stays quiet ' +
'when it cannot look would reproduce the defect. Zero open PRs is reported in words rather ' +
'than passed silently, because it looks identical to a broken query otherwise',
},
{
file: '49-validate-gap-id-allocation.mjs',
proof: /at least one side yielded NOTHING/,
Expand Down
Loading
Loading