Skip to content

fix(artifacts): decide how compare_artifacts compares every format_metadata kind - #8231

Merged
morluto merged 38 commits into
morluto:mainfrom
Entrpi:fix-artifacts-comparison
Nov 4, 2026
Merged

morluto merged 38 commits into
morluto:mainfrom
Entrpi:fix-artifacts-comparison

Conversation

@Entrpi

@Entrpi Entrpi commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Part of the embedded roadmap #7955.

Summary

compare_artifacts compares some format_metadata declarations. On main, the compared kinds are named by hand in six per-format functions and fields, so a new kind goes uncompared and nothing reports it. ESP segment load addresses were first missed this way, and UnityFS member flags still are. This PR puts the decision in one table beside the schemas. Each kind either projects the declaration that comparison compares or states why comparison uses none of its facts, and a kind without an entry does not compile. A conformance test checks that the contract names exactly the compared kinds.

Problem and expected behavior

Case main d48e472fa This PR
A node-less PYZ namespace record becomes encrypted unchanged changed [metadata]
A PyInstaller member's declaration changes while its bytes are unavailable, for example a link name stored as NFC, then NFD unknown [metadata, availability] changed [metadata, availability]
A UnityFS member's raw flags change, with equal bytes unchanged changed [metadata]
Only one side records a compared declaration a Node SEA sentence for Node SEA; no limitation for ESP segments, FIT images or PyInstaller entries one generic limitation for every compared kind
A symlink in a POSIX directory, app bundle or DMG inventory executable from the link's own mode bits (Linux reports 0777 for every symlink) never executable
Declared digests of one algorithm differ, and either side has no content unknown [availability] unknown [metadata, availability]

The first four rows were executed on main with hand-built inventories (see Evidence). The last two are derived from source, and so are row 4's FIT and PyInstaller cases.

On main, declarationChanged makes a path without content changed when ASAR storage or a Node SEA, ESP segment, DfuSe element, ESP flash partition or FIT declaration changed, but not a PyInstaller declaration. PyInstaller declaration comparison (142cf188a) is older than that exception, and no test or commit gives a reason to leave it out. Here every compared declaration counts, so such a result's status can be changed instead of unknown. main's sentence in docs/python-packaged-applications.md, "Missing declaration facts and unavailable non-metadata content remain unknown.", predates the exception; this PR rewords it.

The generic limitation replaces "Node SEA member metadata is recorded on only one side for matching paths; …" with "Format metadata declarations are missing on one side or differ in kind for matching paths; omitted metadata may be a legacy or non-container observation and leaves the complete declaration unavailable."

Unchanged from main: #7932's rule that a path without content is unknown unless a declaration changed, FIT per-field unknowns, and the comparison of occurrence limitation text. dotnet-bundle-entry, the Bun kinds, hex-record-region, esp-flash-image, uf2, uf2-region and dfuse stay uncompared, and esp-flash-partition and dfuse-element compare the same fields as on main. A HEX region's path names its start and end addresses, so its declaration could never differ between paired regions.

Change and scope

  • src/domain/artifactFormatMetadata.ts: declarationsByKind decides all 26 kinds. Compared: unityfs-entry (source_path and raw flags; new), pyinstaller-entry, node-sea-entry, esp-image-segment, dfuse-element, esp-flash-partition and fit-image (with its unreadable fields). Each other kind states why it is not compared. main's six *Declaration functions are removed.

  • src/domain/artifactComparison.ts: OccurrenceMetadata holds four comparisons: ASAR storage, encryption, the table's declaration and that declaration's field availability. A declaration changed when ASAR storage or the table's declaration changed. A node-less metadata record compares its declaration, encrypted, executable and any recorded ASAR storage. Here a node-less record is a metadata occurrence without artifact_id on both sides, with a compared declaration on at least one side. Its limitations are not compared, as on main. A metadata record that has content keeps the content comparison. classifyPyInstallerDeclaration and compareFitImageDeclarations are removed.

  • src/artifacts/hostExecutableMarking.ts: a symlink has no executable marking on any host, because running a symlink runs its target.

  • Docs: the contract in docs/mcp-contracts.md, and the Godot PCK, HEX/S-record, UnityFS and PyInstaller guides.

  • Commits, in stack order:

    • the table: 9e65be9e3, 88869713e, 3e51a7760 (.NET bundle members stay uncompared, as on main) and 7978b9400 (HEX regions too);
    • encryption in OccurrenceMetadata: 2b66531c7;
    • executable on records, symlinks and declared digests: 2fc3bd2c1, a6e09ece3, 0c92f1c57;
    • the docs test and a shared acceptance fixture: 1a068ac21, 964be7465;
    • record-rule tests: 9ca54d6b0, eb46be7fc;
    • review fixes: 9d5e31a95 (a UnityFS comparison test), 8177a12c4 (which moves it to the shared fixture) and 6b59c4f51 (the docs test finds the compared kinds by marker).

    The merges of main adopt fix(artifacts): preserve known metadata without content hashes #7932, so some earlier commit messages describe superseded behavior. For example, 2fc3bd2c1 says changed, and 9dbf90bd9 stops comparing limitation text; that change is undone. The branch also holds feat(artifacts): inventory and extract ESP32 firmware images #7498's pre-squash commits. Their content is already in main, so they add nothing to the diff.

  • Not included: declared sizes still count only through observed bytes. Limitation text still counts as metadata, as on main, so a new reader caveat can mark an unchanged member changed: adding ZIP member A does this to a through its case-collision note. Whether limitation text is a comparison fact needs its own decision.

