ship finish: a Godot UID sidecar added after review binds with no reader; base-declared test_code globs (GST-1604)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-06 10:45:58 +00:00
1 parent 05114c8049
commit 336dda282e
10 files changed
+366 -21

No files matched your search

+1
View File
@@ -0,0 +1 @@
- **/ship's finish routes a Godot 4 UID sidecar added after review with no reader, and a base policy can declare its own test files under `test_code`.** founderstar#611 (FOUN-32) committed `tests/cutscene_check.gd.uid`, the one-line `uid://…` file Godot writes beside a script, after the owner's granted fix-check, for a test script the PR added before review. No route took it, so a random id cost another fix-check or a fresh /ship. finish now proves each `.uid` that would route as behavior. The branch must add it after review, its sibling script is one the reviewed range added or modified or the review-loop exception accepted, it is a regular non-executable file whose whole content is one canonical `uid://<id>` line exactly as Godot 4 writes it (base 34 over a–y and 0–8, at most 2^63−1, optional trailing newline), and neither file matches the base's `review_exclude_paths`. A proven sidecar prints `UID_SIDECAR:` and counts as `sidecar=<n>` on `ROUTE:`. Any other `.uid` keeps its old route with a `ROUTE_SIDECAR_REFUSED:` reason, and `.import` files are never candidates. The receipt lists each sidecar as a `Godot UID sidecar: …` line, and receipt write refuses a PR body missing one. GDScript checks (`tests/*_check.gd`) match none of the built-in test globs, so the new base-only `test_code` policy key adds globs that finish routes as test code (one scoped reader) instead of behavior; a head-only declaration changes nothing. (GST-1604)
+61
View File
@@ -70,6 +70,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
| `release_gate` | `{ cadence_hours, gate_workflow, branch, stable_ref, check_name, on_red, bisect_spend_cap_usd }` or `null` | `null` | One full paid gate on main every `cadence_hours` (8 to 168) instead of the per-push main backstop, feeding the stable release; see [release_gate](#release-gate). Every field is required: `on_red` is `hold-release`, `bisect_spend_cap_usd` 0 to 500 (0 skips the bisect). `null` keeps the per-push backstop. Needs `min_gstack_version`. |
| `bookkeeping` | glob list | `[]` | <a id="bookkeeping"></a>Root-anchored globs of bookkeeping tables a verify or ratchet step rewrites (size baselines, serial lists, limits), each ending in `.tsv`, `.csv` or `.txt` and never under `.gstack/` or `.github/` (`["scripts/*-baseline.tsv"]`). A matching file changed at most 20 lines after review binds, first touched or not, takes finish's [one scoped reader](review-policy.md#bounded-post-review-edits) instead of Steps 5–11.5; the reader still reads every changed row. Read from the base revision only. Needs `min_gstack_version`. |
| `mechanical_retake` | `[{ path, template, fields, regen }]` | `[]` | A version or rules constant a branch may have to retake after a base merge, next to `bookkeeping`'s bounded kind: `path` is one literal file, `template` the whole line with one `{n}` (`const RULE_NUMBERS := {n}`), `fields` the `{ glob, field }` fixture fields that carry the same value, and `regen` the [`.gstack/generated.json`](pregate.md#generated) entry ids whose command regenerates its goldens. A post-review edit finish proves to be only that retake routes as `mechanical=`, with no reader and no review round; see [mechanical retakes](#mechanical-retake). At most 8 constants and 16 fields or ids each; no glob, `..`, `.gstack/` or `.github/` path. Read from the base revision only. Needs `min_gstack_version`. |
| `test_code` | glob list | `[]` | Root-anchored globs of test code the built-in test names miss, never under `.gstack/` or `.github/` (`["tests/**/*.gd"]` for GDScript checks). A matching file changed after review routes as test code, one scoped reader of its test hunks, instead of behavior, beside the built-in globs; shared `helpers/` and `fixtures/` files stay behavior. See [test code and Godot UID sidecars](#test-code). Read from the base revision only. Needs `min_gstack_version`. |
| `base_red` | `{ setup, select, timeout_sec }` or `null` | `null` | How `/ship` Step 5 proves a failure is the base branch's own before pausing on it; see [base_red](#base-red). Read from the base revision only. Needs `min_gstack_version`. |
| `changelog_fragments` | `{ dir, required_checks? }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version (under `merge: direct`, the [release PR](#release-stamp) does). `required_checks` (up to 50 distinct names) are the checks [`train land`](#stamped-trains-under-changelog_fragments-train-land) requires present and green on a stamp commit. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. |
@@ -1812,6 +1813,66 @@ the lean panel). At the cap it stops for the owner, whose recorded grant
(`--grant`) adds one scoped fix-check, never `done`. So `finish` never asks for
a step the planner refuses.
<a id="test-code"></a>
<a id="uid-sidecar"></a>
## `test_code` and Godot UID sidecars after review
founderstar#611 (GST-1604) hit two gaps after the owner's granted fix-check.
CI forced one more `scripts/ui/cutscene.gd` edit, which the owner accepted
under the review-loop exception. Lint then wanted
`tests/cutscene_check.gd.uid`, the UID sidecar of a test script the PR added
before review. `finish` routed the sidecar as behavior, so a one-line random
id cost another fix-check or a fresh /ship. GDScript tests also match none of
the built-in test globs, so an edit to `tests/*_check.gd` after review routed
as behavior too.
**`test_code`.** A base policy can declare its own test files:
```json
"test_code": ["tests/**/*.gd"]
```
`finish` then routes a matching file changed after review like a built-in
test file. It takes the scoped reader of its test hunks, and a removed or
gate-tagged line still routes as behavior. The globs are read from the base
only, so a branch that declares them changes nothing until the declaration
lands.
**Godot UID sidecars.** Godot 4.4 and later write `<file>.uid` beside each
script or shader they give a UID. The file is one line,
`uid://<id>`, where `<id>` is a random non-negative 63-bit number in base 34
over `a`–`y` and `0`–`8` (Godot's alphabet skips `z` and `9`, GH-83843),
followed by a newline. A reader has nothing to read in it, and no generator
reproduces it. So `finish`, with `--reviewed-base`, proves each `.uid` that
would route as behavior and routes it as `sidecar=<n>` on `ROUTE:`, with no
reader, when all of these hold:
- the branch adds it after review: the path is absent at the reviewed tree
and at the current base, since a changed UID re-identifies the script for
every `uid://` reference to it;
- its sibling (the path without `.uid`) is a regular file now, and one the
reviewed range added or modified (present at the reviewed tree), or one the
review-loop exception (`finish --review-loop-exception`) accepted; a sibling that is declared `test_code` follows the same rule;
- it is a regular, non-executable file (mode `100644`, never a symlink) whose
whole content is `uid://<id>` with an optional trailing `\n`: the id
canonical (no leading `a` digit unless it is `a`), at most 13 digits and at
most 2^63−1 (`d4n4ub6itg400`);
- neither it nor its sibling matches the base policy's
[`review_exclude_paths`](#review-exclude-paths), since no reader saw an
excluded script.
Each proven sidecar prints `UID_SIDECAR: <path> (beside <sibling>)`. Any other
`.uid` keeps its old route, usually behavior, and `finish` prints
`ROUTE_SIDECAR_REFUSED: <path> <reason>`
([troubleshooting](troubleshooting.md#route-sidecar-refused)). A `.import` file
is never a candidate: its `[remap]` and `[params]` sections vary by importer,
so nothing pins its content as tightly. A `.uid` a landed stack parent ships
isn't a candidate either. `finish` records the proven sidecars with the
mechanical retakes, and `gstack-ship-receipt write` copies them into the
receipt's `mechanical` list as `Godot UID sidecar: <path> (beside <sibling>)`.
With `--body` it refuses a PR body that doesn't carry each of those lines
word for word.
## Carrying a receipt across a base merge or a test-only fix
**Merge main into a reviewed branch; never rebase it.** What happens to the
+19
View File
@@ -3197,6 +3197,25 @@ files (or ship it through Steps 5–11.5), write the constant's new value into
every changed field, and rerun the regen entry's command and commit its output.
A behavior STOP that remains is planned with `gstack-review-plan --post-review`.
<a id="route-sidecar-refused"></a>
### `ROUTE_SIDECAR_REFUSED: <path> <reason>`
**Meaning.** `gstack-ship-release finish` found a `.uid` file changed after
review that failed a Godot UID sidecar proof, so it keeps its old route,
usually behavior ([ship-policy.md#uid-sidecar](ship-policy.md#uid-sidecar)).
The reason names the failed proof: `needs --reviewed-base`, `could not be
compared with the reviewed range`, `has no sibling <path> that the reviewed
range added or modified or the review-loop exception accepted`, `has no
sibling <path> at the current tree`, `existed at the reviewed tree or the
current base`, `is not a regular non-executable file (mode <mode>)`, `is not
exactly one Godot uid://<id> line`, or `or its sibling matches the base
policy's review_exclude_paths`.
**Fix.** Commit the sidecar exactly as Godot wrote it, beside a script this PR
added or changed: one `uid://<id>` line, a regular file, no symlink. Keep a
script's existing UID instead of regenerating it. Otherwise take Steps 5–11.5
for the file.
<a id="policy-source-unavailable"></a>
### `policy source unavailable — fix: git fetch origin <base> --depth=1 (POLICY_SOURCE_UNAVAILABLE)`
+7 -5
View File
@@ -20,6 +20,7 @@ import { isRegistryUnavailable, loadRegistryFromBase, runRegen } from './regen';
import { comparePath, type PatchSides } from './ship-patch';
import { matchesAny } from './ship-policy';
import { constantValue, fieldValue, VALUE_SLOT, type MechanicalConstant } from './ship-policy-mechanical';
import { sidecarLine } from './ship-sidecar';
export interface MechanicalRefusal { path: string; reason: string }
export interface MechanicalInput { sides?: PatchSides; base: string; candidates: readonly string[]; declared: readonly MechanicalConstant[] }
@@ -137,24 +138,25 @@ function recordFile(cwd: string, tree: string): string {
return path.resolve(cwd, rel || `.git/gstack/mechanical-${tree.slice(0, 12)}.json`);
}
/** Saves finish's mechanical paths for the tree it routed, for gstack-ship-receipt write; the error message when it cannot. */
export function writeMechanicalRecord(cwd: string, tree: string, proven: Readonly<Record<string, string>>): string | null {
/** Saves finish's mechanical paths and Godot UID sidecars (path -> sibling) for the tree it routed, for gstack-ship-receipt write; the error message when it cannot. */
export function writeMechanicalRecord(cwd: string, tree: string, proven: Readonly<Record<string, string>>, sidecars: Readonly<Record<string, string>> = {}): string | null {
const file = recordFile(cwd, tree);
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ tree, paths: proven }) + '\n');
fs.writeFileSync(file, JSON.stringify({ tree, paths: proven, sidecars }) + '\n');
return null;
} catch (e) {
return `${file}: ${(e as Error).message.split('\n')[0]}`;
}
}
/** The mechanical lines finish recorded for exactly this tree; none when it recorded none. */
/** The mechanical and sidecar lines finish recorded for exactly this tree, which the receipt's `mechanical` lists; none when it recorded none. */
export function readMechanicalLines(cwd: string, tree: string): string[] {
try {
const v = JSON.parse(fs.readFileSync(recordFile(cwd, tree), 'utf8'));
if (v?.tree !== tree || !v.paths || typeof v.paths !== 'object') return [];
return Object.entries(v.paths).filter(([, w]) => typeof w === 'string').map(([p, w]) => mechanicalLine(p, w as string)).sort();
const lines = (o: unknown, line: (p: string, w: string) => string) => Object.entries(o && typeof o === 'object' ? o : {}).filter(([, w]) => typeof w === 'string').map(([p, w]) => line(p, w as string));
return [...lines(v.paths, mechanicalLine), ...lines(v.sidecars, sidecarLine)].sort();
} catch {
return [];
}
+6 -3
View File
@@ -222,10 +222,13 @@ export function packetBrief(): string {
/**
* A test code file outside shared helpers and fixtures: `*.test.*`, `*.spec.*` and `*_test.go` anywhere, and the
* name-only conventions (`test_*.py`, `*Test.java`, `*Tests.swift`, …) only under a test directory, since product
* files can carry those names (`src/main/java/ABTest.java`). A change to one gets the scoped review, not the full loop.
* files can carry those names (`src/main/java/ABTest.java`). A path the base policy's `test_code` globs declare counts too
* (GDScript's `tests/*_check.gd`, GST-1604). A change to one gets the scoped review, not the full loop.
*/
export function isTestCode(rel: string): boolean {
if (!matchesAny(CARRY_TEST_CODE_GLOBS, rel) || /(^|\/)(helpers|fixtures|__fixtures__|__snapshots__)\//.test(rel)) return false;
export function isTestCode(rel: string, declared: readonly string[] = []): boolean {
if (/(^|\/)(helpers|fixtures|__fixtures__|__snapshots__)\//.test(rel)) return false;
if (matchesAny(declared, rel)) return true;
if (!matchesAny(CARRY_TEST_CODE_GLOBS, rel)) return false;
return /\.(test|spec)\.[^/]+$|_test\.go$/.test(rel) || /(^|\/)(test|tests|spec|__tests__|src\/test|[^/]+Tests)\//.test(rel);
}
+2 -1
View File
@@ -47,6 +47,7 @@ export const LAND_KEY_VERSION = '1.96.0.0';
export const READERS_KEY_VERSION = '1.96.0.0';
export const BOOKKEEPING_KEY_VERSION = '1.96.0.0';
export const MECHANICAL_RETAKE_KEY_VERSION = '1.96.1.0';
export const TEST_CODE_KEY_VERSION = '1.96.1.0';
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
@@ -56,5 +57,5 @@ export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION,
base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, review: REVIEW_POLICY_KEY_VERSION, release_gate: RELEASE_GATE_KEY_VERSION,
stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION, merge: MERGE_KEY_VERSION, land: LAND_KEY_VERSION, readers: READERS_KEY_VERSION,
bookkeeping: BOOKKEEPING_KEY_VERSION, mechanical_retake: MECHANICAL_RETAKE_KEY_VERSION,
bookkeeping: BOOKKEEPING_KEY_VERSION, mechanical_retake: MECHANICAL_RETAKE_KEY_VERSION, test_code: TEST_CODE_KEY_VERSION,
};
+4 -1
View File
@@ -97,6 +97,7 @@ export interface ShipPolicy {
release_gate: ReleaseGatePolicy | null;
/** Bookkeeping tables (`.tsv`, `.csv`, `.txt` globs) whose small post-review edits take /ship finish's scoped reader (docs/ship-policy.md#bookkeeping); read from the base only. */ bookkeeping: string[];
/** Constants a branch may retake after a base merge, with their fixture fields and golden regen ids (docs/ship-policy.md#mechanical-retake); read from the base only. */ mechanical_retake: MechanicalConstant[];
/** Globs of test code the built-in test names miss (GDScript checks), which /ship finish routes as test code (docs/ship-policy.md#test-code); read from the base only. */ test_code: string[];
/** `direct` lands each READY PR at its own green head (`land --pin`, docs/ship-policy.md#land-direct); `train` keeps merge trains. */ merge: 'direct' | 'train';
/** What `land --pin` runs on the pin merged onto a base that moved past its merge base (lib/land-merged-checks.ts); null lands a stale base untested with a STALE_BASE: warn line. */ land: LandPolicy | null;
}
@@ -189,7 +190,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
min_gstack_version: null,
train: null,
docs: null,
base_red: null, merge: 'train', land: null, bookkeeping: [], mechanical_retake: [],
base_red: null, merge: 'train', land: null, bookkeeping: [], mechanical_retake: [], test_code: [],
release_gate: null, readers: { forbid_fetch: [], forbid_paths: [], brief_note: null },
};
@@ -229,6 +230,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
land: LAND_EXPLANATION,
bookkeeping: 'Root-anchored globs of bookkeeping tables, each ending in .tsv, .csv or .txt and never under .gstack/ or .github/, for example ["scripts/*-baseline.tsv"]. An edit of at most 20 lines to a matching file after review binds, first touched or not, takes /ship finish\'s scoped reader instead of Steps 5–11.5; the reader still reads every changed row. Read from the base branch only. Empty (default) declares none.',
mechanical_retake: MECHANICAL_EXPLANATION,
test_code: 'Root-anchored globs of test code the built-in test names miss, never under .gstack/ or .github/, for example ["tests/**/*.gd"] for GDScript checks. /ship finish routes a matching file changed after review as test code (one scoped reader of its test hunks) instead of behavior, beside the built-in globs; shared helpers and fixtures stay behavior. Read from the base branch only. Empty (default) declares none.',
merge: 'train (default) | direct. direct: the Captain lands each READY PR at its own green head with `gstack-ship-queue land --pr <n> --pin <sha>` (no train, no stamp commit; the PR leaves VERSION, the manifest version and the CHANGELOG at the base and the release stamps them), and train build refuses. train: merge trains as before (docs/ship-policy.md#land-direct).',
min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).',
};
@@ -470,6 +472,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
if ('stamp_placeholders' in obj) placeholderErrors(obj.stamp_placeholders, errors);
if ('bookkeeping' in obj) listErrors('bookkeeping', obj.bookkeeping, errors, g => globError(g) ?? bookkeepingGlobError(g));
if ('mechanical_retake' in obj) mechanicalErrors(obj.mechanical_retake, errors, globError);
if ('test_code' in obj) listErrors('test_code', obj.test_code, errors, g => globError(g) ?? (/^\.(gstack|github)\//.test(g) ? 'must not name a path under .gstack/ or .github/' : null));
if ('restamp_command' in obj && obj.restamp_command !== null) {
const t = obj.restamp_command;
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'restamp_command', message: 'must be a non-empty string or null' });
+18 -11
View File
@@ -19,6 +19,7 @@ import { baseHolds, boundedPaths, comparePath, contextPacket, gateEditedPaths, i
import { heldBy } from './ship-receipt';
import { locate, readRecord } from './approval-record';
import { mechanicalPaths, writeMechanicalRecord } from './ship-mechanical';
import { uidSidecars } from './ship-sidecar';
import type { MechanicalConstant } from './ship-policy-mechanical';
import { reviewFreshness } from './review-evidence';
import { readRuledNotes, ruledNoteText } from './ruled-notes';
@@ -206,9 +207,9 @@ function harnessRepin(cwd: string, from: string, to: string, p: string): boolean
* prints as RULED_NOTE (GST-1537). Authored docs keep the stage 2.2 recheck.
*/
type FixCheckException = ReviewLoopException & { fix_check: NonNullable<ReviewLoopException['fix_check']> };
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'bounded' | 'mechanical' | 'accepted' | 'fix_checked', string[]>;
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'bounded' | 'mechanical' | 'accepted' | 'fix_checked' | 'sidecar', string[]>;
type ReviewedBase = { commit: string; parent: boolean };
type RouteOpts = { docsRechecked: boolean; contextReviewed?: string; loop?: FixCheckException; audited: Record<string, string | null> | null; bookkeeping: readonly string[]; mechanical: readonly MechanicalConstant[]; baseRelease?: string[] };
type RouteOpts = { docsRechecked: boolean; contextReviewed?: string; loop?: FixCheckException; audited: Record<string, string | null> | null; bookkeeping: readonly string[]; mechanical: readonly MechanicalConstant[]; testCode: readonly string[]; excluded: readonly string[]; baseRelease?: string[] };
/**
* The --reviewed-base commit: one on the base, or a landed stack parent (GST-1399). A child reviewed against its parent
@@ -260,12 +261,12 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: Review
touched = new Set([...a, ...b]);
shipped = b;
}
const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], bounded: [], mechanical: [], accepted: [], fix_checked: [] };
const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], bounded: [], mechanical: [], accepted: [], fix_checked: [], sidecar: [] };
const onlyShipped = new Set(parent ? [...shipped].filter(p => !changedSet.has(p)) : []);
for (const p of [...changedSet, ...onlyShipped].sort()) {
const verdict = sides ? (!touched.has(p) || (parent && !shipped.has(p) && baseHolds(cwd, sides, p)) ? 'same' : comparePath(cwd, sides, p)) : 'changed';
const metadata = parent && shipped.has(p) ? opts.baseRelease!.includes(p) || harnessRepin(cwd, sides!.currentBase, now, p) : release.includes(p) || harnessRepin(cwd, reviewed, now, p);
by[verdict === 'same' ? 'base' : metadata ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' && !matchesAny(opts.bookkeeping, p) ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p);
by[verdict === 'same' ? 'base' : metadata ? 'metadata' : sides && isTestCode(p, opts.testCode) ? 'tests' : classifyPath(p, cwd) === 'docs' && !matchesAny(opts.bookkeeping, p) ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p);
}
const shippedTests = by.tests.filter(p => onlyShipped.has(p));
const gated = new Set([...(gateEditedPaths(cwd, reviewed, now, by.tests.filter(p => !onlyShipped.has(p))) ?? by.tests), ...(gateEditedPaths(cwd, sides?.currentBase ?? reviewed, now, shippedTests) ?? shippedTests)]);
@@ -323,20 +324,25 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: Review
const atHead = own && fromParent ? new Set([...own].filter(p => fromParent.has(p))) : null;
if (!since || !atHead) { r.stop(`git could not list the paths changed since fix-check ${head.slice(0, 12)} or the paths the branch touched at it against ${baseRef}: stage 2 is blocked`); return null; }
const listed = loop.fix_check.files;
const unscanned = [...by.behavior, ...by.docs].filter(isTestCode);
const unscanned = [...by.behavior, ...by.docs].filter(p => isTestCode(p, opts.testCode));
const forced = new Set([...gated, ...(packetBody?.over ?? []), ...(gateEditedPaths(cwd, reviewed, now, unscanned) ?? unscanned)]);
by.accepted = [...by.behavior, ...by.docs].filter(p => since.has(p) && atHead.has(p) && (!listed || listed.includes(p)) && !forced.has(p)).sort();
by.behavior = by.behavior.filter(p => !by.accepted.includes(p));
by.docs = by.docs.filter(p => !by.accepted.includes(p));
}
r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}${by.bounded.length ? ` bounded=${by.bounded.length}` : ''}${by.mechanical.length ? ` mechanical=${by.mechanical.length}` : ''}${counted ? ` fix_checked=${by.fix_checked.length}` : ''}${loop ? ` accepted=${by.accepted.length}` : ''}`);
const uid = uidSidecars(cwd, { sides, candidates: parent ? [] : by.behavior, accepted: by.accepted, excluded: opts.excluded });
by.sidecar = Object.keys(uid.proven).sort();
by.behavior = by.behavior.filter(p => !by.sidecar.includes(p));
r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}${by.bounded.length ? ` bounded=${by.bounded.length}` : ''}${by.mechanical.length ? ` mechanical=${by.mechanical.length}` : ''}${counted ? ` fix_checked=${by.fix_checked.length}` : ''}${loop ? ` accepted=${by.accepted.length}` : ''}${by.sidecar.length ? ` sidecar=${by.sidecar.length}` : ''}`);
if (loop) r.add(`REVIEW_LOOP_EXCEPTION: accepted ${by.accepted.length ? short(by.accepted) : 'none'} (changed since fix-check ${loop.fix_check.head.slice(0, 12)}; accepted by ${loop.accepted_by} ${loop.date} via ${loop.via})`);
for (const x of bounded.refused) if (by.behavior.includes(x.path)) r.add(`ROUTE_BOUNDED_REFUSED: ${x.path} ${x.reason} (fix: ${x.fix}; docs/troubleshooting.md#route-bounded-refused)`);
for (const p of by.mechanical) r.add(`MECHANICAL: ${p} (${mech.proven[p]})`);
for (const x of mech.refused) r.add(`ROUTE_MECHANICAL_REFUSED: ${x.path} ${x.reason} (docs/troubleshooting.md#route-mechanical-refused)`);
for (const p of by.sidecar) r.add(`UID_SIDECAR: ${p} (beside ${uid.proven[p]})`);
for (const x of uid.refused) r.add(`ROUTE_SIDECAR_REFUSED: ${x.path} ${x.reason} (docs/troubleshooting.md#route-sidecar-refused)`);
if (by.behavior.length) { r.stop(`behavior, tests or build inputs changed since review (${short(by.behavior.sort())}): insert 5–11.5 → 12–14 → 16 before Step 17; plan that Step 9 with gstack-review-plan --post-review, which at the round cap stops for the owner's grant of one scoped fix-check (stage 2.1)`); return null; }
const kept = by.mechanical.length ? writeMechanicalRecord(cwd, now, mech.proven) : null;
if (kept) { r.stop(`the mechanical paths cannot be recorded for gstack-ship-receipt write (${kept}): make that directory writable, then rerun finish (stage 2.1)`); return null; }
const kept = by.mechanical.length || by.sidecar.length ? writeMechanicalRecord(cwd, now, mech.proven, uid.proven) : null;
if (kept) { r.stop(`the mechanical and sidecar paths cannot be recorded for gstack-ship-receipt write (${kept}): make that directory writable, then rerun finish (stage 2.1)`); return null; }
const scoped = [...by.context, ...by.tests, ...by.bounded].sort();
if (scoped.length && sides) {
const packet = path.resolve(cwd, git(cwd, ['rev-parse', '--git-path', `gstack/scoped-review-${now.slice(0, 12)}.md`]).stdout.trim() || `.git/gstack/scoped-review-${now.slice(0, 12)}.md`);
@@ -422,7 +428,7 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
const merged = mergeChangelogs(audited.stdout, atNowBase.stdout, atAuditBase.stdout);
return kept !== null && merged !== null && normalizeChangelog(kept) === normalizeChangelog(audited.stdout) && normalizeChangelog(merged) === normalizeChangelog(now);
});
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p))
const carried = { tests: stale.filter(p => isTestCode(p)), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p))
|| (!!auditBase && !!nowBase && /^[0-9a-f]{40,64}$/.test(nowTree) && ![...record.staged, ...record.unstaged, ...record.untracked].includes(p) && comparePath(cwd, { reviewedBase: auditBase, reviewed: record.head, currentBase: nowBase, current: nowTree }, p) === 'same'))), requested: [] as string[], accepted: [] as string[] };
carried.accepted = stale.filter(p => loopAccepted?.includes(p) && !carried.tests.includes(p) && !carried.base.includes(p));
let audit: any = null;
@@ -487,14 +493,15 @@ async function finishStages(argv: readonly string[], cwd: string): Promise<Repor
const release = releasePaths(policy, cwd, flag('--reviewed-tree')!, base);
let audited: Record<string, string | null> | null = null;
try { const h = JSON.parse(fs.readFileSync(flag('--docs-post')!, 'utf8')).hashes; audited = h && typeof h === 'object' && !Array.isArray(h) ? h : null; } catch { audited = null; }
const bookkeeping = Array.isArray(policy?.bookkeeping) ? policy!.bookkeeping.filter((g: unknown): g is string => typeof g === 'string') : [];
const globs = (key: string): string[] => Array.isArray(policy?.[key]) ? policy![key].filter((g: unknown): g is string => typeof g === 'string') : [];
const bookkeeping = globs('bookkeeping');
const mechanical: MechanicalConstant[] = Array.isArray(policy?.mechanical_retake) ? policy!.mechanical_retake : [];
const reviewedBaseArg = flag('--reviewed-base');
const fragments = typeof policy?.changelog_fragments?.dir === 'string' ? [`:(top,exclude,glob)${policy.changelog_fragments.dir.replace(/\/+$/, '')}/*.md`] : [];
const reviewedBase = reviewedBaseArg === undefined ? undefined : reviewedBaseCommit(cwd, `origin/${base.replace(/^origin\//, '')}`, reviewedBaseArg, [...releasePaths(policy, cwd, flag('--reviewed-tree')!, base, true).map(p => `:(top,exclude,literal)${p}`), ...fragments], r);
if (reviewedBase === null) return r;
const baseRelease = reviewedBase?.parent ? releasePaths(policy, cwd, git(cwd, ['merge-base', `origin/${base.replace(/^origin\//, '')}`, 'HEAD']).stdout.trim(), base, true) : undefined;
const routed = route(cwd, base, flag('--reviewed-tree')!, reviewedBase, release, { docsRechecked: has('--docs-rechecked'), contextReviewed: flag('--context-reviewed'), loop, audited, bookkeeping, mechanical, baseRelease }, r);
const routed = route(cwd, base, flag('--reviewed-tree')!, reviewedBase, release, { docsRechecked: has('--docs-rechecked'), contextReviewed: flag('--context-reviewed'), loop, audited, bookkeeping, mechanical, testCode: globs('test_code'), excluded: globs('review_exclude_paths'), baseRelease }, r);
if (!routed) return r;
if (!docsFresh(cwd, base, flag('--docs-post')!, [...routed.base, ...routed.context, ...routed.mechanical, ...stampedFiles(policy, cwd, flag('--reviewed-tree')!, base)], all('--docs-requested'), flag('--docs-result'), loop ? routed.accepted : undefined, reviewedBase?.parent ? reviewedBase.commit : undefined, r)) return r;
if (!list.length && !has('--no-lanes-approved')) return r.stop('no test lanes: require Step 5\'s explicit untested-scope approval (rerun with --no-lanes-approved) or run Steps 5-15');
+85
View File
@@ -0,0 +1,85 @@
/**
* Godot 4 UID sidecars after review (GST-1604, founderstar#611). Godot 4.4+ writes `<file>.uid` beside each script or shader
* it gives a UID: one line, `ResourceUID::id_to_text` of a random non-negative 63-bit id (base 34 over a-y and 0-8, since
* Godot's alphabet constants are off by one, GH-83843), written with `FileAccess::store_line`, so a trailing "\n"
* (core/io/resource_uid.cpp, editor/editor_file_system.cpp). Lint then wants the sidecar committed. When it lands after
* review, nothing in it can be reviewed: the id is random, so no regeneration reproduces it either. /ship finish routes such
* a path as a sidecar, with no reader, only when every proof holds:
* - the branch adds it after review: no such path at the reviewed tree or the current base (a changed UID re-identifies
* the script for every uid:// reference to it);
* - its sibling (the path without `.uid`) is a regular file now, and one the reviewed range added or modified, or one
* the review-loop exception accepted;
* - it is a regular, non-executable file whose whole content is exactly what Godot writes for one id;
* - neither it nor its sibling matches the base policy's review_exclude_paths (a sibling no reader saw is not reviewed).
* Any other `.uid` is refused with the reason and stays behavior; a `.import` file is never a candidate.
*/
import { spawnSync } from 'node:child_process';
import { touchedPaths, type PatchSides } from './ship-patch';
export interface SidecarInput {
sides?: PatchSides;
/** Paths finish still routes as behavior; only those ending in `.uid` are candidates. */
candidates: readonly string[];
/** Paths the review-loop exception accepted. */
accepted: readonly string[];
/** The base policy's review_exclude_paths (git glob pathspecs). */
excluded: readonly string[];
}
export interface SidecarRefusal { path: string; reason: string }
const ALPHABET = 'abcdefghijklmnopqrstuvwxy012345678';
const MAX_ID = (1n << 63n) - 1n;
/** Whether `text` is byte for byte a file Godot 4 writes for one UID: `uid://`, the canonical base-34 id, an optional "\n". */
export function isGodotUidText(text: string): boolean {
const digits = /^uid:\/\/([a-y0-8]{1,13})\n?$/.exec(text)?.[1];
if (!digits || (digits.length > 1 && digits[0] === 'a')) return false;
let id = 0n;
for (const c of digits) id = id * 34n + BigInt(ALPHABET.indexOf(c));
return id <= MAX_ID;
}
/** The PR body and receipt line for one sidecar. */
export const sidecarLine = (p: string, sibling: string) => `Godot UID sidecar: ${p} (beside ${sibling})`;
function git(cwd: string, args: string[]): string | null {
const r = spawnSync('git', ['-c', 'core.quotepath=off', ...args], { cwd, encoding: 'latin1', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
return r.status === 0 ? r.stdout : null;
}
/** The mode and blob of `rel` in `tree`; null when absent or unreadable. */
function entry(cwd: string, tree: string, rel: string): { mode: string; blob: string } | null {
const m = /^(\d{6}) \w+ ([0-9a-f]{40,64})\t/.exec(git(cwd, ['--literal-pathspecs', 'ls-tree', '-z', tree, '--', rel]) ?? '');
return m ? { mode: m[1]!, blob: m[2]! } : null;
}
const regular = (e: { mode: string } | null) => e?.mode === '100644' || e?.mode === '100755';
/** The `.uid` candidates proven sidecars (path -> sibling), and each one refused with the reason. */
export function uidSidecars(cwd: string, i: SidecarInput): { proven: Record<string, string>; refused: SidecarRefusal[] } {
const proven: Record<string, string> = {};
const refused: SidecarRefusal[] = [];
const candidates = i.candidates.filter(p => p.endsWith('.uid'));
if (!candidates.length) return { proven, refused };
const s = i.sides;
if (!s) return { proven, refused: candidates.map(p => ({ path: p, reason: 'needs --reviewed-base' })) };
const range = touchedPaths(cwd, s.reviewedBase, s.reviewed);
const specs = i.excluded.map(g => `:(top,glob)${g}`);
const listed = specs.length ? [[s.reviewedBase, s.reviewed], [s.currentBase, s.current]].map(([a, b]) => git(cwd, ['diff', '-z', '--name-only', '--no-renames', a!, b!, '--', ...specs])) : [];
if (!range || listed.some(l => l === null)) return { proven, refused: candidates.map(p => ({ path: p, reason: 'could not be compared with the reviewed range' })) };
const excluded = new Set(listed.flatMap(l => l!.split('\0').filter(Boolean)));
for (const p of candidates) {
const refuse = (reason: string) => refused.push({ path: p, reason });
const sibling = p.slice(0, -'.uid'.length);
const reviewed = range.has(sibling) && regular(entry(cwd, s.reviewed, sibling));
if (!sibling || sibling.endsWith('/') || (!reviewed && !i.accepted.includes(sibling))) { refuse(`has no sibling ${sibling || '(none)'} that the reviewed range added or modified or the review-loop exception accepted`); continue; }
if (!regular(entry(cwd, s.current, sibling))) { refuse(`has no sibling ${sibling} at the current tree`); continue; }
if (entry(cwd, s.reviewed, p) || entry(cwd, s.currentBase, p)) { refuse('existed at the reviewed tree or the current base (a changed UID re-identifies the script)'); continue; }
const e = entry(cwd, s.current, p);
if (e?.mode !== '100644') { refuse(`is not a regular non-executable file (mode ${e?.mode ?? 'unreadable'})`); continue; }
const text = git(cwd, ['cat-file', 'blob', e.blob]);
if (text === null || !isGodotUidText(text)) { refuse('is not exactly one Godot uid://<id> line'); continue; }
if (excluded.has(p) || excluded.has(sibling)) { refuse('or its sibling matches the base policy\'s review_exclude_paths, so no reader saw the script'); continue; }
proven[p] = sibling;
}
return { proven, refused };
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Godot 4 UID sidecars after review (GST-1604, founderstar#611) and base-declared test code. founderstar#611's owner
* granted a fix-check, CI then forced one more scripts/ui/cutscene.gd edit (owner-accepted under the review-loop
* exception), and lint wanted tests/cutscene_check.gd.uid: the one-line UID sidecar of a test script the PR added before
* review, committed after the fix-check. No route took it, so a one-line random id cost another fix-check or a fresh /ship.
* A sidecar finish proves from the tree now routes as sidecar=, with no reader; every other .uid keeps its old route with a
* ROUTE_SIDECAR_REFUSED reason. GDScript tests (tests/*_check.gd) match no built-in test glob, so a base policy can declare
* them under test_code; a head-only declaration changes nothing.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { readMechanicalLines } from '../lib/ship-mechanical';
import { isTestCode } from '../lib/ship-patch';
import { REVIEW_EXCLUDE_KEY_VERSION, TEST_CODE_KEY_VERSION, validatePolicy } from '../lib/ship-policy';
import { isGodotUidText } from '../lib/ship-sidecar';
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
setDefaultTimeout(180_000);
const BIN = path.dirname(RELEASE);
const LANE = 'tests=printf " 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [5.00ms]\\n"';
const ships: ShipFx[] = [];
afterEach(() => { while (ships.length) ships.pop()!.cleanup(); });
const fx = (...a: Parameters<typeof shipFixture>) => { const f = shipFixture(...a); ships.push(f); return f; };
const wtree = (f: ShipFx) => f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim();
const head = (f: ShipFx) => f.git('rev-parse', 'HEAD').stdout.trim();
const snapshot = (f: ShipFx) => {
const out = path.join(f.root, 'post.json');
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', out, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
return out;
};
const exceptionFile = (f: ShipFx, fixHead: string) => {
const file = path.join(f.root, 'loop-exception.json');
fs.writeFileSync(file, JSON.stringify({ accepted_by: 'GStack Minder (rule 23 grant)', date: '2026-11-06', via: 'GSTA-36', reason: 'the CI fix after the granted fix-check, then ship', fix_check: { head: fixHead, findings: 1 } }));
return file;
};
const behaviorStop = (p: string) => `STOP: behavior, tests or build inputs changed since review (${p}): insert 5–11.5 → 12–14 → 16 before Step 17; plan that Step 9 with gstack-review-plan --post-review, which at the round cap stops for the owner's grant of one scoped fix-check (stage 2.1)`;
const UID = 'uid://cecaux1sm7mo\n';
const SIDECAR = 'tests/cutscene_check.gd.uid';
const CUTSCENE = (extra = '') => `extends Node\nfunc play(): return\n${extra}`;
const CHECK = 'extends SceneTree\nfunc _init():\n\tassert(true)\n\tquit()\n';
interface Shape { after?: (f: ShipFx) => void; base?: Record<string, string>; feat?: Record<string, string>; fixRange?: Record<string, string> }
/**
* founderstar#611: the PR edits scripts/ui/cutscene.gd and adds tests/cutscene_check.gd, and the review reads them; the
* granted fix-check reads them and `fixRange` at fixHead; then one commit edits cutscene.gd again and adds what `after`
* writes (the sidecar).
*/
function founderstar611({ after = f => f.write({ [SIDECAR]: UID }), base = {}, feat = {}, fixRange }: Shape = {}) {
const f = fx({ 'scripts/ui/cutscene.gd': 'extends Node\nfunc play(): pass\n', ...base }, { 'scripts/ui/cutscene.gd': CUTSCENE(), 'tests/cutscene_check.gd': CHECK, ...feat });
const reviewed = wtree(f);
const reviewedBase = f.git('merge-base', 'origin/main', 'HEAD').stdout.trim();
const post = snapshot(f);
if (fixRange) { f.write(fixRange); f.commit('a fix inside the granted fix-check'); }
const fixHead = head(f);
f.write({ 'scripts/ui/cutscene.gd': CUTSCENE('func skip(): pass\n') });
after(f);
f.commit('the CI fix after the fix-check, and the UID sidecar lint asks for');
const run = () => f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--reviewed-base', reviewedBase, '--docs-post', post, '--lane', LANE, '--review-loop-exception', `@${exceptionFile(f, fixHead)}`]);
return { f, run };
}
describe('isGodotUidText pins what Godot 4 writes for one UID', () => {
test('canonical ids with or without the trailing newline, from 0 to 2^63-1', () => {
for (const t of [UID, 'uid://cecaux1sm7mo', 'uid://a\n', 'uid://b\n', 'uid://d4n4ub6itg400\n']) expect([t, isGodotUidText(t)]).toEqual([t, true]);
});
test('anything else: past 2^63-1, z or 9, a leading zero digit, CRLF, a second line, spaces, an empty or invalid id', () => {
for (const t of ['uid://d4n4ub6itg401\n', 'uid://cecaux1sm7mz\n', 'uid://cecaux1sm7m9\n', 'uid://acecaux1sm7mo\n', 'uid://cecaux1sm7mo\r\n', UID + 'uid://b\n',
UID + '\n', ' ' + UID, 'uid://cecaux1sm7mo \n', 'UID://cecaux1sm7mo\n', 'uid://\n', 'uid:// \n', 'uid://<invalid>\n', '', 'uid://cecaux1sm7mobb\n']) expect([t, isGodotUidText(t)]).toEqual([t, false]);
});
});
describe('a Godot UID sidecar added after the fix-check (GST-1604: founderstar#611)', () => {
test('the #611 shape binds with no reader, and the receipt and the PR body list the sidecar', () => {
const { f, run } = founderstar611();
const r = run();
expect(lineOf(r.stdout, 'ROUTE'), r.stdout).toMatch(/ behavior=0 docs=0 metadata=0 tests=0 base=\d+ context=0 accepted=1 sidecar=1$/);
expect(lineOf(r.stdout, 'UID_SIDECAR')).toBe(`UID_SIDECAR: ${SIDECAR} (beside tests/cutscene_check.gd)`);
expect(r.stdout).not.toContain('scoped reader');
expect([r.status, lineOf(r.stdout, 'EVIDENCE')]).toEqual([0, expect.stringMatching(/^EVIDENCE: FRESH label=tests /)]);
const line = `Godot UID sidecar: ${SIDECAR} (beside tests/cutscene_check.gd)`;
expect(readMechanicalLines(f.work, f.git('rev-parse', 'HEAD^{tree}').stdout.trim())).toEqual([line]);
const body = path.join(f.root, 'body.md');
fs.writeFileSync(body, '## Summary\n\nCI fix.\n');
const unlisted = f.run(path.join(BIN, 'gstack-ship-receipt'), ['write', '--pr', '611', '--base', 'main', '--body', body]);
expect([unlisted.status === 0, unlisted.stderr]).toEqual([false, expect.stringContaining(`does not list ${JSON.stringify(line)}`)]);
fs.writeFileSync(body, `## Summary\n\nCI fix.\n\n${line}\n`);
expect(f.run(path.join(BIN, 'gstack-ship-receipt'), ['write', '--pr', '611', '--base', 'main', '--body', body, '--json']).stderr).not.toContain('Godot UID sidecar');
});
test('a sidecar beside a script only the review-loop exception accepted binds too', () => {
const { run } = founderstar611({ fixRange: { 'scripts/ui/skip.gd': 'extends Node\n' }, after: f => f.write({ 'scripts/ui/skip.gd': 'extends Node\nfunc skip(): pass\n', 'scripts/ui/skip.gd.uid': 'uid://b4w2kq0ly8s5n\n' }) });
const r = run();
expect(lineOf(r.stdout, 'ROUTE'), r.stdout).toMatch(/ behavior=0 .* accepted=2 sidecar=1$/);
expect(lineOf(r.stdout, 'UID_SIDECAR')).toBe('UID_SIDECAR: scripts/ui/skip.gd.uid (beside scripts/ui/skip.gd)');
expect(r.status).toBe(0);
});
test('a .uid with no reviewed sibling, a second line, a symlink, an executable or a changed UID refuses and stops as behavior', () => {
const cases: [string, string, (f: ShipFx) => void, Record<string, string>?, Record<string, string>?][] = [
['scripts/menu.gd.uid', 'has no sibling scripts/menu.gd that the reviewed range added or modified or the review-loop exception accepted', f => f.write({ 'scripts/menu.gd.uid': UID }), { 'scripts/menu.gd': 'extends Node\n' }],
['tests/ghost.gd.uid', 'has no sibling tests/ghost.gd', f => f.write({ 'tests/ghost.gd.uid': UID })],
[SIDECAR, 'is not exactly one Godot uid://<id> line', f => f.write({ [SIDECAR]: UID + 'uid://b\n' })],
[SIDECAR, 'is not a regular non-executable file (mode 120000)', f => fs.symlinkSync('cutscene_check.gd', path.join(f.work, SIDECAR))],
[SIDECAR, 'is not a regular non-executable file (mode 100755)', f => { f.write({ [SIDECAR]: UID }); fs.chmodSync(path.join(f.work, SIDECAR), 0o755); }],
['scripts/ui/cutscene.gd.uid', 'existed at the reviewed tree or the current base', f => f.write({ 'scripts/ui/cutscene.gd.uid': 'uid://b4w2kq0ly8s5n\n' }), { 'scripts/ui/cutscene.gd.uid': UID }],
];
for (const [p, why, after, base, feat] of cases) {
const r = founderstar611({ after, base, feat }).run();
expect([why, r.status, lineOf(r.stdout, 'STOP')]).toEqual([why, 3, behaviorStop(p)]);
expect([why, lineOf(r.stdout, 'ROUTE_SIDECAR_REFUSED')]).toEqual([why, expect.stringContaining(`ROUTE_SIDECAR_REFUSED: ${p} ${why}`)]);
expect(r.stdout).not.toContain('UID_SIDECAR:');
}
});
test('a sidecar the base policy excludes from review refuses; a .import file routes as before, with no sidecar line', () => {
const excluded = founderstar611({ base: { '.gstack/ship-policy.json': JSON.stringify({ review_exclude_paths: ['tests/**'], min_gstack_version: REVIEW_EXCLUDE_KEY_VERSION }) + '\n' } }).run();
expect([excluded.status, lineOf(excluded.stdout, 'ROUTE_SIDECAR_REFUSED')]).toEqual([3, expect.stringContaining('review_exclude_paths')]);
const imported = founderstar611({ after: f => f.write({ 'tests/icon.png.import': '[remap]\n\nimporter="texture"\nuid="uid://cecaux1sm7mo"\n' }) }).run();
expect([imported.status, lineOf(imported.stdout, 'STOP')]).toEqual([3, behaviorStop('tests/icon.png.import')]);
expect(imported.stdout).not.toMatch(/UID_SIDECAR|ROUTE_SIDECAR_REFUSED/);
});
});
describe('base-declared test_code (GST-1604: GDScript tests)', () => {
const POLICY = JSON.stringify({ test_code: ['tests/**/*.gd'], min_gstack_version: TEST_CODE_KEY_VERSION }) + '\n';
test('a declared glob counts as test code, shared helpers and fixtures still excepted', () => {
expect(isTestCode('tests/cutscene_check.gd')).toBe(false);
expect(isTestCode('tests/cutscene_check.gd', ['tests/**/*.gd'])).toBe(true);
expect(isTestCode('tests/ui/menu_check.gd', ['tests/**/*.gd'])).toBe(true);
expect(isTestCode('tests/helpers/fake_input.gd', ['tests/**/*.gd'])).toBe(false);
expect(isTestCode('scripts/ui/cutscene.gd', ['tests/**/*.gd'])).toBe(false);
expect(isTestCode('src/a.test.ts', ['tests/**/*.gd'])).toBe(true);
});
test('the key validates as a glob list behind min_gstack_version, never under .gstack/ or .github/', () => {
expect(validatePolicy(JSON.parse(POLICY))).toEqual([]);
expect(validatePolicy({ test_code: ['tests/**/*.gd'] }).map(e => e.message)).toEqual([expect.stringContaining(`set min_gstack_version to ${TEST_CODE_KEY_VERSION}`)]);
expect(validatePolicy({ test_code: ['.github/**/*.gd'], min_gstack_version: TEST_CODE_KEY_VERSION }).map(e => e.path)).toEqual(['test_code[0]']);
expect(validatePolicy({ test_code: 'tests/**', min_gstack_version: TEST_CODE_KEY_VERSION }).map(e => e.path)).toEqual(['test_code']);
});
/** The PR adds tests/menu_check.gd before review; after review one more check is appended. */
const appended = (base: Record<string, string>, feat: Record<string, string> = {}) => {
const f = fx(base, { 'tests/menu_check.gd': CHECK, ...feat });
const reviewed = wtree(f);
const reviewedBase = f.git('merge-base', 'origin/main', 'HEAD').stdout.trim();
const post = snapshot(f);
f.write({ 'tests/menu_check.gd': CHECK + 'func test_menu():\n\tassert(1 + 1 == 2)\n' }); f.commit('one more check after review');
return f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--reviewed-base', reviewedBase, '--docs-post', post, '--lane', LANE]);
};
test('a check appended after review to a declared GDScript test takes the scoped reader; undeclared or head-only it stops as behavior', () => {
const declared = appended({ '.gstack/ship-policy.json': POLICY });
expect(lineOf(declared.stdout, 'ROUTE'), declared.stdout).toMatch(/ behavior=0 docs=0 metadata=0 tests=1 /);
expect(lineOf(declared.stdout, 'STOP')).toContain('test code changed since review in tests/menu_check.gd');
for (const [name, r] of [['undeclared', appended({})], ['head-only', appended({}, { '.gstack/ship-policy.json': POLICY })]] as const) {
expect([name, lineOf(r.stdout, 'ROUTE')]).toEqual([name, expect.stringMatching(/ behavior=1 docs=0 metadata=0 tests=0 /)]);
expect([name, r.status, lineOf(r.stdout, 'STOP')]).toEqual([name, 3, behaviorStop('tests/menu_check.gd')]);
}
});
});