fix(#642): fail closed on computed dynamic imports in check-boundaries RULES - #668
Conversation
…s RULES
check-boundaries.mjs's generic RULES loop and Rule B relied on a hand-rolled
dynamic-import regex (extractSpecifiers's fourth pattern) that could only
extract an argument already shaped like a complete literal. A computed
dynamic import either matched nothing at all (silently exempting it from
every layering rule) or, on a concatenated expression like
import('../' + name), risked being partially matched into just its quoted
prefix -- neither is acceptable for a boundary check, where inability to
prove a target statically is itself the violation.
Added two shared, parser-backed helpers to build/lib/check-legacy-owners.mjs:
findDynamicImportUsages (a real TypeScript-parser walk classifying every
dynamic import(...) call as { kind: 'static', spec } for a string/
no-substitution-template-literal argument, or { kind: 'uncheckable' } for
everything else -- identifier, computed template, concatenation,
conditional, or any other shape, never silently dropped) and
mightContainDynamicImport (a conservative textual gate that only asks
whether an `import` keyword could be followed by legal trivia and `(`,
deliberately preferring false positives, never inspecting the argument).
check-boundaries.mjs now runs a fail-closed pre-pass over the deduplicated
set of files covered by any generic RULES entry, once per unique file (so
nested rule directories like src/dashboard/src/dashboard/application never
double-report the same occurrence): every uncheckable dynamic import is an
unconditional violation, and every static one flows through the existing
relative-resolution/forbidden-prefix logic exactly like an ordinary static
import. Rule B reuses the same cached classification instead of its own
dynamic regex. Rule C/Rule D and the other already-parser-backed package
guards are untouched. extractSpecifiers is renamed extractStaticSpecifiers
to make its narrowed static-only role explicit.
Added tests/unit/check-boundaries-dynamic-imports.test.js for the shared
classifier/gate in isolation, and extended dashboard-boundaries.test.js and
clickhouse-http-package-policy.test.js's Rule A/B policy mirrors with the
full fail-closed sabotage set (single/double/no-substitution-template
static forms, computed template, identifier, concatenation, conditional)
plus production drift binds proving the real checker calls the new shared
helpers and no longer carries the old dynamic-import regex arm.
Audited every other regex/prefilter in check-boundaries.mjs per the repo's
architecture-guard footgun: mightReferencePackage,
mightReferenceForbiddenRelativeDir, and mightReferenceRetiredTopLevelApi are
all unchanged and still scoped to their original threat models; every
resolved-path comparison still canonicalizes through resolveRelative's
fs.realpathSync. check:arch timing is unchanged (~15.3-15.8s before and
after across 3 runs each) -- no caching/gating changes were needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
DYNAMIC_IMPORT_GATE only recognized ASCII space/tab/CR/LF as whitespace and only backslash-n as a line-comment terminator, so a bare CR (not followed by LF), U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR, or vertical-tab/form-feed whitespace between "import" and its call parens made the gate return false for source the real parser correctly classifies as containing a dynamic import - silently exempting the file from check-boundaries.mjs's fail-closed pre-pass entirely, the same fail-open prefilter pattern this issue exists to close, just relocated into the new gate. Rewrite the gate's whitespace alternative to use regex \s (ECMA-262 defines this to match exactly the union of WhiteSpace and LineTerminator code points, so it is sound by construction rather than by enumerating individual code points one at a time), and give the line-comment alternative its own explicit LineTerminator class, written in source with regex escape sequences for U+2028 and U+2029 rather than raw characters, at both its "still inside the comment" and "ends the comment" positions - \s itself would incorrectly let a plain space terminate a line comment early. Adds regression coverage for each gap (bare CR, U+2028, U+2029, vertical tab, form feed, and comment termination at U+2028) and re-verifies live: a CR-comment probe file under src/core now correctly fails check:arch, matching the LF-terminated form; both were confirmed and cleaned up before this commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
BorisTyshkevich
left a comment
There was a problem hiding this comment.
ChatGPT review pass 1
Reviewed head: 650378dac12120d1618644f65659bee37b377e6e
Findings
-
[Major] Inline TypeScript
import()type expressions now bypass the genericRULESloop and Rule B.findDynamicImportUsages()only recordsCallExpressionnodes whose callee isImportKeyword(build/lib/check-legacy-owners.mjs, around lines 507-540). TypeScript'stype T = import('pkg').Foo/typeof import('pkg')is instead anImportTypeNode; the existingfindModuleSpecifiers()explicitly has a separateisImportTypeNodebranch for exactly this reason. Before this PR, the removed dynamic regex happened to extract the literal fromtype T = import('../workspace/model.js').Foo; after this PR,extractStaticSpecifiers()does not see it and the new classifier returns no usage, so the generic boundary receives no decision.This is a concrete regression for both policies:
src/corecan usetype T = import('../workspace/model.js').Foowithout the core→workspace rule seeing it, andpackages/clickhouse-http/srccan usetype T = import('left-pad').Foowithout Rule B seeing the bare package specifier. Guard 1 intentionally skips runtime-source bare specifiers because Rule B owns that case. History already documents this grammar distinction in commiteae412d(the Rule-D inline-import-type fix).Fix: include
ImportTypeNodespecifiers in the shared parser-backed generic classification/resolution path (or reuse the existing parser result forkind: 'import-type') and add sabotage coverage in bothdashboard-boundaries.test.jsandclickhouse-http-package-policy.test.js. Treat a literal import-type specifier as statically analyzable, not as a runtime computed import. -
[Major] The mandatory same-file audit missed the generic static-import regex's existing fail-open forms.
extractStaticSpecifiers()still uses\bimport\s+[\w*{}\s,]+.../ equivalent export regexes. A validimport/*c*/ { x } from '../workspace/x.js'is invisible, while an escaped specifier such asimport { x } from '../worksp\x61ce/x.js'is captured as raw escaped text and therefore resolves to the wrong lexical path rather thansrc/workspace. Both cases leave the generic boundary decision fail-open. The surrounding file already documents this exact comment/escaped-literal failure mode as the reason Rule C/D moved to the parser.The attached review contract explicitly asks this pass to check every other regex/prefilter in
check-boundaries.mjsfor the same unsound pattern. This is the same root class, not an unrelated cleanup.Fix: route the generic static module-specifier forms through the existing parser-backed module-specifier helper (preferably with a conservative gate/caching strategy if timing matters), rather than widening another regex; add comment-trivia and escaped-specifier sabotage cases.
-
[Major] The mandatory prefilter audit also leaves a raw
source.includes(name)gate in front of real-parser Guards 3/4 and the Phase-7 retired-API guard.mightReferenceRetiredTopLevelApi()still returnsnames.some(name => source.includes(name)), andcheck-boundaries.mjsuses it to decide whether to invokefindTransportSurfaceOwnershipViolations()andfindRetiredTopLevelApiViolations(). A valid escaped identifier such asexport const run\u0051uery = ...binds the exact identifierrunQuery, but contains no rawrunQuerysubstring, so the real parser is skipped. The repository's own ship-review guidance specifically says that, when touching an architecture parser prefilter, every other baresource.includes(name)prefilter in the file must be checked for this class of escape.Fix: make this shared prefilter escape-aware (at minimum route any source containing a backslash through the parser, matching the established conservative pattern) and add an escaped-identifier sabotage case for Phase 7 plus one Guards 3/4 name.
Validation
The canonical CI workflow for this exact head is green. A direct local clone/fetch was not possible in the review sandbox because github.com DNS resolution is unavailable, so I could not rerun the repository's Vitest files locally. I did run focused standalone probes against the exact current regex/prefilter expressions: the current static scanner misses the comment-trivia import, the pre-PR dynamic arm matches the inline ImportTypeNode spelling that the new classifier drops, and the raw-name prefilter returns false for a valid Unicode-escaped runQuery identifier.
VERDICT: REVISE
findDynamicImportUsages only walked CallExpression import(...) nodes, so
TypeScript's inline import-type expression (`type T = import('x').Foo`,
`typeof import('x')`) — its own ImportTypeNode grammar production — silently
bypassed the generic RULES loop and Rule B fail-closed pre-pass in
build/check-boundaries.mjs, even though mightContainDynamicImport's textual
gate correctly let those files through. The retired regex this issue replaced
matched this shape too (it couldn't distinguish a call from an import-type
expression either), so the AST-based classifier had strictly less coverage
for this one form until now.
findDynamicImportUsages now also walks ImportTypeNode, classifying its
literal argument through the same {kind: 'static'|'uncheckable'} contract as
a dynamic call. Added unit coverage in check-boundaries-dynamic-imports.test.js
plus sabotage probes in dashboard-boundaries.test.js and
clickhouse-http-package-policy.test.js proving the fix at the RULES-loop/
Rule-A/Rule-B integration level (verified failing without the production fix).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz
BorisTyshkevich
left a comment
There was a problem hiding this comment.
ChatGPT review pass 2
Previously reviewed head: 650378dac12120d1618644f65659bee37b377e6e
Reviewed head: a2318aa2612ab65f1f059649ad51506744b667d4
Reassessment of pass 1
-
RESOLVED — inline TypeScript import-type expressions.
The new commit explicitly adds an
is.isImportTypeNode(node)branch tofindDynamicImportUsages(), feeding the wrapped literal through the same{kind: 'static'|'uncheckable'}classifier as runtimeimport(...). The updated unit and policy-mirror suites cover single/double/no-substitution-template import types,typeof import(...), uncheckable non-literal forms, forbidden relative targets, legal relative targets, and Rule B bare package specifiers. I do not see a residual form-specific bypass in this fix. -
[Major, UNRESOLVED] The generic static-import scanner still fails open on valid static syntax.
build/check-boundaries.mjsstill contains the same three regexes inSPECIFIER_PATTERNS, including\bimport\s+[\w*{}\s,]+...and the analogous export/bare-import forms. This means the pass-1 examples remain reproducible at the new head:import/*c*/ { x } from '../workspace/x.js'produces no specifier at all, whileimport { x } from '../worksp\x61ce/x.js'yields the raw escaped text instead of the decoded../workspace/x.js, soresolveRelative()compares the wrong path. The file itself documents this exact comment/escaped-literal failure mode as the reason Rule C moved tofindModuleSpecifiers().This is directly within the unit's mandatory same-file audit question, not unrelated cleanup. Fix: stop using the static regex as the authoritative generic
RULES/Rule B module-specifier extractor; reuse the existing real-parser module-specifier helper with a conservative cache/prefilter if timing requires it. Add sabotage cases for comment trivia and escaped specifiers across static import/re-export/bare-import forms. -
[Major, UNRESOLVED] The raw-name parser prefilter still skips Unicode-escaped identifiers.
mightReferenceRetiredTopLevelApi()is still exactlynames.some((name) => source.includes(name)), and production still uses it to gate both Guards 3/4 and the Phase-7 retired-top-level-API parser walks. A valid escaped identifier such asconst run\u0051uery = 7does not contain the raw substringrunQuery, so the parser is never called even though the language resolves the binding to the exact banned identifier. A focused executable probe against the current expression confirmssource.includes('runQuery') === falsewhile evaluating the declaration and readingrunQuerysucceeds.This is also the exact prefilter class the repository's architecture-review guidance says to audit whenever one such prefilter is touched. Fix: make this shared gate conservative for escaped identifiers (at minimum, any backslash should route the source through the parser, matching the established
mightReferencePackagepattern) and add sabotage tests for one Phase-7 name and one Guard-3/4 name.
Complete updated-PR check
I re-read the complete six-file PR diff, the one-commit delta from the prior reviewed SHA, the production generic-RULES/Rule-B flow, the shared parser helpers, and the updated policy mirrors. The unique-file pre-pass still deduplicates through guardedFileSources, and Rule B still consumes the cached static classifier results rather than reclassifying. I found no additional material regression beyond the two unresolved pass-1 findings above.
Validation
Canonical CI for a2318aa2612ab65f1f059649ad51506744b667d4 is green. A local clone/fetch remains impossible in this review sandbox because github.com DNS resolution fails, so I could not rerun repository Vitest directly. I did run focused executable probes against the exact current static-regex and raw-name-prefilter expressions; both unresolved bypasses reproduce.
VERDICT: REVISE
BorisTyshkevich
left a comment
There was a problem hiding this comment.
ChatGPT review pass 3
Previously reviewed anchor: 650378dac12120d1618644f65659bee37b377e6e
Reviewed head: a2318aa2612ab65f1f059649ad51506744b667d4
The PR head has not moved since pass 2; relative to the requested anchor it is one commit ahead.
Reassessment of earlier findings
-
RESOLVED — TypeScript inline import-type expressions.
The post-anchor commit explicitly handles
ImportTypeNodealongside runtime dynamic-importCallExpressions. Both node shapes feed the same classifier: onlyStringLiteralandNoSubstitutionTemplateLiteralbecome{kind:'static'}; every other argument shape becomes{kind:'uncheckable'}. The focused tests and both policy mirrors now cover direct/typeofimport types, legal/forbidden relative targets, a Rule-B bare package specifier, and a non-literal import-type argument. -
NOT A #642 BLOCKER — the pre-existing static-import regex limitations raised in pass 2.
I rechecked the pass-3 rebuttal against the merge-base/current diff. The three static-only regex arms are pre-existing behavior; #642 removes only the fourth dynamic-import regex arm and renames the helper to
extractStaticSpecifiers. The comment-trivia/escaped-static-specifier limitation is real, but this PR does not introduce or broaden it, and the delivery contract explicitly keeps #642 scoped to dynamicimport(...)fail-closed classification. I am withdrawing that item as a defect of this unit. -
NOT A #642 BLOCKER — the pre-existing
mightReferenceRetiredTopLevelApi()escape policy raised in pass 2.This helper is unchanged by the PR and its narrower escaped-identifier threat model predates #642. I found no new call site or changed behavior in this diff that makes the pre-existing policy a regression of this unit, so I am not carrying it forward as a pass-3 finding.
Focused pass-3 audit
- Argument AST shapes: the classifier cannot accidentally treat another expression shape as static: the static branch is an exact two-kind check (
StringLiteral/NoSubstitutionTemplateLiteral), and every other runtime-call argument falls intouncheckable. The addedImportTypeNodebranch applies the same closed classification to the wrapped type literal. - Unique-file pre-pass:
guardedFileSourcesis keyed by absolute path before classification, so overlapping directories such assrc/dashboardandsrc/dashboard/applicationcause one parser classification per unique file. The per-rule loop only consumes the cache. - Rule B reuse: package source is already in generic Rule A, and Rule B reads
guardedFileSourcesplusguardedFileDynamicImports; it does not re-run the dynamic classifier. - Gate: I found no additional legal-trivia false negative after the
650378dCR/Unicode fix. - Additional shape probe: I checked JSDoc
import(...)type text as a potential parser-walk omission. The repository's TypeScript configuration states all hand-written modules are strict.ts;allowJsis retained only for generated artifacts andcheckJsis false. That does not establish a supported-source #642 regression.
Complete updated-PR check
I re-read the complete six-file PR diff and the full 650378d... -> a2318aa... delta. I found no new material regression within the delivery contract after the import-type fix.
Canonical CI for a2318aa2612ab65f1f059649ad51506744b667d4 is green. A direct clone/fetch remains unavailable in this sandbox because github.com DNS resolution fails, so I could not rerun the repository's Vitest suite locally.
VERDICT: SHIP
What & why
build/check-boundaries.mjs's genericRULESlayering loop (and Rule B) used a regex(
extractSpecifiers) to find dynamic-import specifiers. That regex could only everextract an argument that already looked like a complete literal — a computed dynamic
import such as
import(\../${name}.js`),import(specifier), orimport('../' + name)` either matched nothing (silently exempting the file from everyboundary rule) or, on a concatenated expression, risked being partially matched into
just its quoted prefix. Neither is acceptable for a boundary check: an import whose
target cannot be statically proven must fail, not fall through as if it were absent.
This adds a new shared, parser-backed classifier
(
findDynamicImportUsages/mightContainDynamicImport, inbuild/lib/check-legacy-owners.mjs) that every generic-guarded file is now checkedagainst: a single-quoted, double-quoted, or no-substitution-template-literal argument
remains statically analyzable and flows through the exact same relative-resolution/
forbidden-prefix logic as an ordinary static import; every other argument shape
(identifier, computed template, concatenation, conditional, or otherwise) is an
unconditional violation, with a diagnostic naming the file and explaining the import
cannot be statically checked against the architecture boundary. Rule C/Rule D and the
other already-parser-backed package guards (#646/#653) are untouched — this issue's
scope is only the generic regex-backed
RULESpath.An internal review pass found and fixed one residual gap in the new gate's own trivia
handling (bare-CR line terminators and Unicode whitespace/line-separators between
importand its call parens) before this PR was opened — see the two commits.No product/runtime behavior changed; this hardens the architecture gate's own
soundness, not the policy it enforces.
Closes #642
Checklist
npm testpasses (the per-file coverage gate is non-negotiable)npm run buildsucceeds (single-filedist/sql.html)src/core/, network insrc/net/(injected fetch), DOM insrc/ui/— n/a, this is a build-tooling change onlyCHANGELOG.md([Unreleased]) updated🤖 Generated with Claude Code
https://claude.ai/code/session_01LwFPT465eDJqYcRa8HGNLz