Contract and boundary impact

  • Semantic owner and earliest changed stage: the domain table declarationsByKind owns each kind's projection, and occurrenceMetadata owns each pair's comparison. The symlink change is at the directory reader (hostExecutableMarking). The superseded per-format functions and fields are removed.
  • Contribution scope fit and linked maintainer decision: an ordinary fix within existing boundaries; not applicable.
  • CLI and MCP/tool-catalog contract: schemas unchanged. Classifications and one limitation change, as in the table above.
  • Provider, bridge, target-format, or platform compatibility: on POSIX hosts, symlinks in directory, app bundle and DMG inventories record executable: false, as Windows, zip.js and the PyInstaller reader already do.
  • Evidence, artifact, provenance, or reconstruction contract: occurrence IDs do not change. On POSIX hosts, the graph digests and manifest IDs of inventories that contain symlinks change. Comparing such an inventory stored before this change with a new one of the same tree reports each symlink that the old one marked executable as unknown [executable, availability].
  • Target selection, authority, upstream behavior, and cleanup details: not applicable.
  • Generated metadata (docs/public/product-catalog.json), package, or installation impact: none.

Expected effects:

  • Read existing files or captured data.
  • Launch or attach to a process.
  • Inject instrumentation or run scripts/code.
  • Change process memory, registers, files, or configuration.
  • Contact network services.
  • Recover protected code or work around protections.
  • Documentation or usability only; no target interaction.
  • Not sure yet; investigation or maintainer guidance needed.

Evidence and regression coverage

  • Tests added or updated:

    • src/domain/artifactComparison.test.ts: one case per rule above, including HEX regions and ESP signature blocks staying uncompared, and the record rule for another compared kind and for ASAR storage.
    • Acceptance tests through the schema-validating MCP client:
      • PyInstaller: a link stored as NFC, then NFD; PYZ namespace encryption.
      • Godot PCK: res:// spellings, encryption and declared MD5s.
      • ZIP: a member that becomes a symlink; a mode change on an encrypted member.
      • Node SEA: the limitation.
      • UnityFS: equal member bytes with other raw flags or a decomposed stored path; reordered members stay unchanged.
    • tests/conformance/artifactComparisonDocs.test.ts, which reads the compared kinds between <!-- BEGIN/END:compare-artifacts:compared-format-metadata-kinds --> markers, and src/artifacts/hostExecutableMarking.test.ts.
    • main's Fit, UnavailableMetadata, HistoricalSummary and Plist acceptance tests are unchanged and pass.
  • Base reproduction or other evidence, executed on main d48e472fa:

    • This PR's src/domain/artifactComparison.test.ts (at 2304a0596) fails 6 of its 32 tests on main's source:
      • The one-sided and mismatched-kind cases get no limitation.
      • A metadata record with content hides a content change.
      • The record rule covers only PyInstaller, so an equal ESP segment record is unknown, and a record's occurrence fields and ASAR storage are not compared.
    • A scratch test with the same helpers recorded main's results for the first four rows of the table above; for row 4 it covered Node SEA and ESP segments. It passes on 2304a0596.
  • User-visible CLI/MCP output (if applicable): the change rows in the table above, which the acceptance tests assert through MCP.

  • Remaining proof gaps: all inputs are synthetic fixtures. No real PyInstaller build with link entries or legacy namespace encryption was compared, and no real encrypted Godot export. The MD5 rule relies on Godot's exporter source (4.3, 4.4.1 and 4.7.1), which hashes a member's original bytes.

  • Observed, derived, and inferred claims remain distinguishable.

  • Artifact identity, source provenance, and failed attempts remain preserved.

  • Unsupported, incomplete, unavailable, or uncertain outcomes remain visible.

