Skip to content

adds bend-lint tool - #1389

Merged
nicolas-abril merged 39 commits into
bendlang:mainfrom
MattCozendey:tools/bend-lint
Oct 8, 2026
Merged

nicolas-abril merged 39 commits into
bendlang:mainfrom
MattCozendey:tools/bend-lint

Conversation

@MattCozendey

Copy link
Copy Markdown
Contributor

Add bend-lint, a standalone linter for Bend 2 with user-written rules

This moves the userland diagnostics work into a tool. Nothing in
bend2/ changes.

tools/bend-lint checks a file with bend's own checker, then runs rules on
it. A rule is a TS module or a Bend file. It reads the source text, the
checker's result for each term (type, scope, depth, quantity, uses), or
both, and returns findings with fixes. The CLI prints them in bend's error
layout, and --fix applies the safe fixes:

bun tools/bend-lint/src/lint.ts file.bend --rules my_rules.ts --rules my_rule.bend

How it gets the checker's results without changing bend: when bend-lint
loads bend2/bend.ts (Bun only), it wraps term_infer and term_check in
memory so they record what they return, and gives bend.ts an fs and path
whose real paths use "/", so imports also resolve on Windows. For rules
written in Bend, it exports RUNTIME_MAIN and js_sat from comp.ts, so a
rule is compiled once. The files on disk do not change, bend and the
gates never load this, and checking gives the same results.

It does not drift silently. Each patch anchor must match exactly once, a
self-check runs at load, and bend.pin holds the git blob hashes of
bend.ts and comp.ts. Any mismatch stops it with a DriftError. To bump:
BEND_LINT_UNPINNED=1 bun test tools/bend-lint, then --pin.

It intentionally does not add anything to bend's CLI, run in the gates,
serve LSP, or give codes to bend's own errors (they are all bend/check).
The only file outside tools/bend-lint is gates/repo.ts: three allow lines.

Validation:

  • bun test tools/bend-lint (45 tests: patch anchors, span mapping, TS and
    Bend rules, CLI, and 12 tests from tests/ against drift), on Linux, WSL
    and Windows
  • bunx tsc -p tools/bend-lint/tsconfig.json --noEmit (no errors in
    bend-lint; the two it reports in bend2/bend.ts are on main too)
  • git diff --check

@nicolas-abril nicolas-abril left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks, this is careful work. The approach is sound for an opt-in tool, and it does what it says. On today's main the 48 tests pass and the branch merges cleanly. With no rules, a run costs about the same as bend --check-only (0.92 s vs 0.83 s on bench/checker/proofs_3200). A few things need fixing before it goes in, mostly around --fix and silent wrong results.

Reviewed at 48db83a. "Confirmed" below means reproduced on the branch; the rest are from reading the code.

Bugs

  1. --fix can rewrite cached BendHub packages (lint.ts:571). It writes safe fixes to every source that isn't Base, including imported packages cached under ~/.bend/lib. bend only downloads a package when it's missing and never re-checks its hash, so every later bend run would silently use the edited package. Limit --fix to the root file, or at least skip BEND_LIB files.

  2. --fix aborts on identical edits (lint.ts:571, lint.ts:342). Confirmed. Two findings that insert the same " " at the same comma count as "overlapping". The run prints only bend-lint: bend-lint: fixes are out of bounds or overlap (the prefix is doubled), exits 2, and shows no findings, because fixed is computed before printing. This happens naturally with one typed rule that doesn't skip fact.inst, since a template instance repeats the body's spans. Merge identical edits, and skip conflicting fixes rather than aborting.

  3. Base facts leak into rules (lint.ts:251, lint.ts:253). Confirmed. The filter bok.tlds[def]?.b !== true misses constructor-field checks (their def is a constructor name, which isn't in tlds) and Base template instances. Base is also only seeded when the root file matches /^import Base\s*$/m. With import Base # comment, or with Base reached through another import, 129 of 137 facts had spans in base.bend. That contradicts "Base's own defs give no facts", and Base gets re-checked on every lint.

  4. Another bend2 checkout can get this one's code (lint.ts:506). Confirmed. Bun.plugin is process-wide, the filter only checks that the folder is named bend2, and contents are looked up by file name alone. With bend-lint loaded, importing a different checkout's bend2/bend.ts in the same process returned this checkout's patched code (its own exports were missing, and the patch marker was present). That's silently wrong code, not a DriftError. Filter on the full resolved path, and look up contents by full path.

  5. Drift that DriftError doesn't catch:

    • check() copies main.ts's private book_read/book_seed (lint.ts:245): the seeding, the PROOF.bend/LAWS.bend rule, the book_valid offset and the TODO count. It has already diverged: it seeds on /^import Base\s*$/m where main.ts uses /^import Base$/m. It also leaves out the unsafe/foreign verdict. On a sample of 412 files from tests/ and demos/, the 27 that rely on @unsafe or foreign code FAIL under bend --check-only but are "ok" under bend-lint, while the README says exit 1 means "the file does not check". Export a shared loader from bend2, or document the difference (or report it as a finding).
    • A future comp.ts export becomes a syntax error (patch.ts:133). The needs check accepts export function js_sat, but the tail then adds export { RUNTIME_MAIN, js_sat }. If comp.ts ever exports js_sat itself, loading fails with a duplicate-export SyntaxError instead of a DriftError.
  6. Rule validation misses an undefined entry (lint.ts:297). rules.find returns the bad element itself, so an undefined entry (or a hole) passes validation and then crashes in rules.some(...) with a generic TypeError. Use findIndex.

  7. Identical files break span mapping (lint.ts:217). mapper picks the source by line content and dir, not by the span's ns. Two byte-identical files, or an import-error span with no dir, give two candidates, and the run dies with a DriftError instead of reporting.

Performance

  1. walk is quadratic (lint.ts:175). Confirmed. A recursive generator with yield* passes every node up through all its ancestors: O(nodes × depth). A 2,000-element list literal takes 408 ms and a 5,000-element one 2.56 s, with stack-overflow risk beyond that. An explicit stack would make it linear.

  2. Facts don't scale to big books. With a needsTypes rule, proofs_3200 records 1.28M facts and peaks at 4.4 GB (vs 1.05 GB for the check), taking 1.86 s vs 0.92 s. Each fact keeps its type, context and book alive, which pins checker state the GC would otherwise free. A Bend rule over those facts reaches 6.7 GB and 4.3 s, because lint.js builds a Bend list of every fact up front. Letting a rule narrow what it gets (the root file only, certain term kinds) would help, as would handing facts to Bend rules incrementally.

  3. Output is O(findings × file size) (lint.ts:379, lint.ts:364). render re-splits the whole file twice per fix, and position rebuilds the line table on every call (--json calls it for every finding and edit).

Process

  1. The PR description is stale. It describes bend.pin, --pin and BEND_LINT_UNPINNED, which 00c86f4 removed; the README correctly says bend-lint "is not pinned". The test count is 48 now, not 45.

  2. Nothing runs these tests. The gates never run the suite, so the next change to bend.ts's internals will only surface when someone next uses the tool. Adding its 0.6 s suite to gates/test.ts would catch it. Longer term, a small official hook in bend.ts (an optional book.see called from term_infer/term_check) would remove the source patching entirely, but that's Taelin's call.

  3. It ships no rules. The only rules are the examples in the test file. If that's intended, say so; otherwise one or two real rules would make it useful out of the box.

I'd treat 1–5 as blockers. 8–10 can come later, and 12 is a decision.

🤖 Generated with Claude Code

@MattCozendey

Copy link
Copy Markdown
Contributor Author

Thanks for the review, all 13 points are handled. Short version:

Blockers (1–5): all fixed

  1. --fix editing packages: --fix now writes only the file you lint. It never touches imports or ~/.bend/lib.
  2. Identical edits abort: identical edits now merge. Clashing fixes are skipped and counted, not fatal. The doubled prefix is gone, and findings always print.
  3. Base facts leak: facts now come only from the linted file (filtered when recorded, then again by span). Base is seeded with main.ts's exact regex.
  4. Other bend2 checkouts: the plugin matches the full resolved path and looks up contents by real path.
    5a. book_read copy / unsafe verdict: the seeding now matches main.ts. The README says plainly that bend-lint does not fail a file for @unsafe or foreign code.
    5b. comp.ts duplicate export: it adds the export only if comp.ts does not already have it.

Other bugs (6–7): fixed

  1. undefined entries in rules are now caught.
  2. Span mapping also uses ns, so identical files map correctly.

Performance (8–10)

  1. walk: now uses an explicit stack, so it is linear.
  2. Facts on huge books: not fixed yet. It is documented. Next step: rules ask for only the facts they need, and Bend rules get facts bit by bit.
  3. Output: line tables are cached per file, so output is linear.

Process (11–13)

  1. Stale description: updated. No more pin, and the test count is current.
  2. Tests in gates: gates/test.ts now runs the bend-lint suite. The official book.see hook is Taelin's call, and the patch goes away if he adds it.
  3. No rules: there are two real rules now, style/trailing-whitespace (safe fix) and style/line-length (max, default 100). Both are opt-in. There are also --fix-suggested and --fix-dangerously.

Tests: 48 → 64, all pass.

@MattCozendey

MattCozendey commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Working on 9. Please don't merge yet.

@MattCozendey

Copy link
Copy Markdown
Contributor Author

Follow-up on #9 (memory with facts): fixed.

Rules now say which facts they want. facts: { kinds: ["Var"] }, or by def or name. bend-lint drops every other fact as soon as the checker gives it to us, so they never pile up.
On proofs_3200 (one machine, peak memory):
No rules: 0.77 GB
All facts: 2.6 GB
Only Vars: 1.5 GB
Most rules need far fewer facts than that.
Bend rules get facts one at a time (Lint.fold_facts), not as one big list. Memory in use stays the same from the first fact to the last.
Bonus: scope: "program" lets a rule also see facts from imported files (never Base). It is opt-in, so a normal run costs the same.
Not done: each kept fact is still a full fact. A slimmer fact is possible if a real case needs it.

MattCozendey and others added 25 commits October 8, 2026 08:39
bend-lint checks a file with bend's checker, then runs rule modules on it.
Rules get the files on disk and, with needsTypes, the checker's result for
each term (type, context, depth, def). Findings carry a severity, a code
(the rule id) and fixes; the CLI prints them in bend's error layout and
--fix applies the safe ones.

bend2/ does not change. When bend-lint loads bend.ts it wraps term_infer
and term_check to record results; it stops on a signature change, a failed
self-check, a span it cannot map, or a bend.ts that differs from bend.pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
bend resolves imports with "/" paths and does not support native Windows,
so the check fails there before bend-lint runs. The tool itself has no
Windows-only failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
src/lint.ts holds the library and the CLI; src/patch.ts holds the two
changes made to bend.ts as Bun loads it and the pin. The new change gives
bend.ts an fs and path where real paths use "/" and a drive letter is a
root, so relative imports resolve on Windows; on POSIX they act as node's.

Single-use helpers are inlined, the term walk is a typed table, the CLI
uses util.parseArgs, and the PatchError/SpanError pair is one DriftError.
No re-exports. The tests and their fixtures are one file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
…emplate instance

A template body is checked as written and again per instance, at the same
spans. Instances are the names in book.tmps, so `fact.inst` needs no
guessing from names; rules skip instance facts instead of relying on order.
qt and us bring back what the first prototype recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
A file with its own `import Base` starts from a copy of a checked Base, as
`bend --checkup` does; the copy is cached by the hash of base.bend. Facts of
Base's own defs are not recorded, so typed rules see only the user's code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
A .bend rule imports src/lint.bend and defines id(), types(), run(input) and
a main that hands run to Lint.serve. bend-lint checks the rule once and, per
run, executes it with comp.ts io_run; lint.js's effects reach the host
through globalThis.BEND_LINT. Facts and types cross as indexes into the
run's tables (Fact{id}, Term{id}), and the rule asks the checker about them
(view, type_of, binder, same, show). bend.pin now also pins comp.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
Lint.normal (a type's normal form), Lint.uses (the variables a term uses,
with quantities) and Lint.text (the source under a span); Lint.View also
carries how many times the term is demanded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
comp.ts is patched to export RUNTIME_MAIN and js_sat, so a Bend rule is
compiled once (as io_run does) and each run only calls its main: about
10 ms per run instead of 100-150 ms. patch() takes the file it patches.

RuleContext gains normal and uses, so the Bend channel only forwards to
the canonical helpers. Removed: position() and line() (no caller; the LSP
can bring them back), Diag.obs and Diag.note (no rule used them), the
BEND_TS/COMP_TS constants and the slash export. One ternary less in
check(); headers and README updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
The span mapper is typed with overloads instead of a generic cast, and the
two bend2 imports are a typed tuple. tsc now reports no error in
bend-lint (two remain in bend2/bend.ts, upstream). tsconfig.json was never
committed; the README says to run tsc, since Bun does not check types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
--json prints only {ok, findings}, each with code, severity, message, def,
path, an LSP range (0-based, UTF-16) and fixes with their edits. position()
comes back for it, and line() is shared again with the span mapper.

--bend <dir> (read before bend loads) or $BEND_DIR picks a bend checkout
or its bend2 folder; the default is this repo's. The plugin matches that
folder, the pin is checked against it, and a wrong folder exits 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
The text edits to bend.ts are now two imports and two renames
(term_infer/term_check become unseen_*), plus a tail that defines
term_infer and term_check with seeInfer/seeCheck from patch.ts. Those are
plain TypeScript, checked by tsc against bend.ts's signatures, with named
parameters instead of copied signatures in strings. comp.ts only gets a
tail, `export { RUNTIME_MAIN, js_sat }`; patch() checks that each name it
exports is declared once. Hooked moves to patch.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
bend.ts and comp.ts change most days, and a pin broke bend-lint on every
change, even ones it does not depend on. Now:

- the wrappers pass every argument through, so bend computes what it
  would without them, and need the arity they read (f.length);
- at load, every fact recorded for `x` in `def id(x: N) -> N: x` (one
  per wrapper) must have the right kinds of values, type, depth, scope,
  quantity, uses and span. Swapping two recorded arguments, or dropping
  the span, now fails with a DriftError naming what is wrong;
- the exact edits and comp.ts's declared exports stay as they were.

Removed: bend.pin, --pin, BEND_LINT_UNPINNED, current(), pinned(), and
blob() (Base's cache is keyed by base.bend's text). With --bend on
upstream main, which already differs from this branch's base, all tests
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
Converting offsets between UTF-16 units and characters scanned the file
from its start every time, so a typed Bend rule on a 1,917-line file took
18.7 s. A table per text, built once per run, makes it 0.2 s.

The comma rule in the tests now walks the text with a tail call (bend runs
those as loops); the old one overflowed the stack on large files. The
README says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
Nothing changes inside the bend repo: its own bend is still the default.
Elsewhere, --bend or $BEND_DIR is required, and without them bend-lint
stops with "no bend found". Type imports go through a `bend2/*` path alias
in a self-contained tsconfig.json (typeRoots also look in the tool's own
node_modules; @types/bun is a dev dependency). The drift tests read the
chosen checkout's tests/ and skip any that are missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
A rule may declare options with defaults and read them from cx.options.
bend-lint.json (in the file's folder or above, or --config) sets them per
rule id, and can turn a rule "off" or set its severity. An unknown option,
a value of another type than its default, or a bad severity is an error.
lint() now takes { signal, config }.

Bend rules get their options in Lint.Input and read them with
Lint.option_number, option_flag and option_text, each with a default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
- --fix writes only the linted file, never an import or a cached BendHub
  package. Equal edits merge; a fix that clashes with an earlier one is
  skipped and counted, not fatal, and findings print first. A rule's own
  fix with clashing or out-of-bounds edits is a rule error. applyFixes
  returns { text, skipped }. Error messages no longer repeat "bend-lint:".
- Facts come only from the linted file: none from Base (constructor
  fields, template instances) or other imports, seeded or not. Base is
  seeded on main.ts's own /^import Base$/m.
- Bun.plugin matches this checkout's bend.ts and comp.ts by full path, so
  another checkout in the process loads its own code.
- comp.ts: the tail exports only names comp.ts does not export already.
- An undefined entry in the rules is reported (findIndex).
- Spans map by namespace too, so identical files map; a failed check whose
  span cannot map still reports.
- walk uses an explicit stack: 2,000 list elements took 408 ms, now ~5 ms.
- Line tables are built once per file; a fix's diff reads only its lines.
- The README says what exit 1 does not cover (@unsafe and foreign code),
  and what facts cost on very large files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
bend-lint patches bend.ts in memory, so its tests are the first to see a
change it relies on. The test gate now runs `bun test tools/bend-lint`
(about 3 s) beside the cluster shards and counts it as one test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
rules/ holds ready rules, one per file, with shared.ts and shared.bend for
helpers. None runs unless passed with --rules.

- style/trailing-whitespace (TS): spaces or tabs at a line's end; safe fix.
- style/line-length (Bend): a line past the option max (default 100); no fix.

--fix-suggested also writes suggested fixes; --fix-dangerously writes all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
A rule's `facts` (a Bend rule's `facts()`) replaces needsTypes and
types(): true for all of the linted file's facts, or a filter on scope
(file, or program: imports too, never Base), kinds, defs and names.
bend-lint keeps a fact only if some rule asks for it, and each rule gets
only its own. On proofs_3200, `kinds: ["Var"]` peaks at 1.5 GB against
2.6 GB for all facts.

A Bend rule now pulls facts one at a time (Lint.next_fact, or
Lint.fold_facts) instead of getting one list of all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
- The hook reads a term's shape once, and only when some filter may want
  it; it no longer builds an array per term and filter.
- open() had one caller left, so it is inline; the scope check of the
  per-rule fast path is computed once.
- Test: a Bend rule without facts() is refused.
- Docs: fold_facts in lint.bend's header, and a comment on fold_next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
MattCozendey and others added 5 commits October 8, 2026 08:39
With no --bend, no $BEND_DIR and no bend repo around it, bend-lint
downloads one bend release from GitHub: the version of the `bend` on the
PATH, else the newest release (from git's ref list, asked once a day). It
takes bend.ts, comp.ts, base.bend and the effs/ files Base imports, into
~/.cache/bend-lint/<version>/bend2, through a temporary folder so a
partial download is never used. Later runs, and offline runs, use the
cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
bendDir gives bend2 paths with "/", as bend-lint does for every path it
hands to bend; the test built the expected one with "\\" on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
Its last case assumed the bend repo around tools/bend-lint, so it failed in
the standalone repo; it now passes the repo as an option. BEND_DIR, when
set, would win over every case, so the test unsets it and restores it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4
Add the format/layout and style/file-length rules and their tests, move the
effects to src/effects.js and the tests to src/lint.test.ts, and drop the
old line_length, trailing_whitespace and shared.ts rules. Rewrite the README.
Update the repo gate's allow list for the new file names.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoGeAfkpDBwvxzCtR9REJ5
lint(file, rules, { unsaved }) maps file paths to editor text that is not
saved. Bend and the rules read it in place of the file for that run only.
A run with unsaved text cannot overlap another run. This is what a language
server needs to check a buffer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoGeAfkpDBwvxzCtR9REJ5
MattCozendey and others added 9 commits October 8, 2026 11:42
Sync with the standalone repo (263d99e). format/layout takes endOfLine:
"lf" (default), "crlf" or "preserve". Preserve uses the first line ending,
or LF if there is none. Line endings inside literals stay as written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXvuzGKEyMUQ8ZTB1Z2Uqk
check() copied main.ts book_read and book_seed: Base seeding, the
PROOF.bend/LAWS.bend rule and the TODO count. If bend changed them,
bend-lint gave different results with no error. Now patch.ts exports
book_read, book_err and Check_Fail from main.ts (with the same fs and path
as bend.ts), and check() calls them. A download also gets main.ts and
safe.ts; a cached release without main.ts downloads again.

book_read makes its own book, so the wrappers report to hook.see in
patch.ts, not to book.see. The hook and the unsaved text are global, so
checks run one at a time in a queue. Runs with unsaved text now wait for
each other instead of failing, and only one check's facts are in memory at
a time.

A failed check's message was only the expected type, without the names in
scope. It is now "expected: ...\nobserved: ...", with the names in scope
and bend's note, as `bend` prints it without the location.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK
Synced from the standalone repo (MattCozendey/bend-lint, up to de52d65),
src/ and rules/ only:

- seam.ts replaces patch.ts and holds every call into bend2: finding and
  downloading it, the in-memory patch, checking a file, the rule
  operations, compiling a Bend rule. lint.ts and the rules import nothing
  from bend2. At load, main.ts's book_read, book_err and Check_Fail are
  checked, and a self-check runs src/sample.bend through check() and the
  rule operations.
- Rules use bend-lint's own types: facts and types are handles, read with
  cx.view, cx.type, cx.binder, cx.same, cx.show, cx.normal and cx.uses (the
  interface Bend rules already had). A span is { file: Source, beg, end };
  `spn` is now `span`. cx.sameDeclarations replaces format.ts's
  sameProgram. cx.unstable and LintResult.unstable keep bend2's objects for
  code that accepts to break when bend2 changes.
- The code is formatted with oxfmt.

gates/repo.ts allows seam.ts and sample.bend in place of patch.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK
Synced from the standalone repo (up to bc054e4), src/ and the README.

- Term view: cx.body(name), cx.node(fact), cx.shape(node) (kind with
  annotations kept, name, span, children) and cx.fact(node) (only facts
  this rule asked for). Bend rules get the same through lint.bend (Node,
  Shape, body, node, shape, fact).
- DriftError is gone: drift errors are Errors named "DriftError" with the
  DRIFT symbol from seam.ts set to true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK
compile() runs a Bend rule with `cli_args = args; return io_run(...)`,
which uses two names inside comp.ts's RUNTIME_MAIN. If bend renamed
io_run, each Bend rule failed on its first run with a ReferenceError; if
it renamed cli_args, the args were lost with no error. Now comp.ts's
patch needs `let cli_args = [];` and `function io_run(m) {` once each, or
loading stops with a drift error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK
- layout() puts the finding's head in place of bend's "Error:" itself,
  so render() no longer knows how err_show's text starts.
- compile() takes check()'s result, so bendRule() no longer holds bend's
  book.
- bendRule's comment no longer describes comp.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK
lint.bend and effects.js (the contract for Bend rules) and sample.bend
(the self-check's program) move to src/bend/. A Bend rule now imports
../src/bend/lint.bend (rules/ and the test rules are updated).

gates/repo.ts allows the three files in src/bend/ instead of src/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK
The test gate no longer runs tools/bend-lint's suite: the tool is an
external addition and must not block changes to bend2. repo.ts goes back
to LF line endings; the only change is the four allow lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- applyFixes: a fix that edits the linted file and another file is
  skipped whole instead of half applied; the CLI says how many.
- --fix applies fixes even when a finding is an error (e.g. a rule made
  an error by the config); the exit code still says FAIL.
- seam: sources take the text bend.ts read during the check, so a save
  while checking is no longer reported as bend drift.
- seam: offline, latestTag uses the newest cached release without noting
  it, so the next run asks GitHub again.
- seam: keep only the latest checked Base book instead of one per
  base.bend text ever seen.
- seam: mapper splits each file's lines once, and only for files whose
  namespace and folder match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@nicolas-abril

Copy link
Copy Markdown
Collaborator

I pushed two commits on top with fixes from review:

f05a9e9d gates

  • gates/test.ts is back to main. bend-lint is an external tool, so its suite shouldn't run in the test gate and block bend2 changes. It also launched lib.BUN (the minis' bun path) on the host with no error handler or timeout.
  • gates/repo.ts is back to LF line endings. The only change is the 4 allow lines for tools/bend-lint.

e867ad32 bend-lint

  • applyFixes: a fix that edits both the linted file and another file used to be half-applied (only the root edits were written). It's now skipped whole, and the CLI reports how many were skipped.
  • --fix now applies fixes even when a finding is an error, e.g. format/layout set to error in the config. The run still prints FAIL and exits 1. The README is updated to match.
  • seam: sources use the text bend.ts read during the check instead of reading the disk again afterwards, so saving a file mid-check no longer shows up as a DriftError.
  • seam: when offline, latestTag uses the newest cached release without writing it to latest.json. Before, a stale tag was trusted for 24h.
  • seam: only the latest checked Base book is kept, instead of one per base.bend text ever seen.
  • seam: mapper splits each file's lines once, and only for files whose namespace and folder match. Before, it was quadratic in the number of imported files.

New tests cover the cross-file fix, --fix with an error, and the offline note. The bend-lint suite passes (111 tests).

Also, the PR description mentions a bend.pin file and a --pin command. Neither exists; the README says "We don't pin".

@nicolas-abril
nicolas-abril merged commit 2dbb907 into bendlang:main Oct 8, 2026
1 check failed
@MattCozendey
MattCozendey deleted the tools/bend-lint branch October 11, 2026 04:11
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