Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions STDLIB.bs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// feature you want is not in this file, it does not exist yet — propose it
// before using it. Keep this file *small*: every form here is load-bearing.

import { ok, err, some, none, http, time, type Option, type Result } from "@mbfarias/botscript-runtime";
import { ok, err, some, none, http, time, clock, type Option, type Result } from "@mbfarias/botscript-runtime";

// ─── 1. Tagged union (0.2+) ────────────────────────────────────────────────
// `type Name = Tag { fields } | Tag2 { fields } | …` desugars to a TS
Expand All @@ -31,6 +31,14 @@ fn now() uses { time } -> number = time.now()
fn lookupCache(id: string) reads { cache } -> Option<string> = none
fn writeCache(id: string, value: string) writes { cache } -> void { }

// ─── 2c. clock.sequence() — free monotonic counter (0.8+) ───────────────────
// `clock.sequence()` returns a monotonically increasing integer with no
// capability required. Use it for ordering, correlation IDs, and cache busting
// when wall-clock time is not needed. Unlike `time.now()`, it carries no
// external-dependency declaration and cannot be mocked via `with mocks {}`.

fn nextId() -> number = clock.sequence()

// ─── 3. fn pure shorthand ───────────────────────────────────────────────────
// `= pure { expr }` is equivalent to `uses { } { return expr; }`.

Expand Down Expand Up @@ -147,9 +155,7 @@ fn castShape(raw: unknown) -> Shape = unsafe "trust caller-validated payload" {
// fn. The reason is preserved as a `/* unsafe: "…" */` comment in the emitted
// TypeScript. Works with async: `unsafe "reason" async fn`.

unsafe "raw API response shape validated by schema" fn parseShape(raw: unknown) -> Shape {
return raw as Shape;
}
unsafe "raw API response shape validated by schema" fn parseShape(raw: unknown) -> Shape = raw as Shape

// ─── helpers used above ─────────────────────────────────────────────────────
// (These exist only to make the examples compile in isolation.)
Expand Down
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