Validation performed

All on c0ed34063, Node 24.22.1, macOS:

  • npm run check:fast — pass (typecheck and lint, nothing cached).
  • npm run format:check, npm run docs:check, npm run verify:module-boundaries — pass.
  • npx knip — one finding, the unused exported type DarwinHelperCache in src/process/DarwinHelperCache.ts; knip reports it on main 6e9ee97a6 too, so none comes from this PR.
  • npm run test:focused over 99 files — 1,216 passed, 4 skipped (Linux-only extraction cases). The files are every test that calls compare_artifacts, the symlink marking test, the HEX reader and MCP tests, the docs conformance test, and main's artifact and firmware tests changed since 3b647132c, including the ESP flash, UF2, DfuSe and iNES tests.
  • Mutation checks for the review fixes, run before committing them: without flags, or without source_path, in the unityfs-entry projection, tests/acceptance/artifactComparisonUnityFs.test.ts fails at the matching assertion. With "also compares" reworded, tests/conformance/artifactComparisonDocs.test.ts still passes; with unityfs-entry removed from the marked paragraph, it fails.
  • npm run test:local on main d48e472fa, in a throwaway worktree, with this PR's comparison unit tests (at 2304a0596) and a scratch test — results under Evidence.

CI passed on c0ed34063 (run).

Compatibility, safety, and release

  • Breaking changes or migration steps: no schema changes. Classifications, one limitation and POSIX symlink marking change, as described above.
  • Real Hopper/Ghidra, browser, or OS coverage: no provider is involved. Checks ran on macOS only.
  • Package or release metadata impact: none.
  • Security, privacy, process, or containment review: no new reads, processes or network effects.
  • Merge order with the other embedded PRs: this PR, the ESP flash layout PR (branch feat-artifacts-esp-flash-layout) and the UF2/DfuSe PR (branch feat-artifacts-uf2-dfuse) each change how compare_artifacts compares format_metadata. Whichever merges later adds comparison-table entries for the new kinds (esp-flash-partition, dfuse-element and their siblings), as the fork's integration branch already does; the table does not compile while a kind lacks an entry.

Author readiness

Human owner: @Entrpi

  • I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work.
  • I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps.
  • I declare this PR ready for initial maintainer review.

Before merge

  • This PR addresses a concrete problem or an agreed enhancement.
  • Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff.
  • The PR has one focused outcome and the title follows type(scope): outcome.
  • Related issue is linked, or the reason for not linking one is stated above.
  • Tests cover changed observable behavior and meaningful failure paths.
  • Owning docs, contracts, and generated metadata are updated where needed.
  • User-visible CLI/MCP changes include representative output.
  • I checked the final diff for secrets, unrelated cleanup, and unsupported claims.

Entrpi added 30 commits October 27, 2026 22:54
ESP32-family app and bootloader images (magic 0xE9) had no reader, so
open_binary refused OTA downloads, app partitions and bootloaders.

Add the esp-image container format behind inspect_artifact and
extract_artifact. A pure decoder reads each byte once and keeps only
fixed-size fields. Derived facts cover the chip, from a table cited to
ESP-IDF v6.1; segment classification as ESP-IDF's should_map and
should_load; checksum and appended SHA-256 results; app and bootloader
descriptors; Secure Boot v2 signature blocks, observed and unverified;
and undescribed trailing byte ranges. Truncation and undescribed bytes
make coverage unknown. ESP8266 images are refused with a specific
reason, and ciphertext stays unrecognized.

Add the format to every target, Evidence, snapshot, live-summary and
artifact vocabulary, document it, and add an optional verify:esp-images
lane that compares esptool-built images with esptool image-info and
espsecure signature-info-v2.

Refs morluto#3615
The lane compared descriptor fields only when esptool image-info printed
one, so a descriptor that REA reports and esptool does not print passed
without comparison. Compare the descriptor kind in both directions.

Record that the lane passed for all 13 images with esptool and espsecure
v5.6.0, the version the layout facts cite, as well as with 4.12.0.
The decoder and the derived facts each defined the segment-header,
appended-digest and flash-sector sizes, the erased-byte scan and the
offset formatter. Define them once with the other fixed-layout structures
and import them in both places. The signature slot count is the one
constant that drives the slot scan.

