mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 03:30:28 +01:00
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:
1 parent
05114c8049
commit
336dda282e
10 files changed
+366
-21
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user