Conversation
) Research only, no codec code. Records the depend-vs-vendor decision that #16 builds on. Decision: vendor a pure-Swift block codec in `Snappy`; #5 stays the exit. Two premises in the tickets turned out to be wrong, and both are corrected in the writeup: - #5 assumed stock libraries expose only the *framing* layer and keep the block codec internal. False — google/snappy's C API (`snappy-c.h`) is block-level only and exposes no framing at all, and `codelynx/snappy-swift` is likewise block-only. Block-level access is common; a *trustworthy* package is not. - The "stock stream libraries reject .iwa" claim is now verified from the bytes rather than assumed. Measured directly from research/fixtures (no vendor library involved): Apple's chunk header is 4 bytes (0x00 + 3-byte LE length), there is no sNaPpY stream identifier and no CRC-32C anywhere, chunk payloads are stock Snappy blocks, and all 25 .iwa files decode with a plain block decompressor. Chunks cap at exactly 65536 bytes; the encoder emits only literal/copy1/copy2 (never copy4), max back-reference 60980. The leading candidate (codelynx/snappy-swift) was built on Swift 6.4 and tested against real Keynote bytes: 30/30 chunks decompressed and round-tripped, 207/207 malformed inputs threw cleanly without trapping. So the rejection is not about quality — it is supply chain: 2 stars, one author, a single squashed commit, no CI, three months old, and its product name collides with our own `Snappy`. Also fixes the vendored-schema proto count in Step 1 (35 -> 33; 34 compiled including compat/14.4/TSKArchives_sos.proto), verified by counting the tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Generate the acceptance/differential goldens from the committed JSON specs via the Python reference backend and commit them under research/goldens/ so the Swift writer (#20) has a fixed target. research/samples/ is gitignored, so the goldens needed a committed home of their own. Ran the three staleness checks first (prepare-keynote-parser, test, verify-pack) to confirm the specs are still valid against the current backend. Document regeneration in research/findings/goldens.md, including why a one-slide spec yields a ~460 KB, 18-slide deck: deckkit.build() creates the base via AppleScript `make new document`, which inherits Keynote's default theme and its master slides and stock imagery. That is expected, not a defect -- the Python backend has no blank-theme path, and the minimal template is #21. Closes #19 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Vendor Keynote 15.3 schema and 14.4 registry (#14) Check in protoc-generated Swift for the 33 vendored Keynote 15.3 protos plus the 14.4 `TSKArchives_sos.proto` the registry still references, and port `TSPRegistryMapping` alongside them. Consumers never run `protoc` and `Package.swift` gains no build-tool plugin. `TSPRegistryMapping` exposes the 631-entry id -> message-name table and resolves each name to its generated Swift metatype. The table is not injective — 631 identifiers name 624 distinct messages — so it is modelled as `[UInt32: String]` and never inverted. Decoding is partial. The 15.3 protos mark 1,497 fields `required`, so a strict parse of a real component would reject documents Keynote itself round-trips; pairing a 15.3 schema with a 14.4 registry makes that worse. Generated code is excluded from lint rather than the rules being relaxed: `.swiftlint.yml` gains one `excluded:` entry and the generated directory carries its own empty `.swift-format`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Delete Sources/KeynoteKitProtobuf/Generated/.swift-format * Record the partial-decoding constraint in the decision log The 15.3 protos mark 1,497 fields `required`, but Keynote does not populate all of them, so a strict proto2 parse throws `.missingRequiredFields` on components Keynote itself round-trips. Partial decoding is therefore correct rather than a workaround, and the constraint applies to any archive decode — not just registry lookups — so #17/#18 inherit it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Executes issue #5's first concrete task: a documentation-only survey of Swift Snappy packages exposing block-level compress/decompress, one day after the vendored codec (#16) landed. Re-verifies the #15 candidates and covers two repos that appeared since; corrects two minor factual claims in the #15 record (codelynx commit count and product name). Recommendation, recorded as PROPOSED in PLAN.md's decision log pending Leo's review: stay vendored (option c). No package clears both the capability and trust bars; extraction (option b) is deferred until a second consumer exists. Exit conditions documented in the survey. Part of #5 — the issue stays open for the eventual swap decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#35) * Add typed scripting-dictionary values for the Keynote escape hatch (#10) Portable value layer for KeynoteKitScripting: AppleEventCode (four-char OSType), the full 44-effect TransitionEffect catalog, the four-field TransitionSettings record, ExportFormat, CloseBehavior, and KeynoteScriptingError (mapping Apple event error -1743 to a typed Automation-denial case). All identities — AppleScript enumerator terms, four-character event codes, and Cocoa string values — are transcribed from Keynote 15.3's Keynote.sdef and cross-checked against the Phase 1 research (research/tools/deckkit.py EFFECTS table). Pure stdlib, no Foundation and no ScriptingBridge import, so this layer compiles and its tests run on every CI platform. Doc comments record what is deliberately absent because Keynote never made it scriptable: object builds, transition direction, and the per-effect custom* options. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add the ScriptingBridge surface driving a live Keynote (#10) The macOS-only half of the escape hatch, gated on #if canImport(ScriptingBridge) so the package still builds (to the value types) on Linux, wasm, Windows, and Android: - Hand-declared @objc protocols for the minimal Keynote sdef surface (application, document, slide, text item) instead of a generated header dump; SBApplication/SBObject conform via empty extensions and resolve the selectors dynamically. - Wrappers for the proven deckkit.py flow: make new document/slide/text item (create + element-array add + explicit setters, sidestepping the known "make new … with properties" ScriptingBridge gaps), transition properties get/set, save in POSIX file, export (PDF/QuickTime/etc.), and close saving no. - An SBApplicationDelegate error monitor that turns silent Apple event failures into typed KeynoteScriptingError values. Unit tests run without Keynote (record lowering, error paths, an uninstalled-bundle failure). The live integration test launches and scripts a real Keynote, so it is skipped unless KEYNOTEKIT_LIVE_KEYNOTE=1 is exported; live verification of the ScriptingBridge record keying remains open on #10. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: CI <ci@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…fferential gate (#24) (#34) * Acceptance decks scripted: DSL catalog, swift run AcceptanceDecks, differential gate (#24) The AFK half of #24. AcceptanceDeckCatalog expresses the five acceptance decks (4 bisect cases + build_acceptance) in the public DSL — Action via .action { MotionPath() }, direction via .direction(.moveInNonDefault) — mirroring the committed specs. `swift run AcceptanceDecks [dir]` writes them for the human pass and self-checks each (every record decodes, both SIGTRAP invariants hold) before printing the PLAN Step 6 checklist. AcceptanceDeckTests gate the catalog three ways per deck: the lowering equals DeckSpec.load of the committed spec; authoring the lowered model into the stripped golden reproduces the golden archive graph; and the full template write path emits a structurally valid deck. Verified the gate can fail by perturbing one spec value. The Keynote 15.3 open pass and the v0.1.0 tag stay human-in-the-loop. Roadmap current-position updated for the post-PR-#32 state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SlideBuilder is inferred on body, as in SwiftUI @slidebuilder now annotates the SlideContent.body requirement itself, so conformances get builder inference automatically instead of opting in at each use site. The primitives' unreachable Never bodies become real compositions (Slide and SlideGroup compose to themselves) since the inferred builder cannot produce Never. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Roadmap: full session handoff in the current-position note Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * agent-notes: session state always lands in versioned files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Handoff: #34 landed; frontier is Keynote open pass + v0.1.0 tag. Refresh PARALLEL-WORKTREES current-position and the v0.1.0 progress memory so the next session starts at the human Keynote 15.3 acceptance gate. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: CI <ci@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
- research/findings/acceptance_keynote_open.md: expanded-pass section (9 decks incl. text_runs, regression + drawable-depth render columns, #10 live-verify extra, tag + merge-to-main steps) - .claude/PLAN.md, .claude/PARALLEL-WORKTREES.md, .claude/agent-notes.md: current position after PR #39 and PR #41 merges (drawable depth, mixed runs, TextBox/Text DSL rename) and issue housekeeping Co-authored-by: CI <ci@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code review — PR #43 (
|
| Axis | Findings | Worst |
|---|---|---|
| Standards | 0 hard · 1 soft footgun · 3 judgement smells | Message-chain / optional-field materialization risk in drawable surgery |
| Spec | 2 partial · 1 intentional scope (scripting) · 2 stale docs | #24 human acceptance still the only v0.1.0 tag gate; MotionPath public API is sharp-only vs “full bezier” PLAN wording |
Merge posture: code/product shape matches the v0.1.0 PLAN gates that are automatable. Do not treat merge of this PR as the v0.1.0 tag — finish the expanded #24 checklist in research/findings/acceptance_keynote_open.md first. Before tag, also fix the two stale public-API doc comments and clarify whether sharp-only MotionPath satisfies the PLAN’s “full bezier” decision or needs a follow-up ticket.
Code Review: PR #43 (
|
Co-authored-by: Cursor <cursoragent@cursor.com>
leogdion
left a comment
There was a problem hiding this comment.
Code review: v0.1.0 (v0.1.x → main)
Overview & scope
This PR lands the entire v0.1.0 release: a Swift package that authors Keynote .key files by byte-surgery on a bundled template, with no Python/keynote-parser/AppleScript at runtime. Of the 659 files / ~326k added lines, only ~20k lines are hand-written — the rest is generated protobuf (Sources/KeynoteKitProtobuf/Generated/), vendored protos, and research/ artifacts. This review covers the hand-written code: Snappy, IWAFraming, KeynoteKitProtobuf (hand-written parts), KeynoteArchiveNavigation, the KeynoteKit surgeon + DSL layers, KeynoteKitScripting, the acceptance-deck targets, tests, and build/CI configuration. Every finding below was verified against the actual code, and severities are calibrated to the project's own documented invariants (.claude/agent-notes.md, .claude/PLAN.md).
Overall assessment: the architecture is strong — clean layer boundaries, research-traceable constants, hard-won crash invariants encoded in code and enforced by a genuinely failable UUIDMapVerifier, and tests that assert archive structure rather than smoke. There is 1 critical bug (multi-image identifier collision), a handful of majors that cluster into three themes — 32-bit / malformed-input trap paths, bundled-template assumptions leaking into the public arbitrary-base API, and DSL result-builder gaps — plus targeted test-coverage and CI-hygiene items.
🔴 Critical
C1. Two images on one slide collide on the same TSP.DataInfo identifier
Sources/KeynoteKit/KeynoteArchiveSurgeon+ImageSupply.swift:175
mintImageDrawable allocates data: try nextDataIdentifier(), and nextDataIdentifier() (KeynoteArchiveSurgeon+DataIdentifiers.swift:45-58) computes metadata.datas.map(\.identifier).max() ?? 0 + 1 from TSP.PackageMetadata. But in expandDrawables the minted DataInfo rows accumulate in pending.infos and are only registered into metadata after the per-item loop. Two .image items on the same slide therefore both read the same pre-loop maximum and mint the same data identifier: two metadata.datas rows sharing one id, two Data/ zip members whose filenames embed the same id, and both TSD.ImageArchive.data references resolving to whichever row wins. Cross-slide allocation is safe only by accident (each slide registers before the next runs). ImageAuthoringTests only ever exercises one image per slide, so this is currently invisible to the suite. Fix: register (or at least reserve) each identifier as it is minted; add a two-images-one-slide test.
🟠 Major
Surgeon layer — template assumptions leaking into the public arbitrary-base API
KeynoteTemplate.init(contentsOf:) is public, so any Keynote-written (or previously KeynoteKit-written) document is a supported base. Several code paths are only correct for the bundled blank template:
- M1.
RecordCloner.remapSlideListsdoes not remapownedDrawables(Sources/KeynoteKit/RecordCloner.swift:113-123). The rewrite inventory coversdrawablesZOrder,builds, andbuildChunks— but the surgeon itself writesslide.ownedDrawablesfor authored images (ImageSupply.swift:144). Cloning a slide that contains an inserted image leaves the clone'sownedDrawablespointing at the original slide's drawables whiledrawablesZOrderpoints at the clones — a cross-slide shared/dangling-ownership violation of exactly the class PLAN treats as crash-inducing. (thumbnailsand note-storage attachment tables are also outside the inventory, though those are documented as template-measured scope.) - M2.
expandTextItems' clone path skipsobjectUuidMapEntriesregistration (Sources/KeynoteKit/KeynoteArchiveSurgeon+Supply.swift:51-75).cloneBodyPlaceholderappends cloned subtree records to the slide member, but nothing mints uuid-map entries for them — unlike the siblingexpandDrawablespath (ImageSupply.swift:85-94), which diff-scans and registers every new record. This directly violates the standing invariant (agent-notes 2026-07-31: "Every record added to a slide member needs a fresh-uuidobjectUuidMapEntriesrow") — the documented failure mode is blank component load or NSSet-nil crash.SupplyTests.oversizedTextItemCountpasses only becauseUUIDMapVerifierchecks builds, not all minted records. - M3. Metadata registration helpers silently no-op on a missing component (
Sources/KeynoteKit/KeynoteArchiveSurgeon+Metadata.swift:80-90, 104-111, 170-175).registerUUIDEntries(_:componentStem:),registerDataObjectReference, and the per-reference lookup inregisterExternalReferencesreturn/continuewhen no component matches the stem. On a base whose stylesheet member isn't namedDocumentStylesheet, minted style edges and thumbnail data references vanish without error — the exact "silent component load failure / crashes layout" modes the findings docs describe. These should throw, asregisterUUIDEntries(_:slideIdentifier:)already does. - M4. Partial bundle mutation when
authorthrows midway (Sources/KeynoteKit/KeynoteArchiveSurgeon+SlideCloning.swift:63-69,KeynoteArchiveSurgeon.swift:72-115).cloneLastSlideinserts the newIndex/Slide-<id>.iwaentry with an empty body, filled only by the final serialization loop;Data/members are upserted per slide. Any throw in between (e.g.targetOutOfRangeon a later slide, or the verifier itself) leaves the caller'sinoutbundle with a zero-byte IWA member and orphanData/blobs. A caller that catches and serializes writes a corrupt.key. Document the invalidation, or stage mutations and commit at the end. - M5. Cached slide record indices assume one slide record per member (
Sources/KeynoteKit/KeynoteArchiveSurgeon.swift:85++SlideSurgery.swift:159-173).authorcomputes member/record indices once, theninsertMintedRecords/cloneSlideNodedo mid-member inserts that shift later records. If a base stores two slide records in one Index member (nothing forbids it), the second slide's cached index silently targets the wrong record. Re-locate by identifier per slide, or assert one-slide-per-member at catalog time.
Parsing/decoding — malformed input traps instead of throwing, and 32-bit platforms
The package builds for watchOS (arm64_32, 32-bit Int), and these layers document a throws-not-traps contract:
- M6.
TSPArchiveStreamtraps on a crafted varint length (Sources/KeynoteKitProtobuf/TSPArchiveStream.swift:97).Int(headerLength)uses the trapping initializer on an input-controlledUInt64; a 10-byte varint decoding to1 << 63crashes the process instead of throwingTSPArchiveStreamError. Bounds-check inUInt64space before converting. Related:ProtobufVarint.read(ProtobufVarint.swift:48-56) silently wraps 10-byte overflow (value |= ... << 63drops bits) instead of returningnilas documented —[0xFF ×9, 0x7F]"succeeds" asUInt64.max, which is precisely the value class that feeds the trap. - M7. Snappy decoder: attacker-controlled
reserveCapacity+ three 32-bit trap paths (Sources/Snappy/SnappyDecoder.swift:54;Snappy.swift:56;Varint.swift:67;SnappyDecoder.swift:82). A 6-byte input (FF FF FF FF 0F …) drives an up-front ~4 GiBreserveCapacitybefore any body validation — a jetsam kill / malloc trap on Apple platforms, contradicting the documented "malformed input never traps" guarantee. On arm64_32:Int(UInt32.max)inmaximumBlockSizetraps at first touch;Varint'sInt(result)traps for lengths >Int32.max; and a width-4 extended literal length shifts into the sign bit, producing a negativelengththat passes the bounds guard and traps in the slice. Clamp the reserve (per-element expansion is bounded ~22×), compute lengths inUInt32/UInt64, and add a per-elementoutput.count + length <= expectedCountfail-fast. - M8. IWAFraming zip writer/reader trap edges (
Sources/IWAFraming/ZipArchive.swift:61-99;ZipCentralDirectoryRecord.swift:87-89;ZipEndOfCentralDirectory.swift:76). The writer guards each body and the entry count but never the cumulative offsets — multiple sub-4 GiB entries summing past 4 GiB trap inUInt32(...)appends instead of throwing the documented.zip64Unsupported. On the read side,Int(buffer.readUInt32(...))on untrusted size/offset fields traps on arm64_32 for values ≥ 2³¹. (All majors here concern inputs/scales Keynote itself never produces; nothing threatens re-emission fidelity for genuine Keynote files.)
DSL surface — result-builder and ordering gaps (user-visible at v0.1.0)
- M9.
SlideItemsBuilderhas nobuildOptional/buildEither(Sources/KeynoteKit/SlideItemsBuilder.swift:32-52):Slide { if showCaption { TextBox("Caption") } }does not compile, whileSlideBuilderandTextRunsBuilderboth support control flow.buildArrayis present, soforworks butifdoesn't — a surprising asymmetry users hit immediately. - M10.
BuildEffectsBuilderhas no control-flow support at all (Sources/KeynoteKit/BuildEffectsBuilder.swift:32-44): nobuildOptional,buildEither, orbuildArray, sobuild(.in) { if emphasize { Blur() } else { Dissolve() } }fails to compile. Both fixes are the same two-linersTextRunsBuilderalready has — worth doing before the API surface is tagged. - M11. Build delivery order follows z-order, not declaration order (
Sources/KeynoteKit/Deck+Lowering.swift:40-42, 88-99).authoredSlidesorts drawables by z-index and then collects builds from that ordering, but PLAN §5 (and this file's own doc comment) fix delivery order as encounter order walking the slide builder. Concretely:TextBox("A").zIndex(10).build(...)declared beforeTextBox("B").zIndex(0).build(...)delivers B's build first — a purely visual layering property silently reorders the animation timeline. The existingzIndexReordersDrawablestest doesn't catch it because its declaration order coincides with z-order. Iterate declaration order and maptargetIndexthrough the ordered list.
Reading/parity infrastructure (internal, but gates depend on it)
- M12.
BuildExtractornever scrapescustomTwist— documented Python parity is broken for twist effects (Sources/KeynoteArchiveNavigation/BuildExtractor.swift:104-126). Python's_parse_build_optionsscrapes the whole BuildArchive block including nestedanimationAttributes;customTwistexists only onKN_AnimationAttributesArchive, which the Swift options bag never reads (customBounce/customTravelDistancealso have unscraped shadow copies there).BuildRecord's own docs listcustomTwistin the bag. The 24-fixture corpus contains zero twist effects, so the parity gate passes vacuously. - M13.
SlideOrderTestsnever asserts order (Tests/KeynoteArchiveNavigationTests/SlideOrderTests.swift:9-19). The only assertions are set-equality and count — a reversed or arbitrary-order result passes on all 24 fixtures, and noSlideOrderErrorcase is ever exercised. Order is the one property the type exists to provide and is what the writer path consumes. Related: the slide-tree walk is one level deep (SlideOrder.swift:87-109) — grouped/indented slides in a user-supplied base would silently vanish from the result; recurse or throw on unexpected depth. - M14. Apple-corpus Snappy test asserts only decoded length (
Tests/SnappyTests/AppleCorpusTests.swift:19). Copy offsets don't affect output length, so the entire class of offset-decoding bugs is invisible to the module's only cross-implementation evidence. Store a SHA-256 (or plaintext) per fixture alongsideuncompressedCount.
🟡 Minor (selected, grouped)
Correctness edges
MotionPath.naturalWidth/Heightcomputed asmaxcoordinate, not extent (max - min) — leftward or offset paths yield 0 or inflated sizes (Sources/KeynoteKit/MotionPath.swift:67-71).- Action build with
motionPath == nilsilently emits an emptyTSD.PathSourceArchiveinstead of throwing (Sources/KeynoteKit/BuildRecordFactory.swift:175-177). Imagedocuments "JPEG or PNG" but sizes only JPEGs; a PNG without explicit dimensions gets the silent 200×200 pixel-size fallback (Sources/KeynoteKit/Image.swift:77-88,KeynoteArchiveSurgeon+ImageSupply.swift:186-193) — pixelSize is a documented crash/misrender-sensitive field. PNG IHDR sniffing is 8 fixed-offset bytes.JPEGSizeSOF guard admitslength2–6, reading dimension bytes from the next segment (garbage, in-bounds); requirelength >= 7. Legal 0xFF fill bytes also make valid JPEGs return nil (Sources/KeynoteKit/JPEGSize.swift:46-55).UUIDMapVerifierwatermark check only runs when builds exist and only against build ids — an image-only deck regression inbumpLastObjectIdentifierpasses the gate (Sources/KeynoteKit/UUIDMapVerifier.swift:69-74);bumpLastObjectIdentifieritself no-ops when the field is absent (+Metadata.swift:190-196).?? 0fallbacks on identifier-map lookups that must never miss would wire the forbidden id-0 reference instead of throwing (+SlideCloning.swift:56,+Supply.swift:112).- Paragraph-fork record header omits the stylesheet reference from
objectReferenceswhile the payload carries it — an undeclared cross-record edge; the siblingcharacterStyleRecorddeclares it (+CharacterStyle.swift:108vs+CharacterRuns.swift:107). - Second
.action { }call silently replaces the first, unlike accumulating.buildcalls (TextBox.swift:178-182,Image.swift:141-145);.direction(_:)is chainable on non-directional transitions (even.none), contra PLAN's "typed enum on directional effects only" (SlideTransition.swift:88-92). TSPArchiveRecordpayloads/messageInfos count invariant unenforced — mismatches serialize mis-parsing streams or silently drop payload bytes viazip(TSPArchiveRecord.swift:55-58, 74; alsoRecordCloner.swift:55).- EOCD backward scan accepts a false
PK\x05\x06inside a trailing comment (no comment-length consistency check), and entry paths > 65,535 UTF-8 bytes are silently corrupted via theappendUInt16mask (ZipEndOfCentralDirectory.swift:49-59,ZipLocalFileHeader.swift:77). IWAChunkCodec.maximumUncompressedChunkCountis an unvalidated public var:0hangsencodeforever; oversized values silently wrap the 24-bit length field (IWAFraming/IWAChunkCodec.swift:51, 85-99).- Scripting:
openDocument's fallback todocuments[0]can bind (and later save over) an unrelated open document (KeynoteScriptingApplication.swift:86-97); single-slotAppleEventErrorMonitor.lastErrormis-attributes the root cause in multi-event commands (AppleEventErrorMonitor.swift:62-83).
Test-coverage gaps worth closing before tag
- Multi-image slides (would catch C1); a Keynote-opened oversized-text-item deck (M2).
- Trigger lowering has zero automated assertions:
.afterPrevious→2 /.withPrevious→3 andchunk.automaticare verified only by the human pass. Similarly untested:autoAdvance, transition/builddelay, custom motion-path points, run-level.italic()/.font(), andSlideBuilder's own control-flow paths. - No known-answer vectors: SHA-1 ("abc" →
a9993e36…; the implementation was hand-verified correct against FIPS 180-4, but the only test compares it to itself), CRC-32 ("123456789"→0xCBF43926), copy1 offsets > 255 in Snappy. KeyBundle.upsertEntry(four ordering branches, on the production surgery path) has no direct test; malformed-zip error paths (truncatedArchivevariants,zip64Unsupported, multi-disk) largely untested.TSPArchiveStream.serializeis never run against real Keynote headers at its own layer (only synthetic round-trips); patch-record resolution (type == 0/shouldMerge) has zero coverage.ArchiveGraphComparerdoesn't comparemessageInfos.dataReferences— a dropped thumbnail/image data reference would pass the golden differential (Tests/KeynoteKitTests/ArchiveGraphComparer.swift:53-57).
CI / config hygiene
- Release-tag pushes get the reduced matrix: the scope check matches branch refs only, so
refs/tags/v0.1.0setsFULL=false— the tag build that certifies the release skips the platform legs (.github/workflows/KeynoteKit.yml:50-61). Addelif [[ "$REF" == refs/tags/* ]]; then FULL=true. - Fork PRs will always fail
claude-code-review(secrets unavailable on forks); guard with a same-repoif:(claude-code-review.yml:4-5, 38). Nopull_request_target/script-injection issues found anywhere. - Three workflows lack
permissions:blocks (KeynoteKit.yml,check-unsafe-flags.yml,swift-source-compat.yml); third-party actions are tag-pinned rather than SHA-pinned (jdx/mise-action,jlumbroso/free-disk-spaceare the real supply-chain surface). .swiftlint.ymllistspattern_matching_keywordsin bothopt_in_rules(line 60) anddisabled_rules(line 147) — disabled wins; delete one deliberately.Scripts/lint.sh's ScriptingBridge-import guard doesn't scanAcceptanceDeckCatalog,AcceptanceDecks, orKeynoteArchiveNavigation(lines 83-87); unquoted$PACKAGE_DIRword-splits on paths with spaces.cleanup-cachesisn't paginated (leftover caches past 30);Deck.writehardcodesSystemRandomNumberGenerator, so a deck failing the human pass can't be regenerated for bisection — thread the existing generator seam through the public API.
Performance (none blocking)
- Surgeon:
SlideCatalogrebuilt inside nearly every helper,withPackageMetadatadecode/encode round-trips per call (~6× per image slide), andauthorre-encodes every member whenever any id was minted — quadratic on large bases; a one-time identifier→location index fixes most of it. - Snappy: fixed 16,384-entry hash table zeroed per encode call regardless of input size (the corpus's smallest block is 18 bytes);
[UInt8]-only API forces anArray(payload[start..<end])copy per 64 KiB chunk inIWAChunkCodec;KeyArchiveIndex.record(withIdentifier:)is a linear scan called repeatedly per slide.
✅ Strengths
- The hardest-won invariants are encoded, not just documented:
has*-guards throughoutRecordCloner(with the materialized-empty-reference rationale inline),UUIDMapVerifierrunning before every write with error messages explaining why Keynote would crash, and tests proving each invariant can actually fail. - The golden-differential design is excellent: strip Python's surgery from a real golden, re-author with Swift, demand graph equality with only the two genuinely random fields normalized — deterministic id allocation makes even minted identifiers comparable.
- Research traceability is unusually good:
BuildRecordFactoryand the transition catalogs cite the exact Python lines, findings docs, and fixture measurements; all 44 scripting transition entries matchdeckkit.pybyte-for-byte including the three deceptive name→archive traps. - Layer boundaries follow the plan: fine-grained targets,
KeynoteKitnever links ScriptingBridge (enforced by lint grep + a Linux CI leg), navigation stayspackage-scoped per the "reading is not v0.1.0 API" directive, Snappy knows nothing about framing. - Malformed-input handling on 64-bit is genuinely throw-based and fuzz-tested in Snappy/IWAFraming (2,000 random blocks, every truncation prefix, every single-byte corruption); the fixture corpus is pinned with an integrity test that avoids the known
Bundle.modulevacuous-pass pitfall. - Live-test gating is exemplary: the only Keynote-launching suite is env-var opt-in,
.serialized, with unique temp dirs and an explanation of why the "safe" test can't trigger TCC. CI is green, two-tiered sensibly, and free of script-injection patterns.
Suggested pre-tag punch list
- Fix C1 (per-slide data-identifier collision) + add a two-images-one-slide test.
- M9/M10 (result-builder control flow) and M11 (declaration-order builds) — public API surface and documented semantics, cheapest to fix before the tag locks them in.
- M2/M3 (uuid-map registration + silently no-oping registration helpers) — these violate the repo's own crash invariants on supported inputs.
- M6/M7/M8 trap paths — or, if deferred, narrow the documented "never traps" claims and file follow-ups.
- Tag-ref matrix fix in CI so
v0.1.0gets the full build. - The one-line known-answer tests (SHA-1, CRC-32) and the trigger-lowering assertion — tiny effort, large regression surface.
Review conducted module-by-module (six parallel deep reads) against .claude/PLAN.md, .claude/agent-notes.md, and the research findings; all findings verified against the code as of this branch's head.
Fixes from the PR #43 author reviews, crash-invariant class: - C1: thread the next data identifier through expandDrawables so two images on one slide mint distinct TSP.DataInfo ids (was: both read the pre-loop metadata maximum). Test: two-images-one-slide. - M1: RecordCloner.remapSlideLists now remaps ownedDrawables; cloning a slide carrying an inserted image no longer leaves the clone pointing at the original's drawables. Unit + basedOn integration tests (confirmed to fail without the fix). - M2: expandTextItems' clone path registers objectUuidMapEntries for cloned subtree records via shared registerFreshRecordUUIDs. - Verifier rule 6: every record minted into a presentation slide member must be registered, exempting KN.BuildChunkArchive and TSWP.NumberAttachmentArchive (template leaves its own unregistered); watermark check now runs unconditionally against all record ids. - M3: registerUUIDEntries(componentStem:), registerDataObjectReference, and registerExternalReferences throw missingComponent instead of silently no-oping on a missing component. - M5: author() asserts one slide record per member before caching member/record indices. - M4: documented that a throw from author() invalidates the surgeon and bundle (transactional staging deferred to a follow-up issue). - Standards: hasTableAttachment/hasObject guards in rewrittenStorage; id-0 `?? 0` fallbacks in cloneLastSlide/cloneBodyPlaceholder now throw; paragraph-fork record header declares its stylesheet reference like the sibling character-style record. - Split KeynoteArchiveSurgeon+UUIDRegistration.swift out of +Metadata (file_length). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Makes the documented throws-not-traps contract true on malformed input and on 32-bit (arm64_32 watchOS) platforms: - M6 KeynoteKitProtobuf: TSPArchiveStream bounds-checks header and payload lengths in UInt64 space before Int conversion (a crafted 10-byte varint decoding to 2^63 previously trapped the process); ProtobufVarint.read detects tenth-byte overflow and returns nil as documented instead of silently wrapping. - M7 Snappy: reserveCapacity clamped to the input's maximum possible expansion (22x - a 6-byte input can no longer force a ~4 GiB allocation); maximumBlockSize uses Int(clamping:) so first touch on watchOS does not trap; Varint.decode and readInteger reject values above Int.max via Int(exactly:) (the width-4 literal sign-bit path produced a negative length that passed the bounds guard); per-element output.count + length <= expectedCount fail-fast. - M8 IWAFraming: the writer guards cumulative header/directory offsets and throws .zip64Unsupported instead of trapping in UInt32 appends when sub-4 GiB entries sum past 4 GiB; the reader converts untrusted size/offset fields with Int(exactly:); ZipArchive.serialize split into validate/appendLocalEntries (cyclomatic_complexity). - IWAChunkCodec.maximumUncompressedChunkCount is now `let`: 0 hung encode forever and oversized values wrapped the 24-bit length field. New malformed-input vectors in SnappyTests and TSPArchiveStreamTests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- M12 refuted with schema evidence, not code: the vendored Keynote 15.3 protos define custom_twist only on KN.TransitionAttributesArchive, and KN.AnimationAttributesArchive has no customBounce/customTravelDistance shadow copies - a KN.BuildArchive YAML block can never contain a customTwist line, so Python's defensive key is unreachable on both sides and Swift parity is already complete. BuildRecord's option-bag doc now records this instead of implying the key can occur. - M13: SlideOrder gets order-sensitive coverage via a SyntheticDeck helper (tree order deliberately reversed from member order), a missingSlide error-path test, and a new nestedSlideNode error - the one-level walk now refuses grouped/indented bases instead of silently dropping their slides. - M14: Apple-corpus Snappy fixtures carry SHA-256 digests of the decoded payloads, computed independently with python-snappy (cramjam), so copy-offset bugs can no longer hide behind length-only assertions. - Known-answer vectors: SHA-1 "abc", CRC-32 "123456789", Snappy copy1 offset > 255 (tag high-bits path). - Lowering vectors: trigger ordinals (onClick/afterPrevious/withPrevious -> 1/2/3 + chunk.automatic), build delay, custom motion-path points, transition delay/autoAdvance, SlideBuilder if/else+for, run-level .italic()/.font(), empty-deck output parses. - KeyBundle.upsertEntry: all four ordering branches pinned. - ArchiveGraphComparer now compares messageInfos.dataReferences, so a dropped thumbnail/image data reference fails the golden differential. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…icId validation, curved MotionPath [skip ci]
PARTIAL - library compiles but tests have NOT been run against these
changes; resume point in .claude/PARALLEL-WORKTREES.md "Current
position". Landed here:
- M9/M10: buildOptional/buildEither (+buildArray) on SlideItemsBuilder
and BuildEffectsBuilder.
- M11: builds collect in declaration order; targetIndex maps through
the z-order permutation (zIndex reorders layers, never the timeline).
- .action {} accumulates like .build (actions: [MotionPath]) - per the
standing DSL directive, never a runtime error.
- magicId implemented: Deck.write() validates Magic Move pairs (same
drawable type + same content, per magic_move_correspondence.md) and
throws public MagicMoveError; the touch-only no-op is gone.
- MotionPath: curved-node API (Node with controlIn/controlOut ->
.bezier; sharp nodes unchanged so goldens stay valid), natural size
now extent (max-min), nil action path throws instead of emitting an
empty TSD.PathSourceArchive.
- .direction() documented as ignored on non-directional transitions;
Deck.swift stale "content not modelled" doc rewritten.
Remaining for batch C + batch E recorded in PARALLEL-WORKTREES.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w-ups #44–#50 Finish the PR #43 triage slate: PNG IHDR / hardened JPEGSize, Batch C tests (M9/M11/magicId/curves), BuildRecord.description for parity diffs, CI hygiene (tag FULL matrix, permissions, fork-PR guard), lint guard quoting, PLAN scripting sync, and thematic follow-up issues. Expanded Co-authored-by: Cursor <cursoragent@cursor.com> #24 human pass remains the v0.1.0 tag gate.
) * Monospace probe deck + findings stub for the #64 spike Prepares the #64 Keynote-bound spike so the human pass is a short scripted check rather than authoring probe slides by hand. `monospace_probe.key` is four family slides (Menlo, SF Mono, Courier New, Monaco) plus an indentation slide. Each family slide renders `iiii` / `MMMM` / `1111` at 96pt: in a genuinely monospaced face those three lines are the same width and their right edges form a clean column; under a proportional substitute `MMMM` is dramatically wider. That signal is legible at a glance, which matters because a silent substitution still renders — it just isn't monospaced. The indentation slide checks that leading-space columns hold, which is what #66 actually depends on. Registered as its own `monospaceProbe` group rather than folded into an existing one, and the findings file records how to remove it: this is spike evidence, not a permanent acceptance deck, and it should not add noise to every future human pass once the working family is pinned. Structurally green via `swift run AcceptanceDecks` (all records decode, both SIGTRAP invariants hold); `swift test` and `LINT_MODE=STRICT ./Scripts/lint.sh` pass. The render result is Leo's to record. Refs #64 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * #63 scale spike: linear, not quadratic. #64 probe round two. ## #63 — measured, and the concern is refuted `Sources/ScaleSpike` times `Deck.write(to:)` across a ladder of slide counts. A single 20-slide number cannot separate "quadratic but small" from "linear", so it sweeps 3/5/10/15/20 and reports the growth exponent. slides drawables median(s) 3 12 0.1049 20 80 0.6558 growth exponent 3->20: 0.97 (1 = linear, 2 = quadratic) A 20-slide demo-shaped deck writes in 0.66s and per-slide cost is flat (0.035s at 3 slides, 0.033s at 20). The quadratic SHAPE in `expandSlides` is real — `SlideCatalog` is rebuilt every loop iteration and `locate` is a full scan — but at demo scale the constant factors dominate. **#44 is not a v0.1.0 blocker.** The deck is demo-shaped rather than minimal (4 drawables/slide, transition, build) because cost scales with records per member; bare slides would understate it. Findings: `research/findings/scale_spike.md`. Still needs a human: open the kept 20-slide deck and confirm it renders. ## #64 — round one failed, but not the way it looked All four families rendered proportional AND at template default size — including Courier New, which certainly exists and is certainly monospaced. Both symptoms together rule out font substitution: a missing family would still honor the authored 96pt. Probing the archive showed KeynoteKit writes the authored values correctly — `fontName=Menlo fontSize=96.0` in a `TSWP.ParagraphStyleArchive` variation, identical for single- and multi-line boxes. So the bytes are right and Keynote ignored them. The one structural difference from `TextFormattingContent`, which does render its authored font, is paragraph count: that deck is one paragraph, every round-one probe box was three. Hypothesis: whole-item font is only honored on single-paragraph items. Round two isolates that variable — slides 1-3 hold paragraph count at one and vary family; slides 4-5 hold family and vary paragraph count and styling route. If 1-3 render and 4 does not, it is a KeynoteKit bug that blocks #66, since code blocks are inherently multi-line. Refs #63, #64 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * #64 resolved: Menlo works; the failure was #81 Round two settles it by render. Monospace was never the problem. slide 1 Menlo, 1 paragraph monospaced at 96pt OK slide 4 Menlo, 3 paragraphs, item template default FAIL slide 5 Menlo, 3 paragraphs, spans 1st + 3rd only PARTIAL Slides 1 and 4 differ only in paragraph count — same family, same size, same deck, same run. That is #81 (one `tableParaStyle` entry written regardless of paragraph count), now confirmed visually as well as structurally. Slide 5 is the useful surprise: styling each paragraph's own `Text` span fixes the FIRST and THIRD paragraphs and leaves the SECOND proportional. So per-span is a different off-by-one, not the same defect reappearing — and #81's fix has to cover both routes. Its acceptance test needs at least three paragraphs; a two-paragraph case would pass and hide this. Consequences: - `Menlo` is the demo deck's family and #66's `CodeTheme` default - #66 is BLOCKED on #81 — neither styling route renders multi-line code correctly today, so shipping it now would produce unstyled code slides Refs #64, #81, #66 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Correct the record: `MMMM` was a bad probe, not a second bug The condensed middle line in round two looked like a second off-by-one in the per-span route. It was not. An isolation probe: iiii / xxxx / 1111 interior line renders Menlo — fine MMMM / iiii / 1111 MMMM fails in FIRST position MMMM alone still condensed, single paragraph `MMMM` fails wherever it appears, including alone on a slide with no multi-paragraph involvement. Any other interior line renders correctly. That is Keynote substituting a condensed face for capital M in Menlo at 90pt — its shrink-to-fit on a wide glyph run — not a KeynoteKit defect. The ruler was badly chosen: I picked `MMMM` precisely BECAUSE it is the widest glyph, which is what provokes the substitution. A width comparison needs glyphs that do not trigger fallback. Consequence: #81's fix is complete, and #66 is unblocked. Refs #64, #81, #66 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Write one tableParaStyle entry per paragraph (#81) Item-level `.font`/`.fontSize`/`.foregroundColor` reached only the FIRST paragraph of a multi-paragraph text box. Keynote rendered the rest at the template default, silently — the archive stayed valid, so nothing failed until a human looked at a slide. `collapsedEntries` run-length collapsed adjacent identical paragraph formats into one entry at offset 0. That reads like a reasonable optimization, but Keynote treats each `tableParaStyle` entry as a paragraph boundary marker, so collapsing left later paragraphs unstyled. The fixture settles the intended shape. A human-authored 5-paragraph body storage in `build_action_B.key` writes FIVE entries: char=0 -> 2651127 char=15 -> 0 char=30 -> 0 char=47 -> 0 char=63 -> 0 The style rides entry 0; later boundaries carry identifier 0, meaning "same as the preceding entry". The entry must exist even though it references no record. `paragraphEntries` emits one entry per paragraph, using identifier 0 for a repeat. Fork records are still deduped — the dedupe is on records, not entries. `applyParagraphStyles` filters identifier-0 entries out of the header references, since 0 is not a record. Two existing tests asserted the collapsing behavior; they encoded the bug, so they now assert the per-paragraph shape instead. Added `MultiParagraphFormattingTests` and a `multi_paragraph_formatting` acceptance deck. Everything uses THREE or more paragraphs deliberately: a two-paragraph case exercises only the first and last and passes while interior paragraphs stay broken. That is exactly how the per-span variant of this bug survived the first #64 probe round. `swift test` 70 green, `LINT_MODE=STRICT ./Scripts/lint.sh` exit 0. Structural only — the render pass is the real gate. Refs #81, #64, #66 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fix the crash: a repeat entry must omit `object`, not zero it Leo's render pass crashed Keynote on open. My fault, and a worse failure than the bug being fixed. The per-paragraph entries were right; writing them was not. I set `entry.object.identifier = 0` for a repeat, which MATERIALIZES a present-but-empty `TSP.Reference`. Keynote resolves that to nil and crashes — the same id-0 trap already recorded twice in agent-notes, for `RecordCloner` remaps and for plain character spans. I read the template's repeats as "identifier 0" and generalized from that without checking how they serialize. The wire bytes are unambiguous: template repeat: [08 0f] characterIndex only, no object field what I wrote: [08 0f 12 00] present-but-empty reference Reading the identifier back cannot tell them apart — absent and zeroed both report 0. Only `hasObject` does. ## Changes - `applyParagraphStyles` assigns `object` only for real identifiers - `UUIDMapVerifier` gains **rule 7**: no storage object-attribute entry may carry a present-but-empty reference, across `tableParaStyle`, `tableCharStyle`, and `tableListStyle`. Every deck now passes through it, so this class of crash cannot ship again from any code path. - A regression test asserting `hasObject` is false and the entry serializes to exactly `[08 04]`. Verified it FAILS against the crashing version — asserting `identifier == 0` alone passes on both, which is precisely why the original test missed it. All 12 acceptance decks regenerate clean under rule 7. 71 tests green, STRICT lint exit 0. Refs #81 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The demo `.key`, the movie, and the slide-image screenshots can only be produced by a human on a Mac running Keynote. Without a written procedure they rot silently as the API changes. `docs/RELEASING.md` covers regenerating the demo deck, the Keynote eyeball pass, movie export via ScriptingBridge (including the TCC prompt on first run and the manual File -> Export fallback when the scripted path stalls), slide images, the `.mov` -> H.264 `.mp4` conversion DocC's `@Video` requires, where each artifact is committed, and which release steps force a refresh. States plainly that none of it is CI-verified and links #8. Two accuracy notes: the scripting snippets use the real signatures (`openDocument(at:)`, `export(to:format:)`) rather than plausible-looking ones, and step 1 flags that the demo target ships with #56 — until then the substitute is `swift run AcceptanceDecks`. Closes #70 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SwiftUI Color.resolve stalls the xrsimulator leg mid-suite and leaves xcodebuild wedged. Skip the suite there with a Testing trait; other platforms still run it. Co-authored-by: Cursor <cursoragent@cursor.com>
No description provided.