From 336dda282e5bb0abe18a1ec82a5eafdbe18e3aaa Mon Sep 17 00:00:00 2001 From: garrytan Date: Fri, 6 Nov 2026 10:45:58 +0000 Subject: [PATCH] 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> --- changelog.d/capy-gst-1604-uid-sidecar.md | 1 + docs/ship-policy.md | 61 +++++++++ docs/troubleshooting.md | 19 +++ lib/ship-mechanical.ts | 12 +- lib/ship-patch.ts | 9 +- lib/ship-policy-keys.ts | 3 +- lib/ship-policy.ts | 5 +- lib/ship-release.ts | 29 ++-- lib/ship-sidecar.ts | 85 ++++++++++++ test/ship-uid-sidecar.test.ts | 163 +++++++++++++++++++++++ 10 files changed, 366 insertions(+), 21 deletions(-) create mode 100644 changelog.d/capy-gst-1604-uid-sidecar.md create mode 100644 lib/ship-sidecar.ts create mode 100644 test/ship-uid-sidecar.test.ts diff --git a/changelog.d/capy-gst-1604-uid-sidecar.md b/changelog.d/capy-gst-1604-uid-sidecar.md new file mode 100644 index 000000000..a6c2245dd --- /dev/null +++ b/changelog.d/capy-gst-1604-uid-sidecar.md @@ -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://` 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=` 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) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 32faf4018..255999778 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -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 | `[]` | 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. + + +## `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 `.uid` beside each +script or shader they give a UID. The file is one line, +`uid://`, where `` 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=` 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://` 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: (beside )`. Any other +`.uid` keeps its old route, usually behavior, and `finish` prints +`ROUTE_SIDECAR_REFUSED: ` +([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: (beside )`. +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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2911d4c32..dcac95c98 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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`. + +### `ROUTE_SIDECAR_REFUSED: ` + +**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 that the reviewed +range added or modified or the review-loop exception accepted`, `has no +sibling at the current tree`, `existed at the reviewed tree or the +current base`, `is not a regular non-executable file (mode )`, `is not +exactly one Godot uid:// 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://` 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. + ### `policy source unavailable — fix: git fetch origin --depth=1 (POLICY_SOURCE_UNAVAILABLE)` diff --git a/lib/ship-mechanical.ts b/lib/ship-mechanical.ts index a333eb5cf..73ecd7dd7 100644 --- a/lib/ship-mechanical.ts +++ b/lib/ship-mechanical.ts @@ -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>): 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>, sidecars: Readonly> = {}): 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 []; } diff --git a/lib/ship-patch.ts b/lib/ship-patch.ts index 8b986a0ff..bfeaa7c04 100644 --- a/lib/ship-patch.ts +++ b/lib/ship-patch.ts @@ -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); } diff --git a/lib/ship-policy-keys.ts b/lib/ship-policy-keys.ts index 472b74aa4..21235f69a 100644 --- a/lib/ship-policy-keys.ts +++ b/lib/ship-policy-keys.ts @@ -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 = { 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 = { 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, }; diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 1558ea338..4c792d9c4 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -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 = { 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 --pin ` (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' }); diff --git a/lib/ship-release.ts b/lib/ship-release.ts index bbe854386..1f8f458d4 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -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 }; -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 | null; bookkeeping: readonly string[]; mechanical: readonly MechanicalConstant[]; baseRelease?: string[] }; +type RouteOpts = { docsRechecked: boolean; contextReviewed?: string; loop?: FixCheckException; audited: Record | 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 | 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'); diff --git a/lib/ship-sidecar.ts b/lib/ship-sidecar.ts new file mode 100644 index 000000000..b479237dc --- /dev/null +++ b/lib/ship-sidecar.ts @@ -0,0 +1,85 @@ +/** + * Godot 4 UID sidecars after review (GST-1604, founderstar#611). Godot 4.4+ writes `.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; refused: SidecarRefusal[] } { + const proven: Record = {}; + 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:// 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 }; +} diff --git a/test/ship-uid-sidecar.test.ts b/test/ship-uid-sidecar.test.ts new file mode 100644 index 000000000..7bd95e3a6 --- /dev/null +++ b/test/ship-uid-sidecar.test.ts @@ -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) => { 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; feat?: Record; fixRange?: Record } +/** + * 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://\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?, Record?][] = [ + ['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:// 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, feat: Record = {}) => { + 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')]); + } + }); +});