feat: declarative reads {} / writes {} annotations (?bs 0.8)#47
feat: declarative reads {} / writes {} annotations (?bs 0.8)#47marcelofarias wants to merge 13 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds reads { ... } / writes { ... } declarative dependency annotations to the Botscript function header syntax (targeting ?bs 0.8), storing them on FnDecl as metadata and stripping them from emitted TypeScript. This is the next incremental step toward issue #14’s “total effect signatures” by making read/write dependencies visible at the declaration level before enforcing them.
Changes:
- Extend
parseFn/FnDeclto parsereads {}andwrites {}(any order with existinguses {}/intent:) and exclude these clauses from TS output. - Add
0.8to supported language versions and update the primer to document the new header clauses. - Add parse + transform tests for
reads {}/writes {}and a?bs 0.8end-to-end example.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/compiler/src/parser/parse-fn.ts | Parse and store reads/writes metadata in FnDecl with any-order header parsing. |
| packages/compiler/src/passes/version.ts | Register ?bs 0.8 as a supported version. |
| packages/compiler/src/primer.ts | Document reads {} / writes {} syntax in the language primer. |
| packages/compiler/tests/reads-writes.test.ts | Add test coverage for parsing and TS-stripping behavior, plus forward-compat parsing under earlier pins. |
| examples/node-app/src/user-cache.bs | Provide an example file demonstrating reads/writes usage in a small flow. |
Comments suppressed due to low confidence (1)
packages/compiler/src/parser/parse-fn.ts:213
- The new loop will also accept multiple
intent: "…"clauses and silently overwriteintent/intentStartwith the last one. If the language intends a single intent declaration per function header, it would be safer to detect a secondintent:and fail parsing (or emit a diagnostic) rather than compiling with last-wins semantics.
if (strTok?.kind === "string") {
// Strip the surrounding quotes to get the raw value.
intentStart = strTok.start;
const raw = strTok.text;
intent = raw.startsWith('"') || raw.startsWith("'")
? raw.slice(1, -1)
: raw;
i++;
i = skipTrivia(tokens, i);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (keyword === "reads") { | ||
| reads = items; | ||
| } else { | ||
| writes = items; | ||
| } |
| fn name(args) reads { a, b } -> ReturnType (0.8+) declare which resource | ||
| fn name(args) writes { a, b } -> ReturnType categories the function reads | ||
| from / writes to. Labels are | ||
| user-defined ("cache", "db", | ||
| "metrics", etc.). Metadata-only | ||
| in 0.8 — stripped from TS output, | ||
| not yet transitively enforced. | ||
| reads {}, writes {}, uses {}, and | ||
| intent: may all coexist in any | ||
| order between args and ->. |
|
|
||
| fn writeCache(id: string, value: string) writes { cache } -> void { } | ||
|
|
||
| fn fetchFromDb(id: string) uses { net } reads { db } -> Promise<Result<string, string>> { |
- detect duplicate reads/writes annotations and return null (parse error)
instead of silently overwriting the first occurrence
- add tests for duplicate reads {} / writes {} returning null
- fix primer.ts: give reads {} and writes {} separate, correctly aligned
description blocks so writes doesn't read as if it describes reads
- fix user-cache.bs example: mark fetchFromDb as async fn (body uses await)
455 tests pass.
|
Addressed all three Copilot suggestions in 2766481:
455 tests pass ( |
| // Build a human-readable list of the conflicting declarations. | ||
| const parts: string[] = []; | ||
| if (hasUses) parts.push(`uses { ${decl.capabilities.join(", ")} }`); | ||
| if (hasReads) parts.push(`reads { ${decl.reads!.join(", ")} }`); | ||
| if (hasWrites) parts.push(`writes { ${decl.writes!.join(", ")} }`); | ||
| const conflictsStr = parts.join(", "); | ||
|
|
| "fn name(args) intent: \"pure\" -> type = ...\n\n" + | ||
| "// option B — remove the intent claim:\n" + | ||
| "fn name(args) uses { caps } -> type = ...", | ||
| "fn name(args) uses { caps } reads { ... } -> type = ...", |
| reads {}, writes {}, uses {}, | ||
| and intent: may all coexist in | ||
| any order between args and ->. |
- intent-check: use space-joined conflictsStr in rewrite field so the
suggested fn header is valid botscript syntax (clauses are
space-separated, not comma-separated)
- error-codes: INT001 rewrite option B now includes writes { ... }
alongside reads { ... } since the rule covers both annotations
- primer: clarify that uses {} (if present) must precede reads/writes/
intent — "any order" was inaccurate; uses {} is parsed in a fixed
position before the annotation loop
| Both are metadata-only in 0.8 | ||
| — stripped from TS output, not | ||
| yet transitively enforced. | ||
| reads {}, writes {}, and intent: | ||
| may coexist with uses {} in any | ||
| order after uses {} (if present) | ||
| and before ->. |
| INT001: { | ||
| code: "INT001", | ||
| title: "intent declares 'pure' but function has capability declarations", | ||
| title: "intent declares 'pure' but function has capability or resource declarations", | ||
| rule: | ||
| "a function whose intent contains 'pure' must have no capability declarations — " + | ||
| "a function whose intent contains 'pure' must have no capability declarations (uses {}) " + | ||
| "and no read/write resource dependencies (reads {} / writes {}) — " + | ||
| "pure functions are deterministic, side-effect-free, and access no external resources", | ||
| idiom: | ||
| "remove uses { ... } from a pure function, or change the intent to reflect the actual behaviour", | ||
| "remove uses { ... }, reads { ... }, and writes { ... } from a pure function, " + | ||
| "or change the intent to reflect the actual behaviour", | ||
| rewrite: | ||
| "// option A — remove the uses clause:\n" + | ||
| "// option A — remove resource annotations:\n" + | ||
| "fn name(args) intent: \"pure\" -> type = ...\n\n" + | ||
| "// option B — remove the intent claim:\n" + | ||
| "fn name(args) uses { caps } -> type = ...", | ||
| "fn name(args) uses { caps } reads { ... } writes { ... } -> type = ...", |
| rewrite: | ||
| `// remove uses clause:\nfn ${decl.name}(...) intent: "pure" -> ...\n` + | ||
| `// or remove the pure intent claim:\nfn ${decl.name}(...) uses { ${capsStr} } -> ...`, | ||
| `// remove resource annotations:\nfn ${decl.name}(...) intent: "pure" -> ...\n` + |
| export const SUPPORTED_VERSIONS: ReadonlyArray<string> = ["0.1", "0.2", "0.3", "0.4", "0.5", "0.6", "0.7", "0.8"]; | ||
| export const LATEST_VERSION = "0.1"; |
| fn lookupCache(id: string) reads { cache } -> string | undefined = | ||
| pure { undefined } | ||
|
|
||
| fn writeCache(id: string, value: string) writes { cache } -> void { } |
- intent-check: file header no longer claims body-shape verification is
implemented today; rewrite text broadened from 'remove resource
annotations' to 'remove the conflicting header clauses (uses/reads/writes)'
so the suggested fix is unambiguous when the conflict is just uses {}
- primer: same softening for the intent: description; INT001 entry now
explicitly mentions reads/writes as triggers, not just uses {}
- mcp/explanations: INT001 explanation updated to cover reads/writes
- examples/user-cache.bs: add a comment that lookupCache/writeCache are
intentionally stubbed (annotations are metadata-only in 0.8)
- README: list 0.7 / 0.8 as opt-in pins
- CHANGELOG: backfill 0.7 (intent + INT001) and 0.8 (reads/writes + INT001
extension) entries
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (1)
examples/node-app/src/user-cache.bs:31
Resultdoes not expose anokboolean property; the runtime type is{ kind: "ok", value } | { kind: "err", error }. This guard will not narrowresult, soresult.valueis unsafe/invalid and the example will fail type-checking. Checkresult.kind === "ok"or useisOk(result)before readingvalue.
if (result.ok) writeCache(id, result.value);
| const hasUses = decl.capabilities.length > 0; | ||
| const hasReads = (decl.reads?.length ?? 0) > 0; | ||
| const hasWrites = (decl.writes?.length ?? 0) > 0; | ||
| if (containsPureClaim(decl.intent) && (hasUses || hasReads || hasWrites)) { |
| - Files pinned to `?bs 0.7` (or earlier) compile to byte-identical output. | ||
| The new `reads { }` / `writes { }` parsing is gated on `?bs 0.8`. |
| (uses {}) or read/write dependencies (reads {} / writes {}). Pure | ||
| functions may not consume external resources or have read/write | ||
| dependencies. |
|
|
||
| async fn fetchFromDb(id: string) uses { net } reads { db } -> Promise<Result<string, string>> { | ||
| const res = await http.get(`https://api.example.com/users/${id}`); | ||
| if (!res.ok) return err("not found"); |
| fn name(args) reads { a, b } -> ReturnType (0.8+) declare which resource | ||
| categories the function reads | ||
| from. Labels are user-defined | ||
| ("cache", "db", etc.). | ||
| fn name(args) writes { a, b } -> ReturnType (0.8+) declare which resource |
| ### Added | ||
| - **Declarative `reads { ... }` / `writes { ... }`** on fn headers — declare | ||
| which user-defined resource categories (e.g. `cache`, `db`, `metrics`) a | ||
| function reads from or writes to. Labels are user-defined strings, not |
| const close = open.matchedAt; | ||
| const items = parseCapList(tokens, i + 1, close); | ||
| if (keyword === "reads") { |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
packages/compiler/src/passes/intent-check.ts:55
passIntentCheckruns for?bs 0.7+, so thesereads/writesconflicts also make?bs 0.7files fail even though the new read/write syntax is documented as a?bs 0.8feature and the MCP explanation says this part of INT001 applies from 0.8. Gate the read/write portion onatLeast(version.resolved, "0.8")(or align the docs/tests) so the versioned behavior is unambiguous.
const hasUses = decl.capabilities.length > 0;
const hasReads = (decl.reads?.length ?? 0) > 0;
const hasWrites = (decl.writes?.length ?? 0) > 0;
if (containsPureClaim(decl.intent) && (hasUses || hasReads || hasWrites)) {
examples/node-app/src/user-cache.bs:31
Result<T, E>in the runtime is discriminated bykind: "ok" | "err"; it does not expose a boolean.okfield. As written, this branch never narrowsresultcorrectly andresult.valueis not safe to access. Use the runtime'sisOkhelper or checkresult.kind === "ok"before writing to the cache.
const result = await fetchFromDb(id);
if (result.ok) writeCache(id, result.value);
| // Skip to end of this declaration to avoid re-parsing inner fns. | ||
| i = decl.tokenEnd - 1; | ||
|
|
| - Files pinned to `?bs 0.7` (or earlier) compile to byte-identical output. | ||
| The new `reads { }` / `writes { }` parsing is gated on `?bs 0.8`. |
| fn writeCache(id: string, value: string) writes { cache } -> void { } | ||
|
|
||
| async fn fetchFromDb(id: string) uses { net } reads { db } -> Promise<Result<string, string>> { | ||
| const res = await http.get(`https://api.example.com/users/${id}`); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
packages/compiler/src/parser/parse-fn.ts:202
- This duplicate-
intent:path has the same user-facing gap as the duplicate read/write path:parseFnreturningnullis silently skipped by the transform pipeline, so duplicate intent clauses are not reported as a compiler parse error and can leave unlowered Botscript syntax in the emitted output. Please make duplicate header clauses fail throughtransform()with a diagnostic or another user-visible validation path.
// Duplicate intent: — treat as a parse error (stop parsing the header).
if (intent !== undefined) break;
| - Files pinned to `?bs 0.7` (or earlier) compile to byte-identical output. | ||
| The new `reads { }` / `writes { }` parsing is gated on `?bs 0.8`. |
| // Duplicate annotation — treat as a parse error (stop parsing the header). | ||
| if (keyword === "reads" && reads !== undefined) break; | ||
| if (keyword === "writes" && writes !== undefined) break; |
| expect(out).toContain("function sync"); | ||
| }); | ||
|
|
||
| it("reads/writes alongside uses and intent are all stripped", () => { |
| ### Added | ||
| - **Declarative `reads { ... }` / `writes { ... }`** on fn headers — declare | ||
| which user-defined resource categories (e.g. `cache`, `db`, `metrics`) a | ||
| function reads from or writes to. Labels are user-defined strings, not |
| ("cache", "db", etc.). | ||
| fn name(args) writes { a, b } -> ReturnType (0.8+) declare which resource | ||
| categories the function writes | ||
| to. Labels are user-defined | ||
| ("metrics", "db", etc.). |
Adds reads { } and writes { } clauses to fn declarations. Both are parsed
from the function header (between uses {} and ->) and stored on FnDecl.
Metadata-only in 0.8: stripped from the TypeScript output, not yet
transitively enforced.
The annotations let bots declare which resource categories a function
reads from or writes to (user-defined labels: cache, db, metrics, etc.).
This is the first slice of the effect-signature design in issue #14.
Enforcement — transitive closure checking similar to cap-check — lands
in a follow-up once the syntax is settled in use.
Changes:
- parser/parse-fn.ts: FnDecl.reads / FnDecl.writes fields; parsed in a
small loop after uses {} that accepts reads, writes, and intent in any
order before ->
- passes/version.ts: add '0.8' to SUPPORTED_VERSIONS
- primer.ts: document reads {} / writes {} in the FUNCTIONS section
- tests/reads-writes.test.ts: 13 tests covering parse and TS-strip
- examples/node-app/src/user-cache.bs: end-to-end demo at ?bs 0.8
All 453 tests pass. pnpm -r build && pnpm test OK.
- detect duplicate reads/writes annotations and return null (parse error)
instead of silently overwriting the first occurrence
- add tests for duplicate reads {} / writes {} returning null
- fix primer.ts: give reads {} and writes {} separate, correctly aligned
description blocks so writes doesn't read as if it describes reads
- fix user-cache.bs example: mark fetchFromDb as async fn (body uses await)
455 tests pass.
…tes {}
Previously INT001 only caught uses {} vs pure intent. A function that
declares reads { cache } or writes { metrics } is also not pure — it
depends on or mutates external resources. The check now covers all three
annotation types.
Adds 4 tests; updates the error-codes rule/idiom/example to reflect the
wider contract. 460 tests pass.
- intent-check: use space-joined conflictsStr in rewrite field so the
suggested fn header is valid botscript syntax (clauses are
space-separated, not comma-separated)
- error-codes: INT001 rewrite option B now includes writes { ... }
alongside reads { ... } since the rule covers both annotations
- primer: clarify that uses {} (if present) must precede reads/writes/
intent — "any order" was inaccurate; uses {} is parsed in a fixed
position before the annotation loop
- intent-check: file header no longer claims body-shape verification is
implemented today; rewrite text broadened from 'remove resource
annotations' to 'remove the conflicting header clauses (uses/reads/writes)'
so the suggested fix is unambiguous when the conflict is just uses {}
- primer: same softening for the intent: description; INT001 entry now
explicitly mentions reads/writes as triggers, not just uses {}
- mcp/explanations: INT001 explanation updated to cover reads/writes
- examples/user-cache.bs: add a comment that lookupCache/writeCache are
intentionally stubbed (annotations are metadata-only in 0.8)
- README: list 0.7 / 0.8 as opt-in pins
- CHANGELOG: backfill 0.7 (intent + INT001) and 0.8 (reads/writes + INT001
extension) entries
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
packages/compiler/src/parser/parse-fn.ts:203
- The duplicate
intent:path has the same problem as duplicate reads/writes:breakleaves the parser positioned on the secondintent, causingparseFnto returnnull, but compiler callers treatnullas “not a Botscript fn” and leave the declaration in the output. Users therefore don't get the intended parse error fromtransform(); they get unstripped Botscript syntax instead.
} else if (tok?.kind === "ident" && tok.text === "intent") {
// Duplicate intent: — treat as a parse error (stop parsing the header).
if (intent !== undefined) break;
const savedI = i;
| // Duplicate annotation — treat as a parse error (stop parsing the header). | ||
| if (keyword === "reads" && reads !== undefined) break; | ||
| if (keyword === "writes" && writes !== undefined) break; | ||
| const savedI = i; |
| // `reads { ... }` and `writes { ... }` declare which user-defined resource | ||
| // categories a function reads from or writes to. Labels are identifiers | ||
| // (e.g. cache, db, metrics) — not stdlib names. Metadata-only in 0.8; | ||
| // transitivity is enforced at ?bs 0.9 (DEP001 / DEP002). |
| function reads from or writes to. Labels are user-defined identifiers, | ||
| not tied to stdlib namespaces. Metadata-only in 0.8: parsed, stored on | ||
| `FnDecl`, and stripped from emitted TypeScript. Transitivity enforcement | ||
| lands at `?bs 0.9` (DEP001 / DEP002). |
| const close = open.matchedAt; | ||
| const items = parseCapList(tokens, i + 1, close); | ||
| if (keyword === "reads") { | ||
| reads = items; | ||
| } else { | ||
| writes = items; | ||
| } |
…, fix Result.ok in examples
…rites to STDLIB.bs
- CHANGELOG: "user-defined strings" → "user-defined identifiers" (labels
inside reads {} / writes {} are identifier tokens, not quoted strings)
- CHANGELOG: compat note now correctly describes forward-compatible parsing
(reads/writes parse at any version; only enforcement is gated on 0.8)
- primer.ts: remove quotes around label examples so readers don't try
reads { "cache" } (identifiers only, parseCapList silently drops strings)
- STDLIB.bs: bump to ?bs 0.8; add canonical reads {} / writes {} example
(AGENTS.md §108: every new form must appear in STDLIB.bs exactly once)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ll return parseFn previously returned null on duplicate reads/writes/intent clauses, causing passFn to skip the fn entirely and leave unlowered botscript syntax in the TypeScript output. First-wins semantics are strictly better: the fn is always lowered correctly, and the duplicate annotation is silently ignored.
ed0f410 to
69eac1f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (3)
packages/compiler/src/parser/parse-fn.ts:224
- Duplicate
intent:clauses are also silently accepted, so a later conflicting intent claim is ignored. For example,intent: "net-fetcher" intent: "pure"will be parsed as onlynet-fetcher, which lets an ambiguous header compile without the intended duplicate-clause error.
// Duplicate: skip (first-wins) rather than breaking to avoid leaving
// unlowered botscript syntax in the emitted TypeScript output.
i++;
packages/compiler/tests/reads-writes.test.ts:159
- These tests lock in silent first-wins behavior for duplicate clauses, but the PR describes duplicates as parse errors. As written, the test suite will pass even when duplicate annotations are dropped from the metadata rather than being rejected.
it("uses first-wins for duplicate reads {} (fn still lowers correctly)", () => {
// Duplicate reads {} is silently ignored (first-wins). parseFn returns a
// valid decl rather than null — returning null would leave the fn unlowered
// as raw botscript syntax in the TypeScript output, which is worse.
packages/compiler/tests/reads-writes.test.ts:79
- The any-order coverage only checks
writesbeforereads; it never exercisesintent:beforereads/writes(or clauses afterintent:), which is one of the new header-order permutations handled by the parser loop. Add a parse/transform case for that ordering so this advertised behavior cannot regress unnoticed.
it("parses writes before reads (any order)", () => {
const decl = parseFirstFn(
`fn process(x: string) writes { log } reads { store } -> void { }`,
);
expect(decl).not.toBeNull();
expect(decl!.reads).toEqual(["store"]);
expect(decl!.writes).toEqual(["log"]);
| "a function whose intent contains 'pure' must have no capability declarations (uses {}) " + | ||
| "and no read/write resource dependencies (reads {} / writes {}) — " + | ||
| "pure functions are deterministic, side-effect-free, and access no external resources", | ||
| idiom: | ||
| "remove uses { ... } from a pure function, or change the intent to reflect the actual behaviour", | ||
| "remove uses { ... }, reads { ... }, and writes { ... } from a pure function, " + | ||
| "or change the intent to reflect the actual behaviour", |
| // Duplicate: skip the block (first-wins) rather than breaking — a break | ||
| // would cause parseFn to return null, leaving unlowered botscript syntax | ||
| // in the emitted TypeScript output. | ||
| i = close + 1; |
| // `reads { ... }` and `writes { ... }` declare which user-defined resource | ||
| // categories a function reads from or writes to. Labels are identifiers | ||
| // (e.g. cache, db, metrics) — not stdlib names. Metadata-only in 0.8; | ||
| // transitivity is enforced at ?bs 0.9 (DEP001 / DEP002). |
| function reads from or writes to. Labels are user-defined identifiers, | ||
| not tied to stdlib namespaces. Metadata-only in 0.8: parsed, stored on | ||
| `FnDecl`, and stripped from emitted TypeScript. Transitivity enforcement | ||
| lands at `?bs 0.9` (DEP001 / DEP002). |
| * Tests for reads {} / writes {} declarative annotations (?bs 0.8+). | ||
| * | ||
| * reads and writes are metadata: parsed and stored on FnDecl, stripped | ||
| * from the TypeScript output. No enforcement in 0.8 — that comes later. |
| it("uses first-wins for duplicate intent: (fn still lowers correctly)", () => { | ||
| // Duplicate intent: is silently ignored (first-wins). parseFn returns a | ||
| // valid decl — returning null would leave the fn unlowered. | ||
| const tokens = lex( | ||
| `fn dup(id: string) intent: "pure" intent: "idempotent" -> string = id`, | ||
| ); | ||
| const fnIdx = tokens.findIndex((t) => t.kind === "keyword" && t.keyword === "fn"); | ||
| const decl = parseFn(tokens, fnIdx, { allowGenerics: true }); | ||
| expect(decl).not.toBeNull(); | ||
| expect(decl!.intent).toBe("pure"); // first-wins |
| if (!isDuplicate) { | ||
| const items = parseCapList(tokens, i + 1, close); | ||
| if (keyword === "reads") { | ||
| reads = items; | ||
| } else { | ||
| writes = items; | ||
| } | ||
| } | ||
| // Duplicate: skip the block (first-wins) rather than breaking — a break | ||
| // would cause parseFn to return null, leaving unlowered botscript syntax | ||
| // in the emitted TypeScript output. |
| if (!isDuplicateIntent) { | ||
| // Strip the surrounding quotes to get the raw value. | ||
| intentStart = strTok.start; | ||
| const raw = strTok.text; | ||
| intent = raw.startsWith('"') || raw.startsWith("'") | ||
| ? raw.slice(1, -1) | ||
| : raw; | ||
| } | ||
| // Duplicate: skip (first-wins) rather than breaking to avoid leaving | ||
| // unlowered botscript syntax in the emitted TypeScript output. |
| if (!isDuplicate) { | ||
| const items = parseCapList(tokens, i + 1, close); | ||
| if (keyword === "reads") { |
| it("uses first-wins for duplicate reads {} (fn still lowers correctly)", () => { | ||
| // Duplicate reads {} is silently ignored (first-wins). parseFn returns a | ||
| // valid decl rather than null — returning null would leave the fn unlowered | ||
| // as raw botscript syntax in the TypeScript output, which is worse. | ||
| const tokens = lex( | ||
| `fn dup(id: string) reads { cache } reads { db } -> string = id`, | ||
| ); | ||
| const fnIdx = tokens.findIndex((t) => t.kind === "keyword" && t.keyword === "fn"); | ||
| const decl = parseFn(tokens, fnIdx, { allowGenerics: true }); | ||
| expect(decl).not.toBeNull(); | ||
| expect(decl!.reads).toEqual(["cache"]); // first-wins | ||
| }); | ||
|
|
||
| it("uses first-wins for duplicate writes {} (fn still lowers correctly)", () => { | ||
| const tokens = lex( | ||
| `fn dup(id: string) writes { metrics } writes { audit } -> void { }`, | ||
| ); | ||
| const fnIdx = tokens.findIndex((t) => t.kind === "keyword" && t.keyword === "fn"); | ||
| const decl = parseFn(tokens, fnIdx, { allowGenerics: true }); | ||
| expect(decl).not.toBeNull(); | ||
| expect(decl!.writes).toEqual(["metrics"]); // first-wins | ||
| }); | ||
|
|
||
| it("uses first-wins for duplicate intent: (fn still lowers correctly)", () => { | ||
| // Duplicate intent: is silently ignored (first-wins). parseFn returns a | ||
| // valid decl — returning null would leave the fn unlowered. | ||
| const tokens = lex( | ||
| `fn dup(id: string) intent: "pure" intent: "idempotent" -> string = id`, | ||
| ); | ||
| const fnIdx = tokens.findIndex((t) => t.kind === "keyword" && t.keyword === "fn"); | ||
| const decl = parseFn(tokens, fnIdx, { allowGenerics: true }); | ||
| expect(decl).not.toBeNull(); | ||
| expect(decl!.intent).toBe("pure"); // first-wins |
- parseFn now throws SYN001 (BotscriptError) when src is provided and it
encounters a duplicate reads/writes/intent clause or an invalid label token
inside reads/writes (e.g. reads { "cache" })
- parseLabelList replaces parseCapList for reads/writes: accepts ident tokens
and comma separators, rejects everything else
- passFn and parseProgram pass src to parseFn so callers get user-visible errors
- Direct parseFn calls without src fall back to first-wins (backward compat)
- SYN001 registered in error-codes.ts and mcp/explanations.ts
- Tests updated to assert SYN001 throws; backward-compat test added
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| message, | ||
| rule: "each fn header clause (reads, writes, intent) may appear at most once; reads/writes labels must be plain identifiers", | ||
| idiom: "declare each clause once; use identifiers (not quoted strings) as resource labels", | ||
| rewrite: "fn name(...) reads { cache, db } writes { metrics } intent: \"pure\" -> ...", |
| SYN001: { | ||
| code: "SYN001", | ||
| title: "duplicate or invalid fn header clause", |
| // Non-identifier, non-separator token inside a reads/writes list. | ||
| throwSyn001(src, t, `invalid label in reads/writes list — labels must be plain identifiers (e.g. \`cache\`, \`db\`), not ${JSON.stringify(t.text)}`); |
| SYN001: { | ||
| code: "SYN001", | ||
| title: "duplicate or invalid fn header clause", |
- parse-fn.ts: SYN001 diagnostic now reads rule/idiom/rewrite from
getErrorCode("SYN001") instead of hard-coding strings (AGENTS.md
requires the central registry to be the source of truth).
- AGENTS.md: add diagnostic-code rows for INT001 and SYN001.
- README.md: add INT001, SYN001, FMT001 to the MCP `explain` row.
Addresses Copilot review on PR #47.
| // Non-identifier, non-separator token inside a reads/writes list. | ||
| throwSyn001(src, t, `invalid label in reads/writes list — labels must be plain identifiers (e.g. \`cache\`, \`db\`), not ${JSON.stringify(t.text)}`); |
| | FMT001 | (0.4+) Source is not in canonical form (RFC #13). Every program has exactly one canonical surface form; from `?bs 0.4` on, the compiler rejects whitespace / ordering variants rather than silently accepting them. The diagnostic points at the first UTF-16 code unit that differs from canonical. | `botscript fmt <file> --write`. | | ||
| | RES001 | (0.3+) `Result.try` / `Result.tryAsync` with no body. | `Result.try { <body that may throw> }`. | | ||
| | INT001 | (0.7+) A fn declares `intent: "pure"` but also has `uses { … }`. (0.8+) Also fires when `intent: "pure"` is combined with `reads { … }` or `writes { … }`. Pure functions may not declare capabilities or resource dependencies. | Either drop the conflicting header clause(s) or change the intent to reflect the actual behaviour. | | ||
| | SYN001 | (0.8+) Duplicate fn header clause (e.g. two `reads { }` on the same fn, or two `intent:`), or a label inside `reads {}` / `writes {}` that is not a plain identifier. | Declare each header clause once; merge label lists rather than repeating the clause; use bare identifiers (not quoted strings) as labels. | |
| - `reads { }` / `writes { }` parsing is forward-compatible: `parseFn` | ||
| accepts and strips the clauses at any version pin. What is gated on | ||
| `?bs 0.8` is enforcement — INT001 firing on reads/writes conflicts with | ||
| `intent: "pure"`. Files pinned to `?bs 0.7` (or earlier) that happen to | ||
| include `reads { }` / `writes { }` annotations still compile; the | ||
| clauses are stripped from the TypeScript output at all versions. |
| if (!atLeast(version.resolved, "0.7")) return src; | ||
|
|
||
| const allowGenerics = atLeast(version.resolved, "0.4"); | ||
| const checksReadsWrites = atLeast(version.resolved, "0.8"); |
| uses / reads / writes; the check | ||
| fires only when they conflict. |
…sts)
- primer.ts: qualify INT001 summary with per-version scope (uses-only
conflict fires from 0.7; reads/writes conflict from 0.8)
- intent.test.ts: add negative ?bs 0.7 cases proving checksReadsWrites
gate — reads/writes + pure intent must NOT fire INT001 before 0.8
- reads-writes.test.ts: add SYN001 tests for quoted/non-identifier
labels inside reads {} / writes {} (the invalid-label code path)
- AGENTS.md, CHANGELOG.md: clarify that SYN001 is syntax validation
and fires at any pin where the clause appears, not exclusively 0.8+
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Implements the first slice of issue #14 — declarative read/write dependency annotations on fn declarations.
Note: this PR targets
botkowski/intent-check(which adds?bs 0.7and theintent:parser changes). When PR #46 merges to main, this branch rebases cleanly.New syntax (
?bs 0.8)?bs 0.8 // Declare which resource categories a function reads from fn lookupCache(id: string) reads { cache } -> string | undefined = pure { undefined } // Declare which resource categories a function writes to fn recordMetric(id: string) writes { metrics } -> void { } // All annotations compose: uses {}, reads {}, writes {}, intent: coexist in any order async fn loadUser(id: string) uses { net } reads { cache, db } writes { cache } -> Promise<Result<string, string>> { const cached = lookupCache(id); if (cached !== undefined) return ok(cached); const result = await fetchFromDb(id); if (result.ok) writeCache(id, result.value); return result; }Labels are user-defined (
cache,db,metrics, etc.) — not tied to stdlib namespaces.What this PR ships
FnDecl, and stripped from TypeScript output. No transitivity enforcement yet — same approach asintent:in its first slice.reads {},writes {}, andintent:are accepted in any order betweenuses {}and->.reads {},writes {}, orintent:annotations are treated as parse errors (null return from parseFn), matching consistent behavior across all header clauses.intent: "pure"now fires INT001 when the function also declaresreads {}orwrites {}— a function that reads from cache or writes to metrics is not pure. Previously onlyuses {}triggered this check.reads {}don't crash.What comes next (per issue #14)
Transitivity enforcement: if
fn Acallsfn Bwhich declaresreads { db }, thenfn Amust also declarereads { db }. This is mechanically verifiable the same way CAP001 is — follow-up PR once the syntax stabilizes in use.Files changed
parser/parse-fn.ts:FnDecl.reads/FnDecl.writesfields; parsed in a loop afteruses {}; duplicate detection for reads/writes/intentpasses/intent-check.ts: extend INT001 to also fire on reads/writes + pure intent conflictpasses/version.ts: add'0.8'toSUPPORTED_VERSIONSerror-codes.ts: update INT001 title/rule/idiom/example to cover reads/writesprimer.ts: documentreads {}/writes {}in the FUNCTIONS sectiontests/reads-writes.test.ts: 16 tests (parse + TS-strip, duplicate detection, forward-compat)tests/intent.test.ts: +4 tests for INT001 firing on reads/writes + pure conflictexamples/node-app/src/user-cache.bs: end-to-end demo at?bs 0.8All 460 tests pass.
pnpm -r build && pnpm test✓Partial progress on #14