fix(honesty): unwrap not-wrapped predicates in absenceBlindSpotNote - #776
Merged
divshekhar merged 4 commits intoSep 6, 2026
Merged
Conversation
A predicate shaped `{ kind: 'not', predicate: { kind: 'element', ... } }`
is semantically an absence assertion, but `absenceBlindSpotNote` checked
only the outer `kind`, which is 'not', not 'element'. The function returned
undefined and the agent got a green verdict with no blind-spot caveat —
the false green the function exists to prevent.
Add `predicate?: AbsencePredicate` to the interface and synthesize
`absent: true` on the inner element predicate when the outer kind is 'not'.
Pin with unit tests (blind-spots.test.ts) and an integration test
(assert-coverage-honesty.test.ts).
Closes reticlehq#774
Signed-off-by: Dev Chiniwala <dev.chiniwala@gmail.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
A small but important convention issue remains (use PredicateKind constants instead of hard-coded kind strings) to prevent predicate-kind drift/typos in this honesty-critical logic.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes an honesty/coverage gap in @reticlehq/server where absenceBlindSpotNote failed to recognize not-wrapped element predicates as absence assertions, causing blind-spot caveats to be silently omitted.
Changes:
- Teach
absenceBlindSpotNoteto unwrap{ kind: 'not', predicate: { kind: 'element', ... } }and treat it as an absence assertion by synthesizingabsent: trueon the inner element predicate. - Add unit tests covering direct element absence,
not-wrapped element absence, non-elementnot, and scoped cross-origin behavior. - Add an integration test ensuring
reticle_assertdowngradesnot-wrapped element absence toUNKNOWNwithABSENCE_BLIND_SPOTwhen virtualized rows are unobserved.
File summaries
| File | Description |
|---|---|
| packages/server/src/honesty/blind-spots.ts | Unwrap not(element) in absenceBlindSpotNote and synthesize absent: true so blind-spot caveats apply consistently. |
| packages/server/src/honesty/blind-spots.test.ts | Add focused unit tests for absenceBlindSpotNote, including not unwrap and scoped cross-origin behavior. |
| packages/server/src/tools/assert-coverage-honesty.test.ts | Add integration coverage to ensure reticle_assert downgrades not-wrapped element absence under virtualized blind spots. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+241
to
+246
| if ('not' === predicate.kind && predicate.predicate !== undefined) { | ||
| if ('element' === predicate.predicate.kind) { | ||
| return absenceBlindSpotNote({ ...predicate.predicate, absent: true }, spots); | ||
| } | ||
| return undefined; | ||
| } |
…d constants
The initial unwrap logic treated not(element { absent: true }) the same
as not(element) — both synthesized absent: true and fired the blind-spot
note. But not(element { absent: true }) is a double negative that
asserts presence, and a positive match is not threatened by an
unobservable region.
Also replaces string literals 'not' and 'element' with PredicateKind.NOT
and PredicateKind.ELEMENT (rule reticlehq#3: no free strings).
Adds CHANGELOG.md entry under [Unreleased].
Signed-off-by: Dev Chiniwala <dev.chiniwala@gmail.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
10 tasks
…rtion Two cases suggested by Chirag6722 in reticlehq#815: 1. not(not(element)) is itself a presence claim, so silence is the correct answer — pins the one-level recursion decision so the next reader does not re-open the question reticlehq#774 raised. 2. Both spellings (absent: true and not-wrapped) produce the identical note string, not merely a non-empty one — stops one of them quietly drifting into a weaker sentence later. Signed-off-by: Dev Chiniwala <dev.chiniwala@gmail.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
absenceBlindSpotNoteis what stops "the element is absent" being read as proof when the page has regions Reticle cannot see into. It fired for one of the two ways that claim is written:A
not-wrapped element predicate is the same assertion —notis one of the twelve predicate kinds — and itskindis'not', so the note never fired.What makes this a disagreement rather than a uniform gap is the function directly below it.
restsOnCompleteWindowopens withif (PredicateKind.NOT === predicate.kind) return true, so buffer loss already impeaches anot-wrapped green exactly as it should. The two honesty checks sitting beside each other read the same predicate differently, and the one that stayed quiet was the one guarding the DOM the walk could not enter.So
reticle_assert { not: { element: … } }over a page with a closed shadow root, an unmounted virtualized row, or a scoped cross-origin frame returned a green implying absence had been proven page-wide. The same assertion writtenabsent: truesaid otherwise.The third spelling of absence
not(element)is the third way to write absence:{ kind: 'element', absent: true }— the explicit spelling{ kind: 'net', count: 0 }— the zero-count spelling (fix(honesty): treat an exact count of zero as the absence claim it is #765){ kind: 'not', predicate: { kind: 'element' } }— the negation wrapperThe function missed exactly one of three.
Polarity decides it, not spelling
The naive unwrap gets it wrong.
notflips the claim, so the fix must consider polarity:{ element, absent: true }{ not: { element } }{ not: { element, absent: true } }{ not: { net } }not(element { absent: true })asserts the element IS there. A positive assertion that passed found its evidence — an unobservable region cannot unmake it. That is the same reasoning the plain-presence case already rests on.Returning the negated predicate also lands the
CROSS_ORIGIN_IFRAMEbranch: that branch selects onquery.scope, which lives on the inner predicate, so a wrapper-only read would have found no scope on exactly the assertions that name a frame. There is a test on both halves of that.What this does NOT do, and why I stopped
No recursion past one level.
not(not(element))is itself a presence claim, so silence there is the correct answer, not a gap being tolerated. The one-level limit only starts to bite at three levels of nesting, which is not a shape a caller writes. A test pins this so the next reader does not re-open the question.No
allOf/anyOfunwrap.not(allOf([element, …]))is a weaker claim than "this element is absent" — asserting the note over it would over-warn about a specific element nobody named.Does not touch
restsOnCompleteWindow. Already correct forNOT, and the two functions address different questions (buffer loss vs. DOM observability).String literals replaced with
PredicateKindconstants. The existing'element'at line 238 and the new'not'/'element'comparisons all usePredicateKind.NOT/PredicateKind.ELEMENT— rule #3.How it was verified
Written RED first. Without the source change, exactly three of the nine new cases fail — the note firing, the two spellings agreeing, and the scope read — while the six guard cases pass, which is the split that shows the change does not over-reach:
not-wrapped element → fires (regression test)not-wrapped non-element → silent (guard)not(element { absent: true })→ silent (polarity guard)not(not(element))→ silent (depth pin, suggested by @Chirag6722 in fix(honesty): read both spellings of an absence claim, not justabsent: true#815)absent: true#815)reticle_assertwith not-wrapped element returnsUNKNOWN/ABSENCE_BLIND_SPOTGates
pnpm format:check— my files clean (472 pre-existing format warnings unrelated to this PR)pnpm typecheck— 22/22 (apps-electron-vue-pinia pre-existing failure, missing node_modules)blind-spots.test.ts— 20/20 passing (was 11 before)assert-coverage-honesty.test.ts— 16/16 passingany, no free strings (PredicateKind.NOT,PredicateKind.ELEMENT), no non-null![Unreleased]Closes #774
🤖 Generated with Claude Code