Docs
Lint + Format Plan: eta_lint and stonebridge
Status. This is the authoritative plan for the Eta linter (
eta_lint) and formatter (stonebridge). It is self-contained: an implementer should not need any other planning document to execute it.
0) Naming convention used in this document
| Concern | Directory | CMake target | Binary |
|---|---|---|---|
| Linter | eta/tools/linter/ | eta_linter | eta_lint |
| Formatter | eta/tools/formatter/ | eta_formatter | stonebridge |
| Shared CST lib | eta/core/ (new module) | eta_cst | — (library) |
| Linter library | eta/tools/linter/ | eta_linter_lib | — (library) |
| Format library | eta/tools/formatter/ | eta_format_lib | — (library) |
stonebridge here names the formatter executable. The linter
executable is eta_lint, matching the existing eta_lsp / eta_dap /
etac / etai naming pattern.
1) Why two tools, not one
The linter and formatter answer different questions and have incompatible contracts. Forcing them into one binary couples concerns that change at different rates and risks one feature blocking releases of the other.
| Question | etac/etai | eta_lint | stonebridge |
|---|---|---|---|
| Does it parse / compile? | ✅ | — | — |
| Does it run correctly on all paths? | ❌ | ✅ | ❌ |
| Is it idiomatic / consistent / unused / shadowed? | ❌ | ✅ | ❌ |
| Is the whitespace canonical? | ❌ | ❌ | ✅ |
| Safe to run on untrusted source in CI? | ❌ | ✅ | ✅ |
Contracts:
eta_lintreports diagnostics. It may refuse to analyse code it does not understand. Its output is information.--fixis opt-in and limited to safe local edits.stonebridgerewrites whitespace. It must accept every well-formed file. Its output is bytes. It guarantees round-trip safety, idempotence, and comment preservation.
Mixing these (one binary, one rule registry) makes both contracts weaker.
2) Shared foundations: do not duplicate
A single lossless concrete syntax tree (eta_cst) underpins both tools.
Duplicating it would let eta_lint and stonebridge drift on span and
comment placement — the linter would point at columns the formatter has
already moved. Worst possible UX.
eta_core (existing) reader: lexer, parser, diagnostics, spans
└─ eta_cst (NEW) lossless CST + trivia (comments, blanks, byte spans)
├─ eta_linter_lib (NEW) rule engine + rule registry
│ ├─ eta_lint binary
│ └─ eta_lsp publishDiagnostics
└─ eta_format_lib (NEW) Doc IR + layout engine + form aliases
├─ stonebridge binary
└─ eta_lsp textDocument/formatting
eta_lsp is a consumer of both libraries; it owns neither. There is
nothing to extract from the existing LSP today (it is lsp_server.{h,cpp} +
main_lsp.cpp and already delegates parsing to eta_core).
Why a new eta_cst rather than extending eta_core::reader::SExpr
parser::SExpris lossy by design — comments and blank lines are discarded, only token-start spans are kept. The compiler hot path depends on it staying small.eta_cstreuseseta_core::reader::lexerbut re-tokenises with trivia retained, exposing acst::Nodetree with full byte ranges plus leading/trailing trivia.- The compiler/interpreter keep using
parser::SExpr. Tools usecst::Node. One lexer, two views.
What lives in each library
eta_cst:
cst::Trivia— comment kind (Line,Block,Datum), blank-line count, original text, span.cst::Nodevariants —Atom,List,Quoted,VectorLiteral,BytevectorLiteral,Error(for tolerant parses).cst::parse(source) -> Result<File, Diagnostic>— tolerant.cst::to_sexpr(node) -> parser::SExpr— bridge to the lossy view.
eta_linter_lib:
Engine(lint a file/string),Diagnostic(extendseta::diagnosticwithrule_code),RuleRegistry,Ruleinterface,Configparser,Reporter(pretty/json/sarif/github),SourceEditfor--fix.
eta_format_lib:
Doc— Wadler/Leijen pretty-print IR.FormAliasregistry mapping head symbols to indentation rules.layout(node, width) -> Doc,render(doc, width) -> string,format_source(src, opts) -> Result<string, Diagnostic>.
3) Repository layout
eta/
├── core/
│ └── src/eta/cst/ # NEW shared CST + trivia
│ ├── trivia.h / .cpp
│ ├── node.h / .cpp
│ ├── parse.h / .cpp
│ └── bridge_sexpr.h / .cpp
└── tools/
├── linter/ # eta_lint
│ ├── CMakeLists.txt
│ ├── README.md
│ ├── src/eta/linter/
│ │ ├── main_eta_lint.cpp
│ │ ├── cli/ (args, reporter)
│ │ ├── core/ (engine, scope, registry, fix, config)
│ │ ├── rules/
│ │ │ ├── naming/ # ETA1xx
│ │ │ ├── imports/ # ETA2xx
│ │ │ ├── bindings/ # ETA3xx
│ │ │ ├── style/ # ETA4xx (whitespace-adjacent only)
│ │ │ ├── complexity/ # ETA5xx
│ │ │ └── bugs/ # ETA6xx
│ │ └── lib.h
│ └── tests/
│ ├── unit/
│ ├── fixtures/<CODE>_<slug>/{input.eta,expected.json}
│ └── golden/runner.cpp
└── formatter/ # stonebridge
├── CMakeLists.txt
├── README.md
├── src/eta/format/
│ ├── main_stonebridge.cpp
│ ├── cli/ (args)
│ ├── doc.h / .cpp
│ ├── layout.h / .cpp
│ ├── render.h / .cpp
│ ├── forms.h / .cpp # FormAlias registry
│ └── format_source.h / .cpp
└── tests/
├── unit/
├── golden/<name>.{input,expected}.eta
└── property/ # roundtrip, idempotence, comment-count
Note: no “structure” rule category
There is deliberately no ETA0xx rule family for things like unbalanced
parens or malformed defun — those are diagnostics the compiler
(etac) already produces, and re-implementing them in the linter would
just risk drift. When eta_lint is run on a file that fails to parse,
it surfaces the compiler’s parse diagnostic in lint format and
exits with code 2; it does not own the detection logic.
4) Executable naming (CMake)
Linter — eta_lint
add_executable(eta_linter
src/eta/linter/main_eta_lint.cpp
src/eta/linter/cli/args.cpp
src/eta/linter/cli/reporter.cpp)
set_target_properties(eta_linter PROPERTIES
OUTPUT_NAME eta_lint
CXX_SCAN_FOR_MODULES OFF)
target_link_libraries(eta_linter PRIVATE eta_linter_lib)
install(TARGETS eta_linter RUNTIME DESTINATION bin)
Formatter — stonebridge
add_executable(eta_formatter
src/eta/format/main_stonebridge.cpp
src/eta/format/cli/args.cpp)
set_target_properties(eta_formatter PROPERTIES
OUTPUT_NAME stonebridge
CXX_SCAN_FOR_MODULES OFF)
target_link_libraries(eta_formatter PRIVATE eta_format_lib)
install(TARGETS eta_formatter RUNTIME DESTINATION bin)
Both eta_linter_lib and eta_format_lib are separate add_library(... STATIC) targets so eta_lsp can link them without pulling either
main_*.cpp.
5) Linter (eta_lint)
5.1 Scope
Catch what the compiler will not: style, conventions, unused/dead code, shadowing, complexity, deprecated forms, suspicious calls.
Guiding principle: Never duplicate a diagnostic the compiler already
produces. If etac rejects it, the linter does not need a rule for it.
5.2 Rule catalogue (initial, ~20 rules across 6 categories)
Severity defaults: E error, W warning, I info.
Naming (ETA1xx)
- ETA101 — PredicateMissingQuestionMark (W)
- ETA102 — MutatorMissingBang (W)
- ETA103 — PrivateNotPrefixed (I) — module-local symbol not prefixed
% - ETA104 — NonKebabCase (I)
- ETA105 — ReservedShadow (W) — user defines
car,if, etc.
Imports / exports (ETA2xx)
- ETA201 — UnusedImport (W)
- ETA202 — ExportedSymbolUndefined (E)
- ETA203 — DuplicateExport (W)
- ETA204 — SelfImport (E)
- ETA205 — ImportOrder (I) — auto-fixable; sorts
importlines
Bindings (ETA3xx)
- ETA301 — UnusedLetBinding (W)
- ETA302 — UnusedParameter (I) —
_-prefix opts out - ETA303 — ShadowsBuiltin (W)
- ETA304 — ShadowsOuterBinding (I)
Style — comments only, never whitespace (ETA4xx)
Whitespace is the formatter’s job. The linter only handles comment-marker style, which the formatter deliberately leaves alone.
- ETA401 — CommentMarkerStyle (I) —
;end-of-line,;;statement,;;;banner. Auto-fixable.
Complexity (ETA5xx)
- ETA501 — FunctionTooLong (W) — default 60 lines
- ETA502 — NestingTooDeep (W) — default 6
- ETA503 — ArityTooHigh (I) — default 7
- ETA504 — CyclomaticComplexity (I) — counts
cond/if/and/or
Common bugs (ETA6xx)
- ETA601 — IfMissingElseInValuePosition (W)
- ETA602 — EqualsOnNonNumeric (W) —
=on string/symbol; suggestequal? - ETA603 — DeadCodeAfterTail (W) — forms after
error/raisein tail position - ETA604 — DuplicateCondClause (W)
- ETA605 — UnreachableElse (W) — clause after
(else …)
5.3 Configuration
Project file .eta-lint.toml (the formatter has no config file by
design, so this name is unambiguous):
[eta-lint]
include = ["stdlib/**/*.eta", "cookbook/**/*.eta"]
exclude = ["**/build/**", "**/.eta/**"]
max-warnings = 0
fix = false
[rules]
ETA101 = "warn"
ETA104 = "off"
ETA501 = { severity = "warn", max-lines = 80 }
[rules.preset]
use = "recommended" # or "all", "minimal"
Inline directives (recognised by the trivia scanner):
;; eta-lint:disable=ETA301 ; disables for the next form
;; eta-lint:disable-line=ETA401 ; disables for the current line
;; eta-lint:disable-file=ETA104 ; whole file (top-of-file only)
;; eta-lint:enable=ETA301 ; re-enables until end of enclosing form
5.4 CLI surface
eta_lint [paths...] # recursive lint of given paths (cwd if none)
eta_lint --config <path> # explicit config file
eta_lint --fix # apply auto-fixes in-place (safe rules only)
eta_lint --format pretty|json|sarif|github # default: pretty (tty) / github (CI)
eta_lint --rules ETA101,ETA301 # only run these rules
eta_lint --no-rule ETA104 # repeatable
eta_lint --max-warnings N
eta_lint --stdin --stdin-filename foo.eta
eta_lint --quiet
eta_lint --no-color
eta_lint --jobs N
eta_lint --list-rules
eta_lint --explain ETA301
eta_lint --version
eta_lint --help
Exit codes
0— clean, or only infos.1— errors, or warnings above--max-warnings.2— input file failed to parse (compiler diagnostic surfaced).64— bad CLI usage.70— internal error.
5.5 Auto-fix policy
--fix is opt-in and limited to textual, local, idempotent edits:
- ETA205 — sort import lines alphabetically inside the
moduleform. - ETA401 — rewrite comment markers to canonical style.
That is the entire auto-fix surface for v1. Anything that touches inter-token
whitespace is delegated to stonebridge. Rules that need AST-aware rewrites
(e.g. ETA101 rename foo → foo?) are surfaced as note: suggestions
only.
5.6 Output formats
Pretty (default), JSON, SARIF 2.1.0, GitHub Actions workflow commands. Examples:
Pretty:
cookbook/basics/basics.eta:23:5: warning[ETA605]: unreachable cond clause after (else …)
|
23 | (cond
24 | ((= 1 2) "nope")
| ^^^^
= help: remove the trailing clauses or move them above (else …)
eta_lint: 1 error, 3 warnings, 0 infos in 14 files (0.42s)
JSON:
{
"version": "1",
"summary": { "errors": 1, "warnings": 3, "infos": 0, "files": 14 },
"diagnostics": [
{
"file": "cookbook/basics/basics.eta",
"span": { "start": {"line":23,"col":5}, "end": {"line":26,"col":6} },
"rule": "ETA605",
"severity": "warning",
"message": "unreachable cond clause after (else …)"
}
]
}
GitHub Actions:
::warning file=cookbook/basics/basics.eta,line=23,col=5,title=ETA605::unreachable cond clause after (else …)
6) Formatter (stonebridge)
6.1 Guarantees
- Round-trip:
parse(format(src)) ≡ parse(src)semantically. - Idempotent:
format(format(src)) == format(src)byte-equal. - Comment-preserving: comment count and text bag unchanged.
- Single style: no per-project rule knobs. Only
--max-widthand--indent. (Borrowed fromgofmt/rustfmt.)
6.2 Lossless CST trivia model
Every token carries leading and trailing trivia:
- Leading trivia of token
T= whitespace/comments from the previous token’s end up toT’s start. - Trailing trivia of
T= up to the next newline.
(Roslyn / rust-analyzer convention; reproduces idiomatic Lisp commentary placement automatically.)
(defun foo (x) ; trailing trivia of `)`
;; leading trivia of `(if ...`
(if (> x 0) x (- x)))
Blank lines are recorded as blank_lines_before (capped: 1 between
top-level forms, 0 inside forms). The formatter never inserts blank
lines but preserves up to the cap that the user wrote.
Comment kinds: ;, ;;, ;;;, #| … |#, #;<datum>. The formatter
does not rewrite comment markers — that is eta_lint ETA401.
6.3 Layout algorithm
Wadler/Leijen pretty-printer with Group/IfFlat, plus a small registry
of form-specific aliases keyed off the head symbol.
Doc ::= Nil | Text(s) | Line | SoftLine | HardLine
| Nest(n, Doc) | Align(Doc) | Group(Doc) | Concat(Doc, Doc)
Default list rule. Generic (h a b c) is laid out as
Group( "(" <> head <> Nest(2, args_separated_by_Line) <> ")" )
Flat if it fits within remaining width: (h a b c). Otherwise head on the
open line, each arg on its own line indented 2.
Form aliases (initial).
| Head | Style |
|---|---|
defun, define, lambda, let, let*, letrec* | Body indent 2; signature/bindings on first line if fits |
let (named-let), do | Same as let |
cond | Each clause on its own line indented 2 |
case | Like cond |
if | Collapsed (if p t e) if fits; else t/e indented 4 |
when, unless | Body indent 2 |
begin, module body | Each form on its own line; one blank line preserved |
module head | (module name + import/export/begin indented 2 |
import, export | Inline if fits; otherwise one symbol per line indented 2 |
define-record-type | Constructor / predicate / fields each on own line, indent 2 |
quote, quasiquote, unquote, unquote-splicing | Reader-macro shorthand preserved ('x, never (quote x)) |
The registry is a code-only table; not user-extensible in v1. Adding an entry is a code change reviewed against the round-trip test suite.
6.4 Worked example
Input:
(
define
foo
1)
CST → List(open=(, [Atom(define), Atom(foo), Atom(1)], close=)).
define with two args fits flat in 100 columns →
(define foo 1)
The user’s shoe.eta sample (already canonical) is a no-op — the
idempotence test exercises this.
6.5 CLI surface
stonebridge [paths...] # rewrite files in place (default)
stonebridge --check [paths...] # exit 1 if any file would change
stonebridge --stdout [paths...] # write to stdout
stonebridge --stdin --stdin-filename foo.eta
stonebridge --diff [paths...] # unified diff; exit 1 if non-empty
stonebridge --max-width N # default 100
stonebridge --indent N # default 2
stonebridge --no-color
stonebridge --jobs N
stonebridge --version
stonebridge --help
Exit codes
0— clean (or--check/--diffreports no changes).1—--check/--difffound changes; or in-place mode found a parse error.64— bad CLI usage.70— internal error (round-trip / idempotence assertion failed).
There is deliberately no --config and no per-rule selection.
6.6 Tolerant parsing
If the CST contains an Error node, stonebridge refuses to format the
file and exits non-zero with the original eta::diagnostic rendered. A
formatter must never invent structure for malformed input.
7) Division of responsibility (cheat sheet)
| Concern | Owner |
|---|---|
| Whitespace, indentation, line-breaking | stonebridge |
Comment marker style (; vs ;;) | eta_lint (ETA401) |
| Comment preservation (text + count) | stonebridge |
| Blank-line preservation (capped) | stonebridge |
Naming conventions (?, !, %, kebab) | eta_lint |
| Unused/shadowed bindings, imports, params | eta_lint |
| Dead code, duplicate cond clauses | eta_lint |
| Complexity caps | eta_lint |
Suspicious calls (= on strings, etc.) | eta_lint |
| Sort imports | eta_lint --fix (ETA205) |
Reflow (if p t e) to fit width | stonebridge |
| Parse errors / unbound symbols | etac/etai, surfaced by eta_lint |
If a feature falls in both columns: it belongs to whichever tool owns the
output file. stonebridge owns whitespace bytes; eta_lint owns
diagnostics. They never overlap.
8) Integration points
8.1 CMake wiring
Add to eta/core/CMakeLists.txt:
add_library(eta_cst STATIC
src/eta/cst/trivia.cpp
src/eta/cst/node.cpp
src/eta/cst/parse.cpp
src/eta/cst/bridge_sexpr.cpp)
target_link_libraries(eta_cst PUBLIC eta_core)
Add to eta/CMakeLists.txt:
add_subdirectory("${ETA_LAYOUT_ROOT_DIR}/tools/linter")
add_subdirectory("${ETA_LAYOUT_ROOT_DIR}/tools/formatter")
eta/tools/linter/CMakeLists.txt:
add_library(eta_linter_lib STATIC
src/eta/linter/core/lint_engine.cpp
src/eta/linter/core/scope.cpp
src/eta/linter/core/rule_registry.cpp
src/eta/linter/core/config.cpp
src/eta/linter/core/fix.cpp
# rules/**/*.cpp
)
target_link_libraries(eta_linter_lib PUBLIC eta_cst)
eta_spdlog_target(_spd)
target_link_libraries(eta_linter_lib PRIVATE ${_spd})
add_executable(eta_linter
src/eta/linter/main_eta_lint.cpp
src/eta/linter/cli/args.cpp
src/eta/linter/cli/reporter.cpp)
set_target_properties(eta_linter PROPERTIES
OUTPUT_NAME eta_lint
CXX_SCAN_FOR_MODULES OFF)
target_link_libraries(eta_linter PRIVATE eta_linter_lib)
install(TARGETS eta_linter RUNTIME DESTINATION bin)
add_subdirectory(tests)
eta/tools/formatter/CMakeLists.txt:
add_library(eta_format_lib STATIC
src/eta/format/doc.cpp
src/eta/format/layout.cpp
src/eta/format/render.cpp
src/eta/format/forms.cpp
src/eta/format/format_source.cpp)
target_link_libraries(eta_format_lib PUBLIC eta_cst)
add_executable(eta_formatter
src/eta/format/main_stonebridge.cpp
src/eta/format/cli/args.cpp)
set_target_properties(eta_formatter PROPERTIES
OUTPUT_NAME stonebridge
CXX_SCAN_FOR_MODULES OFF)
target_link_libraries(eta_formatter PRIVATE eta_format_lib)
install(TARGETS eta_formatter RUNTIME DESTINATION bin)
add_subdirectory(tests)
8.2 LSP wiring
eta_lsp links both libraries:
target_link_libraries(eta_lsp PRIVATE eta_linter_lib eta_format_lib)
and implements:
publishDiagnostics←linter::Engine::lint_text(uri, content)on document change. Rule codes flow through unchanged.textDocument/formatting←format::format_source(buffer, opts), returned as a single full-documentTextEdit.textDocument/rangeFormatting← format the smallest enclosing top-level form; return its replacement edit.
The rule registry is partitioned into fast (lex/AST-local) and slow
(scope/dataflow) sets; LSP runs fast on every keystroke and slow on
save.
8.3 VS Code extension
editors/vscode/:
- Settings:
eta.lint.enabled(default true),eta.lint.path(defaulteta_lint);eta.format.path(defaultstonebridge),eta.format.maxWidth,eta.format.onSave. - Problem matcher
$eta-lintparsingfile:line:col: severity[CODE]: message. - Tasks:
Eta: Lint workspace(runseta_lint --format github .) andEta: Format workspace(runsstonebridge .). - Register
DocumentFormattingEditProvider/DocumentRangeFormattingEditProviderdelegating toeta_lsp.
8.4 Linter ↔ formatter relationship
They are independent binaries. They never call into each other.
Recommended operator workflow (and pre-commit hook order):
1. stonebridge --check # are we formatter-clean?
2. eta_lint --max-warnings 0 # any lint regressions?
For --fix flows, the order is:
1. eta_lint --fix # may rewrite import order, comment markers
2. stonebridge # canonicalise whitespace
Never the reverse — eta_lint --fix may invalidate spans that
stonebridge just produced.
8.5 CI and pre-commit
.github/workflows/lint.yml— two jobs:format-check: buildeta_formatter, runstonebridge --check stdlib cookbook demo.lint-check: buildeta_linter, runeta_lint --max-warnings 0 --format github stdlib cookbook.
scripts/pre-commit-eta.sh— runs both checks on staged.etafiles in the order above; non-zero blocks commit.- Bootstrap commit for the formatter, titled
chore: stonebridge-bootstrap, runsstonebridge stdlib cookbook demoonce and commits the result. Hash recorded indocs/stonebridge.mdsogit blameusers can--ignore-revit.
9) Testing strategy
9.1 Linter
- Per-rule unit tests under
tests/fixtures/<CODE>_<slug>/withinput.eta+expected.json. Walked by a golden runner;--blessto update. - CLI integration tests spawning the
eta_lintbinary; assert exit codes for--max-warnings,--fix,--stdin. - Cookbook regression (Phase 3):
eta_lint --max-warnings 0 cookbookin CTest. - Stdlib regression (Phase 3): same on
stdlib/std/. - Auto-fix idempotence: each fix-capable rule run twice; second pass reports zero diagnostics and produces zero edits.
- Fuzz via
qa/fuzz: never crash on malformed input.
9.2 Formatter
- Doc IR unit tests: handcrafted
Docvalues rendered at widths 80, 40, 20. - Form-rule unit tests: one per entry in the alias table; short and long (“doesn’t fit”) variants.
- Golden corpus at
tests/golden/<name>.{input,expected}.etawith--bless. - Round-trip property over
cookbook/,stdlib/,demo/— assertparse(formatted) ≡ parse(original). CTest entrystonebridge_roundtrip. - Idempotence property — same corpus, format twice, byte-equal.
stonebridge_idempotent. - Comment preservation — same corpus, comment count + text bag unchanged.
- Fuzz via
qa/fuzz: must never crash; either produce output or aDiagnostic. - Bootstrap dogfood (Phase 3):
stonebridge --check stdlib cookbookgates CI.
9.3 Joint
- Order safety test: pick 50 cookbook files. Run
eta_lint --fixthenstonebridge; assert clean. Then run twice more; assert idempotent. Then runstonebridgetheneta_lint --fix; assert no regression in lint diagnostics. (Confirms the documented order is correct and the reverse order is not catastrophic.)
10) Documentation deliverables
docs/eta_lint.md— linter user guide: install, configure, CLI, rule reference, exit codes.docs/eta_lint_rules.md— auto-generated rule catalogue fromrules/**/*.md.docs/stonebridge.md— formatter user guide: install, CLI, exit codes, editor integration, FAQ (“why no config?”).docs/stonebridge_style.md— canonical style spec, before/after for each form alias. Argued about once; frozen at 1.0.- Append “Linting and formatting” section to
docs/architecture.mdcovering theeta_cstshared layer. eta/tools/linter/README.md— how to add a rule.eta/tools/formatter/README.md— how to add a form alias, debug layout decisions.editors/vscode/README.md— settings + format-on-save + lint-on-save.
11) Phased delivery roadmap
Phase 0 — Shared foundation
- Build
eta_cst(trivia model, CST nodes, tolerant parse, S-expr bridge). - Unit tests for trivia attachment, blank-line capping, all five comment kinds.
Gate: round-trip cst::to_text(cst::parse(src)) == src over cookbook/.
Phase 1 — stonebridge MVP
eta_format_libDoc IR + renderer + generic list rule (no aliases).stonebridgeCLI:--stdin,--stdout,--check,--diff,--max-width,--version,--help.- Round-trip + idempotence + comment-count assertions every run.
- Golden corpus seeded with 10 fixtures.
Gate: stonebridge --check cookbook/basics/hello.eta passes; round-trip
and idempotence green on cookbook/basics/.
Phase 2 — eta_lint MVP
eta_linter_libengine consumingeta_cst.- CLI: arg parser, pretty + JSON reporters, exit codes.
- Five seed rules: ETA101, ETA201, ETA301, ETA605, ETA601.
- Per-rule fixtures + golden runner.
- Tool README +
docs/eta_lint.mdskeleton.
Gate: eta_lint cookbook/basics/basics.eta produces correct output;
CTest passes.
Phase 3 — Full rule + alias coverage
stonebridge: every form alias from §6.3 implemented; ≥80 golden fixtures; round-trip and idempotence green over all ofcookbook/anddemo/.eta_lint: full rule catalogue from §5.2;.eta-lint.tomlparser; inline disable directives;--list-rules,--explain.- Auto-fix for ETA205, ETA401.
Gate: stonebridge --check cookbook demo exit 0; cookbook lints clean
with project config.
Phase 4 — Editor + CI bootstrap
eta_lsplinks both libraries;publishDiagnosticsandtextDocument/formatting/rangeFormattingwork.- VS Code provider, settings, status bar, problem matcher, tasks.
- CI
format-checkandlint-checkjobs; pre-commit hook. - Bootstrap commit for
stdlib/(formatter); from this commit the stdlib must stay clean. - Lint
stdlib/std/clean at--max-warnings 0.
Gate: stdlib formatter-clean and lint-clean in main; LSP works in VS Code.
Phase 5 — Hardening & 1.0
- Block + datum comment edge cases; quasiquote / nested-unquote audit.
- Performance: parallel workers, incremental cache keyed on file hash.
- Public API freeze on
eta_format_libandeta_linter_lib; semver from here. - Style spec frozen.
Gate: 1.0 release.
12) Risks and open questions
- CST drift between tools. Mitigated by sharing
eta_cstas the only trivia-aware tree. Any feature requiring trivia goes througheta_cst; no parallel implementations allowed. - Spec arguments at bootstrap.
if/cond/long-arg-list breaking are flashpoints. Mitigation: publishdocs/stonebridge_style.mdfor review before the stdlib bootstrap commit; freeze at 1.0; only major versions can change. - Round-trip on macros.
syntax-rulespatterns have literal…and_. Add explicit golden cases undertests/golden/syntax_rules/. - Reader macros.
'x,`x,,x,,@xmust format as shorthand, never expand to(quote x). CST stores the original form. - CRLF / BOM. Preserve original file’s line ending; preserve BOM if present at offset 0; never inject. Mixed line endings → normalise to LF and warn.
- Strings. Atoms; never reformatted, never reflowed.
cookbook/notebooks/. Large generated files. Respectexcludeglobs.- Bootstrap diff size. Land in a single PR with no other changes;
record hash for
--ignore-rev. - Order of
eta_lint --fixthenstonebridge. Documented as the only supported order; joint test in §9.3 enforces it. - LSP perf budget. Only
fastlint rules run on every keystroke;slowrules run on save. Format requests only on explicit invocation, never on type. - Config file naming.
.eta-lint.tomlchosen over nesting undereta.toml([tool.eta-lint]) for discoverability. Open question revisitable at end of Phase 3 based on user feedback. - Rule-code stability. Once published, codes never change meaning.
Renaming a rule allocates a new code; old one marked
deprecated.
13) Milestones / acceptance criteria (combined v1)
The combined linter + formatter v1 is complete when all of the following hold:
-
cmake --buildproduces binaries literally namedeta_lint(from targeteta_linter) andstonebridge(from targeteta_formatter). -
eta_cst,eta_linter_lib,eta_format_libexist as separate static libraries;eta_lsplinks both tool libraries. -
eta_lint --version,--help,--list-rules,--explain <code>work; ≥20 rules implemented across the 6 categories. -
stonebridge --version,--help,--check,--diff,--stdin,--stdoutwork; every form alias from §6.3 implemented and golden-tested. - Round-trip, idempotence, and comment-preservation tests green
over
stdlib,cookbook,demo. - Pretty / JSON / SARIF / GitHub reporters in
eta_lintproduce conformant output (SARIF validated). -
.eta-lint.tomlparsed; inlinedisable/disable-line/disable-file/enabledirectives honoured. -
eta_lint --fixapplies safe edits and is idempotent. - CI:
stonebridge --check stdlib cookbook demoexits 0 andeta_lint --max-warnings 0 stdlib cookbookexits 0. -
eta_lspsurfaces lint diagnostics with rule codes and servestextDocument/formatting+rangeFormatting. - VS Code: format-on-save and lint diagnostics visible in editor.
- Documentation present:
docs/eta_lint.md,docs/eta_lint_rules.md(auto-gen),docs/stonebridge.md,docs/stonebridge_style.md, both tool READMEs, architecture appendix. - Bootstrap commit landed; stdlib formatter-clean and lint-clean in main.
- Joint order-safety test from §9.3 green.
- Performance:
eta_lint< 1 ms/file/rule;stonebridge< 2 ms/file on cookbook with--jobs 1. -
stonebridgehas no user-tunable style options beyond--max-widthand--indent.