Correct the observedEnd comment: it is the end of the last chunk read.
ESP-IDF's verify_segment_header rejects a segment of 16 MiB or more
unless the chip maps flash through 32-bit cache addresses
(SOC_SPI_MEM_SUPPORT_CACHE_32BIT_ADDR_MAP: ESP32-S3, P4, C5, C61, H4 and
S31 in v6.1). Record that capability in the chip table and add a
limitation for such segments; unknown chips get none.

`loaded` repeats should_load. It does not mean the bootloader accepts the
segment: verify_load_addresses still rejects one outside the chip's RAM,
which REA does not model. Say so in the docs, the skill reference and the
inventory limitation.
The lane started work before checking its tools, so a missing RISC-V
target, ld.lld, esptool or espsecure surfaced as a raw spawn or command
error. Check each one before building and name the variable to set. Warn
when esptool is not v5.6.0, the release the layout facts cite.

Assert each segment's class for the ESP32-C3 app, its variants and the
bootloader, as the Xtensa image already did. Extract the app and the
signed image through the CLI and compare every file with the bytes at
esptool's segment offsets or in the appended signature sector; the
source's SHA-256 must be unchanged and equal the reported root_sha256.
The Verification section listed the lane's checks but not its gaps: only
ESP32-C3 and ESP32 images come from a real producer, no real project
build, OTA download or flash dump is inspected, no signature is verified,
and the lane does not run in CI.

Record the timing of the final lane run on esptool v5.6.0.
Segment entries hash only their data, so compare_artifacts reported a
segment that moved to another load address with the same bytes as
unchanged; only the image itself changed. Compare each segment's
load_addr and data_len as its declaration, as Node SEA and PyInstaller
members are compared, and count a changed declaration as a metadata
change, also when content is unavailable.

Header offsets are layout and the classification is derived, so neither
is compared.
The lane ran its tools through execFile. A timeout killed only the direct
child, so a linker or assembler that Clang or GCC had started could keep
running while the lane removed its workspace. Run every command through
createVerifierCommands, check the tools inside withVerifierCleanup, and
remove the workspace only after commands.close() has stopped every tree;
a failed close keeps the workspace.

Only a nonzero exit now counts as a refusal or an expected esptool
failure; timeouts and spawn failures propagate. Supervising each command
roughly doubles the lane's run time.
The cancellation test requested cancel or close on setImmediate, which
can land while the file is still being opened and checked, so it never
showed that decoding had started. It also passed against a reader that
ignored both requests while decoding. Acknowledge the decoder's first
chunk, then cancel or close, join the operation, and assert that no
later chunk reaches the decoder and that the reader ends closed.
Segments are compared by their declaration. The image's and the
signature blocks' format_metadata are not: every field comes from their
own bytes, which content comparison already covers, and a block's
image_digest_matches follows the image's content.
compare_artifacts listed the format_metadata kinds it compared by hand,
so a new kind went uncompared without notice; ESP segment load addresses
were first missed this way. One table beside the schemas now gives every
kind a declaration projection or the reason comparison uses none of its
facts, and a kind without an entry does not compile. Consumers read one
result per pair instead of naming formats.

- Godot PCK entries stay uncompared. Godot looks members up by the
  simplified path without res://, which logical_path holds apart from
  Unicode normalization. Godot 4.3 exports store res:// and 4.4.1
  exports do not, so comparing source_path would mark every member
  changed. The reader admits only the encryption flag, which the
  occurrence's encrypted field records.
- A changed PyInstaller declaration is now a metadata change also when
  the member's content is unavailable, as for ASAR, Node SEA and ESP
  segments. It was unknown before, for no recorded reason.
- Root kinds and ESP signature blocks stay uncompared: their fields come
  from their own bytes, which content comparison covers.
- A declaration that only one side records now adds one generic
  limitation for every compared kind. Only Node SEA added one before,
  with its own text.
…tent

The record rule compared two metadata occurrences by their declaration
alone. A hand-built record that names content, with an equal declaration,
then hid a content change; PyInstaller records already did so before the
table. The rule now needs both records without an artifact_id, which is
every metadata record a reader emits.

Review changes in the same area:

- The unknown-declaration limitation also covers declarations of different
  kinds and non-container observations, and tests compare it with its
  constant.
