Skip to content

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
epic/2680-mepfrom
security/issue-3318-ban-unparseable-import
Draft

feat(#3318): ban the constructs Semgrep cannot parse, taking the allowlist from 169 to 3#3345
thatskiff33-agents wants to merge 12 commits into
epic/2680-mepfrom
security/issue-3318-ban-unparseable-import

Conversation

@thatskiff33-agents

Copy link
Copy Markdown
Collaborator

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 the
test 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 "the
EMPTY argument list", measured on a single-line repro. That correction was itself
incomplete. Measured against the pinned semgrep/semgrep:1.161.0 image with the exact
blocking config from ci.yml:

construct result
f<typeof import("x")>() fails — >() was unexpected
f<typeof import("x")>( "x", ) on three lines fails — , was unexpected
f<typeof import("x")>("x") parses
f<typeof import("x")>("x", 1) parses
new C<typeof import("x")>() parses
(await f()) as typeof import("x") parses, both argument forms

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 a
FUNCTION 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 one
of 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.ts was allowlisted with no call shape in it
anywhere.

What shipped

A real ESLint rule, scan/no-semgrep-unparsable-import-type, registered in an
all-files block in eslint.config.mjs — not a no-restricted-syntax arm, because that is
switched 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 T
type-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 the
reason 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_CONSTRUCTS said three ampersand files while docs/MAINTENANCE.md said four. The
script 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.tsx
was on the list for the call shape; its &, inside a
https://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 main sync brought four unparsed files and one dead
suppression; 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-001
prefers 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 the
shipped 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.ts was NOT changed here — that was a
different lane.

Eleven mutation probes, all driven from bash with git status after each so no probe
could be left in the tree:

mutation caught by
detection keyed on vi.importActual canary threw, file red, exit 1
detection keyed on importOriginal 11 cases red
empty-args only — #2842's description 5 cases red: the trailing-comma and parses-today fixtures
no parameter arm 5 cases red
parameter arm narrowed to type-arguments 3 cases red
parameter check widened to any position canary threw, file red
NewExpression swept in 1 negative red
fix loses its parentheses 5 autofix cases red
rule downgraded to warn 13 cases red
reason no longer required in the gate 1 case red
alias branch removed survived — dead code, and it was deleted

That 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

  • Final authoritative scan at the final tree state: 4293 files scanned, 0 whole-file
    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 typecheck across all three projects — this is what proves the 307 casts
    type-equivalent — npm run knip, npm run docs:linkcheck, npm run docs:indexcheck,
    npm run quality:budget, and npx playwright test --list (192 tests, 45 files).
  • npx vitest related over all 200 changed paths in 8 chunks, plus all 235
    disk-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 edge
    to what it scans, so vitest related cannot reach it, and this change edits 188 files.
  • The rewrite was verified twice, two ways: the fixer performed it, then an
    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.
  • Line endings: all 201 changed files are w/lf, .gitattributes pins
    *.ts text eol=lf, and git diff --check is clean. The rewrite was done by
    eslint --fix, not by a text-mode script.
  • Self-match: the rule's own source and its guard test contain the construct only inside
    strings and comments; npm run lint is clean on both, and Semgrep parses both fully.

npm run lint found a site the measurement had missede2e/admin-member-detail.spec.ts,
because the scan was src/ and scripts/ while the blocking config also reads e2e/.
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

  • Full npm test: PR CI owns it. The targeted gate here is deliberately two-part —
    vitest related for the module graph, plus every disk-scanning suite by name, because
    the graph cannot reach those.
  • The Semgrep measurements are from local Docker runs of the pinned image, not from CI;
    CI re-measures on push and Static analysis gate is 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

  • N/A — no transaction, lifecycle, capacity, settlement, credit, webhook,
    cron, or concurrency-sensitive writer changed.
  • Writer class(es), canonical lock key(s), and acquisition order: none changed. The diff touches three sensitive-path modules — src/lib/xero-operation-retry.ts, src/lib/xero-bulk-contact-sync.ts and src/lib/age-tier.ts — and all three edits are type-only: a named type alias replacing an inline import() type in a parameter annotation. No lock participant, key, tier or guarded transition is added, moved or re-keyed.
  • Immutable pre-lock key source and mutable under-lock re-read: unchanged. No lock site is touched.
  • Status-guarded claim and proof that a lost claim runs no side effect: unchanged. No guarded transition is touched.
  • Relevant open/last-10 PR numbers, counterpart writers/tests, and compatibility evidence: SECURITY TOOLING: Make Semgrep coverage honest and align Cloud to the repository ruleset #2842 is the gate this empties and is the direct counterpart; its suite gained the reason cases 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 typecheck across all three projects is the compatibility evidence, and it is exactly the check a type-only edit can be wrong under.
  • Provider calls inside a transaction (write None, or justify the bounded exception from docs/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

  • New or moved definitions, and the one module each now lives in: the banned-construct
    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 became
    named aliases in the file that uses them.
  • If anything was copied rather than imported, or a guard was added instead of a
    structural fix: nothing was copied — a duplicated count was removed. The gate's
    KNOWN_CONSTRUCTS and docs/MAINTENANCE.md disagreed about the ampersand file count
    and 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

  • Fragment added (changelog.d/<pr-number>-<slug>.md): yes.

Residual Risks

None. Every finding from the review round is fixed in this pull request.

Stated limits

  • The Semgrep measurements are local Docker runs of the pinned image rather than CI runs.
    CI re-measures on push.
  • Class-method and constructor-signature parameter positions are included by shape
    rather than individually measured, because none occurs in the tree. The config comment
    says so at the site.
  • The allowlist is 3, not 0. Emptying it needs an upstream parser fix and this pull
    request does not claim one.

Manual Checks Required

None.

Safety Confirmation

A process note that belongs in the record

Clearing a hung Semgrep container, this lane ran docker ps -q | xargs docker kill and
took another lane's pg-issue3292 Postgres with it. It was restarted immediately and its
port 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

thatskiff33-agents and others added 12 commits September 8, 2026 22:46
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant