Skip to content

fix(extension-chrome): address all findings from second PR review sweep#20

Merged
paperhurts merged 7 commits into
mainfrom
fix/review-sweep-2
May 24, 2026
Merged

fix(extension-chrome): address all findings from second PR review sweep#20
paperhurts merged 7 commits into
mainfrom
fix/review-sweep-2

Conversation

@paperhurts
Copy link
Copy Markdown
Owner

Summary

Addresses every finding from the second comprehensive 5-agent PR review of the native-tree integration work. 7 focused TDD commits covering 3 real bugs, 1 type refactor, 2 test gaps, 1 comment WHY, and 2 minor cleanups.

Commits (in priority order)

  1. fix(B): narrow applyRemoteEdit catch on chrome.bookmarks.get — bare catch swallowed everything including extension-context invalidation. Mirror the sibling delete branch's case-narrowing: silent skip on 'not found', rethrow + log on other errors.
  2. fix(A): suppress title-only echo from apply-remote.update — title-only changes have changeInfo.url === undefined, URL-suppression couldn't catch them. Added node-id-keyed suppression registry alongside the URL one. Same bug class as onChanged for an unmapped node still triggers a no-op GitHub round-trip #8 via a different path.
  3. refactor(C): brand Etag as non-empty stringetag: string | null admitted "" which would silently take the wrong branch in deps.etag ? readIfChanged : read. Brand + toEtag(s) helper.
  4. refactor(D): typed StorageOps + LastErrorRecord + StorageWrites — replaces Record<string, unknown> with a discriminated union keyed on the three storage keys. Shared StorageOps interface. Popup now imports the proper LastErrorRecord type instead of casting inline.
  5. test(FG): tighten URL-change + add onRemoved-unmapped coverage — URL-change test now asserts old-URL suppression too. New symmetric onRemoved-unmapped test (was only update path regression-guarded for issue onChanged for an unmapped node still triggers a no-op GitHub round-trip #8).
  6. docs(E): document time-since-success early-return rationale — non-obvious WHY for runMaybeReconcile: stamp lives in success path so failures retry on next cold start. A future maintainer moving it would cause hour-long silent blackouts on transient 5xx.
  7. chore: least-privilege CI permissions + dedup BOOKMARKS_PATHpermissions: contents: read on the CI workflow. BOOKMARKS_PATH was redeclared in 3 files; now imported from bookmarks-file.ts (canonical owner).

TDD discipline

Every behavior change started with a failing test:

  • B: rethrow test failed RED (bare catch swallowed everything)
  • A: suppressNode test failed RED (function didn't exist)
  • C: toEtag test failed RED (function didn't exist), then typecheck caught the now-illegal string-literal etags in existing tests
  • F/G: new assertions on existing test cases

Refactors (D, E, minor) had the existing suite as the safety net — full 93/93 stayed green throughout.

Test plan

  • pnpm test 93/93 (was 83 before this sweep — +10 from the 6 new B/A/C/FG tests + existing strengthened)
  • pnpm typecheck clean
  • pnpm build clean
  • CI green

Out of scope (filed separately)

Closes #18

…ks.get

The bare catch silently swallowed every kind of failure including
extension-context invalidation, storage I/O errors, and permission
issues — exactly the conditions where the propagation path (added by
PR #16) needs to surface failure rather than continue with a stale
read. Inconsistent with the sibling delete branch which already
case-narrows by 'not found' and rethrows other errors.

Mirror the delete branch's pattern: silently skip when the message
matches 'Can't find bookmark' / 'not found' (expected when the node
was deleted locally between mapping and apply); otherwise log and
rethrow so runPollRemoteOnce's outer catch records gitmarks:lastError
and withholds the etag persist (next poll retries).

TDD: added 2 tests — silent-skip-on-not-found + rethrow-on-other.
Watched rethrow test fail RED (bare catch swallowed everything).
Implementation passes; full suite 85/85 (was 83).
PR #16 added applyRemoteEdit which propagates remote title/URL edits
via chrome.bookmarks.update. For URL changes, Chrome's onChanged
payload carries the new URL and URL-suppression catches the echo.
For TITLE-only changes, Chrome's changeInfo.url is undefined and
URL-suppression doesn't see the echo — so the listener pushes a
no-op patch back to GitHub, exactly the wasted round-trip class of
bug PR #15 was made to fix.

Add a node-id-keyed suppression registry alongside the URL-keyed
one. applyRemoteEdit suppresses both URLs (catching the URL-change
echo and any racing user edit on the old URL) AND the node id
(catching the title-only echo). The listener's surviving filter
checks both registries for update/remove events.

TDD: started with a failing test in listeners.test.ts asserting
that an onChanged for a node previously suppressed via suppressNode
does NOT trigger client.update. Watched it fail RED with
'suppressNode is not a function'. Added the new API + filter
integration. Test passes. Added 5 unit tests for the new suppression
API. Full suite 91/91 (was 85).
PollDeps.etag was 'string | null', which admits the empty string ''.
runPollRemoteOnce branches on 'deps.etag ? readIfChanged : read', so an
empty-string etag would silently take the wrong branch — calling
readIfChanged with an invalid header value. The current call site in
background.ts only writes non-empty etags, but the type didn't enforce
that.

Brand Etag via unique symbol; add toEtag(s) that returns null for
empty strings; update PollDeps.etag to 'Etag | null'; switch the
runtime check to '!== null' (since empty string is now impossible
to construct). background.ts pipes the stored value through toEtag.

TDD: added toEtag unit test (empty → null, non-empty → non-null).
Watched it fail RED (toEtag didn't exist). Implementation lifts;
typecheck catches the now-illegal string-literal etags in existing
tests; updated those to toEtag('...'). Full suite 92/92.
…ageWrites

ReconcileDeps.setStorage and PollDeps.setStorage previously accepted
'Record<string, unknown>' — any key, any value, no compile-time guard
against typos.

Extract a shared StorageOps interface with:
- StorageWrites: discriminated union keyed on the three known storage
  keys (RECONCILED_AT, LAST_ETAG, LAST_ERROR) with typed payloads
- StorageKey: literal union of the same keys
- LastErrorRecord: shape for gitmarks:lastError that the popup reads

ReconcileDeps and PollDeps now both 'extends StorageOps'. Constants
are re-exported so other modules (listeners.ts) can reuse them
instead of redeclaring the same string literals. popup.ts gains a
proper type for the lastError it displays — previously cast inline
to '{ message: string; source: string; kind?: string }'.

Reviewer's type-design report rated the storage seam 3/5 encapsulation
and 2/5 enforcement; this lifts both to ~5/5: a typo would now be
a compile error, and the LastErrorRecord shape is locked across
writers (listeners.ts flush + background-core.ts poll/reconcile)
and the reader (popup.ts).

Existing tests unaffected; full suite 92/92 still green.
… coverage

Test-analyzer findings from the second review sweep:

F. The 'propagates a remote URL change' test asserted suppression of
   the NEW URL but not the OLD URL. The production code suppresses both
   (the old in case a racing user edit on the old path is in flight)
   but a future refactor dropping suppress(current.url) would silently
   pass. Add the second assertion.

G. listeners.test.ts had a regression test for onChanged-on-unmapped-
   node (issue #8 path) but no symmetric test for onRemoved. The same
   bug class would land silently for removes. Add the analogous test.

Full suite 93/93 (was 92).
…onale

Reviewer flagged this as a non-obvious WHY: lastReconciledAt is only
bumped on success, so a failed reconcile retries on the next cold
start rather than waiting a full hour. A future maintainer 'helpfully'
moving the stamp into the catch or before the try would silently
turn transient GitHub 5xx failures into hour-long sync blackouts.

(apply-remote.ts's analogous 'suppress both URLs + node id' comment
was already rewritten WHY-first during commit A of this sweep.)
CI hardening: add 'permissions: contents: read' at workflow level so
the default GITHUB_TOKEN cannot write to the repo even if a future
step or compromised dependency tries to. The workflow only runs
tests and builds; it doesn't need any write scope.

DRY: BOOKMARKS_PATH was declared as a module-private constant in
background-core.ts and reconcile.ts, while bookmarks-file.ts already
exported the same value. Both consumers now import it from
bookmarks-file.ts (the canonical owner of bookmarks.json-related
operations).
@paperhurts paperhurts merged commit fedcdd1 into main May 24, 2026
1 check passed
@paperhurts paperhurts deleted the fix/review-sweep-2 branch May 24, 2026 04:07
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.

PR review sweep 2: address all 5-reviewer findings against d41623c..3a145f6

1 participant