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
24 changes: 24 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,6 +169,30 @@ jobs:
- name: PM skill issue-ID lint
run: pnpm check:pm-skill-id-lint

# PM dispatch-gates self-test (#8162). `scripts/pm/dispatch-gates.mjs`
# derives the "local gates for this card" line of every dispatch prompt,
# and carried a 61-case --self-test that NO job ran: it executed only when
# a human or an agent typed it. A break in the extraction functions landed
# green and surfaced later as a prompt naming the wrong gate families —
# output that reads as correct, produced by the one tool whose purpose is
# to stop gate lists being memory-shaped. Same family as the
# `Changeset-family gate self-tests` step above, and like that one this
# step is deliberately UNCONDITIONAL: no `if:`, no label read, no paths
# filter. An exemption is what a self-test must not have, or the gap moves.
#
# The gate runs the SELF-TEST only. The live derivation
# (`node scripts/pm/dispatch-gates.mjs <path>`) answers a question about a
# card's file surface, is print-only, and exits 0 on any completed run —
# there is no verdict in it for CI to hold, only a slow read of the whole
# workflow tree. Why the gate is its own file rather than pointing
# `check:pm-dispatch-gates` straight at the tool is measured and argued in
# `scripts/pm/check-dispatch-gates.mjs`'s header (the tool's self-test
# fixtures are path strings, and they become watch hints that fabricate
# MATCHED leads across most of the tree). Reads two files and walks
# packages/; ~0.3s.
- name: PM dispatch-gates self-test
run: pnpm check:pm-dispatch-gates

# Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in
# Markdown/MDX are not type-checked or ESLinted, so skills/ and
# content/docs/ can drift back to teaching the bare `: Page = {}` literal
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,7 @@
"check:agent-model-declared": "node scripts/check-agent-model-declared.mjs --self-test && node scripts/check-agent-model-declared.mjs",
"check:pm-skill-ratchet": "node scripts/pm/check-skill-line-ratchet.mjs --self-test && node scripts/pm/check-skill-line-ratchet.mjs",
"check:pm-skill-id-lint": "node scripts/pm/check-skill-id-lint.mjs --self-test && node scripts/pm/check-skill-id-lint.mjs",
"check:pm-dispatch-gates": "node scripts/pm/check-dispatch-gates.mjs",
"check:adr-anchors": "node scripts/check-adr-anchors.mjs --self-test && node scripts/check-adr-anchors.mjs",
"check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs",
"check:adr-merge-approval": "node scripts/check-adr-merge-approval.mjs --self-test && node scripts/check-adr-merge-approval.mjs",
Expand Down
95 changes: 95 additions & 0 deletions scripts/pm/check-dispatch-gates.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env node
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* check:pm-dispatch-gates (#8162) — the CI gate for the dispatch-gates tool.
*
* node scripts/pm/check-dispatch-gates.mjs # runs the tool's --self-test
*
* ⚠️ This header names repo paths UNQUOTED on purpose — see the last section.
*
* ## Why the gate exists
*
* scripts/pm/dispatch-gates.mjs derives the "local gates for this card" line of
* every dispatch prompt. It carried a --self-test from its first commit — 61
* cases at the time this gate was written, covering workflow extraction, script
* resolution, watch-hint scanning, the runnable-invocation rendering and the
* change-kind derivation — and no CI job ran a single one of them. The
* self-test executed only when a human or an agent typed it, which makes it a
* check whose coverage is a function of who remembered.
*
* The failure that shape produces is quiet: a break in the extraction functions
* lands green, and it surfaces later as a dispatch prompt naming the WRONG gate
* families — output that reads as correct, on the very tool whose purpose is to
* stop gate lists from being memory-shaped. Same family as the changeset gates'
* self-tests, which is why the workflow step, like theirs, is unconditional.
*
* ## Why the self-test ONLY, and not the live derivation
*
* The live derivation answers a QUESTION about a card's file surface: it
* re-reads every workflow file and every check script's source, prints leads,
* and exits 0 on any completed run. There is no verdict in it for CI to hold —
* gating on it would buy a slow read of the whole workflow tree whose exit code
* is 0 by construction. What CI can hold is the half that HAS a verdict, the
* self-test. That self-test is not fixture-only either: it reads the real
* pr-automation.yml and walks the real packages tree in the cases where a
* fixture cannot prove the point, so the derivation's contact with reality is
* covered by this gate too.
*
* ## Why this file exists instead of pointing the script at the tool directly
*
* The obvious spelling is to make check:pm-dispatch-gates run the tool's own
* --self-test, with no file in between. That is the one shape this particular
* tool cannot have. The derivation resolves a check family to its script file
* and then scans THAT FILE's source for the path literals it operates on — its
* watch hints — and dispatch-gates.mjs is a tool whose own tests are made of
* path strings. Measured on the tree at the time of writing: 49 literals
* extracted from its source, of which 2 name inputs it really reads and 43 are
* self-test FIXTURES naming other packages. Wired directly, the derivation
* printed a MATCHED line for this gate on a card touching spec's filter schema,
* matched via a fixture string inside the self-test — a fabricated lead in the
* column the tool's contract reserves for high-signal answers, for most of the
* tree. The tool's own header rejects exactly that ("22 leads is the same as
* none"), so shipping it as the price of gating the tool would have taken more
* from every dispatch prompt than the gate gives back.
*
* A separate gate file is also what the other two pm gates look like
* (check-skill-line-ratchet.mjs, check-skill-id-lint.mjs). Its watch hints are
* the one constant below: this gate is matched for a card that edits the tool,
* and for nothing else — which is the blind spot #8162 is about.
*
* ## Why the paths above are unquoted
*
* Watch-hint extraction reads any quoted-looking span, backticks included, and
* does not skip comments. Written the ordinary way, with each path in backticks,
* this header alone yielded ten hints — packages/spec/src, packages/objectql,
* packages/plugins, packages/drivers, .claude/agents, .changeset among them —
* and reproduced, from the file explaining the pollution, the exact false
* MATCHED leads it exists to avoid (measured, not predicted: the first draft of
* this file did it). So paths are named unquoted here, and the only quoted path
* in this file is the one input this gate genuinely has.
*
* Nothing else belongs in this file. Assertions go in the tool's own self-test,
* beside the code they judge; this is the CI invocation and its reason.
*/

import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import process from 'node:process';

const ROOT = new URL('../..', import.meta.url).pathname;

/** The tool under test, repo-relative — and this gate's only watch hint. */
const TOOL = 'scripts/pm/dispatch-gates.mjs';

const result = spawnSync(process.execPath, [join(ROOT, TOOL), '--self-test'], { stdio: 'inherit' });

if (result.error) {
console.error(`✗ check:pm-dispatch-gates: could not run ${TOOL} — ${result.error.message}`);
process.exit(2);
}
if (result.signal) {
console.error(`✗ check:pm-dispatch-gates: ${TOOL} --self-test was killed by ${result.signal}.`);
process.exit(2);
}
process.exit(result.status ?? 2);
11 changes: 10 additions & 1 deletion scripts/pm/dispatch-gates.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -642,7 +642,16 @@ function selfTest() {
const resolved = (name) => `pnpm ${name}`;
const kindHit = changeKindLines(['packages/objectql/src/engine.test.ts'], resolved);
t('a test path emits the convention section', kindHit.length === 3 && kindHit[0].includes('adds or edits a test file'));
t('the section names both convention gates, runnably', kindHit.some((l) => l.includes('pnpm check:query-options-erasure')) && kindHit.some((l) => l.includes('pnpm check:type-check-coverage')));
// Both halves anchor on the rendered DELIMITERS (`- pnpm x —`), for the
// reason the i18n entry's pins below state at length: a bare `includes` is
// satisfied by every name that merely STARTS WITH the expected one, so a
// prefix-preserving rename is invisible to it — the single rot class the STALE
// branch exists to report. Measured on this entry rather than inherited from
// that one: renaming these gates to `check:query-options-erasure-v2` and
// `check:type-check-coverage-v2` in CHANGE_KIND_GATES left the substring form
// green at 61/61 while the live run printed both as STALE; anchored, the same
// rename fails this case. The two conventions in this file now agree.
t('the section names both convention gates, runnably', kindHit.some((l) => l.includes('- pnpm check:query-options-erasure —')) && kindHit.some((l) => l.includes('- pnpm check:type-check-coverage —')));
t('a non-test path emits nothing', changeKindLines(['scripts/pm/dispatch-gates.mjs'], resolved).length === 0);

// i18n change-kind derivation — the pure judgments first, each mirroring one
Expand Down
Loading