Pre-PR Preflight

Run scripts/preflight-pr.py before gh pr create and before every push. It reports every gate CI would turn red for a reason you can already know locally: the declarations your pull-request body owes, and the source guards a deletion leaves pointing at nothing. Each such red that reaches CI costs a full round (a push or a body edit, a re-run, a wait) for a verdict that was available before the pull request existed.

# core worktree — draft the body into a file first
python3 scripts/preflight-pr.py --body-file pr-body.md            # online: uses $GH_TOKEN or `gh auth token`
python3 scripts/preflight-pr.py --body-file pr-body.md --offline  # no network: static halves only
python3 scripts/preflight-pr.py                                   # no body yet: guards only, body gates SKIPPED

# a satellite checkout (MeshWeaver.Plugins, a node repo)
python3 <core checkout>/scripts/preflight-pr.py --repo-root . --body-file pr-body.md

The verdict line and the exit code say the same thing:

Exit Verdict Meaning
0 GREEN every check that applies passed
1 RED at least one check would fail in CI, and the line under it says why
2 INCOMPLETE nothing red, but at least one check was SKIPPED and is NOT verified

A SKIPPED check is never a pass. It always prints its reason: --offline, no token, no --body-file, an input only CI has.

In a fork, origin names the fork, not the repository the pull request targets. Without --repo Systemorph/<name> the preflight then refuses with INCOMPLETE rather than guessing which gates apply.

What it checks, and how

The script never re-implements a gate. It invokes the gate script that CI's job invokes, with the same arguments, from the repository root, against the merge base with --base (default origin/main). That is the same comparison every diff gate makes in CI. It reads the working tree as it stands, untracked files included. But CI judges the COMMIT, and the gates do not all read the same tree locally: the surface detector reads the working tree, while the pair gate's project listing compares the merge base with HEAD. So any uncommitted change makes the verdict INCOMPLETE (exit 2, the Committed state line names the changes) and never GREEN. Commit, then run it again.

Check Gate it invokes Network
Pairs-with check-type-forwards.py --surface-json, then check-cross-repo-pair.py resolves a declared counterpart's state
Satellite-pins check-package-pin-removal.py none
Implementers check-interface-addition.py on the same surface report none
Mirror-sync check-i18n-mirror-sync.py none
Parser-delta check-parser-delta.py (the record-signatures job's control) none
Closing keywords and Verified-closing: check-closing-keywords.py --repo <this repo> reads each closed issue's labels
Db-migration the repository's own scripts/check-db-migration-declaration.py, where it exists (MeshWeaver.Plugins) none
Source-guard scanned roots the preflight's own scan (below) none
Platform mount MeshWeaver.Plugins' scripts/platform-mount.py --self-test, and --audit-core when --platform-core names the checkout to audit none

In core the gate scripts are the checkout's own copies, because core's CI runs them at the head under test. In a satellite the core gates do not apply, except the closing-keyword gate in the protected-core shape node-repo-validate.yml runs (--protect-public-repo Systemorph/MeshWeaver --only-protected-repos). That gate comes from --core-root, or from the directory the preflight lives in. Failing both, it is fetched at --core-ref (default main) over anonymous git, the transport of .github/scripts/mw-core-file.sh. A satellite lane can therefore run it the way it runs every central script: fetch scripts/preflight-pr.py with mw-core-file, then python3 preflight-pr.py --repo-root . --core-ref <the lane's scripts ref> --body-file ….

Offline

--offline runs only the static half. For the two gates that need GitHub, the preflight calls the gate's own evaluation function (evaluate in check-cross-repo-pair.py and in check-closing-keywords.py) with a resolver that asks nothing:

Without a token the online mode does the same, and says it had no token.

Source guards whose roots a deletion empties

Many guards under test/ scan a fixed list of directories. NoLiteralNulInSourceGuard fails red on a root that matches nothing. Most others go through SourceScan.SourceFiles, which drops an absent root silently, so a guard whose root moved goes on passing having scanned nothing. That is worse than red.

The preflight reads those lists from the test sources themselves: every …Roots field, property or method initializer (ScannedRoots, ProductionRoots, HomeRoots, …), and every inline root list passed to SourceScan.SourceFiles(root, [...]) whose root is the repository (RepoPaths.Root, a local assigned from it, or a helper's string root parameter in a file that passes it). A scanner self-test over a planted temp tree (SourceScan.SourceFiles(dir.FullName, [...])) names the fixture, not the repository, and is skipped. A list entry naming a const string in the same file is resolved, and a root list handed to a local helper that calls SourceScan.SourceFiles counts as SourceScan-backed. Anything the scan cannot resolve statically (an entry built by an expression, a list handed to a method the file does not define) is never dropped: when the diff deletes or renames anything, it makes the verdict INCOMPLETE and is named. Comments are blanked first. For every path the diff deletes or renames (git diff --name-status -M against the merge base), it reports each root that path touches and that is now missing or empty, with the guard's file:line. A root read through SourceScan.SourceFiles is empty once no .cs/.razor/.csx outside bin/obj/… is left under it, whatever else is there, because that is all SourceScan reads. Any other root is empty when no file is left. The fix belongs in the same change: point the root at the new home of a moved directory, or drop the root of a retired one.

Not yet: re-judging an edited body automatically

A PR-body edit still needs a manual re-run of the failed body-gate jobs. The MeshWeaver Build and Test workflow does not fire on edited, and its body gates (every job that needs: [pr-body], plus the parser-delta control in record-signatures) judge the body their run read. Fix the body, run the preflight on it, then re-run the failed jobs. The automatic re-run is a follow-up.

Design notes for the follow-up

An event-driven lane plus a consolidation-time re-check was built and taken out again, after five review rounds each found a new interleaving. A follow-up design has to answer every one of these before it is built:

Not yet: initializer shapes the root scan does not model

The guard-root scan reads source text, not a C# syntax tree. It resolves collection expressions, new[] { … } and same-file const string entries, and every guard root on main today. An initializer it does not model, such as ImmutableArray.Create("src/B", Project), is recorded only through its string literals, so a const-backed entry in it is missed rather than reported as unresolved. The safe rule is the one review suggested: treat every initializer that is not a collection expression or new[] { … } as unresolved, even when it contains literals. Apply it when a real guard first uses such a shape. The guard's own test in CI remains the authority either way.

Proof

Related: Closing Keywords and Issue State · The Cross-Repo Pair Gate · Reading CI Signals · Planning a Database Migration · CI REST Budget.