Skip to content
Closed
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 `(`. `import.meta` (followed by `.`) is 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 loads a module with unknown 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 that names the trust boundary\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" +
"}",
},
DEP001: {
code: "DEP001",
title: "fn transitively reads a resource category not declared in its header",
Expand Down
84 changes: 84 additions & 0 deletions packages/compiler/src/passes/syn-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@
* no `writes {}` label, and no `throws {}` entry covers them.
* 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.
*/

import type { Diagnostic } from "../diagnostics.js";
Expand Down Expand Up @@ -74,6 +81,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 @@ -665,6 +673,82 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
rewrite: syn010.rewrite,
});
}

// SYN011: dynamic import() detection.
// Fires when a fn body calls `import(specifier)` — the dynamic import form.
// Dynamic imports load a module at runtime whose capability surface is unbounded:
// CAP001 cannot see what the dynamically loaded module does at runtime.
// `import.meta` (followed by `.`) is excluded — it's a property, not a call.
// Suppressed inside `unsafe "reason" { }` blocks and `unsafe "reason" fn` bodies.
nextInner = 0;
const open011: typeof inner = [];
for (let i = bodyStart; i < decl.tokenEnd; i++) {
while (open011.length > 0 && open011[open011.length - 1]!.tokenEnd <= i) open011.pop();
while (nextInner < inner.length && inner[nextInner]!.tokenStart <= i) {
open011.push(inner[nextInner]!);
nextInner++;
}
if (open011.length > 0) continue;
Comment thread
marcelofarias marked this conversation as resolved.

const tok11 = tokens[i];
if (!tok11 || tok11.kind !== "ident" || tok11.text !== "import") continue;

// Exclude property accesses: obj.import(...)
const prevIdx11 = prevSignificant(tokens, i - 1);
const prev11 = tokens[prevIdx11];
if (prev11 && ((prev11.kind === "punct" && prev11.text === ".") || prev11.kind === "questionDot"))
continue;

// Exclude fn / function declarations: `fn import(...)` or `function import(...)`
// `fn` is a keyword token; `function` is an ident token.
if (
prev11 &&
((prev11.kind === "keyword" && prev11.text === "fn") ||
(prev11.kind === "ident" && prev11.text === "function"))
)
continue;

// Must be followed by `(` or `?.(` — import.meta is followed by `.` and must be excluded.
let afterIdx11 = nextSignificant(tokens, i + 1);
let afterTok11 = tokens[afterIdx11];
if (afterTok11 && afterTok11.kind === "questionDot") {
afterIdx11 = nextSignificant(tokens, afterIdx11 + 1);
afterTok11 = tokens[afterIdx11];
}
if (!afterTok11 || !(afterTok11.kind === "open" && afterTok11.text === "(")) continue;
Comment on lines +693 to +718

// Exclude method shorthands and class methods: { import(x) { ... } }
// When after the closing `)` is `{` (method body) or `:` (return type), it's a definition.
const closeParenIdx11 = afterTok11.matchedAt;
if (closeParenIdx11 !== undefined) {
const afterParenIdx11 = nextSignificant(tokens, closeParenIdx11 + 1);
const afterParen11 = tokens[afterParenIdx11];
if (
afterParen11 &&
((afterParen11.kind === "open" && afterParen11.text === "{") ||
(afterParen11.kind === "punct" && afterParen11.text === ":"))
) continue;
}

if (isInsideRange(tok11.start, unsafeRanges)) continue;

const loc11 = locationOf(src, tok11.start);
warnings.push({
code: "SYN011",
severity: "warning",
file: null,
line: loc11.line,
column: loc11.column,
start: tok11.start,
end: tok11.end,
message:
`fn '${decl.name}' calls import() — dynamic import bypasses the capability model; ` +
`use static import or wrap in unsafe "loads plugin dynamically" { import(specifier) }`,
rule: syn011.rule,
idiom: syn011.idiom,
rewrite: syn011.rewrite,
});
}
}

return { code: src, warnings };
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
152 changes: 152 additions & 0 deletions packages/compiler/tests/syn011-check.test.ts
Original file line number Diff line number Diff line change
@@ -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, {});
}
Comment thread
marcelofarias marked this conversation as resolved.

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