Skip to content

verify plan-structure: self-invalidating-gate scan misses the ! grep negative-gate form #4541

Closed as duplicate

Description

@MeetMartin

Summary

verify plan-structure's self-invalidating-gate scan (scanNegativeGrepCommentEcho, bin/lib/verify.cjs) only fires on the grep -c … == 0 gate form. The equally common ! grep … form carries the identical hazard and is not checked, so a plan whose <action> prose contains the very literal its own gate forbids passes clean.

Observed on v1.13.0.

Reproduction

Two plans, byte-identical except for the gate form. Both name FORBIDDEN_TOKEN in the <action> block and both gate against it.

Plan A:

<automated>[ "$(grep -c 'FORBIDDEN_TOKEN' src/widget.js)" == 0 ]</automated>

Plan B:

<automated>! grep -q 'FORBIDDEN_TOKEN' src/widget.js</automated>

Running verify plan-structure on each:

Plan Gate form Self-invalidation error
A grep -c … == 0 fires
B ! grep -q … does not fire

(The fixtures also emit unrelated Task missing <name> noise; it does not affect the result.)

Cause

scanNegativeGrepCommentEcho skips any segment that fails either of two conditions:

if (!/grep(?:\s+-{1,2}[A-Za-z])/.test(seg) || !zeroCmp(seg))
    continue;

zeroCmp requires == 0, -eq 0 or equals 0. A ! grep -q 'LIT' file has a flagged grep but no zero comparison, so it is skipped before countGrepRe is ever applied. countGrepRe additionally requires a count flag (-c / --count), which the ! form does not use.

The emitted error message names the covered form explicitly ("…negative-greps for it (grep -c … == 0)"), so this reads as deliberate scoping rather than a regression — but the scoping leaves the more idiomatic form unprotected.

Why it matters

The two forms are interchangeable in practice, and ! grep is the more natural way to express "this literal must not appear". A planner that reaches for it gets no protection at all, and the failure is silent and in the reassuring direction: the plan validates, so the gate looks trustworthy.

In one recent phase, every negative gate happened to be written in the ! grep form and none in the grep -c … == 0 form, so the scan was structurally unable to fire on any of them. Several genuine self-invalidating gates in that phase were found by human/adversarial review instead. Where a checker exists for a hazard, it is reasonable to assume it covers that hazard; here it silently covers only one spelling of it.

Suggested fix

Treat a leading ! (or if ! grep …) plus a plain grep as a negative gate, alongside the existing count-and-compare form. Concretely, relax the guard so a segment qualifies when EITHER:

  • it has a count-flag grep and a zero comparison (current behaviour), OR
  • it has a negated grep (! grep …), with the pattern extracted the same way countGrepRe already does for the quoted/bareword cases.

The existing <!-- planner-discipline-allow: LIT --> escape hatch and the rest of the reporting path should carry over unchanged.

Happy to open a PR if the direction looks right.

Activity

  1. trek-e commented on Sep 8, 2026

    @trek-e
    Collaborator

    This was generated by AI during triage.

    Diagnosis

    Root cause: guard clause in scanNegativeGrepCommentEcho (function src/verify.cts:361-457, guard at src/verify.cts:428): if (!/grep(?:\s+-{1,2}[A-Za-z])/.test(seg) || !zeroCmp(seg)) continue;. zeroCmp (src/verify.cts:377-378) only matches == 0 / -eq 0 / equals 0. A ! grep -q PAT FILE segment has no such comparison anywhere, so the guard continues before the literal-extraction step (countGrepRe, which additionally requires a count flag) ever runs. The negated form is structurally invisible to this detector.

    Evidence: Extracted the exact predicates from the live function into an isolated script and ran the reporter's two fixture segments through them:

    • [ "$(grep -c 'FORBIDDEN_TOKEN' <file>)" == 0 ] → zeroCmp true → proceeds to extraction → literal found in <action> text → error fires.
    • ! grep -q 'FORBIDDEN_TOKEN' <file> → zeroCmp false → guard short-circuits, never reaches extraction → no error, no warning.

    This confirms the report precisely. Not a regression: the timeline for this function shows the guard's decision logic unchanged since the earliest indexed history (only an unrelated entity-decode fix, #3611, was layered on later). No Cortex decision/ban/convention scopes this detector to the == 0 form only — it reads as an unintentional coverage gap rather than deliberate design.

    Blast radius: scanNegativeGrepCommentEcho has exactly one caller (the plan-structure verification command handler). The fix is local to the function body; no signature or return-shape changes needed downstream.

    Notably: a sibling detector in the same file, added for issue #968 (file-wide negative-grep sibling conflict), already implements working negation detection (hasLeadingNot) and pattern/file extraction for exactly the ! grep / if ! grep forms. A fix for this issue should mirror that existing logic rather than reinvent it, keeping the two detectors' negative-gate vocabulary in sync.

    Regression test: none exists today. Add a plan fixture using the ! grep -q 'LIT' <file> form alongside the existing grep -c ... == 0 fixture and assert the same self-invalidation error fires for both forms.

  2. trek-e commented on Sep 8, 2026

    @trek-e
    Collaborator

    This was generated by AI during triage.

    Agent Brief

    Category: Bug fix — detector coverage gap in an existing validation gate.

    Summary: The plan-structure verifier's self-invalidating-gate scan protects planners from writing a gate that bans a literal while the same plan's action prose contains that exact literal. It correctly catches this when the gate is written as a grep-count-and-compare (grep -c 'LIT' file == 0), but silently misses it when the gate is written as a negated grep (! grep -q 'LIT' file, or if ! grep ... ; then). Both forms express the identical intent and are equally common; a planner who reaches for the negated form gets zero protection, and the failure direction is the dangerous one — the plan validates as if the gate were sound.

    Current behavior: A plan whose gate reads ! grep -q 'FORBIDDEN_TOKEN' <file> and whose action prose contains FORBIDDEN_TOKEN passes plan-structure verification cleanly, with no error and no warning.

    Desired behavior: The same plan should raise the same "Plan body contains forbidden literal ... in an <action> block, but an acceptance criterion negative-greps for it" error (or an equivalent message naming the negated form), regardless of whether the gate is written as grep -c ... == 0 or as ! grep ... / if ! grep ... ; then.

    Key interfaces: The plan-structure verifier's self-invalidating-gate scanner and its existing allowlist escape hatch (<!-- planner-discipline-allow: LIT -->). A separate, related detector already in the same module (added for a prior file-wide-negative-gate-conflict issue) already knows how to recognize and parse the negated-grep form — its approach should be reused so the two detectors treat "what counts as a negative grep" consistently.

    Acceptance criteria:

    • A plan gate written as ! grep -q 'LIT' file (and the if ! grep ...; then variant) is recognized as a negative gate by the self-invalidating-gate scanner.
    • When such a gate's literal also appears in the plan's <action> block, the same class of error is raised as for the grep -c ... == 0 form.
    • The -v/--invert-match case (a double negative, which is a positive gate) is still correctly excluded and does not misfire.
    • The existing <!-- planner-discipline-allow: LIT --> allowlist still suppresses the error for both gate forms.
    • The grep -c ... == 0 form's existing behavior is unchanged (no regression).

    Out of scope: Changing the wording of the existing error message for the already-covered form; touching the file-wide negative-gate-conflict detector's own behavior (it already works correctly); any change to how <action>/<verify>/<acceptance_criteria> blocks are parsed structurally.

  3. added
    bugSomething isn't working
    and removed
    needs-triageNew issue awaiting maintainer review
    on Sep 8, 2026
  4. trek-e commented on Sep 11, 2026

    @trek-e
    Collaborator

    Closing as a duplicate of #4631 — consolidating at the seam rather than fixing per-symptom.

    This is not a dismissal of the report. Your specifics are preserved verbatim in that epic's Absorbed issues table, and its Done when checklist requires a failing-first regression test for this exact defect before the epic can close.

    Why it is being consolidated: a scan of all 69 open confirmed-bug issues (2026-09-11) found this one shares a single root cause with seven other open issues — a gate hand-rolls a narrow derivation of what it should evaluate, the derivation comes back empty, and "empty" is reported as "pass". Fixing each occurrence where it was found leaves the remaining call sites carrying the same bug, which is the pattern that produced this cluster in the first place.

    The epic delivers one resolveEvaluationScope(), evidence read through the existing document seams instead of per-gate regex, and an Evidence type that distinguishes "none" from "could not read" so a gate can no longer pass vacuously, plus a machine-enforced anti-divergence ratchet so a new hand-written copy cannot be introduced — the approach that worked for #1372 (markdown sectionizer), #2143 (table + bounded mutation) and #2121 (phase-id), each of which closed its bug class structurally.

    Track the fix at #4631.

  5. added
    duplicateThis issue or pull request already exists
    on Sep 11, 2026
  6. added 3 commits that reference this issue on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconfirmed-bugVerified reproducible bugduplicateThis issue or pull request already exists

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions