Skip to content
15 changes: 15 additions & 0 deletions examples/node-app/src/log-events.bs
Original file line number Diff line number Diff line change
@@ -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 };
13 changes: 13 additions & 0 deletions packages/compiler/src/passes/_stdlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,17 @@ export const STDLIB_TO_CAP: Readonly<Record<string, string>> = {
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: "",
Comment on lines +14 to +18
Comment on lines +14 to +18
Comment on lines +14 to +18
Comment on lines +14 to +18
};

/**
* 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<string> = new Set(["clock"]);
3 changes: 2 additions & 1 deletion packages/compiler/src/passes/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const STDLIB_VALUE_SYMBOLS = [
"unwrapOption",
"unwrapOr",
// Effects
"clock",
"http",
"random",
"stderr",
Expand All @@ -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
Expand Down
118 changes: 117 additions & 1 deletion packages/compiler/src/passes/intent-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Comment on lines +201 to +205
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}(...) -> ...`,
});
}
}

Expand Down Expand Up @@ -325,7 +356,92 @@ 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<string, string> = 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;
return { namespace: canonical, member, accessOp: isDot ? "." : "?." };
}
Comment on lines +400 to +443
return null;
}

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/compiler/src/passes/uns-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
Comment on lines +44 to +46

export function passUnsCheck(src: string, version: VersionInfo): string {
if (!atLeast(version.resolved, "0.9")) return src;
Expand Down
31 changes: 31 additions & 0 deletions packages/compiler/tests/cap-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
85 changes: 85 additions & 0 deletions packages/compiler/tests/intent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
18 changes: 18 additions & 0 deletions packages/compiler/tests/uns-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
25 changes: 25 additions & 0 deletions packages/runtime/src/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Comment on lines +85 to +89
/**
* 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;
},
};
Comment on lines +85 to +101

// 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();
Expand Down
Loading
Loading