Skip to content

Merge: a PR that removes or weakens a test, lint or guard waits for the owner (#783) [PROTECTED: owner merges] - #820

Merged
jayden1711 merged 21 commits into
mainfrom
issue-783-weakening-hold
Oct 20, 2026
Merged

jayden1711 merged 21 commits into
mainfrom
issue-783-weakening-hold

Conversation

@jayden1711

@jayden1711 jayden1711 commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Intent: Make the owner's rule that no agent removes or weakens a check structural (decision guide principles 7 and 9, owner rule 10). When a PR removes or changes a test's assertion or test line, deletes a test, changes an expected output, removes a line of a guard or lint, grows an allowlist, or adds a skip, only or suppression mark, mergeDecision gets a design: may weaken a check: ... reason and the PR waits for the owner, whatever its level. The reason comes from the PR's own diff, never from the evaluator's text.

Risk class: protected. Every path under src/ is engine in this repo's review.yaml. src/merge-policy-weakening.ts falls under the locked src/merge-policy* glob ("the merge policy"), and so do src/merge-policy.ts and src/coordinator/merge-flow.ts. src/coordinator/change-files.ts is "the coordinator", and docs/design.md and test/merge-policy.test.ts are locked too.

Closes #783

The by-design protection (owner rule, 2026-10-19)

A runtime behaviour check in the merge gate. weakeningOf runs inside the coordinator's mergeDecisionFor on each PR's own diff, and its reasons decide whether the PR may auto-merge. An agent can't skip it or edit it: the PR it would need is itself held (the module, its registries and its tests are locked, src/merge-policy* and test/merge-policy.test.ts, L3), and removing a line of the module is a removed guard line.

Backstops (lints that read the repo), named as such: two tests in test/merge-policy.test.ts keep the gate's registries whole. One fails on a check-shaped table in test/ or scripts/ that is neither registered nor in NOT_A_CHECK. The other fails on a test that lists the sources and is in neither LINT_TESTS nor NOT_A_LINT. They protect nothing on their own.

No model-compensating scaffolding, and no Claude Code-specific parsing (none at all: the rule reads diff lines).

Main db2e3df merged in (#855, #856, #819)

  • Merge bcb2202: only CHANGELOG.md conflicted, and both bullets were kept in ## v0.5.3 (unreleased), neither reworded. git diff origin/main -- CHANGELOG.md shows only this PR's seven lines.
  • 1f1ed02, one registry entry: test: a golden test pins what the runners start, before the runner seam moves it (part of #701) #856's test/runner-golden.test.ts const GUARD holds the golden runner settings, the deny list and hooks a run's --settings must carry. The backstop found it, and it is now registered never-shrink (coordinator's call: shrinking it weakens what a run is held to). The change is src/merge-policy-weakening.ts | 2 ++: the entry and its comment.
  • At 1f1ed02 (measured):
    • merge-policy, change-files-renames, machine-path-writes, test/adversarial/adversarial.test.ts and changelog give # tests 217 # pass 210 # fail 0 # todo 7 (the 7 are the adversarial suite's own todo cases);
    • typecheck exit 0, DCO clean;
    • CI was still running when this body was written.

Main 2ce7726 merged in (the CHANGELOG fold, #816)

Round 11's CI was green: 19 passed and 7 skipped, measured at 9c79a3b. Main 2ce7726 is merged in, not rebased (merge commit 4c25810).

  • CHANGELOG fold (Changelog: v0.5.3 back to unreleased (one release when everything is done) #850): main now has one ## v0.5.3 (unreleased) section. The Merge: a PR that removes or weakens test assertions, lint rules or guards waits for the owner #783 bullet moved from the deleted ## v0.5.4 (unreleased) into v0.5.3's "Guardrail changes: what each one does", above its closing empty ### Guardrail changes.
    • git diff origin/main -- CHANGELOG.md shows added lines only, inside v0.5.3, and no 0.5.4 (measured).
    • No doc of this PR says 0.5.4.
    • test/changelog.test.ts passes.
  • First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816 landed first, so this PR adds its TRACED entry (a9d1601, test data only): ['...(i.weakening ?? [])', null] in test/first-task-progress.test.ts.
    • The scan's own test failed without it: "untraced ...(i.weakening ?? [])" (measured). With it, 18/18 pass.
    • The reasons come from weakeningReasons with the design: may weaken a check: prefix.
  • A registry entry main's merge made necessary (3ace297): main's test/machine-path-writes.test.ts names its guard in a check-shaped const GUARD = { file: 'test/isolate.ts', fn: 'onRealMachine' }. The backstop asked for it to be registered.
    • It is registered never-shrink: changing which helper counts as the guard narrows that lint.
    • This is one line in CHECK_TABLES, the only code change since round 11.
  • At 3ace297 (measured):
    • merge-policy, fix-forward-coordinator, change-files-renames, review, slot-path, brand, package-files, changelog, first-task-progress and machine-path-writes give # tests 203 # pass 203 # fail 0;
    • typecheck exit 0, DCO clean, denylist clean ("1320 files clean");
    • CI was still running when this body was written.

Round 11 (the round-10 review, CHANGES NEEDED)

Round 10's CI was green: 19 passed and 7 skipped, measured with gh pr checks at 2e9ea4f. The fixtures were written first and run on round 10's module.

  1. eval and Function are matched as whole words, not eval( / Function(: (?<![\w$])(?:eval|Function)(?![\w$]). The fixture eval ('ALL' + 'OWED')['x'] = 1 (with a space) waits.
  2. .yarnrc, .yarnrc.yml, bunfig.toml and .bunfig.toml are treated like .npmrc: guard files where any added or removed line waits. They share one list, PACKAGE_MANAGER_SETTINGS. The reason now reads "adds a line to an allowlist file or a package manager's settings file". There are fixtures for each, in both scopes.
  3. pnpm-workspace.yaml is a guard file: a removed or changed line waits, and an added one doesn't, since ordinary PRs add packages there. Fixtures cover a removed onlyBuiltDependencies entry (waits) and a package added (passes). Named gap: an added pnpm script setting.

Fail-without, round 11 (measured): the new fixtures on round 10's module give test/merge-policy.test.ts 43 tests, 41 pass, 2 fail:

  • the table test (eval ( with a space);
  • the allowlist and settings test (yarn and bun, pnpm-workspace.yaml, and the reason's new wording, which the .denylist-allow assertions there also use).

At this head, 9c79a3b (measured):

  • npm run typecheck exit 0; check-denylist "1310 files clean"; DCO and the push gate clean; the CHANGELOG diff against main is additions only, and test/changelog.test.ts passes 10/10;
  • CI was still running when this body was written;
  • the targeted files give # tests 176 # pass 176 # fail 0;
  • the 150 PRs: no delta. The decisions are identical to round 10's (114 with a reason, the same 12 newly held, 10 of 16 tests-only held; slowest 33 ms).

Round 10 (the round-9 review: the body, plus two fail-closed tightenings)

Round 9's CI was green: 19 passed and 7 skipped, measured with gh pr checks at db5c355. The reviewer found no code hole. The fixtures were written first and run on round 9's module.

  1. The fail-without count is corrected: round 9's was 2 of 43, not 1 (see Round 9).
  2. eval( and Function( wait in a registered table's file. A name built at run time, such as eval('ALL' + 'OWED')['x'] = 1, has no identifier to match. So an added line holding either one waits (table-used). There are fixtures for eval(...), new Function(...) and Function(...)().
  3. .npmrc is a guard file, and any line added to it waits. Its settings (script-shell=, ignore-scripts=) change what install and every npm script run under.
    • Because pure additions to a guard file pass, **/.npmrc is both in GUARD_FILES (a removed line waits) and in NEVER_GROW_FILES (an added line waits).
    • That reason's wording is now "adds a line to an allowlist file or .npmrc".
    • Fixtures: script-shell=true added and a line removed, at the root and in web/, in both scopes.
  4. Named gaps:
    • a registered table mutated from another module (in the engine those modules are L3 and wait anyway);
    • a copy of a table that already exists on main, then grown.

Fail-without, round 10 (measured): the new fixtures on round 9's module give test/merge-policy.test.ts 43 tests, 41 pass, 2 fail: the table test (eval(/Function() and the allowlist-file test (.npmrc).

At this head, 2e9ea4f (measured):

  • npm run typecheck exit 0; check-denylist "1310 files clean"; DCO and the push gate clean; the CHANGELOG diff against main is additions only, and test/changelog.test.ts passes 10/10;
  • CI was still running when this body was written;
  • the targeted files give # tests 176 # pass 176 # fail 0;
  • the 150 PRs: no delta. The decisions are identical to round 9's in all 150 (114 with a reason, the same 12 newly held, 10 of 16 tests-only held; slowest 39 ms). None of them touches a .npmrc. The eval/Function words add no hold: Slot dir: one writeSlotFile helper for every slot-dir write; the lint fails on any other [PROTECTED: owner merges] #766 already had table-used, and it waits anyway.

Round 9 (the round-8 review, CHANGES NEEDED)

Round 8's CI was green: 19 passed and 7 skipped, measured with gh pr checks at 4dd1abd. This round covers decision 3's case. The tests were written first and run on round 8's module.

  1. A registered allowlist can't grow through a line outside its list.
    • In a registered table's file, an added line that names the table outside the table's own lines now waits (table-used, "changes a check's or guard's table outside its own list"). It is a plain identifier match, (?<![\w$])NAME(?![\w$]), with no parse.
    • It applies to every registered table in a JS or TS file. A JSON table (package.json scripts) is data, never used by name in its file.
    • There are fixtures for an index assignment, Object.assign, .push, .add and a spread declared again, in test/no-clock-helpers.test.ts, plus the same check in change-files-renames.test.ts and no-wall-clock-durations.test.ts. An entry added inside the literal is still table-grown.
  2. A bracket in a comment opens nothing.
    • withoutComments strips // and /* */ comments, but not inside a string, in one pass before the reader counts brackets. It also skips an escape (a regex literal's \/).
    • The backstop strips them too.
    • Fixtures: const ALLOWED = // see [docs] with the list on the next line at the same indent now waits as unreadable. A // inside a string is no comment.
    • Every registered table still reads (backstop, measured). GUARDRAIL_PATHS, a list of regex literals, needed the escape rule.
  3. Renames: nothing added, as asked. The owner is deciding whether every rename should simply wait.

Fail-without, round 9 (measured by the reviewer; corrected in round 10): on round 8's module, test/merge-policy.test.ts gives 43 tests, 41 pass, 2 fail. The two are:

I first wrote "1 fail" from a run made before I added the backstop's comment stripping (principle 5).

At this head, db5c355 (measured):

Round 8 (the round-7 review, CHANGES NEEDED)

Round 7's CI was green: 19 passed and 7 skipped, measured with gh pr checks at eb37ee8. Round 8 has two small items, each failing closed. The tests were written first and run on round 7's module.

  1. A leading-dot file name isn't rename-exempt, like a dot folder: tsc and the dist/test glob skip it. test/x.test.ts to test/.x.test.ts waits, and there is a fixture for it.
  2. A table's list must open on its declaring line, after the =.
    • tableLines now reads the declaring line's value: what follows its = (not an arrow's =>), or a JSON key's :.
    • If no bracket opens there, the table is unreadable and the PR waits. Examples: const ALLOWED: string[] =, or const ALLOWED: with the list below.
    • If the value's brackets balance (a one-line table) but the next non-blank line is indented deeper, it is unreadable too. Examples: = // see [docs] with the list below, or = new Set() with .add(...) below.
    • There are fixtures for all three of the review's shapes.
    • The backstop now also asserts, for every registered one-line table, that its whole list is on the declaring line: a bracket after the =, every bracket closed, and the next line no deeper. Every registered table passes it today (measured).

Fail-without, round 8 (measured): the new tests on round 7's module give test/merge-policy.test.ts 43 tests, 41 pass, 2 fail: the rename unit test (the leading dot) and the table test (the three shapes). The backstop's new assertion passes on round 7's module, because no registered table has these shapes. It is a backstop.

At this head, 4dd1abd (measured):

  • npm run typecheck exit 0; check-denylist "1310 files clean"; DCO and the push gate clean; the CHANGELOG diff against main is additions only, and test/changelog.test.ts passes 10/10;
  • CI was still running when this body was written;
  • the targeted files give # tests 176 # pass 176 # fail 0;
  • the 150 PRs give decisions identical to rounds 5–7 (114 with a reason, the same 12 newly held, 10 of 16 tests-only held; slowest 35 ms);
  • the worst case is still 2,000,000 one-character guard lines, at 342 ms (load 1.4).

Round 7 (the round-6 review, CHANGES NEEDED)

Round 6's CI was green: 19 passed and 7 skipped, measured with gh pr checks at 84ebfa8. Round 7 closes the review's three findings, each failing closed. The tests were written first and run on round 6's module (no git checkout <ref> -- <paths>).

  1. Moves into folders runners skip now wait. A rename target under node_modules/, __mocks__/ or any dot folder (.old/, .cache/, ...) is no longer exempt, alongside fixtures, snapshots, testdata and docs. There is one fixture each: test/x.test.ts to test/node_modules/x.test.ts, test/.old/x.test.ts and test/__mocks__/x.test.ts.
  2. Only a regular file can be a pure rename. exactMoves pairs only modes 100644 and 100755, so a symlink (120000) or a submodule (160000) is never paired.
    • The test uses real git. A symlinked test, test/link.test.ts pointing at real.test.ts, is moved to test/sub/ with its link text kept, so it now points at a new near-empty test/sub/real.test.ts. The move waits as a deleted test file.
    • A regular file moved in the same commit is still paired and passes.
  3. A declaring line that opens no bracket (const ALLOWED: with string[] = [ on the next line) now makes the table unreadable, so the change waits. It used to be read as a one-line table. There is a test, and every registered table still reads (the backstop test).

Fail-without, round 7 (measured): the new tests on round 6's module give test/merge-policy.test.ts 43 tests, 40 pass, 3 fail: the rename unit test (the three folders), the table test (the split declaration), and the symlink test.

At this head, eb37ee8 (measured):

  • npm run typecheck exit 0; check-denylist "1310 files clean"; DCO and the push gate clean; the CHANGELOG diff against main is additions only, and test/changelog.test.ts passes 10/10;
  • CI was still running when this body was written;
  • the targeted files (merge-policy, fix-forward-coordinator, change-files-renames, review, slot-path, brand, package-files, changelog) give # tests 176 # pass 176 # fail 0;
  • the 150 PRs give decisions identical to rounds 5 and 6 (114 with a reason, the same 12 newly held, 10 of 16 tests-only held; slowest 35 ms);
  • the worst case is still 2,000,000 one-character guard lines, at 341 ms (load 4–15).

Round 6 (the round-5 review, CHANGES NEEDED)

Round 5's CI was green, and every round-4 attack was closed (measured by the reviewer). Round 6 closes what the review found open in round 5's new pieces. The tests were written first and run on round 5's module.

  1. The rename exemption's real reach. A pure rename (the same blob and mode, exactMoves) passes only when all of these hold:

    • both names match the same runner's default pattern (RUNNER_NAMES), with the same suffix:
      • jest, vitest, mocha and node: *.test.* / *.spec.* in JS or TS;
      • pytest: test_*.py, *_test.py;
      • go test: *_test.go;
      • rspec and minitest: *_spec.rb, *_test.rb, test_*.rb;
      • Surefire: Test*.java, *Test.java, *Tests.java, *TestCase.java, and Kotlin's;
      • xUnit: *Test(s).cs; XCTest: *Test(s).swift; bats: *.bats;
    • both are under the same test-folder root: the path up to the first test/, tests/, spec/, Tests/ or __tests__/, or none for both;
    • the new path is under no fixtures, snapshots, testdata or docs folder;
    • neither is a guard, lint, allowlist or table file (item 2).

    Anything else is a delete and an add, and waits. That covers:

    • tests/test_math.py to tests/math.py or tests/math_helpers.py;
    • MathTest.java to MathCase.java;
    • spec/math_spec.rb to spec/math.rb;
    • test/x.test.ts to scripts/x.test.ts, docs/x.test.ts, test/fixtures/x.test.ts or test/__snapshots__/x.test.ts;
    • a fixture moved. Its name matches no runner, so a fixtures folder moved with its tests waits. That is stricter than round 5.

    Renames that pass: test/math.test.ts to test/sum.test.ts, a folder of tests (test/unit/ to test/core/), test_sum.py to test_total.py, MathTest.java to SumTest.java, math_spec.rb to sum_spec.rb, math_test.go to sum_test.go, and a co-located web/src/a.test.tsx to b.test.tsx.

    Tests: one case each, plus acceptance through real git (scripts/, test/fixtures/ and a name no runner selects, each waits).

  2. A guard, lint, allowlist or table file is never rename-exempt, even inside the test paths. These now wait: tests/conftest.py to tests/old/conftest.py, test/tsconfig.json and test/jest.config.js into test/old/, test/brand.test.ts (a lint test) renamed, test/isolate.ts renamed, and a lint test with a registered table renamed.

  3. The table trick.

    • A table's declaration is now read with const, let or var and any spacing (^(?:export\s+)?(?:const|let|var)\s+NAME, or a JSON key).
    • When the change removes or changes the base's declaring line, the table waits as unreadable: what reads as the table afterwards may be a decoy.
    • The review's attack waits: a decoy in a comment, the real declaration turned into var ALLOWED, const ALLOWED or let<tab>ALLOWED, and an entry added. So does each declaration change alone. var ALLOWED with two spaces is read, and an entry added to it is table-grown.
    • A stricter hold: a one-line table (its declaring line is the whole table) now waits as unreadable on any change, where round 5 named the entry (table-grown / table-shrunk). It still waits. Two of my own test expectations changed for it: the one-line allowlist test, and src/delegation.ts NEVER_LOOSEN in the guard-list test.
  4. Body: the Rule-10 line is corrected, this section states the rename rule's reach, and the Adversarial table has the round-5 review's attacks.

Fail-without, round 6 (measured): the new tests on round 5's module (this branch before the module changed) give test/merge-policy.test.ts 42 tests, 39 pass, 3 fail:

  • the rename unit test (runner naming, roots, fixtures and docs, guard files);
  • the table test (decoy plus var, two spaces, tab);
  • the rename acceptance test (real git: scripts/, test/fixtures/, an unrun name).

At 84ebfa8 (measured):

  • merge-policy, fix-forward-coordinator, change-files-renames, review, slot-path, brand, package-files and changelog: # tests 175 # pass 175 # fail 0;
  • npm run typecheck exit 0;
  • check-denylist "1310 files clean";
  • DCO "all commits signed off";
  • push gate clean.

My first push of this round was stopped by the push gate's denylist. A conftest fixture string held \n@pytest.fixture, which reads as an email address. The fixture now uses a pytest_configure hook instead, and the commit was amended before any push.

CI: the head is 84ebfa8, and its CI was still running when this body was written. Round 5's 7c632eb was green (19 succeeded, 7 skipped, 1 duplicate new-deps cancelled; the reviewer measured it).

150 PRs, round 6 (measured, the same method): decisions are identical to round 5's in all 150. 114 get a reason; 12 are newly held (#797, #790, #777, #664, #653, #627, #684, #656, #620, #594, #547, #503); 10 of 16 tests-only PRs are held. The slowest weakeningOf took 37 ms, at load 2.

Worst case, round 6 (measured on the final build, weakeningReasons alone, two runs each, load average 2.4):

  • 2,000,000 one-character guard lines: 347–351 ms (the worst);
  • four guard files of distinct lines: 34–36 ms;
  • four fixture files: 28 ms;
  • four files over the cap: 17–18 ms;
  • every added line a mark: 35–36 ms;
  • every assertion line changed: 74–80 ms;
  • a 490 KB allowlist: 19 ms;
  • just over the cap: 9 ms.

Round 5's figures at load 8–33 are below. These are lower mainly because the load is (inferred): round 6 adds no per-line work.

Round 5 (the round-4 review, CHANGES NEEDED)

The design is unchanged: plain lines, no parser. Each item below fails open no longer. The tests were written first and run on round 4's module (fail-without below).

  1. CI is green again without weakening main's fix-forward test. test/fix-forward-coordinator.test.ts's "rename" PR used to rename totalCents in test/price.test.js's assertion lines, so under decision 1 it waited and never merged (CI run 39326108484, shard 2/4, measured by CI and the reviewer).

    • Now the rename PR renames the function in src/price.js, and the test imports it under its old local name (import { applyDiscount, totalInCents as totalCents }), checking that the import line was there to change.
    • Why it isn't weaker (rule 10):
      • every assertion line of test/price.test.js is the same, and still checks 598 cents through the renamed function;
      • the scenario is the same: a rename and a caller of the old name, each green alone, red together;
      • every assertion of the fix-forward test itself is unchanged: the fix task, the culprit, the fix PR, the revert.
    • Measured, in a scratch env: 2/2 at this head.
    • A local trap I hit: the test reads the issue number from its worktree path (/issue-(\d+)/). My scratch env's path held issue-783, so it failed locally at "both PRs open" on main too. That was the "can't run it here" of round 1. Measured with a probe, and fixed by an env path without issue-.
  2. A pure rename doesn't wait (Merge: a PR that removes or weakens test assertions, lint rules or guards waits for the owner #783's deliverable, decision 1).

    • exactMoves (in src/coordinator/change-files.ts, the changed-files helper) pairs each deleted path with an added path of the same blob id and mode (diff --raw, content match, no similarity guess).
    • Such a pair is skipped when both paths are test paths with the same runner suffix. This was too wide, and round 6 narrows it (Round 6, items 1 and 2).
    • A rename with an edit, to another suffix, or out of the test paths is still a delete and an add.
    • Acceptance tests (real git, decideChange): a file renamed and a folder renamed don't wait; a rename with an expected value edited waits.
  3. A non-text line no longer hides a mark.

    • Marks are read on every added line of every file but a doc.
    • A changed file that isn't text (a NUL, a literal U+FFFD, or a byte that isn't UTF-8) now waits, unless it is an asset by its extension (an image, an archive, a font, a binary).
    • Fixtures: NUL, U+FFFD and a Latin-1 byte, each beside a mark; the Latin-1 byte also through real git.
  4. The table reader fails closed and doesn't read around tricks. The change waits when any of these is true:

    • a registered table's name is declared twice on a side (a decoy in a comment);
    • the first line at the table's indent inside it isn't a closer (an entry or a comment at column 0);
    • the table never closes.

    A declaring line that closes every bracket it opens is a one-line table. Fixtures: the review's four tricks, and a one-line table.

  5. Marks: the review's measured list, plain substrings:

    • Go: t.Skipf(, t.SkipNow(;
    • Python: skipTest(, SkipTest, pytest.importorskip, pylint:disable, type:ignore;
    • Rust: #[ignore;
    • Java: @Disabled, @Ignore;
    • vitest: .skipIf(, .runIf(, .skip.;
    • mocha: xcontext(; jasmine: pending(;
    • Swift: XCTSkip;
    • C#: Skip =, Skip=, #pragma warning disable;
    • Kotlin: @Suppress(;
    • PHP: @phpstan-ignore, @psalm-suppress;
    • RSpec: xit and skip (each line is read with a space before it, so the start of a line counts).

    The long tail is a named gap. Each form has a fixture, plus negatives (exit 0, skipped, skips, skipper).

  6. Assertion words:

    • C#: Assert., [Fact, [Theory, [Test;
    • Swift: XCTAssert, XCTFail;
    • Ruby minitest: must_, wont_, refute (assert_ was already there);
    • bats (*.bats, now a test path): @test, run and [ .

    Shell test files (.sh) are no longer read as code, so every removed line of one waits, as an expected output's. Rust is narrowed to what's true: *.test.rs and test folders count, but inline #[cfg(test)] tests in src/ don't (a named gap, with a test).

  7. Test paths at any depth: **/test/**, **/tests/**, **/spec/** and **/Tests/** (Swift, C#). Nested fixtures/ count as expected outputs. A test-file name pattern (*.test.*, test_*.py, ...) counts anywhere.

  8. Docs: a test file under docs/ is a test file, and its marks are read. Only a doc that isn't a test file is skipped.

  9. Timing. The bound is now checked first: the changed lines the rule would read are added up, and over 4,000,000 characters the change waits before any line is read. Each kind keeps only its first three places and a count, with no map per line. Measurements are in the bound table below.

  10. Named (body, docs/design.md, CHANGELOG):

    • a mode-only change (a hook losing its executable bit);
    • a submodule link outside the test paths;
    • Rust's inline tests;
    • the stricter holds: a deleted helper with no assertion, and a whole-file line-ending change.
  11. The Adversarial pass table has each of the review's attacks.

Also: main 71316eb (the v0.5.3 release) is merged in, not rebased. The CHANGELOG bullet moved out of the released v0.5.3 section into a new ## v0.5.4 (unreleased) section. One risk there, measured in memory: scripts/lib/changelog.mjs withSection keeps a section's hand-written top only above a generated list (### Features, ...). So the section ends with an empty ### Guardrail changes heading, as #834 and #839 do (commit 7c632eb). Measured in memory: regenerating v0.5.4 keeps the bullet, and v0.5.3 is unchanged. test/changelog.test.ts: 10/10.

What changed (round 4: the owner's line rule replaces the parser)

The owner decided (2026-10-19) to replace the 80 KB parser with a simple line rule, read from the diff, with no parser. Then the owner added that marker lines wait. Round 4 does both.

  • src/merge-policy-weakening.ts, rewritten. Before: 1,438 lines and 79,978 bytes of source. Now: 455 lines and 24,013 bytes, most of them registries. weakeningReasons is pure, and weakeningOf reads the change. The rule, file by file:
    • A test file (TEST_PATHS, every repo, tests-only changes too):

      • a removed or changed line with an assertion or a test on it waits (CHECK_LINE: assert, self.assert, expect, should, fail(, t.X, require., pytest., .toBe-style and chai .to. matchers, test(/it(/describe(/suite( and their .x forms, def test, class Test, func Test, @Test);
      • so does a deleted test file. Since round 5, a pure rename doesn't (above).

      TEST_PATHS gained **/__snapshots__/**, **/*.snap and **/testdata/**. Round 5 added test folders at any depth, Tests/ and *.bats.

    • An expected output (a snapshot, a file under fixtures/, __snapshots__/ or testdata/, or any test file that isn't code): any removed or changed line waits. This closes round 3's hole 2(a).

    • A guard or lint file (GUARD_FILES and LINT_TESTS, the same file set as before): any removed or changed line waits.

    • A registered table (CHECK_TABLES, the same tables) is read as its lines, from its declaration to the line that closes it at its indent:

      • an entry gone from a rule list, a bad-fixture list or a guard's list waits (never-shrink; package.json scripts is one);
      • an entry new in an allowlist or exception list waits (never-grow).

      A table only after the change is new and passes. A table gone waits.

    • An allowlist file (.denylist-allow, .gitleaksignore, .eslintignore, .semgrepignore): any added line waits.

    • Any file but a doc (a test file under docs/ isn't a doc, since round 5): an added line holding a mark from MARKERS waits. MARKERS is the one list, plain substrings, owner 2026-10-19:

      • the owner's list: .only(, test.only, it.only, describe.only, fit(, fdescribe(, .skip(, skip:, t.skip, test.skip, it.skip, xit(, xdescribe(, @pytest.mark.skip, @unittest.skip, t.Skip(, eslint-disable, eslint , @ts-ignore, @ts-expect-error, @ts-nocheck, NOLINT, noqa, type: ignore, #[allow(, #[expect(, swiftlint:disable, rubocop:disable, rubocop:todo, nolint, istanbul ignore, c8 ignore;
      • round 5's additions (above);
      • plus the old module's markers: xtest(, .todo(, todo:, @pytest.mark.xfail, t.SkipNow(, biome-ignore, oxlint-disable, tslint:disable, deno-lint-ignore, pyright: ignore, mypy: ignore, pylint: disable, #![allow(, v8 ignore, pragma: no cover, nosec, nosemgrep, NOSONAR, gitleaks:allow, @SuppressWarnings, phpcs:ignore, shellcheck disable, stylelint-disable, yamllint disable, hadolint ignore.
    • Any other pure addition passes.

    • Bounded and fail closed.

      • The rule makes one pass over the changed lines of each file, using substrings and one regex of literal alternatives with one-character look-behinds and no quantifier (a test checks the regex has none).
      • The PR waits when:
        • the changed lines pass MAX_CHANGED_CHARS (4,000,000) characters (since round 5, checked before any line is read);
        • a file's diff lines don't match git's own counts (cut short, or a name git can't be handed);
        • a changed file isn't text (a NUL, or bytes that aren't UTF-8). Since round 5 this covers any file, not only test, guard, lint, allowlist and table files;
        • a table file is over MAX_TABLE_FILE (500,000 bytes, not even read);
        • a registered table can't be read plainly (round 5);
        • git fails.
      • An image or other asset, by its extension, is skipped.
    • Scope: the engine's own source paths stand aside in a managed app's repo, as the locks do (review.ts lockedCategories, never named here).

  • weakeningOf reads three things:
    • the deleted paths, through the changed-files helper (changedNames with --diff-filter=D);
    • only the files with a registered table, through filesAt;
    • the diff lines changeFiles already gives mergeDecisionFor.
  • src/merge-policy.ts, src/coordinator/merge-flow.ts and filesAt in src/coordinator/change-files.ts are unchanged since round 3.
  • Moved to test/merge-policy.test.ts (they were only ever used by the backstop tests): NOT_A_CHECK, NOT_A_LINT, checkShapedTables and the source-listing pattern. NOT_A_CHECK and NOT_A_LINT are registered never-grow tables there.
  • Docs: the docs/design.md paragraph and the CHANGELOG bullet are rewritten for the line rule, with its gaps. The bullet survives withSection regeneration (measured in memory with scripts/lib/changelog.mjs).

Owner decisions this PR follows

How it was verified

All runs used a scratch HOME, TMPDIR, WORKLANE_STATE_DIR and WORKLANE_HELD_OUT_DIR under the session scratchpad. No real state was touched. Times were taken on a shared machine under load (load average 22–54).

  • Fail-without, round 5 (measured). I wrote the new tests first and ran them on round 4's module (this branch, before the module changed; no git checkout <ref> -- <paths>).

    • test/merge-policy.test.ts: 42 tests, 35 pass, 7 fail (the guard-files test's fixture path and the Swift path were changed after this run, see Rule 10). The seven are:
      • the other languages and nested paths;
      • the pure rename (unit test);
      • the table tricks;
      • the marks (the review's list, RSpec forms, and the non-text lines);
      • the bound (four files over the cap gave the cap reason plus a guard reason);
      • the two new acceptance tests (rename, Latin-1 beside a mark).
    • test/fix-forward-coordinator.test.ts: with its new fixture, 2/2 on round 4's module, so the fixture alone fixes CI. With main's fixture at round 4's head it was 0/2 (CI run 39326108484, measured by CI and the reviewer).
    • At this head: all of them pass (below).
  • Fail-without, round 4 (measured; scratch worktree at f8a7010, test/merge-policy.test.ts):

    • With src/merge-policy.ts and src/coordinator/merge-flow.ts as on main (no wiring): # tests 40 # pass 35 # fail 5. The five are the merge-decision test and four acceptance tests: a deleted assertion, an added .skip, an allowlist entry and a weakened guard. The no-over-hold acceptance test ("only adds tests") passes both ways.
    • With the wiring back and src/merge-policy-weakening.ts as at 3871c80 (the old parser): # tests 40 # pass 26 # fail 14. Every rule test fails: the line rule, the other languages, deleted files, expected outputs, guard lines, tables, marks, bounds and weakeningOf. So do the backstop (its tableLines is new) and the four acceptance tests, on the new reasons' wording.
    • At f8a7010: 40/40.
  • Targeted, at the merged head (measured): merge-policy, change-files-renames, review, slot-path, brand and package-files give # tests 161 # pass 161 # fail 0. tsc is clean (exit 0).

  • The bound, round 5 (measured on the final build: weakeningReasons alone, one process each, --max-old-space-size=1024, two runs each, load average 8–18):

    Shape Changed chars Round 4 (reviewer) Round 5
    4 guard files at the cap, distinct short removed lines (the review's worst) 4.0 M 792–1,297 ms (load 11–13); 2,331 ms (load 48); +141 MB 38–39 ms, 26 MB heap, held
    4 fixture files, distinct lines 4.0 M 780 ms, +140 MB 33–37 ms, held
    4 files, the cap reached in the last 4.04 M 258 ms 17–20 ms, one reason, held unread
    4 sources, every added line a mark 3.6 M 204 ms 40–46 ms, held
    2,000,000 one-character lines in a guard file (the worst I found) 4.0 M 322–462 ms 411–417 ms, 73 MB, held
    Every assertion line changed, at the cap 4.0 M 221 ms (4 files) 84–87 ms, held
    Near-misses for every CHECK_LINE word, removed 4.0 M 59 ms 26 ms
    Near-misses for every mark, added 4.0 M — 33–36 ms (skip in the near-miss text holds)
    One 4,000,000-character line 4.0 M — 21 ms
    A 490 KB allowlist table, one entry added tiny 124 ms (4 tables) 30–32 ms, held
    Just over the cap, one file 4.03 M — 9–10 ms, held unread
    • The worst case is 0.41 s at load 8–18, from 2,000,000 one-character lines (0.49–0.67 s at load 24–33, measured on the same build). The per-line cost is a fixed handful of checks, so the most lines cost the most. The marks are one regex built from MARKERS' escaped literals, with no quantifier.
    • I didn't re-measure end to end at high load this round. Round 4's 2.3–2.7 s end to end at load 47–48 was mostly the per-line map, which is gone. changeFiles (unchanged, before this check) took 0.7–1.0 s there (reviewer).
  • The bound, round 4 (measured; kept for the record, and its worst case was wrong, see Corrections):

    Shape Changed chars Module (reviewer, round 3) Line rule
    x . x . ... dot chain in a test body (one side 500,000) 1.0 M 39.2 s per parse 9.0 ms, passes (no assertion on the line)
    const a: ... with no = 1.0 M 26.3 s 12.3 ms
    const a = function ... with no ( 1.0 M 2.8 s 11.6 ms
    15,000 const vK: < = K; and one assertion changed 0.19 M 27.7 s 3.3 ms, held
    Dot chain in four 250,000-character files 2.0 M 132 s, no reason 11.5 ms
    Every assertion changed: 5,000 / 9,000 / at the cap 0.59 / 0.29 / 4.0 M 32.1 s / 51 s / over 200 s 27.7 / 21.4 / 149 ms, held
    Two near-cap files with 7,900-character contexts tiny 1.4 GB heap; out of memory under 1 GB 2.5 ms, 20 MB heap
    Near-misses for every CHECK_LINE alternative, removed, at the cap 4.0 M — 83.8 ms
    Near-misses for every mark, added, at the cap 4.0 M — 159.9 ms
    One 4,000,000-character line 4.0 M — 39.2 ms
    2,000,000 one-character lines in a guard file (the worst found) 4.0 M — 463.7 ms, 73 MB, held
    2,000 test files, one assertion changed in each 0.11 M — 40.9 ms, held
    A 490 KB allowlist table, one entry added tiny — 30.6 ms, held
    Just over the cap 4.03 M — 22.0 ms, held unread
    • Round 4 said "the worst case is under half a second" and "over the cap waits in 22 ms". Both were false; see Corrections.
    • Over the last 150 merged PRs, weakeningOf (git calls included) took at most 79 ms for a PR. The parser took at most 1,120 ms.
  • Checks (measured): see "Local results and CI" below.

150-PR comparison, round 5 (measured: the same 150 PRs, the same method)

Round 4 Round 5
PRs that get a reason 106 114
Newly held (wait for nothing else) 11 12: round 4's 11, plus #664
Tests-only PRs held by this rule 9 of 16 10 of 16: round 4's 9, plus #654
Slowest PR (weakeningOf, git included) 79 ms 79 ms

150-PR comparison, round 4 (measured: last 150 merged PRs to 128453a, each PR's own change, today's review.yaml)

Parser (3871c80) Line rule (round 4)
PRs that get a reason 99 106
Newly held (wait for nothing else) 9: #797, #790, #777, #684, #656, #627, #620, #547, #503 11: the same 9, plus #653 and #594
Tests-only PRs that wait 11 of 16 9 of 16: #797, #790, #777, #692, #653, #652, #684, #656, #547
#654 (tests-only, add-only) passes passes
Slowest PR 1,120 ms 79 ms

Rule 10: main's fix-forward test (round 5)

One existing main test changed: test/fix-forward-coordinator.test.ts, its worker's rename step. It now leaves test/price.test.js's assertion lines alone and imports the renamed function under its old local name. Why it isn't weaker is under Round 5, item 1. No other main test changed. git diff origin/main -- test/ touches only that file and test/merge-policy.test.ts, where main's 22 tests are unchanged.

Two of this PR's own round-4 tests changed fixtures:

  • the guard-files test uses .github/actions/setup/action.yml instead of .github/actions/test/action.yml. The old path is now also a nested test path, so it gets an extra fixture reason;
  • the Swift case uses Tests/, which is now a test path.

Rule 10: every test the parser had, kept or not

All 36 weakening and acceptance tests were this PR's own (none on main). Each behaviour the line rule keeps has a test. Those it no longer has were the parser's, and a test can't assert them for a rule that doesn't have them.

  • Kept, rewritten for the line rule:

    • an assertion removed, loosened or unawaited; a whole test removed; an expected value or matcher changed, tests only and with code (owner's rule test);
    • other languages (new test);
    • a deleted test file;
    • adding tests passes;
    • an allowlist grown and a count raised;
    • a rule taken out of a lint or a guard list;
    • guard and lint files, every scope;
    • the round-3 guard files (merged into the guard test);
    • suppressions and skips (now the marks test, one fixture per mark, with negatives);
    • a doc naming a mark (in the marks test);
    • fails closed (now "bounded and fail closed");
    • the merge decision waits whatever the level;
    • the registry (now the backstop: each table is found, and every check-shaped table is registered);
    • every test that lists the sources is registered (backstop);
    • the five acceptance tests.

    The "only adds tests" acceptance test no longer adds a test with { skip: ... } (now a mark), nor renames a test file (now a deleted path). The same fixtures are asserted the other way in the marks and deleted-file tests.

  • Removed, with the reason each no longer applies:

Named gaps (measured: the reviewers' fixtures fed to the new rule)

Each passes under the line rule. "Waits anyway" means it waits for the owner for another reason in this repo.

  • A changed line with no assertion or test on it:
    • a setup line, or the actual's input (got = 3; inserted, let got = (add(1, 2), 3));
    • an expected value in a const, on a line of its own, or in a case-table row (a row changed, swapped, or removed from a spread source);
    • Go's if Add(1, 2) != 3 (its t.Fatal line waits);
    • a call through an alias (eq(x, 3) changed or removed; the alias's own line waits).
  • A pure insertion with no mark that makes a test run less:
    • return;, if (x) return, process.exit(0);
    • a patched or shadowed assert;
    • import { skip as test };
    • a test wrapped in a function nothing calls;
    • a skip const flipped in a helper (SKIP_SLOW = true);
    • in a lint test, files.splice(0), offenders.length = 0, a new hook or helper that empties the list, or a copied Set grown.
  • A line added to a guard or config with no mark: continue-on-error: true, test.exclude in vite.config, an ignorePatterns, a lint step added to .husky. CI workflows are locked in every repo and wait anyway.
  • A lint's rule outside its registered lines and outside an assertion line: a rule in a string, or words in an array, in a lint test that isn't registered.
  • Files not in the guard set, unchanged from round 3: Makefile, justfile, phpstan.neon and .swiftlint.yml. pyproject.toml, setup.cfg and Cargo.toml wait anyway as dependency manifests.
  • Named in round 5, measured by the reviewer at round 4, with no change since:
    • a mode-only change: .githooks/pre-push, .husky/pre-commit or test/run.sh going 755 to 644 has no lines, so nothing is read. git skips a hook that isn't executable (inferred);
    • a submodule link outside the test paths (vendor/suite) moved;
    • Rust's inline #[cfg(test)] tests in src/ (a test asserts it passes);
    • other runners' and linters' skip and suppression forms beyond MARKERS (the long tail).
  • Stricter than the rule, by design (measured by the reviewer): a deleted test helper with no assertion waits (a deleted test file), and so does a whole-file LF-to-CRLF change (every line changed).
  • Closed in round 4, these gaps now wait:
    • every mark the round-3 review listed: a top-level beforeEach((t) => t.skip()); the outer t.skip() from a new subtest; a new test.only; NOLINTNEXTLINE and NOLINTBEGIN; #[expect(; swiftlint:disable; rubocop:todo; ESLint inline config to "warn" or ["off"];
    • a Python test's expected value, a snapshot and an expected-output fixture, tests only;
    • a const past 400 characters, when it sits in the test (its line changed);
    • the mocked unit and the fixture module made tautological, through their changed lines.

Round 3's findings under the line rule (measured with the reviewer's fixtures)

  • Stalls: none. The table above has every round-3 shape.
  • Decision 1:
    • These now wait: Python, the snapshot, test/fixtures/expected.json, the test-local const, and the alias made assert.ok.
    • These pass: Go's comparison line, a top-level const changed past 400 characters (its line has no assertion), and an alias call changed or removed.
    • Over-holds: a renamed test title and a renamed local (got to actual) both wait. They change assertion lines, which the owner accepted.
    • The add-only over-holds via return/throw are gone: all six add-only fixtures pass.
    • The three round-2 add-only fixtures with a skip now wait (a mark).
  • The two regressions: the outer t.skip() from a new subtest waits (a mark). A new helper and its call line in a LINT_TESTS file pass (a named gap).
  • New weakenings:
    • these wait: the top-level beforeEach skip, test.only, NOLINT forms, #[expect(, swiftlint:disable, rubocop:todo and ESLint warn/array config;
    • these pass (named gaps): offenders.length = 0, files.splice(...) inserted, and Makefile, phpstan.neon and .swiftlint.yml.

Package size (measured)

No raise. PACKAGE_BUDGET stays main's ({ size: 1_950_000, unpackedSize: 6_060_000 }), and the PR fits it with room to spare.

  • How it was measured: npm ci, npm run build, npm run build:web and npm pack --dry-run --json, in a fresh worktree of main (71316eb) and in this branch at 9096a3f (main merged in).

  • Results:

    Tree Tarball Unpacked Files
    main (71316eb) 1,903,282 5,938,018 512
    this PR merged with main (round 5) 1,911,634 5,967,997 513
  • Delta against main: +8,352 bytes tarball, +29,979 unpacked, from only these files:

    • dist/src/merge-policy-weakening.js (new): 26,964. The parser was 80,220.
    • dist/src/coordinator/change-files.js: +2,618 (filesAt and round 5's exactMoves).
    • dist/src/coordinator/merge-flow.js: +259.
    • dist/src/merge-policy.js: +138.
  • Earlier deltas: round 4 was +6,050 / +25,320, and round 3 was +24,457 / +81,878.

  • No sourcemap, .d.ts, test, fixture or stray file. The only test-like file in the pack, examples/basic/test/price.test.js, is in main's pack too.

  • A measurement trap I hit (principle 5): a reused worktree's dist/ kept a stale merge-policy-weakening.js from an earlier build, so its "main" counted 80,220 bytes main doesn't have. The figures above come from a fresh worktree.

  • Why 27 KB: most of it is the registries (CHECK_TABLES, GUARD_FILES, LINT_TESTS, TEST_PATHS, MARKERS), the same file set as before, and their comments. The rule itself is about 120 lines.

Local results and CI

  • Local, measured at 9096a3f (round 5; main 71316eb merged in):
    • npm run typecheck is clean (both projects, exit 0).
    • node scripts/check-denylist.mjs: "1310 files clean".
    • node scripts/check-dco.mjs origin/main HEAD: "all commits signed off".
    • node scripts/push-gate-hook.mjs --range origin/main..HEAD: "9 commit(s) and 1 tip(s) clean".
    • The targeted files: merge-policy, fix-forward-coordinator (shard 2's failing file), change-files-renames, review, slot-path, brand and package-files give # tests 165 # pass 165 # fail 0.
    • Full suite (scratchpad fullsuite.sh, scratch HOME, TMPDIR and state dirs; mcp-* and plugin-fetch excluded by rule): # tests 3856 # pass 3794 # fail 5. The 5 are two isolate.test TMPDIR tests (Tests: temporary dirs live under the isolated test machine (TMPDIR in test/isolate.ts), so a run leaves nothing in /tmp #604) and three testrun db-start regressions (Docker). Round 1 measured the same files failing the same way with main's wiring under a scratch env. This PR touches none of them (git diff origin/main --stat), so they come from this machine's env (inferred). test/fix-forward-coordinator.test.ts passes in the full run.
    • A note on that run: I rebuilt dist/ once while it ran, to time a change that read the marks as one regex instead of a substring loop (same matches). Later files may have loaded either build. The targeted 165 ran on the final build.
  • CI: the head is 7c632eb (9096a3f plus the CHANGELOG heading). Its CI was still running when this body was written. Round 4's head ca0b16f was red in shard 2/4 (the fix-forward test, item 1), and every other check passed.

Corrections (principle 5)

Overlaps (measured with git merge-tree --write-tree at the merged head)

Adversarial pass (round 4: the line rule)

Each attack is a fixture fed to weakeningReasons, or to mergeDecisionFor on a scratch repo (rule 8).

Family Attack Result
Assertions expected value, matcher, await dropped, assertion or test removed, tests only or with code waits
Assertions the same in Python, Go t.Fatal/require., Rust assert_eq!, Java @Test, jest .toBe, chai .should waits
Assertions a setup line, a const or a case row changed; Go != 3 changed; an alias call changed passes (named gap)
Over-hold a comment ending "it." or "the test." removed (held #653 and #610 at first) fixed: it./test./t. need a letter after the dot
Over-hold a table registered today but missing in an old PR's base and head (held #654 at first); a new lint's own new allowlist fixed: a table only after the change, or on neither side, has nothing to loosen
Skips test.skip, .only, t.skip(), beforeEach(t => t.skip()), xit(, @pytest.mark.skip, t.Skip( added waits (mark)
Skips return; inserted; a skip const flipped; import { skip as test } passes (named gap)
Suppressions every MARKERS entry inside an ordinary line, in a source, a test, a workflow, a Python file and Cargo.toml waits
Suppressions a doc or README naming a mark passes, as intended
Expected outputs snapshot, fixtures/*.json, .jsonl, a fixture server, testdata/*.golden, *.stdout changed waits; lines only added pass
Deleted tests a test file deleted, moved, or a helper deleted waits
Guards and lints a line removed or changed in each of 23 guard and lint paths, engine and app scope waits (engine-only paths stand aside in an app repo)
Guards and lints a line added to one with no mark passes (named gap)
Tables allowlist entry added (also after the last entry, its comma moved), count raised, rule removed, bad fixture removed, ].slice(1) on the literal, table renamed, a package.json script changed waits
Bounds over the cap, a diff shorter than git's counts, a NUL, U+FFFD, a table file over 500,000 bytes, a reader that throws, an unknown commit waits
Bounds the slowest shape found at the cap (2,000,000 one-character lines) 464 ms
The hold itself a line of the module removed; a registry entry removed waits: the module is a guard file and L3
Round 5
Renames git mv of a test file, or of a test folder with its fixtures, content unchanged (real git) passes, as #783 asks (was: waits)
Renames a rename with an expected value edited; to .spec.ts; out of the test paths waits
Marks eslint-disable-next-line or @ts-nocheck beside a NUL, a literal U+FFFD or a Latin-1 byte (real git for Latin-1) waits: marks are read on every added line, and the file waits as not text (was: passes, file skipped)
Bounds a source that isn't text, with no mark waits (was: passes)
Tables a decoy declaration in a comment, then an entry added to the real allowlist or removed from the real bad-fixture list waits: declared twice (was: passes)
Tables an entry, or a comment then an entry, at column 0 inside an allowlist waits: a line at its indent that doesn't close it (was: passes)
Marks t.Skipf(, self.skipTest(, raise unittest.SkipTest, pytest.importorskip(, #[ignore], @Disabled, @Ignore, describe.skipIf(, describe.skip.each(, test.runIf(false), xcontext(, pending(), xit 'x' do, skip 'x', [Fact(Skip = ...)], #pragma warning disable, throw XCTSkip(, @Suppress(, @phpstan-ignore-next-line, @psalm-suppress, # pylint:disable=, # type:ignore waits, each with a fixture (was: passes)
Marks test.only(...) added to docs/examples/a.test.js waits (was: passes)
Assertions C# Assert.Equal/[Fact]/[Theory] removed; Swift XCTAssertEqual removed; minitest must_equal/refute_nil removed; bats [ ... ] and @test removed waits (was: passes)
Assertions a [ ... ] check removed from test/cli.sh waits (every removed line of a shell test, was: passes)
Paths packages/x/test/math.js assertion removed; packages/x/test/fixtures/expected.json value changed; packages/x/spec/math.js waits (was: passes)
Paths src/lib.rs inline assert_eq! removed passes (named gap)
Bounds four guard files at the cap, distinct lines 38–105 ms across loads 8–33 (was 0.8–2.3 s)
Bounds four files over the cap one reason, before any line is read, 17–57 ms (was 258 ms and a guard reason too)
Bounds a mode change 755 to 644 on a hook; a gitlink moved outside the test paths passes (named gaps)
CI main's fix-forward scenario green with its rename leaving assertion lines alone
Round 6
Renames tests/test_math.py to tests/math.py / tests/math_helpers.py; MathTest.java to MathCase.java; spec/math_spec.rb to spec/math.rb (out of the runner's naming) waits (was: passes)
Renames test/x.test.ts to scripts/x.test.ts (out of the test-folder root); to docs/ or test/fixtures/ or test/__snapshots__/ waits (was: passes); real git for scripts/ and test/fixtures/
Renames tests/conftest.py into tests/old/; test/tsconfig.json and test/jest.config.js into test/old/; test/brand.test.ts (lint test), test/isolate.ts (guard) or a registered lint test renamed waits (was: passes)
Renames a fixture moved (a fixtures folder moved with its tests) waits: no runner selects it (stricter than round 5)
Renames test/math.test.ts to test/sum.test.ts; a test folder moved; test_sum.py to test_total.py; MathTest.java to SumTest.java; math_spec.rb to sum_spec.rb; math_test.go to sum_test.go; a co-located *.test.tsx renamed passes, as decided
Tables a decoy copy in a comment above, the real declaration made var ALLOWED, const ALLOWED or let<tab>ALLOWED, and an entry added waits (was: passes)
Tables the declaring line changed alone (const to var) waits
Tables var ALLOWED (two spaces) with an entry added waits as table-grown (read)
Round 7
Renames test/x.test.ts to test/node_modules/x.test.ts, test/.old/x.test.ts or test/__mocks__/x.test.ts waits (was: passes)
Renames a symlinked test moved one level down with its link text kept, so it points at a new empty test (real git) waits as a deleted test file: only regular files pair (was: passes)
Renames a submodule (gitlink) moved never paired as a rename (mode 160000)
Tables const ALLOWED: with its string[] = [ on the next line, an entry added waits: unreadable (was: passes as a one-line table)
Round 8
Renames test/x.test.ts to test/.x.test.ts (a leading-dot name) waits (was: passes)
Tables const ALLOWED: string[] = with the list below, an entry added waits: no bracket after the = (was: passes)
Tables const ALLOWED = // see [docs] with the list below, an entry added waits: a one-line table followed by a deeper line (was: passes)
Tables const ALLOWED = new Set() with .add(...) below, an entry added waits: the same (was: passes)
Round 9
Tables ALLOWED['test/c.test.ts'] = 1; added to test/no-clock-helpers.test.ts (L0) waits: table-used (was: passes)
Tables Object.assign(ALLOWED, {...}), .push(...), new Set(Object.keys(ALLOWED)).add(...), const ALLOWED_TOO = { ...ALLOWED, ... } waits (was: passes)
Tables OUTSIDE_THE_HELPER.b = 2 in change-files-renames.test.ts; ALLOWED.b = 2 in no-wall-clock-durations.test.ts waits (was: passes)
Tables const ALLOWED = // see [docs] with the list on the next line at the same indent waits: unreadable, the comment stripped (was: the list never read)
Over-hold a line added that only reads a table (for (const c of ALWAYS_L3)) waits too (#766, #688 and #691 in the 150, each waiting anyway)
Round 10
Tables eval('ALL' + 'OWED')['test/c.test.ts'] = 1;, new Function(...), Function('return this')() added in a registered table's file waits (was: passes)
Guards script-shell=true added to .npmrc or web/.npmrc, engine and app scope waits (was: passes, L1 auto in the engine)
Guards a line removed from .npmrc waits
Tables a table mutated from another module; a copy of a table already on main, then grown passes (named gaps; another module of the engine is L3 anyway)
Round 11
Tables eval ('ALL' + 'OWED')['x'] = 1 (a space before the parenthesis) waits: whole-word match (was: passes)
Guards a line added to or removed from .yarnrc, .yarnrc.yml, bunfig.toml or .bunfig.toml, engine and app scope waits (was: passes)
Guards an onlyBuiltDependencies entry removed from pnpm-workspace.yaml waits (was: passes)
Guards a package added to pnpm-workspace.yaml; an added pnpm script setting there passes (the second is a named gap)

What this can never do

  • Make anything merge that waited before. It only appends reasons to mergeDecision (measured: mergeDecision(clean({ weakening: [] })) is unchanged). No existing reason, category, lock, level or limit is changed or removed. No check, test or lint on main was weakened or removed (rule 10): every removed test was this PR's own, listed above. Existing installs keep every hold they had (inferred from the diff: outside the new module, additions only).
  • Read the evaluator's or a worker's words. It reads only the diff and the git objects of base and head.
  • Write anything. changedNames and filesAt run diff --name-only, ls-tree and cat-file (read-only), with --literal-pathspecs and replace refs off.
  • Turn auto-merge on or off, or change who may merge.
  • Stall the merge path. The changed lines are added up first, and over 4,000,000 characters the PR waits before any line is read. Under the cap, one pass reads them with quantifier-free patterns: substrings and literal alternatives. The measured worst case for weakeningReasons alone is 0.41 s at load 8–18, and 0.49–0.67 s at load 24–33 (2,000,000 one-character lines). See the bound table.

Compromised component

  • Agent user / worker:
    • It can write test and source changes. It can't make the hold go away: the hold reads the PR's own diff, and the module, its registries and the tests are locked (src/merge-policy*, test/merge-policy.test.ts).
    • It can use the named gaps, all pure insertions or changed lines with no assertion or mark. The evaluator's test_correct and the owner's review of L3 paths still stand.
  • Coordinator: runs the hold. A coordinator that is itself compromised could skip mergeDecisionFor, as it could skip every other reason. Unchanged by this PR.
  • Dashboard / hub viewer / other instances: no route reaches this. It runs inside the coordinator's merge decision only.
  • Someone with push access to a PR branch: a push by anyone but the harness already waits (foreignPush), and the hold runs on the pushed head too.
  • Someone who can edit the base branch: could edit the registries. The module is self-modification (L3, the owner merges), and any line removed from it waits.
  • A crafted file name (non-UTF-8, control characters): its diff lines aren't read, so the counts don't match and the PR waits. It is also L3 by the existing odd-path categories.

New dependencies

None.

…s for the owner (#783)

The owner's rule (decision guide, principles 7 and 9) is that no agent removes or weakens a check, and that
an added exception, allowlist entry or skip is a loosening. Until now only the evaluator's test_correct answer
stood between such a PR and auto-merge. mergeDecision now gets a `design: may weaken a check:` reason from the
change itself (src/merge-policy-weakening.ts), read through the one changed-files helper (changeFiles, plus a
new filesAt there for a changed file's text before and after):

- tests: with code changed too, an assertion gone or changed (read with its enclosing blocks, what leads into
  it and the exits before it, so one made conditional, unreachable, unawaited or swallowed counts) or a test's
  title gone; in a change of tests only, fewer assertions or tests. Always: an existing test that now runs
  differently (.skip/.todo/.only, its options), an existing file whose assertions call something else (an
  assert import changed, a shadow, a patch) or that now exits the process, and a deleted test file unless the
  change adds the same text as a test file in the same folder;
- lints and guards: a registry of check tables (CHECK_TABLES, with spreads and same-file names followed): an
  entry taken out of a rule list, bad fixtures or a guard's list, or added to an allowlist or good fixtures;
  package.json scripts; a code line removed from a guard, lint, runner or coverage config file (GUARD_FILES),
  or added there with an early exit or an off switch; a line removed from a lint test outside its tables; a
  line added to an allowlist file; an inline suppression added anywhere. A test fails on a check-shaped table
  in test/ or scripts/ that isn't registered (it found test/slot-path.test.ts MAKER_ONLY from #806);
- fail closed: a file it can't read or parse, a table it can't find, or a git failure holds.

The module's name falls under src/merge-policy*, so changing it is self-modification. Engine source paths stand
aside in a managed app's repo, as the locks do; review.ts decides the scope, so this module never names it.
Measured on the last 150 merged PRs (to 178c44e): 4 would have waited that didn't otherwise (#797 added an
OUTSIDE_THE_HELPER entry; #627 and #620 changed web code and its assertions; #503 added eslint-disable).

Test (fails without): test/merge-policy.test.ts "acceptance (#783)" cases run merge-flow's mergeDecisionFor on
scratch repos; with merge-flow.ts and merge-policy.ts as on main they get auto: true for a deleted assertion,
an added .skip, an added allowlist entry and a removed lint rule, and no weakening reason for a weakened guard.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…783)

CI's fix-forward test (test/fix-forward-coordinator.test.ts) merges a PR that renames totalCents to
totalInCents in src/price.js and in test/price.test.js; the weakening hold read the test's assertion as changed
and held it, so the PR never merged. A routine rename doesn't loosen anything: the assertion's expectation,
literals and shape are the same. An assertion now counts as unchanged when it differs only by identifiers, each
renamed the same way in the change's code (its old name gone from the code, its new one new there), and never an
assertion's own name (equal, ok, assert*, expect*, a keyword, or a .toBe/.isTrue matcher after a dot).

Test (fails without): test/merge-policy.test.ts "a rename the code makes too ... is no change" (a rename of
add/sub to total/isSum held with "removes or changes a test assertion" before); CI's fix-forward-coordinator
tests (held at "merged one after the other" before).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 19, 2026
…n from an older head

Review round 1 on #816 (issuecomment-6272706512):

- A merge.decided or merge.failed counts only at the PR's latest known head (its latest pr.opened, pr.status,
  pr.ready or pr.unready). A held decision at head A followed by a push of B no longer shows A's hold; an auto
  decision at A followed by failing checks on B no longer says "will merge it". The stage waits: "a new head:
  it waits for its checks and a fresh decision".
- The card carries no free text. The block line is fixed words pointing to the Board (never issue.blocked.why);
  the issue's title is gone; a run's end reason and a verdict's confidence pass only as enum words.
- src/hold-words.ts words every kind merge.decided carries today, not only mergeDecision's: GitHub can't merge
  cleanly, merging failed, GitHub still computing, the combined check (conflict, no branch point, fast tests
  failing), the docs-only ruleset hold, and conflict fix. An unknown prefix reads "Another reason (see the
  Board)", never its text. The design sentence now also names a possible weakened check (#820), worded as
  examples so it holds whether or not #820 lands first.
- A new hold prefix fails a test by design: a source scan over every source of merge.decided reasons
  (merge-policy.ts, merge-flow.ts mergeOrWait, coordinator.ts combinedGate, pr-rules.ts rulesetHold, conflicts.ts
  conflictWaitReasons) checks each literal reason's prefix has words, and that every reason pushed from a
  variable is a traced one. Fixture text proves it catches a new prefix and a new untraced source.
- prLink fails closed: no repo given, no link.

Correction to 11c9f66's message: its "still warns ... as before" test does not pass before; it fails on main,
which counts 2 steps (Your first task too) where the test expects 1.

Test (fails without): test/first-task-progress.test.ts, "#741 a decision or a failed merge from an older head",
"#741 the card shows no evaluator text, path or run detail", and the source-scan test (its fixture lines).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (round 1)

Reviewed head a3fc766c against #783 and the decision guide (principles 1, 7 and 9). The design is right: the hold only adds reasons, it reads the change itself, and the gate's own files are L3. But the core check (principle 9) misses an explicit skip and several cheap weakenings, so it can't pass yet. The package size budget is pending the owner and is not a required change here.

Acceptance criteria

Criterion (#783) Status Evidence
A structural hold in mergeDecision, never the evaluator's text met src/merge-policy.ts:250, src/coordinator/merge-flow.ts:540 (measured: acceptance tests run the real mergeDecisionFor)
Tests (with code changed): assert removed, .skip/.todo added, strict to loose, expected widened, test or file deleted partly Holds: removed, strict to loose, widened, unawaited, conditional, try/catch, string or comment, test.skip/.todo/.only, { skip }, describe.skip (measured). Missed: t.skip() or t.todo() in an existing test's body, rename abuse, and renaming a test out of the runner (RC1-RC3).
Lint rules (any PR): a rule or pattern removed from a lint's table or matcher; bad fixtures shrunk; allowlist grown; lint script removed partly Registered tables, package.json scripts and registered lint tests' logic hold (measured). Missed: a matcher in a lint test with no registered table (RC4), and a registered table shrunk by .slice()/.filter() on its literal (RC5).
Guards (any PR): an entry removed from a guard table, a refusal removed or made conditional, fail-closed made fail-open partly Guard source files and review.yaml hold on removed lines and bypass words (measured). Missed: config.yaml required_checks and tests.yaml tiers (RC6), and pure insertions in config guard files (RC7).
A registry the hold reads; a test fails when a known table isn't registered met for tables The registry test passes at head. #806's MAKER_ONLY is never-shrink (correct: it names what only the maker may use), and SLOT_SOURCES is never-grow (correct: an allowlist). Both inferred from test/slot-path.test.ts:215-217.
Fixture tests: each kind holds; adding doesn't; same-content rename doesn't; each fails without met for what it covers See the fail-without row below.

Attacks (measured: fixtures fed to weakeningReasons from dist/; no repo file edited)

"Code" means a change that also changes src/. "Tests only" means the change touches only tests.

Attack Code Tests only
deepEqual to ok; exact regex to loose; number to range; console.assert; equal(x, x) HELD pass (the issue's scope, see the owner trade-off)
Inline timeout raised HELD HELD
timeout raised in an options const (const OPTS = { timeout }) pass pass
Test wrapped in if (process.env.FULL); test function made function* (never runs) HELD pass
t.skip() / t.todo() added in an existing test's body (test already takes t) pass pass
try/catch around an assertion HELD pass
Assertion made a string or a comment; moved to a local helper that doesn't assert HELD HELD
Rename abuse: assert.equal(got, wanted) to assert.equal(got, got), with // wanted to // got (or const wanted to const got) in src pass n/a
assertStrict renamed to checkLoose in code and test HELD n/a
Test renamed in the same folder, same text, to .spec.ts / .test.mts / .test.js / .test.tsx n/a pass
Renamed to .tset.ts or into another folder n/a HELD
Unregistered lint test (test/pipefail.test.ts shape): rule regex loosened pass pass
Registered lint test: regex beside its table widened n/a HELD
LOCK_SCOPE_BAD = [...].slice(1) (registered lint test); GUARDRAIL_PATHS = [...].filter(...) (scripts/lib/changelog.mjs) n/a pass
The same on NEVER_LOOSEN in src/delegation.ts (a guard file) n/a HELD
CI: continue-on-error, || true, a changed run: line n/a HELD (both scopes)
CI: pure-inserted if: github.actor == 'nobody' n/a pass (L3 by ci-config anyway)
.worklane/config.yaml required_checks entry removed; tests.yaml tier removed n/a pass (both scopes)
review.yaml wait_categories shrunk n/a HELD
package.json test script narrowed n/a HELD
tsconfig strict: false, noImplicitAny: false added n/a HELD
tsconfig "exclude": [...] added (tsconfig.json is L1 here: measured with computeLevel) n/a pass
eslint.config.js ignores, .eslintrc ignorePatterns, vitest exclude, jest testPathIgnorePatterns/passWithNoTests, .gitleaks.toml allowlist path added n/a pass
Inline eslint-disable, @ts-ignore n/a HELD

node --test semantics behind the t.skip() row (measured, scratch file): a test that calls t.skip() or t.todo() and then fails an assertion is reported # SKIP/# TODO. A function* test body never runs. The run reported # fail 0 and exited 0.

Fail closed (measured unless marked)

  • A reader that throws holds. A test file that doesn't parse after the change holds. A bad revision (git failure) holds with "could not run". Binary bytes in a .test.ts after the change hold as unreadable. A blob over 256 MB makes git's maxBuffer throw, which holds (inferred).
  • A huge file never passes, but it stalls the coordinator. testFacts is quadratic in assertions per test: 1.03 MB took 2.9 s, and 4.26 MB took 70 s per side. It runs synchronously in mergeDecisionFor on every poll (RC8).

Self-protection (measured: computeLevel, this repo's review.yaml and a bare review.yaml with no L3 categories)

src/merge-policy-weakening.ts, test/merge-policy.test.ts, src/merge-policy.ts, merge-flow.ts, change-files.ts, review.ts, guardrails/glob.ts, path-text.ts and brand.ts are all L3 by self-modification (locked in code), even with the bare review.yaml. The module's own lists are registered never-shrink, and NOT_A_CHECK is never-grow.

False positives (measured independently: changeFiles + weakeningOf over the last 150 merged PRs on origin/main 128453a, read-only git)

No regression (measured)

Fail-without (measured)

  • Scratch worktree at a3fc766 with src/coordinator/merge-flow.ts and src/merge-policy.ts from origin/main, then npm run build (2 TS2353 type errors on weakening, but it emits) and node --import ./dist/test/isolate.js --test dist/test/merge-policy.test.js: # tests 41 # pass 36 # fail 5.
  • The five that fail are "the merge decision waits on it whatever the level" and acceptance "deletes an assertion", "adds .skip", "adds a lint allowlist entry" and "weakens a guard file", each with + auto: true, reasons: [].
  • At head: # tests 41 # pass 41. The builder reported 35/5; 41 is the count at a3fc766 (inferred: the rename test came in that commit).
  • At head: merge-policy, change-files-renames, review and slot-path tests: # pass 153 # fail 0. npm run typecheck is clean. check:denylist: "1306 files clean".

Protection, hygiene, CI

  • Protected and marked correctly: the [PROTECTED: owner merges] title, **Risk class:** protected, the needs-owner label, and real "What this can never do" and "Compromised component" sections.
    • "Make anything merge that waited before" is true: it only appends reasons (measured, above).
    • "Write anything" is true: filesAt runs only ls-tree and cat-file (inferred from the code).
  • Both commits are signed off. No attribution trailers or lines. No new dependencies, no .claude/. No hardcoded product name in the new module (measured with grep). "Closes Merge: a PR that removes or weakens test assertions, lint rules or guards waits for the owner #783" is present.
  • The Adversarial pass section is present.
  • Scope is fine: 7 files, +1330/-0.
  • CI: the only failing test in run 39287373023 is test/package-files.test.ts "the package stays within its size budget" (tarball 1,908,377 > 1,900,000; unpacked 5,965,009 > 5,930,000): measured from the node-22 shard 1 log. gh returned no log lines for the node-24 shard 1 job. The aggregate test (ubuntu-latest, 24) job only reports a shard failure; inferred: the same test. Every other check is green. Pending the owner, not a required change.

Required changes

  1. t.skip() / t.todo() added inside an existing test's body must hold, in every change. Principle 9 calls it an added skip, and Merge: a PR that removes or weakens test assertions, lint rules or guards waits for the owner #783 lists ".skip/.todo added". The PR body says skip and todo "always hold". Today a new loose skip mark is ignored (missing(before.looseSkips, after.looseSkips) only holds marks that went away). Add a fixture where the test already takes t.
  2. Rename rule abuse. renamedOnly accepts assert.equal(got, wanted) to assert.equal(got, got) when the src diff drops wanted and adds got, even in a comment.
    • Fix: compute gone/came from code tokens only (not comments).
    • Fix: refuse a pair whose new name already appears in the old assertion, or whose old name still appears in the test file after the change.
    • Add both fixtures.
  3. Renaming a test file out of the runner. A same-folder, same-text rename to .spec.ts, .test.mts, .test.js or .test.tsx doesn't hold. This repo runs only dist/test/**/*.test.js built from test/**/*.ts (inferred from package.json and tsconfig.json). Require the same test suffix and extension (only the stem may change), or hold.
  4. Lint tests without a registered table. test/pipefail.test.ts's /shell:\s*true|\bexecSync\(/, loosened, passes with or without a code change. Merge: a PR that removes or weakens test assertions, lint rules or guards waits for the owner #783's lint item covers "a rule ... removed from a lint's ... matcher" in any PR. Either:
  5. A registered table shrunk by a call on its literal ([...].slice(1), .filter(...)) passes outside guard files (measured on a lint test and on scripts/lib/changelog.mjs GUARDRAIL_PATHS). Fix: tableSpan should take the whole statement when anything but as const/satisfies follows the literal, so it reads as one changed entry.
  6. <configDir>/config.yaml (required_checks) and <configDir>/tests.yaml (test-run tiers). An entry removed from either passes in both scopes. Add them to GUARD_FILES (they're already L3 harness-config here, so it costs nothing in this repo; inferred).
  7. Pure insertions in config guard files. Bypass words miss exclude, ignores, ignorePatterns, testPathIgnorePatterns, passWithNoTests, a gitleaks allowlist path and a CI if:. By principle 1, prefer that any added line holds in non-source guard files (configs, workflows, hooks), keeping the bypass-word rule for JS/TS guard sources. Otherwise, at least match the stems case-insensitively (ignor, exclud, skip, allow, passWithNoTests, if:). Add a fixture for each.
  8. Size cap. A JS/TS test or table file over a fixed size (for example 1 MB) should hold as unreadable before testFacts runs. It stays fail closed and keeps mergeDecisionFor from blocking for minutes on every poll (70 s per side at 4.26 MB, measured). Add a test.
  9. Docs false positive. Leave docs paths (isDoc) out of the suppression rule. A docs or README line that mentions eslint-disable/noqa now holds a docs-only PR (measured).
  10. The test wrapped in a condition, or made a generator, in a tests-only change (if (process.env.X) test(...), function*): hold it as "changes how an existing test runs". For example, put the test call's enclosing non-test headers and its function's kind into runs. Or name it as a known gap in the PR body, with the raised timeout in an options const beside it.
  11. Correct the PR body's claims for items 1, 3 and 10 (principle 5), and update the fail-without numbers to 36/5 of 41.

For the owner (a genuine trade-off, not decided here)

The round-1 review of #820 found cheap weakenings the hold missed; each is closed by its class:
- a test's run options now include t.skip()/t.todo()/this.skip() in its body, a generator body, a condition,
  loop, try or `&&` around it (with the literal value of a case table it loops over), and an options const it
  names, read as its value; any change to them holds, tests only too;
- an assertion carries the literal value of each const it names (scoped: the test's own, else a top-level one);
- renames come from code words only (never comments or strings), never to a name the old assertion had, and
  never with the old name still used in the test;
- a test file moved within its folder must keep its runner suffix (`.test.ts` stays `.test.ts`);
- LINT_TESTS registers lint tests by file (a removed line, or an added exit or off switch, holds); any other
  test that walks the sources holds on a regex line removed or changed;
- a registered table narrowed, mutated or declared again where it is used holds, and a literal that goes on
  into a call (`[...].slice(1)`) is read as the whole statement;
- config.yaml and tests.yaml are guard files; any line added to a guard that isn't engine source (configs,
  workflows, hooks) holds; husky, pre-commit, lefthook, pylint, mypy, stylelint and markdownlint configs join;
- a file over SIZE_CAP (500,000 characters) holds unread, and the parse is linear (measured 0.2 s at the cap,
  70 s per side at 4.26 MB before);
- docs are no longer read for inline suppressions; more markers (pylint, rubocop, shellcheck, `#[allow(`);
- whether a tests-only reworded assertion holds stays the owner's call, kept to one line (changedHolds).

Measured on the last 150 merged PRs (to 128453a): 5 newly held (#797, #627, #620, #503 and #547, a changed
regex in a lint-shaped test); one add-only test change gets a test reason (#499, waits anyway).

Test (fails without): test/merge-policy.test.ts "weakening (round 2, ...)" and "(adversarial pass, round 2)"
cases fail with src/merge-policy-weakening.ts as at a3fc766.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (round 2)

Reviewed head a8d68edb against #783, the round-1 review and the decision guide (principles 1, 5, 7 and 9). All ten round-1 gaps are closed, and the no-regression and false-positive numbers match the builder's. Three things block a pass:

  • a 500,000-character test file can still stall the coordinator for minutes, or crash it out of memory;
  • several cheap skips and lint routes still pass at L0;
  • three ways of only adding tests now wait.

The package size budget is pending the owner and is not a required change here.

Round 1's gaps (measured: the round-1 fixtures, rerun at a8d68ed)

Round-1 item Now
1. t.skip() / t.todo() in an existing test's body HELD, in every change
2. Rename abuse (comment words; const wanted to const got) HELD
3. Same-folder rename to .spec.ts, .test.mts, .test.js or .test.tsx HELD
4. Unregistered lint test (pipefail shape), regex loosened HELD
5. [...].slice(1) and .filter() on a registered literal HELD
6. config.yaml required_checks and tests.yaml tier removed HELD, in both scopes
7. Pure insertions in config guards (CI if:, exclude, ignores, ignorePatterns, testPathIgnorePatterns, passWithNoTests, gitleaks path) HELD
8. Size cap Over 500,000 characters, a file holds unread. Under the cap the parse is not linear (see Speed).
9. A docs line naming eslint-disable or noqa No longer held
10. A test behind if, or made a generator, in a tests-only change; timeout raised in an options const HELD
11. Claims corrected Done for 1, 3 and 10, and for the 36/5 of 41

Round-2 attacks (measured: fixtures fed to weakeningReasons from dist/; no repo file edited)

"Code" means the change also changes src/. "Tests only" means it touches only tests. "Engine" and "app" are the two lock scopes. Levels are measured with computeLevel: this repo's review.yaml, and DEFAULT_REVIEW in a managed app.

Family Attack Code Tests only / app
Assertions Actual value overwritten before the assertion (got = 3; inserted) pass pass
Assertions Actual value recomputed (let got = (add(1, 2), 3)) pass pass
Assertions An imported test helper that throws (not assert*) emptied (test/helpers.ts, L0) pass pass
Assertions Expected value moved into an imported JS fixture (test/fixtures/expected.ts) and made tautological pass pass
Assertions The unit under test mocked to return the expected value HELD pass (the open owner decision)
Skips { skip: SKIP_SLOW } with const SKIP_SLOW = false flipped to true, or made !process.env.X pass pass
Skips import { skip as test } from 'node:test' (node:test: every test # SKIP, exit 0) pass pass
Skips from 'node:test' swapped to a local no-op module pass pass
Skips beforeEach((t) => t.skip()) added (node:test: the failing test is # SKIP, exit 0) HELD HELD
Runner An existing test wrapped in a function or arrow that nothing calls (node:test never registers it) HELD pass
Runner vite.config.ts test.exclude added (vitest reads it); cypress.config.ts specPattern narrowed pass, L1 app pass, L1
Lint tables Registered lint test: the scanned set narrowed by a pure insertion with no bypass word (files.splice(...)), or the table copied into a new Set that grows pass pass
Lint tables Registered never-grow table: an allowance count raised engine HELD app pass (stands aside, as designed)
Lint regexes test/brand.test.ts, the product-name lint CLAUDE.md names, made to skip a path (&& !p.includes('legacy')) n/a pass, L0
Lint regexes test/testrun-gpu.test.ts, the nvidia-smi lint over git ls-files src: regex narrowed, or src dropped from the listing n/a pass, L0
Lint regexes A lint-shaped test whose rule is a string (.includes('shell: true')), changed pass pass
Lint regexes A lint walking new URL('../src/', import.meta.url) (no repoRoot), regex loosened pass pass
Guard files .github/actions/test/action.yml: run: npm test to run: true engine pass (L3) app pass, L1
Guard files .worklane/checks/lint.mjs (the project lints of the PR-gate tier): a rule removed pass (L3) pass (L3)
Guard files .lintstagedrc: the eslint step dropped; .github/CODEOWNERS: a reviewer line removed pass app pass, L1
Guard files scripts/ci/affected.mjs and scripts/ci/shard.mjs choose which test files a PR's CI runs (ci.yml:89-91), but they aren't in GUARD_FILES (inferred from the list, no fixture run) L3 here (measured) n/a
Configs .semgrep.yml rule removed; .rubocop.yml cop off; deno.json lint exclude pass, L1 app pass, L1
Suppressions A multi-line /* / eslint-disable / */ comment; /* eslint no-eval: "off" */ n/a pass
Suppressions #![allow(clippy::all)], @SuppressWarnings, // nosemgrep, // NOSONAR, stylelint-disable, tslint:disable, oxlint-disable, # pyright: ignore, # yamllint disable, # hadolint ignore= n/a pass

The node:test semantics behind the skip and runner rows were measured with Node v22.23.2, on a scratch file in the scratchpad.

Speed (measured: testFacts on one side, files of 499,800 to 500,000 characters, scratch processes)

Shape at the cap Time Heap
The builder's shapes (many tests, 10k assertions, callbacks) 0.1–0.3 s, as reported small
~19,000 nested describes 106.6 s 47 MB
100 nested calls, each with a ~400-character first argument, then many assertions 47.5 s 1.7 GB
The same with 600 nested calls FATAL JavaScript heap out of memory after 223 s (default heap; it also crashed with --max-old-space-size=4096) over 4 GB
~11,000 tests naming one 200,000-character options const (test('t', OPTS, ...)) over 120 s (stopped) n/a
2,000 nested ifs around ~22,000 tests 14.0 s 393 MB
~33,000 const a = (1); then one { at the end 3.0 s small
  • A PR touching 50 test files: 50 plain files near the cap took 14.8 s for weakeningReasons (measured). 50 files of only ~50,000 characters each (10% of the cap), with nested describes, took 103 s (measured).
  • Is the synchronous path safe? No. weakeningOf runs inside mergeDecisionFor, which the PR batcher's waitsForOwner and the merge pass call (merge-flow.ts:164, :574).
    • A minutes-long parse blocks the coordinator's event loop. An out-of-memory error can't be caught: the coordinator dies, and on restart it reads the same head again (inferred).
    • filesAt also reads every blob in full (up to git's 256 MB buffer) before the size check (inferred from the code).
  • The worst shapes come from:
    • closeOf run per test call (quadratic in nesting);
    • where and around rebuilt per assertion and per test from the whole bracket stack, with each first argument up to 400 characters;
    • tableSpan resolving an options const again on every call.

False positives (measured independently: changeFiles plus weakeningOf over the 150 merged PRs to 128453a, the PR's base; read-only git)

No regression (measured)

  • mergeDecision at the PR's base (128453a's merge-policy.ts and merge-flow.ts, built in a second worktree) against the head, over the last 60 merged PRs:
    • all 21 with no weakening reason are identical;
    • the 39 with one are auto: false, with a superset of the base's reasons;
    • there are no mismatches.
  • Self-protection: the new module, test/merge-policy.test.ts, merge-policy.ts, merge-flow.ts, change-files.ts, review.ts, glob.ts, path-text.ts and brand.ts are L3 by self-modification, even with a bare review.yaml (measured with computeLevel). LINT_TESTS and SIZE_CAP are registered never-shrink.

Fail-without (measured)

  • The round-2 tests on a3fc766's module. I used a scratch worktree at a8d68ed with src/merge-policy-weakening.ts from a3fc766, then ran npm run build (it emits, with 5 TS errors for the new exports) and node --import ./dist/test/isolate.js --test dist/test/merge-policy.test.js.
  • The base's wiring. With the head's module and the base's merge-policy.ts and merge-flow.ts: # tests 50 # pass 45 # fail 5. The five are the same five as round 1, each with auto: true.
    • With today's main (d4b4503) wiring, a sixth fails on this.moneyPaths is not a function. That is main moving, not this PR.
  • At head: merge-policy, change-files-renames, review and slot-path tests give # pass 162 # fail 0. npm run typecheck is clean. check:denylist reports "1306 files clean". All runs used a scratch HOME, TMPDIR and state dirs.

Protection, hygiene, CI, overlaps

Required changes

  1. Bound the parse, so no file under the cap can stall or crash the coordinator (principle 1; measured above: 106 s, over 120 s, and a fatal out-of-memory error, all at or under 500,000 characters).

    • Match brackets once, in one stack pass, instead of closeOf per test call.
    • Bound each assertion's where and each test's around (for example, the innermost brackets up to a fixed number of characters). Or hold the file as unreadable when the nesting depth or the total key text passes a fixed limit.
    • Resolve an options const once per name.
    • Add a per-PR budget: total characters parsed across files, the rest unread. Skip cat-file for a blob over SIZE_CAP (ls-tree -l gives its size).
    • Add tests with these worst-case shapes at the cap. Prefer an operation-count bound to a wall-clock one, so they aren't flaky.
  2. Skips and runner selection that still pass in a tests-only change, at L0:

    • (a) An identifier inside an options object ({ skip: SKIP_SLOW }): read as its value, like a whole-argument const.
    • (b) What the test calls bind to: an import of test/it/describe/suite (any module, including skip as test from node:test, or a swap to a local module), and a declaration that shadows one. Treat them as test-binding, as for assert.
    • (c) An existing test wrapped in a function or arrow nothing calls: put every enclosing header, not only CONTROL ones, into runs.
    • Add a fixture for each.
  3. Lint tests this repo has that aren't covered.

    • test/brand.test.ts (the product-name rule in CLAUDE.md) and test/testrun-gpu.test.ts (nvidia-smi over git ls-files src) pass as L0 tests-only weakenings. Add both to LINT_TESTS.
    • Make it structural (principle 4): a registry test fails on any test file that lists src/ or scripts/ files (ls-files, or readdir/glob/walk into them) and is neither in LINT_TESTS nor in a named not-a-lint list.
    • Widen lintShaped to ls-files and to roots not named repoRoot.
  4. Suppressions:

    • In JS/TS, count a mark that isn't inside a string, so a multi-line block comment counts.
    • Add ESLint inline config (/* eslint <rule>: "off" */ or 0).
    • Add #![allow(, @SuppressWarnings, nosemgrep, NOSONAR, stylelint-disable, tslint:disable, oxlint-disable, pyright: ignore, yamllint disable and hadolint ignore.
    • Add fixtures.
  5. Guard files to add (a list change each):

    • In every scope: .github/actions/** (L1 in a managed app), **/vite.config.*, **/cypress.config.*, .lintstagedrc* and lint-staged.config.*, **/.semgrep.y*ml and .semgrep/**, **/.rubocop.yml, **/deno.json*, .github/CODEOWNERS, and <configDir>/checks/** (the project lints the PR-gate tier runs).
    • In the engine: scripts/ci/** (affected.mjs and shard.mjs choose which tests a PR's CI runs).
  6. A tests-only PR that only adds tests must not wait. Each of these is held now (measured):

    • (a) a new it() with t.skip() in an existing describe: count body skips outside nested test calls;
    • (b) a row added to a loop's case table: read it as never-shrink (hold a row removed or changed, not one added);
    • (c) a new test appended to a LINT_TESTS file, on a line with a bypass word: ignore added lines inside a test call new in the change, or name it as a known over-hold with its measured cost.
    • Add fixtures.
  7. Name these as known gaps in the PR body, the docs/design.md paragraph and the CHANGELOG (principle 5), or close them:

    • an actual value overwritten or recomputed before an existing assertion (got = 3; inserted; let got = (add(1, 2), 3));
    • an imported test helper that throws (not assert*) emptied;
    • an expected value moved into an imported JS fixture. A cheap close: treat a JS file under test paths with no tests like an expected-output fixture when code changes. Measure the cost first;
    • a lint-shaped test's string rule (includes) changed;
    • a registered lint test narrowed by a pure insertion with no bypass word (files.splice(...), a copied Set grown);
    • a guard source's pure insertion with no bypass word (L3 in the engine).
  8. Correct the speed claims (principle 5):

    • the body's "testFacts is now linear" and "0.20–0.23 s at the cap";
    • docs/design.md's "a parse at the cap measured about 0.2 s";
    • SIZE_CAP's comment, "well under a second".

    Each is false for nested shapes (measured above). Add round 2's attacks to the Adversarial pass table, and the stall or crash to "Compromised component".

For the owner (not decided here)

…tion changes wait (#783)

The round-2 review of #820 measured the parse stalling the coordinator's synchronous merge path: 106 s on nested
describes at the cap, a fatal out-of-memory after 223 s on nested calls, 15 s for 50 near-cap files. Now:
- brackets are matched once per text (kept on the scanned text, never keyed by the string), a depth-zero scan
  jumps bracketed spans, and an assertion's or test's context is built once per open bracket;
- a file holds unread past MAX_DEPTH (100) of nesting, MAX_KEY (8,000) characters of context, or WORK_PER_CHAR
  (40) parse steps per character; a change parses at most PARSE_BUDGET (2,000,000) characters, the rest unread;
  filesAt reads no blob over SIZE_CAP (`ls-tree -l`);
- measured at the cap: every shape takes at most 12 steps per character, each worst shape holds in under a
  second, and 20 near-cap files take about a second.

Owner's decision (2026-10-19): a tests-only change that changes an existing assertion's expected value or matcher
waits; an added assertion and a pure rename (a test's title, or a test's own local) don't. Through changedHolds.

The review's other gaps, each closed by its class:
- skips: a const named inside a test's options is read as its value; an import or shadow of test/it/describe is
  a binding (`import { skip as test }`, a swap to a local no-op); every header around a test is how it runs (a
  function nothing calls);
- lint tests: brand.test.ts, testrun-gpu.test.ts and seven more are in LINT_TESTS; a registry test fails on a test
  that lists src/ or scripts/ files and is in neither LINT_TESTS nor NOT_A_LINT (with why);
- suppressions: a marker counts anywhere but in a string (a bare line of a block comment), ESLint inline config
  turning a rule off, and ten more markers;
- guard files: .github/actions, CODEOWNERS, vite, cypress, lint-staged, semgrep, rubocop and deno configs, the
  project lints (<configDir>/checks) and, in the engine, scripts/ci;
- only adding never waits: a skip counts for the innermost test only, a case table is compared by rows (one added
  runs more), and a lint test's new tests and new helpers aren't read as bypasses.

Measured on the last 150 merged PRs (to 128453a): 9 newly held, the 5 before plus #790, #777, #684 and #656
(tests-only changes of existing assertions, the owner's decision).

Test (fails without): test/merge-policy.test.ts "weakening (round 3, ...)" and the owner's-decision test fail
with src/merge-policy-weakening.ts as at a8d68ed.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>

# Conflicts:
#	test/package-files.test.ts
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (round 3)

I reviewed head 10b66b38 against #783, the round-2 review and the owner's decisions of 2026-10-19, judging by the decision guide (principles 1, 5, 7 and 9). While I was reviewing, the branch moved to 3871c80d, a merge of main d5674d0 with no change to the hold's code. That head is covered under CI and Budget.

Most round-2 items are closed. The skip, import and header bindings, LINT_TESTS, the registry test, the guard files and the add-only fixes all work as described (measured). Four things block a pass:

  • The parse can still stall. The step counter misses regex backtracking and the assertion pairing, so files under every limit still take 26–39 s per parse, and over 200 s per change.
  • Decision 1 has holes. Some tests-only changes of an expected value still pass, and some add-only changes still wait.
  • The add-only fixes reopened two weakenings that a8d68ed held.
  • More cheap weakenings pass in each family.

1. Parse safety (measured: scratch processes, files at or under 500,000 characters)

The round-2 shapes are fixed.

  • Every round-2 shape now holds unread in 39–48 ms, or reads in 93–142 ms: nested describes, deep first arguments (600 deep), 2,000 nested ifs, the options const, and declarations then a brace.
  • The 50-file change takes 579 ms (near the cap) and 169 ms (nested). Both hold.

Shapes at the edge of the limits behave.

  • Just under MAX_DEPTH (99 deep): 115 ms, read.
  • Just under MAX_KEY (keys of about 7,900 characters, 44,728 assertions): 121 ms, read.
  • Unbalanced closers, unbalanced openers, mismatched brackets, brackets inside strings, templates and regex literals: 41–92 ms. Every unbalanced or mismatched one holds unread.
  • Every limit I passed made the file hold: depth, the parse budget, the size cap, a mismatch, and an unterminated literal. None passed.

The work bound is not enforced in every loop. work counts characters walked, but not regex backtracking and not the loops outside testFacts. Each shape below reports at most 11 steps per character and comes back ok=true.

Shape (one side) Where the time goes Time
x . x . x . ... in a test body ASSERT_CALL's lazy (?:ident\s*\.\s*)*? prefix, retried from every start (quadratic) 39.2 s
const a: const a: ... (no = or ;) the declarations regex (?::[^=;]*)?= 26.3 s
15,000 const vK: < = K;, named by one assertion spanFrom: < raises depth, so each value scans to the end of the file, uncounted 27.7 s
const a = function ... (no () assertingFunctions' function\b[^(]*\( 2.8 s
  • The dot chain in four files within PARSE_BUDGET (250,000 characters each, both sides): weakeningReasons took 132 s and returned no reason. It passes after stalling.
  • Assertion pairing is quadratic. It is renamedOnly for every gone × left pair, tokenizing both strings each time, outside testFacts and outside any budget. A tests-only change that alters the context of every assertion in one file took:
    • 2,000 assertions: 5.4 s;
    • 5,000 assertions: 32.1 s, in a file of about 370 KB like test/web-render.test.ts (1,543 assertions today);
    • a 100,000-character file of 9,000 assertions: 51 s;
    • at the cap: over 200 s (stopped).
  • Memory. MAX_KEY bounds each key, not their sum. Two near-cap files with about 7,900-character contexts, in a change that only adds one test, took 1.4 GB of heap (4.3 s), and hit a fatal out-of-memory error under --max-old-space-size=1024. Default heap limits are lower on small hosts (inferred).
  • These claims are false (principle 5):
    • body: "Every shape takes at most 12 parse steps per character"; the steps don't measure the time;
    • "Compromised component": "such a file holds unread in well under a second";
    • docs/design.md: "under a second per worst-case file".
    • The "no file under the cap can stall the parse" test bounds work, so it passes on all four shapes above.

2. Owner's decision 1 (measured: fixtures fed to weakeningReasons)

These behave as decided, both tests-only and with a code change:

Change Result
Expected 3 to 4 HELD
deepEqual to ok HELD
equal to notEqual HELD
An assertion removed HELD
One removed and another added (count kept) HELD
A rename to an imported name (add(1, 2) to sub(1, 2)) HELD
A local renamed with its literal changed (want = 3 to wanted = 4) HELD
An added assertion pass
A local renamed (got to actual) pass
A test title renamed, tests only pass

A title renamed with code changed still holds, as the module's comment says.

These still break decision 1:

Change Tests only With code
Python assert add(1, 2) == 3 to == 4 (tests/test_math.py) pass (L0, no other wait) HELD
Go != 3 to != 4 (*_test.go) pass HELD
Snapshot src/__snapshots__/view.test.ts.snap value changed pass HELD
test/fixtures/expected.json value changed pass HELD
A literal const changed past its first 400 characters (const EXPECTED = { ...40 fields..., total: 3 } to total: 4), top-level or test-local pass pass
const eq = assert.deepStrictEqual; then eq(x, 3) to eq(x, 4), or the eq(...) call removed, or = assert.ok pass pass
  • The first four come from the non-JS branch, which still skips unless code changes (if (!codeChanged) continue;).
  • The const row comes from valueOf, which truncates to HEAD (400). The alias row comes from a method alias, which is no assertion name.

Add-only over-holds (owner: "purely added assertions don't wait"):

  • A new it() with an early return before an existing it() in the same describe: HELD as "changes a test assertion".
  • A new it() holding assert.throws(() => { throw ... }) before an existing one: HELD.
  • A new assertion with a throw before an existing one in the same test: HELD.
  • The cause: exits counts every return/throw since the outermost bracket, including ones inside closed sibling functions and test calls.
  • Controls that pass, as they should: the same new it() appended after the existing one; the round-2 add-only fixtures.

Are #790, #777, #684 and #656 true changes of an existing expected value or matcher? I read each diff.

So two of the four are holds broader than the owner's wording. By principle 1, they are fair as fail closed. They need describing accurately (below), and are noted for the owner.

3. The round-2 gaps rerun, and new attacks (measured, round-2 fixtures unchanged)

  • Round-2 rows now held:
    • the { skip: SKIP_SLOW } const, flipped or env-driven;
    • skip as test, and the swap to a local no-op;
    • an existing test wrapped in a function or arrow;
    • vite and cypress configs, .github/actions, .worklane/checks, lint-staged and CODEOWNERS;
    • semgrep, rubocop and deno configs;
    • all 13 suppression markers.
  • Still passing, as named: actual overwritten or recomputed; helper emptied; fixture module made tautological; string rule; ls-files lint; the copied Set.
  • The mocked unit (tests-only) now passes and isn't named.
  • files.splice(...) in a registered lint test still passes (round-2 row 14). LINT_BYPASS's (?<![\w$.]) lookbehind means splice, filter and delete never match as a method call. So the body's "splice/filter are words now" is false.

Opened by round 3's add-only fixes (HELD at a8d68ed, pass at 10b66b3, measured on both builds):

  • The outer t.skip() called inside a new subtest: await t.test('setup', () => { t.skip(); }). The skip is credited to the new subtest. Node v22.23.2 (measured on a scratch file): the existing test with a failing assertion reports # SKIP, # fail 0, exit 0.
  • A new helper function in a LINT_TESTS file, called from a new top-level line (function trim() { if (files.length) return files.splice(0); } trim();), engine scope.

New weakenings that pass (each measured; "auto" means no other reason waits, also measured):

Family Attack Result
Skips A top-level beforeEach((t) => t.skip()) pass. Node: # SKIP, exit 0 (measured). It passed at a8d68ed too.
Skips A new test.only(...) beside existing tests pass. vitest and jest run only it by default (inferred). Node --test-only: the failing test isn't run, # fail 0 (measured).
Skips { skip: SKIP_SLOW } with SKIP_SLOW imported from test/env.ts and flipped there pass
Runner and configs Makefile test: emptied; phpstan.neon level lowered; .swiftlint.yml rule disabled pass, L1 auto in both scopes. pyproject.toml, setup.cfg and Cargo.toml already wait as dependency manifests.
Lint tests test/brand.test.ts (the CLAUDE.md product-name lint): offenders.length = 0; inserted before its assertion pass
Lint tests A new test or describe placed first that empties the lint's shared list (files.splice(0), or files.length = 0 in a new before hook) pass
Lint tables A case table built by a spread (const CASES = [...BASE]), a row removed from BASE pass
Lint regexes An unregistered lint-shaped test whose rule words sit in an array (new RegExp(WORDS.join('|'))), one word removed pass
Suppressions NOLINTNEXTLINE and NOLINTBEGIN (the \b after NOLINT stops them), #[expect(, swiftlint:disable, /* eslint x: "warn" */, /* eslint x: ["off"] */, rubocop:todo pass
  • Case tables are safe against the obvious trick: a row added while another is removed holds, and so does a row changed and reordered. A row only added passes.

4. False positives (measured independently)

I ran changeFiles plus weakeningOf over the last 150 merged PRs to 128453a, with today's review.yaml, using read-only git.

5. Package budget (measured: npm ci, npm run build, npm run build:web, npm pack --dry-run --json --ignore-scripts in my own worktrees)

Tree Tarball Unpacked Files
main d4b4503 1,902,158 5,934,411 512
PR base 128453a 1,894,384 5,911,738 512
head 10b66b3 1,918,351 5,993,616 513
  • The PR's file-list diff (head against base) is exactly four files:

    • dist/src/merge-policy-weakening.js +80,220 (new);
    • change-files.js +1,261;
    • merge-flow.js +259;
    • merge-policy.js +138.

    That is 81,878 in all. main against base touches none of them.

  • Merged size. main + 81,878 = 6,016,289 unpacked (inferred by addition), the builder's measured figure.

    • I did not build a merged tree (rule 12). The builder's merged tarball of 1,926,615 is plausible: by addition I get 1,926,125 (inferred).
  • Arithmetic. 1,926,615 × 1.02 = 1,965,147, rounded up to 1,970,000. 6,016,289 × 1.02 = 6,136,615, rounded up to 6,140,000. Both correct.

  • At 10b66b3 only the budget line changed in test/package-files.test.ts (+1/−1).

  • What's in the 80 KB module (measured with TypeScript's printer):

  • Now moot. test: raise the package budget to main's size + 2% after #805 and #787 [PROTECTED: owner merges] #823 merged main's own raise (1,950,000 / 6,060,000). The current head 3871c80 takes main's line, and the PR no longer touches test/package-files.test.ts (measured with git diff d5674d04 3871c80d --stat). CI's budget test passes there (below). By owner decision 2 no raise is needed, since the PR isn't over budget. The body's "Package budget" section is stale.

6. The usual checks

  • Fail-without (measured). I used a scratch worktree at 10b66b3 with src/merge-policy-weakening.ts and src/coordinator/change-files.ts from a8d68ed (git checkout a8d68edb -- ...), then ran npm run build (5 TS errors for the new exports; it emits anyway) and node --import ./dist/test/isolate.js --test dist/test/merge-policy.test.js. Result: # tests 58 # pass 47 # fail 11, the builder's count.
  • The two changed expectations (rule 10). Engine install ships the dashboard's web UI #32 (adversarial pass) and CI fix runs: a failed required check on a harness PR gets a bounded fix #39 (round-2 adversarial) now expect [ASSERTION, 'removes or changes code in a guard or lint'] where they expected only the second. They are stricter, not weaker: the regex named by the assertion is its matcher, and the inserted continue makes the assertion conditional, which the owner's rule holds.
    • The decision test's reworded [] to [ASSERTION] follows the owner's decision.
    • One more loosened expectation isn't named in the body. The round-2 test "every LINT_TESTS entry reads as a lint" became lintShaped(text) || LISTS_SOURCES.test(text). It is the PR's own test, not main's, so rule 10 doesn't bind, but principle 5 asks to name it.
  • At head (measured):
    • merge-policy, change-files-renames, review, slot-path, package-files and brand: # tests 179 # pass 179 # fail 0 (scratch HOME, TMPDIR and state dirs);
    • npm run typecheck is clean;
    • check:denylist reports "1306 files clean";
    • scripts/check-dco.mjs d4b45039 HEAD reports "all commits signed off".
  • Hygiene (measured).
  • Protection (measured).
    • The title is [PROTECTED: owner merges], the label is needs-owner, the risk class is protected, and both sections are real.
    • With computeLevel, every src file of the PR is L3 by self-modification, and so are docs/design.md, test/merge-policy.test.ts and test/package-files.test.ts.
    • "Make anything merge that waited before" still holds (inferred: only appends reasons).
    • "Compromised component" is false on the stall (section 1).
  • Adversarial pass. The section is present, with a round-3 table. It missed the uncounted regex and pairing costs, and the holes in section 3.
  • CI.
    • 10b66b3: every check passed (measured with gh pr view --json statusCheckRollup).
    • 3871c80: every test shard on node 22 and 24, build, desktop, scan, denylist, dco, push-gate and agentshield passed, including the package budget test. Of the two new-deps runs, one was cancelled and one passed.
  • Overlaps (measured with git merge-tree --write-tree).
  • CHANGELOG (measured in memory). The bullet and its 5 sub-bullets survive withSection(changelogSection('0.5.3', ...)) at both 10b66b3 and 3871c80.

Required changes

  1. Bound the time the hold actually takes, not just the characters it walks. Principle 1; section 1 measured 26–39 s per parse, 132 s and over 200 s per change, and an out-of-memory crash under a 1 GB heap.

    • Make each whole-text regex linear:
      • ASSERT_CALL: no lazy identifier-chain prefix. Match assert|expect and walk back.
      • The declarations regex: bound its type annotation, e.g. [^=;\n]{0,200}.
      • assertingFunctions: [^(]{0,200}.
    • Count spanFrom's scan in work and stop it at a bound.
    • Pair changed assertions without gone × left: bucket them by token shape, or hold without pairing past a fixed count.
    • Cap the total key characters per file, and hold unread past it.
    • Add the four shapes, the all-changed file and the long-key pair as tests. A coarse wall-clock ceiling is acceptable as a backstop, since work can't see regex backtracking.
  2. Owner's decision 1, as decided:

    • (a) A tests-only change of a non-JS test, a snapshot or an expected-output fixture holds on a removed or changed line. Drop the codeChanged gate there; added lines still don't hold.
    • (b) An assertion carries the whole literal value of each const it names (or a hash of it), not its first 400 characters. The same goes for case-table rows.
    • (c) A method alias of an assertion (const eq = assert.deepStrictEqual, .bind(assert)) is an assertion name, like a destructured one.
    • (d) Adding never changes an existing assertion. Count only exits that can stop it: none inside a function body or test call that closed before it.
    • Add a fixture for each.
  3. Close the two holes round 3's add-only fixes opened. Both are measured HELD at a8d68ed and pass now.

    • The outer t.skip() inside a new subtest: a body skip counts for the test whose context it calls, so a skip on a name bound outside the innermost test call counts for the test that binds it.
    • In a LINT_TESTS file, a new function called from a new line outside a new test: read its lines as bypasses.
    • Also hold a line in a new test, hook or function of a lint test that writes a name declared outside it (files.splice(0), files.length = 0).
  4. The new weakenings in section 3. Close the cheap ones:

    • a top-level beforeEach/before hook with a skip;
    • .only on a new test: an only anywhere in an existing file is a change to every test in it;
    • LINT_BYPASS's lookbehind, so .splice(, .filter( and .length = count;
    • in a registered lint test, hold every added line inside an existing test or at top level (web: while paused, start actions say they take effect on resume, File it and Add ready say they happen now, and land approvals say they land now #664's lines sit in new tests and functions, so they stay exempt);
    • the markers NOLINT (prefix: NOLINTNEXTLINE, NOLINTBEGIN), #[expect(, swiftlint:disable, rubocop:todo, and ESLint inline config to warn or 1 and its array form;
    • **/Makefile, **/justfile, **/phpstan.neon* and **/.swiftlint.yml as guard files.

    Name the rest in the body, docs/design.md and the CHANGELOG (principle 5):

    • a skip const imported from a helper;
    • a case table built by a spread;
    • a lint's rule words in an array;
    • a mocked unit in a tests-only change.
  5. Correct the body, docs and CHANGELOG (principle 5):

For the owner (not decided here)

…rser (#783)

The owner decided (2026-10-19) to replace the 80 KB parser with a line-based
rule read from the PR's own diff:

- a test file: a removed or changed line with an assertion or a test on it
  (CHECK_LINE, one regex of literal alternatives, no quantifier), and a
  deleted test file;
- a snapshot, fixture or other expected output: any removed or changed line;
- a guard or lint file (GUARD_FILES, LINT_TESTS): any removed or changed line;
- a registered table (CHECK_TABLES), read as its lines: an entry gone from a
  rule or guard list, or new in an allowlist;
- an allowlist file: any added line;
- any file but docs: an added line with a skip, only or suppression mark
  (MARKERS, plain substrings; the owner accepts the false holds).

Bounded and fail closed: one pass over the changed lines; over 4,000,000
characters, a diff that doesn't match git's counts, a file that isn't text,
a table file over 500,000 bytes or a git failure, the PR waits.

The registry lints (check-shaped tables, tests that list the sources) stay
as backstops in test/merge-policy.test.ts, next to the merge-time check.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (round 4)

I reviewed head ca0b16f1, which is f8a7010 (the line rule) plus a merge of main 6e0d7d8. I judged it against #783, round 3's findings, the owner's three decisions of 2026-10-19 and the decision guide. All runs used my own worktrees, a scratch HOME, TMPDIR and state dirs, and scratch git repos. No real state was touched.

What works (measured):

  • The parser is gone, and the rule is the owner's line rule.
  • The fail-without reproduces.
  • The 150-PR numbers and the package delta reproduce exactly.
  • Every round-3 stall is gone.
  • All 36 removed tests were this PR's own.

What blocks a pass:

1. Does the rule match the owner's decisions?

Decision 1 (the line rule): met, with one stricter point and several looser ones.

  • Waits, as decided (measured): an assertion removed or changed, a matcher changed, await dropped, a test removed, tests only or with code. The same holds in Python, Go t.Fatal/require., Rust assert_eq!, Java @Test, jest and chai. Pure additions pass, and all six round-3 add-only fixtures pass.

  • Stricter than decided: a pure rename waits (measured, real git, changeFiles + weakeningOf).

    • git mv test/math.test.ts test/sum.test.ts, with an identical blob, gives deletes a test file plus removes or changes a line with an assertion or a test on it, in both scopes.
    • A directory move (test/unit to test/core) gives the same.
    • Merge: a PR that removes or weakens test assertions, lint rules or guards waits for the owner #783's Deliverable says "a rename with the same content doesn't hold". This round deleted the acceptance test that checked it ("a PR that only adds tests, or renames a test file with the same text, does not wait"). Required change 2.
  • Looser than decided: the rule reads .cs, .swift, .rb and .sh test files as code (TEST_CODE), but CHECK_LINE has no assertion word for them. Removing each of these from a test file passes (measured):

    • C# Assert.Equal(3, Add(1, 2)); or [Fact];
    • Swift XCTAssertEqual(...);
    • Ruby minitest _(x).must_equal 3 or refute_nil x;
    • a bats [ "$(add 1 2)" = 3 ] in test/cli.sh.

    If those extensions weren't in TEST_CODE, isFixture would make every removed line of such a file wait as an expected output (inferred from the code). Listing them is what lets these removals pass. Required change 6.

  • Test paths: TEST_PATHS anchors test/, tests/ and spec/ at the repo root. Both of these pass (measured):

    • packages/x/test/math.js with an assertion removed (mocha's default naming, with no .test. suffix);
    • packages/x/test/fixtures/expected.json with a value changed.

    Required change 7.

  • Rust: the body says the rule covers Rust. Rust's usual unit tests sit inline in src/ under #[cfg(test)]. Removing an assert_eq! line in src/lib.rs passes (measured), because that path isn't a test path. Name it, or narrow the claim.

Decision 2 (marks): the owner's own examples are covered, but "and similar" isn't.

  • Each of the owner's named forms waits, with a fixture each (measured): test.only, .skip, a skip in a hook, eslint-disable and NOLINT. The PR's marks test also runs every MARKERS entry in five file types.

  • These common skip and suppression forms pass (measured, an added line in an existing test or source file):

    Family Forms that pass
    Go t.Skipf(
    Python self.skipTest(, raise unittest.SkipTest, pytest.importorskip(
    Rust #[ignore]
    Java @Disabled, @Ignore
    vitest describe.skipIf(, describe.skip.each(, test.runIf(false), describe.runIf(false)
    mocha xcontext(
    jasmine pending()
    RSpec xit 'x' do, skip 'x' (no parentheses)
    C# [Fact(Skip = "...")], #pragma warning disable
    Swift throw XCTSkip(
    Kotlin @Suppress(
    PHP @phpstan-ignore-next-line, @psalm-suppress
    Python lint # pylint:disable= and # type:ignore, without the space

    Required change 5.

  • Hidden marks. A mark in a non-test, non-guard file stops being read when any changed line of that file holds a NUL, a literal U+FFFD, or a byte that isn't UTF-8. The file is then skipped as an "image or other asset" (weakeningReasons: else if (binary) continue;). Measured, with real git and changeFiles, in app/a.js:

    • // eslint-disable-next-line plus a line const z = "\0";: passes;
    • the same with a literal "�": passes;
    • // @ts-nocheck plus a Latin-1 é byte: passes;
    • the mark alone (control): waits.

    Required change 3.

  • Docs exclusion. A test file under docs/ is a test path, so a removed assertion there still waits (measured). But its marks aren't read: test.only(...) added to docs/examples/a.test.js passes (measured). jest and vitest find *.test.js under docs/ by default (inferred), so in a managed app this runs only the one test. In the engine, tests run only from dist/test (measured: package.json test). The .md/.txt exclusion itself works: a README or docs/lint.md naming a mark passes, as intended (measured). Required change 8.

Decision 3 (lints and guards, same line shape): met in shape, with one hole.

  • Each of these waits, as built (measured, the PR's own tests):

    • a guard or lint file's removed or changed line;
    • an allowlist file's added line;
    • a registered table's entry added (never-grow) or removed (never-shrink), including a package.json script.
  • The table reader can be fooled (measured, weakeningReasons). tableLines reads from the first line that declares the name to the first later line at its indent. Using test/instance-name.test.ts ALLOWED and test/add-project.test.ts NAMES_BAD (neither file is a guard or lint test), each of these passes:

    • (a) a decoy copy of the declaration in a /* ... */ block earlier in the file, then an entry added to the real allowlist;
    • (b) the same decoy, then an entry removed from the real bad-fixture list;
    • (c) an entry added at column 0 inside the allowlist;
    • (d) a column-0 comment, then an entry, inside the allowlist.

    The controls (an entry added, an entry removed) wait. Required change 4.

No parser left (inferred, from reading all 455 lines). The module uses substrings, one quantifier-free regex, path globs, and tableLines, which is line-and-indent only. CHECK_LINE has no quantifier (measured by the PR's own test).

2. Fail closed and bounded

Attacks (measured, real git: changeFiles + weakeningOf, managed-app scope):

Attack Result
CRLF test file, expected value changed; CRLF test.only added waits
LF to CRLF on a test file (whitespace only) waits (an over-hold, fail closed)
A test file made binary by a NUL, assertion removed waits ("not text")
*.ts -diff already on main, assertion removed waits (--text reads it)
A guard file (.eslintrc.json) replaced by a symlink waits
A test file replaced by a symlink to an empty file waits
Rename with an expected value edited waits
A gitlink at test/suite moved waits ("Subproject commit" line, read as a fixture)
A gitlink at vendor/suite (a suite outside the test paths) moved passes, unnamed (minor)
Mode 755 to 644 on .githooks/pre-push, .husky/pre-commit and test/run.sh passes: no lines, so nothing is read. git skips a hook that isn't executable (inferred). Unnamed.
A non-test source with a NUL, U+FFFD or Latin-1 byte, plus a mark passes (section 1)

Worst-case time (measured, weakeningReasons alone, one process each, --max-old-space-size=1024):

Shape Changed chars Time Heap / RSS
4 guard files at the cap, distinct short removed lines 3,999,744 792 ms and 1,297 ms (load 11–13); 2,331 ms (load 48) +141 MB / 247 MB
4 guard files, one-character lines (the builder's worst) 3,999,744 322 ms and 462 ms +6 MB
4 fixture files, distinct lines 3,999,744 780 ms +140 MB
4 test files, every removed line an assertion 3,999,744 221 ms +49 MB
4 sources, every added line a mark 3,999,792 204 ms +38 MB
4 test files of near-misses for every CHECK_LINE word and mark 3,932,040 59 ms +6 MB
40,000 test files of about 100 characters 3,920,000 226 ms +9 MB
4 table files of 499,000 bytes, both sides (tables) 124 ms +23 MB
Over the cap, four files (the cap reached in the last one) 4,007,744 258 ms
  • End to end (scratch repo, changeFiles + weakeningOf, 4 guard files at the cap): weakeningOf took 2,706 ms and 2,284 ms, and changeFiles 723–953 ms, at load average 47–48. Peak RSS was 248 MB.
  • It is bounded and linear, and every shape at the cap waits. But the body's "the worst case is under half a second" and "464 ms (the worst found)" are false (principle 5): distinct lines cost about 2–3 times as much, from the found map keeping every distinct place. Neither is "Anything over the cap waits in 22 ms": a four-file over-cap change took 258 ms. Required change 9.
  • Regex backtracking: none found (inferred). CHECK_LINE is literal alternatives with one-character look-behinds. The path regexes are anchored and run on paths only. tableLines uses startsWith and trimStart.

3. Markers

See section 1 for the owner's forms, the "similar" forms that pass, the hidden-mark bypass and the docs exclusion.

4. Round 3's attacks against the new rule (measured: round 3's 51 fixtures, unchanged, fed to the head module)

  • As round 3 wanted: 26. These include:
    • Python, snapshot and expected.json changes, tests only;
    • the test-local const, the alias made assert.ok, and the want=3 to wanted=4 disguise;
    • top-level beforeEach skip, the outer t.skip() from a new subtest, and test.only;
    • NOLINTNEXTLINE, NOLINTBEGIN, #[expect(, swiftlint:disable, ESLint warn and ["off"], and rubocop:todo;
    • a case-table row added only (passes, as wanted).
  • Differ: 25. All are named in the body, or are over-holds the owner accepted:
    • over-holds (accepted): a test title renamed, a local renamed;
    • passes, named:
      • the const past 400 characters;
      • the alias call changed or removed;
      • Go's != 3 line;
      • the imported skip const;
      • the four lint-test insertions (splice(0), a new first test, a before() hook, a new helper);
      • three case-table rows (spread source, swap, reorder);
      • the unregistered lint's word array and walked folder;
      • continue-on-error;
      • Makefile, phpstan.neon and .swiftlint.yml;
      • pyproject.toml, setup.cfg and Cargo.toml (they wait anyway as dependency manifests, per the body and round 3).
  • Round 3's six add-only fixtures: all pass (measured).
  • Not named (new this round): the hidden marks, the table decoy and column-0 tricks, the missing skip forms, the C#/Swift/Ruby/bats assertion words, the nested test paths, marks in docs/ tests, the mode change, and the gitlink outside the test paths.

5. Fail-without (measured, owner method (b))

I made a fresh worktree at base 6e0d7d8. I wrote the PR's test/merge-policy.test.ts into it with Edit (cmp against the head copy: identical), ran npm run build, then ran node --import ./dist/test/isolate.js --test dist/test/merge-policy.test.js.

6. The 150 PRs (measured: all 150, the last 150 merged to 128453a, review.yaml at 128453a, read-only git)

Builder Mine
With a reason 106 106
Newly held 11: #797, #790, #777, #684, #656, #627, #620, #547, #503, #653, #594 the same 11
Tests-only PRs held by this rule 9 of 16 9 of 16 (#797, #790, #777, #692, #653, #652, #684, #656, #547)
#654 passes passes (it waits anyway, for other reasons)
Held only by a mark 21, 2 new (#594, #503) 21, 2 new (#594, #503)
Slowest weakeningOf 79 ms 98 ms (#802), on a loaded machine

The named PRs:

7. Package size (measured: npm ci, npm run build, npm run build:web, npm pack --dry-run --json --ignore-scripts, in my own worktrees)

Tree Tarball Unpacked Files
main 6e0d7d8 1,903,282 5,938,018 512
head ca0b16f (main merged in) 1,909,332 5,963,338 513
  • The delta is +6,050 / +25,320, the builder's exact figures. merge-policy-weakening.js is 23,662 bytes.
  • It fits main's PACKAGE_BUDGET (1,950,000 / 6,060,000), so no raise is needed.

8. Rule 10 (measured)

  • main's file has 22 tests, 3871c80 had 58, and the head has 40.
  • git diff 6e0d7d81 ca0b16f1 -- test/merge-policy.test.ts is +415/−0, so no line of main's tests changed.
  • The 36 removed or rewritten tests are 58 − 22, all this PR's own. Each of the 33 removed test( titles maps to the body's "kept" or "removed" lists (inferred, by reading them side by side).
  • One of the removed tests is Merge: a PR that removes or weakens test assertions, lint rules or guards waits for the owner #783's own acceptance check for renames: "renames a test file with the same text, does not wait". Rule 10 doesn't bind the PR's own tests, but the issue's Deliverable does (required change 2).
  • Main's test/fix-forward-coordinator.test.ts now fails (section 9). Making CI green must not weaken it. Any change to it is a change to an existing main test, so the body names it (rule 10).

9. The usual checks

  • CI on ca0b16f: red, caused by this PR (measured). Run 39326108484, test shard 2/4 fails on node 22 and node 24, with # fail 2 each. The failing test is test/fix-forward-coordinator.test.ts: "two PRs green alone, merged one after the other ..." (merged one after the other: expected [5, 6], actual [6]) and "the fix PR fails its CI ...".
    • Locally the file gives 0/2 at the head and 2/2 at main 6e0d7d8 (measured).
    • The cause (inferred from the test): its "rename" PR runs replaceAll('totalCents', 'totalInCents') on test/price.test.js. That changes assertion lines, so the PR now waits and is never merged.
    • Every other check passed: shards 1, 3 and 4, scan, scope, push-gate, new-deps, desktop, denylist, dco and agentshield.
  • Targeted tests at the head (measured): merge-policy, change-files-renames, review, slot-path, brand and package-files give # tests 161 # pass 161 # fail 0.
  • Checks at the head (measured): npm run typecheck is clean (both projects), and check:denylist reports "1310 files clean".
  • Commits (measured): all 7 commits since main have Signed-off-by. No attribution appears in the commits or the body (grep: 0).
  • Protection (measured with computeLevel and the head's review.yaml):
    • every src file, docs/design.md and test/merge-policy.test.ts is L3 self-modification, and CHANGELOG.md is L0;
    • the title is [PROTECTED: owner merges], the label is needs-owner, and the risk class is protected;
    • "What this can never do" and "Compromised component" are real.
    • "Make anything merge that waited before" holds (inferred: the diff outside the new module only appends a reason).
    • "Stall the merge path ... worst case 464 ms" is wrong (section 2).
  • Durability: met (inferred). The protection is a runtime check in mergeDecisionFor, and the two lints are named as backstops.
  • Overlaps: git merge-tree --write-tree origin/main ca0b16f1 is clean, and origin/main is 6e0d7d8 (measured).
  • The TRACED note for First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816 is accurate. First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816 (head 640b6cd, open) has no weakening entry in test/first-task-progress.test.ts TRACED (measured with grep). Its own fixture reasons.push(...(i.instructionEvalsExtra ?? [])) is flagged "untraced", so reasons.push(...(i.weakening ?? [])) will be too (inferred). Whichever lands second adds the entry.
  • Claims: labelled. The corrections are in required change 9.

Required changes

  1. Make CI green without weakening the fix-forward test (measured red, section 9). Under decision 1 the fixture's rename PR now waits, because it changes assertion lines. Change the scenario so that it still proves fix-forward. For example, the rename PR could leave the test's assertion lines alone (an alias kept for the old name), or the test could approve the held PR. Name the change to this existing main test in the body. If it can't be done without loosening what the test proves, keep the test and take it to the owner (rule 10).

  2. A pure rename doesn't wait (Merge: a PR that removes or weakens test assertions, lint rules or guards waits for the owner #783's Deliverable, and the owner's decision 1).

    • A deleted test path whose exact blob reappears at a new path that is still a test path with the same runner suffix counts as neither deleted nor removed lines.
    • A rename that edits the content, or leaves the runner's naming, still waits (round 2's Run a coordinator per instance, with only that instance's logins #3).
    • Restore an acceptance test for both through decideChange (real git).
  3. A non-text line must not hide marks.

    • Read MARKERS on every added line of a non-doc file, whatever else the file holds.
    • Or make a non-text change to a file that isn't an asset (a code or config extension) wait as unreadable.
    • Add fixtures: NUL, a literal U+FFFD, and a Latin-1 byte, each beside a mark.
  4. Make the table reader fail closed. When the table's name is declared more than once on either side (a decoy in a comment or string), or when the line that ends it isn't a closer (], }, )), treat the table as unreadable, so the change waits. Add the four fixtures from section 1.

  5. Add the common "similar" marks, or name each one you leave out.

    • Go Skipf(;
    • Python skipTest, SkipTest, importorskip;
    • Rust #[ignore;
    • Java @Disabled, @Ignore;
    • vitest .skipIf(, .runIf(, .skip.each;
    • mocha xcontext(; jasmine pending(;
    • Swift XCTSkip; C# Skip =, #pragma warning disable;
    • Kotlin @Suppress(; PHP phpstan-ignore, psalm-suppress;
    • pylint:disable and type:ignore without the space;
    • RSpec xit/skip without parentheses, if a substring for them doesn't flood the false holds.

    Give each a fixture, and re-measure the 150 PRs.

  6. Assertion words for every extension in TEST_CODE. Add C# Assert./[Fact/[Test, Swift XCTAssert/XCTFail, Ruby refute/must_/wont_, and bats @test. Or take those extensions out of TEST_CODE, so every removed line of such a test waits (principle 1). Add a fixture each.

  7. Nested test paths. Add **/test/**, **/tests/** and **/spec/** to TEST_PATHS, and a nested fixtures/ under them. Or name the gap. Re-measure the 150 PRs.

  8. Marks in a test or guard file under docs/, or in a .md/.txt test file: read them. Skip marks only where the file is skipped (isDoc && !strict).

  9. Correct the timing claims (principle 5) in the body's bound table and "What this can never do".

    • The measured worst case at the cap is 0.8–1.3 s (load 11–13), and 2.3–2.7 s end to end at load 47–48, with +141 MB heap.
    • "Over the cap waits in 22 ms" is true only for one file; four files took 258 ms.
    • Optional: keep only the first three places and a count per kind, which removes the per-line map cost.
  10. Name the rest in the body, docs/design.md and the CHANGELOG (or close them):

    • a mode-only change to a hook or guard (exec bit dropped);
    • a gitlink outside the test paths;
    • Rust's inline #[cfg(test)] tests in src/ (narrow "covers Rust");
    • the stricter holds: a deleted helper with no assertion, and a whole-file line-ending change.
  11. Add each attack above to the Adversarial pass table.

For the owner (not decided here)

  • The xit( false hold. process.exit( holds xit( in 25 of the 150 PRs (builder's count). In those 150 it newly held none: each such PR waits anyway (measured, section 6). In a managed app with process.exit( in ordinary code, it would newly hold PRs (inferred). The plain-substring rule would need a one-character look-behind for xit(/fit(. That is a small change to the "plain substrings" design, so it's your call.
  • Renames. Required change 2 follows Merge: a PR that removes or weakens test assertions, lint rules or guards waits for the owner #783's Deliverable and decision 1 as I was briefed. If you want renames of test files to wait too, say so, and change 2 drops.
  • The fix-forward test. Decision 1 holds a rename that touches assertion lines. So the engine's own fix-forward scenario test (a rename PR plus a caller PR) no longer auto-merges its first PR. The test's fixture must change (required change 1).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…found it open (#783)

- A pure rename of a test file or folder doesn't wait: exactMoves (the
  changed-files helper) pairs a deleted path with an added one of the same
  blob and mode; both are skipped when they are test paths with the same
  runner suffix. A rename with an edit is still a delete and an add.
- Marks are read on every added line; a changed file that isn't text (a NUL,
  a U+FFFD, a byte that isn't UTF-8) waits, unless it is an asset by its
  extension.
- A registered table declared twice, with a line at its indent inside it, or
  with no close makes the change wait; a line that closes every bracket it
  opens is a one-line table.
- More marks (Go, Python, Rust, Java, vitest, mocha, jasmine, Swift, C#,
  Kotlin, PHP, RSpec) and assertion words (C#, Swift, minitest, bats); shell
  test files wait on any removed line; test folders count at any depth, and
  a test file under docs/ is a test file.
- The cap is checked before any line is read, and each kind keeps only its
  first three places and a count.
- test/fix-forward-coordinator.test.ts: the rename PR imports the renamed
  function under its old local name, so its assertion lines stay as they
  are and the scenario still auto-merges it.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…ding (#783)

Until #829 lands, the release lib keeps an unreleased section's hand-written
notes only above a generated heading, as #834 and #839 do.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: CHANGES NEEDED (round 5)

I reviewed head 7c632eb4, which is 9096a3f plus one CHANGELOG commit, against #783, round 4's review, the owner's three decisions of 2026-10-19 and the decision guide. All runs used my own worktrees (head, a clean main 71316eb, and a scratch base), a scratch HOME, TMPDIR and state dirs, and throwaway git repos.

The follow-up commit (measured): git diff --stat 9096a3fb 7c632eb4 is CHANGELOG.md | 2 ++, adding only ### Guardrail changes and a blank line. It is signed off. The module, the tests and the wiring are byte-for-byte the same as at 9096a3f.

Most of round 4 is fixed (measured).

  • CI's fix-forward failure is fixed without weakening the test.
  • Every round-4 attack on marks, assertion words, test paths, docs, non-text lines and the round-4 table tricks now waits.
  • The bound is checked first.
  • The fail-without, the 150-PR numbers, the size and the timing all reproduce.

What blocks a pass is the new pure-rename exemption. It also lets through renames that stop a test from running, and moves of guard and lint files. It's too wide. There is also one table trick left.

1. Round 4's attacks, rerun (measured: the same fixture scripts, at this head)

Round-4 finding Now
A pure git mv of a test file or folder waited passes (real git)
CI red: test/fix-forward-coordinator.test.ts 2/2 locally; CI below
A NUL, U+FFFD or Latin-1 byte hid a mark (app/a.js, real git) waits (unreadable + mark)
A decoy declaration, or a column-0 entry or comment, in a registered table waits (all four)
23 missing skip and suppression forms (t.Skipf(, self.skipTest(, #[ignore], @Disabled, describe.skipIf(, pending(), RSpec xit/skip, #pragma warning disable, @Suppress(, ...) all 28 fixtures wait
C# Assert./[Fact], Swift XCTAssert, minitest must_/refute, bats wait
packages/x/test/math.js; packages/x/test/fixtures/expected.json wait
test.only in docs/examples/a.test.js waits
CRLF, binary test file, -diff, symlink swaps, gitlink at test/suite wait, as in round 4
A mode change on a hook; a gitlink at vendor/suite; Rust inline tests in src/ pass. Named gaps now (body, docs/design.md, CHANGELOG).
  • Round 3's 51 fixtures: 26 behave as wanted. The other 25 are named gaps, or the owner-accepted over-holds on a title or local rename (measured). That is the same split as round 4.
  • Round 3's six add-only fixtures: all pass (measured).

2. Attacks on the new pieces (measured, real git: changeFiles + weakeningOf)

exactMoves and the rename exemption.

Change Result
test/math.test.ts to test/sum.test.ts; test/unit/ to test/core/ passes (as decided)
Rename with an edit; to .test.ts.bak; to .spec.ts; pkg/math_test.go to pkg/math.go waits
Same blob, exec bit dropped in the move (mode differs) waits
A copy (same blob added, original kept) passes (nothing is lost)
Two identical tests on main: one deleted, one renamed waits (the second delete stays a delete)
Two tests swap names waits (a stricter hold, fine)
A gitlink moved from test/suite to vendor/suite, same commit waits
tests/test_math.py to tests/math.py, or to tests/math_helpers.py passes. pytest only collects test_*.py/*_test.py by default (inferred), so the test stops running.
src/test/java/MathTest.java to MathCase.java passes. Surefire selects *Test.java, Test*.java and *Tests.java by default (inferred).
spec/math_spec.rb to spec/math.rb passes. rspec's default pattern is *_spec.rb (inferred).
Engine: test/math.test.ts to scripts/math.test.ts passes. The engine runs only dist/test/**/*.test.js (measured: package.json test), so the test stops running (inferred).
test/math.test.ts to docs/math.test.ts, or to test/fixtures/math.test.ts passes
tests/conftest.py to tests/old/conftest.py (a guard file) passes. A conftest only applies to its own folder and below (inferred), so an autouse fixture or hook stops covering the tests it covered.
test/tsconfig.json and test/jest.config.js, moved into test/old/ (guard files) passes
Engine: test/brand.test.ts (in LINT_TESTS) to test/brand-name.test.ts passes the hold. The backstop "every LINT_TESTS entry is a real file" would then fail in CI (inferred).
Engine: test/isolate.ts (a guard file) to test/isolate-old.ts passes the hold. It is L3 by the lock anyway (inferred).

Why: runnerSuffix is only the file extension unless the name holds .test./.spec., and TEST_PATHS is much wider than what any runner selects (every file under test/, tests/, spec/, plus **/*.test.* anywhere). The exemption also covers files that are guard, lint, allowlist or table files as well as test paths. #783's deliverable is "a rename with the same content doesn't hold". A move that takes a test out of what its runner runs is a deleted test (decision 1), and principle 1 says to fail closed. Required changes 1 and 2.

The asset exemption (principle 9).

  • It narrows round 4's skip: round 4 skipped every non-text, non-strict file, while now only a known asset extension is skipped, and every other non-text file waits.
  • It loosens no check on main, because the hold is new in this PR (inferred).
  • A test or guard file can't use it, because asset() requires !strict(path): a binary test/data.bin change waits (measured).
  • A non-test app/run.bin holding // eslint-disable and a NUL passes (measured). Linters don't read those extensions, so the mark there turns nothing off (inferred).
  • No change needed.

The table rules.

  • A duplicate declaration, a column-0 line inside the table, and a table that never closes each wait (measured, the PR's fixtures and mine).
  • A one-line table: changing the declaring line to fake a close (= { // }) changes that line, which waits in both kinds (inferred from missing).
  • Still open (measured): a decoy copy of the table in a /* */ comment above it, plus the real declaration made unreadable to the rule (var ALLOWED, or const ALLOWED with two spaces), plus an entry added to the real table. This passes for test/instance-name.test.ts ALLOWED in engine scope.
    • Without the decoy, the same var change waits ("removes or renames a check's or guard's table").
    • The rule then sees only one declaration, the decoy, and the decoy is identical to the base's table.
    • Required change 3.

The single marks regex. It is escaped literals joined by |, with no quantifier.

  • 4 files of near-miss prefixes of the longest marks (@pytest.mark.ski, #pragma warning disabl, ...), as single lines and as many lines, 3.8 M characters: 25 ms (measured). No catastrophic case found (inferred: no quantifier means no backtracking).
  • The RSpec forms xit and skip widen the false holds (section 4).

3. Bound and timing (measured, load average 14–20, weakeningReasons alone, one process each)

Shape (4 files, together under 4,000,000 characters) Time Heap
Distinct short removed lines in guard files (round 4's worst, 0.8–2.3 s) 52 ms +17 MB
One-character lines in guard files 514 ms +7 MB
Every removed line an assertion 43 ms +10 MB
Every added line a mark 48 ms +13 MB
Near-misses for every word and mark 22 ms +5 MB
Marks-regex near-prefix lines 25 ms +1 MB
40,000 test files 163 ms +9 MB
4 table files of 499,000 bytes 208 ms +30 MB
Over the cap, four files 44 ms +2 MB
  • End to end at the cap, in a scratch repo (4 guard files): changeFiles took 277 ms and weakeningOf 89 ms.
  • These are consistent with the body's 0.41–0.67 s worst case and 38–39 ms for my round-4 shape. My 44 ms against the body's 17–20 ms for over the cap is load.
  • A minor point, not required: once a kind has three places, hit counts repeat places too, so "and N more" can overcount.

4. The 150 PRs (measured: all 150, the last 150 merged to 128453a, review.yaml at 128453a)

Builder Mine
With a reason 114 114
Newly held 12 12: #797, #790, #777, #664, #653, #627, #684, #656, #620, #594, #547, #503
Tests-only PRs held by this rule #797, #790, #777, #692, #653, #654, #652, #684, #656, #547
#654 reason from skip, waits anyway the same: const skip = process.platform === 'win32' ? ...
Held only by a mark 29, 3 of them new (#664, #594, #503)
Slowest weakeningOf 62 ms (#802)

The named PRs:

The 8 PRs that gain a reason over round 4 all wait anyway except #664. Several come from skip in ordinary text or names: #638 (a string, "Totals skip zero quantities"), #654 and #539 (const skip = ..., the latter in src/coordinator.ts).

5. Fail-without (measured, owner method (b))

I used a fresh worktree at base 71316eb. I wrote the PR's test/merge-policy.test.ts and test/fix-forward-coordinator.test.ts into it with Edit (each cmp-identical to the head copy), ran npm run build, then ran node --import ./dist/test/isolate.js --test dist/test/<file>.test.js.

  • With the tests alone:
    • merge-policy fails to load (ERR_MODULE_NOT_FOUND: the module is new, so this is unavoidable);
    • fix-forward passes 2/2, so the new fixture works on main as well.
  • With round 4's module (sha256 matches ca0b16f1), the head's change-files.ts, and the wiring (each cmp-identical): # tests 44 # pass 37 # fail 7. The 7 are all merge-policy, and fix-forward passes 2/2. That is the builder's 7 of 42. The seven:
    • other languages;
    • pure rename;
    • the table tricks;
    • the marks;
    • bounded and fail closed;
    • the rename acceptance test;
    • the non-UTF-8 acceptance test.
  • At the head: merge-policy, change-files-renames, review, slot-path, brand, package-files, fix-forward-coordinator and changelog give # tests 175 # pass 175 # fail 0.

6. Rule 10: main's fix-forward test (measured diff, plus inferred reading)

  • The only change is the worker's rename step. test/price.test.js now imports totalInCents as totalCents, so its assertion lines stay, and a new assert.notEqual(renamed, t, ...) checks that the import line was there to change.
  • No assertion of the fix-forward test was removed or changed, and the scenario still breaks main: src/report.js still imports the old name (inferred).
  • It passes on main with the new fixture (2/2, measured), so it doesn't depend on the hold to pass.
  • The body names it under "Rule 10: main's fix-forward test".
  • The file is L0 (tests) under the engine's review.yaml (measured with computeLevel). The PR is protected anyway.
  • One stale line: the body's older Rule-10 list still says "(round 2, Run a coordinator per instance, with only that instance's logins #3) renamed with another runner suffix: every move deletes a path, and waits". Pure renames no longer wait.

7. The usual checks

  • CI on 7c632eb: green (measured). 19 checks succeeded, including all 8 test shards on node 22 and 24, build, desktop, push-gate, dco, denylist, scan and agentshield. 7 were skipped (the real-systemd jobs), and 1 was cancelled: one of two new-deps runs, the other succeeded. 9096a3f's run was cancelled by the new push, but its shard 2/4 (node 24), which holds the fix-forward file, passed before the cancel (measured, check runs).
  • CHANGELOG (measured, git diff 71316ebd 7c632eb4 -- CHANGELOG.md): it only adds a ## v0.5.4 (unreleased) section above ## v0.5.3 (2026-10-19), which ends with the empty ### Guardrail changes. Main's v0.5.3 section is untouched, and test/changelog.test.ts passes.
  • Package (measured): npm ci, build, build:web and pack --dry-run --json --ignore-scripts give:
    • main 71316eb: 1,903,282 / 5,938,018 / 512 files;
    • head: 1,911,634 / 5,967,997 / 513 files;
    • delta +8,352 / +29,979, the builder's exact figures, within main's budget (1,950,000 / 6,060,000).
  • Checks at the head (measured): typecheck is clean, and check:denylist reports "1310 files clean".
  • Commits (measured): all 10 commits since main are signed off, and grep finds no attribution in the commits or the body.
  • Protection (measured with computeLevel): every src file, docs/design.md and test/merge-policy.test.ts is L3 self-modification. The title, label, risk class and both sections are present.
  • First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816 TRACED: First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816 is still open at 640b6cd with no entry for reasons.push(...(i.weakening ?? [])). Whichever lands second adds it (inferred, as round 4).

Required changes

  1. A pure rename may skip the hold only if it keeps the test selected by its runner.
    • Compare the runner's naming class, not just the extension: test_*.py/*_test.py, *Test.java/Test*.java/*Tests.java, *_spec.rb, *_test.go, *.test.*/*.spec.* with the same suffix, *.bats.
    • Keep the old path's test root: the nearest test/, tests/, spec/, __tests__/ or Tests/ folder, or the top folder for a name-matched file.
    • Never move into fixtures/, __snapshots__, testdata/ or docs/.
    • Anything else stays a delete and an add (principle 1).
    • Add the measured cases above as fixtures, with real git for at least the Python and scripts/ ones.
  2. Never exempt a rename of a guard, lint, allowlist or registered-table file, even when it is also a test path: conftest.py, tsconfig and jest configs under test/, LINT_TESTS, test/isolate.ts. Restrict the exemption to isTest && !isGuard && !isAllow && !table. Add a fixture each.
  3. Close the decoy-plus-unreadable-declaration trick.
    • When the base's declaring line is among the file's removed lines, treat the table as gone or unreadable, so the change waits.
    • Also count a declaration in any of const, let or var, with any run of spaces.
    • Add the measured fixture: a decoy comment, then var ALLOWED with an entry added.
  4. Correct the body (principle 5):

For the owner (not decided here)

  • The false holds grew. 114 of 150 PRs now get a reason (106 in round 4, 99 under the parser). In the 150 there is one new false hold, web: while paused, start actions say they take effect on resume, File it and Add ready say they happen now, and land approvals say they land now #664 (pending(); the rest wait anyway. skip matches prose and names (const skip = ... in src/coordinator.ts, a test-data string "Totals skip zero quantities"), so in a managed app it will hold ordinary PRs (inferred). Narrowing it, for example to a line that starts with skip after indentation, is a small change to the plain-substring design, so it's your call. The same applies to xit( matching process.exit( (25 of 150).
  • Renames. Required changes 1 and 2 keep pure renames passing as decided. They only stop moves that drop a test from its runner, or that move a guard file. If you'd rather every rename wait (simpler and fail closed), say so, and those two changes collapse into "renames wait".

…t; a table's declaring line is pinned (#783)

- A pure rename is exempt only when both names match the same runner's
  default pattern with the same suffix, under the same test-folder root, the
  new one under no fixtures, snapshots, testdata or docs folder, and neither
  is a guard, lint, allowlist or table file. Any other move waits.
- A table is declared by const, let or var with any spacing; the change
  removing or changing the base's declaring line makes it wait as unreadable
  (a decoy can't stand in for it).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Evaluator verdict: PASS (round 11)

I reviewed head 9c79a3b6 (one commit on 2e9ea4f) against #783, round 10's review and the decision guide, using my own worktrees (the head, and a scratch base at 71316eb), a scratch HOME and throwaway files. Round 10's two required changes are done, and every probe behaves as the builder claims. No required changes remain.

1. eval and Function in a registered table's file (weakeningReasons, engine scope, ALLOWED in test/no-clock-helpers.test.ts, an added line)

Added line Result (measured)
eval('ALL' + 'OWED')['x'] = 1; waits (changes a check's or guard's table outside its own list)
eval ('ALL' + 'OWED')['x'] = 1; (round 10's hole) waits
eval/**/('ALL' + 'OWED')['x'] = 1; waits
eval with (...) on the next line waits (the eval line holds the word)
new Function('t', 't.x = 1')(globalThis); waits
const s = 'see Function docs'; waits: the accepted, harmless over-hold for a word in a string
const n = 1; (control) passes

The matcher is (?<![\w$])(?:eval|Function)(?![\w$]), a whole word, so any direct-eval spelling waits. The indirect forms I measured in round 10 ((0, eval), an alias, globalThis['ev'+'al']) can't reach a module-scope table: each throws ReferenceError.

2. Package-manager settings (weakeningReasons, managed-app scope)

Change Result (measured)
.npmrc + script-shell=true waits (adds a line to an allowlist file or a package manager's settings file)
.yarnrc + ignore-scripts true waits
.yarnrc.yml + enableScripts: false waits
A nested packages/web/.yarnrc.yml + a line waits
bunfig.toml + [test] root = ... waits
A nested apps/x/.bunfig.toml + a line waits
pnpm-workspace.yaml: a package added passes, as intended
pnpm-workspace.yaml: a line changed (scriptShell: bash to scriptShell: true) waits (removes or changes a line of a guard or lint)
pnpm-workspace.yaml: a new setting added (scriptShell: true) passes. This is the named gap ("an added pnpm script setting").

3. Fail-without (measured, owner method (b))

I used a fresh worktree at base 71316eb. I wrote the PR's test/merge-policy.test.ts and test/fix-forward-coordinator.test.ts into it with Edit (each cmp-identical to the head), plus round 10's module (sha256 045f3500..., matching 2e9ea4fb), its change-files.ts and the wiring (each cmp-identical). Then npm run build (no type errors), and node --import ./dist/test/isolate.js --test on both files.

4. The usual checks

  • CI on 9c79a3b: green (measured). 20 checks succeeded, including all 8 test shards on node 22 and 24, and 7 real-systemd jobs were skipped. Nothing failed or was cancelled.
  • The 150 PRs (measured, all 150 to 128453a): unchanged. 114 get a reason, the same 12 are newly held, and the slowest weakeningOf took 36 ms.
  • Body (measured): round 11's section matches what I measured. That covers the whole-word eval/Function, the yarn and bun settings files, pnpm-workspace.yaml as a guard with the added-setting gap named, and "41 pass, 2 fail".
  • CHANGELOG (measured): against main it removes no line; only the v0.5.4 section changes, and v0.5.3 is untouched. The changelog test passes.
  • Checks at the head (measured): typecheck is clean, and check:denylist reports "1310 files clean".
  • Commits (measured): 9c79a3b is signed off, and grep finds no attribution in any commit since main.
  • Protection: unchanged. The PR is still marked protected ([PROTECTED: owner merges], needs-owner), and the owner merges.

Named gaps (as the body names them; confirmed where I probed)

  • Changes the line rule doesn't see:
    • a changed line with no assertion on it;
    • a pure insertion with no mark;
    • a config line added with no mark.
  • Ways around a registered table:
    • a copied table grown (copy.set);
    • mutation from another module (L3 in the engine, so it waits anyway).
  • Unread paths and files:
    • Rust's inline tests in src/;
    • a hook losing its executable bit;
    • a submodule outside the test paths;
    • an added pnpm script setting;
    • Makefile, phpstan.neon and .swiftlint.yml.
  • Marks: the long tail of other runners' and linters' forms.

For the owner (unchanged, nothing new)

…-783-weakening-hold

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>

# Conflicts:
#	CHANGELOG.md
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>

#816 landed first: its scan of merge.decided's reason sources needs the entry for reasons.push(...(i.weakening ?? [])) in mergeDecision. Test data only.
…ain (#783)

Main's machine-path lint names its guard (file and function) in a const GUARD; the backstop asks every check-shaped table to be registered. Changing which helper counts as the guard narrows the lint: never-shrink.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…g-hold

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>

# Conflicts:
#	CHANGELOG.md
)

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>

#856's golden runner settings: the deny list and hooks a run's --settings must carry. The backstop asks every check-shaped table to be registered; shrinking it weakens what a run is held to: never-shrink.
@jayden1711
jayden1711 merged commit caaa4f2 into main Oct 20, 2026
27 checks passed
@jayden1711
jayden1711 deleted the issue-783-weakening-hold branch October 20, 2026 15:38
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…owner)

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…owner)

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…owner)

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…owner)

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…owner)

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…owner)

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…owner)

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
main: docs-guardrails test turns #820's weakening input on, and the page lists its hold [PROTECTED: owner merges]
jayden1711 added a commit that referenced this pull request Oct 20, 2026
… at the top level

Round 1's review measured two gaps in #881's declarations:

- #820's weakening hold let an added `// runtime-import:` line through in any file. Each declaration turns tests
  that ran on every change into selected ones, which is a loosening (decision guide 9). `runtime-import:` joins
  MARKERS in src/merge-policy-weakening.ts, so a change that adds or changes a declaration line waits for the
  owner. Taking one out does not wait: its tests run on every change again.
- With no `{…}` around the import (a top-level `const w = () => import(…)`), the check fell back to every string
  in the file, so a declaration of a file that other code there names was taken (route C). It is now refused at
  the top level (fail closed).

Docs and the changelog fragment say both.

Test (fails without): test/merge-policy.test.ts "a test-selection declaration ... waits" (an added declaration gave
[] in scripts/up.cjs); test/ci-affected.test.ts top-level cases (route C gave an edge to src/c.ts).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
jayden1711 added a commit that referenced this pull request Oct 20, 2026
…eck table (#884 round 2)

#820's weakening backstop found scripts/bench/pins.ts SELECTION_RULES, a check-shaped table, neither registered
nor excused, and failed test shard 4/4. It is the list of rule names pins.json may name: a new name widens what
the pins accept. So a line added to it holds for the owner (never-grow). One line added to CHECK_TABLES, nothing
else changed.

Test (fails without): test/merge-policy.test.ts "weakening backstop: each registered table is found where it is
registered, and every check-shaped table in test/ and scripts/ is registered or says why not" (failed in CI on
c0529b1).

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Merge: a PR that removes or weakens test assertions, lint rules or guards waits for the owner

1 participant