diff --git a/lib/ship-sidecar.ts b/lib/ship-sidecar.ts index 2e38cc3d0..62748036a 100644 --- a/lib/ship-sidecar.ts +++ b/lib/ship-sidecar.ts @@ -10,7 +10,8 @@ * - 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, and no other file - * in the current tree names that uid:// (a copied sidecar gives two resources one UID); + * in the current tree declares that uid:// as its own (a copied sidecar gives two resources one UID; a scene that + * references the script by it is fine); * - neither it nor its sibling matches the base policy's review_exclude_paths or readers.forbid_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. @@ -49,9 +50,13 @@ function git(cwd: string, args: string[]): string | null { return r.status === 0 ? r.stdout : null; } -/** The other files in `tree` that name `uid` as a whole word; null when git cannot search it. */ +/** + * The other files in `tree` that declare `uid` as their own: another sidecar holding it as its line, a scene or resource + * header (`[gd_scene … uid="…"]`) or an import file's `uid="…"` line. A reference (`[ext_resource … uid="…"]`) is not one. + * Null when git cannot search the tree. + */ function holders(cwd: string, tree: string, uid: string, self: string): string[] | null { - const r = spawnSync('git', ['-c', 'core.quotepath=off', 'grep', '-z', '-l', '-w', '-F', '-e', uid, tree], { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 }); + const r = spawnSync('git', ['-c', 'core.quotepath=off', 'grep', '-z', '-l', '-E', '-e', `^${uid}$`, '-e', `^\\[gd_(scene|resource)[^]]* uid="${uid}"`, '-e', `^uid="${uid}"$`, tree], { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 }); if (r.status !== 0 && r.status !== 1) return null; return r.stdout.split('\0').filter(Boolean).map(l => l.slice(tree.length + 1)).filter(p => p !== self); } diff --git a/test/ship-uid-sidecar.test.ts b/test/ship-uid-sidecar.test.ts index e1504b8da..8cfce61ab 100644 --- a/test/ship-uid-sidecar.test.ts +++ b/test/ship-uid-sidecar.test.ts @@ -73,7 +73,7 @@ describe('isGodotUidText pins what Godot 4 writes for one UID', () => { 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 { f, run } = founderstar611({ feat: { 'scenes/cutscene_check.tscn': `[gd_scene load_steps=2 format=3 uid="uid://b4w2kq0ly8s5n"]\n\n[ext_resource type="Script" uid="${UID.trim()}" path="res://tests/cutscene_check.gd" id="1"]\n` } }); 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)`); @@ -106,6 +106,8 @@ describe('a Godot UID sidecar added after the fix-check (GST-1604: founderstar#6 [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 }], [SIDECAR, 'shares its UID with scripts/old.gd.uid', f => f.write({ [SIDECAR]: UID }), { 'scripts/old.gd': 'extends Node\n', 'scripts/old.gd.uid': UID }], + [SIDECAR, 'shares its UID with scenes/menu.tscn', f => f.write({ [SIDECAR]: UID }), { 'scenes/menu.tscn': `[gd_scene load_steps=1 format=3 uid="${UID.trim()}"]\n` }], + [SIDECAR, 'shares its UID with assets/icon.png.import', f => f.write({ [SIDECAR]: UID }), { 'assets/icon.png.import': `[remap]\n\nimporter="texture"\ntype="CompressedTexture2D"\nuid="${UID.trim()}"\n` }], ]; for (const [p, why, after, base, feat] of cases) { const r = founderstar611({ after, base, feat }).run();