feat(cli): Rust candy CLI scaffold + candy lint + npm wrapper#42
Merged
Conversation
- ci.yml: fmt check, clippy -D warnings, cargo test, candy lint examples/ - release.yml: 5-platform matrix (linux-x86_64, linux-aarch64 via cross, macos-x86_64, macos-aarch64, windows-x86_64), SHA256 checksums, softprops/action-gh-release upload
…ispatch Single crate at cli/candy; deps: clap (derive), anyhow, thiserror, walkdir, serde/serde_json. gen/test/fmt subcommands are stubs that exit 1 with a "see issue #N" message.
Produces AST sufficient for all 10 v0.1 rules. Handles:
- All block kinds (prose, actor, external actor, flow, controller,
policy, event, type, enum, invariant, target, schedule)
- Multi-line flow params with nested generics (e.g. Result<{...}, E>)
- Triple-quoted strings consumed verbatim
- Inline event payload syntax (single-line {payload: {...}})
- Schedule declarations with indented continuation lines
- Multi-op uses: lines (feature X for OpA, OpB)
- Cross-file feature name derivation from file path
mod.rs: file collection (single-file vs project mode), Project::load, rule dispatch, exit code derivation. output.rs: Violation struct with serde, human printer (stderr), NDJSON printer (stdout with --json), exit_code() → 0/1/2.
Rules and severity: - prose-required-intent (error): prose block must have non-empty intent: - broken-cross-feature-ref (error): uses: feature X for Op must resolve - broken-symbol-ref (error): type/policy refs must resolve to declarations - money-no-floats (error): float forbidden in type bodies - idempotency-key (warning): flows calling externals need key: Key param - schedule-syntax-valid (error): must have every/at and for any clause - policy-attachment-resolves (error): policies: list must resolve - event-payload-types-resolve (error): payload field types must resolve - actor-state-defaults-typed (warning): default must match declared type - underscores-in-keywords (error): block/message names must not use _ Cross-file rules run in project mode only (directory lint).
18 tests: pass corpus (auth, todo, wallet, hello, all examples), one test per rule firing on its negative fixture, exit code tests (0/1/2), and NDJSON format test. One negative fixture per rule under tests/fixtures/fail/<rule-name>/.
Follows esbuild/swc pattern: postinstall.js fetches the versioned platform binary from GitHub releases, verifies SHA256 against sha256sums.txt, and installs it at bin/candy-bin (or candy.exe). bin/candy is a node shim using execFileSync. Supports 5 platforms: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64. Graceful degradation if download fails.
README.md: build instructions, lint subcommand usage, rule table with severities, project structure, guide for adding a rule. HANDOFF.md: invented semantics, parser under-strictness, TODOs, skipped platforms/features, surprises found in examples/*.
`read_type_header` called `skip_whitespace` inside its loop. That
helper consumes newlines, so a bodyless declaration like
type PasswordHash opaque
type Token opaque { max: 256 }
silently absorbed the next line into the first declaration's header.
The cursor jumped past `type Token`, the outer parser never saw it
as a separate keyword, and Token was never indexed. The hardcoded
built-in name table masked the symptom — the rule
`event-payload-types-resolve` resolved Token via that fallback rather
than via project declarations.
Switch the helper inside `read_type_header` to
`skip_whitespace_no_newline` so newline keeps its terminator role.
Existing tests still pass (the hardcode kept them green); a follow-up
will narrow the hardcode and rely on the now-correct project parse.
Two named types are universally built into candy: `Id` and
`Timestamp`. Their canonical shapes (`opaque { max: 64 }`,
`instant { tz: utc }`) never vary across projects in the example
tier, and projects already use them without declaration in actor
headers (`actor Foo(id: Id)`) and `now` parameters
(`now: Timestamp`). Document them.
Everything else the examples share — `Money`, `Email`, `Password`,
`PasswordHash`, `Token`, `Key`, `Role`, `CouponCode` — stays
project-declared. Their shapes carry project-specific freedom:
currency for `Money`, format for `Email`, length caps for opaque
tokens, policy attachments on `Password`. Declaring them per
project preserves that freedom without forcing a global default.
Soften the §Hard rules wording for idempotency keys: the parameter
type is conventionally `Key` but is not built-in. Projects may use
a different identifier (e.g. `IdempotencyKey`) without changing
the rule. Same softening in the §Cross-cutting Idempotency line.
Match the grammar formalization (GRAMMAR.md §type "Built-in named types"). The previous table treated `Key`, `Money`, `Email`, `Password`, `Token`, `Role` as universally built-in; in practice every shipped example declares them as project types with project-specific constraints. Hard-coding their names hid two real classes of error: - Projects that never declared `Token` would silently lint clean (rule `event-payload-types-resolve` resolved via the fallback). - A bug in `read_type_header` that dropped bodyless type declarations (fixed in the previous commit) was masked by the same fallback. After this change every project must declare its own `Money`, `Email`, `Password`, `Token`, `Key`, `Role`, etc. The shipped examples already do, so all 18 tests stay green.
This was referenced May 7, 2026
Two `for line in body.lines() { line_num += 1; ... }` patterns in
`parse_external_actor_body` and `parse_event_body` tripped
clippy::explicit_counter_loop on Rust 1.95 (the GitHub Actions
runner version) even though local Rust 1.85 didn't flag them. The
CI command `cargo clippy --all -- -D warnings` failed.
Switch both to `for (offset, line) in body.lines().enumerate()` and
compute line_num inline. Same behaviour, no counter mutation.
cargo fmt clean. cargo clippy clean. cargo test --all 18/18 green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase B of the candy alpha plan (`.claude/session-handoff.txt`).
Implementation of the linter portion of the codegen wave architecture
(closed #36). Closes #39.
Single binary `candy` (Rust + clap), starting with the `lint`
subcommand. Stub modules for `gen`, `test`, `fmt` lock in the
dispatch shape so future subcommands (#13–#16, #17) plug in cleanly.
Ships an `@tensorkit/candy` npm wrapper with postinstall fetch and
a 4-platform release matrix.
What landed
Total Rust src: 2,345 LOC (under the 4,000 budget).
The 10 lint rules
`prose-required-intent`, `broken-cross-feature-ref`,
`broken-symbol-ref`, `money-no-floats`, `idempotency-key`,
`schedule-syntax-valid`, `policy-attachment-resolves`,
`event-payload-types-resolve`, `actor-state-defaults-typed`,
`underscores-in-keywords` — all per #39's table.
JSON output via `--json`; pretty by default. Exit codes: 0 clean,
1 warnings-only, 2 errors.
Verification
CI runs all of the above on every PR; release workflow builds binaries
for linux-x86_64, linux-aarch64, macos-x86_64, macos-aarch64,
windows-x86_64 on tag push.
Deviations from the brief
Parser: hand-written recursive descent, not chumsky. The brief
recommended chumsky 0.10. The agent shipped a hand-written
recursive-descent parser. Trade-off: simpler dependency tree and
easier ad-hoc handling of candy's mixed brace/indent constructs
(e.g. `schedule` is indent-continued, not brace-delimited; `type`
record bodies are inline; flow return types contain `{` inside
generic args). chumsky would have been more elegant for clean
error recovery; the hand-written parser uses brace-skip recovery,
which is sufficient for v0.1. Worth revisiting if/when error
messages need to improve.
Single-file lint of files with cross-file refs reports false
positives. `candy lint examples/airbnb/booking.candy` exits 2
because `TimeRange` is declared in `airbnb/types.candy`. Project-
mode lint (`candy lint examples/airbnb/` or `candy lint examples/`)
resolves this and exits 0. Two rules (`broken-symbol-ref` and
`policy-attachment-resolves`) were already gated to project-mode
only; `event-payload-types-resolve` was not. Out of scope for v0.1
acceptance (the criteria pin single-file lint of self-contained
files like `auth.candy` and project-mode lint of full directories).
File a follow-up to tighten this: project-mode-only gating for any
rule that depends on cross-file resolution.
Judgment-call digest
Full report at `cli/HANDOFF.md`. Key invented semantics:
`accepts` op and lacks `key: Key`".
message names; record/state field names are not flagged
(snake_case fields appear throughout the examples).
hard-coded in `Project::declared_names()`. Not declared anywhere in
spec or grammar; assembled empirically from the examples. Worth
formalising in GRAMMAR.md.
Test plan
platform binaries with sha256 sums.
once the binaries are published.
Closes
Closes #39.
Refs #12 (parallel phase A), #13, #14, #15, #16, #17 (consumes the
`gen`/`test` subcommand stubs), #36 (closed; CLI architecture).