From 8e1b55b29182af9a3414180a4332e4b6bf5c6469 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 12 Jun 2026 11:34:28 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(compiler):=20SYN011=20=E2=80=94=20warn?= =?UTF-8?q?=20on=20dynamic=20import()=20calls=20that=20bypass=20the=20modu?= =?UTF-8?q?le=20capability=20model=20(=3Fbs=200.7+)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates SYN011 into the single dispatch loop introduced in #155 (no extra per-body scan). Detection: `import` token (kind=ident) not preceded by `.`/`?.`, followed by `(` or `?.(`. Excluded: `import.meta` property accesses (followed by `.`), object method shorthands, `fn import(...)` declarations (checks `kind==='keyword'` for the preceding `fn` token). Suppressed inside `unsafe {}` blocks and `unsafe "reason" fn` bodies. Closes #153 (replaces the conflicted branch botkowski/syn011-dynamic-import). Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 1 + README.md | 2 +- packages/compiler/src/error-codes.ts | 36 +++++ packages/compiler/src/passes/syn-check.ts | 70 +++++++++ packages/compiler/tests/error-codes.test.ts | 2 +- packages/compiler/tests/syn011-check.test.ts | 152 +++++++++++++++++++ packages/mcp/src/explanations.ts | 41 +++++ packages/mcp/tests/server.test.ts | 1 + 8 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/tests/syn011-check.test.ts diff --git a/AGENTS.md b/AGENTS.md index 17e7d2e4..f4fa3266 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,6 +202,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers and to static analysis; no capability or resource declaration covers it, so the fn has an undeclared dependency on deployment configuration. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`. `obj.process.env` (member access on a local), `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass config and secrets as explicit fn parameters so the dependency is visible in the call signature; if env access is required at the load site, wrap in `unsafe "reads deployment env" { }`. | | SYN006 | (0.7+, warning) A fn body calls `process.exit()`, `process?.exit()`, or `process.exit?.()`. All forms terminate the entire host process — not just the fn, not just the bot. They produce no return value, bypass `Result` propagation, `throws {}`, `match`, and any caller recovery path. No capability declaration covers them. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `exit` then `(` or `?.(`. `obj.process.exit(...)`, `process.exit` without `(`, and `process.exitCode` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Return `err(...)` and let the caller decide whether to terminate. If `process.exit` is genuinely required at a bootstrap entry point, wrap in `unsafe "exits on invalid config" { process.exit(1) }`. | | SYN010 | (0.7+, warning) A fn body calls `setTimeout(...)`, `setInterval(...)`, or `queueMicrotask(...)`. These globals schedule callbacks that run after the fn returns — any effects inside those callbacks are invisible to callers: no capability declaration, no `writes {}` label, and no `throws {}` entry can cover them. Detection: identifier not preceded by `.`/`?.`, followed by `(` or `?.(`. Member calls (`obj.setTimeout(...)`) and bare references (without `(`) are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Make the timing explicit: return a `Promise` the caller awaits, or return a teardown function so the caller controls the lifecycle. If a timer is genuinely required, wrap in `unsafe "schedules deferred effect" { setTimeout(...) }`. | +| SYN011 | (0.7+, warning) A fn body calls `import(specifier)` — the dynamic import form. Dynamic imports load a module at runtime whose capability surface is unbounded: CAP001 checks for stdlib namespace calls, not dynamic module loads. A fn that calls `import()` has an undeclared capability surface proportional to everything the dynamically loaded module might do at runtime. Detection: `import` token not preceded by `.`/`?.`, followed by `(` or `?.(`. `import.meta` (followed by `.`) is excluded. Object method shorthands and `fn import(...)` declarations are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | If the module is known at compile time, use a static top-level `import { ... } from` declaration instead. If dynamic loading is required, wrap in `unsafe "loads plugin dynamically" { import(specifier) }`. | | INT002 | (0.7+) A fn declares `intent: "pure"` but its body directly references a stdlib capability (e.g. `http.get`, `fs.read`). Pure intent is enforced at the body level as well as the header. | Remove the stdlib call from the body, or change the intent. | | INT003 | (0.7+) A fn declares `intent: "idempotent"` but also has `uses { random }` or `uses { time }`. Both capabilities produce different values on each call, making the function non-idempotent. Only `random` and `time` are flagged; other capabilities are not structurally flagged by this check (INT003 is a narrow heuristic, not a proof of idempotence). | Remove `random`/`time` from `uses {}`, or change the intent. | | INT004 | (0.7+) A fn declares `intent: "idempotent"` but its body directly references `random` or `time` without declaring them. Under-declaration variant of INT003 — fires when INT003 does not. | Remove the non-idempotent call from the body, or declare the capability and remove the idempotent intent. | diff --git a/README.md b/README.md index 2901cca9..5968e574 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ claude mcp add botscript -- npx -y @mbfarias/botscript-mcp | ----------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- | | `primer` | (no args) | The canonical language primer (same text the `?primer` directive emits). | | `transform` | `{ source: string, filename?: string }` | `{ ok: true, code, forms, version, warnings: [...] }` on success, or `{ ok: false, diagnostics: [...] }` on failure. `warnings` is an array of non-blocking diagnostics (e.g. CAP003). | -| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`ALI001`, `ALI002`, `ALI003`, `BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `SYN010`, `THR001`–`THR004`, `UNS001`–`UNS005`, `VER001`–`VER003`) plus a fails/passes example pair. | +| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`ALI001`, `ALI002`, `ALI003`, `BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `SYN010`, `SYN011`, `THR001`–`THR004`, `UNS001`–`UNS005`, `VER001`–`VER003`) plus a fails/passes example pair. | A bot's loop becomes deterministic: `transform` → if `ok=false`, read `diagnostics[0].code` → `explain(code)` → apply `rewrite` → `transform` again. diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index c155911b..324256f7 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -676,6 +676,42 @@ const E: Record = { " return () => clearInterval(id)\n" + "}", }, + SYN011: { + code: "SYN011", + title: "dynamic import() call bypasses the module capability model", + rule: + "`import(specifier)` at runtime loads a module whose capabilities are not statically declared: " + + "CAP001 checks for stdlib namespace calls, not dynamic module loads. " + + "A fn that calls `import()` has an unbounded, undeclared capability surface proportional to " + + "everything the dynamically loaded module might do — the capability manifest hash proves the " + + "fn body unchanged; it says nothing about what the loaded module does at runtime.", + idiom: + "if the module is known at compile time, use a static `import { ... } from` declaration at the top level instead; " + + "if dynamic loading is genuinely required (e.g. a plugin system), wrap in " + + "`unsafe \"loads plugin dynamically\" { import(specifier) }`", + rewrite: + "// before — unbounded capability surface from dynamic load\n" + + "async fn loadPlugin(name: string) -> Plugin {\n" + + " const mod = await import(`./plugins/${name}`) // SYN011\n" + + " return mod.default\n" + + "}\n\n" + + "// after — explicit escape hatch\n" + + "async fn loadPlugin(name: string) -> Plugin {\n" + + " const mod = await unsafe \"loads plugin by name from trusted plugin dir\" { import(`./plugins/${name}`) }\n" + + " return mod.default\n" + + "}", + example: + "// SYN011: dynamic import hides an unbounded capability surface\n" + + "async fn getAdapter(type: string) -> any {\n" + + " const m = await import(`./adapters/${type}`) // SYN011\n" + + " return m.default\n" + + "}\n\n" + + "// fix: wrap in unsafe with a reason, or use a static import at the top level\n" + + "async fn getAdapter(type: string) -> any {\n" + + " const m = await unsafe \"adapter type validated by registry\" { import(`./adapters/${type}`) }\n" + + " return m.default\n" + + "}", + }, DEP001: { code: "DEP001", title: "fn transitively reads a resource category not declared in its header", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 18b8c945..3742e4e0 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -44,6 +44,14 @@ * Excluded: member calls (`obj.setTimeout`), function declarations * named `setTimeout`, and object/class method shorthands. * + * SYN011 A dynamic `import(specifier)` call was detected in a fn body (?bs 0.7+). + * Dynamic imports load a module at runtime whose capability surface is + * unbounded: CAP001 checks for stdlib namespace calls, not dynamic module + * loads. A fn that calls `import()` has an undeclared capability surface + * proportional to everything the dynamically loaded module might do at runtime. + * `import.meta` (followed by `.`) is excluded — it's a property, not a call. + * Excluded: member calls, `fn import(...)` declarations, object method shorthands. + * * All checks share a single token scan per fn body. The outer loop runs once, * skipping nested fn bodies once. Per-token dispatch is a switch on tok.text * after a kind==="ident" guard. @@ -83,6 +91,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const syn005 = getErrorCode("SYN005")!; const syn006 = getErrorCode("SYN006")!; const syn010 = getErrorCode("SYN010")!; + const syn011 = getErrorCode("SYN011")!; // Collect char-offset ranges where all SYN checks are suppressed: // 1. `unsafe "reason" { ... }` expression blocks — explicit acknowledgment. @@ -501,6 +510,67 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult break; } + // ── SYN011: dynamic import() call ──────────────────────────────────── + case "import": { + // Exclude: `obj.import(...)` — preceded by `.` or `?.` + const prevIdx11 = prevSignificant(tokens, i - 1); + const prev11 = tokens[prevIdx11]; + if (prev11 && ((prev11.kind === "punct" && prev11.text === ".") || prev11.kind === "questionDot")) + continue; + + // Exclude: `fn import(...)` botscript declarations — `fn` is kind="keyword". + if (prev11 && prev11.kind === "keyword" && prev11.text === "fn") continue; + + // Exclude: `import.meta` and other `import.something` property accesses. + const nextIdx11 = nextSignificant(tokens, i + 1); + const next11 = tokens[nextIdx11]; + if (next11 && next11.kind === "punct" && next11.text === ".") continue; + + // Must be followed by `(` or `?.(` — confirming this is a dynamic import call. + let isOptImport = false; + let callIdx11 = nextIdx11; + if (next11 && next11.kind === "questionDot") { + isOptImport = true; + callIdx11 = nextSignificant(tokens, nextIdx11 + 1); + } + const callTok11 = tokens[callIdx11]; + if (!callTok11 || !(callTok11.kind === "open" && callTok11.text === "(")) continue; + + // Exclude object/class method shorthands: { import(x) { ... } } + // and TypeScript method signatures: { import(x): T; } + if (callTok11.matchedAt !== undefined) { + const afterCloseIdx = nextSignificant(tokens, callTok11.matchedAt + 1); + const afterClose = tokens[afterCloseIdx]; + if (afterClose && ( + (afterClose.kind === "open" && afterClose.text === "{") || + afterClose.kind === "fatArrow" || + (afterClose.kind === "punct" && afterClose.text === ":") + )) continue; + } + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const callSep11 = isOptImport ? "?." : ""; + const loc11 = locationOf(src, tok.start); + warnings.push({ + code: "SYN011", + severity: "warning", + file: null, + line: loc11.line, + column: loc11.column, + start: tok.start, + end: callTok11.start + 1, + message: + `fn '${decl.name}' calls import${callSep11}() — ` + + `dynamic imports load a module at runtime whose capability surface is unbounded; ` + + `wrap in unsafe "loads for " { import(specifier) }`, + rule: syn011.rule, + idiom: syn011.idiom, + rewrite: syn011.rewrite, + }); + break; + } + // ── SYN010: setTimeout / setInterval / queueMicrotask ──────────────── default: { if (!TIMER_GLOBALS.has(tok.text)) continue; diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index b2f61184..88c0e827 100644 --- a/packages/compiler/tests/error-codes.test.ts +++ b/packages/compiler/tests/error-codes.test.ts @@ -15,7 +15,7 @@ describe("error-code registry", () => { "INT001", "INT002", "INT003", "INT004", "INT005", "MAT001", "MAT002", "MAT003", "MAT004", "RES001", "RES002", - "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN010", + "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN010", "SYN011", "THR001", "THR002", "THR003", "THR004", "UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "VER001", "VER002", "VER003", diff --git a/packages/compiler/tests/syn011-check.test.ts b/packages/compiler/tests/syn011-check.test.ts new file mode 100644 index 00000000..24b62ae4 --- /dev/null +++ b/packages/compiler/tests/syn011-check.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for SYN011: dynamic import() call detection (?bs 0.7+). + * + * SYN011 is a non-blocking warning — transform must not throw. + */ + +import { describe, it, expect } from "vitest"; +import { transform } from "../src/index.js"; + +function compile(src: string) { + return transform(src, {}); +} + +describe("SYN011: dynamic import() call detection", () => { + it("fires on bare import(specifier) inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn loadMod(path: string) -> any {\n" + + " return import(path)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN011")).toBe(true); + }); + + it("fires on template literal import(`./module`)", () => { + const src = + "?bs 0.7\n" + + "fn loadMod(name: string) -> any {\n" + + " return import(`./modules/${name}`)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN011")).toBe(true); + }); + + it("fires on awaited const mod = await import(path)", () => { + const src = + "?bs 0.7\n" + + "async fn loadPlugin(path: string) -> any {\n" + + " const mod = await import(path)\n" + + " return mod\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN011")).toBe(true); + }); + + it("does NOT fire inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "async fn loadPlugin(path: string) -> any {\n" + + ' const mod = await unsafe "loads plugin dynamically" { import(path) }\n' + + " return mod\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN011")).toBe(false); + }); + + it("does NOT fire inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + 'unsafe "dynamic loader" async fn loadPlugin(path: string) -> any {\n' + + " const mod = await import(path)\n" + + " return mod\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN011")).toBe(false); + }); + + it("does NOT fire on import.meta.url (import.meta property access)", () => { + const src = + "?bs 0.7\n" + + "fn getUrl() -> string {\n" + + " return import.meta.url\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN011")).toBe(false); + }); + + it("does NOT fire below ?bs 0.7", () => { + const src = + "?bs 0.6\n" + + "fn loadMod(path: string) -> any {\n" + + " return import(path)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN011")).toBe(false); + }); + + it("severity is warning (non-blocking)", () => { + const src = + "?bs 0.7\n" + + "fn loadMod(path: string) -> any {\n" + + " return import(path)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN011"); + expect(w?.severity).toBe("warning"); + }); + + it("fires once per distinct import() call in the same fn", () => { + const src = + "?bs 0.7\n" + + "async fn loadBoth(a: string, b: string) -> any {\n" + + " const m1 = await import(a)\n" + + " const m2 = await import(b)\n" + + " return { m1, m2 }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.filter((w) => w.code === "SYN011").length).toBe(2); + }); + + it("does NOT fire on bare import reference (not called)", () => { + const src = + "?bs 0.7\n" + + "fn getImport() -> any {\n" + + " return import\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN011")).toBe(false); + }); + + it("does NOT fire on object method shorthands named import", () => { + const src = + "?bs 0.7\n" + + "fn test() -> void {\n" + + " const handler = { import(x) { return x; } };\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN011")).toBe(false); + }); + + it("does NOT fire on a botscript fn declaration named import", () => { + // `fn` is a keyword token (kind==='keyword'), not an ident — the exclusion + // must check kind==='keyword' to avoid false positives on fn declarations. + const src = + "?bs 0.7\n" + + "fn import(specifier: string) -> any {\n" + + " return specifier\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN011")).toBe(false); + }); + + it("fires on optional-call form import?.(specifier)", () => { + const src = + "?bs 0.7\n" + + "async fn loadMod(path: string) -> any {\n" + + " return await import?.(path)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN011")).toBe(true); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 1aa2c9d7..a384e82d 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -887,6 +887,47 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN011: { + code: "SYN011", + title: "dynamic import() call bypasses the module capability model", + body: + "SYN011 fires when a fn body calls `import(specifier)` — the dynamic import form. " + + "This is the same class of capability-surface bypass as timer globals (SYN010): a real runtime " + + "effect that sidesteps the declared capability surface.\n\n" + + "**Why it matters:** CAP001 checks for direct stdlib namespace calls (`http.get`, `fs.read`, " + + "etc.) in a fn body. A dynamic `import()` call loads an entirely separate module at runtime. " + + "That module can call `http.get`, write to the filesystem, spawn processes, or do anything else " + + "— none of it visible to the static analysis. The fn's capability manifest hash proves the fn " + + "body unchanged; it says nothing about what the dynamically loaded module does at runtime. " + + "The capability surface of a fn that calls `import()` is unbounded and invisible to CAP001.\n\n" + + "**Detection:** the check looks for an `import` token (kind=ident) not preceded by `.`/`?.` " + + "(which would make it a property access), followed by `(` or `?.(` (confirming it is a call). " + + "`import.meta.url` and other `import.meta` property accesses are excluded because they are " + + "followed by `.`, not `(`. Object method shorthands named `import` and `fn import(...)` " + + "botscript declarations are excluded. Static `import { ... } from` declarations at the top level " + + "are outside fn bodies and are never seen by this scan.\n\n" + + "**Fix:** if the module is known at compile time, use a static top-level " + + "`import { ... } from '...'` declaration instead. If dynamic loading is genuinely required " + + "(e.g. a plugin system, lazy code splitting), wrap in " + + "`unsafe \"loads plugin dynamically\" { import(specifier) }` to make the escape hatch visible " + + "in the diff and give reviewers the reason.\n\n" + + "SYN011 fires at `?bs 0.7+` as a non-blocking warning. " + + "Calls inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", + example: { + fails: + "?bs 0.7\n" + + "async fn getAdapter(type: string) -> any {\n" + + " const m = await import(`./adapters/${type}`)\n" + + " return m.default\n" + + "}\n", + passes: + "?bs 0.7\n" + + "async fn getAdapter(type: string) -> any {\n" + + " const m = await unsafe \"adapter type is validated by the registry\" { import(`./adapters/${type}`) }\n" + + " return m.default\n" + + "}\n", + }, + }, DEP001: { code: "DEP001", title: "fn transitively reads a resource category not declared in its header", diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 41991681..744cc37a 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -79,6 +79,7 @@ describe("botscript-mcp explanations", () => { "SYN005", "SYN006", "SYN010", + "SYN011", "THR001", "THR002", "THR003", From 5697792513d5c8c6b5985216e30b2b8af43d4d66 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 12 Jun 2026 15:28:40 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(compiler):=20SYN011=20=E2=80=94=20don't?= =?UTF-8?q?=20suppress=20import()=20in=20ternary=20consequent=20when=20tra?= =?UTF-8?q?iling=20':'=20follows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ':' exclusion (designed to skip TypeScript method signatures like `{ import(x): T }`) was incorrectly suppressing SYN011 for `import()` calls in ternary consequent position: `cond ? import(a) : import(b)`. Fix: only apply the ':' suppression when the preceding token is not a ternary '?' (kind==="question"). Adds regression test covering both import() calls in a ternary expression. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 5 ++++- packages/compiler/tests/syn011-check.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 3742e4e0..758edb17 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -538,13 +538,16 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // Exclude object/class method shorthands: { import(x) { ... } } // and TypeScript method signatures: { import(x): T; } + // Exception: when prev11 is `?` (ternary), a trailing `:` is the + // ternary else-branch, not a method return type — don't suppress. + const isTernaryConsequent = prev11 && prev11.kind === "question"; if (callTok11.matchedAt !== undefined) { const afterCloseIdx = nextSignificant(tokens, callTok11.matchedAt + 1); const afterClose = tokens[afterCloseIdx]; if (afterClose && ( (afterClose.kind === "open" && afterClose.text === "{") || afterClose.kind === "fatArrow" || - (afterClose.kind === "punct" && afterClose.text === ":") + (!isTernaryConsequent && afterClose.kind === "punct" && afterClose.text === ":") )) continue; } diff --git a/packages/compiler/tests/syn011-check.test.ts b/packages/compiler/tests/syn011-check.test.ts index 24b62ae4..ddeff962 100644 --- a/packages/compiler/tests/syn011-check.test.ts +++ b/packages/compiler/tests/syn011-check.test.ts @@ -149,4 +149,17 @@ describe("SYN011: dynamic import() call detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN011")).toBe(true); }); + + it("fires on import() in ternary consequent — not suppressed by trailing ':'", () => { + // Regression: the `:` exclusion (for TS method signatures) must not suppress + // import() when it appears as the true-branch of a ternary expression. + const src = + "?bs 0.7\n" + + "async fn loadMod(flag: boolean, a: string, b: string) -> any {\n" + + " return flag ? import(a) : import(b)\n" + + "}\n"; + const result = compile(src); + const codes = result.warnings.filter((w) => w.code === "SYN011"); + expect(codes.length).toBe(2); + }); });