Six false-negative bypasses closed in Tools/check-webkit-test-isolation.py, the static guard that stops one synchronous WebKit-touching test from aborting the shared test host and reporting four figures of fictional failures (T-1541 / T-2096 / T-2219). Tooling only — no Swift changed. PR #386.
@Test parsed on both sides, 19 members removed and all 19 are phantoms read out of embedded JS/markdown string literals. Zero real losses, zero gains.TRAILING_INFIX_RE, and blanking >>/<< alongside ->).strip_noncode in the two staleness checks, and the one-line-suite reformat message, which is the entire user-facing deliverable of that gap's fix.3< before a newline is a postfix operator that does not exist — the tight/spaced asymmetry is a language fact, not a style guess, and enumerating > instead loses a member whichever global answer you pick.extent() continues a member only when the next line starts with . or {. A member-level property wrapped as let w = 1 / + WebPage()… is cut at line one and the construction on line two lands in no member body at all — silent, no anti-loss counter. Three member-level instances of the shape are in prismTests today.nonisolated(unsafe) drops the whole declaration. MODIFIER accepts nonisolated only when followed by whitespace, so nonisolated(unsafe) static var page = WebPage() matches no declaration regex and vanishes. Live in the tree once (URLDocumentLoaderTests.swift:65), and it is the idiomatic Swift 6 spelling for shared mutable test state.6f1d96a2): the round-4 Unicode custom-operator residual is now recorded in the rule's own comment and in docs/agent-notes/development-tooling.md. Only the regex-literal residual (an over-extension) had been written down; the Unicode one is a miss, the direction that reports a clean file.Ready to push
The branch is a strict improvement over main and introduces no regression. I ran an independent whole-target differential of the parser before and after: 4,508 @Test declarations parsed on both sides — identical — and member count 5,901 → 5,882, where every one of the 19 removals is a phantom let/var read out of JavaScript or markdown inside a string literal, plus one duplicate read+ambient registration collapsed to a single ambient. Zero real declarations lost, zero gained. All checks green: guard clean, 64 guard tests, 99 Tools tests, SwiftLint 0 violations, make build-macos succeeded.
Two residual false-negative classes remain that four review rounds did not surface, and both are pre-existing — neither is caused by this branch, and neither is a reason to hold it. They are, however, a reason to not close T-2244 on the belief that the Unicode custom operator is the only residual left: one of them (the leading-operator continuation) is the direct mirror of the gap commit 13a9ffb5 just closed, and its shape occurs at member level in the tree three times today while the shape that commit fixed occurs zero times. Push, then file the follow-up.
On the tests themselves: the “every fix is mutation-checked” claim in the commit bodies holds for the primary fix of every gap — I killed 13 of 13 core mutations independently. Seven auxiliary elements survive mutation with all 64 tests green. Five of those are cheap coverage additions; two are worth doing before merge, because they are the branch's own new code: strip_noncode in the two staleness checks (its regression direction is the guard going blind) and the one-line-suite reformat message (which is the entire user-facing deliverable of that gap's fix). Neither is a defect in behaviour today.
f873f534 fix(tooling): close five WebKit test-isolation guard bypasses (T-2244) 7a8ca7be fix(tooling): close the split-generic property gap in the isolation guard (T-2244) 13a9ffb5 fix(tooling): finish a declaration wrapped on a binary operator (T-2244) 6f1d96a2 docs(tooling): record the Unicode custom-operator residual (T-2244) Prism's test suite runs every unit test inside one shared process. If any single test crashes that process, every test still waiting in the queue is reported as a failure it never actually ran — one bad test can produce hundreds of fake failures. That has happened for real, more than once (tickets T-1541, T-2096, T-2219).
There is one specific way to cause it: touching the web engine (WebKit) from a test that does not await. Such a test can end up running on a background thread, and WebKit deliberately kills the process when it is started from one. So the project has a small Python program, Tools/check-webkit-test-isolation.py, that reads every test file and fails the build if it finds a test that could do this.
The problem this branch fixes: that checker was reading Swift source line by line with regular expressions, and there were six perfectly ordinary ways of writing Swift that it simply could not see. A test written in any of those ways was passed as safe while being exactly the dangerous thing the checker exists to catch.
A checker that says "all clear" when it is actually blind is worse than no checker, because people stop looking. The failures it misses are the expensive kind — not "one test failed" but "the whole run is fictional".
WebPage(. WebPage.init(), let p: WebPage = .init() and func f() -> WebPage { .init() } all build the same page with the type written somewhere a bare seed cannot reach. construction_patterns() now emits four spellings per seeded type.extent() ended a member at the first line that balanced its brackets, so a body whose { sits on the following line produced a member with an empty body — parsed, counted by the anti-loss cross-check, and unreadable.let page: / WebPage = WebPage() found no initialiser on line one and was dropped entirely, taking with it the ambient construction that taints every test in the suite.let pages: Array< / WebPage / > = [WebPage()] ends in no continuation character and balances its own brackets, so it needed angle-bracket depth of its own.@Test from the anti-loss counter, which only matched line-leading attributes. The counter now reads @Test anywhere on a line, so the loss is reported rather than silent.@MainActor matched anywhere on the declaration line, so an attribute on a parameter's type (_ body: @MainActor () -> Void) granted the isolation exemption to a test with no actor to hop to.Plus the inverse error: seed matching ran over raw text, so a source-contract test quoting "WebPage(" was reported as a violation. strip_noncode() now blanks comments and string literals across the whole file before anything is matched, preserving length and line structure exactly.
Every fix here trades between two failure modes that are not symmetric. Over-extending a member swallows the declaration below it — a silent loss, because only lost @Tests have a counter, never lost helpers. Under-extending cuts a member short and loses whatever is on the continuation line — also silent. Both directions lose. That is why the third commit stops enumerating continuation tokens and keys on Swift's own lexing rule instead: an operator with whitespace on its left and a line break on its right is infix and must be finished; the same characters written tight (Array<Int>, Int??, try f()!) are postfix or type syntax and end a complete declaration. Swift will not accept those spaced, so the two cases genuinely cannot collide.
The parser has two independent consumers of "does this line continue?" and they decide the two halves of the same member. extent() bounds the member's body (the value expression); _declaration_continues() drives the annotation walk inside _match_declaration (the type annotation). Commit 13a9ffb5 unified them behind _ends_mid_expression, which is the correct move: a token the two disagree about cuts the member in half at that line, and nothing downstream would notice.
Angle depth is deliberately not in extent(). At brace depth 0 a body such as func f() -> Bool { a < b } reads as an unclosed generic and swallows the declaration after it, so _match_declaration instead returns the last line it read the declaration across and the walker takes max(extent(...), last + 1) as the floor. Confining </> counting to the region before the =/{ marker is what makes the count exact rather than heuristic — there, those characters can mean nothing but a generic argument list. Correspondingly only -> is blanked before counting: blanking >> too reads Dictionary<String, Set<Int>> as two unclosed generics, the same swallow by another route.
strip_noncode preserving literal delimiters is the other non-obvious invariant, and it interacts with the continuation rule: blanking the quotes as well leaves let path = "…" as a line ending in =, which is a CONTINUATIONS member, which makes the walker swallow the declaration underneath. It is also load-bearing for _depth_deltas, whose """ fence tracking runs over the already-stripped text and needs the surviving fences to stay synchronised.
The parser differential over 294 files is the load-bearing regression evidence, and it is stronger than the anti-loss counter: the counter only sees lost @Tests, while the differential sees every member. Result: @Test 4,508 identical, members 5,901 → 5,882, and all 19 removals are phantoms out of JS/markdown string literals (walker, node, ruleCount, remaining …) plus webAttackFixtures collapsing from a duplicate read+ambient pair to one ambient. No real declaration moved.
What it does not prove is that the model is complete, and the two residuals below are the shapes it cannot show, because a shape absent from the tree produces no differential. The leading-operator case in particular is worth sitting with: the follows.startswith((".", "{")) escape in extent() already concedes that a member can continue onto a line that does not look like a continuation from above. It handles method chaining and next-line braces. Every other leading-operator continuation — and the target writes 303 of them — is outside it. The three at member level are harmless today only because nothing there constructs WebKit.
@Test cross-check, and the zero-gain differential confirms it empirically over the real tree.@MainActor suite do not inherit owner_main_actor. Swift infers the global actor for nested types, so the guard is stricter than the language here — a false positive, the safe direction.{, so _is_async_signature sees no effects clause and returns False. Errs toward reporting a violation. Correct.check-webkit-test-isolation.py
Why it matters. A construction the seed cannot spell is a construction the guard cannot see, and that is the false-negative direction — the one that reopens the host abort while CI reports the target structurally safe. Three of the four spellings were invisible before.
What to look at. check-webkit-test-isolation.py:124-147 construction_patterns()
check-webkit-test-isolation.py
Why it matters. The highest-leverage design decision on the branch. Whether a line continues decides where a member ends, and both wrong answers lose a declaration silently. Enumerating tokens forces one global answer for `>` and `?`, and both answers lose a member.
What to look at. check-webkit-test-isolation.py:336-374 CONTINUATIONS / TRAILING_INFIX_RE, and _ends_mid_expression at 649-659
check-webkit-test-isolation.py
Why it matters. extent() and _declaration_continues() decide the two halves of the same member — the value expression and the type annotation. Before this they used different rules, so a token they disagreed about cut a member in half at that line with nothing downstream noticing.
What to look at. check-webkit-test-isolation.py:649-671
check-webkit-test-isolation.py
Why it matters. The obvious implementation — teach extent() to count `<`/`>` — is wrong in a way that is invisible in a unit test but destructive over a real tree: at brace depth 0 a body like `func f() -> Bool { a < b }` reads as unclosed and swallows the declaration after it.
What to look at. check-webkit-test-isolation.py:632-644 _angle_depth, 689-742 _match_declaration's fourth return value, 783 stop = max(extent(...), last + 1)
check-webkit-test-isolation.py
Why it matters. It closes the branch's only false POSITIVE (a source-contract test quoting `"WebPage("`), and it removes 19 phantom `let`/`var` members the parser had been reading out of JavaScript and markdown embedded in test fixtures. A guard people have to disbelieve is a guard people disable.
What to look at. check-webkit-test-isolation.py:386-497
check-webkit-test-isolation.py
Why it matters. The subtlest of the six. `func f(_ body: @MainActor () -> Void) async` isolates the CLOSURE, not the test. Matching the attribute anywhere on the line handed the async exemption to a test with no actor to hop to — which is precisely the defect the async half of the rule exists to catch.
What to look at. check-webkit-test-isolation.py:518-529 _modifier_prefix, 864-908 _has_attribute
Swift's lexer classifies an operator by the whitespace around it. One with whitespace on its left and the line break on its right is infix, so the expression cannot be finished on that line; the same characters written tight against what precedes them are postfix or type syntax and end a complete declaration. Swift will not accept those spaced, so the two cases cannot collide. This replaces a seven-token CONTINUATIONS enumeration that had to answer “is > a continuation” once for the whole file, where both answers lose a member.
I independently verified the asymmetry holds: let ok = 3< before a newline would lex < as a postfix operator, which does not exist — so the tight form is not valid Swift as a continuation, and reading it as complete is correct rather than merely convenient.
_match_declaration returns the last line it read the declaration across, and the walker uses max(extent(index, end), last + 1). Tracking </> inside extent() would read func f() -> Bool { a < b } as unclosed at brace depth 0 and swallow the declaration after it.
-> is the one sequence that borrows the characters and means something else; without blanking it, let make: () -> Array<WebPage> reads as balanced one character too early. Blanking >> as well would read Dictionary<String, Set<Int>> as two unclosed generics and swallow the declaration underneath — the same loss by another route. Both are pinned by fixtures in both directions.
Blanking the quotes too leaves let path = "…" as a line ending in =, which is a continuation token, so the walker swallows the next declaration. The delimiters also keep _depth_deltas' """ fence tracking synchronised over the stripped text.
A line-based walker cannot split members out of a single line. Rather than adding a second parsing mode, the anti-loss counter was widened to read @Test anywhere on a line, so the suite now fails the run with a message telling the author to reformat it. Trading a small, self-correcting build failure for a silent blind spot is the right way round here.
->\s*WebPage\s*[?!]?\s*\{[^{}]*?\.\s*init\s*\( deliberately refuses to cross a nested brace, so a .init() inside a closure in the body is not attributed to the enclosing return type it does not construct. Over-tainting here would be safe but noisy; the constraint keeps the seed honest.
A swallowed declaration is still inside some member's body, so the taint can still arrive by luck and the violation assertion passes for the wrong reason. DECLARATION_AFTER_A_STRING_VALUED_PROPERTY, INNOCENT_GENERICS_AND_ARROWS and INNOCENT_TIGHT_OPERATOR_SUFFIXES all assert the exact member-name set instead. This is the correct instinct for a parser test and worth copying.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | check-webkit-test-isolation.py — extent(), leading-operator continuation | The mirror of the gap commit 13a9ffb5 just closed is still open, and it is the more common shape by a wide margin. extent() continues a member past a balanced line only when the next non-blank line starts with '.' or '{'. A member-level property wrapped with the operator LEADING the continuation line is therefore cut at line one, and — unlike a swallowed declaration, which at least lands in some member's body — the continuation line lands in NO member at all: walk() resumes at index=stop, _match_declaration returns None for '+ WebPage().layoutSize.width', and the line is skipped. Verified by direct probe against the branch parser: for 'private let w = 1' / '+ WebPage().layoutSize.width' inside a @MainActor suite, w.body is truncated to the first line, taint() returns an empty owner set, and a synchronous @Test in that suite scans clean. Silent: only lost @Test declarations have a counter, never lost helpers or truncated bodies. Prevalence measured over prismTests: 303 leading-operator continuation lines in code (post-strip), of which 3 sit immediately after a member extent — expectedCSP (WebRendering/PrismDocSchemeHandlerTests.swift:85), boundarySweep (WebRendering/WebSelectionOverlayGeometryTests.swift:219), verbatimCSP (WebRenderingSpikes/MermaidCSPSpikeTests.swift:300). None constructs WebKit on its continuation line, so there is no live violation. For contrast, the trailing-infix shape closed by 13a9ffb5 occurs ZERO times in the target. Pre-existing, not introduced by this branch. | Reported, not fixed — pre-existing and outside an auditor's remit. Recommend a follow-up ticket rather than holding the push. The fix looks small: extent()'s existing 'follows.startswith((".", "{"))' escape already concedes that a member can continue onto a line that does not look like a continuation from above; extending that tuple to cover a leading infix operator (a mirror of TRAILING_INFIX_RE, anchored at the start) closes it in the same idiom. Note this finding also corrects the round-4 conclusion that the ASCII-only operator class is the only residual: this one is strictly larger and does not need a custom operator to reach. |
| major | check-webkit-test-isolation.py — MODIFIER, nonisolated(unsafe) | MODIFIER matches the bare keyword 'nonisolated' followed by whitespace, and separately any '@attribute(...)' with parenthesised arguments — but not a KEYWORD with parenthesised arguments. So 'nonisolated(unsafe) private static var page = WebPage()' matches none of FUNC_RE / VAR_RE / TYPE_RE, _match_declaration returns None, the walker advances one line, and the entire declaration disappears. The construction is then in no member body and a synchronous test in that suite scans clean. Verified by probe (the member is absent from parse_members' output) and confirmed in the real tree: prismTests/URLDocumentLoaderTests.swift:65 declares 'nonisolated(unsafe) private static var handlers:' and the parser's member list for that file contains scopeHeader, lock, setHandler, handler, canInit, canonicalRequest and startLoading — but no 'handlers'. That property does not build WebKit, so there is no live violation. It matters because 'nonisolated(unsafe) static var' is the idiomatic Swift 6 spelling for shared mutable test state, which is exactly the shape a shared WebKit harness takes, and because a dropped declaration is the worst silent class — the @Test cross-check cannot see it. Pre-existing, not introduced by this branch. | Reported, not fixed. One-line widening of MODIFIER to allow a parenthesised argument list on the keyword alternatives would close it (the '@\w+(...)' branch already has the exact sub-pattern to reuse). Worth folding into the same follow-up ticket as the leading-operator gap. |
| minor | check-webkit-test-isolation.py — residual-risk comment / development-tooling.md | The residual note above TRAILING_INFIX_RE recorded only the regex-literal case, which is an OVER-EXTENSION. The Unicode custom-operator case found in review round 4 is a MISS — the direction that reports a clean file — and it was recorded nowhere: not in the rule's comment, not in docs/agent-notes/development-tooling.md, not in the commit body. Confirmed by probe: for 'private let ok = a ∘' / 'WebPage().layoutSize.width', ok.body is truncated to the first line and taint() returns an empty owner set, whereas the ASCII twin ('a <') and even 'a &+' are both caught correctly. On a file whose whole method is to write down what it cannot see, leaving the miss-direction residual unwritten is the omission that matters. | Fixed in editorial commit 6f1d96a2 (pushed). The comment now lists both residuals with their direction, and records why the Unicode one is left open: a custom operator must be declared before it can be used and prismTests declares none, so widening the character class would trade an unreachable gap for over-extensions on ordinary lines. The same note is added to docs/agent-notes/development-tooling.md. |
| major | test_webkit_test_isolation.py — strip_noncode in the staleness checks is unpinned | verify_seeds (check-webkit-test-isolation.py:238) and verify_test_harnesses (:278) both run strip_noncode over production/test source before searching for a construction. Reverting EITHER call site to the raw text leaves all 64 guard tests green — verified by mutation. The direction of that regression is the guard going blind, not noisy: a production file whose only remaining 'WKWebView(' sits in a comment or a string would keep PRODUCTION_WEBKIT_TYPES looking live after the real construction moved away, and the whole point of verify_seeds is that the seed list 'cannot silently go blind'. SeedTests' existing stale-file fixture writes '// no WebKit here any more', which mentions no constructor spelling at all — the one shape that structurally cannot detect this. | Reported. Two fixtures close it: a SeedTests case whose production file contains a commented-out construction plus a string mentioning the spelling, and a TestHarnessSeedTests case with a commented-out cross-file harness call. Behaviour is correct today; only the pin is missing. |
| major | test_webkit_test_isolation.py:1206 — the one-line-suite message is the deliverable and is not asserted | test_reports_a_suite_written_entirely_on_one_line asserts only 'parsed 0 of 1 @Test'. For every other gap the fix makes the guard SEE the hazard; for this one the guard admits it cannot parse the suite and the reformat instruction ('put its declarations on lines of their own') is the entire user-facing output of the fix. Verified by mutation: replacing the message with a bare 'Fix the parser.' leaves the suite green, so the branch could ship the counter without the actionable half and nothing would notice. | Reported. One added assertIn on the 'on lines of their own' phrase in that test. |
| minor | test_webkit_test_isolation.py — three secondary elements of the fixes are unpinned | Verified by mutation, all surviving with 64 green: (a) extent()'s next-NON-BLANK-line lookahead (check-webkit-test-isolation.py:752-755) — reverting it to lines[index + 1] changes nothing, because SYNC_WITH_THE_BRACE_ON_THE_NEXT_LINE has no blank line between the signature and its brace; (b) construction_patterns' '[^{}]*?' carve-out (:145), documented as load-bearing so a '.init()' buried in a nested closure is not attributed to a return type it does not construct — widening it to '[\s\S]*?' is invisible, and ConstructionSpellingTests has no false-positive fixture of any kind; (c) PROPERTY_LOOKAHEAD's UPPER bound (:377) — lowering it to 1 or 2 fails 3-5 tests, but raising it to 100 changes nothing, so the stated purpose ('bounded so a malformed line cannot walk the rest of the file') is untested, because MALFORMED_UNCLOSED_GENERIC sits fewer than 10 lines from the end of its fixture. | Reported. Each is one line of fixture: a blank line inside the existing next-line-brace fixture; a '.init()' inside a nested closure under a '-> WebPage' signature; a malformed declaration followed by more than ten lines and then a second @Test. Behaviour is correct today in all three. |
| nit | test_webkit_test_isolation.py:779 — ASYNC_WITH_A_MAIN_ACTOR_PARAMETER_TYPE is not compilable Swift Testing | The fixture is '@Test func viaParameterType(_ body: @MainActor () -> Void) async' — a @Test function with an un-defaulted parameter and no 'arguments:', which Swift Testing cannot run. It pins a shape that cannot appear in the target. Its deliberate twin SYNC_WITH_AN_ASYNC_CLOSURE_PARAMETER (:348) gets this right with 'arguments: [1]' and a default value. The gap-6 fix itself is sound and separately pinned; only the fixture's realism is off. | Reported. Rewrite it in the same shape as its twin so the fixture corpus stays a corpus of Swift that could actually be written. |
| nit | check-webkit-test-isolation.py:320-327 — part of the regex tightening is behaviourally inert | The new '(?<![\w.@])' lookbehinds are unobservable: reverting MAIN_ACTOR_ATTR_RE to a bare '@MainActor\b', or dropping the lookbehind from TEST_ATTR_RE, leaves the suite green — _modifier_prefix and strip_noncode already do that work. Only TEST_ATTR_RE's UNANCHORING is load-bearing (reverting it fails 5 tests). Not a defect, and defence in depth is reasonable here. | No action. Recorded so the green suite is not read as endorsing the lookbehinds — if they ever need to change, no test will object. |
| minor | check-webkit-test-isolation.py — extent(), generic where clause | A declaration whose signature is followed by a 'where' clause on its own line is ended at the signature: the signature line balances its brackets, does not end mid-expression, and 'where T: Sendable' starts with neither '.' nor '{'. The member's body is then the signature alone and the real body's statements belong to no member. Probed: '@Test func t<T: Equatable>(_ x: T)' / 'where T: Sendable' / '{ _ = WebPage() }' yields a test whose body is just the signature, and taint() finds nothing. Only a partial mitigation applies — declarations inside the orphaned body would be re-read as ambient members of the suite (over-tainting, the safe direction), but a bare statement like '_ = WebPage()' is not a declaration and simply vanishes. | Reported. Zero instances in prismTests (grep for a func with a where clause returns nothing) and generic test functions are close to nonexistent under Swift Testing, which uses 'arguments:' for parameterisation. Lowest priority of the three; mention it in the follow-up ticket for completeness rather than acting on it. |
| nit | check-webkit-test-isolation.py — duplicate blanking passes | _code_only() re-blanks strings and comments per line on text that parse_members has already run through strip_noncode(), and _depth_deltas' own blanking is a documented second pass that finds nothing. parse_members also joins the lines it was handed only to split them again inside code_lines(). All three are deliberate (the functions stay correct for a caller passing raw source) and strip_noncode is lru_cached, so the cost is a few seconds across ~300 files on a build gate. | No action. Recorded so a future reader does not mistake the redundancy for a bug and 'simplify' _depth_deltas into something that breaks for a raw-source caller. |
| nit | Verification scope | No xcodebuild test suite was run, per instruction (machine under contention). The diff touches only two .py and two .md files — no Swift, no project file, no build setting — so a Swift test run has nothing in it that could change behaviour. make build-macos was run anyway and succeeded. | No action. Checks run: make verify-test-isolation (clean, 64 guard tests), python3 -m unittest discover -s Tools/Tests (99 tests), make lint (0 violations in 555 files), make build-macos (Build Succeeded), plus an independent whole-target parser differential against origin/main. |
Click to expand.
diff --git a/Tools/check-webkit-test-isolation.py b/Tools/check-webkit-test-isolation.pyindex 36d6e3d6..5f83a02d 100755--- a/Tools/check-webkit-test-isolation.py+++ b/Tools/check-webkit-test-isolation.py@@ -74,13 +74,31 @@ Declarations *inside* a body are not members — the parser consumes a member's whole extent in one step — so a local `let page = WebPage()` taints only the test that writes it, never its siblings. +And it covers the *spellings* a construction can be written in, not just+`Name(`: `WebPage.init()`, `let page: WebPage = .init()`, and a `.init()`+inferred from a `-> WebPage` return type all build the same page (T-2244).+What stays invisible is a `.init()` whose type comes from somewhere the guard+does not read — a parameter's declared type at the call site, a collection+element — and code interpolated inside a string literal, which is blanked with+the rest of the literal.+ Because a parser that silently loses a declaration would look exactly like a clean repository, `scan` cross-checks the number of `@Test` attributes it parsed against the number written in the file and fails the run if any went missing.-Note what that does and does not cover: it catches a lost TEST, not a lost-HELPER. A dropped helper is equally silent and has no comparable counter, so-every declaration form the walker meets must be recorded rather than skipped —-which is why a one-line type is taken whole above instead of being stepped over.+That counter reads `@Test` wherever it appears on a line, not only at the start+of one, so it also catches the two shapes that used to slip past it: an+attribute list written as `@MainActor @Test func …`, and a whole suite written+on ONE line, whose members a line-based walker cannot split apart (T-2244). The+second is reported rather than parsed — reformatting the suite is the fix.+Note what the counter does and does not cover: it catches a lost TEST, not a+lost HELPER. A dropped helper is equally silent and has no comparable counter,+so every declaration form the walker meets must be recorded rather than+skipped — which is why a one-line type is taken whole above instead of being+stepped over.++Comments and string literals are blanked before any of this, so a constructor+spelling that a source-contract test merely *quotes* is not a construction, and+a brace inside an HTML or JS fixture cannot desynchronise the walker. Usage: Tools/check-webkit-test-isolation.py [repo-root] Exit 0 when clean, 1 on any violation (or if a seed list has gone stale).@@ -90,15 +108,42 @@ from __future__ import annotations import re import sys+from functools import lru_cache from pathlib import Path # Direct WebKit constructions. Reaching any of these off the main thread is what # kills the host.+WEBKIT_TYPE_NAMES = [+ "WKWebView",+ "WKWebViewConfiguration",+ "WKUserContentController",+ "WebPage",+]+++def construction_patterns(name: str) -> list[str]:+ r"""Every spelling that CONSTRUCTS `name`, not only `Name(`.++ `let page: WebPage = .init()` and `WebPage.init()` are the same construction+ with the type written somewhere a bare `\bWebPage\s*\(` seed cannot see, and+ a missed construction is a false NEGATIVE — the direction that reopens the+ host abort (T-2244). Two forms carry the type on the other side of the+ initialiser: an explicit annotation (`let p: WebPage = .init()`) and an+ inferred return (`func make() -> WebPage { .init() }`); the latter is matched+ only while no brace intervenes, so a `.init()` buried inside a nested closure+ is not attributed to the return type it does not construct.+ """+ symbol = re.escape(name)+ return [+ r"\b" + symbol + r"\s*\(",+ r"\b" + symbol + r"\s*\.\s*init\s*\(",+ r":\s*" + symbol + r"\s*[?!]?\s*=\s*\.\s*init\s*\(",+ r"->\s*" + symbol + r"\s*[?!]?\s*\{[^{}]*?\.\s*init\s*\(",+ ]++ WEBKIT_CONSTRUCTORS = [- r"\bWKWebView\s*\(",- r"\bWKWebViewConfiguration\s*\(",- r"\bWKUserContentController\s*\(",- r"\bWebPage\s*\(",+ pattern for name in WEBKIT_TYPE_NAMES for pattern in construction_patterns(name) ] # Prism types whose initialiser constructs one of the above. Kept as an explicit@@ -159,7 +204,11 @@ def seed_pattern() -> re.Pattern[str]: + list(PRODUCTION_WEBKIT_FACTORIES) + test_harness_patterns() )- parts += [r"\b" + name + r"\s*\(" for name in PRODUCTION_WEBKIT_TYPES]+ parts += [+ pattern+ for name in PRODUCTION_WEBKIT_TYPES+ for pattern in construction_patterns(name)+ ] return re.compile("|".join(parts)) @@ -182,7 +231,7 @@ def verify_seeds(root: Path) -> list[str]: f"constructs WebKit. Update PRODUCTION_WEBKIT_TYPES." ) continue- if not ctor.search(path.read_text(encoding="utf-8")):+ if not ctor.search(strip_noncode(path.read_text(encoding="utf-8"))): problems.append( f"{rel} no longer constructs WebKit directly, so listing {symbol} " f"as a WebKit constructor is stale. Update PRODUCTION_WEBKIT_TYPES."@@ -222,7 +271,7 @@ def verify_test_harnesses(root: Path) -> list[str]: """TEST_HARNESS_FACTORIES must list exactly the cross-file harnesses that exist.""" discovered = discover_test_harnesses(root) texts = {- path: path.read_text(encoding="utf-8")+ path: strip_noncode(path.read_text(encoding="utf-8")) for path in sorted((root / TEST_ROOT).rglob("*.swift")) } problems = []@@ -264,14 +313,76 @@ SUBSCRIPT_RE = re.compile(rf"^\s*{MODIFIERS}subscript\s*[(<]") TYPE_RE = re.compile(rf"^\s*{MODIFIERS}(?:struct|class|enum|actor|protocol)\s+(\w+)") EXTENSION_RE = re.compile(rf"^\s*{MODIFIERS}extension\s+([\w.]+)") VAR_RE = re.compile(rf"^\s*{MODIFIERS}(?:var|let)\s+(`?\w+`?)\s*(.*)$")-TEST_ATTR_RE = re.compile(r"^\s*@Test\b")+# Unanchored on purpose: `@MainActor @Test func …` puts the attribute mid-line,+# and a one-line suite puts it after the type's `{`. An anchored `^\s*@Test`+# misses both — the first as a test that is never checked at all, the second as a+# whole suite the anti-loss counter cannot see has gone missing (T-2244).+TEST_ATTR_RE = re.compile(r"(?<![\w.@])@Test\b") # `async` only buys the hop when there is an actor to hop TO. A `@MainActor` # global-actor attribute on the suite (or on the test itself) is what supplies it.-MAIN_ACTOR_ATTR_RE = re.compile(r"^\s*@MainActor\b|@MainActor\b")+MAIN_ACTOR_ATTR_RE = re.compile(r"(?<![\w.@])@MainActor\b")++# Where a declaration's introducer keyword starts. Everything before it is the+# modifier/attribute prefix.+DECL_KEYWORD_RE = re.compile(+ r"\b(?:func|var|let|init|deinit|subscript|struct|class|enum|actor|protocol"+ r"|extension|typealias)\b"+) # A declaration continues onto the next line when it ends mid-expression. Brace,-# paren and bracket depth cover almost everything; these are the rest.-CONTINUATIONS = ("=", ",", "->", "&&", "||", "+")+# paren and bracket depth cover almost everything; these are the rest. Every+# entry here is a token that is written TIGHT against what precedes it at least+# some of the time — `let page:` / ` WebPage = WebPage()` breaks after a colon+# with no space before it, and `,` never has one — so a plain suffix test is the+# only thing that sees them. Spaced binary operators are a separate rule below.+CONTINUATIONS = ("=", ",", "->", "&&", "||", "+", ":")++# A binary operator left dangling at the end of a line, which means its right+# operand is on the next one. Which characters those are is not a style guess:+# Swift lexes an operator by the whitespace around it, and one with whitespace on+# its left and the line break on its right is INFIX, so the expression cannot be+# finished. Matching on that instead of on the last character is what keeps the+# rule from over-extending, because every shape that ends a COMPLETE declaration+# with one of these characters writes it tight against what precedes it:+# `Array<Int>`, `var x: Int??`, `let y = try f()!`, `let z = foo.bar`. Those are+# postfix or type syntax, and Swift will not accept them spaced.+#+# So the honest set is "every infix operator", reached by spelling rather than by+# enumeration: `<`, `>`, `<=`, `>=`, `==`, `!=`, `&&`, `||`, `+`, `-`, `*`, `/`,+# `%`, `??`, `?`, `:`, `..<`, `...`, `&`, `|`, `^` and any custom operator built+# from the same characters. Enumerating instead would have to answer "is `>` a+# continuation" with one global yes/no, and both answers are wrong somewhere: yes+# swallows the declaration under `var grouped: Dictionary<String,` /+# `Set<Int>>`, no drops the second line of `let ok = 3 >` / `WebPage().width`+# — the T-2244 loss class, reported as a clean file (T-2244 round 3).+#+# Known residuals, one in each direction:+#+# * A bare regex literal whose last character before the closing `/` is a space+# (`let r = /a /`) reads as a dangling `/`. It costs an over-extension, not a+# miss, and the target has no regex literals.+# * The character class is ASCII. Swift also builds operators from the Unicode+# maths and symbol blocks, so a declaration wrapped on a CUSTOM operator+# spelled with one — `let ok = a ∘` / `WebPage().layoutSize.width` — is read+# as complete and the construction on its second line is cut off. That is the+# MISS direction, the one that reports a clean file (T-2244 round 4). It is+# left open rather than fixed because a custom operator has to be declared+# before it can be used and `prismTests` declares none, so widening the class+# to `\w`-excluding punctuation would trade a gap nothing in the target can+# reach for over-extensions on every identifier that ends a line. Revisit if a+# custom operator is ever introduced.+TRAILING_INFIX_RE = re.compile(r"(?:^|[\s(\[{,])[-+*/%<>=!&|^~?.]+$")++# The only thing spelled with `<`/`>` that is NOT a generic-argument bracket in a+# type annotation. `<=`, `>=`, `<<` and `>>` are expression operators and cannot+# appear in one, so they are deliberately left alone — blanking `>>` would read+# `Dictionary<String, Set<Int>>` as two unclosed generics.+ARROW_RE = re.compile(r"->")++# How far a `var|let` declaration may be followed before giving up looking for+# its initialiser. Generous enough for a wrapped type annotation, bounded so a+# malformed line cannot walk the rest of the file.+PROPERTY_LOOKAHEAD = 10 STRING_RE = re.compile(r'"(?:\\.|[^"\\])*"') COMMENT_RE = re.compile(r"//.*$")@@ -284,6 +395,128 @@ def _code_only(line: str) -> str: return code[: comment.start()] if comment else code +# The only places non-code can begin: a line comment, a block comment, or a+# string literal with any number of leading `#`s.+NONCODE_START_RE = re.compile(r'//|/\*|#*"')+# The body of a `"…"` literal up to and including its closing quote, and of a+# `"""…"""` one. Escapes are honoured so `"a\"b"` is one literal, not two. The+# single-line form may not cross a newline — Swift has no such literal, and+# letting it run on would blank real code as far as the next quote in the file.+SINGLE_BODY_RE = re.compile(r'(?:\\.|[^"\\\n])*"')+MULTI_BODY_RE = re.compile(r'(?:\\.|[^\\])*?"""', re.S)+BLOCK_FENCE_RE = re.compile(r"/\*|\*/")+NEWLINE_RE = re.compile(r"[^\n]")+++def _blank(text: str) -> str:+ """`text` with every character but the newlines turned into a space."""+ return NEWLINE_RE.sub(" ", text)+++@lru_cache(maxsize=None)+def strip_noncode(text: str) -> str:+ """`text` with comments and string literals blanked out, line structure intact.++ Every character inside a comment or a literal becomes a space and newlines are+ kept, so line numbers and lengths still line up while the taint matcher can no+ longer be fooled by a constructor spelling that is only mentioned. A+ source-contract test asserting `#expect(source.contains("WebPage("))`+ constructs nothing, and failing it teaches maintainers to distrust the guard+ (T-2244).++ Handles `//`, nestable `/* */`, `"…"`, `\"\"\"…\"\"\"` and the `#"…"#` raw forms,+ which the target uses. Interpolated code inside a literal is blanked with the+ rest of it — the same blind spot the previous per-line blanking had.++ A literal's DELIMITERS survive. Blanking them too leaves `let path = "…"` as a+ line ending in `=`, which reads as a continuation and makes the walker swallow+ the declaration that follows.+ """+ out: list[str] = []+ index = 0+ length = len(text)+ while index < length:+ opening = NONCODE_START_RE.search(text, index)+ if opening is None:+ out.append(text[index:])+ break+ out.append(text[index : opening.start()])+ index = opening.start()+ token = opening.group(0)+ if token == "//":+ end = text.find("\n", index)+ end = length if end < 0 else end+ out.append(_blank(text[index:end]))+ index = end+ continue+ if token == "/*":+ depth = 0+ cursor = index+ while cursor < length:+ fence = BLOCK_FENCE_RE.search(text, cursor)+ if fence is None:+ cursor = length+ break+ depth += 1 if fence.group(0) == "/*" else -1+ cursor = fence.end()+ if depth == 0:+ break+ out.append(_blank(text[index:cursor]))+ index = cursor+ continue+ pounds = len(token) - 1+ quote = '"""' if text.startswith('"""', opening.end() - 1) else '"'+ body_at = opening.end() - 1 + len(quote)+ closing = quote + "#" * pounds+ out.append(text[index:body_at])+ if pounds:+ # A raw literal has no escapes: `\` is literal, and `\#(…)` is the+ # interpolation, blanked along with everything else inside.+ found = text.find(closing, body_at)+ terminated = found >= 0+ end = found + len(closing) if terminated else length+ else:+ body = (MULTI_BODY_RE if quote == '"""' else SINGLE_BODY_RE).match(text, body_at)+ terminated = body is not None+ if terminated:+ end = body.end()+ elif quote == '"':+ # Unterminated on its line. Blank the line, not the rest of the file.+ newline = text.find("\n", body_at)+ end = length if newline < 0 else newline+ else:+ end = length+ if terminated:+ out.append(_blank(text[body_at : end - len(closing)]) + closing)+ else: # Unterminated: everything to the end of the file is inside the literal.+ out.append(_blank(text[body_at:end]))+ index = end+ return "".join(out)+++def code_lines(text: str) -> list[str]:+ return strip_noncode(text).split("\n")+++def count_declared_tests(text: str) -> int:+ """`@Test` attributes actually written in `text`, wherever they sit on a line."""+ return len(TEST_ATTR_RE.findall(strip_noncode(text)))+++def _modifier_prefix(code: str) -> str:+ """The part of a declaration line BEFORE its introducer keyword.++ Which is the only place an attribute can isolate the declaration. Matching+ `@MainActor` anywhere on the line marks+ `func f(_ body: @MainActor () -> Void) async` as main-actor isolated when+ neither it nor its suite is — an exemption handed to a test that has no actor+ to hop to, which is the whole defect the `async` half of the rule guards+ against (T-2244).+ """+ match = DECL_KEYWORD_RE.search(code)+ return code[: match.start()] if match else code++ def _is_async_signature(signature: str) -> bool: """True when `async` appears in the EFFECTS clause of this signature. @@ -350,7 +583,9 @@ def _depth_deltas(lines: list[str]) -> list[int]: Test files for a web renderer are full of HTML and JS fixtures in multi-line string literals; counting their braces would desynchronise the parser and- silently swallow whole suites.+ silently swallow whole suites. `parse_members` hands this already-stripped+ lines, so the blanking below is a second pass that finds nothing — kept so+ the function is correct for a caller that passes raw source. """ deltas = [] in_raw_string = False@@ -383,57 +618,126 @@ def _depth_deltas(lines: list[str]) -> list[int]: return deltas -def _property_kind(rest: str, follow: str, is_lazy: bool) -> str | None:- """Classify what follows `var|let NAME` — or None when nothing ever runs."""+def _property_marker(rest: str) -> int | None:+ """Index of the `=` or `{` that ends `var|let NAME …`, at bracket depth 0.""" depth = 0- marker = None- for char in rest:+ for position, char in enumerate(rest): if char in "([": depth += 1 elif char in ")]": depth -= 1 elif depth == 0 and char in "={":- marker = char- break- if marker is None:+ return position+ return None+++def _angle_depth(annotation: str) -> int:+ """Unclosed generic-argument brackets in a `var|let` declaration's annotation.++ Only ever asked about the text BEFORE the `=`/`{` that ends the annotation,+ where `<` and `>` can mean nothing but a generic argument list — so counting+ them is exact rather than a heuristic. `->` is the one sequence that borrows+ the characters and means something else, and it is removed first: without+ that, `let make: () -> Array<WebPage>` reads as balanced one character too+ early on the wrap and `let handler: Dictionary<String,` / `(Int) -> Bool` /+ `> = [:]` ends at its second line.+ """+ code = ARROW_RE.sub(" ", annotation)+ return code.count("<") - code.count(">")+++def _ends_mid_expression(line: str) -> bool:+ """True when `line` stops on a token that has to be finished on the next line.++ The tight-token suffixes and the spaced-infix rule, in one place, because+ `extent()` and `_declaration_continues()` must agree: they decide the two+ halves of the same member (the value expression and the type annotation), and+ a token one of them reads as a continuation and the other does not cuts the+ member in half at that line.+ """+ stripped = line.rstrip()+ return bool(stripped.endswith(CONTINUATIONS) or TRAILING_INFIX_RE.search(stripped))+++def _declaration_continues(rest: str) -> bool:+ """True when `rest` breaks off mid-declaration, so the next line finishes it."""+ stripped = rest.rstrip()+ if _ends_mid_expression(stripped):+ return True+ # `let pages: Array<` / ` WebPage` / `> = [WebPage()]` is the T-2244+ # multiline-property gap wearing a generic: the first line ends in no+ # continuation character and balances its own brackets, so without angle+ # depth the member is dropped exactly as `let page:` used to be.+ if _angle_depth(stripped) > 0:+ return True+ return sum(stripped.count(char) for char in "([") > sum(+ stripped.count(char) for char in ")]"+ )+++def _property_kind(rest: str, follow: str, is_lazy: bool) -> str | None:+ """Classify what follows `var|let NAME` — or None when nothing ever runs."""+ position = _property_marker(rest)+ if position is None: return None # `var x: T` — a declaration with no body and no initialiser.- if marker == "{":+ if rest[position] == "{": return READ # Computed property, or observers on a property with no initialiser.- value = rest.split("=", 1)[1].strip() or follow.strip()+ value = rest[position + 1 :].strip() or follow.strip() if value.startswith("{"): return READ # Stored closure: the body runs when the name is used, not at init. return READ if is_lazy else AMBIENT -def _match_declaration(lines: list[str], index: int) -> tuple[str, str, bool] | None:- """(kind, name, is_static) for a declaration starting at `index`, else None."""+def _match_declaration(lines: list[str], index: int) -> tuple[str, str, bool, int] | None:+ """(kind, name, is_static, last_line) for a declaration at `index`, else None.++ `last_line` is the last line the declaration was read across — `index` for+ every form but a `var|let` whose type annotation wraps. `extent()` decides a+ member's end from bracket depth and the continuation suffixes, neither of+ which sees an unclosed generic, so the walker is told the floor here rather+ than teaching `extent` to count `<`/`>` everywhere: at brace depth 0 a body+ such as `func f() -> Bool { a < b }` would then read as unclosed and swallow+ the declaration after it.+ """ line = lines[index] prefix = line.split("func", 1)[0] is_static = bool(re.search(r"\b(?:static|class)\b", prefix)) match = FUNC_RE.match(line) if match:- return FUNC, match.group(1).strip("`"), is_static+ return FUNC, match.group(1).strip("`"), is_static, index for pattern, name in ((INIT_RE, "init"), (DEINIT_RE, "deinit"), (SUBSCRIPT_RE, "subscript")): if pattern.match(line):- return AMBIENT, name, False+ return AMBIENT, name, False, index match = TYPE_RE.match(line) if match:- return TYPE, match.group(1), False+ return TYPE, match.group(1), False, index match = EXTENSION_RE.match(line) if match:- return TYPE, match.group(1).split(".")[-1], False+ return TYPE, match.group(1).split(".")[-1], False, index match = VAR_RE.match(line) if match:- follow = next((text for text in lines[index + 1 : index + 3] if text.strip()), "") keyword_prefix = re.split(r"\b(?:var|let)\b", line, maxsplit=1)[0]- kind = _property_kind(- match.group(2), follow, bool(re.search(r"\blazy\b", keyword_prefix))- )+ # `let page:` / ` WebPage = WebPage()` is ONE declaration. Reading only+ # the first line finds no initialiser, so the member is dropped — and with+ # it the ambient WebKit construction that taints every test in the suite,+ # which leaves the whole suite scanning clean (T-2244).+ rest = match.group(2)+ cursor = index+ limit = min(len(lines), index + PROPERTY_LOOKAHEAD)+ while (+ _property_marker(rest) is None+ and _declaration_continues(rest)+ and cursor + 1 < limit+ ):+ cursor += 1+ rest += " " + lines[cursor]+ follow = next((text for text in lines[cursor + 1 : cursor + 3] if text.strip()), "")+ kind = _property_kind(rest, follow, bool(re.search(r"\blazy\b", keyword_prefix))) if kind is None: return None return kind, match.group(1).strip("`"), bool( re.search(r"\b(?:static|class)\b", keyword_prefix)- )+ ), cursor return None @@ -443,7 +747,12 @@ def parse_members(lines: list[str]) -> list[Member]: Declarations nested inside a member's body are deliberately not members: the walker consumes a member's whole extent in one step, so a local `let page = WebPage()` stays attributed to the one test that writes it.++ Comments and string literals are blanked first, so everything downstream —+ declaration matching, bracket depth, attribute look-back and the member+ bodies the taint matcher reads — sees code only. """+ lines = code_lines("\n".join(lines)) deltas = _depth_deltas(lines) members: list[Member] = [] @@ -452,9 +761,15 @@ def parse_members(lines: list[str]) -> list[Member]: index = start while index < limit: depth += deltas[index]- follows = lines[index + 1].lstrip() if index + 1 < limit else ""- if depth <= 0 and not lines[index].rstrip().endswith(CONTINUATIONS):- if not follows.startswith("."):+ follows = next(+ (lines[cursor].lstrip() for cursor in range(index + 1, limit) if lines[cursor].strip()),+ "",+ )+ if depth <= 0 and not _ends_mid_expression(lines[index]):+ # A body whose `{` is written on the NEXT line is still this+ # declaration's body. Stopping at the signature leaves the member+ # with an empty body — parsed, counted, and never checked (T-2244).+ if not follows.startswith((".", "{")): return index + 1 index += 1 return limit@@ -466,8 +781,12 @@ def parse_members(lines: list[str]) -> list[Member]: if declaration is None: index += 1 continue- kind, name, is_static = declaration- stop = extent(index, end)+ kind, name, is_static, last = declaration+ # A wrapped `var|let` ends no earlier than the line its annotation+ # was read to; `extent` alone stops at the first line that balances+ # its brackets, which for `let pages: Array<` is the line the type+ # opens on and leaves the initialiser out of the member body.+ stop = max(extent(index, end), last + 1) if kind == TYPE: brace = next((cursor for cursor in range(index, stop) if "{" in lines[cursor]), None) if brace is not None and brace + 1 < stop - 1:@@ -534,30 +853,38 @@ def parse_members(lines: list[str]) -> list[Member]: def _has_attribute( lines: list[str], deltas: list[int], index: int, attribute: re.Pattern[str] ) -> bool:- """The attribute sits on the declaration line or on the lines above it.+ """The attribute sits in the declaration's modifier prefix or on the lines above it. An attribute is frequently spread over several lines — `@Test(` / display name / `arguments:` / `)` — and the closing lines look nothing like an attribute. Walking back through them needs bracket depth, not a prefix test; without it every parameterised test in the target reads as an ordinary function and is never checked at all.++ On the declaration line itself only the modifier prefix counts, and a line+ above must actually BE an attribute line rather than merely contain the+ text: an attribute written on a parameter's type (`_ body: @MainActor () ->+ Void`) isolates the parameter, not the declaration. """- if attribute.search(_code_only(lines[index])):+ if attribute.search(_modifier_prefix(lines[index])): return True back = index - 1 depth = 0 while back >= 0: stripped = lines[back].strip()+ # `@Test @MainActor` on one line is two attributes on the same+ # declaration, so an attribute line is searched, not prefix-matched.+ carries = stripped.startswith("@") and bool(attribute.search(stripped)) if depth < 0: # Inside the brackets of a multi-line attribute.- if attribute.match(lines[back]) and depth + deltas[back] >= 0:+ if carries and depth + deltas[back] >= 0: return True depth += deltas[back] back -= 1 continue- if stripped == "" or stripped.startswith("//"):+ if stripped == "": back -= 1 continue- if attribute.match(lines[back]):+ if carries: return True # A closing bracket continues an attribute; a closing BRACE is the end of # the previous declaration, and walking past it would inherit its `@Test`.@@ -638,15 +965,17 @@ def scan(root: Path) -> list[str]: seeds = seed_pattern() violations: list[str] = [] for path in sorted((root / TEST_ROOT).rglob("*.swift")):- lines = path.read_text(encoding="utf-8").split("\n")- members = parse_members(lines)- declared = sum(1 for line in lines if TEST_ATTR_RE.match(line))+ text = path.read_text(encoding="utf-8")+ members = parse_members(text.split("\n"))+ declared = count_declared_tests(text) parsed = sum(1 for member in members if member.is_test) if parsed < declared: # A parser that loses declarations looks exactly like a clean file. violations.append( f"{path.relative_to(root)}: the guard parsed {parsed} of {declared} @Test "- f"declarations, so the rest are unchecked. Fix the parser, not the file."+ f"declarations, so the rest are unchecked. Either fix the parser, or — if a "+ f"suite is written with its whole body on one line, which a line-based walker "+ f"cannot split into members — put its declarations on lines of their own." ) continue calls, reads, owners = taint(members, seeds)
diff --git a/Tools/Tests/test_webkit_test_isolation.py b/Tools/Tests/test_webkit_test_isolation.pyindex b795f74d..5ee7e2d9 100644--- a/Tools/Tests/test_webkit_test_isolation.py+++ b/Tools/Tests/test_webkit_test_isolation.py@@ -577,6 +577,285 @@ struct SampleTests { } """ +# --- Construction spellings that are not `Name(`. --------------------------++SYNC_VIA_TYPED_INIT = """+@MainActor+struct SampleTests {+ @Test("typed init")+ func viaTypedInit() {+ let page: WebPage = .init()+ #expect(page != nil)+ }+}+"""++SYNC_VIA_QUALIFIED_INIT = """+@MainActor+struct SampleTests {+ @Test("qualified init")+ func viaQualifiedInit() {+ let page = WebPage.init()+ #expect(page != nil)+ }+}+"""++SYNC_VIA_INFERRED_RETURN_INIT = """+@MainActor+struct SampleTests {+ private func makePage() -> WebPage {+ .init()+ }++ @Test("inferred return init")+ func viaInferredReturn() {+ #expect(makePage() != nil)+ }+}+"""++# --- Formatting the walker used to mis-read. -------------------------------++SYNC_WITH_THE_BRACE_ON_THE_NEXT_LINE = """+@MainActor+struct SampleTests {+ @Test("brace on the next line")+ func viaNextLineBrace()+ {+ _ = WebPage()+ }+}+"""++SYNC_VIA_MULTILINE_STORED_PROPERTY = """+@MainActor+struct SampleTests {+ private let page:+ WebPage = WebPage()++ @Test("never names the property")+ func neverNamesIt() {+ #expect(true)+ }+}+"""++SYNC_VIA_A_SPLIT_GENERIC_STORED_PROPERTY = """+@MainActor+struct SampleTests {+ private let pages: Array<+ WebPage+ > = [WebPage()]++ @Test("never names the property")+ func neverNamesIt() {+ #expect(true)+ }+}+"""++SYNC_VIA_A_SPLIT_GENERIC_CARRYING_A_FUNCTION_TYPE = """+@MainActor+struct SampleTests {+ private let observers: Dictionary<String,+ (WebPage) -> Void+ > = SampleTests.wire(for: WebPage())++ static func wire(for page: WebPage) -> Dictionary<String, (WebPage) -> Void> {+ [:]+ }++ @Test("never names the property")+ func neverNamesIt() {+ #expect(true)+ }+}+"""++SYNC_VIA_A_WRAPPED_COMPARISON = """+@MainActor+struct SampleTests {+ private let ok = 3 <+ WebPage().layoutSize.width++ @Test("never names the property")+ func neverNamesIt() {+ #expect(true)+ }+}+"""++SYNC_VIA_A_WRAPPED_NIL_COALESCE = """+@MainActor+struct SampleTests {+ private let resolved = SampleTests.stored ??+ WebPage()++ static let stored: WebPage? = nil++ @Test("never names the property")+ func neverNamesIt() {+ #expect(true)+ }+}+"""++SYNC_VIA_A_WRAPPED_PROTOCOL_COMPOSITION = """+@MainActor+struct SampleTests {+ private let page: any WebPageProtocol &+ Sendable = WebPage()++ @Test("never names the property")+ func neverNamesIt() {+ #expect(true)+ }+}+"""++INNOCENT_TIGHT_OPERATOR_SUFFIXES = """+@MainActor+struct SampleTests {+ var maybe: Int??+ var one: Array<Int>+ let forced = SampleTests.title!+ let make: () -> Bool = { true }++ static let title: String? = ""++ init() {+ maybe = nil+ one = []+ }++ @Test("clean")+ func pure() {+ #expect(maybe == nil && one.isEmpty && forced.isEmpty && make())+ }+}+"""++INNOCENT_GENERICS_AND_ARROWS = """+@MainActor+struct SampleTests {+ var grouped: Dictionary<String,+ Set<Int>>+ let make: (Int) -> Bool = { $0 > 0 }++ init() {+ grouped = [:]+ }++ func pick<T>(_ values: Array<T>) -> T? { values.first }++ @Test("clean")+ func pure() {+ #expect(pick(Array<Int>([1])) != nil)+ #expect(grouped.isEmpty && make(1))+ }+}+"""++MALFORMED_UNCLOSED_GENERIC = """+@MainActor+struct SampleTests {+ private let broken: Array<+ WebPage++ @Test("swallowed by the malformed declaration above")+ func swallowed() {+ _ = WebPage()+ }+}+"""++ONE_LINE_SUITE = """+@MainActor struct SampleTests { @Test("bad") func viaOneLineSuite() { _ = WebPage() } }+"""++# --- The isolation exemption. ----------------------------------------------++ASYNC_WITH_A_MAIN_ACTOR_PARAMETER_TYPE = """+struct SampleTests {+ @Test("main-actor closure parameter")+ func viaParameterType(_ body: @MainActor () -> Void) async {+ let page = WebPage()+ #expect(page != nil)+ }+}+"""++ASYNC_WITH_TEST_AND_MAIN_ACTOR_ON_ONE_LINE = """+struct SampleTests {+ @Test @MainActor+ func isolatedOnTheAttributeLine() async {+ let page = WebPage()+ #expect(page != nil)+ }+}+"""++SYNC_WITH_MAIN_ACTOR_AND_TEST_ON_THE_DECLARATION_LINE = """+struct SampleTests {+ @MainActor @Test func viaTrailingAttributes() {+ let page = WebPage()+ #expect(page != nil)+ }+}+"""++# --- Seeds that are only mentioned, never called. --------------------------++INNOCENT_CONSTRUCTOR_IN_A_STRING = """+@MainActor+struct SampleTests {+ @Test("source contract")+ func mentionsTheSpelling() {+ let source = "WebPage(configuration:)"+ #expect(source.contains("WebPage("))+ }+}+"""++INNOCENT_CONSTRUCTOR_IN_A_COMMENT = """+@MainActor+struct SampleTests {+ @Test("documented")+ func mentionsTheSpelling() {+ // Production calls WebPage(configuration:) here.+ /* and WKWebView(frame:) over here. */+ #expect(true)+ }+}+"""++INNOCENT_CONSTRUCTOR_IN_A_RAW_STRING = """+@MainActor+struct SampleTests {+ @Test("fixture text")+ func mentionsTheSpelling() {+ let expected = \"\"\"+ let page = WebPage()+ \"\"\"+ #expect(expected.contains(#"WebPage()"#))+ }+}+"""++DECLARATION_AFTER_A_STRING_VALUED_PROPERTY = """+@MainActor+struct SampleTests {+ private let path = "/tmp/doc.md"+ private let page = WebPage()++ @Test("never names either property")+ func neverNamesIt() {+ #expect(true)+ }+}+"""+ class ScanTests(unittest.TestCase): def test_flags_synchronous_direct_construction(self):@@ -697,6 +976,31 @@ class AsyncExemptionTests(unittest.TestCase): self.assertIn("directConstruction()", violations[0]) self.assertIn("synchronous", violations[0]) + def test_a_main_actor_parameter_type_does_not_exempt_an_async_test(self):+ # The exact twin of the case above, and the same dangerous direction:+ # `@MainActor` on a parameter's TYPE isolates the closure, not the test.+ # Matching the attribute anywhere on the declaration line hands the+ # exemption to a test with no actor to hop to (T-2244).+ violations = scan_source(ASYNC_WITH_A_MAIN_ACTOR_PARAMETER_TYPE)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("viaParameterType()", violations[0])+ self.assertIn("@MainActor", violations[0])++ def test_accepts_main_actor_written_after_test_on_the_attribute_line(self):+ # `@Test @MainActor` is two attributes on one declaration and the target+ # writes it that way. A look-back that prefix-MATCHES the line sees only+ # the `@Test` and calls a genuinely isolated test a violation.+ self.assertEqual(scan_source(ASYNC_WITH_TEST_AND_MAIN_ACTOR_ON_ONE_LINE), [])++ def test_finds_the_test_when_main_actor_precedes_test_on_the_declaration_line(self):+ # And the false-negative twin: with `@Test` no longer first on the line an+ # anchored attribute match reads this as an ordinary function, so it is+ # never checked at all.+ violations = scan_source(SYNC_WITH_MAIN_ACTOR_AND_TEST_ON_THE_DECLARATION_LINE)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("viaTrailingAttributes()", violations[0])+ self.assertIn("synchronous", violations[0])+ class NonFunctionDeclarationTests(unittest.TestCase): """The evasion family: every declaration form that is not a `func`.@@ -785,9 +1089,160 @@ class NonFunctionDeclarationTests(unittest.TestCase): self.assertIn("viaLocalFunction()", violations[0]) +class ConstructionSpellingTests(unittest.TestCase):+ """A construction the seed cannot spell is a construction the guard cannot see.++ Every case here is a false NEGATIVE — the direction that reopens the host+ abort while CI reports the target structurally safe (T-2244).+ """++ def test_flags_a_typed_init(self):+ # `let page: WebPage = .init()`: the type is on the LEFT of the `=`, so a+ # `\\bWebPage\\s*\\(` seed never sees a construction at all.+ violations = scan_source(SYNC_VIA_TYPED_INIT)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("viaTypedInit()", violations[0])+ self.assertIn("directly", violations[0])++ def test_flags_a_qualified_init(self):+ # `WebPage.init()` — the `.` between name and paren defeats `WebPage\\s*\\(`.+ violations = scan_source(SYNC_VIA_QUALIFIED_INIT)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("viaQualifiedInit()", violations[0])++ def test_flags_an_init_inferred_from_the_return_type(self):+ violations = scan_source(SYNC_VIA_INFERRED_RETURN_INIT)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("viaInferredReturn()", violations[0])+ self.assertIn("makePage()", violations[0])+++class DeclarationFormattingTests(unittest.TestCase):+ """Valid Swift the line-based walker used to mis-read into a clean result."""++ def test_flags_a_body_whose_brace_is_on_the_next_line(self):+ # The declaration line balances its own brackets, so the walker ended the+ # member at the signature. The test was still parsed and still counted —+ # so the anti-loss cross-check stayed green — but its body was empty and+ # nothing in it could ever be flagged.+ violations = scan_source(SYNC_WITH_THE_BRACE_ON_THE_NEXT_LINE)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("viaNextLineBrace()", violations[0])+ self.assertIn("directly", violations[0])++ def test_flags_a_stored_property_split_across_lines(self):+ # `let page:` / ` WebPage = WebPage()`. Reading only the first line+ # finds no initialiser, drops the member, and the ambient construction+ # that taints every test in the suite disappears with it.+ violations = scan_source(SYNC_VIA_MULTILINE_STORED_PROPERTY)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("neverNamesIt()", violations[0])+ self.assertIn("outside", violations[0])++ def test_flags_a_stored_property_split_inside_a_generic(self):+ # The same loss as above wearing a generic: `let pages: Array<` ends in no+ # continuation character and balances its own brackets, so without angle+ # depth the member — and the ambient construction on its last line — is+ # dropped and the suite scans clean. A line-length-driven wrap of any+ # `Array<WebPage>`/`Optional<WebPage>` property reproduces it.+ violations = scan_source(SYNC_VIA_A_SPLIT_GENERIC_STORED_PROPERTY)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("neverNamesIt()", violations[0])+ self.assertIn("outside", violations[0])++ def test_flags_a_split_generic_that_contains_a_function_type(self):+ # `->` is the one sequence that spells a `>` meaning something other than+ # the end of a generic argument list. Counted as one, `Dictionary<String,`+ # / `(WebPage) -> Void` reads as balanced a line early and the wrap ends+ # before the initialiser that builds the page.+ violations = scan_source(SYNC_VIA_A_SPLIT_GENERIC_CARRYING_A_FUNCTION_TYPE)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("neverNamesIt()", violations[0])+ self.assertIn("outside", violations[0])++ def test_flags_a_value_expression_wrapped_on_a_comparison(self):+ # `private let ok = 3 <` / ` WebPage().layoutSize.width`. The marker+ # (`=`) is on the first line, so the annotation walk never runs and only+ # `extent()` decides where the member ends — and a bare `<` was in+ # neither the continuation suffixes nor the bracket depth, so the second+ # line, the construction on it and the violation all disappeared.+ violations = scan_source(SYNC_VIA_A_WRAPPED_COMPARISON)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("neverNamesIt()", violations[0])+ self.assertIn("outside", violations[0])++ def test_flags_a_value_expression_wrapped_on_a_nil_coalesce(self):+ # The same cut one operator over. `??` cannot be enumerated by its last+ # character — `var x: Int??` ends a complete declaration with the same+ # two — so only the spacing tells them apart.+ violations = scan_source(SYNC_VIA_A_WRAPPED_NIL_COALESCE)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("neverNamesIt()", violations[0])+ self.assertIn("outside", violations[0])++ def test_flags_a_type_annotation_wrapped_on_a_protocol_composition(self):+ # The other consumer: `&` ends the ANNOTATION half, before any marker is+ # found, so the walk that looks for the initialiser stopped one line+ # short and the property was dropped for having none.+ violations = scan_source(SYNC_VIA_A_WRAPPED_PROTOCOL_COMPOSITION)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("neverNamesIt()", violations[0])+ self.assertIn("outside", violations[0])++ def test_reports_rather_than_absorbs_a_declaration_that_never_closes(self):+ # The over-extension direction of the annotation walk. It needs source+ # that does not compile — in valid Swift an unclosed `<`, `(` or `[` is+ # finished on a following line, which is the walk being right — so the+ # tree that could trigger it cannot produce a test RUN at all, and the+ # fictional-pass this guard exists to prevent is out of reach by+ # construction. What is pinned here is that the loss is loud anyway: the+ # malformed property absorbs the test below it, and the `@Test`+ # cross-check reports the members it could not parse instead of scanning+ # a suite that no longer exists.+ violations = scan_source(MALFORMED_UNCLOSED_GENERIC)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("parsed 0 of 1 @Test", violations[0])++ def test_reports_a_suite_written_entirely_on_one_line(self):+ # A line-based walker cannot split members out of a single line, so the+ # whole suite is taken whole and its test is invisible. What must NOT+ # happen is silence: the `@Test` counter reads attributes wherever they+ # sit on the line, so the loss is reported rather than passing.+ violations = scan_source(ONE_LINE_SUITE)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("parsed 0 of 1 @Test", violations[0])++ def test_a_string_valued_property_does_not_swallow_the_next_declaration(self):+ # Blanking a literal must leave its quotes behind: `let path = "…"` reduced+ # to `let path =` ends in a continuation, so the walker reads the next+ # declaration as part of it and the member is lost. Asserted on the parsed+ # members rather than on the violation, because a swallowed declaration is+ # still inside SOME member's body and the taint can arrive by luck.+ names = {+ member.name+ for member in guard.parse_members(+ DECLARATION_AFTER_A_STRING_VALUED_PROPERTY.split("\n")+ )+ }+ self.assertEqual({"path", "page", "neverNamesIt"}, names)+ violations = scan_source(DECLARATION_AFTER_A_STRING_VALUED_PROPERTY)+ self.assertEqual(len(violations), 1, violations)+ self.assertIn("neverNamesIt()", violations[0])+ self.assertIn("outside", violations[0])++ class FalsePositiveTests(unittest.TestCase): """The other direction. A guard people have to disbelieve is a guard people disable.""" + def test_a_constructor_named_in_a_string_is_not_a_construction(self):+ self.assertEqual(scan_source(INNOCENT_CONSTRUCTOR_IN_A_STRING), [])++ def test_a_constructor_named_in_a_comment_is_not_a_construction(self):+ self.assertEqual(scan_source(INNOCENT_CONSTRUCTOR_IN_A_COMMENT), [])++ def test_a_constructor_named_in_a_raw_string_is_not_a_construction(self):+ self.assertEqual(scan_source(INNOCENT_CONSTRUCTOR_IN_A_RAW_STRING), [])+ def test_a_computed_property_that_builds_nothing_is_not_flagged(self): self.assertEqual(scan_source(INNOCENT_COMPUTED_PROPERTY), []) @@ -802,6 +1257,37 @@ class FalsePositiveTests(unittest.TestCase): # `struct DummyError: Error {}` in the target becomes a violation. self.assertEqual(scan_source(INNOCENT_ONE_LINE_NESTED_TYPE), []) + def test_generics_and_arrows_do_not_swallow_the_declarations_under_them(self):+ # The other direction of the angle-depth fix. An over-continuing+ # declaration eats the one below it, which is a silent loss rather than a+ # violation — so the depth must close on `>>` (blanking it along with `->`+ # would leave `Dictionary<String,` / `Set<Int>>` two generics deep and+ # swallow `make`), and `(Int) -> Bool`, `pick<T>(…) -> T?` and `$0 > 0`+ # must open nothing. `grouped` is legitimately absent: a declaration with+ # no initialiser and no body has nothing that can ever run.+ names = {+ member.name+ for member in guard.parse_members(INNOCENT_GENERICS_AND_ARROWS.split("\n"))+ }+ self.assertEqual({"make", "init", "pick", "pure"}, names)+ self.assertEqual(scan_source(INNOCENT_GENERICS_AND_ARROWS), [])++ def test_tight_operator_suffixes_do_not_swallow_the_declarations_under_them(self):+ # The cost of reading a dangling operator as a continuation, paid in the+ # other direction. `Int??`, `Array<Int>` and `title!` end COMPLETE+ # declarations with characters the spaced-infix rule matches, and every+ # one of them is written tight against what precedes it — which is what+ # the rule keys on. Treating them as continuations instead would take+ # `make`, `title`, `init` or `pure` into the member above, and a+ # swallowed helper is a silent loss rather than a violation. `maybe` and+ # `one` are legitimately absent: no initialiser, no body, nothing runs.+ names = {+ member.name+ for member in guard.parse_members(INNOCENT_TIGHT_OPERATOR_SUFFIXES.split("\n"))+ }+ self.assertEqual({"forced", "make", "title", "init", "pure"}, names)+ self.assertEqual(scan_source(INNOCENT_TIGHT_OPERATOR_SUFFIXES), [])+ def test_a_local_declaration_does_not_taint_a_sibling_test(self): # `let page = WebPage()` inside one test body must not make `page` a # tainted member name for the whole suite.@@ -952,9 +1438,11 @@ class RepositoryTests(unittest.TestCase): # a parser regression legible as a parser regression. root = TOOLS_DIR.parent for path in sorted((root / guard.TEST_ROOT).rglob("*.swift")):- lines = path.read_text(encoding="utf-8").split("\n")- declared = sum(1 for line in lines if guard.TEST_ATTR_RE.match(line))- parsed = sum(1 for member in guard.parse_members(lines) if member.is_test)+ text = path.read_text(encoding="utf-8")+ declared = guard.count_declared_tests(text)+ parsed = sum(+ 1 for member in guard.parse_members(text.split("\n")) if member.is_test+ ) self.assertEqual(parsed, declared, path.relative_to(root))
diff --git a/docs/agent-notes/development-tooling.md b/docs/agent-notes/development-tooling.mdindex 94fd86ac..4bffd715 100644--- a/docs/agent-notes/development-tooling.md+++ b/docs/agent-notes/development-tooling.md@@ -89,6 +89,58 @@ Four things about the guard itself, all learned by it failing on this repo: declaration (`enum Fixture { static func page() -> WebPage { … } }`) whole and read-triggered instead of stepping over it — it has no interior lines to descend into, and skipping it made every test using that type read as clean.+- **Valid Swift the line-based parser used to read as clean** (T-2244, six+ independent bypasses, each closed with a fixture in+ `Tools/Tests/test_webkit_test_isolation.py`). Worth knowing because every one+ was found by reading the parser rather than by a failure, and the next one will+ be too: (1) a construction spelled `WebPage.init()`, `let p: WebPage = .init()`+ or `-> WebPage { .init() }` — the seeds now cover all four spellings per type;+ (2) a body whose `{` sits on the line AFTER the signature, which ended the+ member at the signature and left it with an empty body while still counting as+ parsed, so the anti-loss counter stayed green; (3) a stored property split+ across lines (`let page:` / `WebPage = WebPage()`), dropped entirely, taking+ the ambient construction that taints the whole suite with it — in BOTH its+ spellings, since a wrap inside a generic (`let pages: Array<` / `WebPage` /+ `> = [WebPage()]`) ends in no continuation character and balances its own+ brackets, so it needs angle depth of its own; (4) a suite+ written wholly on one line, whose `@Test` the counter could not see because it+ only counted line-leading attributes — it now reads `@Test` anywhere on a line,+ so this is REPORTED (reformat the suite) rather than silently unparsed; (5)+ `@MainActor` on a parameter's TYPE granting the isolation exemption to a test+ that has neither — the attribute is honoured only in the declaration's modifier+ prefix or on an attribute line above it; (6) a VALUE expression wrapped on a+ binary operator (`let ok = 3 <` / `WebPage().layoutSize.width`), where the+ `=` is found on the first line so the annotation walk never runs and only+ `extent()` decides the member's end — a bare `<` was in neither the+ continuation suffixes nor the bracket depth, so the construction on the second+ line was cut off and the file scanned clean. What decides a continuation now is+ Swift's own lexing rule rather than an enumeration: an operator with whitespace+ on its left and the line break on its right is INFIX and must be finished on+ the next line, while the same characters written TIGHT (`Array<Int>`,+ `var x: Int??`, `try f()!`) are postfix or type syntax and end a complete+ declaration. Enumerating instead forces one global answer for `>` and `?`, and+ both answers lose a member — a miss one way, a swallowed declaration the other.+ The `CONTINUATIONS` suffix tuple stays for the tokens that ARE written tight+ (`:` in `let page:`, `,`, `=`). The same pass strips comments and+ string literals before any matching, so a source-contract test that merely+ quotes `"WebPage("` is no longer a false positive and the phantom `let`/`var`+ members read out of embedded JS/HTML fixtures are gone. Stripping preserves the+ literal's DELIMITERS deliberately: `let path = "…"` reduced to `let path =`+ ends in a continuation, and the walker then swallows the declaration underneath+ it. Two more traps in that area, both load-bearing: angle depth is confined to+ a `var|let`'s TYPE ANNOTATION (`_declaration_continues`, plus a floor handed to+ `extent()` by `_match_declaration`) rather than tracked file-wide, because at+ brace depth 0 a body like `func f() -> Bool { a < b }` would then read as+ unclosed and swallow the declaration after it; and only `->` is blanked before+ counting — blanking `>>`/`<<` too reads `Dictionary<String, Set<Int>>` as two+ unclosed generics, which is the same swallow by another route. Recorded+ residual in the MISS direction: `TRAILING_INFIX_RE`'s character class is+ ASCII, so a declaration wrapped on a CUSTOM operator spelled from the Unicode+ maths/symbol blocks (`let ok = a ∘` / `WebPage().layoutSize.width`) reads as+ complete and loses its second line. Left open deliberately — a custom operator+ must be declared before it can be used and `prismTests` declares none, so+ widening the class would buy an unreachable gap at the price of+ over-extensions on ordinary lines. Revisit if one is ever introduced. ## Test-suite gotchas found while clearing the T-1541/T-1983 backlog
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bb517458..a014aa65 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The check that stops one test from aborting a whole test run no longer misses six ways of writing the very thing it looks for (T-2244). `make verify-test-isolation` fails the build when a test that runs without waiting can start up the web engine, because doing that off the main thread kills the process hosting every other test and reports them all as failures they never ran. It recognised only one spelling of "start up the web engine", one way of laying out a test's body, two ways of splitting a declaration over two lines, and one place an isolation marker can be written — so a test written in any of the other, equally ordinary, ways was passed as safe. A whole test suite written on a single line disappeared from it without trace. It also flagged a test that merely quotes the spelling in a string or a comment as if that test were doing it. Every gap now has a case of its own in the check's test suite, which runs alongside it. Developer tooling only; nothing in the app behaves differently. - An image that is tiny as a file but enormous as a picture can no longer exhaust memory or terminate Prism, whether it comes from the web, from a file beside the document, or written directly into the markdown (T-2132, T-2149, T-2151, T-1867). A picture is stored compressed, and a plain-coloured one compresses at about a thousand to one — so a 400 KB download can be a 20,000 by 20,000 image that needs about 400 MB the moment anything tries to display it, and four times that if it is in colour. Prism's limits were all written on the wrong side of that: a 50 MB cap on the download said nothing about the picture inside it, and the 2 MB cap on a local SVG was applied only after the whole file had already been read, so a very large one could freeze the app on its way to being refused. The limits that did exist covered only images fetched from the web; the same image referenced from a file next to your document, or embedded inline in the markdown, went straight to the renderer unchecked. Prism now reads the picture's dimensions from its header — a few bytes, before anything is decoded — and decides from that. An ordinary image is displayed as before. A very large one referenced from the web or from a file is scaled down to fit. One beyond any reasonable size is refused outright and shows the usual "Image failed to load" placeholder, rather than being handed to a decoder that would have to build the whole thing first. How large a picture is now also accounts for how much detail each dot of it carries: most pictures store one byte per colour, but some store two or four, and Prism previously assumed the smaller size for all of them and so under-counted the deep ones by half or three quarters. One consequence you may see: a very large deep-colour photograph that used to display at full size is now scaled down, because its true size was always above the limit and is now measured as such. Files are now read up to their limit instead of read whole and then measured — including the copy Prism keeps of a document you have not saved yet, which is restored when the app reopens. How much decoding happens at once is limited by how much memory those pictures actually need rather than by how many of them there are, so a page full of large images no longer overruns while appearing to stay within its bounds. Two things behave differently, both deliberately. An image whose file does not say how big it is, or what kind of dots it stores, now shows the "Image failed to load" placeholder instead of being displayed — there is no way to know what it would cost until it has already cost it. And an image written directly into the markdown is treated more strictly than the same image kept in a file beside the document: it is either small enough to display as it is or refused, never scaled down. That difference is about memory rather than effort. Scaling a picture that is written into the markdown means rebuilding it and writing the smaller version back into the page, where it then stays for as long as the document is open — which costs more memory, for longer, than not showing it. A picture in a file has somewhere else to keep its smaller version, so it can be scaled instead of refused. Animated images are unaffected in either case: they play as before, however many frames they have. - A verification scan that starts during the app's initial entitlement bootstrap can no longer publish a stale result while a newer scan is still in flight (T-2152). While `entitlementState` was still `.loading`, any scan's result was accepted regardless of whether a more recent scan — for example one started right after `AppStore.sync()` — was still reading the world; the older scan finishing first could briefly flip the paywall to locked (or unlocked) ahead of the newer, more current answer. An older result that arrives while a newer scan is still outstanding is now held back rather than published. If the newer scan goes on to answer, its fresher result is published and the held-back one is simply dropped; if instead it is cancelled without ever answering, the held-back result is released, so a cancelled scan cannot leave the paywall stranded on `.loading`. The trade is that the brief loading state now ends when the last overlapping scan answers rather than the first, so it can last marginally longer; every control it gates is disabled meanwhile, so nothing silently does nothing. - Saving a pasted document to a file no longer disturbs whatever document you opened next (T-2213). A save finishes in two parts: the file is written straight away, but the document only becomes that file once its notes have been moved across, and on a slow iCloud connection that second part can still be running after you have closed the document or opened another one. When it finished late, it acted on the document then on screen instead of the one it had saved: the pasted text of that other document was deleted from the place Prism keeps unsaved documents — so it could no longer be recovered after a relaunch — its entry in Recent Files was labelled with the wrong document's title, and an action you had queued behind its own Save prompt could run without you confirming it. A save that failed to move its notes also raised an alert naming a file you were no longer looking at. Each of these now belongs to the document that was actually saved, and the document on screen is left alone. Its Recent Files entry is labelled with its own title rather than the other document's. Where that other document had itself started saving in the meantime, the late save no longer takes over the shortcut that document had prepared for its own file, which can leave the saved file without a Recent Files entry of its own. The file is saved either way, and can be opened from the Files app.
Round 4's conclusion understates what is open. The leading-operator continuation gap is the direct mirror of the shape round 3 fixed, needs no custom operator to reach, and its shape sits at member level in prismTests three times today — while the shape round 3 fixed occurs zero times. Together with the nonisolated(unsafe) declaration drop, that is two false-negative classes with live instances of the shape in the tree. Neither blocks this branch; both belong on a follow-up ticket before the guard's coverage claim is treated as settled.
Both major findings above are silent for the same structural reason: scan's cross-check compares @Test counts, so a lost or truncated helper has no alarm at all. The module docstring already says this. It is worth noticing that the differential I ran — comparing the full member set before and after — is a strictly stronger regression check than the counter, and could be worth keeping as a repository test that pins the member set against a recorded baseline, so that a future parser change that drops a helper is legible the way a dropped test already is.
follows.startswith((".", "{")) is the only place the parser looks forward to decide a member's end, and both the leading-operator gap and the where-clause gap live there. If the follow-up widens it, widen it once with a named predicate mirroring _ends_mid_expression rather than adding characters to the tuple — the branch's own lesson from unifying extent() and _declaration_continues() applies to the forward direction too.
walk passes owner_main_actor down one level only, so a suite nested inside a @MainActor type without its own attribute is treated as unisolated even though Swift infers the global actor for nested types. That is a false positive — the safe direction, and pre-existing — but if someone ever hits it, the fix is to inherit the flag rather than to relax the rule.