From 3ca34f91ee5d06bbbe9fb1fde4f5714da6e20816 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 19:48:06 -0300 Subject: [PATCH 1/8] =?UTF-8?q?feat(runtime+compiler):=20add=20clock.seque?= =?UTF-8?q?nce()=20=E2=80=94=20free=20monotonic=20counter=20namespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #173. Implements a minimal clock namespace providing process-local monotonic ordering without wallclock access or capability declaration requirements. Runtime (effects.ts): - Add MonotonicTimestamp branded type (number & { __brand: "MonotonicTimestamp" }) - Add clock.sequence() — increments a process-local counter starting at 0, returns MonotonicTimestamp. No $require() call; no external state. Compiler (_stdlib.ts): - Add clock: "" to STDLIB_TO_CAP. Empty string is falsy — CAP001 and CAP002 skip capability checks for clock (if (!cap) continue), so uses { clock } is never required. STDLIB_NAMESPACES derives from Object.keys(STDLIB_TO_CAP), so dep-check and hasOpaqueCall correctly exclude clock.sequence() from opaque-call detection. Compiler (imports.ts): - Add "clock" to STDLIB_VALUE_SYMBOLS and STDLIB_NAMESPACE_NAMES so the transform pass emits the correct import for clock in compiled output. Tests (cap-check.test.ts): - Verify CAP001 does not fire for clock.sequence() without any uses {} clause. - Verify CAP002 is not triggered when clock.sequence() and a real capability (time) are used together. Design notes: - MonotonicTimestamp is distinct from number at the type level; it cannot be passed to APIs expecting wallclock values without an explicit cast. - clock.sequence() counter resets on process restart — suitable for intra-run ordering, not cross-service correlation. Cross-service correlation requires uses { time } and explicit wallclock injection. - No SYN020/SYN021 interaction: those check Date.now()/performance.now(), not clock.sequence(). Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/_stdlib.ts | 5 ++++ packages/compiler/src/passes/imports.ts | 3 ++- packages/compiler/tests/cap-check.test.ts | 31 +++++++++++++++++++++++ packages/runtime/src/effects.ts | 20 +++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/_stdlib.ts b/packages/compiler/src/passes/_stdlib.ts index e68ba4a1..06f80130 100644 --- a/packages/compiler/src/passes/_stdlib.ts +++ b/packages/compiler/src/passes/_stdlib.ts @@ -11,4 +11,9 @@ export const STDLIB_TO_CAP: Readonly> = { fs: "fs", stdout: "stdout", stderr: "stderr", + // clock is a free namespace — no capability declaration required. + // clock.sequence() returns a MonotonicTimestamp: a process-local monotonic counter + // that provides ordering guarantees without wallclock access. CAP001 skips namespaces + // mapped to "" (falsy), so `uses { clock }` is never required. + clock: "", }; diff --git a/packages/compiler/src/passes/imports.ts b/packages/compiler/src/passes/imports.ts index f824fdca..a9fbf7d4 100644 --- a/packages/compiler/src/passes/imports.ts +++ b/packages/compiler/src/passes/imports.ts @@ -65,6 +65,7 @@ const STDLIB_VALUE_SYMBOLS = [ "unwrapOption", "unwrapOr", // Effects + "clock", "http", "random", "stderr", @@ -73,7 +74,7 @@ const STDLIB_VALUE_SYMBOLS = [ ] as const; /** Stdlib namespace names (member-access objects, not standalone call targets). */ -const STDLIB_NAMESPACE_NAMES = new Set(["http", "random", "stderr", "stdout", "time"]); +const STDLIB_NAMESPACE_NAMES = new Set(["clock", "http", "random", "stderr", "stdout", "time"]); /** * Stdlib value helpers that appear as bare `ident(` call sites in function bodies diff --git a/packages/compiler/tests/cap-check.test.ts b/packages/compiler/tests/cap-check.test.ts index 01313b0b..585b674e 100644 --- a/packages/compiler/tests/cap-check.test.ts +++ b/packages/compiler/tests/cap-check.test.ts @@ -336,3 +336,34 @@ describe("cap-check: no false positive for stdlib namespace in parameter type", expect(() => t(src)).toThrow(/CAP001/); }); }); + +// --------------------------------------------------------------------------- +// clock.sequence() — free namespace, no capability declaration required +// --------------------------------------------------------------------------- + +describe("cap-check: clock.sequence() does not require uses { clock }", () => { + it("does NOT fire CAP001 for clock.sequence() — clock is a free namespace", () => { + // clock.sequence() provides process-local monotonic ordering without + // wallclock access. No capability declaration should be needed. + const src = + "?bs 0.7\n" + + "fn tagEvent(name: string) -> string {\n" + + " const seq = clock.sequence()\n" + + " return `${name}#${seq}`\n" + + "}\n"; + expect(() => t(src)).not.toThrow(); + }); + + it("does NOT fire CAP002 for clock.sequence() — not a declarable capability", () => { + // Even with an explicit `uses { time }` declaration, clock.sequence() in + // the body should not cause issues (it's not a capability overshoot). + const src = + "?bs 0.7\n" + + "fn tagWithTime(name: string) uses { time } -> string {\n" + + " const seq = clock.sequence()\n" + + " const ts = time.now()\n" + + " return `${name}#${seq}@${ts}`\n" + + "}\n"; + expect(() => t(src)).not.toThrow(); + }); +}); diff --git a/packages/runtime/src/effects.ts b/packages/runtime/src/effects.ts index c913e8b6..15b7fe0c 100644 --- a/packages/runtime/src/effects.ts +++ b/packages/runtime/src/effects.ts @@ -75,6 +75,26 @@ export const http = { }, }; +/** + * MonotonicTimestamp — an opaque, process-local, ordered counter value. + * Provides ordering guarantees (A happened before B) without wallclock access. + * Not serializable as real time; not comparable across processes or restarts. + */ +export type MonotonicTimestamp = number & { readonly __brand: "MonotonicTimestamp" }; + +// Process-local monotonic sequence counter. Starts at 0, increments by 1. +// Does not require a capability declaration — no external state, no wallclock. +let _clockSeq = 0; + +export const clock = { + /** + * Returns the next MonotonicTimestamp for the current process execution. + * Each call returns a value strictly greater than the previous call's value. + * Suitable for ordering events in structured logs without declaring `uses { time }`. + */ + sequence: (): MonotonicTimestamp => _clockSeq++ as MonotonicTimestamp, +}; + // Mockable sources. `with mocks { time, random }` swaps these in tests; in // app code they default to the real wallclock and Math.random. let timeSource: () => number = () => Date.now(); From 4aa89a115f154a8846c8f0ac7ac4ba5a4ab3a8ab Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 23:26:17 -0300 Subject: [PATCH 2/8] =?UTF-8?q?test(runtime):=20add=20clock.sequence()=20v?= =?UTF-8?q?itest=20=E2=80=94=20no-throw=20+=20strict-monotonic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review comment on PR #175: clock.sequence() had no runtime test. Asserts (1) calling inside $enter([]) does not throw and (2) successive calls are strictly increasing. Co-Authored-By: Botkowski --- packages/runtime/tests/effects.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/runtime/tests/effects.test.ts b/packages/runtime/tests/effects.test.ts index ae189a41..a4c18502 100644 --- a/packages/runtime/tests/effects.test.ts +++ b/packages/runtime/tests/effects.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { $enter, $reset, CapabilityViolation } from "../src/capabilities.js"; -import { http } from "../src/effects.js"; +import { clock, http } from "../src/effects.js"; import { fs } from "../src/fs.js"; import { isErr, isOk } from "../src/result.js"; @@ -143,3 +143,17 @@ describe("fs wrappers", () => { ).toThrow(CapabilityViolation); }); }); + +describe("clock.sequence()", () => { + it("does not throw inside $enter([])", () => { + expect(() => $enter([], () => clock.sequence())).not.toThrow(); + }); + + it("successive calls are strictly increasing", () => { + const a = $enter([], () => clock.sequence()); + const b = $enter([], () => clock.sequence()); + const c = $enter([], () => clock.sequence()); + expect(b).toBeGreaterThan(a); + expect(c).toBeGreaterThan(b); + }); +}); From a768870efcf6341ebf1fcf52330b0f11da963d62 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 03:29:49 -0300 Subject: [PATCH 3/8] fix(compiler): INT002/INT004 now catch clock.sequence() in pure/idempotent fns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clock is a stateful-free namespace — no capability required, but clock.sequence() is non-deterministic (monotonic counter). INT002 (pure) and INT004 (idempotent) now fire when a fn body calls clock.sequence() via a new findFirstStatefulFreeUse scan backed by STATEFUL_FREE_NAMESPACES in _stdlib.ts. Addresses Copilot review comment on PR #175. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/_stdlib.ts | 8 ++ packages/compiler/src/passes/intent-check.ts | 93 +++++++++++++++++++- packages/compiler/tests/intent.test.ts | 65 ++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/_stdlib.ts b/packages/compiler/src/passes/_stdlib.ts index 06f80130..cb812e52 100644 --- a/packages/compiler/src/passes/_stdlib.ts +++ b/packages/compiler/src/passes/_stdlib.ts @@ -17,3 +17,11 @@ export const STDLIB_TO_CAP: Readonly> = { // mapped to "" (falsy), so `uses { clock }` is never required. clock: "", }; + +/** + * Stdlib namespaces that require no capability declaration but are stateful + * (non-deterministic per call). Intent-check uses this set to block + * `intent: "pure"` (INT002) and `intent: "idempotent"` (INT004) claims even + * though cap-check never fires for these namespaces. + */ +export const STATEFUL_FREE_NAMESPACES: ReadonlySet = new Set(["clock"]); diff --git a/packages/compiler/src/passes/intent-check.ts b/packages/compiler/src/passes/intent-check.ts index a1b92db7..0c2d2022 100644 --- a/packages/compiler/src/passes/intent-check.ts +++ b/packages/compiler/src/passes/intent-check.ts @@ -58,7 +58,7 @@ import { parseProgram } from "../parser/parse.js"; import type { FnDecl } from "../parser/parse-fn.js"; import { locationOf } from "./_location.js"; import { atLeast, type VersionInfo } from "./version.js"; -import { STDLIB_TO_CAP } from "./_stdlib.js"; +import { STDLIB_TO_CAP, STATEFUL_FREE_NAMESPACES } from "./_stdlib.js"; import { aliasesForFn, blockShadowsForFn, isInBlockShadow, collectStdlibAliases, type BlockShadowRange } from "./_alias.js"; export function passIntentCheck(src: string, version: VersionInfo): string { @@ -195,6 +195,37 @@ function checkPureClaim( `// option B — declare the capability and remove the pure claim:\n` + `fn ${decl.name}(...) uses { ${bodyUse.capability} } -> ...`, }); + return; + } + + // INT002: stateful-free namespace variant — clock.sequence() and similar are + // capability-free (no `uses {}` required) but non-deterministic. A pure fn + // must be deterministic, so calling these still violates the claim. + const statefulFreeUse = findFirstStatefulFreeUse(tokens, decl, allDecls); + if (statefulFreeUse) { + const entry = getErrorCode("INT002")!; + const intentStart = decl.intentStart!; + const loc = locationOf(src, intentStart); + diagnostics.push({ + code: "INT002", + severity: "error", + file: null, + line: loc.line, + column: loc.column, + start: intentStart, + end: intentStart + decl.intent!.length + 2, + message: + `fn '${decl.name}' declares intent: "pure" but body calls ` + + `'${statefulFreeUse.namespace}${statefulFreeUse.accessOp}${statefulFreeUse.member}' which is stateful (non-deterministic) — ` + + `pure functions must be deterministic`, + rule: entry.rule, + idiom: entry.idiom, + rewrite: + `// option A — remove the stateful call from the body:\n` + + `fn ${decl.name}(...) intent: "pure" -> ...\n\n` + + `// option B — remove the pure intent claim:\n` + + `fn ${decl.name}(...) -> ...`, + }); } } @@ -325,7 +356,67 @@ function checkIdempotentClaim( `// option B — declare the capability and remove the idempotent claim:\n` + `fn ${decl.name}(...) uses { ${proposedCaps} } -> ...`, }); + return; } + + // INT004: stateful-free namespace variant — clock.sequence() is capability-free + // but non-deterministic (returns a new value each call). An idempotent fn must + // produce the same observable result on retries; calling clock.sequence() breaks that. + const statefulFreeUse4 = findFirstStatefulFreeUse(tokens, decl, allDecls); + if (statefulFreeUse4) { + const entry = getErrorCode("INT004")!; + const intentStart = decl.intentStart!; + const loc = locationOf(src, intentStart); + diagnostics.push({ + code: "INT004", + severity: "error", + file: null, + line: loc.line, + column: loc.column, + start: intentStart, + end: intentStart + decl.intent!.length + 2, + message: + `fn '${decl.name}' declares intent: "idempotent" but body calls ` + + `'${statefulFreeUse4.namespace}${statefulFreeUse4.accessOp}${statefulFreeUse4.member}' which returns a different value on each call — ` + + `idempotent functions must be safe to retry with the same result`, + rule: entry.rule, + idiom: entry.idiom, + rewrite: + `// option A — remove the stateful call from the body:\n` + + `fn ${decl.name}(...) intent: "idempotent" -> ...\n\n` + + `// option B — remove the idempotent intent claim:\n` + + `fn ${decl.name}(...) -> ...`, + }); + } +} + +/** + * Scan the fn body for a stateful-free namespace call (e.g. clock.sequence()). + * These namespaces require no capability declaration but are non-deterministic, + * so they still violate `intent: "pure"` and `intent: "idempotent"` claims. + */ +function findFirstStatefulFreeUse( + tokens: Token[], + fn: FnDecl, + allDecls: FnDecl[], +): { namespace: string; member: string; accessOp: "." | "?." } | null { + const inner = allDecls.filter( + (g) => g !== fn && g.tokenStart >= fn.tokenStart && g.tokenEnd <= fn.tokenEnd, + ); + for (let i = fn.bodyTokenStart ?? fn.tokenStart; i < fn.tokenEnd; i++) { + if (insideAny(i, inner)) continue; + const tok = tokens[i]; + if (!tok || tok.kind !== "ident") continue; + if (!STATEFUL_FREE_NAMESPACES.has(tok.text)) continue; + const j = nextSignificant(tokens, i + 1); + const next = tokens[j]; + const isDot = next?.kind === "punct" && next.text === "."; + const isOptChain = next?.kind === "questionDot"; + if (!isDot && !isOptChain) continue; + const member = nextIdent(tokens, j) ?? "…"; + return { namespace: tok.text, member, accessOp: isDot ? "." : "?." }; + } + return null; } /** diff --git a/packages/compiler/tests/intent.test.ts b/packages/compiler/tests/intent.test.ts index 7d5e63bc..54b852f7 100644 --- a/packages/compiler/tests/intent.test.ts +++ b/packages/compiler/tests/intent.test.ts @@ -317,6 +317,41 @@ describe("INT002 — optional-chaining bypass (?bs 0.8)", () => { }); }); +// --------------------------------------------------------------------------- +// INT002 — stateful-free namespace (clock.sequence) variant +// --------------------------------------------------------------------------- + +describe("INT002 — pure intent vs clock.sequence() (stateful-free namespace)", () => { + it("fires INT002 when pure fn body calls clock.sequence()", () => { + const src = `?bs 0.7\nfn makeSeq() intent: "pure" -> number = clock.sequence()\n`; + expect(() => t(src)).toThrow(/INT002/); + }); + + it("INT002 message mentions 'stateful' for clock.sequence()", () => { + const src = `?bs 0.7\nfn makeSeq() intent: "pure" -> number = clock.sequence()\n`; + try { + t(src); + expect.fail("should have thrown"); + } catch (e) { + const err = e as BotscriptError; + const d = err.diagnostics[0]!; + expect(d.code).toBe("INT002"); + expect(d.message).toContain("clock.sequence"); + expect(d.message).toContain("stateful"); + } + }); + + it("does NOT fire INT002 for clock.sequence() in a non-pure fn", () => { + const src = `?bs 0.7\nfn logStep() intent: "logging" -> number = clock.sequence()\n`; + expect(() => t(src)).not.toThrow(/INT002/); + }); + + it("does NOT fire INT002 for bare 'clock' reference without member call", () => { + const src = `?bs 0.7\nfn noop(clock: number) intent: "pure" -> number = clock\n`; + expect(() => t(src)).not.toThrow(/INT002/); + }); +}); + // --------------------------------------------------------------------------- // INT003 — idempotent intent vs non-idempotent capability in uses {} // --------------------------------------------------------------------------- @@ -484,6 +519,36 @@ describe("INT004 — idempotent intent vs non-idempotent call in body (?bs 0.7+) }); }); +// --------------------------------------------------------------------------- +// INT004 — idempotent intent vs clock.sequence() (stateful-free namespace) +// --------------------------------------------------------------------------- + +describe("INT004 — idempotent intent vs clock.sequence() (stateful-free namespace)", () => { + it("fires INT004 when idempotent fn body calls clock.sequence()", () => { + const src = `?bs 0.7\nfn step() intent: "idempotent" -> number = clock.sequence()\n`; + expect(() => t(src)).toThrow(/INT004/); + }); + + it("INT004 message mentions 'returns a different value' for clock.sequence()", () => { + const src = `?bs 0.7\nfn step() intent: "idempotent" -> number = clock.sequence()\n`; + try { + t(src); + expect.fail("should have thrown"); + } catch (e) { + const err = e as BotscriptError; + const d = err.diagnostics[0]!; + expect(d.code).toBe("INT004"); + expect(d.message).toContain("clock.sequence"); + expect(d.message).toContain("different value"); + } + }); + + it("does NOT fire INT004 for clock.sequence() in a non-idempotent fn", () => { + const src = `?bs 0.7\nfn step() intent: "writes" -> number = clock.sequence()\n`; + expect(() => t(src)).not.toThrow(/INT004/); + }); +}); + // --------------------------------------------------------------------------- // INT001 extended: reads {} / writes {} also contradict "pure" intent // --------------------------------------------------------------------------- From a5fb1f6bfe075a8125734241cdbad594ef7caf13 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 07:29:09 -0300 Subject: [PATCH 4/8] =?UTF-8?q?fix(clock.sequence):=20address=20Copilot=20?= =?UTF-8?q?review=20=E2=80=94=20UNS005=20false=20positive,=20intent-check?= =?UTF-8?q?=20shadow/alias,=20overflow=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uns-check: filter STDLIB_CAPS to truthy capabilities only so clock (mapped to "") never triggers UNS005 on capability-free calls - intent-check: pass declAliases + declBlockShadows into findFirstStatefulFreeUse so block-shadowed `clock` bindings don't false-positive and module-level aliases are resolved; mirrors findFirstCapabilityUse handling - effects.ts: throw RangeError on MAX_SAFE_INTEGER overflow so the monotonic ordering guarantee holds for the documented safe range Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/intent-check.ts | 19 +++++++++++++++---- packages/compiler/src/passes/uns-check.ts | 4 +++- packages/runtime/src/effects.ts | 7 ++++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/passes/intent-check.ts b/packages/compiler/src/passes/intent-check.ts index 0c2d2022..85c9927e 100644 --- a/packages/compiler/src/passes/intent-check.ts +++ b/packages/compiler/src/passes/intent-check.ts @@ -201,7 +201,7 @@ function checkPureClaim( // INT002: stateful-free namespace variant — clock.sequence() and similar are // capability-free (no `uses {}` required) but non-deterministic. A pure fn // must be deterministic, so calling these still violates the claim. - const statefulFreeUse = findFirstStatefulFreeUse(tokens, decl, allDecls); + const statefulFreeUse = findFirstStatefulFreeUse(tokens, decl, allDecls, declAliases, declBlockShadows); if (statefulFreeUse) { const entry = getErrorCode("INT002")!; const intentStart = decl.intentStart!; @@ -362,7 +362,7 @@ function checkIdempotentClaim( // INT004: stateful-free namespace variant — clock.sequence() is capability-free // but non-deterministic (returns a new value each call). An idempotent fn must // produce the same observable result on retries; calling clock.sequence() breaks that. - const statefulFreeUse4 = findFirstStatefulFreeUse(tokens, decl, allDecls); + const statefulFreeUse4 = findFirstStatefulFreeUse(tokens, decl, allDecls, declAliases4, declBlockShadows4); if (statefulFreeUse4) { const entry = getErrorCode("INT004")!; const intentStart = decl.intentStart!; @@ -394,11 +394,15 @@ function checkIdempotentClaim( * Scan the fn body for a stateful-free namespace call (e.g. clock.sequence()). * These namespaces require no capability declaration but are non-deterministic, * so they still violate `intent: "pure"` and `intent: "idempotent"` claims. + * Mirrors findFirstCapabilityUse: resolves module-level aliases and suppresses + * block-shadowed identifiers so `const clock = {...}` doesn't false-positive. */ function findFirstStatefulFreeUse( tokens: Token[], fn: FnDecl, allDecls: FnDecl[], + aliases: Map = new Map(), + blockShadows: BlockShadowRange[] = [], ): { namespace: string; member: string; accessOp: "." | "?." } | null { const inner = allDecls.filter( (g) => g !== fn && g.tokenStart >= fn.tokenStart && g.tokenEnd <= fn.tokenEnd, @@ -407,14 +411,21 @@ function findFirstStatefulFreeUse( if (insideAny(i, inner)) continue; const tok = tokens[i]; if (!tok || tok.kind !== "ident") continue; - if (!STATEFUL_FREE_NAMESPACES.has(tok.text)) continue; + // Resolve module-level alias before checking the namespace set. + const aliasCanonical = !isInBlockShadow(tok.text, i, blockShadows) + ? aliases.get(tok.text) + : undefined; + const canonical = aliasCanonical ?? tok.text; + if (!STATEFUL_FREE_NAMESPACES.has(canonical)) continue; + // Suppress if the identifier is block-shadowed by a local binding. + if (isInBlockShadow(tok.text, i, blockShadows)) continue; const j = nextSignificant(tokens, i + 1); const next = tokens[j]; const isDot = next?.kind === "punct" && next.text === "."; const isOptChain = next?.kind === "questionDot"; if (!isDot && !isOptChain) continue; const member = nextIdent(tokens, j) ?? "…"; - return { namespace: tok.text, member, accessOp: isDot ? "." : "?." }; + return { namespace: canonical, member, accessOp: isDot ? "." : "?." }; } return null; } diff --git a/packages/compiler/src/passes/uns-check.ts b/packages/compiler/src/passes/uns-check.ts index 2345d283..eeb0a144 100644 --- a/packages/compiler/src/passes/uns-check.ts +++ b/packages/compiler/src/passes/uns-check.ts @@ -41,7 +41,9 @@ import { computeNesting, nextSignificant } from "./_callgraph.js"; import { collectUnsafeBlockRanges, isInsideRange, type CharRange } from "./_unsafe-ranges.js"; import { aliasesForFn, blockShadowsForFn, isInBlockShadow, collectStdlibAliases } from "./_alias.js"; -const STDLIB_CAPS = new Set(Object.keys(STDLIB_TO_CAP)); +// Only namespaces with a truthy capability string trigger UNS005. +// Free namespaces (clock: "") have no external capability and must not fire. +const STDLIB_CAPS = new Set(Object.keys(STDLIB_TO_CAP).filter((k) => STDLIB_TO_CAP[k])); export function passUnsCheck(src: string, version: VersionInfo): string { if (!atLeast(version.resolved, "0.9")) return src; diff --git a/packages/runtime/src/effects.ts b/packages/runtime/src/effects.ts index 15b7fe0c..f8aab81d 100644 --- a/packages/runtime/src/effects.ts +++ b/packages/runtime/src/effects.ts @@ -92,7 +92,12 @@ export const clock = { * Each call returns a value strictly greater than the previous call's value. * Suitable for ordering events in structured logs without declaring `uses { time }`. */ - sequence: (): MonotonicTimestamp => _clockSeq++ as MonotonicTimestamp, + sequence: (): MonotonicTimestamp => { + if (_clockSeq >= Number.MAX_SAFE_INTEGER) { + throw new RangeError("clock.sequence: monotonic counter overflow — MAX_SAFE_INTEGER reached"); + } + return _clockSeq++ as MonotonicTimestamp; + }, }; // Mockable sources. `with mocks { time, random }` swaps these in tests; in From 3890f4408a6318dea4242a88c98d9e5595298aa9 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 11:42:27 -0300 Subject: [PATCH 5/8] test(clock.sequence): UNS005 regression test + log-events example - uns-check.test.ts: assert clock.sequence() does NOT trigger UNS005 (free namespace with empty capability string must be skipped by the UNS005 check, not treated as an external contract-required call) - examples/node-app/src/log-events.bs: end-to-end usage showing clock.sequence() for monotonic event ordering without the time capability Co-Authored-By: Claude Sonnet 4.6 --- examples/node-app/src/log-events.bs | 15 +++++++++++++++ packages/compiler/tests/uns-check.test.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 examples/node-app/src/log-events.bs diff --git a/examples/node-app/src/log-events.bs b/examples/node-app/src/log-events.bs new file mode 100644 index 00000000..dec2e225 --- /dev/null +++ b/examples/node-app/src/log-events.bs @@ -0,0 +1,15 @@ +?bs 0.1 + +import { clock, stdout } from "@mbfarias/botscript-runtime"; + +// clock.sequence() provides monotonic ordering without the `time` capability. +// Unlike time.now(), it carries no wallclock information — just a strictly +// increasing counter that lets you verify A-before-B ordering in logs and +// event sequences. + +fn logEvent(label: string) uses { stdout } -> void { + const seq = clock.sequence(); + stdout.println(`[${seq}] ${label}`); +} + +export { logEvent }; diff --git a/packages/compiler/tests/uns-check.test.ts b/packages/compiler/tests/uns-check.test.ts index b51c036c..ef7b2bb1 100644 --- a/packages/compiler/tests/uns-check.test.ts +++ b/packages/compiler/tests/uns-check.test.ts @@ -436,3 +436,21 @@ describe("UNS005: scan scope (body only)", () => { expect(() => compile(src)).toThrow("UNS005"); }); }); + +// --------------------------------------------------------------------------- +// Free namespaces (clock: "" — no capability required) +// --------------------------------------------------------------------------- + +describe("UNS005: free namespace (clock)", () => { + it("does not fire for clock.sequence() — free namespace needs no capability or result contract", () => { + // clock is registered with an empty capability ("") so CAP001/CAP002 never fire for it. + // UNS005 must likewise skip free namespaces — clock.sequence() is process-local and + // has no external contract to enforce. + const src = + "?bs 0.9\n" + + "fn stamp() -> any {\n" + + " clock.sequence()\n" + + "}\n"; + expect(() => compile(src)).not.toThrow(); + }); +}); From a31aab5a042fa17fdbf470c382a85b68062da326 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 15:30:41 -0300 Subject: [PATCH 6/8] fix(intent-check): only flag clock.sequence() invocations, not bare member reads findFirstStatefulFreeUse was returning a violation when it saw clock. even without a call (e.g. const f = clock.sequence). Fix requires the token after the member ident to be an open paren (or optional-call questionDot), matching the call-detection pattern used throughout syn-check.ts. Also adds INT002/INT004 regression tests for the non-invocation case. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/intent-check.ts | 12 +++++++++++- packages/compiler/tests/intent.test.ts | 10 ++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/intent-check.ts b/packages/compiler/src/passes/intent-check.ts index 85c9927e..028bef83 100644 --- a/packages/compiler/src/passes/intent-check.ts +++ b/packages/compiler/src/passes/intent-check.ts @@ -424,7 +424,17 @@ function findFirstStatefulFreeUse( const isDot = next?.kind === "punct" && next.text === "."; const isOptChain = next?.kind === "questionDot"; if (!isDot && !isOptChain) continue; - const member = nextIdent(tokens, j) ?? "…"; + const memberIdx = nextSignificant(tokens, j + 1); + const memberTok = tokens[memberIdx]; + if (!memberTok || memberTok.kind !== "ident") continue; + const member = memberTok.text; + // Only flag actual invocations (clock.sequence()), not bare member reads (clock.sequence). + const afterMemberIdx = nextSignificant(tokens, memberIdx + 1); + const afterMember = tokens[afterMemberIdx]; + const isCall = + (afterMember?.kind === "open" && afterMember.text === "(") || + afterMember?.kind === "questionDot"; // clock.sequence?.() + if (!isCall) continue; return { namespace: canonical, member, accessOp: isDot ? "." : "?." }; } return null; diff --git a/packages/compiler/tests/intent.test.ts b/packages/compiler/tests/intent.test.ts index 54b852f7..49f179e4 100644 --- a/packages/compiler/tests/intent.test.ts +++ b/packages/compiler/tests/intent.test.ts @@ -350,6 +350,11 @@ describe("INT002 — pure intent vs clock.sequence() (stateful-free namespace)", const src = `?bs 0.7\nfn noop(clock: number) intent: "pure" -> number = clock\n`; expect(() => t(src)).not.toThrow(/INT002/); }); + + it("does NOT fire INT002 for clock.sequence member read without invocation", () => { + const src = `?bs 0.7\nfn getRef() intent: "pure" -> any = clock.sequence\n`; + expect(() => t(src)).not.toThrow(/INT002/); + }); }); // --------------------------------------------------------------------------- @@ -547,6 +552,11 @@ describe("INT004 — idempotent intent vs clock.sequence() (stateful-free namesp const src = `?bs 0.7\nfn step() intent: "writes" -> number = clock.sequence()\n`; expect(() => t(src)).not.toThrow(/INT004/); }); + + it("does NOT fire INT004 for clock.sequence member read without invocation", () => { + const src = `?bs 0.7\nfn getRef() intent: "idempotent" -> any = clock.sequence\n`; + expect(() => t(src)).not.toThrow(/INT004/); + }); }); // --------------------------------------------------------------------------- From f0e6de9a686d64c44520cb50063ebff530890c9e Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 19:33:14 -0300 Subject: [PATCH 7/8] fix(intent-check): questionDot on member access is not a call (clock.sequence?.length false-positive) findFirstStatefulFreeUse was treating any `questionDot` token after the member name as evidence of an optional call. `?.` is also used for optional property access (e.g. clock.sequence?.length), which should NOT count as an invocation and must not trigger INT002/INT004. Fix: require the token after `questionDot` to be `(` before treating it as an optional call, matching how findFirstCapabilityUse handles the same case. Add two regression tests. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/intent-check.ts | 6 +++++- packages/compiler/tests/intent.test.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/intent-check.ts b/packages/compiler/src/passes/intent-check.ts index 028bef83..63a68b34 100644 --- a/packages/compiler/src/passes/intent-check.ts +++ b/packages/compiler/src/passes/intent-check.ts @@ -431,9 +431,13 @@ function findFirstStatefulFreeUse( // Only flag actual invocations (clock.sequence()), not bare member reads (clock.sequence). const afterMemberIdx = nextSignificant(tokens, memberIdx + 1); const afterMember = tokens[afterMemberIdx]; + // questionDot alone is not enough — clock.sequence?.length uses questionDot for property access. + // Require the token after questionDot to be `(` to confirm it's an optional call. const isCall = (afterMember?.kind === "open" && afterMember.text === "(") || - afterMember?.kind === "questionDot"; // clock.sequence?.() + (afterMember?.kind === "questionDot" && + tokens[nextSignificant(tokens, afterMemberIdx + 1)]?.kind === "open" && + tokens[nextSignificant(tokens, afterMemberIdx + 1)]?.text === "("); // clock.sequence?.() if (!isCall) continue; return { namespace: canonical, member, accessOp: isDot ? "." : "?." }; } diff --git a/packages/compiler/tests/intent.test.ts b/packages/compiler/tests/intent.test.ts index 49f179e4..dedbf532 100644 --- a/packages/compiler/tests/intent.test.ts +++ b/packages/compiler/tests/intent.test.ts @@ -355,6 +355,11 @@ describe("INT002 — pure intent vs clock.sequence() (stateful-free namespace)", const src = `?bs 0.7\nfn getRef() intent: "pure" -> any = clock.sequence\n`; expect(() => t(src)).not.toThrow(/INT002/); }); + + it("does NOT fire INT002 for clock.sequence?.length (optional-chain property, not a call)", () => { + const src = `?bs 0.7\nfn getLen() intent: "pure" -> any = clock.sequence?.length\n`; + expect(() => t(src)).not.toThrow(/INT002/); + }); }); // --------------------------------------------------------------------------- @@ -557,6 +562,11 @@ describe("INT004 — idempotent intent vs clock.sequence() (stateful-free namesp const src = `?bs 0.7\nfn getRef() intent: "idempotent" -> any = clock.sequence\n`; expect(() => t(src)).not.toThrow(/INT004/); }); + + it("does NOT fire INT004 for clock.sequence?.length (optional-chain property, not a call)", () => { + const src = `?bs 0.7\nfn getLen() intent: "idempotent" -> any = clock.sequence?.length\n`; + expect(() => t(src)).not.toThrow(/INT004/); + }); }); // --------------------------------------------------------------------------- From 0a797dd1ab8452dd8694733013deec4ac9b1bbf7 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 23:32:47 -0300 Subject: [PATCH 8/8] =?UTF-8?q?docs(intent-check):=20address=20Copilot=20r?= =?UTF-8?q?eview=20=E2=80=94=20INT002/INT004=20cover=20stateful-free=20nam?= =?UTF-8?q?espaces,=20primer=20lists=20clock.sequence,=20diagnostic=20uses?= =?UTF-8?q?=20source=20ident?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findFirstStatefulFreeUse: return tok.text instead of canonical so diagnostics show the identifier the user wrote (e.g. alias `c` not `clock`) - error-codes.ts INT002/INT004: expand title, rule, idiom, rewrite, and example to mention stateful-free namespaces (clock.sequence) alongside stdlib capabilities - mcp/explanations.ts INT002/INT004: same expansion so botscript explain INT002/INT004 output is accurate for clock.sequence triggers - primer.ts: add clock.sequence() -> MonotonicTimestamp to STDLIB CALLS section with no-capability / stateful note Co-Authored-By: Botkowski --- packages/compiler/src/error-codes.ts | 47 ++++++++++++++------ packages/compiler/src/passes/intent-check.ts | 4 +- packages/compiler/src/primer.ts | 1 + packages/mcp/src/explanations.ts | 35 +++++++++++---- 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 1bd18e33..6780f915 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -280,17 +280,21 @@ const E: Record = { }, INT002: { code: "INT002", - title: "intent declares 'pure' but function body uses a capability", + title: "intent declares 'pure' but function body uses a capability or stateful-free namespace", rule: "a function declaring intent: \"pure\" must not directly reference any stdlib capability " + - "in its body — the pure claim means deterministic and side-effect-free", + "in its body — the pure claim means deterministic and side-effect-free; " + + "this also covers stateful-free namespaces like clock.sequence() that require no capability declaration " + + "but are non-deterministic (each call returns a different value)", idiom: - "move the capability usage out of the pure fn, or change the intent to reflect the actual behaviour", + "move the capability or stateful-free call out of the pure fn, or change the intent to reflect the actual behaviour", rewrite: - "// option A — remove the capability call from the body:\n" + + "// option A — remove the non-pure call from the body:\n" + "fn name(args) intent: \"pure\" -> type = pure { ... }\n\n" + - "// option B — remove the pure intent claim:\n" + - "fn name(args) uses { cap } -> type = ...", + "// option B (capability) — remove the pure intent claim and declare the capability:\n" + + "fn name(args) uses { cap } -> type = ...\n\n" + + "// option B (stateful-free, e.g. clock.sequence) — remove the pure intent claim:\n" + + "fn name(args) -> type = ...", example: "// before — fn says pure but body calls http.get\n" + "?bs 0.7\n" + @@ -301,7 +305,14 @@ const E: Record = { "?bs 0.7\n" + "fn fetchUser(id: string) uses { net } -> string {\n" + " return http.get(\"/users/\" + id);\n" + - "}", + "}\n\n" + + "// also fires for stateful-free namespaces (no uses {} required but non-deterministic):\n" + + "// before\n" + + "?bs 0.7\n" + + "fn tag() intent: \"pure\" -> number = clock.sequence()\n" + + "// after — remove the pure claim\n" + + "?bs 0.7\n" + + "fn tag() -> number = clock.sequence()", }, INT003: { code: "INT003", @@ -328,25 +339,35 @@ const E: Record = { }, INT004: { code: "INT004", - title: "intent declares 'idempotent' but function body directly calls a non-idempotent capability", + title: "intent declares 'idempotent' but function body directly calls a non-idempotent capability or stateful-free namespace", rule: "a function declaring intent: \"idempotent\" must not directly reference `random` or `time` in its body — " + - "these stdlib namespaces produce different values on each invocation, making any function that uses them non-idempotent", + "these stdlib namespaces produce different values on each invocation, making any function that uses them non-idempotent; " + + "this also covers stateful-free namespaces like clock.sequence() that require no capability declaration " + + "but produce a different value on each call", idiom: "move the non-idempotent call out of the idempotent fn, or change the intent to reflect the actual behaviour", rewrite: "// option A — remove the non-idempotent call from the body:\n" + "fn name(args) intent: \"idempotent\" -> type = ...\n\n" + - "// option B — declare the capability and remove the idempotent intent claim\n" + - "// (preserve any other existing capabilities alongside the non-idempotent one):\n" + - "fn name(args) uses { …other-caps, random } -> type = ... // or `uses { …other-caps, time }`", + "// option B (capability) — declare the capability and remove the idempotent intent claim:\n" + + "fn name(args) uses { …other-caps, random } -> type = ... // or `uses { …other-caps, time }`\n\n" + + "// option B (stateful-free, e.g. clock.sequence) — remove the idempotent intent claim:\n" + + "fn name(args) -> type = ...", example: "// before — fn claims idempotent but body calls random.next; INT004 fires\n" + "?bs 0.7\n" + "fn generateId(prefix: string) intent: \"idempotent\" -> string = prefix + random.next()\n\n" + "// after — remove the idempotent claim and declare the capability\n" + "?bs 0.7\n" + - "fn generateId(prefix: string) uses { random } -> string = prefix + random.next()", + "fn generateId(prefix: string) uses { random } -> string = prefix + random.next()\n\n" + + "// also fires for stateful-free namespaces (no uses {} required but non-deterministic):\n" + + "// before\n" + + "?bs 0.7\n" + + "fn tag() intent: \"idempotent\" -> number = clock.sequence()\n" + + "// after — remove the idempotent claim\n" + + "?bs 0.7\n" + + "fn tag() -> number = clock.sequence()", }, INT005: { code: "INT005", diff --git a/packages/compiler/src/passes/intent-check.ts b/packages/compiler/src/passes/intent-check.ts index 63a68b34..2ecc5a89 100644 --- a/packages/compiler/src/passes/intent-check.ts +++ b/packages/compiler/src/passes/intent-check.ts @@ -439,7 +439,9 @@ function findFirstStatefulFreeUse( tokens[nextSignificant(tokens, afterMemberIdx + 1)]?.kind === "open" && tokens[nextSignificant(tokens, afterMemberIdx + 1)]?.text === "("); // clock.sequence?.() if (!isCall) continue; - return { namespace: canonical, member, accessOp: isDot ? "." : "?." }; + // Use the source token text (not canonical) so diagnostics reference + // the identifier the user actually wrote (e.g. alias `c` not `clock`). + return { namespace: tok.text, member, accessOp: isDot ? "." : "?." }; } return null; } diff --git a/packages/compiler/src/primer.ts b/packages/compiler/src/primer.ts index 1474da92..421ad52d 100644 --- a/packages/compiler/src/primer.ts +++ b/packages/compiler/src/primer.ts @@ -149,6 +149,7 @@ additions below are the entire language surface. http.post(url) -> Promise> requires uses { net } time.now() / time.iso() requires uses { time } random.next() / random.int(a, b) requires uses { random } + clock.sequence() -> MonotonicTimestamp no capability required; stateful (non-deterministic) — violates intent: "pure"/"idempotent" // import { fs } from "@mbfarias/botscript-runtime/fs"; (Node only) fs.exists(path) requires uses { fs } fs.readText(path) -> Result requires uses { fs } diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index e6f5a6a0..a0755a72 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -339,13 +339,18 @@ export const EXPLANATIONS: Readonly> = { }, INT002: { code: "INT002", - title: "intent declares 'pure' but function body uses a capability", + title: "intent declares 'pure' but function body uses a capability or stateful-free namespace", body: "INT002 is the body-level complement to INT001. INT001 catches the case where " + "`intent: \"pure\"` and a non-empty `uses { ... }` clause are both declared in the " + "header (an obvious contradiction). INT002 catches the more subtle case: the header " + "looks fine (empty or absent `uses {}`), but the function body directly references a " + "stdlib capability namespace like `http`, `time`, `random`, `fs`, `stdout`, or `stderr`.\n\n" + + "INT002 also fires for stateful-free namespaces — namespaces that require no `uses {}` " + + "declaration but are non-deterministic. The primary example is `clock.sequence()`: it " + + "returns a strictly-increasing monotonic counter, so each call produces a different value. " + + "A `pure` function must be deterministic, so `clock.sequence()` violates the claim even " + + "though it needs no capability.\n\n" + "This matters because INT001 is a header-level consistency check — it compares clauses " + "to each other, but does not look at the body. A function that declares " + "`intent: \"pure\"` and no capabilities can still lie: the body can call `http.get()` " + @@ -361,14 +366,19 @@ export const EXPLANATIONS: Readonly> = { fails: "?bs 0.7\n" + "// drainSecrets claims pure but directly calls http.get\n" + - "fn drainSecrets(id: string) intent: \"pure\" -> string = http.get(\"/s/\" + id)\n", + "fn drainSecrets(id: string) intent: \"pure\" -> string = http.get(\"/s/\" + id)\n\n" + + "// also fails — clock.sequence() is stateful (non-deterministic)\n" + + "fn tag() intent: \"pure\" -> number = clock.sequence()\n", passes: "// option A — remove the capability call (make it truly pure)\n" + "?bs 0.7\n" + "fn formatId(id: string) intent: \"pure\" -> string = pure { \"user-\" + id }\n\n" + "// option B — remove the pure claim and declare the capability\n" + "?bs 0.7\n" + - "fn drainSecrets(id: string) uses { net } -> string = http.get(\"/s/\" + id)\n", + "fn drainSecrets(id: string) uses { net } -> string = http.get(\"/s/\" + id)\n\n" + + "// option B (stateful-free) — remove the pure claim\n" + + "?bs 0.7\n" + + "fn tag() -> number = clock.sequence()\n", }, }, INT003: { @@ -405,31 +415,40 @@ export const EXPLANATIONS: Readonly> = { }, INT004: { code: "INT004", - title: "intent declares 'idempotent' but function body directly calls a non-idempotent capability", + title: "intent declares 'idempotent' but function body directly calls a non-idempotent capability or stateful-free namespace", body: "INT004 is the body-level complement to INT003. INT003 catches the case where " + "`intent: \"idempotent\"` and `uses { random }` or `uses { time }` are both present in " + "the header (an obvious contradiction). INT004 catches the subtler case: the header looks " + "fine (no `random` or `time` in `uses {}`), but the function body directly references " + "`random.*` or `time.*` without declaring them.\n\n" + + "INT004 also fires for stateful-free namespaces — namespaces that require no `uses {}` " + + "declaration but are non-deterministic. The primary example is `clock.sequence()`: it " + + "returns a strictly-increasing monotonic counter (different value on every call), which " + + "violates the idempotency contract even though no capability declaration is needed.\n\n" + "This matters because INT003 is a header-level check — it compares clauses to each other " + "but does not look at the body. A function can declare `intent: \"idempotent\"` and an empty " + "`uses {}` clause while still calling `random.next()` directly in the body. INT004 closes " + "that gap. INT003 takes priority: if both header and body are inconsistent, only INT003 fires.\n\n" + - "The check scans the fn body's own token range for direct `random.*` or `time.*` references, " + - "excluding nested `fn` declarations. It does not do transitive call-graph inference.\n\n" + + "The check scans the fn body's own token range for direct `random.*`, `time.*`, or stateful-free " + + "namespace references, excluding nested `fn` declarations. It does not do transitive call-graph inference.\n\n" + "INT004 is gated on `?bs 0.7` (same gate as INT003). Files on earlier pins are not checked.", example: { fails: "?bs 0.7\n" + - "fn generateId(prefix: string) intent: \"idempotent\" -> string = prefix + random.next()\n", + "fn generateId(prefix: string) intent: \"idempotent\" -> string = prefix + random.next()\n\n" + + "// also fails — clock.sequence() is non-deterministic\n" + + "fn tag() intent: \"idempotent\" -> number = clock.sequence()\n", passes: "// option A — remove the non-idempotent call (make it truly idempotent)\n" + "?bs 0.7\n" + "fn generateId(prefix: string, suffix: string) intent: \"idempotent\" -> string = prefix + suffix\n\n" + "// option B — remove the idempotent claim and declare the capability\n" + "?bs 0.7\n" + - "fn generateId(prefix: string) uses { random } -> string = prefix + random.next()\n", + "fn generateId(prefix: string) uses { random } -> string = prefix + random.next()\n\n" + + "// option B (stateful-free) — remove the idempotent claim\n" + + "?bs 0.7\n" + + "fn tag() -> number = clock.sequence()\n", }, }, INT005: {