diff --git a/AGENTS.md b/AGENTS.md index c23bffd4..2e56cc2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,6 +211,8 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | SYN016 | (0.7+, warning) A fn body accesses `indexedDB.*` — any member access (`.`, `?.`) on the `indexedDB` global. `indexedDB` is same-origin persistent database storage invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). Unlike `localStorage`, `indexedDB` is asynchronous and has no practical size limit, making invisible access higher-impact. A fn that accesses `indexedDB` has undeclared persistent state dependencies invisible to callers and audit tooling. Detection: `indexedDB` ident not preceded by `.`/`?.`, followed by `.` or `?.`. Bare references and `fn`/`function` declarations named `indexedDB` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass an `IDBDatabase` handle as an explicit fn parameter so callers control what database is accessed and tests can inject a mock. If direct global access is required, wrap in `unsafe "reads/writes indexedDB for " { indexedDB.open(name) }`. | | SYN018 | (0.7+, warning) A fn body calls `Math.random()`, `Math?.random()`, or `Math.random?.()`. `Math.random` generates a random float at runtime but is invisible to botscript's capability model: `uses { random }` covers `random.*` stdlib namespace calls, not the `Math` global. A fn that calls `Math.random()` has an undeclared randomness dependency — callers cannot see it, tests cannot mock or suppress it the way they can the `random` stdlib. Detection: `Math` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member is `random`, followed by `(` or `?.(`. Bare `Math.random` references without a trailing `(` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Replace `Math.random()` with `random.next()` and add `uses { random }` to the fn header. If `Math.random` is required, wrap in `unsafe "uses Math.random for " { Math.random() }`. | | SYN019 | (0.7+, warning) A fn body calls `crypto.getRandomValues(buf)` or `crypto.randomUUID()` (including optional-chain forms `crypto?.getRandomValues(buf)` and optional-call forms `crypto.getRandomValues?.(buf)`). These calls generate cryptographic randomness at runtime but are invisible to botscript's capability model: `uses { random }` covers `random.*` stdlib calls (`random.next()`, `random.int()`), not the `crypto` global. A fn that calls these methods has an undeclared randomness dependency — tests cannot control the output and callers cannot observe the dependency from the fn header. Detection: `crypto` ident not preceded by `.`/`?.`, followed by `.` or `?.`, followed by `getRandomValues` or `randomUUID`, followed by `(` or `?.(`. Member calls (`obj.crypto.getRandomValues(...)`), non-randomness members (e.g. `crypto.subtle.digest(...)`), bare references, and `fn`/`function` declarations named `crypto` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Use `random.next()` or `random.int(min, max)` from the `random` stdlib with `uses { random }` when general randomness is sufficient. If cryptographic randomness or UUIDs are genuinely required, wrap in `unsafe "uses crypto for " { crypto.getRandomValues(buf) }`. | +| SYN020 | (0.7+, warning) A fn body calls `Date.now()`, constructs `new Date()` / `new Date` (no-arg ambient time), or calls `Date()` / `Date?.()`. These inject the current wallclock time at runtime but are invisible to botscript's capability model: `uses { time }` covers `time.*` stdlib calls, not the `Date` global. A fn that reads these has an undeclared time dependency — callers cannot see it and tests cannot control the clock value observed. Detection has three forms: (1) member call — `Date` ident (not preceded by `.`/`?.`) followed by `.` or `?.` then `now`, then `(` or `?.(`; (2) new-expression — `Date` preceded by `new` with no-parens (`new Date`) or empty parens (`new Date()`), but NOT `new Date(...)` or `new Date(arg)` with explicit arguments (which construct a specific date, not ambient time); (3) bare call — `Date` followed by `(` or `?.(` without a preceding `new`. Excluded: `new Date(arg)` with explicit arguments, `obj.Date.*`, member calls on a `Date` instance, and `fn`/`function` declarations named `Date`. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass `nowMs: number` as an explicit parameter so callers and tests can control the time value. If ambient time is required at an entry point, use `time.now()` from the `time` stdlib with `uses { time }`, or wrap in `unsafe "reads wallclock time for " { Date.now() }`. | +| SYN021 | (0.7+, warning) A fn body calls `performance.now()`, `performance?.now()`, `performance.now?.()`, or reads `performance.timeOrigin` / `performance?.timeOrigin`. These inject ambient timing information at runtime but are invisible to botscript's capability model: `uses { time }` covers `time.*` stdlib calls, not the `performance` global. Detection: `performance` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member is `now` (with trailing `(` or `?.(`) or `timeOrigin`. `obj.performance.*` member calls, `fn`/`function` declarations named `performance`, and `unsafe {}` / `unsafe "reason" fn` bodies are suppressed. | Pass the current time (or time origin) as an explicit parameter so callers control the clock value. If `performance.now` is genuinely needed (e.g. for high-resolution elapsed time), wrap in `unsafe "uses performance.now for " { performance.now() }`. | | SYN022 | (0.7+, warning) A fn body accesses `process.argv`, `process.cwd()`, `process.platform`, `process.arch`, `process.pid`, `process.ppid`, `process.version`, `process.versions`, `process.hrtime()`, `process.uptime()`, `process.memoryUsage()`, `process.cpuUsage()`, or `process.resourceUsage()`. These read ambient Node.js process state at runtime — OS identity, process tree, memory, CPU — invisible to botscript's capability model. Unlike `process.env` (SYN005) and `process.exit` (SYN006), which cover configuration and termination respectively, SYN022 targets the remaining ambient introspection surface. Detection: `process` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member in the ambient-state set, optionally followed by `(` or `?.(`. Bare `process.*` references without a trailing `(` still fire. `obj.process.*` member calls, `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. `process.env` and `process.exit` are handled by SYN005/SYN006 and excluded here. | Pass the required ambient value as an explicit fn parameter so the dependency is visible in the call signature and tests can inject a mock. If direct `process.*` access is required at a bootstrap entry point, wrap in `unsafe "reads process state for " { process.argv }`. | | SYN023 | (0.7+, warning) A fn body accesses a high-concern `navigator.*` member: `geolocation`, `clipboard`, `mediaDevices`, `serviceWorker`, `permissions`, `onLine`, `userAgent`, `language`, `languages`, `platform`, `hardwareConcurrency`, `deviceMemory`, `connection`, or `wakeLock`. These expose ambient browser capability state — location, clipboard, media devices, background service workers, network connectivity, browser identity, and hardware specs — invisible to botscript's capability model. Detection: `navigator` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member in the high-concern set. `obj.navigator.*` member calls, `fn`/`function`/`function*` declarations named `navigator`, members not in the listed set, `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass the required value as an explicit fn parameter so callers can see the dependency and tests can inject a mock. If direct `navigator.*` access is required, wrap in `unsafe "accesses navigator. for " { navigator. }`. | | 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. | diff --git a/README.md b/README.md index 00df835d..81f474a0 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`, `SYN007`, `SYN008`, `SYN010`, `SYN011`, `SYN012`, `SYN013`, `SYN014`, `SYN016`, `SYN018`, `SYN019`, `SYN022`, `SYN023`, `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`, `SYN007`, `SYN008`, `SYN010`, `SYN011`, `SYN012`, `SYN013`, `SYN014`, `SYN016`, `SYN018`, `SYN019`, `SYN020`, `SYN021`, `SYN022`, `SYN023`, `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 1bd18e33..fe3ddc86 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -981,6 +981,75 @@ const E: Record = { " return random.int(1, 7)\n" + "}", }, + SYN020: { + code: "SYN020", + title: "Date.now() / new Date() / new Date (no parens) / Date() / Date?.() construction bypasses the time capability model", + rule: + "`Date.now()`, `new Date()`, and `Date()` inject the current time at runtime but are " + + "invisible to botscript's capability model: `uses { time }` declarations cover `time.*` " + + "stdlib namespace calls, not the `Date` global. A fn that calls these forms has an " + + "undeclared time dependency — no `uses {}` declaration covers it, callers cannot see it, " + + "and tests cannot control the time value the fn observes.", + idiom: + "pass the current time as an explicit parameter so callers and tests can control it; " + + "or use `time.now()` from the `time` stdlib namespace with `uses { time }` so the " + + "time dependency is declared in the fn header (note: `time.now()` returns epoch ms, not a Date object); " + + "if the raw `Date` API is genuinely required, wrap in " + + "`unsafe \"uses current time for \" { Date.now() }`", + rewrite: + "// before — time dependency invisible to the capability model\n" + + "fn isExpired(expiresAtMs: number) -> boolean {\n" + + " return Date.now() > expiresAtMs // SYN020\n" + + "}\n\n" + + "// after — time passed as a parameter; tests can control it\n" + + "fn isExpired(expiresAtMs: number, nowMs: number) -> boolean {\n" + + " return nowMs > expiresAtMs\n" + + "}", + example: + "// SYN020: Date.now() bypasses the time capability model\n" + + "fn isExpired(expiresAt: number) -> boolean {\n" + + " return Date.now() > expiresAt // SYN020\n" + + "}\n\n" + + "// fix: pass nowMs as a parameter\n" + + "fn isExpired(expiresAt: number, nowMs: number) -> boolean {\n" + + " return nowMs > expiresAt\n" + + "}", + }, + SYN021: { + code: "SYN021", + title: "performance.now() / performance.timeOrigin access bypasses the time capability model", + rule: + "`performance.now()` and `performance.timeOrigin` inject ambient timing information at " + + "runtime but are invisible to botscript's capability model: `uses { time }` declarations " + + "cover `time.*` stdlib namespace calls, not the `performance` global. A fn that reads " + + "these values has an undeclared time dependency — no `uses {}` declaration covers it, " + + "callers cannot see it, and tests cannot control the clock value the fn observes.", + idiom: + "pass the current time as an explicit parameter so callers and tests can control it (preferred); " + + "if only epoch time (not monotonic time) is needed, use `time.now()` from the `time` stdlib " + + "with `uses { time }` so the dependency is declared in the fn header — " + + "note: `time.now()` is wall-clock epoch time, not a monotonic clock; " + + "if direct `performance` access is required, wrap in " + + "`unsafe \"uses performance.now for \" { performance.now() }`", + rewrite: + "// before — time dependency invisible to the capability model\n" + + "fn elapsed(startMs: number) -> number {\n" + + " return performance.now() - startMs // SYN021\n" + + "}\n\n" + + "// after — time passed as a parameter; tests can control it\n" + + "fn elapsed(startMs: number, nowMs: number) -> number {\n" + + " return nowMs - startMs\n" + + "}", + example: + "// SYN021: performance.now() bypasses the time capability model\n" + + "fn elapsed(startMs: number) -> number {\n" + + " return performance.now() - startMs // SYN021\n" + + "}\n\n" + + "// fix: pass nowMs as a parameter\n" + + "fn elapsed(startMs: number, nowMs: number) -> number {\n" + + " return nowMs - startMs\n" + + "}", + }, SYN022: { code: "SYN022", title: "process.* ambient state access bypasses the capability model", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index a9f192c6..0a19b112 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -137,6 +137,40 @@ * `fn`/`function` declarations named `crypto` and non-randomness members are excluded. * `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. * + * SYN020 A `Date.now()`, `new Date()`, `new Date` (no parens), or `Date()` call was detected + * in a fn body (?bs 0.7+). These forms inject the current time at runtime but are invisible + * to botscript's capability model: `uses { time }` covers `time.*` stdlib calls, not the + * `Date` global. A fn that calls these forms has an undeclared time dependency — callers + * cannot see it and tests cannot control the time value observed by the fn. + * Detection paths: + * 1. `Date.now()` / `Date?.now()` / `Date.now?.()` — `Date` not preceded by `.`/`?.`, + * followed by `.`/`?.`, member is `now`, followed by `(`/`?.(`. + * 2. `new Date()` / `new Date()` — `Date` preceded by `new`, followed by empty + * parens (arg-count check: first token inside `(…)` must be `)`). Generic scan + * only when `new` precedes to avoid `Date < x > (y)` comparison false-positives. + * 3. `new Date` (no parentheses) — `Date` preceded by `new`, not followed by `(`, `<`, `?.`, or `.` + * (equivalent to `new Date()` in JS/TS — creates a Date object for current time). + * 4. `Date(...)` / `Date?.()` — bare call (any args; JS ignores them and returns current date string). + * Excluded: `new Date(timestamp)` / `new Date("str")` / `new Date(y,m,d,…)` (explicit + * args), `Date.parse(str)` / `Date.UTC(…)` (no ambient time), `obj.Date()` (member + * call), fn/function/function* declarations named `Date`, method shorthands, TS method + * signatures. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. + * + * SYN021 A `performance.now()` or `performance.timeOrigin` access was detected in a fn body (?bs 0.7+). + * `performance.now()` returns a high-resolution monotonic timestamp (milliseconds since + * the page/process started) and `performance.timeOrigin` exposes the absolute epoch of + * that clock. Both inject ambient timing information at runtime but are invisible to + * botscript's capability model: `uses { time }` covers `time.*` stdlib calls, not the + * `performance` global. A fn that reads these values has an undeclared time dependency — + * callers cannot see it and tests cannot control the clock value observed by the fn. + * Detection: + * 1. `performance.now()` / `performance?.now()` / `performance.now?.()` — `performance` + * not preceded by `.`/`?.`, followed by `.`/`?.`, member is `now`, followed by `(`/`?.(`. + * 2. `performance.timeOrigin` / `performance?.timeOrigin` — `performance` not preceded + * by `.`/`?.`, followed by `.`/`?.`, member is `timeOrigin` (property, no call needed). + * Excluded: `obj.performance.*` (member call), fn/function/function* declarations named `performance`, + * TS method signatures. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. + * * SYN022 A `process.argv`, `process.cwd`, `process.platform`, `process.arch`, * `process.pid`, `process.ppid`, `process.version`, `process.versions`, * `process.hrtime`, `process.uptime`, `process.memoryUsage`, @@ -242,6 +276,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const syn016 = getErrorCode("SYN016")!; const syn018 = getErrorCode("SYN018")!; const syn019 = getErrorCode("SYN019")!; + const syn020 = getErrorCode("SYN020")!; + const syn021 = getErrorCode("SYN021")!; const syn022 = getErrorCode("SYN022")!; const syn023 = getErrorCode("SYN023")!; @@ -1449,6 +1485,322 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult break; } + // ── SYN020: Date.now() / new Date() / Date() — ambient time dependency ─ + case "Date": { + // Exclude: `obj.Date` — preceded by `.` or `?.` + const prevIdx20 = prevSignificant(tokens, i - 1); + const prev20 = tokens[prevIdx20]; + if (prev20 && ((prev20.kind === "punct" && prev20.text === ".") || prev20.kind === "questionDot")) + continue; + + // Exclude: fn/function/function* declarations named Date + if (prev20 && prev20.kind === "keyword" && prev20.text === "fn") continue; + if (prev20 && prev20.kind === "ident" && prev20.text === "function") continue; + if (isFunctionStarDecl(tokens, prevIdx20)) continue; + + const hasNew20 = prev20 && prev20.kind === "ident" && prev20.text === "new"; + + const nextIdx20 = nextSignificant(tokens, i + 1); + const next20 = tokens[nextIdx20]; + + // ── Pattern 1: Date.now() / Date?.now() ────────────────────────── + // Followed by `.` or `?.`, then `now`, then `(` or `?.(`. + const isDotNext20 = next20 && next20.kind === "punct" && next20.text === "."; + const isOptChain20 = next20 && next20.kind === "questionDot"; + if (isDotNext20 || isOptChain20) { + const memberIdx20 = nextSignificant(tokens, nextIdx20 + 1); + const memberTok20 = tokens[memberIdx20]; + // Only enter Pattern 1 when the member is `now`. + // `Date?.()` has `?.` followed directly by `(` — fall through to Pattern 2. + if (!memberTok20 || memberTok20.kind !== "ident" || memberTok20.text !== "now") { + // `.xxx` that isn't `.now` is not an ambient-time call (e.g. Date.parse). + // `?.xxx` that isn't `?.now` — still not ambient time, except `Date?.()` where + // the `(` appears as member. That is handled below in Pattern 2 (next20 === `?.`). + if (isDotNext20) continue; + // isOptChain20 && member isn't `now`: fall through to Pattern 2. + } else { + // Confirm call: next after `now` is `(` or `?.(` + let afterNowIdx20 = nextSignificant(tokens, memberIdx20 + 1); + let afterNow20 = tokens[afterNowIdx20]; + let isOptCall20 = false; + if (afterNow20 && afterNow20.kind === "questionDot") { + isOptCall20 = true; + afterNowIdx20 = nextSignificant(tokens, afterNowIdx20 + 1); + afterNow20 = tokens[afterNowIdx20]; + } + if (!afterNow20 || !(afterNow20.kind === "open" && afterNow20.text === "(")) continue; + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const sep20 = isOptChain20 ? "?." : "."; + const callSep20 = isOptCall20 ? "?." : ""; + const loc20 = locationOf(src, tok.start); + warnings.push({ + code: "SYN020", + severity: "warning", + file: null, + line: loc20.line, + column: loc20.column, + start: tok.start, + end: afterNow20.start + 1, + message: + `fn '${decl.name}' calls Date${sep20}now${callSep20}() — ` + + `Date.now() injects the current time invisible to the capability model; ` + + `pass nowMs as a parameter or use time.now() with uses { time }, ` + + `or wrap in unsafe "uses current time for " { Date.now() }`, + rule: syn020.rule, + idiom: syn020.idiom, + rewrite: syn020.rewrite, + }); + break; + } + } + + // ── Pattern 3: new Date (no parentheses) ───────────────────────── + // In JS/TS `new Date` without parens is equivalent to `new Date()` — ambient time injection. + if (hasNew20 && ( + !next20 || + (next20.kind !== "open" && + next20.kind !== "questionDot" && + !(next20.kind === "operator" && next20.text === "<") && + !(next20.kind === "punct" && next20.text === ".")) + )) { + if (isInsideRange(tok.start, unsafeRanges)) { break; } + const loc20c = locationOf(src, prev20!.start); + warnings.push({ + code: "SYN020", + severity: "warning", + file: null, + line: loc20c.line, + column: loc20c.column, + start: prev20!.start, + end: tok.end, + message: + `fn '${decl.name}' constructs new Date (no-paren form) — ` + + `new Date without parentheses is equivalent to new Date() and injects the current time invisible to the capability model; ` + + `pass nowMs as a parameter (time.now() with uses { time } gives epoch ms, not a Date object), ` + + `or wrap in unsafe "uses current time for " { new Date }`, + rule: syn020.rule, + idiom: syn020.idiom, + rewrite: syn020.rewrite, + }); + break; + } + + // ── Pattern 2: new Date() / Date(...) / new Date() ──────────── + // Check: followed by `(`, `?.(`, or (when `new`) `(`. + // `new Date(arg)` with explicit args is excluded (constructs a specific date, not ambient time). + // `Date(arg)` without `new` ignores args and always returns the current date string — always fires. + let callIdx20 = nextIdx20; + let isOpt20 = false; + + if (next20 && next20.kind === "questionDot") { + // Date?.( — optional bare call + isOpt20 = true; + callIdx20 = nextSignificant(tokens, nextIdx20 + 1); + } else if (hasNew20 && next20 && next20.kind === "operator" && next20.text === "<") { + // new Date( — generic scan only when `new` precedes (avoids comparison false-positives) + let depth = 1; + let j = nextIdx20 + 1; + while (j < decl.tokenEnd && depth > 0) { + const t = tokens[j]; + if (!t) break; + if (t.kind === "operator" && t.text === "<") depth++; + else if (t.kind === "operator" && (t.text === ">" || t.text === ">>" || t.text === ">>>")) + depth = Math.max(0, depth - t.text.length); + j++; + } + callIdx20 = nextSignificant(tokens, j); + } + + const callTok20 = tokens[callIdx20]; + if (!callTok20 || !(callTok20.kind === "open" && callTok20.text === "(")) continue; + + // Exclude method shorthands and TS method signatures: `{ Date(str): T; }` + // Guard against ternary consequents: `cond ? Date(str) : other` + // Also handles `await`-wrapped forms: `cond ? await Date() : other` + const prevBeforeNewIdx20 = hasNew20 ? prevSignificant(tokens, prevIdx20 - 1) : -1; + const prevBeforeNew20 = prevBeforeNewIdx20 >= 0 ? tokens[prevBeforeNewIdx20] : undefined; + // Look through `await` one level to detect ternary position + const awaitSkipPrev20 = (prev20 && prev20.kind === "ident" && prev20.text === "await") + ? tokens[prevSignificant(tokens, prevIdx20 - 1)] : undefined; + const awaitSkipPrevBeforeNew20 = (prevBeforeNew20 && prevBeforeNew20.kind === "ident" && prevBeforeNew20.text === "await") + ? tokens[prevSignificant(tokens, prevBeforeNewIdx20 - 1)] : undefined; + const isTernary20 = prev20?.kind === "question" || + prevBeforeNew20?.kind === "question" || + awaitSkipPrev20?.kind === "question" || + awaitSkipPrevBeforeNew20?.kind === "question"; + if (callTok20.matchedAt !== undefined) { + const afterCloseIdx20 = nextSignificant(tokens, callTok20.matchedAt + 1); + const afterClose20 = tokens[afterCloseIdx20]; + if (afterClose20 && ( + (afterClose20.kind === "open" && afterClose20.text === "{") || + afterClose20.kind === "fatArrow" || + (!isTernary20 && afterClose20.kind === "punct" && afterClose20.text === ":") + )) continue; + // Exclude TS method signatures with omitted return type: `{ Date(x: string) }`. + // A `:` at depth 0 inside the parens is a type annotation — not an ambient-time call. + // Also handles optional params: `{ Date(x?: string) }`. + let hasTypeAnnotation20 = false; + let depth20 = 0; + let ternaryDepth20 = 0; + for (let k20 = callIdx20 + 1; k20 < callTok20.matchedAt; k20++) { + const at20 = tokens[k20]; + if (!at20) continue; + if (at20.kind === "open") { depth20++; continue; } + if (at20.kind === "close") { depth20--; continue; } + if (depth20 !== 0) continue; + if (at20.kind === "question") { + const nextAfterQ20 = nextSignificant(tokens, k20 + 1); + const nextTokQ20 = tokens[nextAfterQ20]; + if (nextTokQ20 && nextTokQ20.kind === "punct" && nextTokQ20.text === ":") { + hasTypeAnnotation20 = true; + break; + } + ternaryDepth20++; + continue; + } + if (at20.kind === "punct" && at20.text === ":") { + if (ternaryDepth20 > 0) { ternaryDepth20--; continue; } + hasTypeAnnotation20 = true; + break; + } + } + if (hasTypeAnnotation20) continue; + } + + // `new Date(arg)` with arguments constructs a specific date — not ambient time; skip. + // `Date(arg)` (without `new`) always returns the current date string regardless of args; fire. + const firstInsideIdx20 = nextSignificant(tokens, callIdx20 + 1); + if (hasNew20 && firstInsideIdx20 !== callTok20.matchedAt) continue; // new Date(arg) → skip + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const warnStart20 = hasNew20 ? prev20!.start : tok.start; + const loc20b = locationOf(src, warnStart20); + // Bare `Date(arg)` ignores args and always returns current date string — show `Date(...)` when args present. + const hasDateArgs20 = !hasNew20 && firstInsideIdx20 !== callTok20.matchedAt; + const callForm20 = hasNew20 ? "new Date()" : isOpt20 ? (hasDateArgs20 ? "Date?.(...)" : "Date?.()") : (hasDateArgs20 ? "Date(...)" : "Date()"); + const formDesc20 = hasNew20 ? `constructs new Date()` : isOpt20 ? (hasDateArgs20 ? `calls Date?.(...)` : `calls Date?.()`) : (hasDateArgs20 ? `calls Date(...)` : `calls Date()`); + warnings.push({ + code: "SYN020", + severity: "warning", + file: null, + line: loc20b.line, + column: loc20b.column, + start: warnStart20, + end: callTok20.start + 1, + message: + `fn '${decl.name}' ${formDesc20} — ` + + `${callForm20} injects the current time invisible to the capability model; ` + + `pass nowMs as a parameter (time.now() with uses { time } gives epoch ms, not a Date object), ` + + `or wrap in unsafe "uses current time for " { ${callForm20} }`, + rule: syn020.rule, + idiom: syn020.idiom, + rewrite: syn020.rewrite, + }); + break; + } + + // ── SYN021: performance.now() / performance.timeOrigin ─────────────── + case "performance": { + // Exclude: `obj.performance.*` — performance preceded by `.` or `?.` + const prevIdx21 = prevSignificant(tokens, i - 1); + const prev21 = tokens[prevIdx21]; + if (prev21 && ((prev21.kind === "punct" && prev21.text === ".") || prev21.kind === "questionDot")) + continue; + + // Exclude function declarations: function performance(…), fn performance(…), function* performance(…) + if (prev21 && prev21.kind === "ident" && prev21.text === "function") continue; + if (prev21 && prev21.kind === "keyword" && prev21.text === "fn") continue; + if (isFunctionStarDecl(tokens, prevIdx21)) continue; + + // Must be followed by `.` or `?.` + const nextIdx21 = nextSignificant(tokens, i + 1); + const next21 = tokens[nextIdx21]; + const isDot21 = next21 && next21.kind === "punct" && next21.text === "."; + const isOptChain21 = next21 && next21.kind === "questionDot"; + if (!isDot21 && !isOptChain21) continue; + + // Member must be `now` or `timeOrigin` + const memberIdx21 = nextSignificant(tokens, nextIdx21 + 1); + const memberTok21 = tokens[memberIdx21]; + if (!memberTok21 || memberTok21.kind !== "ident") continue; + if (memberTok21.text !== "now" && memberTok21.text !== "timeOrigin") continue; + + if (memberTok21.text === "now") { + // `performance.now` must be followed by a call: `(` or `?.(` + let afterNowIdx21 = nextSignificant(tokens, memberIdx21 + 1); + let afterNow21 = tokens[afterNowIdx21]; + let isOptCall21 = false; + if (afterNow21 && afterNow21.kind === "questionDot") { + isOptCall21 = true; + afterNowIdx21 = nextSignificant(tokens, afterNowIdx21 + 1); + afterNow21 = tokens[afterNowIdx21]; + } + if (!afterNow21 || !(afterNow21.kind === "open" && afterNow21.text === "(")) continue; + + if (isInsideRange(memberTok21.start, unsafeRanges)) continue; + + const sep21 = isOptChain21 ? "?." : "."; + const callSep21 = isOptCall21 ? "?." : ""; + const loc21 = locationOf(src, tok.start); + warnings.push({ + code: "SYN021", + severity: "warning", + file: null, + line: loc21.line, + column: loc21.column, + start: tok.start, + end: afterNow21.start + 1, + message: + `fn '${decl.name}' calls performance${sep21}now${callSep21}() — ` + + `performance.now() injects monotonic time (ms since process start) invisible to the capability model; ` + + `pass nowMs as a parameter (preferred); ` + + `note: time.now() is wall-clock epoch time and does NOT replace performance.now() for elapsed-time measurement; ` + + `or wrap in unsafe "uses performance.now for " { performance.now() }`, + rule: syn021.rule, + idiom: syn021.idiom, + rewrite: syn021.rewrite, + }); + } else { + // `performance.timeOrigin` — property access, no call required + // Exclude TS method signatures: `{ performance: { timeOrigin: number } }` + // Guard against ternary consequents: `cond ? performance.timeOrigin : other` + // (the `:` there belongs to the ternary, not a type annotation) + // Also handles `await`-wrapped: `cond ? await performance.timeOrigin : other` + const afterMemberIdx21 = nextSignificant(tokens, memberIdx21 + 1); + const afterMember21 = tokens[afterMemberIdx21]; + const prevPrevIdx21 = prevSignificant(tokens, prevIdx21 - 1); + const prevPrev21 = tokens[prevPrevIdx21]; + const isTernaryConsequent21 = (prev21 && prev21.kind === "question") || + (prev21 && prev21.kind === "ident" && prev21.text === "await" && prevPrev21?.kind === "question"); + if (!isTernaryConsequent21 && afterMember21 && afterMember21.kind === "punct" && afterMember21.text === ":") continue; + + if (isInsideRange(memberTok21.start, unsafeRanges)) continue; + + const sep21b = isOptChain21 ? "?." : "."; + const loc21b = locationOf(src, tok.start); + warnings.push({ + code: "SYN021", + severity: "warning", + file: null, + line: loc21b.line, + column: loc21b.column, + start: tok.start, + end: memberTok21.end, + message: + `fn '${decl.name}' reads performance${sep21b}timeOrigin — ` + + `performance.timeOrigin exposes the epoch of the monotonic clock, invisible to the capability model; ` + + `pass the origin as a parameter (preferred), ` + + `or wrap in unsafe "uses performance.timeOrigin for " { performance.timeOrigin }`, + rule: syn021.rule, + idiom: syn021.idiom, + rewrite: syn021.rewrite, + }); + } + break; + } + // ── SYN023: navigator.* ambient browser capability ─────────────────── case "navigator": { // Exclude: `obj.navigator.*` — navigator preceded by `.` or `?.` diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index 8c9bfdf2..7d2ca507 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", "SYN007", "SYN008", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN016", "SYN018", "SYN019", "SYN022", "SYN023", + "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN016", "SYN018", "SYN019", "SYN020", "SYN021", "SYN022", "SYN023", "THR001", "THR002", "THR003", "THR004", "UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "VER001", "VER002", "VER003", diff --git a/packages/compiler/tests/syn020-check.test.ts b/packages/compiler/tests/syn020-check.test.ts new file mode 100644 index 00000000..4b55cfe4 --- /dev/null +++ b/packages/compiler/tests/syn020-check.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it } from "vitest"; +import { passSynCheck } from "../src/passes/syn-check.js"; + +function compile(src: string) { + return passSynCheck(src, { resolved: "0.7", declared: "0.7" }); +} + +describe("SYN020: Date.now() / new Date() / Date() detection", () => { + // ── fires: Date.now() ────────────────────────────────────────────────────── + + it("fires on Date.now() inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn isExpired(expiresAt: number) -> boolean {\n" + + " return Date.now() > expiresAt\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(true); + }); + + it("fires on Date?.now() — optional chaining on Date", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> number {\n" + + " return Date?.now()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(true); + }); + + it("fires on Date.now?.() — optional call on now", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> number {\n" + + " return Date.now?.()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(true); + }); + + // ── fires: new Date() ────────────────────────────────────────────────────── + + it("fires on new Date() — no-arg constructor", () => { + const src = + "?bs 0.7\n" + + "fn now() -> any {\n" + + " return new Date()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(true); + }); + + it("fires on new Date() — TypeScript generic no-arg constructor", () => { + const src = + "?bs 0.7\n" + + "fn now() -> any {\n" + + " return new Date()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(true); + }); + + it("fires on new Date (no parentheses) — equivalent to new Date() in JS/TS", () => { + const src = + "?bs 0.7\n" + + "fn now() -> any {\n" + + " const d = new Date\n" + + " return d\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(true); + }); + + it("does NOT fire on new Date (no parens) inside unsafe block", () => { + const src = + "?bs 0.7\n" + + "fn now() -> any {\n" + + " return unsafe \"uses current time\" { new Date }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + // ── fires: bare Date() ───────────────────────────────────────────────────── + + it("fires on Date() — bare call with no args", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> string {\n" + + " return Date()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(true); + }); + + it("fires on Date?.() — optional bare call with no args", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> string {\n" + + " return Date?.()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(true); + }); + + it("SYN020 message uses Date?.(...) when Date?.(x) has arguments", () => { + const src = + "?bs 0.7\n" + + "fn ts(x: number) -> string {\n" + + " return Date?.(x)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN020"); + expect(w).toBeDefined(); + expect(w!.message).toContain("Date?.(...)"); + expect(w!.message).not.toContain("Date?.()"); + }); + + it("fires on Date(arg) — JS ignores args to Date() called without new, still returns current time", () => { + const src = + "?bs 0.7\n" + + "fn ts(x: number) -> string {\n" + + " return Date(x)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(true); + }); + + // ── does NOT fire below ?bs 0.7 ─────────────────────────────────────────── + + it("does NOT fire below ?bs 0.7", () => { + const src = + "?bs 0.6\n" + + "fn ts() -> number {\n" + + " return Date.now()\n" + + "}\n"; + const result = passSynCheck(src, { resolved: "0.6", declared: "0.6" }); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + // ── suppressed inside unsafe ─────────────────────────────────────────────── + + it("does NOT fire inside unsafe {} block", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> number {\n" + + ' return unsafe "uses current time for logging" { Date.now() }\n' + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + it("does NOT fire inside unsafe fn body", () => { + const src = + "?bs 0.7\n" + + 'unsafe "uses current time" fn ts() -> number {\n' + + " return Date.now()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + // ── excluded forms ───────────────────────────────────────────────────────── + + it("does NOT fire on new Date(timestamp) — explicit arg", () => { + const src = + "?bs 0.7\n" + + "fn fromMs(ms: number) -> any {\n" + + " return new Date(ms)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + it("does NOT fire on new Date('2024-01-01') — explicit string arg", () => { + const src = + "?bs 0.7\n" + + "fn parseDate(s: string) -> any {\n" + + " return new Date('2024-01-01')\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + it("does NOT fire on new Date(year, month, day) — multiple explicit args", () => { + const src = + "?bs 0.7\n" + + "fn makeDate(y: number, m: number, d: number) -> any {\n" + + " return new Date(y, m, d)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + it("does NOT fire on Date.parse(str) — not an ambient-time call", () => { + const src = + "?bs 0.7\n" + + "fn parseMs(s: string) -> number {\n" + + " return Date.parse(s)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + it("does NOT fire on Date.UTC(...) — not an ambient-time call", () => { + const src = + "?bs 0.7\n" + + "fn utc(y: number, m: number) -> number {\n" + + " return Date.UTC(y, m)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + it("does NOT fire on obj.Date() — member call on a local", () => { + const src = + "?bs 0.7\n" + + "fn ts(obj: any) -> any {\n" + + " return obj.Date()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + it("does NOT fire on fn Date(...) botscript declaration", () => { + const src = + "?bs 0.7\n" + + "fn Date(ms: number) -> string {\n" + + " return ms.toString()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + it("does NOT fire on function Date() {} — function declaration named Date", () => { + const src = + "?bs 0.7\n" + + "fn outer() -> void {\n" + + " function Date() { return '' }\n" + + " return\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + it("does NOT fire on function* Date() {} — generator declaration named Date", () => { + const src = + "?bs 0.7\n" + + "fn outer() -> void {\n" + + " function* Date() { yield '' }\n" + + " return\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + it("does NOT fire on Date < x > (y) — comparison expression, not generic construction", () => { + const src = + "?bs 0.7\n" + + "fn cmp(x: number, y: number) -> boolean {\n" + + " return Date < x > (y)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + // ── attribution and message ──────────────────────────────────────────────── + + it("warning severity is 'warning' (non-blocking)", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> number {\n" + + " return Date.now()\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN020"); + expect(w?.severity).toBe("warning"); + }); + + it("message mentions Date.now()", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> number {\n" + + " return Date.now()\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN020"); + expect(w?.message).toContain("Date.now()"); + }); + + it("fires twice when Date.now() and new Date() both appear in the same fn", () => { + const src = + "?bs 0.7\n" + + "fn dual() -> string {\n" + + " const ms = Date.now()\n" + + " const d = new Date()\n" + + " return d.toISOString() + ms\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.filter((w) => w.code === "SYN020").length).toBe(2); + }); + + it("nested fn attribution: warns on inner fn, not outer", () => { + const src = + "?bs 0.7\n" + + "fn outer() -> void {\n" + + " fn inner() -> number {\n" + + " return Date.now()\n" + + " }\n" + + " return\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN020"); + expect(w?.message).toContain("inner"); + }); + + it("fires on Date.now() as ternary consequent after await", () => { + // `cond ? await Date.now() : 0` — the `:` is ternary, not a TS type annotation; must not suppress + const src = + "?bs 0.7\n" + + "fn ts(cond: boolean) -> number {\n" + + " return cond ? await Date.now() : 0\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(true); + }); + + it("fires on new Date() as ternary consequent after await", () => { + // `cond ? await new Date() : null` — must not be suppressed by `:` guard + const src = + "?bs 0.7\n" + + "fn ts(cond: boolean) -> string {\n" + + " return cond ? await new Date() : \"\"\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(true); + }); + + it("does NOT fire on TS type-literal method signature with omitted return type: type T = { Date(x: string) }", () => { + const src = + "?bs 0.7\n" + + "fn setup() -> void {\n" + + " type T = { Date(x: string) }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); + + it("does NOT fire on TS type-literal method signature with optional param: type T = { Date(x?: string) }", () => { + const src = + "?bs 0.7\n" + + "fn setup() -> void {\n" + + " type T = { Date(x?: string) }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN020")).toBe(false); + }); +}); diff --git a/packages/compiler/tests/syn021-check.test.ts b/packages/compiler/tests/syn021-check.test.ts new file mode 100644 index 00000000..3bc988f9 --- /dev/null +++ b/packages/compiler/tests/syn021-check.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import { passSynCheck } from "../src/passes/syn-check.js"; + +function compile(src: string) { + return passSynCheck(src, { resolved: "0.7", declared: "0.7" }); +} + +describe("SYN021: performance.now() / performance.timeOrigin detection", () => { + // ── fires: performance.now() ────────────────────────────────────────────── + + it("fires on performance.now() inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn elapsed(startMs: number) -> number {\n" + + " return performance.now() - startMs\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(true); + }); + + it("fires on performance?.now() — optional chaining on performance", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> number {\n" + + " return performance?.now()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(true); + }); + + it("fires on performance.now?.() — optional call on now", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> number {\n" + + " return performance.now?.()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(true); + }); + + // ── fires: performance.timeOrigin ───────────────────────────────────────── + + it("fires on performance.timeOrigin property read", () => { + const src = + "?bs 0.7\n" + + "fn origin() -> number {\n" + + " return performance.timeOrigin\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(true); + }); + + it("fires on performance?.timeOrigin — optional chaining", () => { + const src = + "?bs 0.7\n" + + "fn origin() -> number {\n" + + " return performance?.timeOrigin\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(true); + }); + + // ── does NOT fire below ?bs 0.7 ─────────────────────────────────────────── + + it("does NOT fire below ?bs 0.7", () => { + const src = + "?bs 0.6\n" + + "fn ts() -> number {\n" + + " return performance.now()\n" + + "}\n"; + const result = passSynCheck(src, { resolved: "0.6", declared: "0.6" }); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(false); + }); + + // ── suppressed inside unsafe ─────────────────────────────────────────────── + + it("does NOT fire inside unsafe {} block", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> number {\n" + + ' return unsafe "uses performance.now for timing" { performance.now() }\n' + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(false); + }); + + it("does NOT fire inside unsafe fn body", () => { + const src = + "?bs 0.7\n" + + 'unsafe "uses performance.now" fn ts() -> number {\n' + + " return performance.now()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(false); + }); + + // ── excluded forms ───────────────────────────────────────────────────────── + + it("does NOT fire on obj.performance.now() — member access on local binding", () => { + const src = + "?bs 0.7\n" + + "fn ts(obj: any) -> number {\n" + + " return obj.performance.now()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(false); + }); + + it("does NOT fire on performance.mark() — excluded by SYN021 check", () => { + const src = + "?bs 0.7\n" + + "fn markEvent() -> void {\n" + + " performance.mark('start')\n" + + " return\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(false); + }); + + it("does NOT fire on performance.measure() — excluded by SYN021 check", () => { + const src = + "?bs 0.7\n" + + "fn measure() -> void {\n" + + " performance.measure('total', 'start', 'end')\n" + + " return\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(false); + }); + + it("does NOT fire on fn performance(...) declaration", () => { + const src = + "?bs 0.7\n" + + "fn performance(x: number) -> number {\n" + + " return x * 2\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(false); + }); + + it("does NOT fire on function* performance(...) generator declaration", () => { + const src = + "?bs 0.7\n" + + "fn wrapper() -> any {\n" + + " function* performance(x: number) { yield x }\n" + + " return performance(1)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(false); + }); + + // ── attribution and message ──────────────────────────────────────────────── + + it("warning severity is 'warning' (non-blocking)", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> number {\n" + + " return performance.now()\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN021"); + expect(w?.severity).toBe("warning"); + }); + + it("message mentions performance.now()", () => { + const src = + "?bs 0.7\n" + + "fn ts() -> number {\n" + + " return performance.now()\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN021"); + expect(w?.message).toContain("performance"); + expect(w?.message).toContain("now"); + }); + + it("message mentions performance.timeOrigin for property read", () => { + const src = + "?bs 0.7\n" + + "fn origin() -> number {\n" + + " return performance.timeOrigin\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN021"); + expect(w?.message).toContain("timeOrigin"); + }); + + it("fires twice when performance.now() and performance.timeOrigin both appear", () => { + const src = + "?bs 0.7\n" + + "fn dual() -> number {\n" + + " const t = performance.now()\n" + + " const o = performance.timeOrigin\n" + + " return t + o\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.filter((w) => w.code === "SYN021").length).toBe(2); + }); + + it("fires on performance.timeOrigin in ternary consequent", () => { + // The `:` here is the ternary separator, not a TS type annotation — must not suppress + const src = + "?bs 0.7\n" + + "fn origin(usePerf: boolean) -> number {\n" + + " return usePerf ? performance.timeOrigin : 0\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(true); + }); + + it("fires on performance.timeOrigin as ternary consequent after await", () => { + // `cond ? await performance.timeOrigin : 0` — `:` is ternary, not a TS type annotation + const src = + "?bs 0.7\n" + + "fn origin(usePerf: boolean) -> number {\n" + + " return usePerf ? await performance.timeOrigin : 0\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN021")).toBe(true); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index e6f5a6a0..78831068 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1248,6 +1248,118 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN020: { + code: "SYN020", + title: "Date.now() / new Date() / new Date (no parens) / Date() call bypasses the time capability model", + body: + "SYN020 fires when a fn body calls `Date.now()`, `Date?.now()`, `Date.now?.()`, " + + "`new Date()` (no args), `new Date` (no parentheses — equivalent to `new Date()` in JS/TS), " + + "`new Date()` (TypeScript generic, no args), or `Date(...)` / `Date?.()` " + + "(bare call — JS ignores arguments and always returns the current date string). " + + "These forms inject the current wall-clock time at runtime.\n\n" + + "**Why it matters:** `Date.now()` and `new Date()` read the current time at runtime but are " + + "entirely invisible to botscript's capability model. `uses { time }` declarations cover " + + "`time.*` stdlib namespace calls, not the `Date` global. A fn that calls these forms has an " + + "undeclared time dependency: callers reading the fn header see no indication of time-sensitivity, " + + "tests cannot control the time value the fn observes, and the capability manifest does not " + + "record the dependency. In agent / bot contexts this is especially hazardous: a fn declared " + + "idempotent that secretly depends on the current time will produce different outputs across " + + "retries in a way callers cannot observe or audit.\n\n" + + "**Detected forms:**\n" + + "- `Date.now()` / `Date?.now()` / `Date.now?.()` — any call form on `Date.now`\n" + + "- `new Date()` / `new Date()` — no-arg constructor (with optional TypeScript generic)\n" + + "- `new Date` (no parentheses) — equivalent to `new Date()` in JS/TS\n" + + "- `Date(...)` / `Date?.()` — bare call (JS ignores arguments; always returns current date string)\n\n" + + "**Not detected** (these don't inject ambient time):\n" + + "- `new Date(timestamp)` / `new Date('2024-01-01')` / `new Date(y, m, d)` — explicit args\n" + + "- `Date.parse(str)` / `Date.UTC(...)` — work with explicit values, no ambient time\n" + + "- `obj.Date()` — member call on a local binding\n\n" + + "**Fix (preferred — pass time as a parameter):** make the time dependency explicit and " + + "testable by accepting `nowMs` as a parameter:\n\n" + + "```\n" + + "// SYN020 — before\n" + + "fn isExpired(expiresAt: number) -> boolean {\n" + + " return Date.now() > expiresAt\n" + + "}\n\n" + + "// fix — time passed as a parameter; tests can control it\n" + + "fn isExpired(expiresAt: number, nowMs: number) -> boolean {\n" + + " return nowMs > expiresAt\n" + + "}\n" + + "```\n\n" + + "**Fix (stdlib):** use `time.now()` with `uses { time }` to make the time dependency " + + "visible in the fn header.\n\n" + + "**Fix (escape hatch):** if direct `Date` access is required, wrap in an `unsafe` block:\n" + + "`unsafe \"uses current time for \" { Date.now() }`\n\n" + + "SYN020 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" + + "fn isExpired(expiresAt: number) -> boolean {\n" + + " return Date.now() > expiresAt\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn isExpired(expiresAt: number, nowMs: number) -> boolean {\n" + + " return nowMs > expiresAt\n" + + "}\n", + }, + }, + SYN021: { + code: "SYN021", + title: "performance.now() / performance.timeOrigin access bypasses the time capability model", + body: + "SYN021 fires when a fn body calls `performance.now()`, `performance?.now()`, " + + "`performance.now?.()`, or reads `performance.timeOrigin` / `performance?.timeOrigin`.\n\n" + + "**Why it matters:** `performance.now()` returns a high-resolution monotonic timestamp " + + "(milliseconds since the page/process started) and `performance.timeOrigin` exposes the " + + "absolute epoch of that clock. Both inject ambient timing information at runtime but are " + + "entirely invisible to botscript's capability model. `uses { time }` declarations cover " + + "`time.*` stdlib namespace calls, not the `performance` global. A fn that reads these " + + "values has an undeclared time dependency: callers reading the fn header see no indication " + + "of time-sensitivity, tests cannot control the clock value the fn observes, and the " + + "capability manifest does not record the dependency. In agent / bot contexts this matters: " + + "a fn declared idempotent that secretly reads the monotonic clock will produce " + + "timing-dependent outputs that callers cannot observe or audit.\n\n" + + "**Detected forms:**\n" + + "- `performance.now()` / `performance?.now()` / `performance.now?.()` — any call form\n" + + "- `performance.timeOrigin` / `performance?.timeOrigin` — property read (no call needed)\n\n" + + "**Not detected** (excluded by this check):\n" + + "- `obj.performance.*` — member access on a local binding\n" + + "- `performance.mark()` / `performance.measure()` — excluded by this check (these methods can timestamp internally, but are not surfaced as ambient reads by SYN021)\n\n" + + "**Fix (preferred — pass time as a parameter):** make the time dependency explicit:\n\n" + + "```\n" + + "// SYN021 — before\n" + + "fn elapsed(startMs: number) -> number {\n" + + " return performance.now() - startMs\n" + + "}\n\n" + + "// fix — time passed as a parameter; tests can control it\n" + + "fn elapsed(startMs: number, nowMs: number) -> number {\n" + + " return nowMs - startMs\n" + + "}\n" + + "```\n\n" + + "**Fix (stdlib — epoch time only):** if you only need current epoch time (not monotonic " + + "time relative to process start), use `time.now()` with `uses { time }` to make the " + + "dependency visible in the fn header. Note: `time.now()` is wall-clock epoch time " + + "(equivalent to `Date.now()`), not a monotonic clock — it does not replace `performance.now()` " + + "when relative elapsed time is what you need.\n\n" + + "**Fix (escape hatch):** if direct `performance` access is required, wrap in an `unsafe` block:\n" + + "`unsafe \"uses performance.now for \" { performance.now() }`\n\n" + + "SYN021 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" + + "fn elapsed(startMs: number) -> number {\n" + + " return performance.now() - startMs\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn elapsed(startMs: number, nowMs: number) -> number {\n" + + " return nowMs - startMs\n" + + "}\n", + }, + }, SYN022: { code: "SYN022", title: "process.* ambient state access bypasses the capability model", diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 780ef193..0e6d9e93 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -88,6 +88,8 @@ describe("botscript-mcp explanations", () => { "SYN016", "SYN018", "SYN019", + "SYN020", + "SYN021", "SYN022", "SYN023", "THR001",