[GH-ISSUE #5887] [Bug] Asset catalog join loses structured coverage when a catalog is skipped #3567

Closed
opened 2026-10-31 00:41:56 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/5887

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

rea inspect-asset-catalog reports a recognized Toolbar resource key as unmatched when the bundle has one inspected catalog and another Assets.car entry skipped because it is a symlink. Direct system assetutil --info on the skipped catalog lists Name: "Toolbar". REA discloses that skipped path in a text limitation, but the structured match row is indistinguishable from the complete-inventory control where the key has no match.

Replacing the symlink with a regular copy of exactly the same catalog bytes changes the CLI result to matched. No catalog metadata, Interface Builder decoding or target selection was mocked.

Reproduction

On macOS with Xcode actool and a built checkout, save the script below outside the checkout and run node repro.mjs ./scripts/rea.mjs from the checkout root. It compiles two small catalogs and runs three public CLI calls. The app is inspected as data and never launched; generated artifacts are removed on exit.

The NIB bytes are the existing rooted parser fixture used for recognized resource-key coverage, with an NSImage resource whose NSImageName is Toolbar. They are not ibtool output. Color catalogs deliberately exercise REA's exact-name metadata join; the test makes no claim about runtime image rendering.

import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdir, mkdtemp, writeFile, copyFile, symlink, unlink, rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
const run = promisify(execFile);
const entry = resolve(process.argv[2]);
const root = await mkdtemp(join(tmpdir(), 'asset-join-'));
try {
  const app = join(root, 'Fixture.app');
  const resources = join(app, 'Contents/Resources');
  await mkdir(join(resources, 'Nested'), { recursive: true });
  await mkdir(join(app, 'Contents/MacOS'), { recursive: true });
  await mkdir(join(root, 'home'));
  await copyFile('/usr/bin/true', join(app, 'Contents/MacOS/App'));
  await writeFile(join(app, 'Contents/Info.plist'), '<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>');
  const nib = 'TklCQXJjaGl2ZQEAAAAKAAAABAAAADIAAAAEAAAAPgAAAAQAAABgAAAABAAAAHwAAACAgIGBgYGCgoGDg4GGTlNSb290hWltYWdli05TSW1hZ2VOYW1liE5TLmJ5dGVzgAoBAAAAgQoCAAAAggoDAAAAgwiHVG9vbGJhco6ATlNJQk9iamVjdERhdGGLgE5TSW1hZ2VWaWV3h4BOU0ltYWdliIBOU1N0cmluZw==';
  await writeFile(join(resources, 'Panel.nib'), Buffer.from(nib, 'base64'));
  for (const name of ['Other', 'Toolbar']) {
    const source = join(root, `${name}.xcassets`);
    const output = join(root, name);
    await mkdir(join(source, `${name}.colorset`), { recursive: true });
    await mkdir(output);
    await writeFile(join(source, 'Contents.json'), JSON.stringify({ info: { author: 'xcode', version: 1 } }));
    await writeFile(join(source, `${name}.colorset/Contents.json`), JSON.stringify({ colors: [{ idiom: 'universal', color: { 'color-space': 'srgb', components: { red: '0.1', green: '0.2', blue: '0.3', alpha: '1.0' } } }], info: { author: 'xcode', version: 1 } }));
    await run('/usr/bin/xcrun', ['actool', source, '--compile', output, '--platform', 'macosx', '--minimum-deployment-target', '12.0']);
  }
  await copyFile(join(root, 'Other/Assets.car'), join(resources, 'Assets.car'));
  await copyFile(join(root, 'Toolbar/Assets.car'), join(resources, 'Secondary.car'));
  const env = { PATH: process.env.PATH, HOME: join(root, 'home'), XDG_CONFIG_HOME: join(root, 'home'), XDG_CACHE_HOME: join(root, 'home'), REA_LOG_LEVEL: 'silent', REA_ANALYSIS_PROVIDER: 'auto', HOPPER_LAUNCHER_PATH: join(root, 'absent-hopper') };
  async function call() {
    const { stdout } = await run(process.execPath, [entry, 'inspect-asset-catalog', app, '--json'], { env, timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
    return JSON.parse(stdout).normalized_result;
  }
  const complete = await call();
  assert.equal(complete.resource_key_matches[0].key, 'Toolbar');
  assert.equal(complete.resource_key_matches[0].status, 'unmatched');
  const link = join(resources, 'Nested/Assets.car');
  await symlink('../Secondary.car', link);
  const skipped = JSON.parse((await run('/usr/bin/assetutil', ['--info', link])).stdout);
  assert(skipped.some(record => record.Name === 'Toolbar'));
  const mixed = await call();
  assert.equal(mixed.catalogs.length, 1);
  assert.equal(mixed.resource_key_matches[0].status, 'unmatched');
  assert(mixed.limitations.some(line => line.includes('may resolve there: Contents/Resources/Nested/Assets.car (symlink)')));
  await unlink(link);
  await copyFile(join(resources, 'Secondary.car'), link);
  const repaired = await call();
  assert.equal(repaired.catalogs.length, 2);
  assert.equal(repaired.resource_key_matches[0].status, 'matched');
  console.log(JSON.stringify({ complete: { catalogs: complete.catalogs.length, key: complete.resource_key_matches[0].key, status: complete.resource_key_matches[0].status }, mixed: { catalogs: mixed.catalogs.length, resource_key_matches: mixed.resource_key_matches, limitations: mixed.limitations }, skipped_catalog_names: skipped.filter(record => typeof record.Name === 'string').map(record => record.Name), repaired: { catalogs: repaired.catalogs.length, key: repaired.resource_key_matches[0].key, status: repaired.resource_key_matches[0].status } }, null, 2));
} finally {
  await rm(root, { recursive: true, force: true });
}

Expected behavior

When a known skipped catalog could contain a collected key, preserve that uncertainty in a structured result or return a typed incomplete outcome. A caller should be able to distinguish “no match in the complete examined catalog set” from “no match in the examined subset” without interpreting a prose limitation. This does not require following symlinks or claiming runtime resource resolution.

The existing all-skipped guard explicitly prevents an unmatched key from overstating absence that a skipped entry may contradict. That uncertainty also applies when an unrelated regular catalog was inspected. The repository contract requires preserving omission and uncertainty across boundaries; the operation documentation promises exact UI resource-name matches.

Impact and scope

P2: a bounded evidence-coverage defect for bundles containing both inspected and skipped Assets.car entries. Automated consumers of resource_key_matches[].status cannot distinguish this known-incomplete join from a complete zero-match result. The text limitation preserves the warning, so callers can avoid concluding absence by treating results with skipped catalogs as unknown. Direct read-only assetutil inspection of the skipped path confirmed the name in this fixture. Real-app prevalence and downstream automation effects were not measured.

REA version

Source revision 52415975942a4b9c53a817915b313ccc404f280e; package manifest version 6.7.0. Fetched origin/main at 5eebe2fdf87915dc3e4c604ad6945668dd1c6c50 has no changes to the analyzer, projection, CLI/provider dispatch or cited documentation. That freshness check was a source comparison, not execution of the newer revision.

Runtime and platform

Node.js v26.12.0; macOS 27.0.1 (26A434), arm64; Xcode 27.1 (27A9275); system assetutil 1010.00. No deep-analysis provider was involved.

Target and artifact context

Generated macOS-layout .app with executable-resolving Info.plist, an inert copied system Mach-O executable, a parser-valid rooted NIBArchive, one regular actool-built catalog containing Other, and a nested Assets.car symlink pointing to a second real catalog containing Toolbar.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

The script completed successfully and asserted all three outcomes. Relevant observed values:

{
  "complete": { "catalogs": 1, "key": "Toolbar", "status": "unmatched" },
  "mixed": {
    "catalogs": 1,
    "resource_key_matches": [
      { "field": "NSImageName", "key": "Toolbar", "status": "unmatched", "renditions": [] }
    ],
    "limitation": "Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: Contents/Resources/Nested/Assets.car (symlink)"
  },
  "skipped_catalog_names": ["Toolbar"],
  "repaired": { "catalogs": 2, "key": "Toolbar", "status": "matched" }
}

This is a reduced selection of output fields. Actual rows also retain source-node and archive provenance. skipped_catalog_names comes from independent real assetutil output; the other fields come from REA CLI output. The repaired case changes fixture representation only, and is not a software fix.

Typed outcome or recovery expected

Preserve the skipped-catalog coverage fact alongside the join, with a structured way to prevent zero matches from implying a complete search. Preserve successful observed matches, provenance and the complete-inventory control. The particular schema or typed-error design is a maintainer decision.

Analysis or suspected cause

Source-derived: the analyzer passes only inspected catalogs into the projection and adds the skipped-entry warning afterwards. The projection determines status solely from exact matches in those supplied catalogs. The row schema has no unknown status or per-row coverage field. The producing boundary knows the omission, but it is absent from the structured join.

Additional context

Related reports cover different failures: #5698 loses the diagnostic for a readable malformed catalog; #5635 and #2176 concern omitted resource-key rows; #3914 and its merged repair #4094 concern unrelated filenames selected as catalogs; #5111 concerns empty-inventory pagination. Here the recognized key row is present, catalog metadata is valid, and default pagination is used. No other-platform or MCP execution was performed, and no known-good REA revision was established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5887 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior `rea inspect-asset-catalog` reports a recognized `Toolbar` resource key as `unmatched` when the bundle has one inspected catalog and another `Assets.car` entry skipped because it is a symlink. Direct system `assetutil --info` on the skipped catalog lists `Name: "Toolbar"`. REA discloses that skipped path in a text limitation, but the structured match row is indistinguishable from the complete-inventory control where the key has no match. Replacing the symlink with a regular copy of exactly the same catalog bytes changes the CLI result to `matched`. No catalog metadata, Interface Builder decoding or target selection was mocked. ### Reproduction On macOS with Xcode actool and a built checkout, save the script below outside the checkout and run `node repro.mjs ./scripts/rea.mjs` from the checkout root. It compiles two small catalogs and runs three public CLI calls. The app is inspected as data and never launched; generated artifacts are removed on exit. The NIB bytes are the existing rooted parser fixture used for recognized resource-key coverage, with an `NSImage` resource whose `NSImageName` is `Toolbar`. They are not ibtool output. Color catalogs deliberately exercise REA's exact-name metadata join; the test makes no claim about runtime image rendering. ```js import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { mkdir, mkdtemp, writeFile, copyFile, symlink, unlink, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; const run = promisify(execFile); const entry = resolve(process.argv[2]); const root = await mkdtemp(join(tmpdir(), 'asset-join-')); try { const app = join(root, 'Fixture.app'); const resources = join(app, 'Contents/Resources'); await mkdir(join(resources, 'Nested'), { recursive: true }); await mkdir(join(app, 'Contents/MacOS'), { recursive: true }); await mkdir(join(root, 'home')); await copyFile('/usr/bin/true', join(app, 'Contents/MacOS/App')); await writeFile(join(app, 'Contents/Info.plist'), '<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>'); const nib = 'TklCQXJjaGl2ZQEAAAAKAAAABAAAADIAAAAEAAAAPgAAAAQAAABgAAAABAAAAHwAAACAgIGBgYGCgoGDg4GGTlNSb290hWltYWdli05TSW1hZ2VOYW1liE5TLmJ5dGVzgAoBAAAAgQoCAAAAggoDAAAAgwiHVG9vbGJhco6ATlNJQk9iamVjdERhdGGLgE5TSW1hZ2VWaWV3h4BOU0ltYWdliIBOU1N0cmluZw=='; await writeFile(join(resources, 'Panel.nib'), Buffer.from(nib, 'base64')); for (const name of ['Other', 'Toolbar']) { const source = join(root, `${name}.xcassets`); const output = join(root, name); await mkdir(join(source, `${name}.colorset`), { recursive: true }); await mkdir(output); await writeFile(join(source, 'Contents.json'), JSON.stringify({ info: { author: 'xcode', version: 1 } })); await writeFile(join(source, `${name}.colorset/Contents.json`), JSON.stringify({ colors: [{ idiom: 'universal', color: { 'color-space': 'srgb', components: { red: '0.1', green: '0.2', blue: '0.3', alpha: '1.0' } } }], info: { author: 'xcode', version: 1 } })); await run('/usr/bin/xcrun', ['actool', source, '--compile', output, '--platform', 'macosx', '--minimum-deployment-target', '12.0']); } await copyFile(join(root, 'Other/Assets.car'), join(resources, 'Assets.car')); await copyFile(join(root, 'Toolbar/Assets.car'), join(resources, 'Secondary.car')); const env = { PATH: process.env.PATH, HOME: join(root, 'home'), XDG_CONFIG_HOME: join(root, 'home'), XDG_CACHE_HOME: join(root, 'home'), REA_LOG_LEVEL: 'silent', REA_ANALYSIS_PROVIDER: 'auto', HOPPER_LAUNCHER_PATH: join(root, 'absent-hopper') }; async function call() { const { stdout } = await run(process.execPath, [entry, 'inspect-asset-catalog', app, '--json'], { env, timeout: 30000, maxBuffer: 8 * 1024 * 1024 }); return JSON.parse(stdout).normalized_result; } const complete = await call(); assert.equal(complete.resource_key_matches[0].key, 'Toolbar'); assert.equal(complete.resource_key_matches[0].status, 'unmatched'); const link = join(resources, 'Nested/Assets.car'); await symlink('../Secondary.car', link); const skipped = JSON.parse((await run('/usr/bin/assetutil', ['--info', link])).stdout); assert(skipped.some(record => record.Name === 'Toolbar')); const mixed = await call(); assert.equal(mixed.catalogs.length, 1); assert.equal(mixed.resource_key_matches[0].status, 'unmatched'); assert(mixed.limitations.some(line => line.includes('may resolve there: Contents/Resources/Nested/Assets.car (symlink)'))); await unlink(link); await copyFile(join(resources, 'Secondary.car'), link); const repaired = await call(); assert.equal(repaired.catalogs.length, 2); assert.equal(repaired.resource_key_matches[0].status, 'matched'); console.log(JSON.stringify({ complete: { catalogs: complete.catalogs.length, key: complete.resource_key_matches[0].key, status: complete.resource_key_matches[0].status }, mixed: { catalogs: mixed.catalogs.length, resource_key_matches: mixed.resource_key_matches, limitations: mixed.limitations }, skipped_catalog_names: skipped.filter(record => typeof record.Name === 'string').map(record => record.Name), repaired: { catalogs: repaired.catalogs.length, key: repaired.resource_key_matches[0].key, status: repaired.resource_key_matches[0].status } }, null, 2)); } finally { await rm(root, { recursive: true, force: true }); } ``` ### Expected behavior When a known skipped catalog could contain a collected key, preserve that uncertainty in a structured result or return a typed incomplete outcome. A caller should be able to distinguish “no match in the complete examined catalog set” from “no match in the examined subset” without interpreting a prose limitation. This does not require following symlinks or claiming runtime resource resolution. The existing [all-skipped guard](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/apple/AppleAssetCatalogAnalysis.ts#L158-L164) explicitly prevents an unmatched key from overstating absence that a skipped entry may contradict. That uncertainty also applies when an unrelated regular catalog was inspected. The [repository contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/AGENTS.md#L19-L21) requires preserving omission and uncertainty across boundaries; the [operation documentation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/native-investigation.md#L363-L366) promises exact UI resource-name matches. ### Impact and scope P2: a bounded evidence-coverage defect for bundles containing both inspected and skipped `Assets.car` entries. Automated consumers of `resource_key_matches[].status` cannot distinguish this known-incomplete join from a complete zero-match result. The text limitation preserves the warning, so callers can avoid concluding absence by treating results with skipped catalogs as unknown. Direct read-only assetutil inspection of the skipped path confirmed the name in this fixture. Real-app prevalence and downstream automation effects were not measured. ### REA version Source revision `52415975942a4b9c53a817915b313ccc404f280e`; package manifest version 6.7.0. Fetched `origin/main` at `5eebe2fdf87915dc3e4c604ad6945668dd1c6c50` has no changes to the analyzer, projection, CLI/provider dispatch or cited documentation. That freshness check was a source comparison, not execution of the newer revision. ### Runtime and platform Node.js v26.12.0; macOS 27.0.1 (26A434), arm64; Xcode 27.1 (27A9275); system assetutil 1010.00. No deep-analysis provider was involved. ### Target and artifact context Generated macOS-layout `.app` with executable-resolving Info.plist, an inert copied system Mach-O executable, a parser-valid rooted NIBArchive, one regular actool-built catalog containing `Other`, and a nested `Assets.car` symlink pointing to a second real catalog containing `Toolbar`. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence The script completed successfully and asserted all three outcomes. Relevant observed values: ```json { "complete": { "catalogs": 1, "key": "Toolbar", "status": "unmatched" }, "mixed": { "catalogs": 1, "resource_key_matches": [ { "field": "NSImageName", "key": "Toolbar", "status": "unmatched", "renditions": [] } ], "limitation": "Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: Contents/Resources/Nested/Assets.car (symlink)" }, "skipped_catalog_names": ["Toolbar"], "repaired": { "catalogs": 2, "key": "Toolbar", "status": "matched" } } ``` This is a reduced selection of output fields. Actual rows also retain source-node and archive provenance. `skipped_catalog_names` comes from independent real assetutil output; the other fields come from REA CLI output. The repaired case changes fixture representation only, and is not a software fix. ### Typed outcome or recovery expected Preserve the skipped-catalog coverage fact alongside the join, with a structured way to prevent zero matches from implying a complete search. Preserve successful observed matches, provenance and the complete-inventory control. The particular schema or typed-error design is a maintainer decision. ### Analysis or suspected cause Source-derived: [the analyzer](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/apple/AppleAssetCatalogAnalysis.ts#L73-L101) passes only inspected catalogs into the projection and adds the skipped-entry warning afterwards. [The projection](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/apple/appleAssetCatalog.ts#L150-L179) determines status solely from exact matches in those supplied catalogs. [The row schema](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/apple/appleAssetCatalog.ts#L52-L67) has no unknown status or per-row coverage field. The producing boundary knows the omission, but it is absent from the structured join. ### Additional context Related reports cover different failures: #5698 loses the diagnostic for a readable malformed catalog; #5635 and #2176 concern omitted resource-key rows; #3914 and its merged repair #4094 concern unrelated filenames selected as catalogs; #5111 concerns empty-inventory pagination. Here the recognized key row is present, catalog metadata is valid, and default pagination is used. No other-platform or MCP execution was performed, and no known-good REA revision was established.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3567
No description provided.