- docs/godot-pck.md gives each Godot version's lookup path, the backslash
  case, and that a member that becomes encrypted compares unknown. The
  table points to it instead of restating it.
- The PyInstaller regression moves to an acceptance test whose MCP client
  validates the advertised output schema, and the domain tests keep one
  representative case per behavior.
An encrypted member has no content. Comparison then took the
content-unavailable fallback, which counted only ASAR storage and format
declarations, so a Godot PCK, ZIP or PYZ member that became encrypted
compared unknown although both sides record `encrypted`. The record rule
compared node-less metadata records by format declaration alone, so a PYZ
namespace record whose encryption flipped compared unchanged.

`OccurrenceMetadata` now owns the encryption comparison beside ASAR
storage and the format declaration. Every occurrence records `encrypted`,
so a pair's comparison is never unknown. The fallback, the record rule and
the metadata dimension read it there; `metadataChanged` no longer compares
`encrypted` itself. The record rule compares all of `OccurrenceMetadata`:
a changed value is changed in metadata, with availability when another is
unknown.

Decision: occurrence limitations still do not count for node-less
records. Readers derive them from the declared facts (the PYZ caveat
follows `encrypted`), and their wording can change between REA versions
while the record stays the same. The fallback does not compare them
either.

Behavior changes:
- A pair with unavailable content whose `encrypted` differs is changed
  [metadata, availability], not unknown [availability].
- A node-less record whose `encrypted` differs is changed [metadata], not
  unchanged.
- A hand-built node-less record that records ASAR storage now compares
  it; no reader emits such records.

Remaining:
- The fallback still ignores `entry_kind` and `executable`, which readers
  also declare, so a file that becomes a symlink, or a mode change on an
  encrypted ZIP member, still compares unknown. `executable` has its own
  dimension, so its fix needs content-free checks split from node checks.
  Each is its own behavior decision.
- Node-bearing pairs still compare limitation wording; records do not.
  Whether limitation wording is ever a comparison fact needs one decision.
…vailable

When either side lacked verified content, comparison took a fallback that
counted only ASAR storage, encryption and format declarations. It compared
`entry_kind` only in the metadata check of pairs with content, and
`executable` only beside the node checks. A ZIP member that became a
symlink, whose target REA does not read, and an encrypted ZIP member whose
Unix mode gained an execute bit therefore compared unknown, although
readers declare both fields on every occurrence.

`OccurrenceMetadata` now owns the entry kind comparison beside encryption;
it is never unknown. Comparison checks fall into two groups: declared facts
that need no content (`executable` and the metadata declarations), and
facts that need both content nodes (content, kind, format, size, relations
and node metadata). The fallback, the record rule and pairs with content
share the declared checks and one rule for unknown declarations. Kind and
format stay content checks: without content they are guesses from the path.

Behavior changes:
- Unless both sides have content, a pair whose entry kind differs is
  changed [metadata, availability], not unknown [availability].
- Such a pair whose `executable` differs is changed [executable,
  availability], with metadata when another declared fact also differs.
- A hand-built node-less record whose `executable` differs is changed
  [executable]. No reader emits such records.

Remaining: declared sizes and integrity digests still compare only through
observed bytes, so they do not count when content is unavailable.
On POSIX hosts the directory reader recorded a symlink's own permission
bits as `executable`. Linux reports 0777 for every symlink and macOS
usually 0755, so directory, app bundle and DMG symlinks read true. zip.js,
the PyInstaller reader and the Windows branch record false. The previous
commit compares `executable` without content, so a symlink that two
readers or two hosts record differently compared changed in `executable`,
and a non-executable file that became a symlink also reported `executable`.

`hostExecutableMarking` now gives a symlink no marking on any host:
running a symlink runs its target, and the link's own bits do not control
that.

Behavior change: directory, app bundle and DMG inventories record
`executable: false` for symlinks. Occurrence IDs do not change, but the
graph digests and manifest IDs of inventories with symlinks do. Comparing
an inventory stored before this change with a new one therefore reports
changed [executable, availability] once for each such symlink.
Pairs with content compared node and occurrence limitations as metadata,
while node-less records, the content-unavailable fallback and inventory
limitations did not. Limitations explain what an observation could
establish. Readers mostly derive them from fields that comparison already
reads, from other occurrences or from what REA could read, and their
wording changes between REA versions. So adding `A` beside an unchanged
ZIP member `a` marked `a` changed in metadata through its case-collision
note, and an older and a newer inventory of equal bytes could differ
wherever a reader reworded a note.

