Skip to content

feat(cli): Rust candy CLI scaffold + candy lint + npm wrapper#42

Merged
koolamusic merged 12 commits into
mainfrom
feat/candy-cli-lint
May 8, 2026
Merged

feat(cli): Rust candy CLI scaffold + candy lint + npm wrapper#42
koolamusic merged 12 commits into
mainfrom
feat/candy-cli-lint

Conversation

@koolamusic

Copy link
Copy Markdown
Contributor

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

Area Files LOC
Workspace + dispatch `cli/Cargo.toml`, `cli/candy/Cargo.toml`, `cli/candy/src/main.rs` ~80
Parser `cli/candy/src/lint/parser.rs` 1593
Lint entry + output `cli/candy/src/lint/mod.rs`, `output.rs` 90
10 rules `cli/candy/src/lint/rules/*.rs` ~620
Tests + fixtures `cli/candy/tests/` ~400
npm wrapper `npm/{package.json,postinstall.js,bin/candy}` ~120
CI + release `.github/workflows/{ci.yml,release.yml}` ~150

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

  • `cargo fmt --all -- --check` ✓
  • `cargo clippy --all -- -D warnings` ✓
  • `cargo test --all` ✓ (18/18)
  • `candy lint examples/auth/auth.candy` → exit 0 ✓
  • `candy lint examples/` → exit 0 ✓ (every shipped example clean)
  • Per-rule negative fixtures produce the expected JSON output ✓

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

  1. 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.

  2. 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:

  • `idempotency-key` interpreted as "flow that calls an external actor's
    `accepts` op and lacks `key: Key`".
  • `underscores-in-keywords` flags block-level names and `accepts`
    message names; record/state field names are not flagged
    (snake_case fields appear throughout the examples).
  • Built-in type set (`Id`, `Timestamp`, `Money`, `Key`, etc.) is
    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

  • CI passes on this PR.
  • Tag a v0.1.0 dry-run release; release workflow produces all five
    platform binaries with sha256 sums.
  • `npm install -g @tensorkit/candy` works on macOS, Linux, Windows
    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).

koolamusic added 11 commits May 7, 2026 12:31
- 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.
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.
@koolamusic koolamusic merged commit 5d255ed into main May 8, 2026
1 check passed
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.

[cli] Build candy Rust CLI scaffold + candy lint subcommand + npm wrapper [gen] Discuss codegen wave architecture before execution

1 participant