Skip to content

feat: declarative reads {} / writes {} annotations (?bs 0.8)#47

Open
marcelofarias wants to merge 13 commits into
mainfrom
botkowski/reads-writes-annotations
Open

feat: declarative reads {} / writes {} annotations (?bs 0.8)#47
marcelofarias wants to merge 13 commits into
mainfrom
botkowski/reads-writes-annotations

Conversation

@marcelofarias
Copy link
Copy Markdown
Owner

@marcelofarias marcelofarias commented May 13, 2026

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.7 and the intent: 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

  • Metadata-only in 0.8: both clauses are parsed, stored on FnDecl, and stripped from TypeScript output. No transitivity enforcement yet — same approach as intent: in its first slice.
  • Any-order parsing: reads {}, writes {}, and intent: are accepted in any order between uses {} and ->.
  • Duplicate detection: duplicate reads {}, writes {}, or intent: annotations are treated as parse errors (null return from parseFn), matching consistent behavior across all header clauses.
  • INT001 extended: intent: "pure" now fires INT001 when the function also declares reads {} or writes {} — a function that reads from cache or writes to metrics is not pure. Previously only uses {} triggered this check.
  • Forward-compat: parseFn is not version-gated, so earlier pins that happen to write reads {} don't crash.

What comes next (per issue #14)

Transitivity enforcement: if fn A calls fn B which declares reads { db }, then fn A must also declare reads { 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.writes fields; parsed in a loop after uses {}; duplicate detection for reads/writes/intent
  • passes/intent-check.ts: extend INT001 to also fire on reads/writes + pure intent conflict
  • passes/version.ts: add '0.8' to SUPPORTED_VERSIONS
  • error-codes.ts: update INT001 title/rule/idiom/example to cover reads/writes
  • primer.ts: document reads {} / writes {} in the FUNCTIONS section
  • tests/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 conflict
  • examples/node-app/src/user-cache.bs: end-to-end demo at ?bs 0.8

All 460 tests pass. pnpm -r build && pnpm test

Partial progress on #14

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / FnDecl to parse reads {} and writes {} (any order with existing uses {} / intent:) and exclude these clauses from TS output.
  • Add 0.8 to supported language versions and update the primer to document the new header clauses.
  • Add parse + transform tests for reads {} / writes {} and a ?bs 0.8 end-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 overwrite intent/intentStart with the last one. If the language intends a single intent declaration per function header, it would be safer to detect a second intent: 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.

Comment on lines +190 to +194
if (keyword === "reads") {
reads = items;
} else {
writes = items;
}
Comment thread packages/compiler/src/primer.ts Outdated
Comment on lines +36 to +45
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 ->.
Comment thread examples/node-app/src/user-cache.bs Outdated

fn writeCache(id: string, value: string) writes { cache } -> void { }

fn fetchFromDb(id: string) uses { net } reads { db } -> Promise<Result<string, string>> {
marcelofarias pushed a commit that referenced this pull request May 13, 2026
- 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.
@marcelofarias
Copy link
Copy Markdown
Owner Author

Addressed all three Copilot suggestions in 2766481:

  1. Duplicate reads/writes detectionparseFn now breaks out of the header loop (returning null) when a second reads {} or writes {} is encountered for the same keyword. Added two new tests that assert null is returned, confirming no silent overwrite.

  2. Primer formatting — split reads {} and writes {} into separate, correctly aligned description blocks. Each now has its own wrapped description line so neither reads as the other's explanation.

  3. fetchFromDb async — added async to the function declaration in user-cache.bs; the body was already using await and returning Promise<Result<string, string>>.

455 tests pass (pnpm -r build && pnpm test ✓).

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Comment on lines 57 to 63
// 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(", ");

Comment thread packages/compiler/src/error-codes.ts Outdated
"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 = ...",
Comment thread packages/compiler/src/primer.ts Outdated
Comment on lines +47 to +49
reads {}, writes {}, uses {},
and intent: may all coexist in
any order between args and ->.
marcelofarias pushed a commit that referenced this pull request May 13, 2026
- 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
@marcelofarias marcelofarias requested a review from Copilot May 13, 2026 15:38
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Comment on lines +44 to +50
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 ->.
Comment on lines 138 to +152
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` +
Comment on lines +22 to 23
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";
Comment on lines +11 to +14
fn lookupCache(id: string) reads { cache } -> string | undefined =
pure { undefined }

fn writeCache(id: string, value: string) writes { cache } -> void { }
marcelofarias pushed a commit that referenced this pull request May 13, 2026
- 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
@marcelofarias marcelofarias requested a review from Copilot May 13, 2026 19:43
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • Result does not expose an ok boolean property; the runtime type is { kind: "ok", value } | { kind: "err", error }. This guard will not narrow result, so result.value is unsafe/invalid and the example will fail type-checking. Check result.kind === "ok" or use isOk(result) before reading value.
  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)) {
Comment thread CHANGELOG.md Outdated
Comment on lines +22 to +23
- Files pinned to `?bs 0.7` (or earlier) compile to byte-identical output.
The new `reads { }` / `writes { }` parsing is gated on `?bs 0.8`.
Comment thread packages/compiler/src/primer.ts Outdated
Comment on lines +68 to +70
(uses {}) or read/write dependencies (reads {} / writes {}). Pure
functions may not consume external resources or have read/write
dependencies.
Comment thread examples/node-app/src/user-cache.bs Outdated

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");
Comment on lines +40 to +44
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
Comment thread CHANGELOG.md Outdated
### 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
Comment on lines +191 to +193
const close = open.matchedAt;
const items = parseCapList(tokens, i + 1, close);
if (keyword === "reads") {
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • passIntentCheck runs for ?bs 0.7+, so these reads/writes conflicts also make ?bs 0.7 files fail even though the new read/write syntax is documented as a ?bs 0.8 feature and the MCP explanation says this part of INT001 applies from 0.8. Gate the read/write portion on atLeast(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 by kind: "ok" | "err"; it does not expose a boolean .ok field. As written, this branch never narrows result correctly and result.value is not safe to access. Use the runtime's isOk helper or check result.kind === "ok" before writing to the cache.
  const result = await fetchFromDb(id);
  if (result.ok) writeCache(id, result.value);

Comment on lines 43 to 45
// Skip to end of this declaration to avoid re-parsing inner fns.
i = decl.tokenEnd - 1;

Comment thread CHANGELOG.md Outdated
Comment on lines +22 to +23
- 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}`);
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: parseFn returning null is 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 through transform() with a diagnostic or another user-visible validation path.
      // Duplicate intent: — treat as a parse error (stop parsing the header).
      if (intent !== undefined) break;

Comment thread CHANGELOG.md Outdated
Comment on lines +22 to +23
- Files pinned to `?bs 0.7` (or earlier) compile to byte-identical output.
The new `reads { }` / `writes { }` parsing is gated on `?bs 0.8`.
Comment on lines +179 to +181
// 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", () => {
Comment thread CHANGELOG.md Outdated
### 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
Comment thread packages/compiler/src/primer.ts Outdated
Comment on lines +43 to +47
("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.).
Marcelo Farias added 5 commits May 14, 2026 04:43
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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: break leaves the parser positioned on the second intent, causing parseFn to return null, but compiler callers treat null as “not a Botscript fn” and leave the declaration in the output. Users therefore don't get the intended parse error from transform(); 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;

Comment on lines +179 to +182
// 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;
Comment thread STDLIB.bs
// `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).
Comment thread CHANGELOG.md
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).
Comment on lines +191 to +197
const close = open.matchedAt;
const items = parseCapList(tokens, i + 1, close);
if (keyword === "reads") {
reads = items;
} else {
writes = items;
}
Marcelo Farias and others added 3 commits May 14, 2026 08:35
…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.
@marcelofarias marcelofarias force-pushed the botkowski/reads-writes-annotations branch from ed0f410 to 69eac1f Compare May 14, 2026 11:44
@marcelofarias marcelofarias changed the base branch from botkowski/intent-check to main May 14, 2026 11:44
@marcelofarias marcelofarias requested a review from Copilot May 14, 2026 11:44
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 only net-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 writes before reads; it never exercises intent: before reads/writes (or clauses after intent:), 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"]);

Comment on lines +142 to +147
"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",
Comment on lines +199 to +202
// 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;
Comment thread STDLIB.bs
// `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).
Comment thread CHANGELOG.md
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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Comment on lines +199 to +208
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
Comment on lines +191 to +201
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.
Comment on lines +214 to +223
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.
Comment on lines +191 to +193
if (!isDuplicate) {
const items = parseCapList(tokens, i + 1, close);
if (keyword === "reads") {
Comment on lines +176 to +208
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.

Comment on lines +523 to +526
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\" -> ...",
Comment on lines +176 to +178
SYN001: {
code: "SYN001",
title: "duplicate or invalid fn header clause",
Comment on lines +500 to +501
// 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)}`);
Comment on lines +225 to +227
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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 5 comments.

Comment on lines +501 to +502
// 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)}`);
Comment thread AGENTS.md Outdated
| 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. |
Comment thread CHANGELOG.md Outdated
Comment on lines +22 to +27
- `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");
Comment thread packages/compiler/src/primer.ts Outdated
Comment on lines +38 to +39
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants