Merge pull request #42 from fiddlerwoaroof/fix/checker-silent-success
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled

fix(checker): three silent successes in check-comment-runs.py
This commit is contained in:
Ed Langley
2026-09-20 10:43:27 -07:00
committed by GitHub
3 changed files with 85 additions and 11 deletions
+2 -1
View File
@@ -270,7 +270,8 @@
{"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:58:08Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
{"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:58:09Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-bidq","title":"Two answers to 'is this category virtual', four lines apart in view/types.rs","description":"Found by the post-wave code consistency review. PRE-EXISTING, carried across unchanged by PR #27's 8173b87.\n\ncrates/improvise-core/src/view/types.rs:112 and :118 test whether a category is virtual with `cat_name.starts_with('_')`. Four lines away, `is_records_layout()` (:67-74) uses `CategoryKind::of_name(..)`, and `CategoryKind` is imported at :6.\n\nTwo answers to \"is this category virtual\", in one file, four lines apart.\n\n## Why it is reachable rather than theoretical\n\n`Model::add_category` does not validate names. So a user category literally named `_Foo` is:\n * `CategoryKind::Regular` to records_columns, cell_id and is_empty_model;\n * \"virtual\" to these two lines — which would seat it on Axis::None and it would never appear in a pivot.\n\ndesign-principles §2 is explicit that business rules go through the enum rather than a name prefix, and names this exact class: \"enforced by matching on the enum, not by checking name prefixes\".\n\n## Note\n\nThe cheap fix is to use CategoryKind at both sites. The better question, worth one line of thought first: should `add_category` refuse a leading underscore outright? That would make the divergence unrepresentable rather than merely consistent — but it is a user-visible restriction and belongs in the commit message either way.\n\n## Steps to Reproduce\n\n1. `Model::add_category(\"_Foo\")` — the name is not validated.\n2. `CategoryKind::of_name(\"_Foo\")` makes it `Regular`, so `records_columns`, `cell_id` and `is_empty_model` treat it as an ordinary user category.\n3. `view/types.rs:112` and `:118` test `cat_name.starts_with('_')`, so *they* treat it as virtual — and seat it on `Axis::None`.\n4. The category therefore never appears in a pivot, while every other consumer believes it is a normal one.\n\nFour lines away, `is_records_layout()` (`:67-74`) asks `CategoryKind`, and `CategoryKind` is imported at `:6`.\n","acceptance_criteria":"Both sites use CategoryKind, so there is one answer. Decide explicitly whether Model::add_category should refuse a leading underscore — if yes, the divergence becomes unrepresentable and the restriction is stated in the commit; if no, say why a user category named _Foo is allowed and now behaves consistently.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:54Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-5jm5","title":"Model::evaluate_aggregated takes a list it only tests for emptiness","description":"Found by CC-core while capping comments (2026-09-20). Not filed as a bug because nothing misbehaves today; filed because the signature promises something the body does not do.\n\n`Model::evaluate_aggregated` takes `none_cats: \u0026[String]` and only ever tests `none_cats.is_empty()`. The aggregation itself is over `key.0` via `matching_values`. So the parameter is a BOOLEAN WEARING A LIST'S TYPE.\n\n## Steps to Reproduce\n\n1. Read `Model::evaluate_aggregated`'s signature: it takes a slice of category names.\n2. Read the body: the slice is consulted once, for emptiness. No element is ever examined.\n3. Pass a list with different contents but the same emptiness — the result is identical.\n\n## Why it is worth an hour\n\nThe name promises the function filters by that list. It does not. A caller reasoning about which categories are on `Axis::None` will believe this function honours them, and the next person to add a case will thread a carefully-built list into a parameter that cannot use it. That is the same shape as `prune_axis_items` taking a flag vector before it took a predicate: a parameter whose type admits states the body cannot distinguish.\n\n## The fix is a decision, not a rename\n\nEither the emptiness test is the real dispatch — in which case take a `bool` (or better, an enum saying what the two states MEAN) and let the caller compute it — or the function was meant to filter and does not, which is a behaviour question and needs a test first. Establish which before touching it; `git log -S` on the parameter will say whether it ever filtered.\n","acceptance_criteria":"evaluate_aggregated's parameter admits only the states the body can distinguish — a bool or a named enum if the emptiness test is the real dispatch, or a filter that actually filters with a test proving it. Establish which it was MEANT to be first: git log -S on the parameter says whether it ever examined an element.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T16:53:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:36Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-bidq","title":"Two answers to 'is this category virtual', four lines apart in view/types.rs","description":"Found by the post-wave code consistency review. PRE-EXISTING, carried across unchanged by PR #27's 8173b87.\n\ncrates/improvise-core/src/view/types.rs:112 and :118 test whether a category is virtual with `cat_name.starts_with('_')`. Four lines away, `is_records_layout()` (:67-74) uses `CategoryKind::of_name(..)`, and `CategoryKind` is imported at :6.\n\nTwo answers to \"is this category virtual\", in one file, four lines apart.\n\n## Why it is reachable rather than theoretical\n\n`Model::add_category` does not validate names. So a user category literally named `_Foo` is:\n * `CategoryKind::Regular` to records_columns, cell_id and is_empty_model;\n * \"virtual\" to these two lines — which would seat it on Axis::None and it would never appear in a pivot.\n\ndesign-principles §2 is explicit that business rules go through the enum rather than a name prefix, and names this exact class: \"enforced by matching on the enum, not by checking name prefixes\".\n\n## Note\n\nThe cheap fix is to use CategoryKind at both sites. The better question, worth one line of thought first: should `add_category` refuse a leading underscore outright? That would make the divergence unrepresentable rather than merely consistent — but it is a user-visible restriction and belongs in the commit message either way.\n\n## Steps to Reproduce\n\n1. `Model::add_category(\"_Foo\")` — the name is not validated.\n2. `CategoryKind::of_name(\"_Foo\")` makes it `Regular`, so `records_columns`, `cell_id` and `is_empty_model` treat it as an ordinary user category.\n3. `view/types.rs:112` and `:118` test `cat_name.starts_with('_')`, so *they* treat it as virtual — and seat it on `Axis::None`.\n4. The category therefore never appears in a pivot, while every other consumer believes it is a normal one.\n\nFour lines away, `is_records_layout()` (`:67-74`) asks `CategoryKind`, and `CategoryKind` is imported at `:6`.\n","acceptance_criteria":"Both sites use CategoryKind, so there is one answer. Decide explicitly whether Model::add_category should refuse a leading underscore — if yes, the divergence becomes unrepresentable and the restriction is stated in the commit; if no, say why a user category named _Foo is allowed and now behaves consistently.","notes":"SHARPENED by CC-core (comment wave, 2026-09-20), and the trap is subtler than this ticket stated.\n\nIt is not two sites four lines apart. View::on_category_added's early return is:\n\n cat_name.starts_with('_') || self.is_records_layout()\n\nso THE SAME EXPRESSION mixes both derivations, ONE OPERATOR APART -- the left operand tests a name prefix and the right operand asks CategoryKind. A reader checking this line sees the enum being used and has no reason to look at the operand beside it.\n\nConfirmed still present at the comment wave's base commit; deliberately untouched (a behaviour change is out of that wave's scope). The comment residue there no longer asserts either spelling, and context/code/core.md section 7 records it as an open defect.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-taxx","title":"[bug] The socket close listener overwrites a precise error with disconnected","description":"Found during the PR #13 review, reported and not fixed -- same family as the watchdog bug Copilot found, different question.\n\nweb/app.js socket close listener writes \"disconnected\" UNCONDITIONALLY, so a connection that failed loudly (the error listener already wrote \"cannot reach ... -- is improvise-ws-server running?\") is immediately followed by a close event that replaces it with the vaguer message. The user sees the least informative of the two things that happened.\n\nTHE WATCHDOG BUG WAS THE SAME SHAPE AND WAS FIXED: a timer fired 8s later and overwrote whatever a loud failure had already written; setShellFailure now clears it. This one was left because the question is genuinely different -- what should the page say when a connection BOTH failed AND went away? -- and because suppressing close unconditionally could hide a GENUINE MID-SESSION DISCONNECT, which is the one thing that listener is really for.\n\nLIKELY ANSWER: distinguish \"never connected\" from \"was connected and dropped\", which the socket readyState at close time can tell you. Not \"skip close if error fired\".\n\nUntestable here (no browser, no headless DOM), which is the standing caveat on every web/*.js finding -- see the browser harness ticket.","status":"closed","priority":4,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:09Z","started_at":"2026-09-20T05:32:34Z","closed_at":"2026-09-20T06:22:09Z","close_reason":"Closed by PR #21 (WU-I). Fixed -- AND THE TICKET'S PROPOSED MECHANISM WAS WRONG, which is the part worth keeping.\n\nThe ticket said: \"distinguish 'never connected' from 'was connected and dropped', which the socket readyState at close time can tell you.\" IT CANNOT. readyState is already CLOSED by the time any close listener runs, whichever happened; and the CloseEvent cannot separate them either, because a refused connection and a mid-session drop are BOTH code 1006 with wasClean:false. Nothing on the event carries the distinction.\n\nThe only discriminator is whether `open` ever fired, which the page must REMEMBER ITSELF. Hence an `opened` flag set in the open handler.\n\nDeliberately NOT the watchdog's fix: the watchdog is disarmed because it exists for silence only, whereas suppressing `close` outright would hide a genuine mid-session disconnect, which is the one thing that listener is really for.\n\nA DETAIL FOUND WHILE FIXING IT, worth knowing before touching this again: armWatchdog runs only AFTER `transport.ready` resolves -- on `open` -- so on the never-opened path the watchdog never exists at all. That is not what its own comment leads you to expect, and it means the original watchdog bug could only ever have fired on a connection that SUCCEEDED.\n\nSTILL UNVERIFIED BY EXECUTION, and stated rather than glossed: web/app.js has never been run here. The change is argued from the WebSocket spec and checked with node --check (proved discriminating by appending an unbalanced paren and watching exit 1), not observed. The four assertions this fix owes are recorded on improvise-vvl3 so the browser harness can discharge them -- including the one that must FAIL against the previous unconditional handler, which is the guard and not the decoration.","dependencies":[{"issue_id":"improvise-taxx","depends_on_id":"improvise-xps8","type":"relates-to","created_at":"2026-09-20T06:44:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-7v62","title":"Checked does not bind the App it was checked against","description":"Residual hole in improvise-ondj, found and documented by the agent that closed it. Not reachable today; filed so it is a decision rather than a discovery.\n\nChecked\u003ca\u003e is a private-field newtype over a \u0026Command whose only constructor, Checked::against(\u0026App, \u0026Command), runs the basis check. But the resulting Checked carries no evidence of WHICH App it was checked against, so a multi-document host could in principle check against one App and apply to another -- and the versions would be meaningless, since document_generation, layout_version and record_versions all start at zero in a fresh App and are per document (ADR 001 scopes them that way deliberately).\n\nWHY IT IS NOT REACHABLE TODAY: a Sessions is bound to one document (connect/apply/project_all all take the App as a parameter), and Registry hands out one (App, Sessions) pair per DocumentId. It is documented on the type.\n\nTHE OPTIONS, in increasing cost: leave it documented (defensible -- this is a coordination check, not a security boundary, and the misuse requires a host to actively mix two documents); give Checked a lifetime tied to the \u0026App so the borrow checker forbids the mix; or carry a DocumentId. The middle one is probably free and worth trying before accepting the first.\n\nWON T-FIX IS A LEGITIMATE OUTCOME -- but say so deliberately, because improvise-j31x made this a multi-document server on purpose and ADR 001 notes that a document is never evicted precisely so a reconnecting client cannot have a stale basis accepted as current. The invariant is load-bearing.","status":"closed","priority":4,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:54:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:49Z","started_at":"2026-09-20T05:32:30Z","closed_at":"2026-09-20T07:29:49Z","close_reason":"Closed WON'T FIX by PR #26 (WU-C), deliberately and with the compiler output on the type, which is what the ticket asked for (\"won't-fix is a legitimate outcome -- but say so deliberately\").\n\nThe ticket's middle option -- give Checked a lifetime tied to the \u0026App so the borrow checker forbids applying it to a different document -- WAS TRIED. It is not free, and the reason generalises: A PROOF DISCHARGED BEFORE A MUTATION CANNOT BE REPRESENTED AS A BORROW HELD ACROSS IT. A Checked is evidence produced by reading the App and whose whole job is to be carried INTO the mutation; a borrow must stay live DURING it. Every call site becomes E0502, starting with Sessions::apply. Now doctrine(a_proof_obligation_is_not_a_borrow).\n\nThe DocumentId option is worse placed than it looks: DocumentId lives in the SERVER'S REGISTRY and an App does not have one, because the unit of authority is one document per App. Threading it in would put a server concept into the command layer to guard against a misuse the server's own structure already prevents.\n\nWHAT KEEPS THE MISUSE UNREACHABLE, unchanged and load-bearing: a Sessions is bound to one document (connect/apply/project_all all take the App as a parameter) and Registry hands out one (App, Sessions) pair per DocumentId. ADR 001 notes a document is never evicted precisely so a reconnecting client cannot have a stale basis accepted as current.\n\nThe ticket was right that this is a coordination check and not a security boundary, and right that the invariant is load-bearing. It is documented on the type, with the reason the cheap fix is unavailable, so the next person does not re-derive it.","dependency_count":0,"dependent_count":0,"comment_count":0}
+4
View File
@@ -53,3 +53,7 @@ GSYMS
/graph.html
/graph.svg
.human
# Python bytecode from scripts/ (interpreter-version-specific, regenerated)
__pycache__/
*.pyc
+79 -10
View File
@@ -6,8 +6,17 @@ A comment run is at most two lines, optionally naming a context/ file for the
rest. See context/code/ for where the prose went, and improvise-9c8c for why.
Counts `///`, `//!` and `//` alike. Attributes are not comments and are
ignored. A ``` fence inside a doc comment suspends the count, because a
doctest is a running test rather than prose -- see --list-doctests.
ignored.
A ``` fence SUSPENDS the count rather than ending the run: the fenced body is
exempt (a doctest is a running test, not prose) but the prose on either side
belongs to ONE run and is summed. Two lines above a fence and two below is a
run of four, which is over the limit -- and that is deliberate, because a
reader meets it as one uninterrupted comment block. See --list-doctests.
Note rustdoc executes a bare ``` or ```rust fence and does NOT execute
```text, ```ignore or ```no_run. Only a runnable fence earns the exemption;
the rest is prose wearing a fence and the cap applies to it.
"""
import argparse
@@ -18,17 +27,44 @@ LIMIT = 2
def comment_runs(lines):
"""Yield (start_line_number, length) for each run, skipping doctest bodies."""
"""Yield (start_line_number, length) for each run, skipping runnable doctests.
Fence state and exemption are TWO facts and must be tracked separately.
Deciding both from one flag meant a non-runnable fence's bare closing ```
looked runnable and opened an exemption, so prose after a ```text block
evaded the cap entirely (found by review on PR #42 -- the sixth silent
success in this file, and the one introduced by fixing the fourth).
"""
run_start = None
run_len = 0
in_fence = False
in_fence = False # are we between an opening and a closing fence?
fence_exempt = False # is THIS fence's body a doctest rustdoc will run?
for n, raw in enumerate(lines, 1):
s = raw.strip()
is_comment = s.startswith("//")
if is_comment and s.lstrip("/!").strip().startswith("```"):
in_fence = not in_fence
if in_fence:
# The DELIMITERS belong to the block they close, so a runnable
# fence's closing ``` is exempt too -- otherwise it lands in the
# prose run and a two-line doc reads as three.
exempt_here = fence_exempt
in_fence = False
fence_exempt = False
else:
info = s.lstrip("/!").strip()[3:].strip().lower()
# rustdoc runs a bare or `rust` fence and does not run `text`,
# `ignore` or `no_run`. Only a runnable body earns the exemption.
in_fence = True
fence_exempt = info in ("", "rust") or info.startswith("rust,")
exempt_here = fence_exempt
if exempt_here:
continue
# A non-runnable fence line is prose and counts like any other.
if run_start is None:
run_start, run_len = n, 0
run_len += 1
continue
if is_comment and in_fence:
if is_comment and in_fence and fence_exempt:
continue
if is_comment:
if run_start is None:
@@ -51,6 +87,23 @@ def rust_files(roots):
yield os.path.join(dirpath, f)
_TEST_ATTRS = ("test", "tokio::test", "async_std::test", "wasm_bindgen_test")
def _is_test_attribute(line):
"""True only for an attribute that actually marks a test.
Matched by exact path, never by substring: `#[cfg(test)]` contains "test"
and marks a compilation condition, not a test.
"""
body = line.strip()
if not body.startswith("#["):
return False
body = body[2:].rstrip()
body = body[:-1] if body.endswith("]") else body
return body.strip().strip(":") in _TEST_ATTRS
EVIDENCE_DIR = os.path.join("context", "test-evidence")
@@ -83,12 +136,28 @@ def check_evidence(roots):
for path in rust_files(roots):
lines = open(path, encoding="utf8", errors="replace").read().split("\n")
annotated = False
in_proptest = False
# A `proptest!` block accepts its bodies as tests -- but only INSIDE the
# block. A bare flag was never cleared, so the first proptest! in a file
# made every later fn, helpers included, count as a test: a silent
# success in the gate itself (found by review on PR #40). Track the
# block's braces and leave when they balance.
proptest_depth = None
for raw in lines:
s = raw.strip()
if s.startswith("proptest!"):
in_proptest = True
if s.startswith("#[") and ("test" in s or "tokio::test" in s):
if proptest_depth is not None:
proptest_depth += raw.count("{") - raw.count("}")
if proptest_depth <= 0:
proptest_depth = None
elif s.startswith("proptest!"):
proptest_depth = raw.count("{") - raw.count("}")
if proptest_depth <= 0:
proptest_depth = None if "{" in raw else 0
in_proptest = proptest_depth is not None
# ANCHOR the attribute. `"test" in s` also matched `#[cfg(test)]`,
# so every cfg-gated fixture cleared a gate written to reject
# exactly those -- the third instance of the unanchored-substring
# class in this campaign, after the bd audit's exclusion list.
if s.startswith("#[") and _is_test_attribute(s):
annotated = True
continue
if s.startswith("fn ") or s.startswith("pub fn ") or s.startswith("async fn "):