Skip to content

Commit 358924a

Browse files
Merge pull request #668 from Altinity/fix/642-dynamic-import-fail-closed
fix(#642): fail closed on computed dynamic imports in check-boundaries RULES
2 parents 961f0e6 + a2318aa commit 358924a

6 files changed

Lines changed: 1027 additions & 57 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,25 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
448448
throughout: `src/net/ch-client.ts` remains authoritative; no `src/**` code
449449
changed at any point across either amendment.
450450

451+
### Fixed
452+
- **#642: `check:arch`'s generic layering rules (and Rule B) now fail closed
453+
on a computed dynamic `import(...)` instead of silently skipping it.**
454+
`extractSpecifiers` (renamed `extractStaticSpecifiers`) used to include a
455+
dynamic-import regex arm that could only ever extract an argument that
456+
already looked like a complete literal — a computed expression such as
457+
`import('../' + name)` either matched nothing (exempting it from every
458+
`RULES` boundary entirely) or risked being partially matched into just its
459+
quoted prefix. Every dynamic import under a guarded directory is now
460+
classified by a new shared, parser-backed helper
461+
(`findDynamicImportUsages`/`mightContainDynamicImport`,
462+
`build/lib/check-legacy-owners.mjs`): a single-quoted, double-quoted, or
463+
no-substitution-template-literal argument remains statically analyzable and
464+
flows through the same relative-resolution/forbidden-prefix logic as an
465+
ordinary static import; every other argument shape (identifier, computed
466+
template, concatenation, conditional, or otherwise) is an unconditional
467+
violation. No product/runtime behavior changed — this hardens the
468+
architecture gate's own soundness, not the policy it enforces.
469+
451470
## [0.7.3] - 2026-08-06
452471

453472
### Added

build/check-boundaries.mjs

Lines changed: 137 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,62 @@
1414
// second script.
1515
//
1616
// Hand-rolled regex scan for the internal src-layering rules (RULES below)
17-
// and Rule B's zero-bare-specifier check: the codebase has no exotic import
18-
// syntax there, so scanning for import/export specifiers is enough and keeps
19-
// those rules a zero-dependency, sub-second pretest step. The exceptions are
20-
// the former-owner rules, Rule C (the package relative-deep-import ban,
21-
// Guard 2 — issue #630 Phase 8, review pass 1), BOTH halves of the revised
22-
// package Rule D (the deep-import-subpath ban and the bare-specifier
23-
// name/shape check), and the `@clickhouse/client-web` reintroduction ban
24-
// (Guard 5) below — all of which need identifier/import-shape-level (not
25-
// specifier-text-level) detection and therefore delegate to a real
26-
// TypeScript parse in `build/lib/check-legacy-owners.mjs` — see that module
27-
// for why textual matching was retired there (issue #630 Phase 3), and why
28-
// the same real-parser mechanism (not a new hand-rolled scanner) was
29-
// required again for issue #630 Phase 5's revised Rule D, and again for
30-
// issue #630 Phase 8's Rule C/Guard 2 broadening and Guard 5: a comment
31-
// sitting between `import`/`export` and the specifier, or an escaped
17+
// and Rule B's zero-bare-specifier check: the codebase has no exotic STATIC
18+
// import syntax there, so scanning for static import/export specifiers is
19+
// enough and keeps those rules a zero-dependency, sub-second pretest step for
20+
// their static forms. The exceptions are the former-owner rules, Rule C (the
21+
// package relative-deep-import ban, Guard 2 — issue #630 Phase 8, review pass
22+
// 1), BOTH halves of the revised package Rule D (the deep-import-subpath ban
23+
// and the bare-specifier name/shape check), and the `@clickhouse/client-web`
24+
// reintroduction ban (Guard 5) below — all of which need identifier/
25+
// import-shape-level (not specifier-text-level) detection and therefore
26+
// delegate to a real TypeScript parse in `build/lib/check-legacy-owners.mjs`
27+
// — see that module for why textual matching was retired there (issue #630
28+
// Phase 3), and why the same real-parser mechanism (not a new hand-rolled
29+
// scanner) was required again for issue #630 Phase 5's revised Rule D, and
30+
// again for issue #630 Phase 8's Rule C/Guard 2 broadening and Guard 5: a
31+
// comment sitting between `import`/`export` and the specifier, or an escaped
3232
// string-literal segment, defeats a regex (however far its
3333
// whitespace/delimiter patterns are widened) but is ordinary parser
3434
// trivia/decoded text to a real parse — review pass 1 confirmed Rule C's
3535
// production enforcement still ran the regex (`extractSpecifiers`) despite
3636
// this file's own stated Phase 8 design goal, while its unit-test mirror
3737
// independently reimplemented the identical regex rather than calling the
3838
// real parser.
39+
//
40+
// Issue #642 — dynamic `import(...)` calls under the generic `RULES` loop
41+
// (and Rule B) are a FOURTH exception, on top of the three above, for a
42+
// different reason: the former dynamic-import arm of the regex below
43+
// (`extractSpecifiers`, now renamed `extractStaticSpecifiers` to make its
44+
// narrowed role explicit) could only ever extract a specifier that LOOKED
45+
// like a complete literal — a computed expression such as
46+
// `import('../' + name)` either matched nothing (silently exempting it from
47+
// every rule below) or, worse, could be partially matched into just its
48+
// quoted prefix. Neither is acceptable for a boundary check: an import whose
49+
// target cannot be statically proven must fail, not fall through as if it
50+
// were absent. Every generic-guarded file is now additionally classified by
51+
// the shared real-parser helpers `findDynamicImportUsages`/
52+
// `mightContainDynamicImport` below — `mightContainDynamicImport` gates the
53+
// cheap case (a file that provably contains no `import(...)`-shaped
54+
// construct skips the parser entirely, preserving the ordinary static fast
55+
// path for files with none at all); `findDynamicImportUsages` then classifies
56+
// every dynamic-import call expression AND every TypeScript inline
57+
// import-type expression (`type T = import('x').Foo`, `typeof import('x')` —
58+
// review pass 1: a structurally distinct `ImportTypeNode`, textually
59+
// identical at the `import(...)` shape the gate above matches, so it reaches
60+
// this classifier too and must not silently fall through it) in a matched
61+
// file as `{ kind: 'static', spec }` (a plain string/no-substitution-
62+
// template-literal argument, fed through the exact same relative-resolution/
63+
// forbidden-prefix logic as an ordinary static import) or
64+
// `{ kind: 'uncheckable' }` (everything else — identifier, computed template,
65+
// concatenation, conditional, a bare type reference, or any other expression
66+
// shape — an UNCONDITIONAL violation, independent of which rule eventually
67+
// would have matched the file). Computed/non-static dynamic imports (and
68+
// non-literal import-type expressions) are forbidden in every source file
69+
// covered by at least one generic `RULES` entry; Rule C/Rule D and the other
70+
// already-parser-backed package guards are untouched by this — they already
71+
// classify their own dynamic imports through the real parser and
72+
// #630/#646/#653's package-guard work is not being redone here.
3973

4074
import fs from 'node:fs';
4175
import path from 'node:path';
@@ -64,6 +98,8 @@ import {
6498
manifestDependencyFields,
6599
lockHasPackage,
66100
retiredClientSpikeScriptNames,
101+
findDynamicImportUsages,
102+
mightContainDynamicImport,
67103
} from './lib/check-legacy-owners.mjs';
68104

69105
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -221,31 +257,35 @@ function collectFiles(target) {
221257
}
222258

223259
// Matches, in order: static `import ... from '...'` (incl. `import type`),
224-
// `export ... from '...'` (incl. `export type`), a bare side-effect
225-
// `import '...'`, and dynamic `import('...')`. Each pattern requires only
226-
// identifier/brace/comma/whitespace characters between the keyword and
227-
// `from`, so it can't skip past a from-less import into a later statement's
228-
// clause, and `\b` keeps it off the word "import" inside an identifier.
229-
// Used only by the checks named in the comment above (internal src layering,
230-
// the @clickhouse/client-web ban, Rule B) — NEITHER half of Rule D's
231-
// `@altinity/clickhouse-http` check calls this anymore (both now delegate to
232-
// the real-parser helpers in `build/lib/check-legacy-owners.mjs`, below).
260+
// `export ... from '...'` (incl. `export type`), and a bare side-effect
261+
// `import '...'`. Each pattern requires only identifier/brace/comma/
262+
// whitespace characters between the keyword and `from`, so it can't skip
263+
// past a from-less import into a later statement's clause, and `\b` keeps it
264+
// off the word "import" inside an identifier. Used only by the checks named
265+
// in the comment above (internal src layering, the @clickhouse/client-web
266+
// ban, Rule B) — NEITHER half of Rule D's `@altinity/clickhouse-http` check
267+
// calls this anymore (both now delegate to the real-parser helpers in
268+
// `build/lib/check-legacy-owners.mjs`, below).
233269
//
234-
// Only the dynamic-import pattern also accepts a backtick-delimited
235-
// no-substitution template literal (`` import(`pkg`) ``): a static
236-
// import/export declaration's module specifier and a bare side-effect
237-
// import's specifier must be a plain string literal per grammar — only a
238-
// dynamic `import(...)` call can take a template literal argument — so
239-
// widening the other three patterns to backticks would only ever match
240-
// syntax that can't occur.
270+
// Issue #642 — the FOURTH pattern this array used to carry (a dynamic
271+
// `import(...)` call) is gone: it could only ever extract a specifier that
272+
// LOOKED like a complete literal, so a computed dynamic import either
273+
// matched nothing (silently exempting it from every rule below) or, on a
274+
// concatenated expression like `import('../' + name)`, could be partially
275+
// matched into just its quoted prefix — the exact "reduced to the quoted
276+
// prefix" bug this issue closes. `extractStaticSpecifiers` (renamed from
277+
// `extractSpecifiers` to make its narrowed role explicit) now handles ONLY
278+
// the three ordinary static forms above; every dynamic `import(...)` call in
279+
// a generic-guarded file is classified separately, by the real-parser helpers
280+
// `findDynamicImportUsages`/`mightContainDynamicImport`, in the fail-closed
281+
// pre-pass right before the `RULES` loop below.
241282
const SPECIFIER_PATTERNS = [
242283
/\bimport\s+[\w*{}\s,]+\s+from\s*['"]([^'"]+)['"]/g,
243284
/\bexport\s+[\w*{}\s,]+\s+from\s*['"]([^'"]+)['"]/g,
244285
/\bimport\s*['"]([^'"]+)['"]/g,
245-
/\bimport\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
246286
];
247287

248-
function extractSpecifiers(source) {
288+
function extractStaticSpecifiers(source) {
249289
const specs = [];
250290
for (const pattern of SPECIFIER_PATTERNS) {
251291
pattern.lastIndex = 0;
@@ -293,15 +333,61 @@ function resolveRelative(fromFile, spec) {
293333
const violations = [];
294334
let checkedFiles = 0;
295335
let activeRules = 0;
336+
337+
// Issue #642 — collect the FULL set of files covered by at least one generic
338+
// RULES entry, deduplicated by absolute path, before evaluating any single
339+
// rule. This matters because rule directories can nest (e.g. `src/dashboard`
340+
// and `src/dashboard/application`: `collectFiles` on the outer dir already
341+
// walks the inner one), so a naive per-rule loop would otherwise hand the
342+
// same file's dynamic imports to the real parser once per matching rule and
343+
// — if not careful — report the same uncheckable occurrence more than once.
344+
// Each file's source is read exactly once here and reused by every rule below
345+
// instead of re-reading it per rule.
346+
const guardedFileSources = new Map(); // absolute path -> source text
347+
const ruleFileLists = []; // { rule, files: absolute path[] }
296348
for (const rule of RULES) {
297349
const ruleDir = path.join(repoRoot, rule.dir);
298350
const files = fs.existsSync(ruleDir) ? collectFiles(ruleDir) : [];
351+
ruleFileLists.push({ rule, files });
352+
for (const file of files) {
353+
if (!guardedFileSources.has(file)) guardedFileSources.set(file, fs.readFileSync(file, 'utf8'));
354+
}
355+
}
356+
357+
// Issue #642 — fail-closed dynamic-import pre-pass, run ONCE per unique
358+
// guarded file (see the dedup rationale above), strictly before any
359+
// individual RULES entry is evaluated: every `uncheckable` dynamic import
360+
// (an identifier, a computed template, a concatenation, a conditional, or any
361+
// other non-literal argument shape) is an unconditional violation, regardless
362+
// of which rule(s) would otherwise have matched the file and regardless of
363+
// where the import might have actually resolved — inability to prove that
364+
// statically is itself the violation. `mightContainDynamicImport` gates the
365+
// expensive real-parser call so a file that provably has no `import(...)`
366+
// call anywhere never pays for it; a `static` result (a plain string or
367+
// no-substitution-template-literal argument) is cached here and consumed by
368+
// the RULES loop below exactly like an ordinary static import.
369+
const guardedFileDynamicImports = new Map(); // absolute path -> DynamicImportUsage[]
370+
for (const [file, source] of guardedFileSources) {
371+
if (!mightContainDynamicImport(source)) continue;
372+
const relFile = path.relative(repoRoot, file).split(path.sep).join('/');
373+
const usages = findDynamicImportUsages(source, relFile);
374+
guardedFileDynamicImports.set(file, usages);
375+
for (const usage of usages) {
376+
if (usage.kind !== 'uncheckable') continue;
377+
violations.push(`${relFile} → dynamic import(...) cannot be statically checked against the architecture boundary (issue #642: only a single-quoted, double-quoted, or no-substitution-template-literal specifier is statically analyzable)`);
378+
}
379+
}
380+
381+
for (const { rule, files } of ruleFileLists) {
299382
if (files.length === 0) continue; // directory not born yet — rule activates with it
300383
activeRules += 1;
301384
checkedFiles += files.length;
302385
for (const file of files) {
303-
const source = fs.readFileSync(file, 'utf8');
304-
for (const spec of extractSpecifiers(source)) {
386+
const source = guardedFileSources.get(file);
387+
const dynamicStaticSpecs = (guardedFileDynamicImports.get(file) ?? [])
388+
.filter((usage) => usage.kind === 'static')
389+
.map((usage) => usage.spec);
390+
for (const spec of [...extractStaticSpecifiers(source), ...dynamicStaticSpecs]) {
305391
if (!spec.startsWith('.')) continue; // bare/package specifiers can't reach src dirs
306392
const resolved = resolveRelative(file, spec);
307393
const relResolved = path.relative(repoRoot, resolved).split(path.sep).join('/');
@@ -460,13 +546,27 @@ if (fs.existsSync(lockPath)) {
460546
// with '.', so a literal absolute-looking path would otherwise slip past
461547
// Rule A undetected — everything that isn't a relative specifier is a
462548
// violation here, with no exceptions.
549+
//
550+
// Issue #642 — this block used to independently re-run the (now-removed)
551+
// dynamic-import arm of `extractSpecifiers`. It no longer needs any dynamic-
552+
// import handling of its own: `packages/clickhouse-http/src` is ALSO a
553+
// generic RULES entry (Rule A, above), so a COMPUTED dynamic import here
554+
// already failed the fail-closed pre-pass before this block ever runs. What
555+
// remains for Rule B is exactly the bare-vs-relative policy decision for a
556+
// dynamic import whose specifier IS statically known — reusing the same
557+
// cached source and cached `{ kind: 'static', spec }` results Rule A already
558+
// produced, rather than re-reading the file or re-deriving the classification
559+
// a second time.
463560
const PACKAGE_SRC_DIR = path.join(repoRoot, 'packages/clickhouse-http/src');
464561
if (fs.existsSync(PACKAGE_SRC_DIR)) {
465562
for (const file of collectFiles(PACKAGE_SRC_DIR)) {
466563
const relFile = path.relative(repoRoot, file).split(path.sep).join('/');
467564
checkedFiles += 1;
468-
const source = fs.readFileSync(file, 'utf8');
469-
for (const spec of extractSpecifiers(source)) {
565+
const source = guardedFileSources.get(file) ?? fs.readFileSync(file, 'utf8');
566+
const dynamicStaticSpecs = (guardedFileDynamicImports.get(file) ?? [])
567+
.filter((usage) => usage.kind === 'static')
568+
.map((usage) => usage.spec);
569+
for (const spec of [...extractStaticSpecifiers(source), ...dynamicStaticSpecs]) {
470570
if (spec.startsWith('.')) continue; // relative — governed by Rule A above
471571
violations.push(`${relFile}${spec} (issue #630 Phase 2: clickhouse-http has zero bare package imports)`);
472572
}

0 commit comments

Comments
 (0)