feat(#3318): ban the constructs Semgrep cannot parse, taking the allowlist from 169 to 3 - #3345
Draft
thatskiff33-agents wants to merge 12 commits into
Draft
feat(#3318): ban the constructs Semgrep cannot parse, taking the allowlist from 169 to 3#3345thatskiff33-agents wants to merge 12 commits into
thatskiff33-agents wants to merge 12 commits into
Conversation
Adds `scan/no-semgrep-unparsable-import-type`, a rule of its own rather than a `no-restricted-syntax` arm because that rule is switched off for every test file and tests are the entire population. Both shapes are measured against the pinned scan image rather than described: a call whose type argument contains an `import()` type fails when the argument list is empty OR carries a trailing comma, and an `import()` type in a parameter annotation fails once it carries a type-argument list or an indexed access. The second was not previously written down anywhere and is the reason one allowlist entry read as unexplained. The class is banned rather than the broken spelling because which spelling a call has is decided by print width, so 23 files that parse today sit one reflow away from an allowlist entry of their own. The tree does not satisfy this rule yet; the rewrite follows in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
Mechanical, and applied by the rule's own autofix rather than by hand or by a
regex: `await f<typeof import("x")>(args)` becomes
`(await f(args)) as typeof import("x")` at 307 sites across 188 test files.
Type-equivalent because `importOriginal<T>()` and `vi.importActual<T>(path)`
are both declared to resolve to `T`, so dropping the type argument and
asserting the awaited value yields the identical type; a type argument is
erased at runtime, so nothing about what these mock factories return changes.
Verified by replaying the transformation through a second, independent parser
pass and range arithmetic and comparing the whitespace-normalised result to
what the fixer wrote: 307 of 307 sites identical, and no call in the tree still
carries the shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
…position The second unparseable shape, and the one nothing had written down: an `import()` type in a function parameter annotation, once it carries a type-argument list or an indexed access. One line in `adult-member-hosting-queue-merge.realdb.test.ts` was the whole reason that file sat on the allowlist while carrying none of the call shape. Each is now a named type, which is what parses and is the more readable code. `xero-bulk-contact-sync.ts` routes to the `xero-node` type import the file already had rather than minting a second path to the same type. All four are type-only edits; no runtime import graph changes, and the two dynamically imported modules are still imported dynamically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
27 cases through the SHIPPED eslint.config.mjs: the three callee spellings, the multi-line trailing-comma form the original description missed, the spelling that parses today and is banned anyway, the five parameter forms, the measured- clean neighbours the rule must stay silent on, the exact text the autofix writes, the three shapes it refuses to fix, and a severity check at seven representative paths. Mutation proofs follow in the report rather than in the tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
`inParameterAnnotation` stopped its walk at a type alias, with a comment saying that kept an alias from being reported in a circle. A type alias can only be declared at statement level, so an `import()` type inside one never has a function whose parameters contain the branch it came up through - the guard was unreachable. Deleting it left all 27 cases green, which is how it was found, and it is gone rather than left in with a comment claiming work it does not do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
The 165 entries carrying a rewritable shape are gone, and what remains is the
string-literal ampersand, which has no safe rewrite - one of the three is
asserted with `toHaveAttribute`, so escaping the `&` would change the value the
test asserts rather than only the source. THREE, not the four the prose claimed:
one of those four was unparsed for the CALL shape and its own `&` never tripped
anything, which the gate's own message had right and the summary beside the list
had wrong.
That drift is why the entry shape changed from a bare path to `{ file, reason }`
with the reason REQUIRED. The composition summary was a second statement of the
list and both of its clauses had gone false; the reason now sits on the entry it
explains and an entry without one is refused. `KNOWN_CONSTRUCTS` gains the third
shape and the trailing-comma half of the first, and says that both call-site
shapes are now banned by lint - so a partial parse of either means the rule was
bypassed, not that an entry is warranted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
…d by lint `docs/MAINTENANCE.md` -> "Semgrep parse coverage" said TWO faults and named four ampersand files; it is three faults and three ampersand files. The trailing-comma half of the first fault, the parameter-annotation fault nobody had written down, and the corrected count are all in, each with the measured repro that establishes it. It also no longer reads as pending work: the section says the first list's recurrence is closed by the lint rule and the suppression census's is not, and warns that a stray `.ts` file under `.artifacts/` is scanned by the local command and will be reported as a new unparsed region - which is not something CI can see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
…nline A fifth parameter-position `import()` type, in `e2e/admin-member-detail.spec.ts` - and the one my own measurement missed, because it scanned `src/` and `scripts/` while the blocking Semgrep scan reads `e2e/` too. `npm run lint` is what found it, which is the argument for the rule existing at all. It routes to the type import the file already had rather than minting a second path to the same type, so the diff is one import specifier and one annotation. The count in `docs/MAINTENANCE.md` moves from four to five. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
Nothing imports it: the guard suite reaches the rule by linting fixture text through the shipped config with the real `ESLint` class, so it never needs the plugin object. Its neighbour `SSOT_LOCAL_RULES` is exported with a docblock saying it is exported for its test, and no test imports that either - a claim worth not copying. An export here would also owe `eslint.config.d.mts` a declaration to stay reachable from TypeScript. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
…state it Re-measured against the branch point rather than carried over: the form appears at 12 call sites, and for 10 of the 169 entries it was the ONLY cause - the eleventh unexplained entry was the parameter shape, which is a different fault and is counted as one. Four statements of that number now agree, and the changelog says 166 files rather than 165 (165 carried the call shape and one carried the parameter shape). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
The fragment-directory rule names each file by its pull request number, which no lane can know before the pull request exists. Both fragments were staged under the issue number and are renamed now that #3345 is open. Content unchanged; the size ratchet still reports OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Linked Issue
Summary
#2842 made Semgrep's parse coverage honest: it measured which files the scanner could
not fully read, gated the number so it cannot grow silently, and left an allowlist of
169 files as the worklist. This empties that worklist by banning the constructs at
source instead of cataloguing them. The allowlist is now 3.
The three survivors are a bare
&inside a string literal, which has no safe rewrite —one of them is asserted with
toHaveAttribute, so changing the string changes what thetest checks. An empty list needs an upstream parser fix, and this pull request does not
claim one.
The construct description was wrong again, and measuring it is most of the work here.
#2842 corrected the original filing from "a generic call with an
import()type" to "theEMPTY argument list", measured on a single-line repro. That correction was itself
incomplete. Measured against the pinned
semgrep/semgrep:1.161.0image with the exactblocking config from
ci.yml:f<typeof import("x")>()>()was unexpectedf<typeof import("x")>( "x", )on three lines,was unexpectedf<typeof import("x")>("x")f<typeof import("x")>("x", 1)new C<typeof import("x")>()(await f()) as typeof import("x")Ten of the eleven unexplained entries were the trailing-comma form. The multi-line
spelling is not the trigger; the trailing comma is. So the real rule is that the call must
stay re-readable as a comparison expression — empty parentheses break that, and so does a
trailing comma. Which side of the line a call falls on is therefore decided by print
width: a formatter reflowing a long call creates a new unscanned region with no change
of substance at all. Twelve call sites in the tree sit on that boundary, and for ten of
the 169 entries it was the only cause.
The eleventh was a third fault nobody had written down. An
import()type in aFUNCTION PARAMETER annotation, once it carries a type-argument list or an indexed access.
(i: import("x").A)parses;(i: import("x").A<null>),(i: import("x").A["k"])and(i: (import("x").A)["k"])all fail;(i: (typeof import("x"))["k"])parses. Every oneof them parses in a return position, a variable annotation, an interface property or a
type alias. That single line was the entire reason
adult-member-hosting-queue-merge.realdb.test.tswas allowlisted with no call shape in itanywhere.
What shipped
A real ESLint rule,
scan/no-semgrep-unparsable-import-type, registered in anall-files block in
eslint.config.mjs— not ano-restricted-syntaxarm, because that isswitched off for every test file and tests are the entire population. Detection is
behavioural for both shapes. Only the autofix is name-gated, to
importOriginal/importActual/vi.importActual, whose declared signatures make(await f(args)) as Ttype-equivalent. It refuses to fix an unknown callee, a non-awaited call, more than one
type argument, an optional call, a comment inside the rewritten expression, or the
parameter shape.
307 call sites in 188 files rewritten, and 5 parameter-position types named as
aliases.
The allowlist entry shape changed from a bare path to
{ file, reason }, with thereason structurally required — the gate refuses an entry without one.
Decisions, and why
The rule bans each shape as a CLASS, not as the broken spelling. This is a deliberate
widening and you should know about it. 23 files held the "parses today" call spelling,
and every one of them was a single rename or reflow away from becoming an allowlist entry
of its own. Inside the parameter position the boundary is incoherent — a type argument or
an index breaks it, a return position does not — so enumerating the quirks is not
maintainable. This is the issue's original proposal #1 read literally. The issue body's
correction warned against describing those 23 as broken, and the rule's message states
the measured truth instead: your call may well parse today, and it is still reported,
because print width decides.
Why the reasons moved into the data. They used to live in a prose composition summary
beside the list, and both of its clauses had gone false: the gate's own
KNOWN_CONSTRUCTSsaid three ampersand files whiledocs/MAINTENANCE.mdsaid four. Thescript was right and the prose was wrong, and the classification inherited the wrong
count. A reason a gate refuses to accept as absent cannot drift away from its entry.
The allowlist lands at 3, not the 4 the issue predicted.
page-go-to-xero.test.tsxwas on the list for the call shape; its
&, inside ahttps://go.xero.com/...?shortcode=!aBc12&redirecturl=...string, never tripped anything.Removing the call shape removed the file.
A lint rule rather than absorbing the recurrence again. Measured across four events
while #2842 was in flight: a
mainsync brought four unparsed files and one deadsuppression; one sibling child brought a suppression; four siblings at once brought two
files; a later sibling brought one more. Three of those four were sibling children
rather than upstream syncs — so this is not an event to absorb once, it is anything
landing at all. Removing the growth at source is the structural fix
INV-SSOT-001prefers over a policed one.
Pros and cons
The gain is that the worklist is gone, the growth cannot recur, and the reason each
survivor exists is now data the gate enforces rather than prose beside it. The cost is a
wide, mechanical 201-file diff, and a rule that reports 23 call sites which parse today —
by design, and with the reason in the message, but a contributor who meets it will need
that sentence to make sense of it.
Tests Added Or Updated
src/lib/__tests__/semgrep-unparsable-import-type-guard.test.ts— 27 cases through theshipped config: three spellings, the trailing-comma form, the parses-today form, five
parameter forms, five measured-clean negatives, exact autofix output for five inputs,
three fix refusals, and a severity check at seven representative paths. The coverage
gate's own suite gained cases for the required
reason.src/lib/__tests__/member-guest-add-call-sites.test.tswas NOT changed here — that was adifferent lane.
Eleven mutation probes, all driven from bash with
git statusafter each so no probecould be left in the tree:
vi.importActualimportOriginalNewExpressionswept inwarnreasonno longer required in the gateThat last row is the one worth reading: a probe that survives is a probe that found
something. The branch was unreachable and is gone.
Validation Commands Run
unparsed, 0 rules abandoned, exactly 3 partial, matching the allowlist. Gate exits 0;
the scan itself exits 0 with 3 pre-existing suppressed findings.
npm run db:generate,npm run lint(0 errors, 48 pre-existing warnings),npm run typecheckacross all three projects — this is what proves the 307 caststype-equivalent —
npm run knip,npm run docs:linkcheck,npm run docs:indexcheck,npm run quality:budget, andnpx playwright test --list(192 tests, 45 files).npx vitest relatedover all 200 changed paths in 8 chunks, plus all 235disk-scanning census and contract suites by name through the fail-closed runner in 8
chunks. That second half matters: a suite that reads
src/from disk has no import edgeto what it scans, so
vitest relatedcannot reach it, and this change edits 188 files.independent replay through a separate parser pass and separate range arithmetic
reproduced all 307 sites byte-identically after whitespace normalisation, with no site
left in the tree.
w/lf,.gitattributespins*.ts text eol=lf, andgit diff --checkis clean. The rewrite was done byeslint --fix, not by a text-mode script.strings and comments;
npm run lintis clean on both, and Semgrep parses both fully.npm run lintfound a site the measurement had missed —e2e/admin-member-detail.spec.ts,because the scan was
src/andscripts/while the blocking config also readse2e/.Five parameter sites, not four. The rule caught what the measurement did not, which is
the argument for the rule.
Commands Not Run And Why
npm test: PR CI owns it. The targeted gate here is deliberately two-part —vitest relatedfor the module graph, plus every disk-scanning suite by name, becausethe graph cannot reach those.
CI re-measures on push and
Static analysis gateis the arbiter.Screenshots Or UI Evidence
N/A.
Security And Privacy Impact
This is a security-coverage change and it strengthens coverage. Before, 169 files were
partially unreadable to the blocking Semgrep gate, which means rules that should have run
over them did not — silently, because a partial parse still exits 0. That is now 3, each
with a recorded reason the gate will not let anyone drop. No authentication,
authorisation, session, token or privacy behaviour is touched.
Data Integrity Impact
None. No writer, query, transaction or column is changed. The three non-test source edits
are type-only.
Concurrency And Lock Impact
cron, or concurrency-sensitive writer changed.
src/lib/xero-operation-retry.ts,src/lib/xero-bulk-contact-sync.tsandsrc/lib/age-tier.ts— and all three edits are type-only: a named type alias replacing an inlineimport()type in a parameter annotation. No lock participant, key, tier or guarded transition is added, moved or re-keyed.reasoncases here. The two dynamically-imported modules in the Xero retry and bulk-sync paths are STILL imported dynamically — the alias names the type, not the import — so load order and outbox behaviour are unchanged.npm run typecheckacross all three projects is the compatibility evidence, and it is exactly the check a type-only edit can be wrong under.None, or justify the bounded exception fromdocs/CONCURRENCY_AND_LOCKING.md): None.N/A is not ticked because sensitive paths are in the diff, which is the right thing for
the gate to insist on even when every edit is a type name.
Single Source Of Truth
rule has one home, the ESLint rule; each survivor's reason has one home, its own
allowlist entry, structurally required. Five inline
import()parameter types becamenamed aliases in the file that uses them.
structural fix: nothing was copied — a duplicated count was removed. The gate's
KNOWN_CONSTRUCTSanddocs/MAINTENANCE.mddisagreed about the ampersand file countand the prose was wrong; the reasons now live in the data the gate reads. On "guard
versus structural fix": the rule is the fix at source, and the SECURITY TOOLING: Make Semgrep coverage honest and align Cloud to the repository ruleset #2842 coverage gate stays
as the measurement. They are not two enforcers of one rule — one stops the construct
being written, the other reports what the scanner actually managed to read.
Payment Or Accounting Impact
None.
Migration Or Deployment Impact
None.
Docs Updated
docs/MAINTENANCE.md— the third parser fault stated, the ampersand count corrected,and that two of the three shapes are now banned by lint rather than catalogued.
Changelog Entry
changelog.d/<pr-number>-<slug>.md): yes.Residual Risks
None. Every finding from the review round is fixed in this pull request.
Stated limits
CI re-measures on push.
rather than individually measured, because none occurs in the tree. The config comment
says so at the site.
request does not claim one.
Manual Checks Required
None.
Safety Confirmation
live provider endpoints. Every Semgrep run was a
--rmcontainer against the localtree.
AGENTS.md"Completion and Merge" risk gate: this is achild of epic MEP PROGRAMME: maintainability, mechanical enforcement and codebase hygiene #2680 and targets the epic's integration branch under Epics reach main as one merge from an integration branch, and the fork upgrade contract gets stated where forks read it #3002. It does
not merge to
mainon its own; the epic's single pull request intomainis wherethis gets its owner review.
A process note that belongs in the record
Clearing a hung Semgrep container, this lane ran
docker ps -q | xargs docker killandtook another lane's
pg-issue3292Postgres with it. It was restarted immediately and itsport mapping survived, and a note went to that lane on #3292 so a transient database red
in that window is not diagnosed twice. A lane clears only the containers it started, by
name.
🤖 Generated with Claude Code
https://claude.ai/code/session_01CAXBDdR1kBnUf4WQkayDrV