Skip to content

Settle the ParsedResult information model before the port (#391) - #435

Merged
tylervick merged 13 commits into
mainfrom
tylervick/legacy-migration-391
Aug 12, 2026
Merged

tylervick merged 13 commits into
mainfrom
tylervick/legacy-migration-391

Conversation

@tylervick

@tylervick tylervick commented Aug 11, 2026

Copy link
Copy Markdown
Member

Documentation only. Settles the information model for #391 before any code exists, and propagates the consequences through the plan. No implementation — nothing under Sources/ or Tests/ changes.

What needs review: the eight decisions

ParsedResult is the artifact this migration would otherwise build twice. Shaped so today's templates render unchanged, it isn't backend-neutral — it's legacy-shaped, and the modern reader spends its life supplying nil for fields that exist only because the old UI reads them. Task 2.5's recommendations, applied as written:

# Question Answer Why
1 Per-activity durations? No — drop finish Modern publishes startTime only. No duration beats a fabricated (0.00s).
2 Five activity-type states? No — drop activityType, keep isFailure The one useful state (userCreated) has no modern source at any fidelity.
3 ObjectClass in the model? No — delete it IDESchemeActionTestSummaryGroup is an Xcode internal rendered into a CSS class.
4 Attachment UTI as a field? NofilenameExtension only AttachmentType needs only enough to pick a template and a MIME type.
5 Status as a legacy raw string? No — neutral enum A raw string forces the modern reader to emit legacy spellings it never saw.
6 Swift Testing Arguments? Yes, now The only addition inside the tree; adding the slot later reshapes the port.
7 Insights / metrics? No, not now Top-level and additive later. Cheap to defer, no empty slots.
8 Duration sums repetitions? Yes — keep today's behaviour Modern's own node value isn't the sum; summing makes both backends agree.

I agreed with all eight, so nothing was applied over an objection.

The direction is the point. Each answer removes a field from the port or an entry from the differential allow-list. Holding the legacy backend down to the modern backend's capability makes the two agree by construction rather than by mask — and the masked region is exactly where a regression hides. The allow-list drops from five entries to three: activityTypeClasses and durations are gone because there is no longer a field to diverge. The cost is honest and one-way: legacy stops rendering things it could have. That's a 4.0 behaviour change, made once and visible in the model.

Two constraints found while applying, not assumed away

Answer 6 is unexercised by any fixture. Arguments is in the published TestNodeType enum (xcresulttool get test-results tests --schema), but SwiftTestingSuite has no parameterized case and all three bundles contain zero Arguments nodes. Adding the field on the strength of a schema is defensible — it's a tree reshape, expensive later — but shipping a field nothing populates and nothing checks is not. Task 8 gains a step that adds a parameterized @Test to the sample app, and its test fails until that lands, with an explicit instruction to remove the field and revisit answer 6 if the nodes don't appear.

Answer 4 can't use UTType(filenameExtension:) — macOS 11+ against a 10.15 floor. The mapping keeps an explicit table, matching how Attachment.swift already guards UTType for MIME types.

Two things the rebase exposed

origin/main brought in #430 (deterministic report identifiers), and Task 5 still said Activity.uuid becomes UUID().uuidString. That would compile and silently undo #430ReproducibilityTests.testRenderingTheSameBundleTwiceProducesIdenticalBytes asserts byte-identical renders, and a random id per render breaks it immediately. Activity now mints from IdentifierPath like every other model, which also satisfies the rule this task exists to enforce: a path-derived id is backend-neutral, where an activity uuid would have been legacy-only.

Removing the activity-type and duration fields orphans two template placeholders. They're fed empty strings rather than deleted, because createTemplates.sh — the generator named in HTMLTemplates.swift's own DO-NOT-EDIT header — is not in this repository. That leaves a cosmetic empty paren on activity rows (Some activity ()), which I've routed to the redesign workstream rather than hand-editing generated code. Still better than (0.00s): one is untidy, the other is a fabricated number.

Everything else is mechanical propagation

Task 3's model, Task 4 (legacy reader), Tasks 8–10 (modern reader), Task 5 (renderer), Task 12 (allow-list and masker), plus the spec's "not a superset" table, where voided rows now read dropped (a decision) rather than lost (an asymmetry). Task 10 is reframed — attachment typing was written as a modern-only workaround; under answer 4 it's how both backends type, so it gains a cross-backend agreement test.

Verified mechanically rather than by eye: every ParsedActivity/ParsedIteration/ParsedTestCase/ParsedAttachment construction in the plan matches the amended shape, the allow-list JSON parses, and its three rules have exactly three masker implementations with no orphans either way.

What happens after this merges

This merges documentation. Implementation starts after, following the repo's existing convention: one worktree per phase, branches as tylervick/<slug>-<issue>, one PR per phase, main green throughout. Task 2.5 is complete and its steps are checked off; Task 3 is the first code.

Verification

Rebased onto origin/main (was 4 behind, clean — the branch touches only docs/). The plan's own Task 1 Step 0 now passes: IdentifierPath.swift present, ReproducibilityTests green at 5 tests / 0 failures.

Full suite after regenerating fixtures: 28 tests, 1 skipped, 0 failures, exit 0.

The regeneration was necessary, and the reason is worth recording. Before it, three tests failed — testAttachmentsExist, testDownsizedAttachmentsExist, testResultStatusCount (16 vs 17). Nothing to do with this diff: #428 added FirstSuite.testAttachScreenshot to the sample app at 00:10, and my bundles were generated at 22:16 the night before. Stale fixtures, not a regression.

One self-inflicted note: my first run reported EXIT=0 while carrying those three failures, because it piped through tail and read tail's status. That is exactly the trap this plan documents as a Global Constraint — I wrote the warning and then walked straight into it, which is a reasonable argument that the constraint earns its place. The verified run above uses set -o pipefail.

Refs #391. Milestone 4.0.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added a design specification for migrating result processing from legacy tooling to modern and legacy-compatible formats.
    • Documented backend selection, status handling, repetitions, failures, attachments, and fault behavior.
    • Defined a replacement JSON output schema and clarified compatibility expectations.
    • Added plans for differential testing, deterministic identifiers, CI coverage, phased implementation, and migration risks.
    • Clarified scope and future considerations for result-processing improvements.

tylervick and others added 9 commits August 11, 2026 14:47
Records the strategy decision and the evidence behind it: read the new
xcresulttool format directly and drop XCResultKit, behind a reader
abstraction that keeps the legacy path alive until Apple removes it.

The central finding is that the new format is not a superset of the
legacy one. Activity types and finish times, user-supplied attachment
names, attachment UTIs, structured failure locations, and log
emittedOutput all have no new-format equivalent, and exportRecursiveJson
has no replacement at all. Byte-identical output across the two backends
is therefore not achievable, so the bar is a declared and CI-asserted
diff rather than an empty one.

Every measurement in the spec was taken on freshly generated fixtures
under Xcode 26.2 rather than inferred, including two that shape the
plan: reports are not byte-reproducible (18 differing lines on the
smallest fixture, all synthetic UUIDs, identical after one regex), and
the two read paths are equal within noise on a same-runner interleaved
A/B, so no performance claim is made in either direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifteen tasks, each ending in a green test run and a commit, following the
spec's phasing: extract a backend-neutral port, add the modern reader, prove
parity differentially, then flip selection on.

Two ordering decisions carry most of the risk reduction. The UUID normalizer
and a pre-refactor baseline capture land before the large renderer refactor,
so "behaviour-preserving" is something the implementer diffs rather than
asserts. And the differential harness lands with the migration rather than
after it, because it only works while xcresulttool still supports both
formats.

Fixtures are regenerated on every CI run, so checked-in golden HTML is
impossible; every comparison in the plan is between two renders produced
within one run. Both assertions that could pass on empty input are paired
with an explicit non-vacuity guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The differential test was the weak point. It only checked that declared
markers appeared on legacy and vanished on modern, which says nothing about
lines nobody declared — an undeclared regression would have passed. Checking
the marker strings against HTMLTemplates.swift also showed three of them did
not exist: activity durations render as a bare "(0.00s)" suffix and attachment
names as bare text, with no class to key on.

Replaced with masking: strip exactly the declared losses from both renders and
require what remains to be byte-identical. That is implementable against the
real markup and is a stronger claim than the marker match ever was. Each rule
maps 1:1 to an allow-list entry, and the masked comparison asserts every test
title survives masking so an over-broad mask cannot make it pass vacuously.

Baseline capture no longer skips missing fixtures. It previously continued past
one, which would let Task 5 diff two partial directories and report them
identical — the same vacuous-verification shape.

Also from review: the attachment export leaked its temp directory (a full copy
of every screen recording) on every run; attachment comparison used Set<Data>,
which collapses duplicates; testNodes and testRuns were non-optional and would
throw keyNotFound on a bundle where xcresulttool omits the key; a failed
activities query returned an empty list with no fault, so a visibly gutted
report would exit 0; and Task 5 quoted a stale expected test count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three more from review. The --result-reader option was declared on
SummaryOptions but never passed into Summary, so it would have parsed,
validated, and done nothing; the end-to-end check now renders through both
readers and requires the wrapper-group counts to differ, which fails if the
flag is not reaching Summary.

ResultBackend.resolved() only consulted the version string, so an explicit
--result-reader legacy on a post-removal toolchain would have selected a
backend that cannot work. It now demotes to modern with a warning, which is
the degradation rule the spec already stated. The accompanying test is
conditional on toolchain capability rather than asserting .legacy -> .legacy
outright, since that assertion would start failing exactly when the fallback
becomes load-bearing.

The spec listed read() as throwing while the plan returned an optional. Settled
on the optional, matching the existing getInvocationRecord() contract that
Summary.init already guards with a fault, and documented why the sub-level
failures are the ones that needed a new fault kind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan recorded .missingActivities when an activities query fails, but
nothing asserted the fault reaches summary.faults. Since a failed query
degrades to an empty activity list rather than aborting, unproven plumbing
means the CLI could exit 0 on a report whose tests have no activities — the
exact outcome the fault exists to prevent.

Added an XCResultToolInvoking seam so a client that fails only on `activities`
can be injected, and a test that reads through it and asserts both halves: the
read still succeeds, and the fault lands on the caller's collector. The
collector is the one Summary.init owns, so the existing exit-3 path covers the
rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three more from review, two of them substantive.

The modern reader was sourcing failure text from the activities document. The
tests tree's Failure Message nodes are strictly better: measured on
TestResults, they give "FirstSuite.swift:66: XCTAssertTrue failed - Test
failed" where the activity title gives only "XCTAssertTrue failed - Test
failed". The plan was reading from the lossier of two available sources and
the spec described that loss as unavoidable. Both corrected; skip reasons ride
the same node.

The modern reader also collapsed every destination to devices.first, where
legacy emits one run per ActionRecord. Every fixture boots a single simulator,
so no test would have caught the difference — and the differential's zip()
truncates to the shorter sequence, so it would have compared the runs that did
exist and passed. Now one run per device, with an explicit run-count assertion
before the zip, and the coverage gap stated in both documents rather than
implied to be tested.

Also: the legacy-capability probe never drained stderr, which deadlocks if the
pipe fills — the same bug the codebase already documents in TestSupport and
XCResultToolClient.run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four amendments to the design spec and implementation plan, none of which
change the strategy.

**Milestone.** Both documents targeted 3.0, inherited from #391's label.
3.0.0 shipped 2026-08-07, so the breaking changes here — the `--json` schema
and the declared output diff — land in 4.0. Notes that the report redesign is
a sibling workstream in that same major rather than a later release, since
`activityType`'s removal breaks the visual contract either way, and that the
two are strictly sequenced: templates stay frozen until the differential is
proven.

**#430 replaces Task 1's normalizer, but does not retire it.** Task 1 had
rediscovered #411 independently and solved it in the harness with a UUID
regex. #430 fixes it in the product via `IdentifierPath`. The subtlety worth
recording: identifiers are a digest of each element's *structural path*, and
the two backends disagree on structure — the modern tree drops the "All tests"
and "<bundle>.xctest" wrapper levels — so cross-backend identifiers still
diverge. The normalizer survives, retargeted from RFC-4122 to `[0-9a-f]{32}`;
a regex left matching UUIDs would silently match nothing and the differential
would compare raw digests and fail on every run.

Task 1 also collided with #430 on `ReproducibilityTests.swift`. It now appends
to that file instead of creating it, and #430 is a stated prerequisite.

**Task 2 captures raw renders.** Same-backend renders are byte-identical after
#430, so normalizing the baseline is unnecessary — and harmful: Task 5 moves
the renderer onto `ParsedResult`, and a refactor that perturbed the tree would
move every affected digest, which a normalized baseline would hide. Phase 1's
gate is correspondingly strengthened to exact equality with no normalization.

**HTMLTemplates.swift is not generated.** Both documents asserted it was.
Its `DO NOT EDIT … autogenerated by createTemplates.sh` header is stale —
that script was deleted in #295 — and both linters exempt the file on that
basis, which is how `HTML/*.html` drifted 28 hunks behind while the "generated"
file was hand-edited. No consequence for this work, which touches neither; it
is a live trap for the redesign workstream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ParsedResult` is the one artifact this migration could build twice. Shaped so
the current templates render unchanged, it is not backend-neutral — it is
legacy-shaped, and `ModernResultReader` spends its life supplying nil for
fields that exist only because the old UI reads them. The proposed model
already shows the pattern: `activityType`, `finish`, `name`, and
`uniformTypeIdentifier` are each documented as "nil on the modern backend",
and `statusRawValue` would have the modern reader emit legacy spellings it
never saw.

Adds Task 2.5 between the baseline capture and the model: a decision task, not
a code task, with eight questions and a recommended answer for each. The
redesign's visual work stays a sibling workstream and does not gate this one —
but its information model is exactly what the port encodes, and that is an
afternoon rather than a design phase.

Every answer either removes a field from the port or an entry from the
differential allow-list, because holding the legacy backend down to the modern
backend's capability makes the two agree and an unmasked diff proves more than
a masked one. On the recommended defaults, `activityTypeClasses` and
`durations` leave the allow-list entirely: with no `finish` and no
`activityType` in the model, there is no divergence left to mask. The cost is
one-way and stated — the legacy backend stops rendering some things it could
have — but that is a 4.0 behaviour change made once and visible in the model.

Two answers go the other way for reasons worth keeping: Swift Testing
`Arguments` is added now because it is a reshape *inside* the tree and
expensive to retrofit, while insights and metrics are left out because they
attach at the top level beside `runs` and are cheap to add when something
renders them.

Also records the rule the task exists to enforce: no reader code whose only
purpose is to satisfy the render-level diff. The current templates are a
verification scaffold with a retirement date, not a compatibility target.

Updates both #430 prerequisite notes — merged 2026-08-11 as a28b131.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applies Task 2.5's eight recommendations as written and propagates them
through the port, the readers, the renderer task, and the differential.

Three fields leave ParsedResult (activity finish, activity type, attachment
UTI), one arrives (Swift Testing arguments), one is retyped (status raw string
becomes a neutral enum), and ObjectClass is deleted rather than threaded
through. The direction is the point: each removal makes the two backends agree
by construction instead of by mask, so the differential allow-list drops from
five entries to three. An unmasked diff proves more than a masked one, and the
masked region is exactly where a regression can hide.

Two constraints surfaced while applying the answers, both recorded rather than
assumed away. Answer 6's `arguments` is unexercised: `Arguments` is in the
published TestNodeType enum, but SwiftTestingSuite has no parameterized case
and all three fixtures contain zero such nodes, so Task 8 gains a step that
adds one and a test that fails until it lands. Answer 4 cannot use
UTType(filenameExtension:), which is macOS 11+ against a 10.15 floor, so the
mapping keeps an explicit table.

Rebasing onto #430 also exposed stale guidance in Task 5: it had Activity.uuid
becoming UUID().uuidString, which would compile and silently undo the
reproducibility #430 just established. Activity now mints from IdentifierPath
like every other model.

Removing the activity-type and duration fields leaves two template
placeholders fed with empty strings rather than deleted, because
createTemplates.sh — the generator named in HTMLTemplates.swift's own
DO-NOT-EDIT header — is not in this repository. That leaves a cosmetic empty
paren on activity rows, noted for the redesign workstream, and is still better
than rendering a fabricated (0.00s).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tylervick tylervick added this to the 4.0 milestone Aug 11, 2026
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 31e83297-06b2-4ed4-a845-1f5052b49024

📥 Commits

Reviewing files that changed from the base of the PR and between edf432b and 4462b34.

📒 Files selected for processing (2)
  • docs/superpowers/plans/2026-08-10-xcresulttool-legacy-migration.md
  • docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md

📝 Walkthrough

Walkthrough

The pull request adds a 4.0 design specification for replacing XCResultKit and xcresulttool --legacy with dual readers. It defines a neutral ParsedResult model, backend selection, output rules, verification, and phased implementation.

Changes

XCResultTool migration

Layer / File(s) Summary
Reader architecture and parsed model
docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md
Defines legacy and modern readers, backend detection, fallback behavior, fault handling, schema differences, and the backend-neutral ParsedResult model.
Parity rules and JSON output
docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md
Defines attachment export, status mapping, repetition handling, failure-message sourcing, identifier normalization, and the replacement versioned --json schema.
Verification and phased rollout
docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md
Defines differential tests, CI coverage, implementation phases, scope exclusions, and migration risk handling.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: settling the ParsedResult information model before implementing the migration.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tylervick/legacy-migration-391

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md`:
- Around line 77-80: The specification must explicitly name the three remaining
differential allow-list entries. Add a normative list near the statement that
five entries become three, mapping each entry to its fixture and specifying the
exact selector and differences that remain unmasked; align it with the listed
losses such as attachment names, failure structure, log output, tree shape, and
display names.
- Around line 229-231: Revise the legacy-command error handling so the backend
falls back to modern only for errors that explicitly confirm legacy capability
is unavailable. In the legacy execution flow, propagate corrupt bundles,
permission errors, malformed results, and subprocess failures through
FaultCollector instead of demoting unconditionally; preserve successful legacy
handling and the existing modern fallback for confirmed capability failures.
- Around line 381-388: Expand the “--json becomes our own schema” section to
define a normative public wire contract independent of ParsedResult internals.
Specify field names, enum encoding, null-versus-omitted behavior, duration
units, array ordering, schema versioning, and compatibility rules, including
representative JSON examples, and require the same contract from both backends
before Phase 5.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 407dcb2b-b946-478d-bf56-77e67cf75b47

📥 Commits

Reviewing files that changed from the base of the PR and between f717a1d and a836eb3.

📒 Files selected for processing (2)
  • docs/superpowers/plans/2026-08-10-xcresulttool-legacy-migration.md
  • docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md

Comment thread docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md Outdated
Comment thread docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md Outdated
Comment thread docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md Outdated
…require a --json contract

Three findings from review, all against the spec.

The demotion rule was written as "any hard failure of a legacy command demotes
to modern", which is both broader than the plan implements and a hazard: a
corrupt bundle or permission error would silently retry on the modern reader
and produce a partial report where a clear failure belonged. Demotion is now
scoped to capability detection only, with everything else propagating through
FaultCollector to the exit-3 path.

The spec claimed the allow-list drops from five entries to three without saying
which three. Since this record is what Task 12 is read against, it now names
them with what still differs and which fixture exercises each, plus the
standing instruction to prefer deleting a field from the port over adding an
entry.

--json was specified only as "our schema". ParsedResult is an internal Swift
model, so deriving public output from a synthesized Encodable would make every
later field rename a silent breaking change. Task 14 gains a step requiring the
wire contract be written first -- field names, enum encoding, null-versus-
omitted, units, ordering, and a schema version -- with the encoder made to
match the document rather than the reverse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md (1)

278-280: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Add arguments to the parity rules or defer the fixture.

The model adds ParsedTestCase.arguments, but the specification says legacy emits an empty value. When the planned parameterized fixture populates modern Arguments, the backends will differ on a fourth field even though the allow-list contains only three entries. Define equivalent legacy extraction, add a narrowly scoped arguments allow-list entry, or exclude that fixture from cross-backend parity until support exists. Also define argument ordering and stringification.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md`
around lines 278 - 280, Update the parity rules around the
ParsedTestCase.arguments model field to address parameterized fixtures: either
specify equivalent legacy argument extraction or add a narrowly scoped arguments
allow-list entry and exclude unsupported fixtures from cross-backend parity.
Explicitly define argument ordering and stringification, while preserving empty
arguments for legacy and non-parameterized tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md`:
- Around line 419-435: Clarify the --json acceptance criteria to require
identical versioned schema, field names, nesting, and encoding across both
backends, while explicitly permitting value differences in attachment display
names, failure titles, wrapper groups, and legacy-empty arguments. Update the
relevant Task 14 contract and parity sections, distinguishing schema identity
from value identity.
- Around line 229-232: Update the demotion behavior specification so only auto
may fall back to modern; an explicit legacy selection must remain legacy and
return a capability-unavailable result for the harness to skip or fail after
confirming the backend. Define unparseable version strings as unknown capability
detection rather than evidence that legacy support is unavailable, and preserve
the differential reader-forcing requirement.
- Around line 234-241: Update both payload providers, specifically
ResultFile.exportLogs and ModernPayloadStore.exportLogs, to record
FaultCollector’s .logExportFailed fault in their write-failure catch blocks
before returning nil. Add regression coverage verifying each backend records the
fault and produces the expected exit-3 behavior.

---

Outside diff comments:
In `@docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md`:
- Around line 278-280: Update the parity rules around the
ParsedTestCase.arguments model field to address parameterized fixtures: either
specify equivalent legacy argument extraction or add a narrowly scoped arguments
allow-list entry and exclude unsupported fixtures from cross-backend parity.
Explicitly define argument ordering and stringification, while preserving empty
arguments for legacy and non-parameterized tests.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fd31a6c4-2cb9-4eac-a7f4-318bbcdf801c

📥 Commits

Reviewing files that changed from the base of the PR and between a836eb3 and 4aa1bdd.

📒 Files selected for processing (2)
  • docs/superpowers/plans/2026-08-10-xcresulttool-legacy-migration.md
  • docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md

…cit legacy reader

Review caught a vacuous-verification hazard I had introduced. Making an
explicit --result-reader legacy fall back to modern meant a modern-only host
would run the modern reader twice and the differential would compare a backend
against itself and report parity -- the exact failure that suite exists to
prevent.

Detection is now tri-state (available / unavailable / unknown) and only `auto`
ever substitutes. An explicit legacy request that cannot be honoured is an
error; an unparseable version string is `unknown`, which degrades `auto` to
modern but lets an explicit legacy attempt proceed, since a string we cannot
read is not proof the commands are gone. requireBothBackends now asserts on the
backend it actually resolved rather than trusting the request.

--json parity was contradictory: the spec promised output "identical on both
backends" while the same document preserves three render-level differences.
Restated as schema identity -- same field names, nesting, enum encoding and
schemaVersion -- with the four permitted value differences enumerated and
everything else declared a reader bug. The accompanying test compared only
top-level keys, which cannot see a nested field present on one side; it now
compares full key paths.

Log write failures recorded no fault in either provider, so a report missing a
log it had successfully read would still exit 0. Both now record
.logExportFailed. The legacy one is pre-existing rather than introduced here,
but leaving one of two implementations silent is how that asymmetry survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md`:
- Line 422: Update the “failure activity titles” text in the migration design
document by removing the trailing space inside the inline code span, changing
the referenced prefix to end at “at” without an internal trailing space.
- Around line 415-429: Clarify the treatment of testCase.arguments in the
“Identical across backends” section: explicitly state whether differential tests
exclude parameterized cases or compare arguments separately as a documented
fixture limitation. Keep the differential allow-list limited to the three
existing render-level differences and reconcile the final statement accordingly.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ce78f37-aa67-447e-b860-2ab96764bbae

📥 Commits

Reviewing files that changed from the base of the PR and between 4aa1bdd and edf432b.

📒 Files selected for processing (2)
  • docs/superpowers/plans/2026-08-10-xcresulttool-legacy-migration.md
  • docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md

Comment thread docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md Outdated
Comment thread docs/superpowers/specs/2026-08-10-xcresulttool-legacy-migration-design.md Outdated
tylervick and others added 2 commits August 11, 2026 15:16
Review caught that --json listed four permitted value differences while the
allow-list has three entries, leaving testCase.arguments as an implicit fourth
rule. It is not one: nothing renders arguments, so it cannot appear in the HTML
differential at all and needs no masking rule. Now classified separately as a
model-level capability difference, with the instruction that --json compares it
by asserting legacy is empty rather than asserting the two sides match -- and a
note that until the parameterized @test lands, both sides are empty and a naive
equality assertion would pass vacuously.

Also corrects reasoning I got wrong in the previous commit. Task 5 justified
leaving the orphaned TIME and ITEM_CLASS placeholders by claiming the template
generator is missing and hand-editing is forbidden. The spec already establishes
the opposite: createTemplates.sh was deleted in #295, HTMLTemplates.swift is
hand-maintained, and #349 edited it directly. The outcome is unchanged --
templates stay untouched -- but because the Global Constraints scope this plan
out of report markup and the redesign workstream must first settle which
template copy is the source of truth, not because the edit is impossible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An xhigh multi-agent review found 30 distinct defects in #435, most of them
downstream of two wrong decisions. Correcting the decisions and re-deriving,
rather than patching the symptoms.

Decision 3 becomes replace-not-delete. Deleting ObjectClass empties ITEM_CLASS
and renders <div class=" failed"> on every row, which breaks the report's own
"show only failures" filter (showElementsWithSelector on .test-summary.failed
and siblings), group expand/collapse (querySelectorAll on .test-summary-group),
the stylesheet rules keyed on both classes, and four test call sites -- one in
CoreTests and three in ReproducibilityTests. The raw IDESchemeActionTest*
values were the legacy part and still go; the emitted class names are the
report's own contract and stay, behind a neutral renderer-side NodeKind. The
port needs no field, since ParsedNode already distinguishes group from case.

Decision 1 still removes the field, with two corrections. `start` is now named
as the replacement ordering key: the sort it fed interleaves failure rows among
activities so a failure renders where it occurred, and deleting it rather than
re-keying it would silently append every failure to the end. And the `durations`
allow-list entry is restored -- deleting it assumed removing `finish` removed
all duration divergence, but the surviving divergence is in group durations.
Verified: durationInSeconds is null on every Test Suite, Test Plan and test
bundle node in all three fixtures, while legacy reports FirstSuite 0.699s,
SecondSuite 0.126s, ThirdSuite 0.132s, SampleAppUnitTests 0.213s. wrapperGroups
only drops wrapper lines, so real suite headings were diverging unmasked.

Task 5 is split. It conflated a pure refactor with mandated behaviour changes
and then demanded a byte-identical gate, so the gate could only ever be waived
-- leaving the migration's largest refactor with no behaviour check. 5a moves
the renderer onto ParsedResult and must be byte-identical; 5b applies the
decisions that change output against an enumerated three-shape diff.

Also fixed, from the same review: failure rows were double-counted on modern
because both documents describe the same failure; the failureTitlePrefix mask
stripped only the legacy shape so every failing test still differed after
masking; read() handed every device the same testables array; an empty devices
list returned a non-nil empty result that exited 0 where legacy exits 3;
PayloadProviding omitted three members the call sites use, including the
downsize path from #428; Task 5 deleted three accessors LegacyResultReader
calls; Summary.init dropped the resultIndex/actionIndex seeding #430 needs;
ValidationError was thrown from a non-throwing init in a target without
ArgumentParser; legacyCapability returned false where the type wanted .unknown;
run??.activities double-chained a flattened optional; and Task 1 called a
render helper that does not exist.

Two refinements beyond the brief. The durations mask is necessarily
over-broad -- the duration sits on a different line from the group class, so it
cannot be scoped -- which also hides XCTest case durations that do agree, so a
targeted model-level assertion restores that coverage. And the empty-read rule
is stated for both readers rather than fixed only on the modern one, since
legacy has the identical shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tylervick

Copy link
Copy Markdown
Member Author

Worked the xhigh review. Both wrong decisions corrected and the propagation re-derived rather than patched; Task 5 split; all 15 findings addressed. Local suite green (28 tests, 1 skipped, 0 failures).

Everything factual below I re-ran against the committed fixtures on Xcode 26.2 rather than taking from the brief.

The two decisions

Decision 3 → replace, not delete. Confirmed, and the blast radius is wider than reported. ITEM_CLASS empty renders <div class=" failed">, which breaks:

  • showElementsWithSelector('.run.active .test-summary.succeeded') and its .skipped / .failed / .mixed siblings — the "show only failures" filter matches nothing
  • querySelectorAll('.run.active .test-summary-group') and the classList.contains('test-summary-group') filter under it — group collapse
  • stylesheet rules on both classes (.test-summary p, .test-summary-group > p, .test-summary.<status> .test-result-icon, …)
  • four test call sites, not one: CoreTests.swift:122, and ReproducibilityTests.swift 138, 174, 175

The raw IDESchemeActionTest* values were the legacy part and still go. The emitted class names are the report's own contract and stay, behind a renderer-side NodeKind. The port needs no field — ParsedNode already distinguishes group from case — so this also keeps the "no legacy shape in the model" rule intact.

Decision 1 → field still goes, two corrections. start is now named as the replacement ordering key, in the spec and in Task 4, with the invariant spelled out: that sort interleaves failure rows among activities so a failure renders where it occurred.

durations is back on the allow-list. Verified across all three fixtures — durationInSeconds is null on every Test Suite, Test Plan, UI test bundle and Unit test bundle node, against real legacy values:

Group Legacy Modern
FirstSuite 0.699s null0.00s
SecondSuite 0.126s null0.00s
ThirdSuite 0.132s null0.00s
SampleAppUnitTests 0.213s null0.00s

Swift Testing test cases are null the same way. wrapperGroups only drops lines containing Selected tests/All tests/.xctest, so these were diverging unmasked. Allow-list is four entries; every "three" updated in both documents.

Task 5 split

5a moves the renderer onto ParsedResult and must be byte-identical against the Task 2 baseline. 5b applies the decisions that change output, gated on an enumerated diff of exactly three shapes (activity class loses its type token, activity row loses (1.23s), activity id becomes a path digest). The spec's phase 1 split the same way.

ObjectClass → NodeKind moved into 5a, since it now preserves bytes. Activity.uuid → IdentifierPath moved into 5b, since it changes them.

Findings 5–15

All addressed. Notable ones: the failure double-count is now deduped on the message tail (both documents describe the same failure — activities gives XCTAssertTrue failed - Test failed, tests gives FirstSuite.swift:86: XCTAssertTrue failed - Test failed); failureTitlePrefix strips both shapes; PayloadProviding gains url plus the two content helpers as a protocol extension, with a note that keeping ResultFile at those sites to get a build would silently vanish screenshots and videos; the three XCResultKit accessors stay on ResultFile; Summary.init keeps its resultIndex/actionIndex seeding.

Two things I did beyond the brief

The durations mask is necessarily over-broad, and that costs coverage. The duration sits on a different line from the test-summary-group class, so it cannot be scoped by line — normalising all (N.NNs) therefore also hides XCTest case durations, which do agree. Added testXCTestCaseDurationsAgreeAcrossBackends, asserting on the model, to restore exactly that coverage, with both sides commented not to delete one without the other.

Finding 8's rule applies to the legacy reader too. The brief asked for a stated rule covering every reader, and fixing only ModernResultReader would have left the identical hole: LegacyResultReader.read() returns ParsedResult(runs: []) when no ActionRecord parses, which satisfies Summary.init's guard, records no fault, and exits 0. Both readers now return nil, and the spec states it as a contract rather than a case fix.

Disagreements

None on the findings themselves — all 15 reproduced. One qualification on finding 7: the per-run split is now structured (testables(on:from:)) but still returns the whole tree, because no fixture has two destinations and Device is itself a TestNodeType, so the nested shape is unverified. I have written that in the comment rather than implementing a filter I cannot test. Implementing it blind would be worse than the current honest placeholder — but it does mean finding 7 is structurally addressed rather than behaviourally fixed, and it should not be marked resolved without a two-destination bundle.

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.

1 participant