No comparison path reads limitations now. Inventory limitations still
appear in the result, and occurrence limitations stay in the inventory
Evidence. Readers write no node limitations, so only hand-built inventories
compared them.

Behavior change: for a pair with content, limitation text no longer adds
metadata. A pair that differs only there compares unchanged, or unknown
[availability] when a declaration is unknown.

Remaining: a few facts appear only in limitation text, or also in fields
that comparison does not read, so their changes are no longer reported
while the compared bytes stay equal: an ASAR block integrity table or an
unsupported integrity declaration, a Godot member's all-zero MD5, a ZIP
name's leading `./` (also in `source_path`), and a PyInstaller PYZ image
digest (also in `pyz_sha256`; the archive root still reports the change).
Each needs a field or a comparison decision of its own if it matters.
Without observed bytes on both sides, comparison ignored the containers'
declared integrity claims. Two encrypted Godot PCK members therefore
compared unknown even when their declared MD5s showed that the script had
changed. Godot's exporter stores the MD5 of a member's original bytes, so
the MD5 does not depend on the encryption IV. This comes from the 4.3,
4.4.1 and 4.7.1 exporter sources; no real encrypted export was compared.

When either side has no content, claims of one algorithm that differ now
make the path changed in metadata and availability. Equal claims add no
change, because they are unverified, and observed bytes still decide when
both sides have them. Godot PCK and ASAR readers record such claims.

For a readable member, a directory that declares or omits an MD5 only
decides whether REA can check its bytes, so such a member compares
unchanged. An encrypted member with an MD5 on only one side stays unknown.

Remaining: declared sizes still compare only through observed bytes.
docs/mcp-contracts.md named the format_metadata kinds that
compare_artifacts compares in prose, by hand, so a kind added to the
declaration table could go unnamed. The integration branch already omits
esp-flash-partition, which the ESP flash layout branch adds to the table.

The paragraph now names each compared kind by its identifier. A
conformance test requires it to name exactly the kinds whose table entry
has a declaration, which artifactFormatMetadata.ts exports as
COMPARED_FORMAT_METADATA_KINDS, and no kind that comparison ignores.
Six artifact comparison acceptance tests each copied the same MCP
steps: open_binary, inspect_artifact, the inventory_artifact substep
Evidence, then compare_artifacts. The coverage test repeated the
inspect steps three times and kept its own inventory lookup.

tests/fixtures/artifactComparisonMcp.ts now owns these steps on the
schema-validating connectLocalToolsMcp client.
connectArtifactComparisonMcp() returns call, inspect(path) and
compare(left, right). inventoryEvidence() reads the inventory Evidence
that an inspection retains, from MCP or CLI output. Each test still
writes its own authored input and keeps its expectations.

The coverage lookup matched evidence.operation, not the substep
operation. The inspection schema fixes both to inventory_artifact, so
both select the same substep.

The plist acceptance test does not change. It compares the inspection
Evidence itself across CLI and MCP and passes the comparison Evidence
to verify_reconstruction and find_changed_behavior.

No assertion or scenario changes.
Upstream squash-merged this branch's ESP image prototype (ea2b386) as
morluto#7498 (64196b0), with maintainer commits on top. The merge therefore
uses the prototype as the base: files this branch did not change after
the prototype take main's version, and the nine files it did change
merge against the prototype. Git's own base, d6f88be, gave 32 false
conflicts and kept src/domain/firmware/espImage.fixture.ts, which
upstream moved to tests/fixtures/espImage.ts.

Main now compares format metadata with one function per format: morluto#7932
(morluto) keeps known metadata when content hashes are missing, morluto#7771
adds FIT images, and morluto#3787 adds .NET bundle members. This merge keeps
the declaration table as the one owner of what each kind declares, and
adopts main's comparison semantics:

