Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,42 @@ const E: Record<string, ErrorCodeEntry> = {
" 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",
Expand Down
73 changes: 73 additions & 0 deletions packages/compiler/src/passes/syn-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -501,6 +510,70 @@ 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; }
// 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" ||
(!isTernaryConsequent && 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 <module> for <reason>" { import(specifier) }`,
rule: syn011.rule,
idiom: syn011.idiom,
rewrite: syn011.rewrite,
});
break;
}

// ── SYN010: setTimeout / setInterval / queueMicrotask ────────────────
default: {
if (!TIMER_GLOBALS.has(tok.text)) continue;
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/tests/error-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
165 changes: 165 additions & 0 deletions packages/compiler/tests/syn011-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* 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);
});

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);
});
});
Loading
Loading