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/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/_stdlib.ts b/packages/compiler/src/passes/_stdlib.ts index e68ba4a1..cb812e52 100644 --- a/packages/compiler/src/passes/_stdlib.ts +++ b/packages/compiler/src/passes/_stdlib.ts @@ -11,4 +11,17 @@ 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: "", }; + +/** + * 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/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/src/passes/intent-check.ts b/packages/compiler/src/passes/intent-check.ts index a1b92db7..2ecc5a89 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, declAliases, declBlockShadows); + 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,94 @@ 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, declAliases4, declBlockShadows4); + 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. + * 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, + ); + 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; + // 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 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]; + // 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" && + tokens[nextSignificant(tokens, afterMemberIdx + 1)]?.kind === "open" && + tokens[nextSignificant(tokens, afterMemberIdx + 1)]?.text === "("); // clock.sequence?.() + if (!isCall) continue; + // 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/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/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/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/compiler/tests/intent.test.ts b/packages/compiler/tests/intent.test.ts index 7d5e63bc..dedbf532 100644 --- a/packages/compiler/tests/intent.test.ts +++ b/packages/compiler/tests/intent.test.ts @@ -317,6 +317,51 @@ 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/); + }); + + 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/); + }); + + 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/); + }); +}); + // --------------------------------------------------------------------------- // INT003 — idempotent intent vs non-idempotent capability in uses {} // --------------------------------------------------------------------------- @@ -484,6 +529,46 @@ 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/); + }); + + 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/); + }); + + 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/); + }); +}); + // --------------------------------------------------------------------------- // INT001 extended: reads {} / writes {} also contradict "pure" intent // --------------------------------------------------------------------------- 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(); + }); +}); 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: { diff --git a/packages/runtime/src/effects.ts b/packages/runtime/src/effects.ts index c913e8b6..f8aab81d 100644 --- a/packages/runtime/src/effects.ts +++ b/packages/runtime/src/effects.ts @@ -75,6 +75,31 @@ 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 => { + 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 // app code they default to the real wallclock and Math.random. let timeSource: () => number = () => Date.now(); 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); + }); +});