- When either side's content is unavailable, a path lists its known
  differences and is unknown unless a compared declaration or ASAR
  storage changed (morluto#7932).
- A field that the producer could not read compares unknown on either
  side, and the other fields still compare. A table entry names such
  fields; fit-image names its unreadable_fields (morluto#7771).
- Limitation wording stays compared, as main compares it.

Table decisions for main's new kinds:

- unityfs-entry compares source_path and flags, hex-record-region
  compares start_address and length, and unityfs, hex-records, uimage
  and uimage-entry are not compared. These repeat the decisions that
  embedded-integration made.
- fit-image compares main's fitImageDeclaration fields; fit is not
  compared, because its fields come from the FIT's own bytes.
- dotnet-bundle-entry compares file_type, which tells the host how to
  treat a member. Its header versions come from the host's own bytes.
  Main did not compare this kind.

fitImageDeclaration, nodeSeaEntryDeclaration, espImageSegmentDeclaration
and pyInstallerEntryDeclaration move into the table. The generic
limitation for a declaration that one side omits replaces main's Node
SEA limitation, and a changed PyInstaller declaration stays changed when
content is unavailable, as for the other compared kinds.

Tests: main's unit tests keep their packingObservation calls on the
shared observation helper. A new acceptance test compares .NET bundle
members by file type. Docs name every compared kind and the per-field
unknowns, and the UnityFS, .NET and HEX guides name their compared
fields.
…-comparison

The base branch now merges upstream main, which compares a path without
content as morluto#7932 does: the path lists the differences that are still
known and is unknown unless a declaration changed. This branch adopts
that rule for encryption:

- An encryption change no longer makes a path without content changed.
  A member that becomes encrypted compares unknown in metadata and
  availability, as main already reports it. declarationChanged keeps
  only ASAR storage and format declarations.
- OccurrenceMetadata still owns the encryption comparison, and the
  metadata dimension reads it there instead of comparing encrypted
  again.
- A node-less record still compares its declaration, encryption and
  ASAR storage, and a difference in them is changed. A record has no
  content whose identity could be unknown, and its limitations still do
  not count.

Changed expectation: "reports a Godot PCK member that becomes encrypted"
in tests/acceptance/artifactComparisonGodotPck.test.ts now expects
unknown, not changed, with the same metadata and availability
dimensions. The contract, Godot PCK and PyInstaller docs state the rule
for paths without content and for records.
The base branches now merge upstream main and adopt its comparison of
paths without content (morluto#7932, morluto): such a path lists the
differences that are still known and is unknown unless a declaration
changed. Upstream's artifactComparisonUnavailableMetadata acceptance
test requires this for a symlink compared with an executable file.
This merge revises this branch to that rule:

- 2fc3bd2 compared entry kind and executable without content and
  called the path changed. Main's classifyPath replaces its fallback,
  declaredChecks, nodeChecks and classifyChecks: an entry kind or
  executable difference without content is now unknown, with the
  difference in its dimensions. entry_kind leaves OccurrenceMetadata,
  because main compares it where it lists differences. A node-less
  record still compares executable, as changed in executable.
- 9dbf90b stopped comparing limitation wording. Main compares
  occurrence and node limitations as metadata, so that change is undone
  with its unit test and its ZIP acceptance test. With this merge, a ZIP
  member `a` compares changed in metadata when a sibling `A` is added,
  because the reader adds a case-collision limitation to `a`.
- 0c92f1c compared declared digests without content and called the
  path changed. Differing claims of one algorithm now add metadata to
  the unknown path: they are unverified, like equal claims.
- The symlink executable marking (a6e09ec), the contract conformance
  test (1a068ac) and the shared MCP fixture (964be74) are kept. The
  contract paragraph names every kind the table compares, and the new
  .NET bundle acceptance test uses the shared fixture.
- The ESP comparison test takes main's fixture path, tests/fixtures/
  espImage.ts, with the shared MCP fixture.

Changed expectations, each for the rule above unless stated:

- artifactComparisonZip.test.ts, "reports a ZIP member that becomes a
  symlink": unknown [metadata, availability], not changed.
- artifactComparisonZip.test.ts, "reports an executable mode change on
  an encrypted ZIP member": unknown [executable, availability], not
  changed.
- artifactComparisonZip.test.ts, "keeps a ZIP member unchanged when a
  case-colliding sibling is added": removed with 9dbf90b.
- artifactComparisonCoverage.test.ts, "reports a directory file that
  becomes a symlink without marking the link executable": unknown
  [metadata, availability], not changed. The link still records
  executable false.
- artifactComparisonGodotPck.test.ts, "compares encrypted Godot PCK
  members by their declared MD5": differing MD5s give unknown [metadata,
  availability], not changed. An MD5 on one side now gives [metadata,
  availability], not [availability], because the reader's all-zero MD5
  limitation is compared again.
- artifactComparison.test.ts, "does not compare limitation wording
  beside content": removed with 9dbf90b.

The contract and Godot PCK docs state these rules, and that occurrence
limitation text is a metadata difference.
Upstream main compares no .NET bundle member facts. Comparing `file_type`
changed `compare_artifacts` output beyond this branch's fixes, so the kind
stays uncompared until that is decided. A changed manifest still shows as a
content change of the host.
The comparison table compared a region's start_address and length. A
region's path names its start and end addresses, and its length follows
from them, so two regions that pair at one path always declare the same
facts: the entry could never find a change. It only made a region with
equal bytes unknown when the other side recorded no metadata, such as a
directory of extracted regions. The kind is not compared, as on main.

The contract paragraph also stops naming .NET bundle members, which
3e51a77 left uncompared.
The record rule follows the comparison table, not PyInstaller, but only
PyInstaller records exercised it. An ESP segment record shows both
outcomes: an equal declaration compares unchanged and a moved one changed
in metadata, without the availability that the fallback for unread
content would add.
The record rule compares ASAR storage beside the declaration and
`encrypted`, but no test recorded it on a metadata record. A changed
value is changed in metadata, and a value that only one side records
is unknown in availability, as for any occurrence.
@greptile-apps

greptile-apps Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5 Tier: plus

[Medium impact] The PR appears safe to merge; no new actionable issue remains.

Summary

Moves format-declaration comparison into one exhaustive table and adds UnityFS member comparison.

  • Artifact comparisons use declared facts from selected format metadata.
  • Symlinks no longer carry an executable marking in artifact inventories.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["Paired occurrences"] --> B["Project declarations through declarationsByKind"]
  B --> C["Compare readable facts and retain unknowns"]
  C --> D{"Both are metadata records without content?"}
  D -->|Yes| E["Compare declaration, encryption, executable marking and ASAR storage"]
  D -->|No| F["Compare content when available; retain known differences otherwise"]
  E --> G["Change rows and limitations"]
  F --> G
Loading

Reviews (3) · Last reviewed commit: "Merge upstream main into fix-artifacts-c..." · Reviewed by Greptile

Comment thread src/domain/artifactFormatMetadata.ts Resolved
The table compares a UnityFS member's declared source_path and raw
flags, but no comparison test covered the kind, so dropping either field
from the projection left every test passing. Bundles with equal member
bytes now show both: other flags, or a stored path in decomposed form,
compare changed in metadata, and reordered members, whose index and
stream offset are layout, compare unchanged.
The UnityFS comparison test arrived from the bottom branch with its own
copy of the open_binary, inspect_artifact and compare_artifacts steps,
which tests/fixtures/artifactComparisonMcp.ts owns here. It now uses
connectArtifactComparisonMcp() like the other comparison acceptance
tests. No assertion changes.
The docs conformance test found its paragraph by an exact English phrase,
so rewording "also compares" failed it while the named kinds stayed
right. The paragraph now sits between BEGIN and END markers, as the
allocation report's sections do, and the test reads the kinds between
them. It still requires exactly the kinds whose table entry has a
declaration.
Entrpi added a commit to Entrpi/rea that referenced this pull request Oct 31, 2026
@Entrpi
Entrpi marked this pull request as ready for review October 31, 2026 00:20
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-31T00:21:01.251003Z
We couldn’t complete this review. Comment @codex review to try again.
6b59c4f Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@morluto

morluto commented Oct 31, 2026

Copy link
Copy Markdown
Owner

@Entrpi, could you please resolve the merge conflicts with main on this PR?

Main's ESP flash layout (morluto#8228) and UF2/DfuSe (morluto#8230) changes compare
their declarations through per-format fields. Here the table owns every
kind: esp-flash-partition and dfuse-element get table entries with
main's fields, and esp-flash-image, uf2, uf2-region and dfuse state why
they are not compared. espFlashPartitionDeclaration, its
EspFlashPartitionMetadata alias, dfuSeElementDeclaration and
pyInstallerEntryDeclaration are removed, and the contract paragraph names
the two compared kinds.
@Entrpi

Entrpi commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for merging #8227–#8230. c0ed34063 merges main (6e9ee97a6) and resolves the conflicts. The table now also decides the kinds that #8228 and #8230 added. esp-flash-partition and dfuse-element compare the same fields as on main. esp-flash-image, uf2, uf2-region and dfuse stay uncompared. CI passes on the new head. #7486, #7487 and #7951 now merge main too, so none of our four open PRs has conflicts.

Entrpi added a commit to Entrpi/rea that referenced this pull request Oct 31, 2026
@morluto
morluto merged commit c13dd36 into morluto:main Nov 4, 2026
46 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants