Skip to content
Open
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 };
47 changes: 34 additions & 13 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,17 +280,21 @@ const E: Record<string, ErrorCodeEntry> = {
},
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" +
Expand All @@ -301,7 +305,14 @@ const E: Record<string, ErrorCodeEntry> = {
"?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",
Expand All @@ -328,25 +339,35 @@ const E: Record<string, ErrorCodeEntry> = {
},
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",
Expand Down
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"]);
5 changes: 4 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 All @@ -99,6 +100,8 @@ const STDLIB_TYPE_SYMBOLS = [
"None",
"Option",
"Some",
// clock.sequence() return type — free namespace, no capability required
"MonotonicTimestamp",
] as const;

/**
Expand Down
120 changes: 119 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,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<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;
// 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 ? "." : "?." };
}
Comment on lines +400 to +445
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
1 change: 1 addition & 0 deletions packages/compiler/src/primer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ additions below are the entire language surface.
http.post(url) -> Promise<Result<Response, Error>> 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 }
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();
});
});
Loading
Loading