From 0813401afde00e90a652cd277229d53c8ba8ef5e Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 13:46:34 +0000 Subject: [PATCH 01/39] Add tools/bend-lint: rules over Bend source text and checker types 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- gates/repo.ts | 3 + tools/bend-lint/README.md | 80 ++++++++ tools/bend-lint/bend.pin | 1 + tools/bend-lint/package.json | 19 ++ tools/bend-lint/src/cli.ts | 73 +++++++ tools/bend-lint/src/core.ts | 6 + tools/bend-lint/src/diag.ts | 83 ++++++++ tools/bend-lint/src/index.ts | 10 + tools/bend-lint/src/instrument.ts | 145 ++++++++++++++ tools/bend-lint/src/lint.ts | 96 +++++++++ tools/bend-lint/src/load.ts | 96 +++++++++ tools/bend-lint/src/rules.ts | 27 +++ tools/bend-lint/src/spans.ts | 101 ++++++++++ tools/bend-lint/src/types.ts | 86 ++++++++ tools/bend-lint/src/walk.ts | 44 +++++ tools/bend-lint/test/cli.test.ts | 82 ++++++++ tools/bend-lint/test/drift.test.ts | 33 ++++ tools/bend-lint/test/erasure.test.ts | 54 +++++ tools/bend-lint/test/fixtures/erasure.bend | 34 ++++ tools/bend-lint/test/fixtures/format.bend | 23 +++ tools/bend-lint/test/fixtures/userland.bend | 27 +++ tools/bend-lint/test/format.test.ts | 63 ++++++ tools/bend-lint/test/instrument.test.ts | 43 ++++ tools/bend-lint/test/lint.test.ts | 208 ++++++++++++++++++++ tools/bend-lint/test/rules/erasure.ts | 52 +++++ tools/bend-lint/test/rules/format.ts | 49 +++++ tools/bend-lint/test/spans.test.ts | 50 +++++ tools/bend-lint/test/walk.test.ts | 23 +++ 28 files changed, 1611 insertions(+) create mode 100644 tools/bend-lint/README.md create mode 100644 tools/bend-lint/bend.pin create mode 100644 tools/bend-lint/package.json create mode 100644 tools/bend-lint/src/cli.ts create mode 100644 tools/bend-lint/src/core.ts create mode 100644 tools/bend-lint/src/diag.ts create mode 100644 tools/bend-lint/src/index.ts create mode 100644 tools/bend-lint/src/instrument.ts create mode 100644 tools/bend-lint/src/lint.ts create mode 100644 tools/bend-lint/src/load.ts create mode 100644 tools/bend-lint/src/rules.ts create mode 100644 tools/bend-lint/src/spans.ts create mode 100644 tools/bend-lint/src/types.ts create mode 100644 tools/bend-lint/src/walk.ts create mode 100644 tools/bend-lint/test/cli.test.ts create mode 100644 tools/bend-lint/test/drift.test.ts create mode 100644 tools/bend-lint/test/erasure.test.ts create mode 100644 tools/bend-lint/test/fixtures/erasure.bend create mode 100644 tools/bend-lint/test/fixtures/format.bend create mode 100644 tools/bend-lint/test/fixtures/userland.bend create mode 100644 tools/bend-lint/test/format.test.ts create mode 100644 tools/bend-lint/test/instrument.test.ts create mode 100644 tools/bend-lint/test/lint.test.ts create mode 100644 tools/bend-lint/test/rules/erasure.ts create mode 100644 tools/bend-lint/test/rules/format.ts create mode 100644 tools/bend-lint/test/spans.test.ts create mode 100644 tools/bend-lint/test/walk.test.ts diff --git a/gates/repo.ts b/gates/repo.ts index 0b59edcce4..c5161e9b8a 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -84,6 +84,9 @@ allow(/^tests\/[a-z]+\/[a-z0-9_]+\.(c|js)$/); allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\.json|tsconfig\.json)$/); allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); +allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json|bend\.pin)$/); +allow(/^tools\/bend-lint\/src\/[a-z]+\.ts$/); +allow(/^tools\/bend-lint\/test\/([a-z]+\/)?[a-z_]+\.(test\.ts|ts|bend)$/); // Gate // ==== diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md new file mode 100644 index 0000000000..6f16a79c23 --- /dev/null +++ b/tools/bend-lint/README.md @@ -0,0 +1,80 @@ +# bend-lint + +`bend-lint` checks a Bend 2 file with bend's own checker, then runs your +rules on it. A rule reads the source text, the checker's types, or both, +and returns diagnostics with fixes. Bend itself does not change. + +## Run + +Bun 1.2 or newer is required. From the repo root: + +```sh +bun tools/bend-lint/src/cli.ts file.bend --rules my_rules.ts [--rules more.js] [--fix] +``` + +Exit codes: 0 no error, 1 an error was found (a failed check, or a rule +finding with severity `error`), 2 bad usage or a tool failure. `--fix` +writes the `safe` fixes to the files. + +## Rules + +A rule module default-exports an array of rules: + +```ts +import type { LintRule } from "bend-lint"; // tools/bend-lint/src/index.ts + +export default [{ + id: "style/comma-space", // namespace/name; it is the diagnostic code + needsTypes: false, // true gives the rule cx.facts + run(cx, signal) { + return [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { + const spn = { file: cx.root.file, beg: m.index + 1, end: m.index + 1 }; + return cx.diag({ + message: "Add a space after the comma.", severity: "warning", spn, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }], + }); + }); + }, +}] satisfies LintRule[]; +``` + +Rules run in order, one at a time; `cx.prior` holds what earlier rules +found. A finding with severity `error` stops the run. + +`cx` gives: + +- `sources`, `root`: the files of the book as they are on disk. +- `facts` (with `needsTypes`): for each checked term, its type, context, + depth and def. `fact.spn` is a span in the file on disk. +- `span(s)`: a span from bend.ts (for example `term.s`), mapped to the file + on disk. +- `walk`, `binder`, `show`, `same`, `diag`: helpers over bend's terms. +- `Bend`: the bend.ts module, for anything else. + +Get bend.ts from `cx.Bend`. Do not import `bend2/bend.ts` in a rule. + +## How types are captured + +bend.ts does not expose the checker's results. When bend-lint loads +bend.ts, it puts two recording wrappers around `term_infer` and +`term_check`. The file on disk does not change, and checking gives the same +results. The patch looks for the two exact signatures; if they changed, or a +self-check on a tiny program records no type, bend-lint stops with an +error. If bend.ts gets a `book.see` hook one day, bend-lint uses it and does +not patch. + +## The bend.ts pin + +`bend.pin` holds the git blob hash of the `bend2/bend.ts` this tool was +tested with. If bend.ts differs, bend-lint stops. To bump: + +```sh +BEND_LINT_UNPINNED=1 bun test tools/bend-lint +bun tools/bend-lint/src/cli.ts --pin +``` + +## Test + +```sh +bun test tools/bend-lint +``` diff --git a/tools/bend-lint/bend.pin b/tools/bend-lint/bend.pin new file mode 100644 index 0000000000..95596e252d --- /dev/null +++ b/tools/bend-lint/bend.pin @@ -0,0 +1 @@ +0a0814a0544b67f278751a5b2715bed67af37bff diff --git a/tools/bend-lint/package.json b/tools/bend-lint/package.json new file mode 100644 index 0000000000..b8a7b72954 --- /dev/null +++ b/tools/bend-lint/package.json @@ -0,0 +1,19 @@ +{ + "name": "bend-lint", + "version": "0.1.0", + "description": "Diagnostics for Bend 2 from rules outside the language: source text, and the checker's types", + "private": true, + "type": "module", + "bin": { + "bend-lint": "src/cli.ts" + }, + "exports": "./src/index.ts", + "scripts": { + "test": "bun test", + "typecheck": "tsc -p tsconfig.json" + }, + "engines": { + "bun": ">=1.2" + }, + "license": "Apache-2.0" +} diff --git a/tools/bend-lint/src/cli.ts b/tools/bend-lint/src/cli.ts new file mode 100644 index 0000000000..d322fe5686 --- /dev/null +++ b/tools/bend-lint/src/cli.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env bun +// bend-lint: checks a Bend file with bend's checker, then runs rules on it. +// Exit: 0 ok, 1 an error was found, 2 bad usage or a tool failure. + +import * as fs from "node:fs"; + +import { pin } from "./instrument.ts"; + +const USAGE = [ + "usage: bun tools/bend-lint/src/cli.ts --rules [--rules ...] [--fix]", + " bun tools/bend-lint/src/cli.ts --pin (pin the current bend2/bend.ts)", +].join("\n"); + +function usage(msg: string): number { + console.error("bend-lint: " + msg + "\n" + USAGE); + return 2; +} + +async function main(argv: string[]): Promise { + let file: string | undefined; + const mods: string[] = []; + let fix = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--pin") { + console.log("bend-lint: pinned bend2/bend.ts " + pin()); + return 0; + } else if (a === "--rules") { + const m = argv[++i]; + if (m === undefined || m.startsWith("-")) return usage("--rules needs a module"); + mods.push(m); + } else if (a === "--fix") { + fix = true; + } else if (a === "-h" || a === "--help") { + console.log(USAGE); + return 0; + } else if (a.startsWith("-")) { + return usage("unknown option " + a); + } else if (file === undefined) { + file = a; + } else { + return usage("one file at a time"); + } + } + if (file === undefined) return usage("no file"); + // loaded here, after --pin, so a pin mismatch does not block --pin + const { lint } = await import("./lint.ts"); + const { render, applyFixes } = await import("./diag.ts"); + const { loadRules } = await import("./rules.ts"); + const res = await lint(file, await loadRules(mods)); + for (const d of res.diags) { + console.log(render(d) + "\n"); + } + if (fix && res.ok) { + for (const src of res.sources) { + if (src.base) continue; + const out = applyFixes(src.file, res.diags); + if (out !== src.text) { + fs.writeFileSync(src.path, out); + console.error("bend-lint: fixed " + src.path); + } + } + } + console.log(res.ok ? "bend-lint: " + res.diags.length + " finding(s)" : "bend-lint: FAIL"); + return res.ok ? 0 : 1; +} + +try { + process.exit(await main(process.argv.slice(2))); +} catch (e) { + console.error("bend-lint: " + (e instanceof Error ? e.message : String(e))); + process.exit(2); +} diff --git a/tools/bend-lint/src/core.ts b/tools/bend-lint/src/core.ts new file mode 100644 index 0000000000..89016633a1 --- /dev/null +++ b/tools/bend-lint/src/core.ts @@ -0,0 +1,6 @@ +// The instrumented bend2/bend.ts. Every bend-lint module gets Bend from +// here, so the patch is in place before bend.ts loads. + +import { loadBend, type Bend as BendT } from "./instrument.ts"; + +export const Bend: BendT = await loadBend(); diff --git a/tools/bend-lint/src/diag.ts b/tools/bend-lint/src/diag.ts new file mode 100644 index 0000000000..143dcdab39 --- /dev/null +++ b/tools/bend-lint/src/diag.ts @@ -0,0 +1,83 @@ +// Diagnostics: rendering, fixes and LSP positions. + +import type { Err } from "../../../bend2/bend.ts"; +import { Bend } from "./core.ts"; +import { line, starts } from "./spans.ts"; +import type { Applicability, Diag, Edit, Fix, Severity, SourceFile, Span } from "./types.ts"; + +const HEAD: Record = { + error: "Error", warning: "Warning", information: "Information", hint: "Hint", +}; + +export function isErr(e: unknown): e is Err { + return typeof e === "object" && e !== null && (e as { $?: unknown }).$ === "Err"; +} + +// bend's own layout; only the head names the severity and the code. +export function render(d: Diag): string { + const err = isErr(d.core) ? d.core + : Bend.Err(d.bok ?? Bend.book_nil(), d.ctx ?? Bend.ctx_nil(), d.message, d.obs, d.spn, d.def, d.note); + return Bend.err_show(err).replace(/^Error:/, HEAD[d.severity] + " [" + d.code + "]:") + + d.fixes.map(fixShow).join(""); +} + +// A fix, as a unified diff per file. +export function fixShow(fix: Fix): string { + let out = "\n\nFix: " + fix.title + " [" + fix.applicability + "]"; + const files = new Map(); + for (const edit of fix.edits) { + const edits = files.get(edit.spn.file) ?? []; + edits.push(edit); + files.set(edit.spn.file, edits); + } + for (const [file, edits] of files) { + const str = [...edits].sort((a, b) => b.spn.beg - a.spn.beg).reduce( + (s, { spn, text }) => s.slice(0, spn.beg) + text + s.slice(spn.end), file.str); + const old = file.str.match(/[^\n]*\n|[^\n]+$/g) ?? []; + const now = str.match(/[^\n]*\n|[^\n]+$/g) ?? []; + let beg = 0; + let end = old.length; + let tip = now.length; + while (beg < end && beg < tip && old[beg] === now[beg]) ++beg; + while (end > beg && tip > beg && old[end - 1] === now[tip - 1]) { --end; --tip; } + if (beg === end && beg === tip) continue; + const name = (file as Partial).path ?? (file.ns || ""); + const range = (e: number) => (e === beg ? beg : beg + 1) + "," + (e - beg); + const lines = (xs: string[], prefix: string) => xs.map((l) => prefix + + l.replace(/\r?\n$/, "") + (l.endsWith("\n") ? "" : "\n\\ No newline at end of file")); + out += "\n--- " + name + "\n+++ " + name + "\n@@ -" + range(end) + " +" + range(tip) + " @@\n" + + lines(old.slice(beg, end), "-").concat(lines(now.slice(beg, tip), "+")).join("\n"); + } + return out; +} + +// The text of `file` with the fixes of the given levels applied. Edits +// must not overlap. +export function applyFixes(file: SourceFile, diags: Diag[], levels: Applicability[] = ["safe"]): string { + const edits = diags.flatMap((d) => d.fixes) + .filter((f) => levels.includes(f.applicability)) + .flatMap((f) => f.edits).filter((e) => e.spn.file === file) + .sort((a, b) => b.spn.beg - a.spn.beg); + let out = file.str; + let wall = out.length + 1; + for (const { spn, text } of edits) { + if (spn.beg < 0 || spn.beg > spn.end || spn.end > file.str.length || spn.end > wall || spn.beg >= wall) { + throw new Error("bend-lint: fixes are out of bounds or overlap"); + } + out = out.slice(0, spn.beg) + text + out.slice(spn.end); + wall = spn.beg; + } + return out; +} + +export type Position = { line: number; character: number }; + +// LSP range of a span in a file on disk (0-based; UTF-16, as JS strings). +export function position(spn: Span): { start: Position; end: Position } { + const ss = starts(spn.file.str); + const at = (off: number): Position => { + const i = line(ss, off); + return { line: i, character: off - ss[i] }; + }; + return { start: at(spn.beg), end: at(spn.end) }; +} diff --git a/tools/bend-lint/src/index.ts b/tools/bend-lint/src/index.ts new file mode 100644 index 0000000000..7689c2eb87 --- /dev/null +++ b/tools/bend-lint/src/index.ts @@ -0,0 +1,10 @@ +// bend-lint as a library. + +export { Bend } from "./core.ts"; +export { lint, validRule } from "./lint.ts"; +export { loadRules } from "./rules.ts"; +export { applyFixes, fixShow, position, render } from "./diag.ts"; +export { children, walk } from "./walk.ts"; +export { PatchError, pin, pinned } from "./instrument.ts"; +export { SpanError } from "./spans.ts"; +export type * from "./types.ts"; diff --git a/tools/bend-lint/src/instrument.ts b/tools/bend-lint/src/instrument.ts new file mode 100644 index 0000000000..1aa9c9703c --- /dev/null +++ b/tools/bend-lint/src/instrument.ts @@ -0,0 +1,145 @@ +// Loads bend2/bend.ts for bend-lint. bend.ts on disk never changes: when +// Bun loads it, two recording wrappers go around term_infer and +// term_check, so typed rules can see what the checker found. If bend.ts +// changes, the tool stops with a clear error instead of giving wrong data. + +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as url from "node:url"; + +import type * as BendNS from "../../../bend2/bend.ts"; +import type { Book, Ctx, HTerm, LTerm, Name, Span } from "../../../bend2/bend.ts"; + +export type Bend = typeof BendNS; +export type See = (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn?: Span) => void; +export type Hooked = Book & { see?: See }; + +export class PatchError extends Error { + override name = "PatchError"; +} + +export const BEND_TS = fs.realpathSync(url.fileURLToPath(new URL("../../../bend2/bend.ts", import.meta.url))); +export const PIN_FILE = url.fileURLToPath(new URL("../bend.pin", import.meta.url)); +export const UNPINNED = process.env.BEND_LINT_UNPINNED === "1"; + +// The hook, `book.see`. If bend.ts ships it one day, the tool uses it and +// does not patch. +const HOOK = /\bbook\.see\?\.\(/; +const INFER = /^export function term_infer\(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm\[\] = \[\]\): Infer \{/m; +const CHECK = /^export function term_check\(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number\): Check \{/m; +const MARK = "BEND_LINT_PATCH"; + +const INFER_WRAP = [ + "export function term_infer(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm[] = []): Infer {", + " const inf = lint_infer(book, lhs, tm, qt, ctx, d, sp);", + " (book as any).see?.(book, inf.tm, inf.ty, ctx, d, lhs.def, tm.s);", + " return inf;", + "}", + "function lint_infer(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm[]): Infer {", +].join("\n"); + +const CHECK_WRAP = [ + "export function term_check(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number): Check {", + " const chk = lint_check(book, lhs, tm, qt, ty, ctx, d);", + " (book as any).see?.(book, chk.tm, ty, ctx, d, lhs.def, tm.s);", + " return chk;", + "}", + "function lint_check(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number): Check {", +].join("\n"); + +// The git blob hash of a text file, as `git rev-parse HEAD:` prints +// it (CRLF read as LF, as git stores it with core.autocrlf). +export function blob(text: string): string { + const buf = Buffer.from(text.replace(/\r\n/g, "\n"), "utf8"); + return crypto.createHash("sha1").update("blob " + buf.length + "\0").update(buf).digest("hex"); +} + +export function pinned(): string { + return fs.readFileSync(PIN_FILE, "utf8").trim(); +} + +export function current(): string { + return blob(fs.readFileSync(BEND_TS, "utf8")); +} + +export function pin(): string { + const hash = current(); + fs.writeFileSync(PIN_FILE, hash + "\n"); + return hash; +} + +export function checkPin(): void { + const now = current(); + const want = pinned(); + if (now !== want && !UNPINNED) { + throw new PatchError("bend2/bend.ts is " + now + ", but bend-lint is pinned to " + want + ".\n" + + "To bump: BEND_LINT_UNPINNED=1 bun test tools/bend-lint, then bun tools/bend-lint/src/cli.ts --pin"); + } +} + +export function patch(src: string): string { + for (const [re, name] of [[INFER, "term_infer"], [CHECK, "term_check"]] as const) { + const n = src.match(new RegExp(re.source, "gm"))?.length ?? 0; + if (n !== 1) { + throw new PatchError("cannot instrument bend2/bend.ts: found " + n + " " + name + + " signatures, expected 1. bend.ts changed; update tools/bend-lint/src/instrument.ts."); + } + } + if (/\b(lint_infer|lint_check|BEND_LINT_PATCH)\b/.test(src)) { + throw new PatchError("cannot instrument bend2/bend.ts: it already defines a bend-lint name."); + } + return src.replace(INFER, () => INFER_WRAP).replace(CHECK, () => CHECK_WRAP) + + "\nexport const " + MARK + " = 1;\n"; +} + +let loaded: Promise | undefined; + +export function loadBend(): Promise { + return loaded ??= load(); +} + +async function load(): Promise { + checkPin(); + const src = fs.readFileSync(BEND_TS, "utf8"); + const upstream = HOOK.test(src); + if (!upstream) { + if (typeof Bun === "undefined") { + throw new PatchError("bend-lint runs on Bun: bun tools/bend-lint/src/cli.ts"); + } + const out = patch(src); + Bun.plugin({ + name: "bend-lint", + setup(build) { + build.onLoad({ filter: /[\\/]bend2[\\/]bend\.ts$/ }, () => ({ contents: out, loader: "ts" })); + }, + }); + } + const mod = await import(url.pathToFileURL(BEND_TS).href) as Bend & { [MARK]?: number }; + if (!upstream && mod[MARK] !== 1) { + throw new PatchError("bend2/bend.ts was loaded before bend-lint could instrument it. " + + "Import bend-lint before any module that imports bend.ts."); + } + return mod; +} + +const SAMPLE = "type N is Data:\n Z{}\n\ndef id(x: N) -> N:\n x\n"; +let checked = false; + +// Checks a tiny program and requires the type of `x` in `id` to be N. +export function selfCheck(B: Bend): void { + if (checked) { + return; + } + const book = B.book_nil() as Hooked; + const seen: Array<{ bok: Book; tm: LTerm; ty: HTerm; def: Name }> = []; + book.see = (bok, tm, ty, _ctx, _dep, def) => { seen.push({ bok, tm, ty, def }); }; + B.parse_book(book, "/", SAMPLE, "", Object.create(null)); + B.book_valid(book); + const ok = seen.some((f) => f.def === "id" && B.term_strip(f.tm).$ === "Var" + && (B.term_wnf(f.bok, f.ty) as { k?: string }).k === "N"); + if (!ok) { + throw new PatchError("self-check failed: bend.ts was instrumented but recorded no type for `id`. " + + "The checker changed; update tools/bend-lint/src/instrument.ts."); + } + checked = true; +} diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts new file mode 100644 index 0000000000..ea3ce271ab --- /dev/null +++ b/tools/bend-lint/src/lint.ts @@ -0,0 +1,96 @@ +// Runs rules over a checked book. Rules run one at a time, in order. A +// finding with severity error stops the run; a rule that throws is a bug +// in the rule, so the error goes to the caller. + +import { Bend } from "./core.ts"; +import { selfCheck } from "./instrument.ts"; +import { coreDiag, loadBook, type Loaded } from "./load.ts"; +import { walk } from "./walk.ts"; +import type { Diag, LintResult, LintRule, RuleContext, Source } from "./types.ts"; + +export function validRule(rule: unknown): rule is LintRule { + if (rule === null || typeof rule !== "object") return false; + const r = rule as LintRule; + return typeof r.id === "string" && /^[^/\s]+\/[^/\s]+$/.test(r.id) + && typeof r.run === "function" + && (r.needsTypes === undefined || typeof r.needsTypes === "boolean"); +} + +export async function lint(file: string, rules: LintRule[], opts: { signal?: AbortSignal } = {}): Promise { + const signal = opts.signal ?? new AbortController().signal; + for (const rule of rules) { + if (!validRule(rule)) { + throw new TypeError("bend-lint: invalid rule (needs an id like ns/name and a run function)"); + } + } + const capture = rules.some((r) => r.needsTypes === true); + if (capture) { + selfCheck(Bend); + } + const ld = await loadBook(file, capture, signal); + const done = (ok: boolean, diags: Diag[]): LintResult => + ({ ok, diags, sources: ld.sources, book: ld.book, facts: ld.facts }); + if (ld.failed) { + return done(false, [coreDiag(ld.failure, ld.spans)]); + } + const root = ld.sources.find((s) => s.root)!; + const diags: Diag[] = []; + for (const rule of rules) { + signal.throwIfAborted(); + const out = await rule.run(context(rule, ld, root, diags), signal); + signal.throwIfAborted(); + if (!Array.isArray(out)) { + throw new TypeError("bend-lint: rule " + rule.id + " must return an array of diagnostics"); + } + for (const d of out) { + const diag = settle(d, rule.id, ld); + diags.push(diag); + if (diag.severity === "error") { + return done(false, diags); + } + } + } + return done(true, diags); +} + +// The code is the rule's id; every span is in the file on disk. +function settle(d: Diag, id: string, ld: Loaded): Diag { + return { + ...d, + code: id, + severity: d.severity ?? "warning", + spn: ld.spans.map(d.spn), + fixes: (d.fixes ?? []).map((f) => ({ + ...f, + edits: f.edits.map((e) => ({ ...e, spn: ld.spans.map(e.spn)! })), + })), + }; +} + +function context(rule: LintRule, ld: Loaded, root: Source, prior: Diag[]): RuleContext { + return { + Bend, + book: ld.book, + sources: ld.sources, + root, + facts: rule.needsTypes === true ? ld.facts : undefined, + prior, + span: (s) => ld.spans.map(s), + walk, + binder: (fact, v) => v.$ === "Var" ? Bend.pmap_get(fact.ctx, v.i) : null, + show: (fact, ty) => Bend.term_show(Bend.term_lower(ty, fact.dep)), + same: (fact, a, b) => Bend.term_compare("EQ", fact.bok, a, b, fact.dep), + diag: (init) => ({ + code: rule.id, + severity: init.severity ?? "warning", + message: init.message, + spn: init.spn, + def: init.def ?? init.fact?.def, + ctx: init.fact?.ctx, + bok: init.fact?.bok ?? ld.book, + obs: init.obs, + note: init.note, + fixes: init.fixes ?? [], + }), + }; +} diff --git a/tools/bend-lint/src/load.ts b/tools/bend-lint/src/load.ts new file mode 100644 index 0000000000..686514b34a --- /dev/null +++ b/tools/bend-lint/src/load.ts @@ -0,0 +1,96 @@ +// Loads and checks a book as bend2/main.ts book_read does, and records +// the checker's results when asked. + +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { Bend } from "./core.ts"; +import { isErr } from "./diag.ts"; +import type { Hooked } from "./instrument.ts"; +import { Spans } from "./spans.ts"; +import type { Book, Diag, Fact, LTerm, Source, Span } from "./types.ts"; + +export type Loaded = { + book: Book; + sources: Source[]; + spans: Spans; + facts?: Map; + failed: boolean; + failure?: unknown; +}; + +export async function loadBook(file: string, capture: boolean, signal: AbortSignal): Promise { + const book = Bend.book_nil() as Hooked; + const seen = new Map(); + const facts = capture ? new Map() : undefined; + let spans: Spans | undefined; + if (facts !== undefined) { + book.see = (bok, tm, ty, ctx, dep, def, raw) => { + let memo: Span | undefined | null = null; + facts.set(tm, { + tm, ty, bok, ctx, dep, def, raw, + get spn() { + if (memo === null) memo = spans!.map(raw); + return memo; + }, + }); + }; + } + let failed = false; + let failure: unknown; + try { + signal.throwIfAborted(); + await Bend.book_load(book, file, "", seen); + signal.throwIfAborted(); + const laws = path.join(path.dirname(file), "LAWS.bend"); + if (path.basename(file) === "PROOF.bend" && fs.existsSync(laws) + && !seen.has(fs.realpathSync(laws))) { + throw "Error: PROOF.bend must import ./LAWS.bend"; + } + Bend.book_valid(book, 0); + if (book.hols > 0) { + throw "Error: " + String(book.hols) + " TODO" + (book.hols === 1 ? "" : "s") + + " found.\nThe code is incomplete, and not a valid proof yet."; + } + } catch (e) { + if (signal.aborted) { + throw signal.reason; + } + failed = true; + failure = e; + } + const root = seen.keys().next().value; + const sources: Source[] = []; + for (const [real, ns] of seen) { + let text: string; + try { + text = fs.readFileSync(real, "utf8"); + } catch { + continue; + } + sources.push({ + path: real, ns: ns ?? undefined, text, root: real === root, base: real === Bend.BASE_BEND, + file: { str: text, ns: ns ?? "", al: Object.create(null), path: real }, + }); + } + spans = new Spans(sources); + return { book, sources, spans, facts, failed, failure }; +} + +// bend's own failure, as a diagnostic. +export function coreDiag(e: unknown, spans: Spans): Diag { + if (isErr(e)) { + let spn: Span | undefined; + try { + spn = spans.map(e.spn); + } catch { + spn = undefined; + } + const message = typeof e.exp === "string" ? e.exp : Bend.expr_show(e.bok, e.exp); + return { code: "bend/check", severity: "error", message, spn, def: e.def, fixes: [], core: e }; + } + const message = e instanceof RangeError + ? "the machine stack overflowed (a deep recursion, or a literal too large to expand)" + : String(e).replace(/^Error:\s*/, ""); + return { code: "bend/check", severity: "error", message, fixes: [] }; +} diff --git a/tools/bend-lint/src/rules.ts b/tools/bend-lint/src/rules.ts new file mode 100644 index 0000000000..b6187153a7 --- /dev/null +++ b/tools/bend-lint/src/rules.ts @@ -0,0 +1,27 @@ +// Loads rule modules. A module default-exports an array of rules. + +import * as path from "node:path"; +import * as url from "node:url"; + +import { validRule } from "./lint.ts"; +import type { LintRule } from "./types.ts"; + +export async function loadRules(files: string[]): Promise { + const rules: LintRule[] = []; + for (const file of files) { + if (file.endsWith(".bend")) { + throw new Error(file + ": rules written in Bend are not supported yet"); + } + const { default: loaded } = await import(url.pathToFileURL(path.resolve(file)).href); + if (!Array.isArray(loaded)) { + throw new Error(file + " must default-export an array of rules"); + } + for (const rule of loaded) { + if (!validRule(rule)) { + throw new Error(file + " has an invalid rule (needs an id like ns/name and a run function)"); + } + rules.push(rule); + } + } + return rules; +} diff --git a/tools/bend-lint/src/spans.ts b/tools/bend-lint/src/spans.ts new file mode 100644 index 0000000000..24f11ccf51 --- /dev/null +++ b/tools/bend-lint/src/spans.ts @@ -0,0 +1,101 @@ +// bend.ts parses a copy of each file with its import lines blanked. The +// lines stay in place, so a span maps to the file on disk by line and +// column. Before a file is mapped, the copy is compared with the file; +// if they do not agree, the tool stops. + +import type { Source, Span } from "./types.ts"; + +export class SpanError extends Error { + override name = "SpanError"; +} + +type Loose = { str: string; ns?: string; dir?: string; al?: Record }; +type Hit = { src: Source; conv(off: number): number }; + +export function starts(text: string): number[] { + const out = [0]; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) { + out.push(i + 1); + } + } + return out; +} + +export function line(starts: number[], off: number): number { + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= off) lo = mid; else hi = mid - 1; + } + return lo; +} + +export function compatible(masked: string, text: string): boolean { + if (masked === text) { + return true; + } + const ml = masked.split("\n"); + const ol = text.split("\n"); + return ml.length === ol.length && ml.every((l, i) => + l === ol[i] || l.trim() === "" && /^\s*import(\s|$)/.test(ol[i])); +} + +export function dirOf(p: string): string { + return p.slice(0, p.lastIndexOf("/") + 1); +} + +export class Spans { + private hits = new WeakMap(); + private own = new Set(); + + constructor(readonly sources: Source[]) { + for (const s of sources) { + this.own.add(s.file); + } + } + + map(s: Span | undefined): Span | undefined { + if (s === undefined) { + return undefined; + } + if (this.own.has(s.file)) { + return s; + } + const hit = this.find(s.file as Loose); + return { file: hit.src.file, beg: hit.conv(s.beg), end: hit.conv(s.end) }; + } + + private find(f: Loose): Hit { + const old = this.hits.get(f); + if (old !== undefined) { + return old; + } + const found = this.sources.filter((src) => + (f.ns === undefined || src.ns === undefined || f.ns === src.ns) + && (f.dir === undefined || f.dir === dirOf(src.path)) + && compatible(f.str, src.text)); + if (found.length !== 1) { + throw new SpanError("cannot map a bend.ts span to a file on disk (" + found.length + + " candidates). bend.ts may mask imports another way now; update tools/bend-lint/src/spans.ts."); + } + const src = found[0]; + if (f.al !== undefined) { + Object.assign(src.file.al, f.al); + } + const ms = starts(f.str); + const os = starts(src.text); + const same = f.str === src.text; + const hit: Hit = { + src, + conv: (off) => { + if (same) return off; + const i = line(ms, off); + return os[i] + (off - ms[i]); + }, + }; + this.hits.set(f, hit); + return hit; + } +} diff --git a/tools/bend-lint/src/types.ts b/tools/bend-lint/src/types.ts new file mode 100644 index 0000000000..a62573c4cc --- /dev/null +++ b/tools/bend-lint/src/types.ts @@ -0,0 +1,86 @@ +// The types rules see. + +import type { Ann, Book, Ctx, HTerm, LTerm, Name, Span } from "../../../bend2/bend.ts"; +import type { Bend } from "./instrument.ts"; + +export type { Ann, Book, Ctx, HTerm, LTerm, Name, Span }; + +export type Severity = "error" | "warning" | "information" | "hint"; + +// safe keeps behavior; suggested may change it; dangerous may break code. +export type Applicability = "safe" | "suggested" | "dangerous"; +export type Edit = { spn: Span; text: string }; +export type Fix = { title: string; applicability: Applicability; edits: Edit[] }; + +export type Diag = { + code: string; + severity: Severity; + message: string; + spn?: Span; + def?: Name; + ctx?: Ctx; + bok?: Book; + obs?: string; + note?: string; + fixes: Fix[]; + core?: unknown; // bend's own failure, when the check failed +}; + +export type DiagInit = { + message: string; + severity?: Severity; + spn?: Span; + fact?: Fact; + def?: Name; + obs?: string; + note?: string; + fixes?: Fix[]; +}; + +// A file of the book, as it is on disk. +export type SourceFile = { str: string; ns: string; al: Record; path: string }; +export type Source = { path: string; ns?: string; text: string; root: boolean; base: boolean; file: SourceFile }; + +// One result of the checker: `tm` checked (or inferred) against `ty` at +// depth `dep`, in `ctx`, inside def `def`. `bok` is the book it was +// checked in (a template body has its own). `spn` is in the file on disk; +// `raw` is the span bend.ts gave. +export type Fact = { + tm: LTerm; + ty: HTerm; + bok: Book; + ctx: Ctx; + dep: number; + def: Name; + raw?: Span; + readonly spn?: Span; +}; + +export type RuleContext = { + Bend: Bend; + book: Book; + sources: Source[]; + root: Source; + facts?: Map; // only for a rule with needsTypes + prior: readonly Diag[]; // what earlier rules found + span(s: Span | undefined): Span | undefined; // a bend.ts span, mapped to the file on disk + walk(tm: LTerm): Generator; + binder(fact: Fact, v: LTerm): Ann | null; + show(fact: Fact, ty: HTerm): string; + same(fact: Fact, a: HTerm, b: HTerm): boolean; + diag(init: DiagInit): Diag; +}; + +export type LintRule = { + id: string; // namespace/name + needsTypes?: boolean; + run(cx: RuleContext, signal: AbortSignal): Diag[] | Promise; +}; + +export type LintResult = { + ok: boolean; + diags: Diag[]; + sources: Source[]; + book: Book; + facts?: Map; +}; diff --git a/tools/bend-lint/src/walk.ts b/tools/bend-lint/src/walk.ts new file mode 100644 index 0000000000..cd1563c3da --- /dev/null +++ b/tools/bend-lint/src/walk.ts @@ -0,0 +1,44 @@ +// The children of a checked term. Binders are explicit, so Var.v cells +// are not followed. A new term kind in bend.ts is a type error here, and +// throws when the code runs. + +import type { LTerm } from "./types.ts"; + +export function children(tm: LTerm): LTerm[] { + switch (tm.$) { + case "Let": return [...tm.v, tm.f]; + case "Lam": return [tm.f]; + case "Sub": return [tm.f]; + case "App": return [tm.f, tm.x]; + case "Ctr": return tm.x; + case "ADT": return tm.x; + case "Mat": return [tm.h, tm.m]; + case "Rwt": return [tm.e, tm.p, tm.f]; + case "Typ": return [tm.g]; + case "Min": return [tm.a, tm.b]; + case "All": return [tm.A, tm.B]; + case "Eql": return [tm.a, tm.b, tm.T]; + case "Ann": return [tm.x, tm.T]; + case "Var": + case "Ref": + case "Qnt": + case "Qua": + case "Lit": + case "Efq": + case "Rfl": + case "Hol": + return []; + default: { + const kind: never = tm; + throw new Error("bend-lint: unknown term kind " + (kind as { $: string }).$ + + "; update tools/bend-lint/src/walk.ts"); + } + } +} + +export function* walk(tm: LTerm): Generator { + yield tm; + for (const child of children(tm)) { + yield* walk(child); + } +} diff --git a/tools/bend-lint/test/cli.test.ts b/tools/bend-lint/test/cli.test.ts new file mode 100644 index 0000000000..95fa22624b --- /dev/null +++ b/tools/bend-lint/test/cli.test.ts @@ -0,0 +1,82 @@ +import { afterAll, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); +const fixture = fileURLToPath(new URL("./fixtures/userland.bend", import.meta.url)); +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-lint-cli-")); +afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + +const input = path.join(dir, "cli.bend"); +fs.writeFileSync(input, fs.readFileSync(fixture, "utf8")); + +function rule(name: string, body: string): string { + const file = path.join(dir, name); + fs.writeFileSync(file, "export default " + body + ";\n"); + return file; +} + +const first = rule("first.js", `[{ id: "cli/first", needsTypes: true, run(cx) { + if (!cx.facts?.size || !cx.sources.length) throw new Error("missing metadata"); + return [cx.diag({ message: "First rule", severity: "warning" })]; +} }]`); +const second = rule("second.ts", `[{ id: "cli/second", run(cx) { + if (cx.prior[0]?.code !== "cli/first") throw new Error("wrong rule order"); + return [cx.diag({ message: "Second rule", severity: "hint" })]; +} }]`); +const blocking = rule("error.js", `[{ id: "cli/error", run(cx) { + return [cx.diag({ message: "Blocked by rule", severity: "error" })]; +} }]`); + +function run(...args: string[]) { + return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8" }); +} + +test("rules run in order and print bend's layout", () => { + const out = run(input, "--rules", first, "--rules", second); + expect(out.status).toBe(0); + expect(out.stdout).toMatch(/Warning \[cli\/first\]:[\s\S]*Hint \[cli\/second\]:/); +}); + +test("an error finding exits 1", () => { + const out = run(input, "--rules", blocking); + expect(out.status).toBe(1); + expect(out.stdout).toContain("Error [cli/error]:"); +}); + +test("a failed check exits 1", () => { + const bad = path.join(dir, "bad.bend"); + fs.writeFileSync(bad, "def broken(\n"); + const out = run(bad); + expect(out.status).toBe(1); + expect(out.stdout).toContain("Error [bend/check]:"); +}); + +test("bad usage and bad modules exit 2", () => { + expect(run().status).toBe(2); + expect(run(input, "--rules").status).toBe(2); + expect(run(input, "--nope").status).toBe(2); + expect(run(input, "--rules", path.join(dir, "missing.js")).status).toBe(2); + expect(run(input, "--rules", rule("object.js", "{}")).stderr).toMatch(/must default-export an array/); + expect(run(input, "--rules", rule("bad.js", `[{ id: "bad", run() { return []; } }]`)).stderr).toMatch(/invalid rule/); + const bend = path.join(dir, "rule.bend"); + fs.writeFileSync(bend, ""); + expect(run(input, "--rules", bend).stderr).toMatch(/not supported yet/); +}); + +test("--fix applies safe fixes only", () => { + const target = path.join(dir, "fix.bend"); + fs.writeFileSync(target, fs.readFileSync(fixture, "utf8")); + const comma = rule("comma.js", `[{ id: "style/comma-space", run(cx) { + return [...cx.root.text.matchAll(/,(?=\\w)/g)].map((m) => { + const spn = { file: cx.root.file, beg: m.index + 1, end: m.index + 1 }; + return cx.diag({ message: "space", severity: "hint", spn, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }] }); + }); +} }]`); + expect(run(target, "--rules", comma, "--fix").status).toBe(0); + expect(fs.readFileSync(target, "utf8")).toContain("generic(~N, a)"); +}); diff --git a/tools/bend-lint/test/drift.test.ts b/tools/bend-lint/test/drift.test.ts new file mode 100644 index 0000000000..afecfb9f20 --- /dev/null +++ b/tools/bend-lint/test/drift.test.ts @@ -0,0 +1,33 @@ +// bend-lint loads books with its own copy of bend2/main.ts book_read. If +// that copy drifts, these repo tests stop agreeing with their expected +// output: "SOME PROOFS FAIL" means the check fails, anything else passes. + +import { expect, test } from "bun:test"; +import * as fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { lint } from "../src/index.ts"; + +const TESTS = [ + "check/alpha_equivalence", + "check/assert_plain_fill", + "check/dependent_telescope", + "check/beta_ann_body", + "check/ctor_arity", + "check/forward_reference", + "check/hole_todo", + "check/typed_let_mismatch", + "import/base_prelude", + "import/string_literal", + "import/alias_shadow", + "import/duplicate_name", +]; + +for (const name of TESTS) { + test(name, async () => { + const file = fileURLToPath(new URL("../../../tests/" + name + ".bend", import.meta.url)); + const first = fs.readFileSync(file, "utf8").match(/^#\|(.*)$/m)![1].trim(); + const res = await lint(file, []); + expect(res.ok).toBe(first !== "SOME PROOFS FAIL"); + }); +} diff --git a/tools/bend-lint/test/erasure.test.ts b/tools/bend-lint/test/erasure.test.ts new file mode 100644 index 0000000000..032b07d79e --- /dev/null +++ b/tools/bend-lint/test/erasure.test.ts @@ -0,0 +1,54 @@ +import { afterAll, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Bend, applyFixes, lint } from "../src/index.ts"; +import type { Book } from "../src/index.ts"; +import { redundantAnnotation } from "./rules/erasure.ts"; + +const file = fileURLToPath(new URL("./fixtures/erasure.bend", import.meta.url)); +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-lint-erasure-")); +afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + +function bodies(book: Book): Record { + const out: Record = {}; + for (const name of book.order) { + const tld = book.tlds[name]; + if (tld.$ === "Def" && tld.e !== undefined) out[name] = Bend.term_show(tld.e); + } + return out; +} + +test("finds the four redundant annotations, and the fix keeps every body", async () => { + const source = fs.readFileSync(file, "utf8"); + const res = await lint(file, [redundantAnnotation]); + expect(res.ok).toBe(true); + expect(res.diags.map((d) => d.def).sort()).toEqual(["alias", "dependent", "direct", "generic"]); + expect(res.diags.every((d) => d.fixes.length === 1)).toBe(true); + const root = res.sources.find((s) => s.root)!; + const cleaned = applyFixes(root.file, res.diags, ["suggested"]); + expect(cleaned).toContain("f : N -> N = y => y"); + expect(cleaned).toContain("value : N = Z{}"); + + const fixed = path.join(dir, "erasure.bend"); + fs.writeFileSync(fixed, cleaned); + const after = await lint(fixed, [redundantAnnotation]); + expect(after.ok).toBe(true); + expect(after.diags).toEqual([]); + expect(bodies(after.book)).toEqual(bodies(res.book)); + expect(fs.readFileSync(file, "utf8")).toBe(source); +}); + +test("the annotations it keeps are needed", async () => { + const res = await lint(file, [redundantAnnotation]); + const cleaned = applyFixes(res.sources.find((s) => s.root)!.file, res.diags, ["suggested"]); + const fixed = path.join(dir, "needed.bend"); + for (const [annotation, binding] of [["f : N -> N =", "f ="], ["value : N =", "value ="]]) { + fs.writeFileSync(fixed, cleaned.replace(annotation, binding)); + const broken = await lint(fixed, []); + expect(broken.ok).toBe(false); + expect(broken.diags[0].code).toBe("bend/check"); + } +}); diff --git a/tools/bend-lint/test/fixtures/erasure.bend b/tools/bend-lint/test/fixtures/erasure.bend new file mode 100644 index 0000000000..3d92542ceb --- /dev/null +++ b/tools/bend-lint/test/fixtures/erasure.bend @@ -0,0 +1,34 @@ +# bend-lint fixture: erasure +type N is Data: + Z{} + S{p: N} + +def Alias() -> Type: + N + +def direct(x: N) -> N: + copy : N = x + copy + +def alias(x: N) -> N: + copy : Alias() = x + copy + +def generic(~T: Type, x: T) -> T: + copy : T = x + copy + +def dependent(-x: N, p: {x == x : N}) -> {x == x : N}: + copy : {x == x : N} = p + copy + +def required_lambda(x: N) -> N: + f : N -> N = y => y + f(x) + +def required_constructor() -> N: + value : N = Z{} + value + +def main() -> N: + direct(alias(generic(~N, required_lambda(required_constructor())))) diff --git a/tools/bend-lint/test/fixtures/format.bend b/tools/bend-lint/test/fixtures/format.bend new file mode 100644 index 0000000000..dd4eaf3c36 --- /dev/null +++ b/tools/bend-lint/test/fixtures/format.bend @@ -0,0 +1,23 @@ +# bend-lint fixture: format +import Base + +type Sample is Data: + SampleValue{} + +def choose(x: Sample,y: Sample) -> Sample: + x + +def text() -> String: + "a,b=c # still text; escaped quote: \"x,y=z\"" + +def marker() -> Char: + '=' + +def proof(-x: Sample) -> {x==x : Sample}: + {==} + +def main() -> Sample: + a : Sample=SampleValue{} # preserve,this=comment + b : Sample =SampleValue{} + f : Sample -> Sample=y=>y + f(choose(a,b)) diff --git a/tools/bend-lint/test/fixtures/userland.bend b/tools/bend-lint/test/fixtures/userland.bend new file mode 100644 index 0000000000..938bf2ff87 --- /dev/null +++ b/tools/bend-lint/test/fixtures/userland.bend @@ -0,0 +1,27 @@ +# bend-lint fixture: userland +type N is Data: + Z{} + S{p: N} + +def Alias() -> Type: + N + +def id(x: Alias()) -> Alias(): + x + +def generic(~T: Type, x: T) -> T: + x + +def proof(-x: N) -> {x == x : N}: + {==} + +def peel(x: N) -> N: + match x: + case Z{}: + Z{} + case S{p}: + p + +def main() -> N: + a = id(S{Z{}}) + generic(~N,a) diff --git a/tools/bend-lint/test/format.test.ts b/tools/bend-lint/test/format.test.ts new file mode 100644 index 0000000000..44c608e7fb --- /dev/null +++ b/tools/bend-lint/test/format.test.ts @@ -0,0 +1,63 @@ +import { afterAll, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Bend, applyFixes, lint } from "../src/index.ts"; +import type { Book, Diag, SourceFile } from "../src/index.ts"; +import { spacing, spacingEdits, tokens } from "./rules/format.ts"; + +const file = fileURLToPath(new URL("./fixtures/format.bend", import.meta.url)); +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-lint-format-")); +afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + +function asDiags(file: SourceFile): Diag[] { + return spacingEdits(file).map((edit) => ({ + code: spacing.id, severity: "hint", message: "", spn: edit.spn, + fixes: [{ title: "Normalize spacing", applicability: "safe", edits: [edit] }], + })); +} + +function bodies(book: Book): Record { + const out: Record = {}; + for (const name of book.order) { + const tld = book.tlds[name]; + if (tld.$ === "Def" && tld.e !== undefined && !tld.b) out[name] = Bend.term_show(tld.e); + } + return out; +} + +test("strings, characters, comments, operators and CRLF stay whole", () => { + const source = 'a= "x,y=z#text\\"still,string" # comment,a=b\r\n' + + "b= '='\r\nc= ','\r\nd= '#'\r\ne= '\\''\r\n" + + "f(a,\r\n b)\r\nx==y\r\nx=>y\r\nx!=y\r\nx<=y\r\nx>=y\r\nf(a,)\r\n"; + const sf: SourceFile = { str: source, ns: "", al: {}, path: "" }; + const diags = asDiags(sf); + const formatted = applyFixes(sf, diags); + expect(formatted).toBe(source.replace(/^([abcde])=/gm, "$1 =")); + expect(tokens(formatted).map((t) => t.text)).toEqual(tokens(source).map((t) => t.text)); + expect(() => applyFixes(sf, [diags[0], diags[0]])).toThrow(/overlap/); +}); + +test("one rule gives editor findings and formatter edits", async () => { + const res = await lint(file, [spacing]); + expect(res.ok).toBe(true); + expect(res.facts).toBeUndefined(); + expect(res.diags.length).toBe(8); + const root = res.sources.find((s) => s.root)!; + const formatted = applyFixes(root.file, res.diags); + expect(formatted).toContain("def choose(x: Sample, y: Sample)"); + expect(formatted).toContain("a : Sample = SampleValue{} # preserve,this=comment"); + expect(formatted).toContain("b : Sample = SampleValue{}"); + expect(formatted).toContain("f : Sample -> Sample = y=>y"); + expect(formatted).toContain("f(choose(a, b))"); + expect(tokens(formatted).map((t) => t.text)).toEqual(tokens(root.text).map((t) => t.text)); + + const fixed = path.join(dir, "format.bend"); + fs.writeFileSync(fixed, formatted); + const after = await lint(fixed, [spacing]); + expect(after.diags).toEqual([]); + expect(bodies(after.book)).toEqual(bodies(res.book)); + expect(fs.readFileSync(file, "utf8")).toBe(root.text); +}); diff --git a/tools/bend-lint/test/instrument.test.ts b/tools/bend-lint/test/instrument.test.ts new file mode 100644 index 0000000000..9028d74f81 --- /dev/null +++ b/tools/bend-lint/test/instrument.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import * as fs from "node:fs"; + +import { Bend } from "../src/core.ts"; +import { BEND_TS, PatchError, blob, current, patch, pinned, selfCheck } from "../src/instrument.ts"; + +const src = fs.readFileSync(BEND_TS, "utf8"); + +test("patch wraps term_infer and term_check once each", () => { + const out = patch(src); + expect(out.match(/^export function term_infer\(/gm)?.length).toBe(1); + expect(out.match(/^export function term_check\(/gm)?.length).toBe(1); + expect(out).toContain("function lint_infer("); + expect(out).toContain("function lint_check("); + expect(out).toContain("export const BEND_LINT_PATCH = 1;"); +}); + +test("patch fails loudly when a signature changes", () => { + expect(() => patch(src.replace("export function term_infer(book: Book,", "export function term_infer(bk: Book,"))) + .toThrow(PatchError); + expect(() => patch(src.replace(/^export function term_check\(/m, "export function term_check2("))) + .toThrow(/found 0 term_check/); +}); + +test("patch fails loudly on a second signature", () => { + const sig = src.match(/^export function term_check\(.*$/m)![0]; + expect(() => patch(src + "\n" + sig + "\n")).toThrow(/found 2 term_check/); +}); + +test("the loaded bend.ts is the patched one, and the self-check passes", () => { + expect((Bend as unknown as { BEND_LINT_PATCH?: number }).BEND_LINT_PATCH).toBe(1); + expect(() => selfCheck(Bend)).not.toThrow(); +}); + +test("the pin is a git blob hash, and CRLF reads as LF", () => { + expect(pinned()).toMatch(/^[0-9a-f]{40}$/); + expect(blob("a\r\nb\n")).toBe(blob("a\nb\n")); + // git hash-object of "hello\n" + expect(blob("hello\n")).toBe("ce013625030ba8dba906f756967f9e9ca394464a"); + if (process.env.BEND_LINT_UNPINNED !== "1") { + expect(current()).toBe(pinned()); + } +}); diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts new file mode 100644 index 0000000000..f96d09d75c --- /dev/null +++ b/tools/bend-lint/test/lint.test.ts @@ -0,0 +1,208 @@ +import { afterAll, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Bend, lint, render } from "../src/index.ts"; +import type { Fact, LintRule, RuleContext } from "../src/index.ts"; + +const file = fileURLToPath(new URL("./fixtures/userland.bend", import.meta.url)); +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-lint-")); +afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + +type Loose = Fact & { tm: { x?: { $: string; k?: string; i?: number } } }; + +function facts(cx: { facts?: Map; book: RuleContext["book"]; walk: RuleContext["walk"] }, name: string): Loose[] { + const tld = cx.book.tlds[name]; + if (tld.$ !== "Def" || tld.e === undefined) return []; + return [...cx.walk(tld.e)].map((tm) => cx.facts!.get(tm)).filter((f): f is Loose => f !== undefined); +} + +const commaSpace: LintRule = { + id: "style/comma-space", + run: (cx) => [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { + const spn = { file: cx.root.file, beg: m.index! + 1, end: m.index! + 1 }; + return cx.diag({ + message: "Add a space after the comma.", severity: "warning", spn, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }], + }); + }), +}; + +const identity: LintRule = { + id: "example/identity", + needsTypes: true, + run: async (cx, signal) => { + await Promise.resolve(); + signal.throwIfAborted(); + const body = facts(cx, "id").find((f) => f.dep === 1 && f.tm.x?.$ === "Var")!; + expect(body).toBeDefined(); + const ann = cx.Bend.pmap_get(body.ctx, body.tm.x!.i!)!; + expect(ann.k).toBe("x"); + expect((cx.Bend.term_wnf(body.bok, body.ty) as { k?: string }).k).toBe("N"); + expect(cx.same(body, body.ty, ann.T)).toBe(true); + return [cx.diag({ message: "This function returns its parameter.", severity: "information", spn: body.spn, fact: body })]; + }, +}; + +const neverRun: LintRule = { + id: "test/never-run", + needsTypes: true, + run: () => { throw new Error("this rule must not run"); }, +}; + +test("source and typed rules report in order, with fixes on the file on disk", async () => { + const text = fs.readFileSync(file, "utf8"); + const res = await lint(file, [commaSpace, identity]); + expect(res.ok).toBe(true); + expect(res.diags.map((d) => d.code)).toEqual([commaSpace.id, identity.id]); + const edit = res.diags[0].fixes[0].edits[0]; + expect(edit.spn.file.str).toBe(text); + expect(render(res.diags[0])).toStartWith("Warning [style/comma-space]:"); + expect(render(res.diags[0])).toContain("generic(~N, a)"); + expect(render(res.diags[1])).toContain("Context:"); + expect(fs.readFileSync(file, "utf8")).toBe(text); +}); + +test("only rules with needsTypes get facts", async () => { + let seen: unknown = "unset"; + const look: LintRule = { id: "test/look", run: (cx) => { seen = cx.facts; return []; } }; + await lint(file, [look, identity]); + expect(seen).toBeUndefined(); + const plain = await lint(file, [commaSpace]); + expect(plain.facts).toBeUndefined(); +}); + +test("facts cover templates, proofs, matches and fields", async () => { + const probe: LintRule = { + id: "test/probe", + needsTypes: true, + run: (cx) => { + const generic = facts(cx, "generic").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "x")!; + expect(generic.bok.tlds["generic~T"]).toBeDefined(); + const proof = facts(cx, "proof").find((f) => f.tm.x?.$ === "Rfl")!; + expect(proof.ty.$).toBe("Eql"); + expect(proof.dep).toBe(1); + const peel = [...cx.walk((cx.book.tlds.peel as { e: Parameters[0] }).e)]; + expect(peel.some((tm) => tm.$ === "Mat")).toBe(true); + expect(peel.some((tm) => tm.$ === "Ann" && tm.x.$ === "Efq" && !cx.facts!.has(tm))).toBe(true); + const field = facts(cx, "peel").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "p")!; + expect(cx.Bend.pmap_get(field.ctx, field.tm.x!.i!)!.k).toBe("p"); + return []; + }, + }; + expect((await lint(file, [probe])).ok).toBe(true); +}); + +test("a failed check is one bend/check error, and no rule runs", async () => { + const bad = path.join(dir, "bad.bend"); + fs.writeFileSync(bad, "type N is Data:\n Z{}\ndef broken(\n"); + let res = await lint(bad, [neverRun]); + expect(res.ok).toBe(false); + expect(res.diags.map((d) => d.code)).toEqual(["bend/check"]); + expect(render(res.diags[0])).toStartWith("Error [bend/check]:"); + fs.writeFileSync(bad, "type N is Data:\n Z{}\ndef broken() -> N:\n missing\n"); + res = await lint(bad, [neverRun]); + expect(res.ok).toBe(false); + fs.writeFileSync(bad, "type N is Data:\n Z{}\ndef broken() -> N:\n ?TODO\n"); + res = await lint(bad, [neverRun]); + expect(res.diags[0].message).toContain("1 TODO found"); + res = await lint(path.join(dir, "missing.bend"), [neverRun]); + expect(res.ok).toBe(false); +}); + +test("an error finding stops later rules; the code is always the rule's id", async () => { + const stamp: LintRule = { id: "test/stamp", run: (cx) => [{ ...cx.diag({ message: "stamped", severity: "hint" }), code: "other/code" }] }; + expect((await lint(file, [stamp])).diags[0].code).toBe(stamp.id); + const stop: LintRule = { id: "test/error", run: (cx) => [cx.diag({ message: "failed", severity: "error" })] }; + const res = await lint(file, [stop, neverRun]); + expect(res.ok).toBe(false); + expect(res.diags.map((d) => d.code)).toEqual([stop.id]); +}); + +test("a rule that throws, and an abort, reach the caller", async () => { + const boom: LintRule = { id: "test/boom", run: async () => { throw new Error("rule failed"); } }; + await expect(lint(file, [boom])).rejects.toThrow("rule failed"); + const controller = new AbortController(); + const abort: LintRule = { + id: "test/abort", + run: async (_cx, signal) => { expect(signal).toBe(controller.signal); controller.abort(); return []; }, + }; + await expect(lint(file, [abort, neverRun], { signal: controller.signal })).rejects.toThrow(); + await expect(lint(file, [], { signal: controller.signal })).rejects.toThrow(); +}); + +test("spans after import lines point at the right text on disk", async () => { + const dep = path.join(dir, "dep.bend"); + const main = path.join(dir, "main.bend"); + fs.writeFileSync(dep, "type N is Data:\n Z{}\ndef id(x: N) -> N:\n x\n"); + fs.writeFileSync(main, "import ./dep.bend as D\n\ndef main() -> D.N:\n D.id(D.Z{})\n"); + const look: LintRule = { + id: "test/imports", + needsTypes: true, + run: (cx) => { + const mine = cx.sources.find((s) => s.path === fs.realpathSync(main))!; + expect(mine.root).toBe(true); + expect(cx.sources.find((s) => s.path === fs.realpathSync(dep))!.ns).toBe("dep"); + const spanned = [...cx.facts!.values()].filter((f) => f.def === "main" && f.spn !== undefined); + expect(spanned.length).toBeGreaterThan(0); + for (const f of spanned) { + expect(f.spn!.file).toBe(mine.file); + const text = mine.text.slice(f.spn!.beg, f.spn!.end); + expect(text.length).toBeGreaterThan(0); + expect(mine.text.slice(f.spn!.beg).startsWith(text)).toBe(true); + } + const call = spanned.map((f) => mine.text.slice(f.spn!.beg, f.spn!.end)); + expect(call.some((t) => t.startsWith("D.id") || t.startsWith("D.Z"))).toBe(true); + return []; + }, + }; + const res = await lint(main, [look]); + expect(res.ok).toBe(true); + expect(res.sources.length).toBe(2); +}); + +test("lints in flight keep their own rules and facts", async () => { + let resume!: () => void; + let entered!: () => void; + const waiting = new Promise((r) => { resume = r; }); + const started = new Promise((r) => { entered = r; }); + const paused: LintRule = { + id: "test/paused", + run: async (cx) => { expect(cx.facts).toBeUndefined(); entered(); await waiting; return []; }, + }; + const first = lint(file, [paused]); + await started; + try { + const second = await lint(file, [identity]); + expect(second.facts!.size).toBeGreaterThan(0); + expect(second.diags.map((d) => d.code)).toEqual([identity.id]); + } finally { + resume(); + } + const res = await first; + expect(res.facts).toBeUndefined(); + expect(res.diags).toEqual([]); +}); + +test("a rule may await I/O, and later rules see earlier findings", async () => { + const server = Bun.serve({ port: 0, fetch: async (req) => new Response("advice for " + (await req.json()).type) }); + try { + const remote: LintRule = { + id: "test/api", + needsTypes: true, + run: async (cx, signal) => { + const fact = cx.facts!.get((cx.book.tlds.id as { e: Parameters[0] }).e)!; + const type = cx.Bend.term_wnf(fact.bok, fact.ty) as { $: string; A: Parameters[1] }; + const arg = cx.Bend.term_wnf(fact.bok, type.A) as { k?: string }; + const res = await fetch(server.url, { method: "POST", body: JSON.stringify({ type: arg.k }), signal }); + return [cx.diag({ message: await res.text(), severity: "hint" })]; + }, + }; + const next: LintRule = { id: "test/after-api", run: (cx) => { expect(cx.prior[0].message).toBe("advice for N"); return []; } }; + expect((await lint(file, [remote, next])).ok).toBe(true); + } finally { + server.stop(true); + } +}); diff --git a/tools/bend-lint/test/rules/erasure.ts b/tools/bend-lint/test/rules/erasure.ts new file mode 100644 index 0000000000..abb27382c1 --- /dev/null +++ b/tools/bend-lint/test/rules/erasure.ts @@ -0,0 +1,52 @@ +// Finds local annotations the checker does not need: `x : T = v` where v +// is a variable that already has type T. Constructors and lambdas keep +// theirs, since checking needs the expected type. + +import type { Diag, Edit, Fact, LintRule, RuleContext, Span } from "../../src/index.ts"; + +export const redundantAnnotation: LintRule = { + id: "erasure/redundant-local-annotation", + needsTypes: true, + run(cx, signal) { + const found = new Map(); + for (const fact of cx.facts!.values()) { + signal.throwIfAborted(); + const d = redundant(cx, fact); + if (d === undefined) continue; + const key = cx.sources.findIndex((s) => s.file === d.spn!.file) + ":" + d.spn!.beg + ":" + d.spn!.end; + if (!found.has(key)) found.set(key, d); + } + return [...found.values()]; + }, +}; + +function redundant(cx: RuleContext, fact: Fact): Diag | undefined { + const term = cx.Bend.term_strip(fact.tm); + const binding = fact.spn; + const value = cx.span(term.s); + if (term.$ !== "Var" || binding === undefined || value === undefined) return; + const v = cx.binder(fact, term); + if (v === null || !cx.same(fact, v.T, fact.ty)) return; + const edit = annotationEdit(binding, value); + if (edit === undefined) return; + return cx.diag({ + message: "Remove the redundant annotation: " + v.k + " already has type " + cx.show(fact, v.T) + ".", + severity: "hint", + spn: edit.spn, + fact, + fixes: [{ title: "Remove redundant local type annotation", applicability: "suggested", edits: [edit] }], + }); +} + +function annotationEdit(binding: Span, value: Span): Edit | undefined { + if (binding.file !== value.file || binding.beg >= value.beg) return; + const prefix = binding.file.str.slice(binding.beg, value.beg); + if (prefix.includes("#")) return; + const name = prefix.match(/^([A-Za-z_][A-Za-z_0-9]*)\s*:/); + if (name === null || !prefix.trimEnd().endsWith("=")) return; + const beg = binding.beg + name[1].length; + const end = binding.beg + prefix.lastIndexOf("="); + return { spn: { file: binding.file, beg, end }, text: " " }; +} + +export default [redundantAnnotation]; diff --git a/tools/bend-lint/test/rules/format.ts b/tools/bend-lint/test/rules/format.ts new file mode 100644 index 0000000000..9cf2c92b35 --- /dev/null +++ b/tools/bend-lint/test/rules/format.ts @@ -0,0 +1,49 @@ +// One space after a comma and on each side of an assignment. Quoted +// literals, comments and compound operators stay whole; only gaps change. + +import type { Edit, LintRule, SourceFile } from "../../src/index.ts"; + +type Token = { text: string; beg: number; end: number }; + +export function tokens(source: string): Token[] { + const pattern = /#[^\r\n]*|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|==|=>|!=|<=|>=|[^\s]/g; + return [...source.matchAll(pattern)].map((m) => ({ text: m[0], beg: m.index!, end: m.index! + m[0].length })); +} + +function gapEdit(file: SourceFile, left?: Token, right?: Token): Edit | undefined { + if (left === undefined || right === undefined || right.text.startsWith("#")) return; + const gap = file.str.slice(left.end, right.beg); + if (!/^[ \t]*$/.test(gap) || gap === " ") return; + return { spn: { file, beg: left.end, end: right.beg }, text: " " }; +} + +export function spacingEdits(file: SourceFile): Edit[] { + const stream = tokens(file.str); + const edits: Array = []; + for (let i = 0; i < stream.length; i++) { + const token = stream[i]; + if (token.text === "=") edits.push(gapEdit(file, stream[i - 1], token)); + if (token.text === "=" || token.text === ",") { + const next = stream[i + 1]; + if (next !== undefined && !["}", ")", "]"].includes(next.text)) edits.push(gapEdit(file, token, next)); + } + } + return edits.filter((e): e is Edit => e !== undefined); +} + +// Formats the file that was linted; imports are formatted when linted +// themselves. +export const spacing: LintRule = { + id: "format/spacing", + run(cx, signal) { + signal.throwIfAborted(); + return spacingEdits(cx.root.file).map((edit) => cx.diag({ + message: "Use one space after a comma and on each side of an assignment.", + severity: "hint", + spn: edit.spn, + fixes: [{ title: "Normalize spacing", applicability: "safe", edits: [edit] }], + })); + }, +}; + +export default [spacing]; diff --git a/tools/bend-lint/test/spans.test.ts b/tools/bend-lint/test/spans.test.ts new file mode 100644 index 0000000000..90a7a6148f --- /dev/null +++ b/tools/bend-lint/test/spans.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; + +import { SpanError, Spans } from "../src/spans.ts"; +import type { Source } from "../src/types.ts"; + +function source(path: string, ns: string, text: string): Source { + return { path, ns, text, root: true, base: false, file: { str: text, ns, al: Object.create(null), path } }; +} + +for (const eol of ["\n", "\r\n"]) { + const text = ["import Base", "import ./a.bend as A", "", "def f() -> N:", " x", ""].join(eol); + const lines = text.split("\n"); + const blank = lines.map((l) => l.startsWith("import") ? "" : l).join("\n"); + const space = lines.map((l) => l.startsWith("import") ? " ".repeat(l.length) : l).join("\n"); + for (const [name, masked] of [["empty", blank], ["spaces", space]] as const) { + test("maps a span past " + name + " import lines (" + JSON.stringify(eol) + ")", () => { + const src = source("/p/m.bend", "", text); + const spans = new Spans([src]); + const parse = { str: masked, ns: "", dir: "/p/", al: { A: "a" } }; + const beg = masked.indexOf("x"); + const got = spans.map({ file: parse, beg, end: beg + 1 })!; + expect(got.file).toBe(src.file); + expect(text.slice(got.beg, got.end)).toBe("x"); + expect(src.file.al.A).toBe("a"); + }); + } +} + +test("a span already on disk passes through", () => { + const src = source("/p/m.bend", "", "def f() -> N:\n x\n"); + const spn = { file: src.file, beg: 2, end: 3 }; + expect(new Spans([src]).map(spn)).toBe(spn); +}); + +test("a copy that does not match the file fails loudly", () => { + const src = source("/p/m.bend", "", "import Base\ndef f() -> N:\n x\n"); + const spans = new Spans([src]); + expect(() => spans.map({ file: { str: "\ndef g() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 })) + .toThrow(SpanError); + expect(() => spans.map({ file: { str: "def f() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 })) + .toThrow(SpanError); +}); + +test("the directory and the namespace pick the file", () => { + const a = source("/a/m.bend", "", "import Base\nx\n"); + const b = source("/b/m.bend", "m", "import Base\nx\n"); + const spans = new Spans([a, b]); + expect(spans.map({ file: { str: "\nx\n", ns: "m", dir: "/b/", al: {} }, beg: 1, end: 2 })!.file).toBe(b.file); + expect(spans.map({ file: { str: "\nx\n", ns: "", dir: "/a/", al: {} }, beg: 1, end: 2 })!.file).toBe(a.file); +}); diff --git a/tools/bend-lint/test/walk.test.ts b/tools/bend-lint/test/walk.test.ts new file mode 100644 index 0000000000..3dd72dd26f --- /dev/null +++ b/tools/bend-lint/test/walk.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +import { lint } from "../src/lint.ts"; +import { children, walk } from "../src/walk.ts"; +import type { LTerm } from "../src/types.ts"; + +test("walk reaches matches, lets and annotations of checked bodies", async () => { + const file = fileURLToPath(new URL("./fixtures/userland.bend", import.meta.url)); + const res = await lint(file, []); + expect(res.ok).toBe(true); + const kinds = new Set(); + for (const name of res.book.order) { + const tld = res.book.tlds[name]; + if (tld.$ !== "Def" || tld.e === undefined) continue; + for (const tm of walk(tld.e)) kinds.add(tm.$); + } + for (const k of ["Mat", "Lam", "App", "Var", "Ctr"]) expect(kinds.has(k)).toBe(true); +}); + +test("an unknown term kind fails loudly", () => { + expect(() => children({ $: "Nope" } as unknown as LTerm)).toThrow(/unknown term kind Nope/); +}); From 2b304ce2109b3598c6285d1cb57b8a562090f58a Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 13:48:31 +0000 Subject: [PATCH 02/39] bend-lint: show the reason when the import test fails; say where it runs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 4 +++- tools/bend-lint/test/lint.test.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 6f16a79c23..5676e36055 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -6,7 +6,9 @@ and returns diagnostics with fixes. Bend itself does not change. ## Run -Bun 1.2 or newer is required. From the repo root: +Bun 1.2 or newer is required, on Linux, macOS or WSL (bend resolves +imports with `/` paths, so native Windows cannot load relative imports). +From the repo root: ```sh bun tools/bend-lint/src/cli.ts file.bend --rules my_rules.ts [--rules more.js] [--fix] diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index f96d09d75c..be83540c1e 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -159,6 +159,7 @@ test("spans after import lines point at the right text on disk", async () => { }, }; const res = await lint(main, [look]); + expect(res.diags.map(render)).toEqual([]); expect(res.ok).toBe(true); expect(res.sources.length).toBe(2); }); From ccac75f02974647c9d35ef48d6dfd0074074e376 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 13:51:04 +0000 Subject: [PATCH 03/39] bend-lint: skip the relative-import test on native Windows 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 6 +++--- tools/bend-lint/test/lint.test.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 5676e36055..5dc1284da2 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -6,9 +6,9 @@ and returns diagnostics with fixes. Bend itself does not change. ## Run -Bun 1.2 or newer is required, on Linux, macOS or WSL (bend resolves -imports with `/` paths, so native Windows cannot load relative imports). -From the repo root: +Bun 1.2 or newer is required. bend-lint runs where Bun runs; on native +Windows, bend itself cannot load relative imports (bend supports WSL, not +Windows), so a file with one fails its check there. From the repo root: ```sh bun tools/bend-lint/src/cli.ts file.bend --rules my_rules.ts [--rules more.js] [--fix] diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index be83540c1e..0010f5958d 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -133,7 +133,9 @@ test("a rule that throws, and an abort, reach the caller", async () => { await expect(lint(file, [], { signal: controller.signal })).rejects.toThrow(); }); -test("spans after import lines point at the right text on disk", async () => { +// bend resolves relative imports with "/" paths, and bend does not run on +// native Windows ("No Windows (WSL works)"), so this test does not either. +test.skipIf(process.platform === "win32")("spans after import lines point at the right text on disk", async () => { const dep = path.join(dir, "dep.bend"); const main = path.join(dir, "main.bend"); fs.writeFileSync(dep, "type N is Data:\n Z{}\ndef id(x: N) -> N:\n x\n"); From e8215872389cb3f630b0439f3d0f12e7135dad07 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 14:03:28 +0000 Subject: [PATCH 04/39] bend-lint: two source files, a Windows path shim, one test file 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- gates/repo.ts | 4 +- tools/bend-lint/README.md | 55 +- tools/bend-lint/package.json | 4 +- tools/bend-lint/src/cli.ts | 73 --- tools/bend-lint/src/core.ts | 6 - tools/bend-lint/src/diag.ts | 83 --- tools/bend-lint/src/index.ts | 10 - tools/bend-lint/src/instrument.ts | 145 ----- tools/bend-lint/src/lint.ts | 475 ++++++++++++--- tools/bend-lint/src/load.ts | 96 --- tools/bend-lint/src/patch.ts | 122 ++++ tools/bend-lint/src/rules.ts | 27 - tools/bend-lint/src/spans.ts | 101 ---- tools/bend-lint/src/types.ts | 86 --- tools/bend-lint/src/walk.ts | 44 -- tools/bend-lint/test/cli.test.ts | 82 --- tools/bend-lint/test/drift.test.ts | 33 - tools/bend-lint/test/erasure.test.ts | 54 -- tools/bend-lint/test/fixtures/erasure.bend | 34 -- tools/bend-lint/test/fixtures/format.bend | 23 - tools/bend-lint/test/fixtures/userland.bend | 27 - tools/bend-lint/test/format.test.ts | 63 -- tools/bend-lint/test/instrument.test.ts | 43 -- tools/bend-lint/test/lint.test.ts | 639 +++++++++++++++----- tools/bend-lint/test/rules/erasure.ts | 52 -- tools/bend-lint/test/rules/format.ts | 49 -- tools/bend-lint/test/spans.test.ts | 50 -- tools/bend-lint/test/walk.test.ts | 23 - 28 files changed, 1049 insertions(+), 1454 deletions(-) delete mode 100644 tools/bend-lint/src/cli.ts delete mode 100644 tools/bend-lint/src/core.ts delete mode 100644 tools/bend-lint/src/diag.ts delete mode 100644 tools/bend-lint/src/index.ts delete mode 100644 tools/bend-lint/src/instrument.ts delete mode 100644 tools/bend-lint/src/load.ts create mode 100644 tools/bend-lint/src/patch.ts delete mode 100644 tools/bend-lint/src/rules.ts delete mode 100644 tools/bend-lint/src/spans.ts delete mode 100644 tools/bend-lint/src/types.ts delete mode 100644 tools/bend-lint/src/walk.ts delete mode 100644 tools/bend-lint/test/cli.test.ts delete mode 100644 tools/bend-lint/test/drift.test.ts delete mode 100644 tools/bend-lint/test/erasure.test.ts delete mode 100644 tools/bend-lint/test/fixtures/erasure.bend delete mode 100644 tools/bend-lint/test/fixtures/format.bend delete mode 100644 tools/bend-lint/test/fixtures/userland.bend delete mode 100644 tools/bend-lint/test/format.test.ts delete mode 100644 tools/bend-lint/test/instrument.test.ts delete mode 100644 tools/bend-lint/test/rules/erasure.ts delete mode 100644 tools/bend-lint/test/rules/format.ts delete mode 100644 tools/bend-lint/test/spans.test.ts delete mode 100644 tools/bend-lint/test/walk.test.ts diff --git a/gates/repo.ts b/gates/repo.ts index c5161e9b8a..d66d067bb9 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -85,8 +85,8 @@ allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\ allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json|bend\.pin)$/); -allow(/^tools\/bend-lint\/src\/[a-z]+\.ts$/); -allow(/^tools\/bend-lint\/test\/([a-z]+\/)?[a-z_]+\.(test\.ts|ts|bend)$/); +allow(/^tools\/bend-lint\/src\/(lint|patch)\.ts$/); +allow("tools/bend-lint/test/lint.test.ts"); // Gate // ==== diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 5dc1284da2..66bdcbdf9d 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -4,14 +4,16 @@ rules on it. A rule reads the source text, the checker's types, or both, and returns diagnostics with fixes. Bend itself does not change. +- `src/lint.ts`: the library and the CLI. +- `src/patch.ts`: the two changes made to `bend2/bend.ts` as Bun loads it, + and the pin. + ## Run -Bun 1.2 or newer is required. bend-lint runs where Bun runs; on native -Windows, bend itself cannot load relative imports (bend supports WSL, not -Windows), so a file with one fails its check there. From the repo root: +Bun 1.2 or newer is required. From the repo root: ```sh -bun tools/bend-lint/src/cli.ts file.bend --rules my_rules.ts [--rules more.js] [--fix] +bun tools/bend-lint/src/lint.ts file.bend --rules my_rules.ts [--rules more.js] [--fix] ``` Exit codes: 0 no error, 1 an error was found (a failed check, or a rule @@ -20,13 +22,13 @@ writes the `safe` fixes to the files. ## Rules -A rule module default-exports an array of rules: +A rule module exports `rules`, an array of rules: ```ts -import type { LintRule } from "bend-lint"; // tools/bend-lint/src/index.ts +import type { LintRule } from "./tools/bend-lint/src/lint.ts"; -export default [{ - id: "style/comma-space", // namespace/name; it is the diagnostic code +export const rules: LintRule[] = [{ + id: "style/comma-space", // namespace/name; the code of its findings needsTypes: false, // true gives the rule cx.facts run(cx, signal) { return [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { @@ -37,7 +39,7 @@ export default [{ }); }); }, -}] satisfies LintRule[]; +}]; ``` Rules run in order, one at a time; `cx.prior` holds what earlier rules @@ -47,32 +49,39 @@ found. A finding with severity `error` stops the run. - `sources`, `root`: the files of the book as they are on disk. - `facts` (with `needsTypes`): for each checked term, its type, context, - depth and def. `fact.spn` is a span in the file on disk. -- `span(s)`: a span from bend.ts (for example `term.s`), mapped to the file - on disk. + depth, def, and span in the file on disk. +- `span(s)`: a span from bend.ts (for example `term.s`), in the file on + disk. - `walk`, `binder`, `show`, `same`, `diag`: helpers over bend's terms. - `Bend`: the bend.ts module, for anything else. Get bend.ts from `cx.Bend`. Do not import `bend2/bend.ts` in a rule. -## How types are captured +## The patch + +bend.ts does not expose the checker's results, and builds file paths with +`/`. As Bun loads bend.ts, `patch.ts` changes two things; the file on disk +does not change: + +1. `term_infer` and `term_check` get wrappers that record what they return. + Checking gives the same results. +2. bend.ts gets `fs` and `path` from `patch.ts`, where real paths use `/` + and a drive letter is a root, so imports resolve on Windows too. On + POSIX these behave as node's. -bend.ts does not expose the checker's results. When bend-lint loads -bend.ts, it puts two recording wrappers around `term_infer` and -`term_check`. The file on disk does not change, and checking gives the same -results. The patch looks for the two exact signatures; if they changed, or a -self-check on a tiny program records no type, bend-lint stops with an -error. If bend.ts gets a `book.see` hook one day, bend-lint uses it and does -not patch. +Each change looks for one exact anchor in bend.ts. If an anchor is not +found exactly once, or a self-check on a tiny program records no type, +bend-lint stops with a `DriftError`. -## The bend.ts pin +## The pin `bend.pin` holds the git blob hash of the `bend2/bend.ts` this tool was -tested with. If bend.ts differs, bend-lint stops. To bump: +tested with (`git rev-parse HEAD:bend2/bend.ts`). If bend.ts differs, +bend-lint stops. To bump: ```sh BEND_LINT_UNPINNED=1 bun test tools/bend-lint -bun tools/bend-lint/src/cli.ts --pin +bun tools/bend-lint/src/lint.ts --pin ``` ## Test diff --git a/tools/bend-lint/package.json b/tools/bend-lint/package.json index b8a7b72954..a356dbb0fe 100644 --- a/tools/bend-lint/package.json +++ b/tools/bend-lint/package.json @@ -5,9 +5,9 @@ "private": true, "type": "module", "bin": { - "bend-lint": "src/cli.ts" + "bend-lint": "src/lint.ts" }, - "exports": "./src/index.ts", + "exports": "./src/lint.ts", "scripts": { "test": "bun test", "typecheck": "tsc -p tsconfig.json" diff --git a/tools/bend-lint/src/cli.ts b/tools/bend-lint/src/cli.ts deleted file mode 100644 index d322fe5686..0000000000 --- a/tools/bend-lint/src/cli.ts +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bun -// bend-lint: checks a Bend file with bend's checker, then runs rules on it. -// Exit: 0 ok, 1 an error was found, 2 bad usage or a tool failure. - -import * as fs from "node:fs"; - -import { pin } from "./instrument.ts"; - -const USAGE = [ - "usage: bun tools/bend-lint/src/cli.ts --rules [--rules ...] [--fix]", - " bun tools/bend-lint/src/cli.ts --pin (pin the current bend2/bend.ts)", -].join("\n"); - -function usage(msg: string): number { - console.error("bend-lint: " + msg + "\n" + USAGE); - return 2; -} - -async function main(argv: string[]): Promise { - let file: string | undefined; - const mods: string[] = []; - let fix = false; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === "--pin") { - console.log("bend-lint: pinned bend2/bend.ts " + pin()); - return 0; - } else if (a === "--rules") { - const m = argv[++i]; - if (m === undefined || m.startsWith("-")) return usage("--rules needs a module"); - mods.push(m); - } else if (a === "--fix") { - fix = true; - } else if (a === "-h" || a === "--help") { - console.log(USAGE); - return 0; - } else if (a.startsWith("-")) { - return usage("unknown option " + a); - } else if (file === undefined) { - file = a; - } else { - return usage("one file at a time"); - } - } - if (file === undefined) return usage("no file"); - // loaded here, after --pin, so a pin mismatch does not block --pin - const { lint } = await import("./lint.ts"); - const { render, applyFixes } = await import("./diag.ts"); - const { loadRules } = await import("./rules.ts"); - const res = await lint(file, await loadRules(mods)); - for (const d of res.diags) { - console.log(render(d) + "\n"); - } - if (fix && res.ok) { - for (const src of res.sources) { - if (src.base) continue; - const out = applyFixes(src.file, res.diags); - if (out !== src.text) { - fs.writeFileSync(src.path, out); - console.error("bend-lint: fixed " + src.path); - } - } - } - console.log(res.ok ? "bend-lint: " + res.diags.length + " finding(s)" : "bend-lint: FAIL"); - return res.ok ? 0 : 1; -} - -try { - process.exit(await main(process.argv.slice(2))); -} catch (e) { - console.error("bend-lint: " + (e instanceof Error ? e.message : String(e))); - process.exit(2); -} diff --git a/tools/bend-lint/src/core.ts b/tools/bend-lint/src/core.ts deleted file mode 100644 index 89016633a1..0000000000 --- a/tools/bend-lint/src/core.ts +++ /dev/null @@ -1,6 +0,0 @@ -// The instrumented bend2/bend.ts. Every bend-lint module gets Bend from -// here, so the patch is in place before bend.ts loads. - -import { loadBend, type Bend as BendT } from "./instrument.ts"; - -export const Bend: BendT = await loadBend(); diff --git a/tools/bend-lint/src/diag.ts b/tools/bend-lint/src/diag.ts deleted file mode 100644 index 143dcdab39..0000000000 --- a/tools/bend-lint/src/diag.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Diagnostics: rendering, fixes and LSP positions. - -import type { Err } from "../../../bend2/bend.ts"; -import { Bend } from "./core.ts"; -import { line, starts } from "./spans.ts"; -import type { Applicability, Diag, Edit, Fix, Severity, SourceFile, Span } from "./types.ts"; - -const HEAD: Record = { - error: "Error", warning: "Warning", information: "Information", hint: "Hint", -}; - -export function isErr(e: unknown): e is Err { - return typeof e === "object" && e !== null && (e as { $?: unknown }).$ === "Err"; -} - -// bend's own layout; only the head names the severity and the code. -export function render(d: Diag): string { - const err = isErr(d.core) ? d.core - : Bend.Err(d.bok ?? Bend.book_nil(), d.ctx ?? Bend.ctx_nil(), d.message, d.obs, d.spn, d.def, d.note); - return Bend.err_show(err).replace(/^Error:/, HEAD[d.severity] + " [" + d.code + "]:") - + d.fixes.map(fixShow).join(""); -} - -// A fix, as a unified diff per file. -export function fixShow(fix: Fix): string { - let out = "\n\nFix: " + fix.title + " [" + fix.applicability + "]"; - const files = new Map(); - for (const edit of fix.edits) { - const edits = files.get(edit.spn.file) ?? []; - edits.push(edit); - files.set(edit.spn.file, edits); - } - for (const [file, edits] of files) { - const str = [...edits].sort((a, b) => b.spn.beg - a.spn.beg).reduce( - (s, { spn, text }) => s.slice(0, spn.beg) + text + s.slice(spn.end), file.str); - const old = file.str.match(/[^\n]*\n|[^\n]+$/g) ?? []; - const now = str.match(/[^\n]*\n|[^\n]+$/g) ?? []; - let beg = 0; - let end = old.length; - let tip = now.length; - while (beg < end && beg < tip && old[beg] === now[beg]) ++beg; - while (end > beg && tip > beg && old[end - 1] === now[tip - 1]) { --end; --tip; } - if (beg === end && beg === tip) continue; - const name = (file as Partial).path ?? (file.ns || ""); - const range = (e: number) => (e === beg ? beg : beg + 1) + "," + (e - beg); - const lines = (xs: string[], prefix: string) => xs.map((l) => prefix - + l.replace(/\r?\n$/, "") + (l.endsWith("\n") ? "" : "\n\\ No newline at end of file")); - out += "\n--- " + name + "\n+++ " + name + "\n@@ -" + range(end) + " +" + range(tip) + " @@\n" - + lines(old.slice(beg, end), "-").concat(lines(now.slice(beg, tip), "+")).join("\n"); - } - return out; -} - -// The text of `file` with the fixes of the given levels applied. Edits -// must not overlap. -export function applyFixes(file: SourceFile, diags: Diag[], levels: Applicability[] = ["safe"]): string { - const edits = diags.flatMap((d) => d.fixes) - .filter((f) => levels.includes(f.applicability)) - .flatMap((f) => f.edits).filter((e) => e.spn.file === file) - .sort((a, b) => b.spn.beg - a.spn.beg); - let out = file.str; - let wall = out.length + 1; - for (const { spn, text } of edits) { - if (spn.beg < 0 || spn.beg > spn.end || spn.end > file.str.length || spn.end > wall || spn.beg >= wall) { - throw new Error("bend-lint: fixes are out of bounds or overlap"); - } - out = out.slice(0, spn.beg) + text + out.slice(spn.end); - wall = spn.beg; - } - return out; -} - -export type Position = { line: number; character: number }; - -// LSP range of a span in a file on disk (0-based; UTF-16, as JS strings). -export function position(spn: Span): { start: Position; end: Position } { - const ss = starts(spn.file.str); - const at = (off: number): Position => { - const i = line(ss, off); - return { line: i, character: off - ss[i] }; - }; - return { start: at(spn.beg), end: at(spn.end) }; -} diff --git a/tools/bend-lint/src/index.ts b/tools/bend-lint/src/index.ts deleted file mode 100644 index 7689c2eb87..0000000000 --- a/tools/bend-lint/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// bend-lint as a library. - -export { Bend } from "./core.ts"; -export { lint, validRule } from "./lint.ts"; -export { loadRules } from "./rules.ts"; -export { applyFixes, fixShow, position, render } from "./diag.ts"; -export { children, walk } from "./walk.ts"; -export { PatchError, pin, pinned } from "./instrument.ts"; -export { SpanError } from "./spans.ts"; -export type * from "./types.ts"; diff --git a/tools/bend-lint/src/instrument.ts b/tools/bend-lint/src/instrument.ts deleted file mode 100644 index 1aa9c9703c..0000000000 --- a/tools/bend-lint/src/instrument.ts +++ /dev/null @@ -1,145 +0,0 @@ -// Loads bend2/bend.ts for bend-lint. bend.ts on disk never changes: when -// Bun loads it, two recording wrappers go around term_infer and -// term_check, so typed rules can see what the checker found. If bend.ts -// changes, the tool stops with a clear error instead of giving wrong data. - -import * as crypto from "node:crypto"; -import * as fs from "node:fs"; -import * as url from "node:url"; - -import type * as BendNS from "../../../bend2/bend.ts"; -import type { Book, Ctx, HTerm, LTerm, Name, Span } from "../../../bend2/bend.ts"; - -export type Bend = typeof BendNS; -export type See = (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn?: Span) => void; -export type Hooked = Book & { see?: See }; - -export class PatchError extends Error { - override name = "PatchError"; -} - -export const BEND_TS = fs.realpathSync(url.fileURLToPath(new URL("../../../bend2/bend.ts", import.meta.url))); -export const PIN_FILE = url.fileURLToPath(new URL("../bend.pin", import.meta.url)); -export const UNPINNED = process.env.BEND_LINT_UNPINNED === "1"; - -// The hook, `book.see`. If bend.ts ships it one day, the tool uses it and -// does not patch. -const HOOK = /\bbook\.see\?\.\(/; -const INFER = /^export function term_infer\(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm\[\] = \[\]\): Infer \{/m; -const CHECK = /^export function term_check\(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number\): Check \{/m; -const MARK = "BEND_LINT_PATCH"; - -const INFER_WRAP = [ - "export function term_infer(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm[] = []): Infer {", - " const inf = lint_infer(book, lhs, tm, qt, ctx, d, sp);", - " (book as any).see?.(book, inf.tm, inf.ty, ctx, d, lhs.def, tm.s);", - " return inf;", - "}", - "function lint_infer(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm[]): Infer {", -].join("\n"); - -const CHECK_WRAP = [ - "export function term_check(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number): Check {", - " const chk = lint_check(book, lhs, tm, qt, ty, ctx, d);", - " (book as any).see?.(book, chk.tm, ty, ctx, d, lhs.def, tm.s);", - " return chk;", - "}", - "function lint_check(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number): Check {", -].join("\n"); - -// The git blob hash of a text file, as `git rev-parse HEAD:` prints -// it (CRLF read as LF, as git stores it with core.autocrlf). -export function blob(text: string): string { - const buf = Buffer.from(text.replace(/\r\n/g, "\n"), "utf8"); - return crypto.createHash("sha1").update("blob " + buf.length + "\0").update(buf).digest("hex"); -} - -export function pinned(): string { - return fs.readFileSync(PIN_FILE, "utf8").trim(); -} - -export function current(): string { - return blob(fs.readFileSync(BEND_TS, "utf8")); -} - -export function pin(): string { - const hash = current(); - fs.writeFileSync(PIN_FILE, hash + "\n"); - return hash; -} - -export function checkPin(): void { - const now = current(); - const want = pinned(); - if (now !== want && !UNPINNED) { - throw new PatchError("bend2/bend.ts is " + now + ", but bend-lint is pinned to " + want + ".\n" - + "To bump: BEND_LINT_UNPINNED=1 bun test tools/bend-lint, then bun tools/bend-lint/src/cli.ts --pin"); - } -} - -export function patch(src: string): string { - for (const [re, name] of [[INFER, "term_infer"], [CHECK, "term_check"]] as const) { - const n = src.match(new RegExp(re.source, "gm"))?.length ?? 0; - if (n !== 1) { - throw new PatchError("cannot instrument bend2/bend.ts: found " + n + " " + name - + " signatures, expected 1. bend.ts changed; update tools/bend-lint/src/instrument.ts."); - } - } - if (/\b(lint_infer|lint_check|BEND_LINT_PATCH)\b/.test(src)) { - throw new PatchError("cannot instrument bend2/bend.ts: it already defines a bend-lint name."); - } - return src.replace(INFER, () => INFER_WRAP).replace(CHECK, () => CHECK_WRAP) - + "\nexport const " + MARK + " = 1;\n"; -} - -let loaded: Promise | undefined; - -export function loadBend(): Promise { - return loaded ??= load(); -} - -async function load(): Promise { - checkPin(); - const src = fs.readFileSync(BEND_TS, "utf8"); - const upstream = HOOK.test(src); - if (!upstream) { - if (typeof Bun === "undefined") { - throw new PatchError("bend-lint runs on Bun: bun tools/bend-lint/src/cli.ts"); - } - const out = patch(src); - Bun.plugin({ - name: "bend-lint", - setup(build) { - build.onLoad({ filter: /[\\/]bend2[\\/]bend\.ts$/ }, () => ({ contents: out, loader: "ts" })); - }, - }); - } - const mod = await import(url.pathToFileURL(BEND_TS).href) as Bend & { [MARK]?: number }; - if (!upstream && mod[MARK] !== 1) { - throw new PatchError("bend2/bend.ts was loaded before bend-lint could instrument it. " - + "Import bend-lint before any module that imports bend.ts."); - } - return mod; -} - -const SAMPLE = "type N is Data:\n Z{}\n\ndef id(x: N) -> N:\n x\n"; -let checked = false; - -// Checks a tiny program and requires the type of `x` in `id` to be N. -export function selfCheck(B: Bend): void { - if (checked) { - return; - } - const book = B.book_nil() as Hooked; - const seen: Array<{ bok: Book; tm: LTerm; ty: HTerm; def: Name }> = []; - book.see = (bok, tm, ty, _ctx, _dep, def) => { seen.push({ bok, tm, ty, def }); }; - B.parse_book(book, "/", SAMPLE, "", Object.create(null)); - B.book_valid(book); - const ok = seen.some((f) => f.def === "id" && B.term_strip(f.tm).$ === "Var" - && (B.term_wnf(f.bok, f.ty) as { k?: string }).k === "N"); - if (!ok) { - throw new PatchError("self-check failed: bend.ts was instrumented but recorded no type for `id`. " - + "The checker changed; update tools/bend-lint/src/instrument.ts."); - } - checked = true; -} diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index ea3ce271ab..597f6b4d9f 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -1,96 +1,413 @@ -// Runs rules over a checked book. Rules run one at a time, in order. A -// finding with severity error stops the run; a rule that throws is a bug -// in the rule, so the error goes to the caller. - -import { Bend } from "./core.ts"; -import { selfCheck } from "./instrument.ts"; -import { coreDiag, loadBook, type Loaded } from "./load.ts"; -import { walk } from "./walk.ts"; -import type { Diag, LintResult, LintRule, RuleContext, Source } from "./types.ts"; - -export function validRule(rule: unknown): rule is LintRule { - if (rule === null || typeof rule !== "object") return false; - const r = rule as LintRule; - return typeof r.id === "string" && /^[^/\s]+\/[^/\s]+$/.test(r.id) - && typeof r.run === "function" - && (r.needsTypes === undefined || typeof r.needsTypes === "boolean"); -} - -export async function lint(file: string, rules: LintRule[], opts: { signal?: AbortSignal } = {}): Promise { - const signal = opts.signal ?? new AbortController().signal; - for (const rule of rules) { - if (!validRule(rule)) { - throw new TypeError("bend-lint: invalid rule (needs an id like ns/name and a run function)"); +#!/usr/bin/env bun +// bend-lint checks a Bend file with bend's checker, then runs rules over +// its source and the checker's results. It loads bend2/bend.ts through +// ./patch.ts. Anything in bend.ts it cannot follow stops it with a +// DriftError. As a CLI, it exits 0 when ok, 1 when it found an error, 2 +// on bad usage or a tool failure. + +import * as url from "node:url"; +import * as util from "node:util"; + +import type * as BendModule from "../../../bend2/bend.ts"; +import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Span } from "../../../bend2/bend.ts"; +import { BEND_TS, DriftError, MARK, PIN_FILE, current, fs, patch, path, pinned } from "./patch.ts"; + +// Types +// ===== + +export type Bend = typeof BendModule; +export type Severity = "error" | "warning" | "information" | "hint"; +// safe keeps behavior; suggested may change it; dangerous may break code. +export type Applicability = "safe" | "suggested" | "dangerous"; +export type Edit = { spn: Span; text: string }; +export type Fix = { title: string; applicability: Applicability; edits: Edit[] }; +export type Position = { line: number; character: number }; + +export type Diag = { + code: string; + severity: Severity; + message: string; + spn?: Span; + def?: Name; + ctx?: Ctx; + bok?: Book; + obs?: string; + note?: string; + fixes: Fix[]; + core?: unknown; // bend's own failure, when the check failed +}; + +export type DiagInit = { + message: string; + severity?: Severity; + spn?: Span; + fact?: Fact; + def?: Name; + obs?: string; + note?: string; + fixes?: Fix[]; +}; + +// A file of the book, as it is on disk. +export type SourceFile = { str: string; ns: string; al: Record; path: string }; +export type Source = { path: string; ns: string; text: string; root: boolean; base: boolean; file: SourceFile }; + +// `tm` checked (or inferred) as `ty` at depth `dep` in `ctx`, in def `def`. +// `bok` is the book it was checked in (a template body has its own). +export type Fact = { tm: LTerm; ty: HTerm; bok: Book; ctx: Ctx; dep: number; def: Name; spn?: Span }; + +export type RuleContext = { + Bend: Bend; + book: Book; + sources: Source[]; + root: Source; + facts?: Map; // only for a rule with needsTypes + prior: readonly Diag[]; // what earlier rules found + span(s: S): S; // a bend.ts span, in the file on disk + walk(tm: LTerm): Generator; + binder(fact: Fact, v: LTerm): Ann | null; + show(fact: Fact, ty: HTerm): string; + same(fact: Fact, a: HTerm, b: HTerm): boolean; + diag(init: DiagInit): Diag; +}; + +export type LintRule = { + id: string; // namespace/name; the code of its findings + needsTypes?: boolean; + run(cx: RuleContext, signal: AbortSignal): Diag[] | Promise; +}; + +export type LintResult = { ok: boolean; diags: Diag[]; sources: Source[]; book: Book; facts?: Map }; + +type Hooked = Book & { see?: (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn?: Span) => void }; +type Mapper = (s: S) => S; +type Checked = { book: Book; sources: Source[]; span: Mapper; facts?: Map; failure?: Diag }; + +// Constants +// ========= + +const RULE_ID = /^[^/\s]+\/[^/\s]+$/; +const LINE = /[^\n]*\n|[^\n]+$/g; +const STACK = "the machine stack overflowed (a deep recursion, or a literal too large to expand)"; +const SAMPLE = "type N is Data:\n Z{}\n\ndef id(x: N) -> N:\n x\n"; +const HEAD: Record = { error: "Error", warning: "Warning", information: "Information", hint: "Hint" }; + +// Binders are explicit in checked terms, so Var.v cells are not followed. +// A new term kind is a type error here, and a DriftError when walked. +const CHILDREN: { [K in LTerm["$"]]: (tm: Extract) => LTerm[] } = { + Let: (tm) => [...tm.v, tm.f], + Lam: (tm) => [tm.f], + Sub: (tm) => [tm.f], + App: (tm) => [tm.f, tm.x], + Ctr: (tm) => tm.x, + ADT: (tm) => tm.x, + Mat: (tm) => [tm.h, tm.m], + Rwt: (tm) => [tm.e, tm.p, tm.f], + Typ: (tm) => [tm.g], + Min: (tm) => [tm.a, tm.b], + All: (tm) => [tm.A, tm.B], + Eql: (tm) => [tm.a, tm.b, tm.T], + Ann: (tm) => [tm.x, tm.T], + Var: () => [], + Ref: () => [], + Qnt: () => [], + Qua: () => [], + Lit: () => [], + Efq: () => [], + Rfl: () => [], + Hol: () => [], +}; + +const OPTIONS = { + rules: { type: "string", multiple: true }, + fix: { type: "boolean" }, + pin: { type: "boolean" }, + help: { type: "boolean", short: "h" }, +} as const; + +const USAGE = [ + "usage: bun tools/bend-lint/src/lint.ts [--rules ]... [--fix]", + " bun tools/bend-lint/src/lint.ts --pin (pin the current bend2/bend.ts)", +].join("\n"); + +// Functions +// ========= + +export function* walk(tm: LTerm): Generator { + const children = (CHILDREN as Record LTerm[]) | undefined>)[tm.$]; + if (children === undefined) { + throw new DriftError("unknown term kind " + tm.$ + "; update CHILDREN in tools/bend-lint/src/lint.ts"); + } + yield tm; + for (const child of children(tm)) { + yield* walk(child); + } +} + +function starts(text: string): number[] { + return [0, ...[...text.matchAll(/\n/g)].map((m) => m.index! + 1)]; +} + +function line(starts: number[], off: number): number { + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + [lo, hi] = starts[mid] <= off ? [mid, hi] : [lo, mid - 1]; + } + return lo; +} + +// bend.ts parses a copy of each file with its import lines blanked, so a +// span moves to the file on disk by line and column. A copy that does not +// match its file is a DriftError. +export function mapper(sources: Source[]): Mapper { + const own = new Set(sources.map((s) => s.file)); + const memo = new WeakMap(); + return (s: S): S => { + if (s === undefined || own.has(s.file)) { + return s; + } + const f = s.file as Span["file"] & { dir?: string }; + if (!memo.has(f)) { + const lines = f.str.split("\n"); + const found = sources.filter((src) => { + const theirs = src.text.split("\n"); + return (f.dir === undefined || f.dir === src.path.slice(0, src.path.lastIndexOf("/") + 1)) + && lines.length === theirs.length + && lines.every((l, i) => l === theirs[i] || l.trim() === "" && /^\s*import(\s|$)/.test(theirs[i])); + }); + if (found.length !== 1) { + throw new DriftError("cannot map a bend.ts span to a file on disk (" + found.length + + " candidates); bend.ts may mask imports another way now"); + } + Object.assign(found[0].file.al, f.al); + memo.set(f, { file: found[0].file, from: starts(f.str), to: starts(found[0].text) }); } + const { file, from, to } = memo.get(f)!; + const at = (off: number): number => { + const i = line(from, off); + return to[i] + off - from[i]; + }; + return { file, beg: at(s.beg), end: at(s.end) } as S; + }; +} + +function isErr(e: unknown): e is Err { + return typeof e === "object" && e !== null && (e as { $?: unknown }).$ === "Err"; +} + +// Loads and checks a book as bend2/main.ts book_read does. +async function check(file: string, capture: boolean, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const book: Hooked = Bend.book_nil(); + const seen = new Map(); + const found: Fact[] = []; + book.see = capture ? (bok, tm, ty, ctx, dep, def, spn) => void found.push({ tm, ty, bok, ctx, dep, def, spn }) : undefined; + const caught = await Bend.book_load(book, file, "", seen).then(() => { + const laws = path.join(path.dirname(file), "LAWS.bend"); + if (path.basename(file) === "PROOF.bend" && fs.existsSync(laws) && !seen.has(fs.realpathSync(laws))) { + throw "PROOF.bend must import ./LAWS.bend"; + } + Bend.book_valid(book, 0); + if (book.hols > 0) { + throw book.hols + " TODO" + (book.hols === 1 ? "" : "s") + " found.\nThe code is incomplete, and not a valid proof yet."; + } + }).then(() => undefined, (e: unknown) => ({ e })); + signal.throwIfAborted(); + const root = seen.keys().next().value; + const sources = [...seen.keys()].filter((real) => fs.existsSync(real)).map((real): Source => { + const text = fs.readFileSync(real, "utf8"); + const ns = seen.get(real) ?? ""; + return { path: real, ns, text, root: real === root, base: real === Bend.BASE_BEND, file: { str: text, ns, al: {}, path: real } }; + }); + const span = mapper(sources); + if (caught === undefined) { + return { book, sources, span, facts: capture ? new Map(found.map((f) => [f.tm, { ...f, spn: span(f.spn) }])) : undefined }; } - const capture = rules.some((r) => r.needsTypes === true); - if (capture) { - selfCheck(Bend); + const err = isErr(caught.e) ? caught.e : undefined; + const message = err !== undefined ? Bend.expr_show(err.bok, err.exp) : caught.e instanceof RangeError ? STACK : String(caught.e); + return { book, sources, span, failure: { code: "bend/check", severity: "error", message, spn: span(err?.spn), def: err?.def, fixes: [], core: caught.e } }; +} + +// Rules run in order. A finding with severity error stops the run. A rule +// that throws, and an abort, reach the caller. +export async function lint(file: string, rules: LintRule[], signal = new AbortController().signal): Promise { + if (current() !== pinned() && process.env.BEND_LINT_UNPINNED !== "1") { + throw new DriftError("bend2/bend.ts is " + current() + ", but bend-lint is pinned to " + pinned() + ".\n" + + "To bump: BEND_LINT_UNPINNED=1 bun test tools/bend-lint, then bun tools/bend-lint/src/lint.ts --pin"); } - const ld = await loadBook(file, capture, signal); - const done = (ok: boolean, diags: Diag[]): LintResult => - ({ ok, diags, sources: ld.sources, book: ld.book, facts: ld.facts }); - if (ld.failed) { - return done(false, [coreDiag(ld.failure, ld.spans)]); + const bad = rules.find((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function"); + if (bad !== undefined) { + throw new TypeError("invalid rule " + JSON.stringify(bad?.id) + ": it needs an id like ns/name and a run function"); } - const root = ld.sources.find((s) => s.root)!; - const diags: Diag[] = []; + const { book, sources, span, facts, failure } = await check(file, rules.some((r) => r.needsTypes === true), signal); + if (failure !== undefined) { + return { ok: false, diags: [failure], sources, book }; + } + const root = sources.find((s) => s.root)!; + let diags: Diag[] = []; for (const rule of rules) { signal.throwIfAborted(); - const out = await rule.run(context(rule, ld, root, diags), signal); + const out = await rule.run({ + Bend, book, sources, root, span, walk, + facts: rule.needsTypes === true ? facts : undefined, + prior: diags, + binder: (fact, v) => v.$ === "Var" ? Bend.pmap_get(fact.ctx, v.i) : null, + show: (fact, ty) => Bend.term_show(Bend.term_lower(ty, fact.dep)), + same: (fact, a, b) => Bend.term_compare("EQ", fact.bok, a, b, fact.dep), + diag: (d) => ({ + code: rule.id, severity: d.severity ?? "warning", message: d.message, spn: d.spn, + def: d.def ?? d.fact?.def, ctx: d.fact?.ctx, bok: d.fact?.bok ?? book, obs: d.obs, note: d.note, fixes: d.fixes ?? [], + }), + }, signal); signal.throwIfAborted(); if (!Array.isArray(out)) { - throw new TypeError("bend-lint: rule " + rule.id + " must return an array of diagnostics"); + throw new TypeError("rule " + rule.id + " must return an array of diagnostics"); } - for (const d of out) { - const diag = settle(d, rule.id, ld); - diags.push(diag); - if (diag.severity === "error") { - return done(false, diags); - } + const settled = out.map((d): Diag => ({ + ...d, code: rule.id, spn: span(d.spn), + fixes: d.fixes.map((f) => ({ ...f, edits: f.edits.map((e) => ({ ...e, spn: span(e.spn) })) })), + })); + const stop = settled.findIndex((d) => d.severity === "error"); + diags = [...diags, ...(stop < 0 ? settled : settled.slice(0, stop + 1))]; + if (stop >= 0) { + return { ok: false, diags, sources, book, facts }; } } - return done(true, diags); + return { ok: true, diags, sources, book, facts }; } -// The code is the rule's id; every span is in the file on disk. -function settle(d: Diag, id: string, ld: Loaded): Diag { - return { - ...d, - code: id, - severity: d.severity ?? "warning", - spn: ld.spans.map(d.spn), - fixes: (d.fixes ?? []).map((f) => ({ - ...f, - edits: f.edits.map((e) => ({ ...e, spn: ld.spans.map(e.spn)! })), - })), - }; +// The text of `file` with the edits on it applied. Edits must not overlap. +function edit(file: Span["file"], edits: Edit[]): string { + const mine = edits.filter((e) => e.spn.file === file).sort((a, b) => b.spn.beg - a.spn.beg); + const sound = mine.every(({ spn }, i) => { + const wall = i === 0 ? file.str.length + 1 : mine[i - 1].spn.beg; + return spn.beg >= 0 && spn.beg <= spn.end && spn.end <= file.str.length && spn.beg < wall && spn.end <= wall; + }); + if (!sound) { + throw new Error("bend-lint: fixes are out of bounds or overlap"); + } + return mine.reduce((s, { spn, text }) => s.slice(0, spn.beg) + text + s.slice(spn.end), file.str); } -function context(rule: LintRule, ld: Loaded, root: Source, prior: Diag[]): RuleContext { - return { - Bend, - book: ld.book, - sources: ld.sources, - root, - facts: rule.needsTypes === true ? ld.facts : undefined, - prior, - span: (s) => ld.spans.map(s), - walk, - binder: (fact, v) => v.$ === "Var" ? Bend.pmap_get(fact.ctx, v.i) : null, - show: (fact, ty) => Bend.term_show(Bend.term_lower(ty, fact.dep)), - same: (fact, a, b) => Bend.term_compare("EQ", fact.bok, a, b, fact.dep), - diag: (init) => ({ - code: rule.id, - severity: init.severity ?? "warning", - message: init.message, - spn: init.spn, - def: init.def ?? init.fact?.def, - ctx: init.fact?.ctx, - bok: init.fact?.bok ?? ld.book, - obs: init.obs, - note: init.note, - fixes: init.fixes ?? [], - }), +export function applyFixes(file: SourceFile, diags: Diag[], levels: Applicability[] = ["safe"]): string { + return edit(file, diags.flatMap((d) => d.fixes).filter((f) => levels.includes(f.applicability)).flatMap((f) => f.edits)); +} + +function common(a: string[], b: string[]): number { + const n = a.findIndex((x, i) => x !== b[i]); + return Math.min(n < 0 ? a.length : n, b.length); +} + +// bend's own error layout; the head names the severity and the code, and +// each fix follows as a unified diff. +export function render(d: Diag): string { + const err = isErr(d.core) ? d.core : Bend.Err(d.bok ?? Bend.book_nil(), d.ctx ?? Bend.ctx_nil(), d.message, d.obs, d.spn, d.def, d.note); + const fixes = d.fixes.map((fix) => "\n\nFix: " + fix.title + " [" + fix.applicability + "]" + + [...new Set(fix.edits.map((e) => e.spn.file))].map((file) => { + const old = file.str.match(LINE) ?? []; + const now = edit(file, fix.edits).match(LINE) ?? []; + const pre = common(old, now); + const suf = common(old.slice(pre).reverse(), now.slice(pre).reverse()); + const gone = old.slice(pre, old.length - suf); + const came = now.slice(pre, now.length - suf); + const name = (file as SourceFile).path; + const range = (n: number): string => (n === 0 ? pre : pre + 1) + "," + n; + const show = (xs: string[], sign: string): string[] => xs.map((l) => + sign + l.replace(/\r?\n$/, "") + (l.endsWith("\n") ? "" : "\n\\ No newline at end of file")); + return "\n--- " + name + "\n+++ " + name + "\n@@ -" + range(gone.length) + " +" + range(came.length) + " @@\n" + + [...show(gone, "-"), ...show(came, "+")].join("\n"); + }).join("")); + return Bend.err_show(err).replace(/^Error:/, HEAD[d.severity] + " [" + d.code + "]:") + fixes.join(""); +} + +// The LSP range of a span in a file on disk (0-based, UTF-16). +export function position(spn: Span): { start: Position; end: Position } { + const ss = starts(spn.file.str); + const at = (off: number): Position => { + const i = line(ss, off); + return { line: i, character: off - ss[i] }; }; + return { start: at(spn.beg), end: at(spn.end) }; +} + +// Patches bend.ts as Bun loads it, then checks a tiny program: the type of +// `x` in `id` must be recorded as N. +async function instrument(): Promise { + const patched = patch(fs.readFileSync(BEND_TS, "utf8")); + Bun.plugin({ + name: "bend-lint", + setup: (build) => { + build.onLoad({ filter: /[\\/]bend2[\\/]bend\.ts$/ }, () => ({ contents: patched, loader: "ts" })); + }, + }); + const B: Bend & { [MARK]?: number } = await import(url.pathToFileURL(BEND_TS).href); + if (B[MARK] !== 1) { + throw new DriftError("bend2/bend.ts was loaded before bend-lint could patch it; import bend-lint first"); + } + const sample: Hooked = B.book_nil(); + const seen: Array<{ bok: Book; tm: LTerm; ty: HTerm; def: Name }> = []; + sample.see = (bok, tm, ty, _ctx, _dep, def) => void seen.push({ bok, tm, ty, def }); + B.parse_book(sample, "/", SAMPLE, "", {}); + B.book_valid(sample); + const typed = seen.some((t) => t.def === "id" && B.term_strip(t.tm).$ === "Var" + && (B.term_wnf(t.bok, t.ty) as { k?: string }).k === "N"); + if (!typed) { + throw new DriftError("self-check failed: the patched bend.ts recorded no type for `id`; update tools/bend-lint/src/patch.ts"); + } + return B; +} + +async function cli(argv: string[]): Promise { + const { values, positionals } = util.parseArgs({ args: argv, options: OPTIONS, allowPositionals: true }); + if (values.help) { + console.log(USAGE); + return 0; + } + if (values.pin) { + fs.writeFileSync(PIN_FILE, current() + "\n"); + console.log("bend-lint: pinned bend2/bend.ts " + pinned()); + return 0; + } + if (positionals.length !== 1) { + throw new Error("give one .bend file\n" + USAGE); + } + const modules = await Promise.all((values.rules ?? []).map(async (file): Promise => { + if (file.endsWith(".bend")) { + throw new Error(file + ": rules written in Bend are not supported yet"); + } + const { rules } = await import(url.pathToFileURL(path.resolve(file)).href); + if (!Array.isArray(rules)) { + throw new Error(file + " must export `rules`, an array of rules"); + } + return rules; + })); + const res = await lint(positionals[0], modules.flat()); + const fixed = values.fix && res.ok + ? res.sources.filter((s) => !s.base).map((s) => ({ s, text: applyFixes(s.file, res.diags) })).filter(({ s, text }) => text !== s.text) + : []; + for (const d of res.diags) { + console.log(render(d) + "\n"); + } + for (const { s, text } of fixed) { + fs.writeFileSync(s.path, text); + console.error("bend-lint: fixed " + s.path); + } + console.log(res.ok ? "bend-lint: " + res.diags.length + " finding(s)" : "bend-lint: FAIL"); + return res.ok ? 0 : 1; +} + +function fail(e: unknown): never { + console.error("bend-lint: " + (e instanceof Error ? e.message : String(e))); + process.exit(2); +} + +// Side effects +// ============ + +export const Bend: Bend = await instrument().catch((e: unknown) => import.meta.main ? fail(e) : Promise.reject(e)); + +if (import.meta.main) { + process.exit(await cli(process.argv.slice(2)).catch(fail)); } diff --git a/tools/bend-lint/src/load.ts b/tools/bend-lint/src/load.ts deleted file mode 100644 index 686514b34a..0000000000 --- a/tools/bend-lint/src/load.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Loads and checks a book as bend2/main.ts book_read does, and records -// the checker's results when asked. - -import * as fs from "node:fs"; -import * as path from "node:path"; - -import { Bend } from "./core.ts"; -import { isErr } from "./diag.ts"; -import type { Hooked } from "./instrument.ts"; -import { Spans } from "./spans.ts"; -import type { Book, Diag, Fact, LTerm, Source, Span } from "./types.ts"; - -export type Loaded = { - book: Book; - sources: Source[]; - spans: Spans; - facts?: Map; - failed: boolean; - failure?: unknown; -}; - -export async function loadBook(file: string, capture: boolean, signal: AbortSignal): Promise { - const book = Bend.book_nil() as Hooked; - const seen = new Map(); - const facts = capture ? new Map() : undefined; - let spans: Spans | undefined; - if (facts !== undefined) { - book.see = (bok, tm, ty, ctx, dep, def, raw) => { - let memo: Span | undefined | null = null; - facts.set(tm, { - tm, ty, bok, ctx, dep, def, raw, - get spn() { - if (memo === null) memo = spans!.map(raw); - return memo; - }, - }); - }; - } - let failed = false; - let failure: unknown; - try { - signal.throwIfAborted(); - await Bend.book_load(book, file, "", seen); - signal.throwIfAborted(); - const laws = path.join(path.dirname(file), "LAWS.bend"); - if (path.basename(file) === "PROOF.bend" && fs.existsSync(laws) - && !seen.has(fs.realpathSync(laws))) { - throw "Error: PROOF.bend must import ./LAWS.bend"; - } - Bend.book_valid(book, 0); - if (book.hols > 0) { - throw "Error: " + String(book.hols) + " TODO" + (book.hols === 1 ? "" : "s") - + " found.\nThe code is incomplete, and not a valid proof yet."; - } - } catch (e) { - if (signal.aborted) { - throw signal.reason; - } - failed = true; - failure = e; - } - const root = seen.keys().next().value; - const sources: Source[] = []; - for (const [real, ns] of seen) { - let text: string; - try { - text = fs.readFileSync(real, "utf8"); - } catch { - continue; - } - sources.push({ - path: real, ns: ns ?? undefined, text, root: real === root, base: real === Bend.BASE_BEND, - file: { str: text, ns: ns ?? "", al: Object.create(null), path: real }, - }); - } - spans = new Spans(sources); - return { book, sources, spans, facts, failed, failure }; -} - -// bend's own failure, as a diagnostic. -export function coreDiag(e: unknown, spans: Spans): Diag { - if (isErr(e)) { - let spn: Span | undefined; - try { - spn = spans.map(e.spn); - } catch { - spn = undefined; - } - const message = typeof e.exp === "string" ? e.exp : Bend.expr_show(e.bok, e.exp); - return { code: "bend/check", severity: "error", message, spn, def: e.def, fixes: [], core: e }; - } - const message = e instanceof RangeError - ? "the machine stack overflowed (a deep recursion, or a literal too large to expand)" - : String(e).replace(/^Error:\s*/, ""); - return { code: "bend/check", severity: "error", message, fixes: [] }; -} diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts new file mode 100644 index 0000000000..55946f7dc9 --- /dev/null +++ b/tools/bend-lint/src/patch.ts @@ -0,0 +1,122 @@ +// The two changes bend-lint makes to bend2/bend.ts as Bun loads it; the +// file on disk never changes. One wraps term_infer and term_check so a +// `book.see` hook records what they return. The other gives bend.ts this +// module's fs and path: bend.ts builds paths with "/" (path.posix), so here +// real paths use "/" and a Windows drive letter counts as a root. On POSIX +// they behave as node's. bend.pin holds the bend.ts these were tested on. + +import * as crypto from "node:crypto"; +import * as nodeFs from "node:fs"; +import * as nodePath from "node:path"; +import * as url from "node:url"; + +// Constants +// ========= + +export class DriftError extends Error { + override name = "DriftError"; +} + +const HERE = url.fileURLToPath(new URL(".", import.meta.url)); +const DRIVE = /^[A-Za-z]:(?=\/)/; +const SHIM = JSON.stringify(url.pathToFileURL(nodePath.join(HERE, "patch.ts")).href); + +export const MARK = "BEND_LINT_PATCH"; +export const PIN_FILE = nodePath.join(HERE, "..", "bend.pin"); + +const PATCHES = [ + { + name: "the node:fs import", + at: /^import \* as fs from "node:fs";/m, + to: "import { fs } from " + SHIM + ";", + }, + { + name: "the node:path import", + at: /^import \* as path from "node:path";/m, + to: "import { path } from " + SHIM + ";", + }, + { + name: "term_infer", + at: /^export function term_infer\(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm\[\] = \[\]\): Infer \{/m, + to: [ + "export function term_infer(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm[] = []): Infer {", + " const inf = lint_infer(book, lhs, tm, qt, ctx, d, sp);", + " (book as any).see?.(book, inf.tm, inf.ty, ctx, d, lhs.def, tm.s);", + " return inf;", + "}", + "function lint_infer(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm[]): Infer {", + ].join("\n"), + }, + { + name: "term_check", + at: /^export function term_check\(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number\): Check \{/m, + to: [ + "export function term_check(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number): Check {", + " const chk = lint_check(book, lhs, tm, qt, ty, ctx, d);", + " (book as any).see?.(book, chk.tm, ty, ctx, d, lhs.def, tm.s);", + " return chk;", + "}", + "function lint_check(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number): Check {", + ].join("\n"), + }, +]; + +// fs and path for bend.ts. +export const fs = { ...nodeFs, realpathSync: (p: nodeFs.PathLike): string => slash(nodeFs.realpathSync(p)) }; + +export const path = { + ...nodePath, + join: (...ps: string[]): string => slash(nodePath.join(...ps)), + resolve: (...ps: string[]): string => slash(nodePath.resolve(...ps)), + posix: { ...nodePath.posix, resolve: (...ps: string[]): string => resolve(process.cwd(), ...ps), relative }, +}; + +// Functions +// ========= + +export function slash(p: string): string { + return p.split(nodePath.sep).join("/"); +} + +// path.posix.resolve from `cwd`, where a drive letter is a root. +export function resolve(cwd: string, ...ps: string[]): string { + const all = [cwd, ...ps].map(slash); + const drive = all.filter((p) => DRIVE.test(p)).at(-1)?.slice(0, 2) ?? ""; + return drive + nodePath.posix.resolve(...all.map((p) => p.replace(DRIVE, ""))); +} + +export function relative(from: string, to: string): string { + return nodePath.posix.relative(slash(from).replace(DRIVE, ""), slash(to).replace(DRIVE, "")); +} + +export function patch(src: string): string { + return PATCHES.reduce((out, { name, at, to }) => { + const n = out.match(new RegExp(at.source, "gm"))?.length ?? 0; + if (n !== 1) { + throw new DriftError("cannot patch bend2/bend.ts: found " + n + " of " + name + + ", expected 1. Update PATCHES in tools/bend-lint/src/patch.ts."); + } + return out.replace(at, () => to); + }, src) + "\nexport const " + MARK + " = 1;\n"; +} + +// The git blob hash of a text, as `git rev-parse HEAD:` prints it +// (CRLF counts as LF, as git stores it). +export function blob(text: string): string { + const buf = Buffer.from(text.replace(/\r\n/g, "\n"), "utf8"); + return crypto.createHash("sha1").update("blob " + buf.length + "\0").update(buf).digest("hex"); +} + +export function pinned(): string { + return nodeFs.readFileSync(PIN_FILE, "utf8").trim(); +} + +export function current(): string { + return blob(nodeFs.readFileSync(BEND_TS, "utf8")); +} + +// Side effects +// ============ + +export const BEND_TS = fs.realpathSync(path.join(HERE, "..", "..", "..", "bend2", "bend.ts")); + diff --git a/tools/bend-lint/src/rules.ts b/tools/bend-lint/src/rules.ts deleted file mode 100644 index b6187153a7..0000000000 --- a/tools/bend-lint/src/rules.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Loads rule modules. A module default-exports an array of rules. - -import * as path from "node:path"; -import * as url from "node:url"; - -import { validRule } from "./lint.ts"; -import type { LintRule } from "./types.ts"; - -export async function loadRules(files: string[]): Promise { - const rules: LintRule[] = []; - for (const file of files) { - if (file.endsWith(".bend")) { - throw new Error(file + ": rules written in Bend are not supported yet"); - } - const { default: loaded } = await import(url.pathToFileURL(path.resolve(file)).href); - if (!Array.isArray(loaded)) { - throw new Error(file + " must default-export an array of rules"); - } - for (const rule of loaded) { - if (!validRule(rule)) { - throw new Error(file + " has an invalid rule (needs an id like ns/name and a run function)"); - } - rules.push(rule); - } - } - return rules; -} diff --git a/tools/bend-lint/src/spans.ts b/tools/bend-lint/src/spans.ts deleted file mode 100644 index 24f11ccf51..0000000000 --- a/tools/bend-lint/src/spans.ts +++ /dev/null @@ -1,101 +0,0 @@ -// bend.ts parses a copy of each file with its import lines blanked. The -// lines stay in place, so a span maps to the file on disk by line and -// column. Before a file is mapped, the copy is compared with the file; -// if they do not agree, the tool stops. - -import type { Source, Span } from "./types.ts"; - -export class SpanError extends Error { - override name = "SpanError"; -} - -type Loose = { str: string; ns?: string; dir?: string; al?: Record }; -type Hit = { src: Source; conv(off: number): number }; - -export function starts(text: string): number[] { - const out = [0]; - for (let i = 0; i < text.length; i++) { - if (text.charCodeAt(i) === 10) { - out.push(i + 1); - } - } - return out; -} - -export function line(starts: number[], off: number): number { - let lo = 0; - let hi = starts.length - 1; - while (lo < hi) { - const mid = (lo + hi + 1) >> 1; - if (starts[mid] <= off) lo = mid; else hi = mid - 1; - } - return lo; -} - -export function compatible(masked: string, text: string): boolean { - if (masked === text) { - return true; - } - const ml = masked.split("\n"); - const ol = text.split("\n"); - return ml.length === ol.length && ml.every((l, i) => - l === ol[i] || l.trim() === "" && /^\s*import(\s|$)/.test(ol[i])); -} - -export function dirOf(p: string): string { - return p.slice(0, p.lastIndexOf("/") + 1); -} - -export class Spans { - private hits = new WeakMap(); - private own = new Set(); - - constructor(readonly sources: Source[]) { - for (const s of sources) { - this.own.add(s.file); - } - } - - map(s: Span | undefined): Span | undefined { - if (s === undefined) { - return undefined; - } - if (this.own.has(s.file)) { - return s; - } - const hit = this.find(s.file as Loose); - return { file: hit.src.file, beg: hit.conv(s.beg), end: hit.conv(s.end) }; - } - - private find(f: Loose): Hit { - const old = this.hits.get(f); - if (old !== undefined) { - return old; - } - const found = this.sources.filter((src) => - (f.ns === undefined || src.ns === undefined || f.ns === src.ns) - && (f.dir === undefined || f.dir === dirOf(src.path)) - && compatible(f.str, src.text)); - if (found.length !== 1) { - throw new SpanError("cannot map a bend.ts span to a file on disk (" + found.length - + " candidates). bend.ts may mask imports another way now; update tools/bend-lint/src/spans.ts."); - } - const src = found[0]; - if (f.al !== undefined) { - Object.assign(src.file.al, f.al); - } - const ms = starts(f.str); - const os = starts(src.text); - const same = f.str === src.text; - const hit: Hit = { - src, - conv: (off) => { - if (same) return off; - const i = line(ms, off); - return os[i] + (off - ms[i]); - }, - }; - this.hits.set(f, hit); - return hit; - } -} diff --git a/tools/bend-lint/src/types.ts b/tools/bend-lint/src/types.ts deleted file mode 100644 index a62573c4cc..0000000000 --- a/tools/bend-lint/src/types.ts +++ /dev/null @@ -1,86 +0,0 @@ -// The types rules see. - -import type { Ann, Book, Ctx, HTerm, LTerm, Name, Span } from "../../../bend2/bend.ts"; -import type { Bend } from "./instrument.ts"; - -export type { Ann, Book, Ctx, HTerm, LTerm, Name, Span }; - -export type Severity = "error" | "warning" | "information" | "hint"; - -// safe keeps behavior; suggested may change it; dangerous may break code. -export type Applicability = "safe" | "suggested" | "dangerous"; -export type Edit = { spn: Span; text: string }; -export type Fix = { title: string; applicability: Applicability; edits: Edit[] }; - -export type Diag = { - code: string; - severity: Severity; - message: string; - spn?: Span; - def?: Name; - ctx?: Ctx; - bok?: Book; - obs?: string; - note?: string; - fixes: Fix[]; - core?: unknown; // bend's own failure, when the check failed -}; - -export type DiagInit = { - message: string; - severity?: Severity; - spn?: Span; - fact?: Fact; - def?: Name; - obs?: string; - note?: string; - fixes?: Fix[]; -}; - -// A file of the book, as it is on disk. -export type SourceFile = { str: string; ns: string; al: Record; path: string }; -export type Source = { path: string; ns?: string; text: string; root: boolean; base: boolean; file: SourceFile }; - -// One result of the checker: `tm` checked (or inferred) against `ty` at -// depth `dep`, in `ctx`, inside def `def`. `bok` is the book it was -// checked in (a template body has its own). `spn` is in the file on disk; -// `raw` is the span bend.ts gave. -export type Fact = { - tm: LTerm; - ty: HTerm; - bok: Book; - ctx: Ctx; - dep: number; - def: Name; - raw?: Span; - readonly spn?: Span; -}; - -export type RuleContext = { - Bend: Bend; - book: Book; - sources: Source[]; - root: Source; - facts?: Map; // only for a rule with needsTypes - prior: readonly Diag[]; // what earlier rules found - span(s: Span | undefined): Span | undefined; // a bend.ts span, mapped to the file on disk - walk(tm: LTerm): Generator; - binder(fact: Fact, v: LTerm): Ann | null; - show(fact: Fact, ty: HTerm): string; - same(fact: Fact, a: HTerm, b: HTerm): boolean; - diag(init: DiagInit): Diag; -}; - -export type LintRule = { - id: string; // namespace/name - needsTypes?: boolean; - run(cx: RuleContext, signal: AbortSignal): Diag[] | Promise; -}; - -export type LintResult = { - ok: boolean; - diags: Diag[]; - sources: Source[]; - book: Book; - facts?: Map; -}; diff --git a/tools/bend-lint/src/walk.ts b/tools/bend-lint/src/walk.ts deleted file mode 100644 index cd1563c3da..0000000000 --- a/tools/bend-lint/src/walk.ts +++ /dev/null @@ -1,44 +0,0 @@ -// The children of a checked term. Binders are explicit, so Var.v cells -// are not followed. A new term kind in bend.ts is a type error here, and -// throws when the code runs. - -import type { LTerm } from "./types.ts"; - -export function children(tm: LTerm): LTerm[] { - switch (tm.$) { - case "Let": return [...tm.v, tm.f]; - case "Lam": return [tm.f]; - case "Sub": return [tm.f]; - case "App": return [tm.f, tm.x]; - case "Ctr": return tm.x; - case "ADT": return tm.x; - case "Mat": return [tm.h, tm.m]; - case "Rwt": return [tm.e, tm.p, tm.f]; - case "Typ": return [tm.g]; - case "Min": return [tm.a, tm.b]; - case "All": return [tm.A, tm.B]; - case "Eql": return [tm.a, tm.b, tm.T]; - case "Ann": return [tm.x, tm.T]; - case "Var": - case "Ref": - case "Qnt": - case "Qua": - case "Lit": - case "Efq": - case "Rfl": - case "Hol": - return []; - default: { - const kind: never = tm; - throw new Error("bend-lint: unknown term kind " + (kind as { $: string }).$ - + "; update tools/bend-lint/src/walk.ts"); - } - } -} - -export function* walk(tm: LTerm): Generator { - yield tm; - for (const child of children(tm)) { - yield* walk(child); - } -} diff --git a/tools/bend-lint/test/cli.test.ts b/tools/bend-lint/test/cli.test.ts deleted file mode 100644 index 95fa22624b..0000000000 --- a/tools/bend-lint/test/cli.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { afterAll, expect, test } from "bun:test"; -import { spawnSync } from "node:child_process"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; - -const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); -const fixture = fileURLToPath(new URL("./fixtures/userland.bend", import.meta.url)); -const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-lint-cli-")); -afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); - -const input = path.join(dir, "cli.bend"); -fs.writeFileSync(input, fs.readFileSync(fixture, "utf8")); - -function rule(name: string, body: string): string { - const file = path.join(dir, name); - fs.writeFileSync(file, "export default " + body + ";\n"); - return file; -} - -const first = rule("first.js", `[{ id: "cli/first", needsTypes: true, run(cx) { - if (!cx.facts?.size || !cx.sources.length) throw new Error("missing metadata"); - return [cx.diag({ message: "First rule", severity: "warning" })]; -} }]`); -const second = rule("second.ts", `[{ id: "cli/second", run(cx) { - if (cx.prior[0]?.code !== "cli/first") throw new Error("wrong rule order"); - return [cx.diag({ message: "Second rule", severity: "hint" })]; -} }]`); -const blocking = rule("error.js", `[{ id: "cli/error", run(cx) { - return [cx.diag({ message: "Blocked by rule", severity: "error" })]; -} }]`); - -function run(...args: string[]) { - return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8" }); -} - -test("rules run in order and print bend's layout", () => { - const out = run(input, "--rules", first, "--rules", second); - expect(out.status).toBe(0); - expect(out.stdout).toMatch(/Warning \[cli\/first\]:[\s\S]*Hint \[cli\/second\]:/); -}); - -test("an error finding exits 1", () => { - const out = run(input, "--rules", blocking); - expect(out.status).toBe(1); - expect(out.stdout).toContain("Error [cli/error]:"); -}); - -test("a failed check exits 1", () => { - const bad = path.join(dir, "bad.bend"); - fs.writeFileSync(bad, "def broken(\n"); - const out = run(bad); - expect(out.status).toBe(1); - expect(out.stdout).toContain("Error [bend/check]:"); -}); - -test("bad usage and bad modules exit 2", () => { - expect(run().status).toBe(2); - expect(run(input, "--rules").status).toBe(2); - expect(run(input, "--nope").status).toBe(2); - expect(run(input, "--rules", path.join(dir, "missing.js")).status).toBe(2); - expect(run(input, "--rules", rule("object.js", "{}")).stderr).toMatch(/must default-export an array/); - expect(run(input, "--rules", rule("bad.js", `[{ id: "bad", run() { return []; } }]`)).stderr).toMatch(/invalid rule/); - const bend = path.join(dir, "rule.bend"); - fs.writeFileSync(bend, ""); - expect(run(input, "--rules", bend).stderr).toMatch(/not supported yet/); -}); - -test("--fix applies safe fixes only", () => { - const target = path.join(dir, "fix.bend"); - fs.writeFileSync(target, fs.readFileSync(fixture, "utf8")); - const comma = rule("comma.js", `[{ id: "style/comma-space", run(cx) { - return [...cx.root.text.matchAll(/,(?=\\w)/g)].map((m) => { - const spn = { file: cx.root.file, beg: m.index + 1, end: m.index + 1 }; - return cx.diag({ message: "space", severity: "hint", spn, - fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }] }); - }); -} }]`); - expect(run(target, "--rules", comma, "--fix").status).toBe(0); - expect(fs.readFileSync(target, "utf8")).toContain("generic(~N, a)"); -}); diff --git a/tools/bend-lint/test/drift.test.ts b/tools/bend-lint/test/drift.test.ts deleted file mode 100644 index afecfb9f20..0000000000 --- a/tools/bend-lint/test/drift.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -// bend-lint loads books with its own copy of bend2/main.ts book_read. If -// that copy drifts, these repo tests stop agreeing with their expected -// output: "SOME PROOFS FAIL" means the check fails, anything else passes. - -import { expect, test } from "bun:test"; -import * as fs from "node:fs"; -import { fileURLToPath } from "node:url"; - -import { lint } from "../src/index.ts"; - -const TESTS = [ - "check/alpha_equivalence", - "check/assert_plain_fill", - "check/dependent_telescope", - "check/beta_ann_body", - "check/ctor_arity", - "check/forward_reference", - "check/hole_todo", - "check/typed_let_mismatch", - "import/base_prelude", - "import/string_literal", - "import/alias_shadow", - "import/duplicate_name", -]; - -for (const name of TESTS) { - test(name, async () => { - const file = fileURLToPath(new URL("../../../tests/" + name + ".bend", import.meta.url)); - const first = fs.readFileSync(file, "utf8").match(/^#\|(.*)$/m)![1].trim(); - const res = await lint(file, []); - expect(res.ok).toBe(first !== "SOME PROOFS FAIL"); - }); -} diff --git a/tools/bend-lint/test/erasure.test.ts b/tools/bend-lint/test/erasure.test.ts deleted file mode 100644 index 032b07d79e..0000000000 --- a/tools/bend-lint/test/erasure.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterAll, expect, test } from "bun:test"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { Bend, applyFixes, lint } from "../src/index.ts"; -import type { Book } from "../src/index.ts"; -import { redundantAnnotation } from "./rules/erasure.ts"; - -const file = fileURLToPath(new URL("./fixtures/erasure.bend", import.meta.url)); -const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-lint-erasure-")); -afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); - -function bodies(book: Book): Record { - const out: Record = {}; - for (const name of book.order) { - const tld = book.tlds[name]; - if (tld.$ === "Def" && tld.e !== undefined) out[name] = Bend.term_show(tld.e); - } - return out; -} - -test("finds the four redundant annotations, and the fix keeps every body", async () => { - const source = fs.readFileSync(file, "utf8"); - const res = await lint(file, [redundantAnnotation]); - expect(res.ok).toBe(true); - expect(res.diags.map((d) => d.def).sort()).toEqual(["alias", "dependent", "direct", "generic"]); - expect(res.diags.every((d) => d.fixes.length === 1)).toBe(true); - const root = res.sources.find((s) => s.root)!; - const cleaned = applyFixes(root.file, res.diags, ["suggested"]); - expect(cleaned).toContain("f : N -> N = y => y"); - expect(cleaned).toContain("value : N = Z{}"); - - const fixed = path.join(dir, "erasure.bend"); - fs.writeFileSync(fixed, cleaned); - const after = await lint(fixed, [redundantAnnotation]); - expect(after.ok).toBe(true); - expect(after.diags).toEqual([]); - expect(bodies(after.book)).toEqual(bodies(res.book)); - expect(fs.readFileSync(file, "utf8")).toBe(source); -}); - -test("the annotations it keeps are needed", async () => { - const res = await lint(file, [redundantAnnotation]); - const cleaned = applyFixes(res.sources.find((s) => s.root)!.file, res.diags, ["suggested"]); - const fixed = path.join(dir, "needed.bend"); - for (const [annotation, binding] of [["f : N -> N =", "f ="], ["value : N =", "value ="]]) { - fs.writeFileSync(fixed, cleaned.replace(annotation, binding)); - const broken = await lint(fixed, []); - expect(broken.ok).toBe(false); - expect(broken.diags[0].code).toBe("bend/check"); - } -}); diff --git a/tools/bend-lint/test/fixtures/erasure.bend b/tools/bend-lint/test/fixtures/erasure.bend deleted file mode 100644 index 3d92542ceb..0000000000 --- a/tools/bend-lint/test/fixtures/erasure.bend +++ /dev/null @@ -1,34 +0,0 @@ -# bend-lint fixture: erasure -type N is Data: - Z{} - S{p: N} - -def Alias() -> Type: - N - -def direct(x: N) -> N: - copy : N = x - copy - -def alias(x: N) -> N: - copy : Alias() = x - copy - -def generic(~T: Type, x: T) -> T: - copy : T = x - copy - -def dependent(-x: N, p: {x == x : N}) -> {x == x : N}: - copy : {x == x : N} = p - copy - -def required_lambda(x: N) -> N: - f : N -> N = y => y - f(x) - -def required_constructor() -> N: - value : N = Z{} - value - -def main() -> N: - direct(alias(generic(~N, required_lambda(required_constructor())))) diff --git a/tools/bend-lint/test/fixtures/format.bend b/tools/bend-lint/test/fixtures/format.bend deleted file mode 100644 index dd4eaf3c36..0000000000 --- a/tools/bend-lint/test/fixtures/format.bend +++ /dev/null @@ -1,23 +0,0 @@ -# bend-lint fixture: format -import Base - -type Sample is Data: - SampleValue{} - -def choose(x: Sample,y: Sample) -> Sample: - x - -def text() -> String: - "a,b=c # still text; escaped quote: \"x,y=z\"" - -def marker() -> Char: - '=' - -def proof(-x: Sample) -> {x==x : Sample}: - {==} - -def main() -> Sample: - a : Sample=SampleValue{} # preserve,this=comment - b : Sample =SampleValue{} - f : Sample -> Sample=y=>y - f(choose(a,b)) diff --git a/tools/bend-lint/test/fixtures/userland.bend b/tools/bend-lint/test/fixtures/userland.bend deleted file mode 100644 index 938bf2ff87..0000000000 --- a/tools/bend-lint/test/fixtures/userland.bend +++ /dev/null @@ -1,27 +0,0 @@ -# bend-lint fixture: userland -type N is Data: - Z{} - S{p: N} - -def Alias() -> Type: - N - -def id(x: Alias()) -> Alias(): - x - -def generic(~T: Type, x: T) -> T: - x - -def proof(-x: N) -> {x == x : N}: - {==} - -def peel(x: N) -> N: - match x: - case Z{}: - Z{} - case S{p}: - p - -def main() -> N: - a = id(S{Z{}}) - generic(~N,a) diff --git a/tools/bend-lint/test/format.test.ts b/tools/bend-lint/test/format.test.ts deleted file mode 100644 index 44c608e7fb..0000000000 --- a/tools/bend-lint/test/format.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterAll, expect, test } from "bun:test"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { Bend, applyFixes, lint } from "../src/index.ts"; -import type { Book, Diag, SourceFile } from "../src/index.ts"; -import { spacing, spacingEdits, tokens } from "./rules/format.ts"; - -const file = fileURLToPath(new URL("./fixtures/format.bend", import.meta.url)); -const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-lint-format-")); -afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); - -function asDiags(file: SourceFile): Diag[] { - return spacingEdits(file).map((edit) => ({ - code: spacing.id, severity: "hint", message: "", spn: edit.spn, - fixes: [{ title: "Normalize spacing", applicability: "safe", edits: [edit] }], - })); -} - -function bodies(book: Book): Record { - const out: Record = {}; - for (const name of book.order) { - const tld = book.tlds[name]; - if (tld.$ === "Def" && tld.e !== undefined && !tld.b) out[name] = Bend.term_show(tld.e); - } - return out; -} - -test("strings, characters, comments, operators and CRLF stay whole", () => { - const source = 'a= "x,y=z#text\\"still,string" # comment,a=b\r\n' - + "b= '='\r\nc= ','\r\nd= '#'\r\ne= '\\''\r\n" - + "f(a,\r\n b)\r\nx==y\r\nx=>y\r\nx!=y\r\nx<=y\r\nx>=y\r\nf(a,)\r\n"; - const sf: SourceFile = { str: source, ns: "", al: {}, path: "" }; - const diags = asDiags(sf); - const formatted = applyFixes(sf, diags); - expect(formatted).toBe(source.replace(/^([abcde])=/gm, "$1 =")); - expect(tokens(formatted).map((t) => t.text)).toEqual(tokens(source).map((t) => t.text)); - expect(() => applyFixes(sf, [diags[0], diags[0]])).toThrow(/overlap/); -}); - -test("one rule gives editor findings and formatter edits", async () => { - const res = await lint(file, [spacing]); - expect(res.ok).toBe(true); - expect(res.facts).toBeUndefined(); - expect(res.diags.length).toBe(8); - const root = res.sources.find((s) => s.root)!; - const formatted = applyFixes(root.file, res.diags); - expect(formatted).toContain("def choose(x: Sample, y: Sample)"); - expect(formatted).toContain("a : Sample = SampleValue{} # preserve,this=comment"); - expect(formatted).toContain("b : Sample = SampleValue{}"); - expect(formatted).toContain("f : Sample -> Sample = y=>y"); - expect(formatted).toContain("f(choose(a, b))"); - expect(tokens(formatted).map((t) => t.text)).toEqual(tokens(root.text).map((t) => t.text)); - - const fixed = path.join(dir, "format.bend"); - fs.writeFileSync(fixed, formatted); - const after = await lint(fixed, [spacing]); - expect(after.diags).toEqual([]); - expect(bodies(after.book)).toEqual(bodies(res.book)); - expect(fs.readFileSync(file, "utf8")).toBe(root.text); -}); diff --git a/tools/bend-lint/test/instrument.test.ts b/tools/bend-lint/test/instrument.test.ts deleted file mode 100644 index 9028d74f81..0000000000 --- a/tools/bend-lint/test/instrument.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, test } from "bun:test"; -import * as fs from "node:fs"; - -import { Bend } from "../src/core.ts"; -import { BEND_TS, PatchError, blob, current, patch, pinned, selfCheck } from "../src/instrument.ts"; - -const src = fs.readFileSync(BEND_TS, "utf8"); - -test("patch wraps term_infer and term_check once each", () => { - const out = patch(src); - expect(out.match(/^export function term_infer\(/gm)?.length).toBe(1); - expect(out.match(/^export function term_check\(/gm)?.length).toBe(1); - expect(out).toContain("function lint_infer("); - expect(out).toContain("function lint_check("); - expect(out).toContain("export const BEND_LINT_PATCH = 1;"); -}); - -test("patch fails loudly when a signature changes", () => { - expect(() => patch(src.replace("export function term_infer(book: Book,", "export function term_infer(bk: Book,"))) - .toThrow(PatchError); - expect(() => patch(src.replace(/^export function term_check\(/m, "export function term_check2("))) - .toThrow(/found 0 term_check/); -}); - -test("patch fails loudly on a second signature", () => { - const sig = src.match(/^export function term_check\(.*$/m)![0]; - expect(() => patch(src + "\n" + sig + "\n")).toThrow(/found 2 term_check/); -}); - -test("the loaded bend.ts is the patched one, and the self-check passes", () => { - expect((Bend as unknown as { BEND_LINT_PATCH?: number }).BEND_LINT_PATCH).toBe(1); - expect(() => selfCheck(Bend)).not.toThrow(); -}); - -test("the pin is a git blob hash, and CRLF reads as LF", () => { - expect(pinned()).toMatch(/^[0-9a-f]{40}$/); - expect(blob("a\r\nb\n")).toBe(blob("a\nb\n")); - // git hash-object of "hello\n" - expect(blob("hello\n")).toBe("ce013625030ba8dba906f756967f9e9ca394464a"); - if (process.env.BEND_LINT_UNPINNED !== "1") { - expect(current()).toBe(pinned()); - } -}); diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 0010f5958d..9bc5be6867 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -1,23 +1,120 @@ -import { afterAll, expect, test } from "bun:test"; +import { afterAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { Bend, lint, render } from "../src/index.ts"; -import type { Fact, LintRule, RuleContext } from "../src/index.ts"; +import type { Book, LTerm, Span } from "../../../bend2/bend.ts"; +import { Bend, applyFixes, lint, mapper, render, walk } from "../src/lint.ts"; +import type { Diag, Edit, Fact, LintRule, RuleContext, Source, SourceFile } from "../src/lint.ts"; +import { BEND_TS, DriftError, blob, current, patch, pinned, relative, resolve } from "../src/patch.ts"; -const file = fileURLToPath(new URL("./fixtures/userland.bend", import.meta.url)); -const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-lint-")); -afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); +// Types +// ===== type Loose = Fact & { tm: { x?: { $: string; k?: string; i?: number } } }; +type Token = { text: string; beg: number; end: number }; -function facts(cx: { facts?: Map; book: RuleContext["book"]; walk: RuleContext["walk"] }, name: string): Loose[] { - const tld = cx.book.tlds[name]; - if (tld.$ !== "Def" || tld.e === undefined) return []; - return [...cx.walk(tld.e)].map((tm) => cx.facts!.get(tm)).filter((f): f is Loose => f !== undefined); -} +// Constants +// ========= + +const USERLAND = String.raw`type N is Data: + Z{} + S{p: N} + +def Alias() -> Type: + N + +def id(x: Alias()) -> Alias(): + x + +def generic(~T: Type, x: T) -> T: + x + +def proof(-x: N) -> {x == x : N}: + {==} + +def peel(x: N) -> N: + match x: + case Z{}: + Z{} + case S{p}: + p + +def main() -> N: + a = id(S{Z{}}) + generic(~N,a) +`; + +const ERASURE = String.raw`type N is Data: + Z{} + S{p: N} + +def Alias() -> Type: + N + +def direct(x: N) -> N: + copy : N = x + copy + +def alias(x: N) -> N: + copy : Alias() = x + copy + +def generic(~T: Type, x: T) -> T: + copy : T = x + copy + +def dependent(-x: N, p: {x == x : N}) -> {x == x : N}: + copy : {x == x : N} = p + copy + +def required_lambda(x: N) -> N: + f : N -> N = y => y + f(x) + +def required_constructor() -> N: + value : N = Z{} + value + +def main() -> N: + direct(alias(generic(~N, required_lambda(required_constructor())))) +`; + +const FORMAT = String.raw`import Base + +type Sample is Data: + SampleValue{} + +def choose(x: Sample,y: Sample) -> Sample: + x + +def text() -> String: + "a,b=c # still text; escaped quote: \"x,y=z\"" + +def marker() -> Char: + '=' + +def proof(-x: Sample) -> {x==x : Sample}: + {==} + +def main() -> Sample: + a : Sample=SampleValue{} # preserve,this=comment + b : Sample =SampleValue{} + f : Sample -> Sample=y=>y + f(choose(a,b)) +`; + +// Repo tests whose first expected line says if the check passes. +const DRIFT = [ + "check/alpha_equivalence", "check/assert_plain_fill", "check/dependent_telescope", "check/beta_ann_body", + "check/ctor_arity", "check/forward_reference", "check/hole_todo", "check/typed_let_mismatch", + "import/base_prelude", "import/string_literal", "import/alias_shadow", "import/duplicate_name", +]; + +const DIR = fs.mkdtempSync(path.join(os.tmpdir(), "bend-lint-")); +const CLI = fileURLToPath(new URL("../src/lint.ts", import.meta.url)); const commaSpace: LintRule = { id: "style/comma-space", @@ -37,7 +134,6 @@ const identity: LintRule = { await Promise.resolve(); signal.throwIfAborted(); const body = facts(cx, "id").find((f) => f.dep === 1 && f.tm.x?.$ === "Var")!; - expect(body).toBeDefined(); const ann = cx.Bend.pmap_get(body.ctx, body.tm.x!.i!)!; expect(ann.k).toBe("x"); expect((cx.Bend.term_wnf(body.bok, body.ty) as { k?: string }).k).toBe("N"); @@ -52,160 +148,415 @@ const neverRun: LintRule = { run: () => { throw new Error("this rule must not run"); }, }; -test("source and typed rules report in order, with fixes on the file on disk", async () => { - const text = fs.readFileSync(file, "utf8"); - const res = await lint(file, [commaSpace, identity]); - expect(res.ok).toBe(true); - expect(res.diags.map((d) => d.code)).toEqual([commaSpace.id, identity.id]); - const edit = res.diags[0].fixes[0].edits[0]; - expect(edit.spn.file.str).toBe(text); - expect(render(res.diags[0])).toStartWith("Warning [style/comma-space]:"); - expect(render(res.diags[0])).toContain("generic(~N, a)"); - expect(render(res.diags[1])).toContain("Context:"); - expect(fs.readFileSync(file, "utf8")).toBe(text); -}); +// x : T = v, where v is a variable that already has type T. Constructors +// and lambdas keep theirs: checking needs the expected type. A template +// body is checked again per instance (generic~0), at the same span; the +// first finding at a span is from the def as written, so it is kept. +const redundantAnnotation: LintRule = { + id: "erasure/redundant-local-annotation", + needsTypes: true, + run: (cx) => [...new Map([...cx.facts!.values()].flatMap((fact): Array<[string, Diag]> => { + const term = cx.Bend.term_strip(fact.tm); + const value = cx.span(term.s); + const v = cx.binder(fact, term); + if (term.$ !== "Var" || fact.spn === undefined || value === undefined || v === null || !cx.same(fact, v.T, fact.ty)) { + return []; + } + const prefix = fact.spn.file.str.slice(fact.spn.beg, value.beg); + const name = prefix.match(/^([A-Za-z_][A-Za-z_0-9]*)\s*:/); + if (fact.spn.file !== value.file || fact.spn.beg >= value.beg || prefix.includes("#") || name === null || !prefix.trimEnd().endsWith("=")) { + return []; + } + const spn = { file: fact.spn.file, beg: fact.spn.beg + name[1].length, end: fact.spn.beg + prefix.lastIndexOf("=") }; + return [[spn.beg + ":" + spn.end, cx.diag({ + message: "Remove the redundant annotation: " + v.k + " already has type " + cx.show(fact, v.T) + ".", + severity: "hint", spn, fact, + fixes: [{ title: "Remove redundant local type annotation", applicability: "suggested", edits: [{ spn, text: " " }] }], + })]]; + }).reverse()).values()].reverse(), +}; -test("only rules with needsTypes get facts", async () => { - let seen: unknown = "unset"; - const look: LintRule = { id: "test/look", run: (cx) => { seen = cx.facts; return []; } }; - await lint(file, [look, identity]); - expect(seen).toBeUndefined(); - const plain = await lint(file, [commaSpace]); - expect(plain.facts).toBeUndefined(); -}); +// One space after a comma and on each side of an assignment, in the file +// linted. Quoted literals, comments and compound operators stay whole. +const spacing: LintRule = { + id: "format/spacing", + run: (cx) => spacingEdits(cx.root.file).map((edit) => cx.diag({ + message: "Use one space after a comma and on each side of an assignment.", + severity: "hint", spn: edit.spn, + fixes: [{ title: "Normalize spacing", applicability: "safe", edits: [edit] }], + })), +}; -test("facts cover templates, proofs, matches and fields", async () => { - const probe: LintRule = { - id: "test/probe", - needsTypes: true, - run: (cx) => { - const generic = facts(cx, "generic").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "x")!; - expect(generic.bok.tlds["generic~T"]).toBeDefined(); - const proof = facts(cx, "proof").find((f) => f.tm.x?.$ === "Rfl")!; - expect(proof.ty.$).toBe("Eql"); - expect(proof.dep).toBe(1); - const peel = [...cx.walk((cx.book.tlds.peel as { e: Parameters[0] }).e)]; - expect(peel.some((tm) => tm.$ === "Mat")).toBe(true); - expect(peel.some((tm) => tm.$ === "Ann" && tm.x.$ === "Efq" && !cx.facts!.has(tm))).toBe(true); - const field = facts(cx, "peel").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "p")!; - expect(cx.Bend.pmap_get(field.ctx, field.tm.x!.i!)!.k).toBe("p"); - return []; - }, - }; - expect((await lint(file, [probe])).ok).toBe(true); -}); +// Functions +// ========= -test("a failed check is one bend/check error, and no rule runs", async () => { - const bad = path.join(dir, "bad.bend"); - fs.writeFileSync(bad, "type N is Data:\n Z{}\ndef broken(\n"); - let res = await lint(bad, [neverRun]); - expect(res.ok).toBe(false); - expect(res.diags.map((d) => d.code)).toEqual(["bend/check"]); - expect(render(res.diags[0])).toStartWith("Error [bend/check]:"); - fs.writeFileSync(bad, "type N is Data:\n Z{}\ndef broken() -> N:\n missing\n"); - res = await lint(bad, [neverRun]); - expect(res.ok).toBe(false); - fs.writeFileSync(bad, "type N is Data:\n Z{}\ndef broken() -> N:\n ?TODO\n"); - res = await lint(bad, [neverRun]); - expect(res.diags[0].message).toContain("1 TODO found"); - res = await lint(path.join(dir, "missing.bend"), [neverRun]); - expect(res.ok).toBe(false); -}); +function fixture(name: string, text: string): string { + const file = path.join(DIR, name); + fs.writeFileSync(file, text); + return file; +} -test("an error finding stops later rules; the code is always the rule's id", async () => { - const stamp: LintRule = { id: "test/stamp", run: (cx) => [{ ...cx.diag({ message: "stamped", severity: "hint" }), code: "other/code" }] }; - expect((await lint(file, [stamp])).diags[0].code).toBe(stamp.id); - const stop: LintRule = { id: "test/error", run: (cx) => [cx.diag({ message: "failed", severity: "error" })] }; - const res = await lint(file, [stop, neverRun]); - expect(res.ok).toBe(false); - expect(res.diags.map((d) => d.code)).toEqual([stop.id]); -}); +function facts(cx: Pick, name: string): Loose[] { + const tld = cx.book.tlds[name]; + return tld.$ !== "Def" || tld.e === undefined ? [] + : [...cx.walk(tld.e)].map((tm) => cx.facts!.get(tm)).filter((f): f is Loose => f !== undefined); +} + +function bodies(book: Book): Record { + return Object.fromEntries(book.order.flatMap((name) => { + const tld = book.tlds[name]; + return tld.$ === "Def" && tld.e !== undefined && !tld.b ? [[name, Bend.term_show(tld.e)]] : []; + })); +} -test("a rule that throws, and an abort, reach the caller", async () => { - const boom: LintRule = { id: "test/boom", run: async () => { throw new Error("rule failed"); } }; - await expect(lint(file, [boom])).rejects.toThrow("rule failed"); - const controller = new AbortController(); - const abort: LintRule = { - id: "test/abort", - run: async (_cx, signal) => { expect(signal).toBe(controller.signal); controller.abort(); return []; }, +function root(sources: Source[]): Source { + return sources.find((s) => s.root)!; +} + +function tokens(source: string): Token[] { + const pattern = /#[^\r\n]*|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|==|=>|!=|<=|>=|[^\s]/g; + return [...source.matchAll(pattern)].map((m) => ({ text: m[0], beg: m.index!, end: m.index! + m[0].length })); +} + +function spacingEdits(file: SourceFile): Edit[] { + const stream = tokens(file.str); + const gap = (left?: Token, right?: Token): Edit[] => { + const between = left === undefined || right === undefined ? "#" : file.str.slice(left.end, right.beg); + return right?.text.startsWith("#") || !/^[ \t]*$/.test(between) || between === " " + ? [] : [{ spn: { file, beg: left!.end, end: right!.beg }, text: " " }]; }; - await expect(lint(file, [abort, neverRun], { signal: controller.signal })).rejects.toThrow(); - await expect(lint(file, [], { signal: controller.signal })).rejects.toThrow(); + return stream.flatMap((token, i) => [ + ...(token.text === "=" ? gap(stream[i - 1], token) : []), + ...((token.text === "=" || token.text === ",") && !["}", ")", "]"].includes(stream[i + 1]?.text ?? "}") + ? gap(token, stream[i + 1]) : []), + ]); +} + +function run(...args: string[]) { + return spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8" }); +} + +function module(name: string, body: string): string { + return fixture(name, "export const rules = " + body + ";\n"); +} + +// Tests +// ===== + +afterAll(() => fs.rmSync(DIR, { recursive: true, force: true })); + +describe("patch", () => { + const src = fs.readFileSync(BEND_TS, "utf8"); + + test("wraps term_infer and term_check, and swaps fs and path", () => { + const out = patch(src); + expect(out.match(/^export function term_infer\(/gm)?.length).toBe(1); + expect(out).toContain("function lint_infer("); + expect(out).toContain("function lint_check("); + expect(out).toMatch(/^import \{ fs \} from "file:.*patch\.ts";/m); + expect(out).toMatch(/^import \{ path \} from "file:.*patch\.ts";/m); + }); + + test("fails loudly when an anchor changes or repeats", () => { + expect(() => patch(src.replace("term_infer(book: Book,", "term_infer(bk: Book,"))).toThrow(DriftError); + expect(() => patch(src.replace('import * as fs from "node:fs";', 'import fs from "node:fs";'))).toThrow(/node:fs import/); + expect(() => patch(src + "\n" + src.match(/^export function term_check\(.*$/m)![0] + "\n")).toThrow(/found 2 of term_check/); + }); + + test("the loaded bend.ts is the patched one", () => { + expect((Bend as unknown as Record).BEND_LINT_PATCH).toBe(1); + }); + + test("the pin is a git blob hash, and matches bend.ts", () => { + expect(blob("a\r\nb\n")).toBe(blob("a\nb\n")); + expect(blob("hello\n")).toBe("ce013625030ba8dba906f756967f9e9ca394464a"); + expect(pinned()).toMatch(/^[0-9a-f]{40}$/); + if (process.env.BEND_LINT_UNPINNED !== "1") expect(current()).toBe(pinned()); + }); + + test("paths: a drive letter is a root; POSIX paths are unchanged", () => { + expect(resolve("C:/cwd", "C:/Users/m/", "./dep.bend")).toBe("C:/Users/m/dep.bend"); + expect(resolve("C:/cwd", "C:/Users/m/", "/shared/x.bend")).toBe("C:/shared/x.bend"); + expect(resolve("/home/x", "/a/b/", "../c.bend")).toBe("/a/c.bend"); + expect(resolve("/home/x", "y.bend")).toBe("/home/x/y.bend"); + expect(relative("C:/Users/m/", "C:/Users/m/sub/d.bend")).toBe("sub/d.bend"); + expect(relative("/a/b", "/a/c/d.bend")).toBe("../c/d.bend"); + }); }); -// bend resolves relative imports with "/" paths, and bend does not run on -// native Windows ("No Windows (WSL works)"), so this test does not either. -test.skipIf(process.platform === "win32")("spans after import lines point at the right text on disk", async () => { - const dep = path.join(dir, "dep.bend"); - const main = path.join(dir, "main.bend"); - fs.writeFileSync(dep, "type N is Data:\n Z{}\ndef id(x: N) -> N:\n x\n"); - fs.writeFileSync(main, "import ./dep.bend as D\n\ndef main() -> D.N:\n D.id(D.Z{})\n"); - const look: LintRule = { - id: "test/imports", - needsTypes: true, - run: (cx) => { - const mine = cx.sources.find((s) => s.path === fs.realpathSync(main))!; - expect(mine.root).toBe(true); - expect(cx.sources.find((s) => s.path === fs.realpathSync(dep))!.ns).toBe("dep"); - const spanned = [...cx.facts!.values()].filter((f) => f.def === "main" && f.spn !== undefined); - expect(spanned.length).toBeGreaterThan(0); - for (const f of spanned) { - expect(f.spn!.file).toBe(mine.file); - const text = mine.text.slice(f.spn!.beg, f.spn!.end); - expect(text.length).toBeGreaterThan(0); - expect(mine.text.slice(f.spn!.beg).startsWith(text)).toBe(true); - } - const call = spanned.map((f) => mine.text.slice(f.spn!.beg, f.spn!.end)); - expect(call.some((t) => t.startsWith("D.id") || t.startsWith("D.Z"))).toBe(true); - return []; - }, - }; - const res = await lint(main, [look]); - expect(res.diags.map(render)).toEqual([]); - expect(res.ok).toBe(true); - expect(res.sources.length).toBe(2); +describe("spans", () => { + const source = (p: string, text: string): Source => + ({ path: p, ns: "", text, root: true, base: false, file: { str: text, ns: "", al: {}, path: p } }); + + for (const eol of ["\n", "\r\n"]) { + const text = ["import Base", "import ./a.bend as A", "", "def f() -> N:", " x", ""].join(eol); + const lines = text.split("\n"); + for (const [kind, masked] of [ + ["empty", lines.map((l) => l.startsWith("import") ? "" : l).join("\n")], + ["spaces", lines.map((l) => l.startsWith("import") ? " ".repeat(l.length) : l).join("\n")], + ]) { + test("map past " + kind + " import lines (" + JSON.stringify(eol) + ")", () => { + const src = source("/p/m.bend", text); + const beg = masked.indexOf("x"); + const got = mapper([src])({ file: { str: masked, ns: "", dir: "/p/", al: { A: "a" } }, beg, end: beg + 1 } as Span); + expect(got.file).toBe(src.file); + expect(text.slice(got.beg, got.end)).toBe("x"); + expect(src.file.al.A).toBe("a"); + }); + } + } + + test("a span on disk passes through; a copy that does not match fails", () => { + const src = source("/p/m.bend", "import Base\ndef f() -> N:\n x\n"); + const map = mapper([src]); + const spn = { file: src.file, beg: 2, end: 3 }; + expect(map(spn)).toBe(spn); + expect(() => map({ file: { str: "\ndef g() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 })).toThrow(DriftError); + expect(() => map({ file: { str: "def f() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 })).toThrow(DriftError); + }); + + test("the directory picks between equal files", () => { + const a = source("/a/m.bend", "import Base\nx\n"); + const b = source("/b/m.bend", "import Base\nx\n"); + const map = mapper([a, b]); + expect(map({ file: { str: "\nx\n", ns: "", dir: "/b/", al: {} }, beg: 1, end: 2 } as Span).file).toBe(b.file); + }); }); -test("lints in flight keep their own rules and facts", async () => { - let resume!: () => void; - let entered!: () => void; - const waiting = new Promise((r) => { resume = r; }); - const started = new Promise((r) => { entered = r; }); - const paused: LintRule = { - id: "test/paused", - run: async (cx) => { expect(cx.facts).toBeUndefined(); entered(); await waiting; return []; }, - }; - const first = lint(file, [paused]); - await started; - try { - const second = await lint(file, [identity]); +describe("lint", () => { + const userland = fixture("userland.bend", USERLAND); + + test("source and typed rules report in order, with fixes on the file on disk", async () => { + const res = await lint(userland, [commaSpace, identity]); + expect(res.ok).toBe(true); + expect(res.diags.map((d) => d.code)).toEqual([commaSpace.id, identity.id]); + expect(res.diags[0].fixes[0].edits[0].spn.file.str).toBe(USERLAND); + expect(render(res.diags[0])).toStartWith("Warning [style/comma-space]:"); + expect(render(res.diags[0])).toContain("generic(~N, a)"); + expect(render(res.diags[1])).toContain("Context:"); + }); + + test("only rules with needsTypes get facts", async () => { + const look: LintRule = { id: "test/look", run: (cx) => { expect(cx.facts).toBeUndefined(); return []; } }; + expect((await lint(userland, [look, identity])).ok).toBe(true); + expect((await lint(userland, [commaSpace])).facts).toBeUndefined(); + }); + + test("facts cover templates, proofs, matches and fields", async () => { + const probe: LintRule = { + id: "test/probe", + needsTypes: true, + run: (cx) => { + const generic = facts(cx, "generic").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "x")!; + expect(generic.bok.tlds["generic~T"]).toBeDefined(); + const proof = facts(cx, "proof").find((f) => f.tm.x?.$ === "Rfl")!; + expect(proof.ty.$).toBe("Eql"); + expect(proof.dep).toBe(1); + const peel = [...cx.walk((cx.book.tlds.peel as { e: LTerm }).e)]; + expect(peel.some((tm) => tm.$ === "Mat")).toBe(true); + expect(peel.some((tm) => tm.$ === "Ann" && tm.x.$ === "Efq" && !cx.facts!.has(tm))).toBe(true); + const field = facts(cx, "peel").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "p")!; + expect(cx.Bend.pmap_get(field.ctx, field.tm.x!.i!)!.k).toBe("p"); + return []; + }, + }; + expect((await lint(userland, [probe])).ok).toBe(true); + }); + + test("walk reaches every kind in checked bodies; an unknown kind fails", async () => { + const res = await lint(userland, []); + const kinds = new Set(res.book.order.flatMap((name) => { + const tld = res.book.tlds[name]; + return tld.$ === "Def" && tld.e !== undefined ? [...walk(tld.e)].map((tm) => tm.$) : []; + })); + for (const k of ["Mat", "Lam", "App", "Var", "Ctr"]) expect(kinds.has(k as LTerm["$"])).toBe(true); + expect(() => walk({ $: "Nope" } as unknown as LTerm).next()).toThrow(DriftError); + }); + + test("a failed check is one bend/check error, and no rule runs", async () => { + const parse = await lint(fixture("parse.bend", "type N is Data:\n Z{}\ndef broken(\n"), [neverRun]); + expect(parse.ok).toBe(false); + expect(parse.diags.map((d) => d.code)).toEqual(["bend/check"]); + expect(render(parse.diags[0])).toStartWith("Error [bend/check]:"); + expect((await lint(fixture("name.bend", "type N is Data:\n Z{}\ndef broken() -> N:\n missing\n"), [neverRun])).ok).toBe(false); + const todo = await lint(fixture("todo.bend", "type N is Data:\n Z{}\ndef broken() -> N:\n ?TODO\n"), [neverRun]); + expect(todo.diags[0].message).toContain("1 TODO found"); + expect((await lint(path.join(DIR, "missing.bend"), [neverRun])).ok).toBe(false); + }); + + test("an error finding stops later rules; the code is always the rule's id", async () => { + const stamp: LintRule = { id: "test/stamp", run: (cx) => [{ ...cx.diag({ message: "stamped", severity: "hint" }), code: "other/code" }] }; + expect((await lint(userland, [stamp])).diags[0].code).toBe(stamp.id); + const stop: LintRule = { id: "test/error", run: (cx) => [cx.diag({ message: "failed", severity: "error" })] }; + const res = await lint(userland, [stop, neverRun]); + expect(res.ok).toBe(false); + expect(res.diags.map((d) => d.code)).toEqual([stop.id]); + }); + + test("a bad rule, a rule that throws, and an abort reach the caller", async () => { + await expect(lint(userland, [{ id: "bad", run: () => [] }])).rejects.toThrow(/invalid rule/); + await expect(lint(userland, [{ id: "test/boom", run: async () => { throw new Error("rule failed"); } }])).rejects.toThrow("rule failed"); + const controller = new AbortController(); + const abort: LintRule = { id: "test/abort", run: async () => { controller.abort(); return []; } }; + await expect(lint(userland, [abort, neverRun], controller.signal)).rejects.toThrow(); + await expect(lint(userland, [], controller.signal)).rejects.toThrow(); + }); + + test("spans after import lines point at the right text on disk", async () => { + fixture("dep.bend", "type N is Data:\n Z{}\ndef id(x: N) -> N:\n x\n"); + const main = fixture("main.bend", "import ./dep.bend as D\n\ndef main() -> D.N:\n D.id(D.Z{})\n"); + const look: LintRule = { + id: "test/imports", + needsTypes: true, + run: (cx) => { + expect(cx.root.path).toEndWith("/main.bend"); + expect(cx.sources.find((s) => s.path.endsWith("/dep.bend"))!.ns).toBe("dep"); + const texts = [...cx.facts!.values()].filter((f) => f.def === "main" && f.spn !== undefined).map((f) => { + expect(f.spn!.file).toBe(cx.root.file); + return cx.root.text.slice(f.spn!.beg, f.spn!.end); + }); + expect(texts.some((t) => t.startsWith("D.id") || t.startsWith("D.Z"))).toBe(true); + return []; + }, + }; + const res = await lint(main, [look]); + expect(res.diags.map(render)).toEqual([]); + expect(res.sources.length).toBe(2); + }); + + test("lints in flight keep their own rules and facts", async () => { + const gate = Promise.withResolvers(); + const entered = Promise.withResolvers(); + const paused: LintRule = { + id: "test/paused", + run: async (cx) => { expect(cx.facts).toBeUndefined(); entered.resolve(); await gate.promise; return []; }, + }; + const first = lint(userland, [paused]); + await entered.promise; + const second = await lint(userland, [identity]).finally(() => gate.resolve()); expect(second.facts!.size).toBeGreaterThan(0); expect(second.diags.map((d) => d.code)).toEqual([identity.id]); - } finally { - resume(); - } - const res = await first; - expect(res.facts).toBeUndefined(); - expect(res.diags).toEqual([]); -}); + expect((await first).diags).toEqual([]); + }); -test("a rule may await I/O, and later rules see earlier findings", async () => { - const server = Bun.serve({ port: 0, fetch: async (req) => new Response("advice for " + (await req.json()).type) }); - try { + test("a rule may await I/O, and later rules see earlier findings", async () => { + const server = Bun.serve({ port: 0, fetch: async (req) => new Response("advice for " + (await req.json()).type) }); const remote: LintRule = { id: "test/api", needsTypes: true, run: async (cx, signal) => { - const fact = cx.facts!.get((cx.book.tlds.id as { e: Parameters[0] }).e)!; - const type = cx.Bend.term_wnf(fact.bok, fact.ty) as { $: string; A: Parameters[1] }; + const fact = cx.facts!.get((cx.book.tlds.id as { e: LTerm }).e)!; + const type = cx.Bend.term_wnf(fact.bok, fact.ty) as { A: typeof fact.ty }; const arg = cx.Bend.term_wnf(fact.bok, type.A) as { k?: string }; const res = await fetch(server.url, { method: "POST", body: JSON.stringify({ type: arg.k }), signal }); return [cx.diag({ message: await res.text(), severity: "hint" })]; }, }; const next: LintRule = { id: "test/after-api", run: (cx) => { expect(cx.prior[0].message).toBe("advice for N"); return []; } }; - expect((await lint(file, [remote, next])).ok).toBe(true); - } finally { - server.stop(true); + const res = await lint(userland, [remote, next]).finally(() => server.stop(true)); + expect(res.ok).toBe(true); + }); +}); + +describe("rules", () => { + test("erasure: four redundant annotations; the fix keeps every body", async () => { + const file = fixture("erasure.bend", ERASURE); + const res = await lint(file, [redundantAnnotation]); + expect(res.diags.map((d) => d.def).sort()).toEqual(["alias", "dependent", "direct", "generic"]); + const cleaned = applyFixes(root(res.sources).file, res.diags, ["suggested"]); + expect(cleaned).toContain("f : N -> N = y => y"); + expect(cleaned).toContain("value : N = Z{}"); + const after = await lint(fixture("erasure_fixed.bend", cleaned), [redundantAnnotation]); + expect(after.diags).toEqual([]); + expect(bodies(after.book)).toEqual(bodies(res.book)); + for (const [annotation, binding] of [["f : N -> N =", "f ="], ["value : N =", "value ="]]) { + const broken = await lint(fixture("erasure_broken.bend", cleaned.replace(annotation, binding)), []); + expect(broken.diags.map((d) => d.code)).toEqual(["bend/check"]); + } + expect(fs.readFileSync(file, "utf8")).toBe(ERASURE); + }); + + test("format: strings, characters, comments, operators and CRLF stay whole", () => { + const source = String.raw`a= "x,y=z#text\"still,string" # comment,a=b` + "\r\n" + + String.raw`b= '='` + "\r\n" + "c= ','\r\nd= '#'\r\n" + String.raw`e= '\''` + "\r\n" + + "f(a,\r\n b)\r\nx==y\r\nx=>y\r\nx!=y\r\nx<=y\r\nx>=y\r\nf(a,)\r\n"; + const file: SourceFile = { str: source, ns: "", al: {}, path: "" }; + const diags = spacingEdits(file).map((edit): Diag => + ({ code: spacing.id, severity: "hint", message: "", fixes: [{ title: "", applicability: "safe", edits: [edit] }] })); + const formatted = applyFixes(file, diags); + expect(formatted).toBe(source.replace(/^([abcde])=/gm, "$1 =")); + expect(tokens(formatted).map((t) => t.text)).toEqual(tokens(source).map((t) => t.text)); + expect(() => applyFixes(file, [diags[0], diags[0]])).toThrow(/overlap/); + }); + + test("format: one rule gives editor findings and formatter edits", async () => { + const res = await lint(fixture("format.bend", FORMAT), [spacing]); + expect(res.facts).toBeUndefined(); + expect(res.diags.length).toBe(8); + const formatted = applyFixes(root(res.sources).file, res.diags); + for (const line of ["def choose(x: Sample, y: Sample)", "a : Sample = SampleValue{} # preserve,this=comment", + "b : Sample = SampleValue{}", "f : Sample -> Sample = y=>y", "f(choose(a, b))"]) { + expect(formatted).toContain(line); + } + expect(tokens(formatted).map((t) => t.text)).toEqual(tokens(FORMAT).map((t) => t.text)); + const after = await lint(fixture("format_fixed.bend", formatted), [spacing]); + expect(after.diags).toEqual([]); + expect(bodies(after.book)).toEqual(bodies(res.book)); + }); +}); + +describe("drift: the copy of book_read agrees with the repo tests", () => { + for (const name of DRIFT) { + test(name, async () => { + const file = fileURLToPath(new URL("../../../tests/" + name + ".bend", import.meta.url)); + const first = fs.readFileSync(file, "utf8").match(/^#\|(.*)$/m)![1].trim(); + expect((await lint(file, [])).ok).toBe(first !== "SOME PROOFS FAIL"); + }); } }); + +describe("cli", () => { + const input = fixture("cli.bend", USERLAND); + const first = module("first.js", `[{ id: "cli/first", needsTypes: true, run(cx) { + if (!cx.facts?.size || !cx.sources.length) throw new Error("missing metadata"); + return [cx.diag({ message: "First rule", severity: "warning" })]; + } }]`); + const second = module("second.ts", `[{ id: "cli/second", run(cx) { + if (cx.prior[0]?.code !== "cli/first") throw new Error("wrong rule order"); + return [cx.diag({ message: "Second rule", severity: "hint" })]; + } }]`); + + test("rules run in order and print bend's layout", () => { + const out = run(input, "--rules", first, "--rules", second); + expect(out.status).toBe(0); + expect(out.stdout).toMatch(/Warning \[cli\/first\]:[\s\S]*Hint \[cli\/second\]:/); + }); + + test("an error finding, or a failed check, exits 1", () => { + const blocking = module("error.js", `[{ id: "cli/error", run: (cx) => [cx.diag({ message: "Blocked", severity: "error" })] }]`); + expect(run(input, "--rules", blocking)).toMatchObject({ status: 1, stdout: expect.stringContaining("Error [cli/error]:") }); + expect(run(fixture("cli_bad.bend", "def broken(\n"))).toMatchObject({ status: 1, stdout: expect.stringContaining("Error [bend/check]:") }); + }); + + test("bad usage and bad modules exit 2", () => { + expect(run().status).toBe(2); + expect(run(input, "--rules").status).toBe(2); + expect(run(input, "--nope").status).toBe(2); + expect(run(input, "--rules", path.join(DIR, "missing.js")).status).toBe(2); + expect(run(input, "--rules", module("object.js", "{}")).stderr).toMatch(/must export `rules`/); + expect(run(input, "--rules", module("bad.js", `[{ id: "bad", run() { return []; } }]`)).stderr).toMatch(/invalid rule/); + expect(run(input, "--rules", fixture("rule.bend", "")).stderr).toMatch(/not supported yet/); + }); + + test("--fix applies safe fixes", () => { + const target = fixture("fix.bend", USERLAND); + const comma = module("comma.js", `[{ id: "style/comma-space", run: (cx) => + [...cx.root.text.matchAll(/,(?=\\w)/g)].map((m) => { + const spn = { file: cx.root.file, beg: m.index + 1, end: m.index + 1 }; + return cx.diag({ message: "space", severity: "hint", spn, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }] }); + }) }]`); + expect(run(target, "--rules", comma, "--fix").status).toBe(0); + expect(fs.readFileSync(target, "utf8")).toContain("generic(~N, a)"); + }); +}); diff --git a/tools/bend-lint/test/rules/erasure.ts b/tools/bend-lint/test/rules/erasure.ts deleted file mode 100644 index abb27382c1..0000000000 --- a/tools/bend-lint/test/rules/erasure.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Finds local annotations the checker does not need: `x : T = v` where v -// is a variable that already has type T. Constructors and lambdas keep -// theirs, since checking needs the expected type. - -import type { Diag, Edit, Fact, LintRule, RuleContext, Span } from "../../src/index.ts"; - -export const redundantAnnotation: LintRule = { - id: "erasure/redundant-local-annotation", - needsTypes: true, - run(cx, signal) { - const found = new Map(); - for (const fact of cx.facts!.values()) { - signal.throwIfAborted(); - const d = redundant(cx, fact); - if (d === undefined) continue; - const key = cx.sources.findIndex((s) => s.file === d.spn!.file) + ":" + d.spn!.beg + ":" + d.spn!.end; - if (!found.has(key)) found.set(key, d); - } - return [...found.values()]; - }, -}; - -function redundant(cx: RuleContext, fact: Fact): Diag | undefined { - const term = cx.Bend.term_strip(fact.tm); - const binding = fact.spn; - const value = cx.span(term.s); - if (term.$ !== "Var" || binding === undefined || value === undefined) return; - const v = cx.binder(fact, term); - if (v === null || !cx.same(fact, v.T, fact.ty)) return; - const edit = annotationEdit(binding, value); - if (edit === undefined) return; - return cx.diag({ - message: "Remove the redundant annotation: " + v.k + " already has type " + cx.show(fact, v.T) + ".", - severity: "hint", - spn: edit.spn, - fact, - fixes: [{ title: "Remove redundant local type annotation", applicability: "suggested", edits: [edit] }], - }); -} - -function annotationEdit(binding: Span, value: Span): Edit | undefined { - if (binding.file !== value.file || binding.beg >= value.beg) return; - const prefix = binding.file.str.slice(binding.beg, value.beg); - if (prefix.includes("#")) return; - const name = prefix.match(/^([A-Za-z_][A-Za-z_0-9]*)\s*:/); - if (name === null || !prefix.trimEnd().endsWith("=")) return; - const beg = binding.beg + name[1].length; - const end = binding.beg + prefix.lastIndexOf("="); - return { spn: { file: binding.file, beg, end }, text: " " }; -} - -export default [redundantAnnotation]; diff --git a/tools/bend-lint/test/rules/format.ts b/tools/bend-lint/test/rules/format.ts deleted file mode 100644 index 9cf2c92b35..0000000000 --- a/tools/bend-lint/test/rules/format.ts +++ /dev/null @@ -1,49 +0,0 @@ -// One space after a comma and on each side of an assignment. Quoted -// literals, comments and compound operators stay whole; only gaps change. - -import type { Edit, LintRule, SourceFile } from "../../src/index.ts"; - -type Token = { text: string; beg: number; end: number }; - -export function tokens(source: string): Token[] { - const pattern = /#[^\r\n]*|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|==|=>|!=|<=|>=|[^\s]/g; - return [...source.matchAll(pattern)].map((m) => ({ text: m[0], beg: m.index!, end: m.index! + m[0].length })); -} - -function gapEdit(file: SourceFile, left?: Token, right?: Token): Edit | undefined { - if (left === undefined || right === undefined || right.text.startsWith("#")) return; - const gap = file.str.slice(left.end, right.beg); - if (!/^[ \t]*$/.test(gap) || gap === " ") return; - return { spn: { file, beg: left.end, end: right.beg }, text: " " }; -} - -export function spacingEdits(file: SourceFile): Edit[] { - const stream = tokens(file.str); - const edits: Array = []; - for (let i = 0; i < stream.length; i++) { - const token = stream[i]; - if (token.text === "=") edits.push(gapEdit(file, stream[i - 1], token)); - if (token.text === "=" || token.text === ",") { - const next = stream[i + 1]; - if (next !== undefined && !["}", ")", "]"].includes(next.text)) edits.push(gapEdit(file, token, next)); - } - } - return edits.filter((e): e is Edit => e !== undefined); -} - -// Formats the file that was linted; imports are formatted when linted -// themselves. -export const spacing: LintRule = { - id: "format/spacing", - run(cx, signal) { - signal.throwIfAborted(); - return spacingEdits(cx.root.file).map((edit) => cx.diag({ - message: "Use one space after a comma and on each side of an assignment.", - severity: "hint", - spn: edit.spn, - fixes: [{ title: "Normalize spacing", applicability: "safe", edits: [edit] }], - })); - }, -}; - -export default [spacing]; diff --git a/tools/bend-lint/test/spans.test.ts b/tools/bend-lint/test/spans.test.ts deleted file mode 100644 index 90a7a6148f..0000000000 --- a/tools/bend-lint/test/spans.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test } from "bun:test"; - -import { SpanError, Spans } from "../src/spans.ts"; -import type { Source } from "../src/types.ts"; - -function source(path: string, ns: string, text: string): Source { - return { path, ns, text, root: true, base: false, file: { str: text, ns, al: Object.create(null), path } }; -} - -for (const eol of ["\n", "\r\n"]) { - const text = ["import Base", "import ./a.bend as A", "", "def f() -> N:", " x", ""].join(eol); - const lines = text.split("\n"); - const blank = lines.map((l) => l.startsWith("import") ? "" : l).join("\n"); - const space = lines.map((l) => l.startsWith("import") ? " ".repeat(l.length) : l).join("\n"); - for (const [name, masked] of [["empty", blank], ["spaces", space]] as const) { - test("maps a span past " + name + " import lines (" + JSON.stringify(eol) + ")", () => { - const src = source("/p/m.bend", "", text); - const spans = new Spans([src]); - const parse = { str: masked, ns: "", dir: "/p/", al: { A: "a" } }; - const beg = masked.indexOf("x"); - const got = spans.map({ file: parse, beg, end: beg + 1 })!; - expect(got.file).toBe(src.file); - expect(text.slice(got.beg, got.end)).toBe("x"); - expect(src.file.al.A).toBe("a"); - }); - } -} - -test("a span already on disk passes through", () => { - const src = source("/p/m.bend", "", "def f() -> N:\n x\n"); - const spn = { file: src.file, beg: 2, end: 3 }; - expect(new Spans([src]).map(spn)).toBe(spn); -}); - -test("a copy that does not match the file fails loudly", () => { - const src = source("/p/m.bend", "", "import Base\ndef f() -> N:\n x\n"); - const spans = new Spans([src]); - expect(() => spans.map({ file: { str: "\ndef g() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 })) - .toThrow(SpanError); - expect(() => spans.map({ file: { str: "def f() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 })) - .toThrow(SpanError); -}); - -test("the directory and the namespace pick the file", () => { - const a = source("/a/m.bend", "", "import Base\nx\n"); - const b = source("/b/m.bend", "m", "import Base\nx\n"); - const spans = new Spans([a, b]); - expect(spans.map({ file: { str: "\nx\n", ns: "m", dir: "/b/", al: {} }, beg: 1, end: 2 })!.file).toBe(b.file); - expect(spans.map({ file: { str: "\nx\n", ns: "", dir: "/a/", al: {} }, beg: 1, end: 2 })!.file).toBe(a.file); -}); diff --git a/tools/bend-lint/test/walk.test.ts b/tools/bend-lint/test/walk.test.ts deleted file mode 100644 index 3dd72dd26f..0000000000 --- a/tools/bend-lint/test/walk.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, test } from "bun:test"; -import { fileURLToPath } from "node:url"; - -import { lint } from "../src/lint.ts"; -import { children, walk } from "../src/walk.ts"; -import type { LTerm } from "../src/types.ts"; - -test("walk reaches matches, lets and annotations of checked bodies", async () => { - const file = fileURLToPath(new URL("./fixtures/userland.bend", import.meta.url)); - const res = await lint(file, []); - expect(res.ok).toBe(true); - const kinds = new Set(); - for (const name of res.book.order) { - const tld = res.book.tlds[name]; - if (tld.$ !== "Def" || tld.e === undefined) continue; - for (const tm of walk(tld.e)) kinds.add(tm.$); - } - for (const k of ["Mat", "Lam", "App", "Var", "Ctr"]) expect(kinds.has(k)).toBe(true); -}); - -test("an unknown term kind fails loudly", () => { - expect(() => children({ $: "Nope" } as unknown as LTerm)).toThrow(/unknown term kind Nope/); -}); From 21966b343b394b9ce6105cdde7e365c39c75be3d Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 14:10:34 +0000 Subject: [PATCH 05/39] bend-lint: facts carry quantity, uses, and whether they come from a template 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 4 +++- tools/bend-lint/src/lint.ts | 20 ++++++++++++-------- tools/bend-lint/src/patch.ts | 4 ++-- tools/bend-lint/test/lint.test.ts | 21 +++++++++++++-------- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 66bdcbdf9d..8eb77ed5e2 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -49,7 +49,9 @@ found. A finding with severity `error` stops the run. - `sources`, `root`: the files of the book as they are on disk. - `facts` (with `needsTypes`): for each checked term, its type, context, - depth, def, and span in the file on disk. + depth, def, quantity, uses, and span in the file on disk. A template body + is checked as written and again per instance (`generic~0`), at the same + spans; `fact.inst` marks the facts of an instance. - `span(s)`: a span from bend.ts (for example `term.s`), in the file on disk. - `walk`, `binder`, `show`, `same`, `diag`: helpers over bend's terms. diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 597f6b4d9f..cb55f3c843 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -9,7 +9,7 @@ import * as url from "node:url"; import * as util from "node:util"; import type * as BendModule from "../../../bend2/bend.ts"; -import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Span } from "../../../bend2/bend.ts"; +import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "../../../bend2/bend.ts"; import { BEND_TS, DriftError, MARK, PIN_FILE, current, fs, patch, path, pinned } from "./patch.ts"; // Types @@ -52,9 +52,12 @@ export type DiagInit = { export type SourceFile = { str: string; ns: string; al: Record; path: string }; export type Source = { path: string; ns: string; text: string; root: boolean; base: boolean; file: SourceFile }; -// `tm` checked (or inferred) as `ty` at depth `dep` in `ctx`, in def `def`. -// `bok` is the book it was checked in (a template body has its own). -export type Fact = { tm: LTerm; ty: HTerm; bok: Book; ctx: Ctx; dep: number; def: Name; spn?: Span }; +// `tm` checked (or inferred) as `ty` at depth `dep` in `ctx`, in def `def`, +// demanded `qt` times, using the variables in `us`. `bok` is the book it was +// checked in (a template body has its own). A template body is checked as +// written, then again for each instance (generic~0) at the same spans; +// `inst` marks the facts of an instance. +export type Fact = { tm: LTerm; ty: HTerm; bok: Book; ctx: Ctx; dep: number; def: Name; qt: Quant; us: Uses; inst: boolean; spn?: Span }; export type RuleContext = { Bend: Bend; @@ -79,7 +82,7 @@ export type LintRule = { export type LintResult = { ok: boolean; diags: Diag[]; sources: Source[]; book: Book; facts?: Map }; -type Hooked = Book & { see?: (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn?: Span) => void }; +type Hooked = Book & { see?: (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn: Span | undefined, qt: Quant, us: Uses) => void }; type Mapper = (s: S) => S; type Checked = { book: Book; sources: Source[]; span: Mapper; facts?: Map; failure?: Diag }; @@ -202,8 +205,8 @@ async function check(file: string, capture: boolean, signal: AbortSignal): Promi signal.throwIfAborted(); const book: Hooked = Bend.book_nil(); const seen = new Map(); - const found: Fact[] = []; - book.see = capture ? (bok, tm, ty, ctx, dep, def, spn) => void found.push({ tm, ty, bok, ctx, dep, def, spn }) : undefined; + const found: Array> = []; + book.see = capture ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => void found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us }) : undefined; const caught = await Bend.book_load(book, file, "", seen).then(() => { const laws = path.join(path.dirname(file), "LAWS.bend"); if (path.basename(file) === "PROOF.bend" && fs.existsSync(laws) && !seen.has(fs.realpathSync(laws))) { @@ -223,7 +226,8 @@ async function check(file: string, capture: boolean, signal: AbortSignal): Promi }); const span = mapper(sources); if (caught === undefined) { - return { book, sources, span, facts: capture ? new Map(found.map((f) => [f.tm, { ...f, spn: span(f.spn) }])) : undefined }; + const insts = new Set(Object.values(book.tmps).flatMap((m) => [...m.values()])); + return { book, sources, span, facts: capture ? new Map(found.map((f) => [f.tm, { ...f, inst: insts.has(f.def), spn: span(f.spn) }])) : undefined }; } const err = isErr(caught.e) ? caught.e : undefined; const message = err !== undefined ? Bend.expr_show(err.bok, err.exp) : caught.e instanceof RangeError ? STACK : String(caught.e); diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index 55946f7dc9..e3d173310d 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -41,7 +41,7 @@ const PATCHES = [ to: [ "export function term_infer(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm[] = []): Infer {", " const inf = lint_infer(book, lhs, tm, qt, ctx, d, sp);", - " (book as any).see?.(book, inf.tm, inf.ty, ctx, d, lhs.def, tm.s);", + " (book as any).see?.(book, inf.tm, inf.ty, ctx, d, lhs.def, tm.s, qt, inf.us);", " return inf;", "}", "function lint_infer(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm[]): Infer {", @@ -53,7 +53,7 @@ const PATCHES = [ to: [ "export function term_check(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number): Check {", " const chk = lint_check(book, lhs, tm, qt, ty, ctx, d);", - " (book as any).see?.(book, chk.tm, ty, ctx, d, lhs.def, tm.s);", + " (book as any).see?.(book, chk.tm, ty, ctx, d, lhs.def, tm.s, qt, chk.us);", " return chk;", "}", "function lint_check(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number): Check {", diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 9bc5be6867..1f5e615d77 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -149,17 +149,16 @@ const neverRun: LintRule = { }; // x : T = v, where v is a variable that already has type T. Constructors -// and lambdas keep theirs: checking needs the expected type. A template -// body is checked again per instance (generic~0), at the same span; the -// first finding at a span is from the def as written, so it is kept. +// and lambdas keep theirs: checking needs the expected type. Template +// instances repeat the facts of the def as written, so they are skipped. const redundantAnnotation: LintRule = { id: "erasure/redundant-local-annotation", needsTypes: true, - run: (cx) => [...new Map([...cx.facts!.values()].flatMap((fact): Array<[string, Diag]> => { + run: (cx) => [...cx.facts!.values()].flatMap((fact): Diag[] => { const term = cx.Bend.term_strip(fact.tm); const value = cx.span(term.s); const v = cx.binder(fact, term); - if (term.$ !== "Var" || fact.spn === undefined || value === undefined || v === null || !cx.same(fact, v.T, fact.ty)) { + if (fact.inst || term.$ !== "Var" || fact.spn === undefined || value === undefined || v === null || !cx.same(fact, v.T, fact.ty)) { return []; } const prefix = fact.spn.file.str.slice(fact.spn.beg, value.beg); @@ -168,12 +167,12 @@ const redundantAnnotation: LintRule = { return []; } const spn = { file: fact.spn.file, beg: fact.spn.beg + name[1].length, end: fact.spn.beg + prefix.lastIndexOf("=") }; - return [[spn.beg + ":" + spn.end, cx.diag({ + return [cx.diag({ message: "Remove the redundant annotation: " + v.k + " already has type " + cx.show(fact, v.T) + ".", severity: "hint", spn, fact, fixes: [{ title: "Remove redundant local type annotation", applicability: "suggested", edits: [{ spn, text: " " }] }], - })]]; - }).reverse()).values()].reverse(), + })]; + }), }; // One space after a comma and on each side of an assignment, in the file @@ -349,6 +348,12 @@ describe("lint", () => { run: (cx) => { const generic = facts(cx, "generic").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "x")!; expect(generic.bok.tlds["generic~T"]).toBeDefined(); + expect(generic.inst).toBe(false); + const inst = facts(cx, "generic~0").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "x")!; + expect(inst.inst).toBe(true); + expect(inst.spn).toEqual(generic.spn); + expect(["None", "Lone", "Many"]).toContain(generic.qt.$); + expect(generic.us.$).toBeDefined(); const proof = facts(cx, "proof").find((f) => f.tm.x?.$ === "Rfl")!; expect(proof.ty.$).toBe("Eql"); expect(proof.dep).toBe(1); From 4452a653f97ef4e5d6609017409841a33951ea44 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 14:14:16 +0000 Subject: [PATCH 06/39] bend-lint: check Base once per process, and leave out its facts 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 4 ++++ tools/bend-lint/src/lint.ts | 34 +++++++++++++++++++++++++------ tools/bend-lint/test/lint.test.ts | 20 ++++++++++++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 8eb77ed5e2..b5ca251349 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -42,6 +42,10 @@ export const rules: LintRule[] = [{ }]; ``` +A file with its own `import Base` starts from a copy of Base, checked once +per process (again only if base.bend changes), and facts of Base's own defs +are not recorded. + Rules run in order, one at a time; `cx.prior` holds what earlier rules found. A finding with severity `error` stops the run. diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index cb55f3c843..9d5007a365 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -10,7 +10,7 @@ import * as util from "node:util"; import type * as BendModule from "../../../bend2/bend.ts"; import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "../../../bend2/bend.ts"; -import { BEND_TS, DriftError, MARK, PIN_FILE, current, fs, patch, path, pinned } from "./patch.ts"; +import { BEND_TS, DriftError, MARK, PIN_FILE, blob, current, fs, patch, path, pinned } from "./patch.ts"; // Types // ===== @@ -95,6 +95,10 @@ const STACK = "the machine stack overflowed (a deep recursion, or a literal too const SAMPLE = "type N is Data:\n Z{}\n\ndef id(x: N) -> N:\n x\n"; const HEAD: Record = { error: "Error", warning: "Warning", information: "Information", hint: "Hint" }; +// Checked base.bend books, by the hash of base.bend: Base is checked once +// per process, and again only if base.bend changes. +const BASES = new Map>(); + // Binders are explicit in checked terms, so Var.v cells are not followed. // A new term kind is a type error here, and a DriftError when walked. const CHILDREN: { [K in LTerm["$"]]: (tm: Extract) => LTerm[] } = { @@ -200,25 +204,43 @@ function isErr(e: unknown): e is Err { return typeof e === "object" && e !== null && (e as { $?: unknown }).$ === "Err"; } -// Loads and checks a book as bend2/main.ts book_read does. +// Loads and checks a book as bend2/main.ts book_read does. A file with its +// own `import Base` starts from a copy of the checked Base, as `bend +// --checkup` does. Facts of Base's own defs are not recorded. async function check(file: string, capture: boolean, signal: AbortSignal): Promise { signal.throwIfAborted(); const book: Hooked = Bend.book_nil(); const seen = new Map(); const found: Array> = []; - book.see = capture ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => void found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us }) : undefined; - const caught = await Bend.book_load(book, file, "", seen).then(() => { + book.see = capture + ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => void (bok.tlds[def]?.b !== true && found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us })) + : undefined; + const seeded = fs.existsSync(file) && /^import Base\s*$/m.test(fs.readFileSync(file, "utf8")); + const key = seeded ? blob(fs.readFileSync(Bend.BASE_BEND, "utf8")) : ""; + if (seeded && !BASES.has(key)) { + const base = Bend.book_nil(); + BASES.set(key, Bend.book_load(base, Bend.BASE_BEND, "", new Map()).then(() => (Bend.book_valid(base), base))); + } + const caught = await (seeded ? BASES.get(key)! : Promise.resolve(undefined)).then(async (base) => { + if (base !== undefined) { + Object.assign(book.tlds, Object.fromEntries(Object.entries(base.tlds).map(([k, tld]) => [k, { ...tld }]))); + Object.assign(book.ctrs, base.ctrs); + Object.assign(book.tmps, Object.fromEntries(Object.entries(base.tmps).map(([k, m]) => [k, new Map(m)]))); + book.order.push(...base.order); + seen.set(Bend.BASE_BEND, ""); + } + await Bend.book_load(book, file, "", seen); const laws = path.join(path.dirname(file), "LAWS.bend"); if (path.basename(file) === "PROOF.bend" && fs.existsSync(laws) && !seen.has(fs.realpathSync(laws))) { throw "PROOF.bend must import ./LAWS.bend"; } - Bend.book_valid(book, 0); + Bend.book_valid(book, base?.order.length ?? 0); if (book.hols > 0) { throw book.hols + " TODO" + (book.hols === 1 ? "" : "s") + " found.\nThe code is incomplete, and not a valid proof yet."; } }).then(() => undefined, (e: unknown) => ({ e })); signal.throwIfAborted(); - const root = seen.keys().next().value; + const root = [...seen.keys()].find((real) => real !== Bend.BASE_BEND || !seeded); const sources = [...seen.keys()].filter((real) => fs.existsSync(real)).map((real): Source => { const text = fs.readFileSync(real, "utf8"); const ns = seen.get(real) ?? ""; diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 1f5e615d77..52c34437b8 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -429,6 +429,26 @@ describe("lint", () => { expect(res.sources.length).toBe(2); }); + test("Base is reused, its facts are left out, and it stays out of files that do not import it", async () => { + const withBase = fixture("with_base.bend", "import Base\n\ndef main() -> Nat:\n 1n\n"); + const probe: LintRule = { + id: "test/base", + needsTypes: true, + run: (cx) => { + expect(cx.root.path).toEndWith("/with_base.bend"); + expect(cx.sources.some((s) => s.base)).toBe(true); + expect([...cx.facts!.values()].some((f) => f.def === "main")).toBe(true); + expect([...cx.facts!.values()].every((f) => cx.book.tlds[f.def]?.b !== true)).toBe(true); + return []; + }, + }; + const first = await lint(withBase, [probe]); + const second = await lint(withBase, [probe]); + expect([first.ok, second.ok]).toEqual([true, true]); + expect(second.book.order).toEqual(first.book.order); + expect((await lint(fixture("without_base.bend", "def main() -> Nat:\n 1n\n"), [])).ok).toBe(false); + }); + test("lints in flight keep their own rules and facts", async () => { const gate = Promise.withResolvers(); const entered = Promise.withResolvers(); From 168f2716537d2ee1a629eba45eeddd1e676506c2 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 14:25:54 +0000 Subject: [PATCH 07/39] bend-lint: rules written in Bend 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- gates/repo.ts | 2 +- tools/bend-lint/README.md | 25 +++- tools/bend-lint/bend.pin | 3 +- tools/bend-lint/src/lint.bend | 84 +++++++++++++ tools/bend-lint/src/lint.js | 84 +++++++++++++ tools/bend-lint/src/lint.ts | 124 +++++++++++++++++-- tools/bend-lint/src/patch.ts | 11 +- tools/bend-lint/test/lint.test.ts | 192 +++++++++++++++++++++++++++++- 8 files changed, 502 insertions(+), 23 deletions(-) create mode 100644 tools/bend-lint/src/lint.bend create mode 100644 tools/bend-lint/src/lint.js diff --git a/gates/repo.ts b/gates/repo.ts index d66d067bb9..44c8a33097 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -85,7 +85,7 @@ allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\ allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json|bend\.pin)$/); -allow(/^tools\/bend-lint\/src\/(lint|patch)\.ts$/); +allow(/^tools\/bend-lint\/src\/(lint\.ts|patch\.ts|lint\.bend|lint\.js)$/); allow("tools/bend-lint/test/lint.test.ts"); // Gate diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index b5ca251349..1f64460ad7 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -7,6 +7,8 @@ and returns diagnostics with fixes. Bend itself does not change. - `src/lint.ts`: the library and the CLI. - `src/patch.ts`: the two changes made to `bend2/bend.ts` as Bun loads it, and the pin. +- `src/lint.bend`, `src/lint.js`: the contract for rules written in Bend, + and its effects. ## Run @@ -63,6 +65,23 @@ found. A finding with severity `error` stops the run. Get bend.ts from `cx.Bend`. Do not import `bend2/bend.ts` in a rule. +## Rules written in Bend + +A `.bend` rule imports `src/lint.bend` (by a relative path) and defines +`id()`, `types()`, `run(input)` and a `main` that hands `run` to +`Lint.serve`; `src/lint.bend` lists the contract. `run` gets the sources +and, with `types()` true, the facts (as indexes, `Lint.Fact`); it asks about them +through effects (`Lint.view`, `Lint.type_of`, `Lint.binder`, `Lint.same`, +`Lint.show`) and answers a list of `Lint.Diag`. Offsets count characters. + +```sh +bun tools/bend-lint/src/lint.ts file.bend --rules my_rule.bend +``` + +bend-lint checks the rule once; each run compiles it and runs its `main` +with bend's own IO runtime (`comp.ts` `io_run`), so a rule may also use +Base's effects. Effects run synchronously. + ## The patch bend.ts does not expose the checker's results, and builds file paths with @@ -81,9 +100,9 @@ bend-lint stops with a `DriftError`. ## The pin -`bend.pin` holds the git blob hash of the `bend2/bend.ts` this tool was -tested with (`git rev-parse HEAD:bend2/bend.ts`). If bend.ts differs, -bend-lint stops. To bump: +`bend.pin` holds the git blob hashes of the `bend2/bend.ts` and +`bend2/comp.ts` this tool was tested with (`git rev-parse HEAD:`). +If either differs, bend-lint stops. To bump: ```sh BEND_LINT_UNPINNED=1 bun test tools/bend-lint diff --git a/tools/bend-lint/bend.pin b/tools/bend-lint/bend.pin index 95596e252d..a4db2390e5 100644 --- a/tools/bend-lint/bend.pin +++ b/tools/bend-lint/bend.pin @@ -1 +1,2 @@ -0a0814a0544b67f278751a5b2715bed67af37bff +bend.ts 0a0814a0544b67f278751a5b2715bed67af37bff +comp.ts 4be7e94bb622177ba0184eb85f5bab21f41c8d11 diff --git a/tools/bend-lint/src/lint.bend b/tools/bend-lint/src/lint.bend new file mode 100644 index 0000000000..0e582f846e --- /dev/null +++ b/tools/bend-lint/src/lint.bend @@ -0,0 +1,84 @@ +# The contract between bend-lint and a rule written in Bend. A rule file +# imports this file and defines: +# +# def id() -> String: # namespace/name, the code of its findings +# def types() -> Bool: # True{} to get the checker's facts +# def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): +# def main() -> IO(Unit): +# Lint.serve(run) +# +# Offsets count characters (code points) from the start of a file. +import Base + +# A checked term, and a type: indexes into bend-lint's tables for one run. +# Only the effects below read them. +type Fact is Data: + Fact{id: U32} + +type Term is Data: + Term{id: U32} + +type Source is Data: + Source{path: String, text: String, root: Bool} + +type Input is Data: + Input{sources: List<&2, Source>, facts: List<&2, Fact>} + +type Span is Data: + Span{path: String, beg: U32, end: U32} + +type Severity is Data: + Error{} + Warning{} + Information{} + Hint{} + +# Safe keeps behavior; Suggested may change it; Dangerous may break code. +type Applicability is Data: + Safe{} + Suggested{} + Dangerous{} + +type Edit is Data: + Edit{span: Span, text: String} + +type Fix is Data: + Fix{title: String, applicability: Applicability, edits: List<&2, Edit>} + +type Diag is Data: + Diag{severity: Severity, message: String, span: Maybe<&2, Span>, fixes: List<&2, Fix>} + +# A fact as bend-lint sees it: the def it is in, whether that def is a +# template instance, the kind of its term once an annotation is stripped +# (Var, App, ...), the name a Var refers to (else ""), and the spans of the +# checked term and of the stripped one. +type View is Data: + View{owner: String, inst: Bool, kind: String, name: String, span: Maybe<&2, Span>, inner: Maybe<&2, Span>} + +def input() -> IO(Input): + import "./lint.js" + +def report(diags: List<&2, Diag>) -> IO(Unit): + import "./lint.js" + +def view(fact: Fact) -> IO(View): + import "./lint.js" + +# The type the fact's term was checked against, or inferred. +def type_of(fact: Fact) -> IO(Term): + import "./lint.js" + +# The type of the binder a Var fact refers to. +def binder(fact: Fact) -> IO(Maybe<&2, Term>): + import "./lint.js" + +# Whether two types are equal, as the checker compares them in the fact's +# scope. +def same(fact: Fact, a: Term, b: Term) -> IO(Bool): + import "./lint.js" + +def show(fact: Fact, t: Term) -> IO(String): + import "./lint.js" + +def serve(run: Input -> IO(List<&2, Diag>)) -> IO(Unit): + IO.bind(Input, Unit, input(), i => IO.bind(List<&2, Diag>, Unit, run(i), report)) diff --git a/tools/bend-lint/src/lint.js b/tools/bend-lint/src/lint.js new file mode 100644 index 0000000000..dcb6825f8e --- /dev/null +++ b/tools/bend-lint/src/lint.js @@ -0,0 +1,84 @@ +// The effects of lint.bend. bend-lint puts its channel on +// globalThis.BEND_LINT before it runs a rule, and removes it after. Bool, +// String and U32 are native JS values here; other data is {$: CID(Name), ...}. + +const LINT_SEVERITY = { [CID(Error)]: "error", [CID(Warning)]: "warning", [CID(Information)]: "information", [CID(Hint)]: "hint" }; +const LINT_APPLICABILITY = { [CID(Safe)]: "safe", [CID(Suggested)]: "suggested", [CID(Dangerous)]: "dangerous" }; + +function lint_host() { + const host = globalThis.BEND_LINT; + if (host === undefined) { + throw new Error("bend-lint: this program is a rule; run it with bun tools/bend-lint/src/lint.ts --rules "); + } + return host; +} + +function lint_list(xs) { + return xs.reduceRight((tail, head) => ({ $: CID(Con), head, tail }), { $: CID(Nil) }); +} + +function lint_unlist(xs) { + const out = []; + for (; xs.$ === CID(Con); xs = xs.tail) { + out.push(xs.head); + } + return out; +} + +function lint_maybe(x) { + return x === undefined ? { $: CID(None) } : { $: CID(Some), value: x }; +} + +function lint_span(s) { + return { $: CID(Span), path: s.path, beg: s.beg, end: s.end }; +} + +function lint_spot(s) { + return { path: s.path, beg: s.beg, end: s.end }; +} + +function lint_term(id) { + return { $: CID(Term), id }; +} + +function lint_input() { + const { sources, facts } = lint_host().input(); + return { + $: CID(Input), + sources: lint_list(sources.map((s) => ({ $: CID(Source), path: s.path, text: s.text, root: s.root }))), + facts: lint_list(Array.from({ length: facts }, (_, id) => ({ $: CID(Fact), id }))), + }; +} + +function lint_report(diags) { + lint_host().report(lint_unlist(diags).map((d) => ({ + severity: LINT_SEVERITY[d.severity.$], + message: d.message, + span: d.span.$ === CID(Some) ? lint_spot(d.span.value) : undefined, + fixes: lint_unlist(d.fixes).map((f) => ({ + title: f.title, + applicability: LINT_APPLICABILITY[f.applicability.$], + edits: lint_unlist(f.edits).map((e) => ({ span: lint_spot(e.span), text: e.text })), + })), + }))); + return { $: CID(Unit) }; +} + +function lint_view(fact) { + const v = lint_host().view(fact.id); + return { + $: CID(View), owner: v.owner, inst: v.inst, kind: v.kind, name: v.name, + span: lint_maybe(v.span && lint_span(v.span)), inner: lint_maybe(v.inner && lint_span(v.inner)), + }; +} + +io_eff(CID(input), lint_input); +io_eff(CID(report), lint_report); +io_eff(CID(view), lint_view); +io_eff(CID(type_of), (fact) => lint_term(lint_host().type(fact.id))); +io_eff(CID(binder), (fact) => { + const id = lint_host().binder(fact.id); + return lint_maybe(id === undefined ? undefined : lint_term(id)); +}); +io_eff(CID(same), (fact, a, b) => lint_host().same(fact.id, a.id, b.id)); +io_eff(CID(show), (fact, t) => lint_host().show(fact.id, t.id)); diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 9d5007a365..b277f4693f 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun // bend-lint checks a Bend file with bend's checker, then runs rules over // its source and the checker's results. It loads bend2/bend.ts through -// ./patch.ts. Anything in bend.ts it cannot follow stops it with a +// ./patch.ts. Rules are TS modules, or Bend files built on ./lint.bend. Anything in bend.ts it cannot follow stops it with a // DriftError. As a CLI, it exits 0 when ok, 1 when it found an error, 2 // on bad usage or a tool failure. @@ -9,13 +9,15 @@ import * as url from "node:url"; import * as util from "node:util"; import type * as BendModule from "../../../bend2/bend.ts"; +import type * as CompModule from "../../../bend2/comp.ts"; import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "../../../bend2/bend.ts"; -import { BEND_TS, DriftError, MARK, PIN_FILE, blob, current, fs, patch, path, pinned } from "./patch.ts"; +import { BEND_TS, COMP_TS, DriftError, MARK, PIN_FILE, blob, current, fs, patch, path, pinned } from "./patch.ts"; // Types // ===== export type Bend = typeof BendModule; +export type Comp = typeof CompModule; export type Severity = "error" | "warning" | "information" | "hint"; // safe keeps behavior; suggested may change it; dangerous may break code. export type Applicability = "safe" | "suggested" | "dangerous"; @@ -82,6 +84,29 @@ export type LintRule = { export type LintResult = { ok: boolean; diags: Diag[]; sources: Source[]; book: Book; facts?: Map }; +// A span as lint.bend has it: a file's path and two code-point offsets. +type Spot = { path: string; beg: number; end: number }; + +// What a Bend rule reports, as lint.js reads it. +type Reported = { + severity: Severity; + message: string; + span?: Spot; + fixes: Array<{ title: string; applicability: Applicability; edits: Array<{ span: Spot; text: string }> }>; +}; + +// What lint.js asks of bend-lint while a Bend rule runs. Facts and types +// cross as indexes into the run's tables. +type Channel = { + input(): { sources: Array<{ path: string; text: string; root: boolean }>; facts: number }; + report(diags: Reported[]): void; + view(fact: number): { owner: Name; inst: boolean; kind: string; name: string; span?: Spot; inner?: Spot }; + type(fact: number): number; + binder(fact: number): number | undefined; + same(fact: number, a: number, b: number): boolean; + show(fact: number, t: number): string; +}; + type Hooked = Book & { see?: (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn: Span | undefined, qt: Quant, us: Uses) => void }; type Mapper = (s: S) => S; type Checked = { book: Book; sources: Source[]; span: Mapper; facts?: Map; failure?: Diag }; @@ -95,6 +120,8 @@ const STACK = "the machine stack overflowed (a deep recursion, or a literal too const SAMPLE = "type N is Data:\n Z{}\n\ndef id(x: N) -> N:\n x\n"; const HEAD: Record = { error: "Error", warning: "Warning", information: "Information", hint: "Hint" }; +const shared = globalThis as typeof globalThis & { BEND_LINT?: Channel }; + // Checked base.bend books, by the hash of base.bend: Base is checked once // per process, and again only if base.bend changes. const BASES = new Map>(); @@ -133,8 +160,8 @@ const OPTIONS = { } as const; const USAGE = [ - "usage: bun tools/bend-lint/src/lint.ts [--rules ]... [--fix]", - " bun tools/bend-lint/src/lint.ts --pin (pin the current bend2/bend.ts)", + "usage: bun tools/bend-lint/src/lint.ts [--rules ]... [--fix]", + " bun tools/bend-lint/src/lint.ts --pin (pin the current bend2/bend.ts and comp.ts)", ].join("\n"); // Functions @@ -260,7 +287,7 @@ async function check(file: string, capture: boolean, signal: AbortSignal): Promi // that throws, and an abort, reach the caller. export async function lint(file: string, rules: LintRule[], signal = new AbortController().signal): Promise { if (current() !== pinned() && process.env.BEND_LINT_UNPINNED !== "1") { - throw new DriftError("bend2/bend.ts is " + current() + ", but bend-lint is pinned to " + pinned() + ".\n" + throw new DriftError("bend2 is\n" + current() + "\nbut bend-lint is pinned to\n" + pinned() + "\n" + "To bump: BEND_LINT_UNPINNED=1 bun test tools/bend-lint, then bun tools/bend-lint/src/lint.ts --pin"); } const bad = rules.find((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function"); @@ -358,9 +385,86 @@ export function position(spn: Span): { start: Position; end: Position } { return { start: at(spn.beg), end: at(spn.end) }; } +// A rule written in Bend: a file built on ./lint.bend (see there). Its +// book is checked once; each run compiles it and runs its main with bend's +// own IO runtime, while lint.js reaches bend-lint through +// globalThis.BEND_LINT. Offsets cross as code points. +export async function bendRule(file: string): Promise { + const { book, failure } = await check(file, false, new AbortController().signal); + if (failure !== undefined) { + throw new Error(file + " does not check:\n" + render(failure)); + } + const value = (k: Name): string => { + const tld = book.tlds[k]; + return tld?.$ === "Def" && tld.n === 0 && tld.v !== null ? Bend.term_show(Bend.term_lower(Bend.term_snf(book, tld.v))) : ""; + }; + const id = value("id").match(/^"([^"\\]*)"$/)?.[1]; + const types = value("types"); + if (id === undefined || !["True{}", "False{}"].includes(types) || Comp.io_type(book) === null) { + throw new Error(file + " must define id() -> String, types() -> Bool and main() -> IO(Unit)"); + } + return { + id, + needsTypes: types === "True{}", + run: (cx) => { + const spot = (spn: Span | undefined): Spot | undefined => spn && { + path: (spn.file as SourceFile).path, beg: [...spn.file.str.slice(0, spn.beg)].length, end: [...spn.file.str.slice(0, spn.end)].length, + }; + const span = (s: Spot): Span => { + const src = cx.sources.find((x) => x.path === s.path); + if (src === undefined) { + throw new Error("rule " + id + " reported a span in " + s.path + ", which is not in the book"); + } + const at = (n: number): number => [...src.text].slice(0, n).join("").length; + return { file: src.file, beg: at(s.beg), end: at(s.end) }; + }; + const facts = [...cx.facts?.values() ?? []]; + const terms: HTerm[] = []; + const pick = (xs: T[], i: number, what: string): T => { + if (xs[i] === undefined) { + throw new Error("rule " + id + " asked about " + what + " " + i + ", which it was not given"); + } + return xs[i]; + }; + const keep = (t: HTerm): number => terms.push(t) - 1; + const found: Reported[][] = []; + shared.BEND_LINT = { + input: () => ({ sources: cx.sources.map((s) => ({ path: s.path, text: s.text, root: s.root })), facts: facts.length }), + report: (diags) => void found.push(diags), + view: (i) => { + const f = pick(facts, i, "fact"); + const t = Bend.term_strip(f.tm); + return { owner: f.def, inst: f.inst, kind: t.$, name: t.$ === "Var" ? t.k : "", span: spot(f.spn), inner: spot(cx.span(t.s)) }; + }, + type: (i) => keep(pick(facts, i, "fact").ty), + binder: (i) => { + const f = pick(facts, i, "fact"); + const ann = cx.binder(f, Bend.term_strip(f.tm)); + return ann === null ? undefined : keep(ann.T); + }, + same: (i, a, b) => cx.same(pick(facts, i, "fact"), pick(terms, a, "term"), pick(terms, b, "term")), + show: (i, t) => cx.show(pick(facts, i, "fact"), pick(terms, t, "term")), + }; + let code: number; + try { + code = Comp.io_run(book, [file]); + } finally { + shared.BEND_LINT = undefined; + } + if (code !== 0 || found.length !== 1) { + throw new Error("rule " + id + " exited with " + code + " after " + found.length + " reports; it must report once"); + } + return found[0].map((d) => cx.diag({ + message: d.message, severity: d.severity, spn: d.span && span(d.span), + fixes: d.fixes.map((f) => ({ ...f, edits: f.edits.map((e) => ({ spn: span(e.span), text: e.text })) })), + })); + }, + }; +} + // Patches bend.ts as Bun loads it, then checks a tiny program: the type of // `x` in `id` must be recorded as N. -async function instrument(): Promise { +async function instrument(): Promise<{ Bend: Bend; Comp: Comp }> { const patched = patch(fs.readFileSync(BEND_TS, "utf8")); Bun.plugin({ name: "bend-lint", @@ -382,7 +486,7 @@ async function instrument(): Promise { if (!typed) { throw new DriftError("self-check failed: the patched bend.ts recorded no type for `id`; update tools/bend-lint/src/patch.ts"); } - return B; + return { Bend: B, Comp: await import(url.pathToFileURL(COMP_TS).href) }; } async function cli(argv: string[]): Promise { @@ -393,7 +497,7 @@ async function cli(argv: string[]): Promise { } if (values.pin) { fs.writeFileSync(PIN_FILE, current() + "\n"); - console.log("bend-lint: pinned bend2/bend.ts " + pinned()); + console.log("bend-lint: pinned\n" + pinned()); return 0; } if (positionals.length !== 1) { @@ -401,7 +505,7 @@ async function cli(argv: string[]): Promise { } const modules = await Promise.all((values.rules ?? []).map(async (file): Promise => { if (file.endsWith(".bend")) { - throw new Error(file + ": rules written in Bend are not supported yet"); + return [await bendRule(file)]; } const { rules } = await import(url.pathToFileURL(path.resolve(file)).href); if (!Array.isArray(rules)) { @@ -432,7 +536,7 @@ function fail(e: unknown): never { // Side effects // ============ -export const Bend: Bend = await instrument().catch((e: unknown) => import.meta.main ? fail(e) : Promise.reject(e)); +export const { Bend, Comp }: { Bend: Bend; Comp: Comp } = await instrument().catch((e: unknown) => import.meta.main ? fail(e) : Promise.reject(e)); if (import.meta.main) { process.exit(await cli(process.argv.slice(2)).catch(fail)); diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index e3d173310d..297f1d3c14 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -3,7 +3,9 @@ // `book.see` hook records what they return. The other gives bend.ts this // module's fs and path: bend.ts builds paths with "/" (path.posix), so here // real paths use "/" and a Windows drive letter counts as a root. On POSIX -// they behave as node's. bend.pin holds the bend.ts these were tested on. +// they behave as node's. bend.pin holds the hashes of the bend2 files +// bend-lint was tested on: bend.ts, which it patches, and comp.ts, whose +// io_run runs rules written in Bend. import * as crypto from "node:crypto"; import * as nodeFs from "node:fs"; @@ -23,6 +25,7 @@ const SHIM = JSON.stringify(url.pathToFileURL(nodePath.join(HERE, "patch.ts")).h export const MARK = "BEND_LINT_PATCH"; export const PIN_FILE = nodePath.join(HERE, "..", "bend.pin"); +const PINNED = ["bend.ts", "comp.ts"]; const PATCHES = [ { @@ -112,11 +115,13 @@ export function pinned(): string { } export function current(): string { - return blob(nodeFs.readFileSync(BEND_TS, "utf8")); + return PINNED.map((f) => f + " " + blob(nodeFs.readFileSync(path.join(BEND2, f), "utf8"))).join("\n"); } // Side effects // ============ -export const BEND_TS = fs.realpathSync(path.join(HERE, "..", "..", "..", "bend2", "bend.ts")); +export const BEND2 = fs.realpathSync(path.join(HERE, "..", "..", "..", "bend2")); +export const BEND_TS = path.join(BEND2, "bend.ts"); +export const COMP_TS = path.join(BEND2, "comp.ts"); diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 52c34437b8..03b709d505 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -1,12 +1,11 @@ import { afterAll, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { Book, LTerm, Span } from "../../../bend2/bend.ts"; -import { Bend, applyFixes, lint, mapper, render, walk } from "../src/lint.ts"; +import { Bend, applyFixes, bendRule, lint, mapper, render, walk } from "../src/lint.ts"; import type { Diag, Edit, Fact, LintRule, RuleContext, Source, SourceFile } from "../src/lint.ts"; import { BEND_TS, DriftError, blob, current, patch, pinned, relative, resolve } from "../src/patch.ts"; @@ -113,9 +112,160 @@ const DRIFT = [ "import/base_prelude", "import/string_literal", "import/alias_shadow", "import/duplicate_name", ]; -const DIR = fs.mkdtempSync(path.join(os.tmpdir(), "bend-lint-")); +// Inside the repo, so a Bend rule here can import ../../../src/lint.bend; +// .tmp/ is ignored by git. +const TMP = fileURLToPath(new URL("./.tmp", import.meta.url)); +const DIR = (fs.mkdirSync(TMP, { recursive: true }), fs.mkdtempSync(path.join(TMP, "run-"))); const CLI = fileURLToPath(new URL("../src/lint.ts", import.meta.url)); +// The comma-space rule, written in Bend: source text only. +const COMMA_BEND = String.raw`import Base +import ../../../src/lint.bend as Lint + +def id() -> String: + "style/comma-space" + +def types() -> Bool: + False{} + +def is_word(+c: Char) -> Bool: + Bool.or(Char.is_alpha(c), Bool.or(Char.is_digit(c), Char.is_eq(c, '_'))) + +def comma_before_word(+c: Char, rest: String) -> Bool: + match rest: + case SNil{}: + False{} + case SCon{n, t}: + Bool.and(Char.is_eq(c, ','), is_word(n)) + +def keep(hit: Bool, +at: U32, tail: List<&2, U32>) -> List<&2, U32>: + match hit: + case True{}: + U32.add(at, 1) <> tail + case False{}: + tail + +def commas(s: String, +at: U32) -> List<&2, U32>: + match s: + case SNil{}: + [] + case SCon{+c, +rest}: + keep(comma_before_word(c, rest), at, commas(rest, U32.add(at, 1))) + +def diag_at(+span: Lint.Span) -> Lint.Diag: + Lint.Diag{Lint.Warning{}, "Add a space after the comma.", Some{span}, [Lint.Fix{"Insert space", Lint.Safe{}, [Lint.Edit{span, " "}]}]} + +def diag(path: String, +at: U32) -> Lint.Diag: + diag_at(Lint.Span{path, at, at}) + +def diags(+path: String, ats: List<&2, U32>) -> List<&2, Lint.Diag>: + match ats: + case Nil{}: + [] + case Con{+at, rest}: + diag(path, at) <> diags(path, rest) + +def choose(root: Bool, +path: String, text: String, others: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: + match root: + case True{}: + diags(path, commas(text, 0)) + case False{}: + others + +def pick(s: Lint.Source, others: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: + match s: + case Lint.Source{+path, text, root}: + choose(root, path, text, others) + +def all(srcs: List<&2, Lint.Source>) -> List<&2, Lint.Diag>: + match srcs: + case Nil{}: + [] + case Con{s, rest}: + pick(s, all(rest)) + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + match input: + case Lint.Input{sources, facts}: + IO.pure(List<&2, Lint.Diag>, all(sources)) + +def main() -> IO(Unit): + Lint.serve(run) +`; + +// A typed rule written in Bend: for each Var in def id, its type and +// whether the checker finds it equal to its binder's. +const TYPES_BEND = String.raw`import Base +import ../../../src/lint.bend as Lint + +def id() -> String: + "test/types" + +def types() -> Bool: + True{} + +def verdict(same: Bool) -> String: + match same: + case True{}: + " (same as its binder)" + case False{}: + " (unlike its binder)" + +def compare(f: Lint.Fact, b: Maybe<&2, Lint.Term>, t: Lint.Term) -> IO(Bool): + match b: + case None{}: + IO.pure(Bool, False{}) + case Some{x}: + Lint.same(f, x, t) + +def describe(+f: Lint.Fact, name: String, span: Maybe<&2, Lint.Span>) -> IO(Maybe<&2, Lint.Diag>): + do IO>: + t : Lint.Term <- Lint.type_of(f) + shown : String <- Lint.show(f, t) + u : Lint.Term <- Lint.type_of(f) + b : Maybe<&2, Lint.Term> <- Lint.binder(f) + same : Bool <- compare(f, b, u) + return Some{Lint.Diag{Lint.Hint{}, String.append(name, String.append(": ", String.append(shown, verdict(same)))), span, []}} + +def wanted(hit: Bool, +f: Lint.Fact, name: String, span: Maybe<&2, Lint.Span>) -> IO(Maybe<&2, Lint.Diag>): + match hit: + case True{}: + describe(f, name, span) + case False{}: + IO.pure(Maybe<&2, Lint.Diag>, None{}) + +def fact_diag(+f: Lint.Fact, v: Lint.View) -> IO(Maybe<&2, Lint.Diag>): + match v: + case Lint.View{owner, inst, kind, name, span, inner}: + wanted(Bool.and(String.eq(owner, "id"), Bool.and(String.eq(kind, "Var"), Bool.not(inst))), f, name, span) + +def prepend(m: Maybe<&2, Lint.Diag>, xs: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: + match m: + case None{}: + xs + case Some{d}: + d <> xs + +def each(facts: List<&2, Lint.Fact>) -> IO(List<&2, Lint.Diag>): + match facts: + case Nil{}: + IO.pure(List<&2, Lint.Diag>, []) + case Con{+f, rest}: + do IO>: + v : Lint.View <- Lint.view(f) + here : Maybe<&2, Lint.Diag> <- fact_diag(f, v) + later : List<&2, Lint.Diag> <- each(rest) + return prepend(here, later) + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + match input: + case Lint.Input{sources, facts}: + each(facts) + +def main() -> IO(Unit): + Lint.serve(run) +`; + const commaSpace: LintRule = { id: "style/comma-space", run: (cx) => [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { @@ -269,7 +419,7 @@ describe("patch", () => { test("the pin is a git blob hash, and matches bend.ts", () => { expect(blob("a\r\nb\n")).toBe(blob("a\nb\n")); expect(blob("hello\n")).toBe("ce013625030ba8dba906f756967f9e9ca394464a"); - expect(pinned()).toMatch(/^[0-9a-f]{40}$/); + expect(pinned()).toMatch(/^bend\.ts [0-9a-f]{40}\ncomp\.ts [0-9a-f]{40}$/); if (process.env.BEND_LINT_UNPINNED !== "1") expect(current()).toBe(pinned()); }); @@ -540,6 +690,30 @@ describe("drift: the copy of book_read agrees with the repo tests", () => { } }); +describe("rules written in Bend", () => { + const userland = fixture("bend_userland.bend", USERLAND); + + test("a source rule finds the comma, with a fix on the file on disk", async () => { + const rule = await bendRule(fixture("comma_rule.bend", COMMA_BEND)); + expect([rule.id, rule.needsTypes]).toEqual(["style/comma-space", false]); + const res = await lint(userland, [rule]); + expect(res.diags.map((d) => [d.code, d.severity])).toEqual([["style/comma-space", "warning"]]); + expect(render(res.diags[0])).toContain("generic(~N, a)"); + expect(applyFixes(root(res.sources).file, res.diags)).toContain("generic(~N, a)"); + }); + + test("a typed rule asks the checker through effects", async () => { + const rule = await bendRule(fixture("types_rule.bend", TYPES_BEND)); + expect(rule.needsTypes).toBe(true); + const res = await lint(userland, [rule, rule]); + expect(res.diags.length).toBeGreaterThan(0); + for (const d of res.diags) { + expect(d.message).toMatch(/^x: \S.* \(same as its binder\)$/); + expect(d.spn?.file).toBe(root(res.sources).file); + } + }); +}); + describe("cli", () => { const input = fixture("cli.bend", USERLAND); const first = module("first.js", `[{ id: "cli/first", needsTypes: true, run(cx) { @@ -570,7 +744,15 @@ describe("cli", () => { expect(run(input, "--rules", path.join(DIR, "missing.js")).status).toBe(2); expect(run(input, "--rules", module("object.js", "{}")).stderr).toMatch(/must export `rules`/); expect(run(input, "--rules", module("bad.js", `[{ id: "bad", run() { return []; } }]`)).stderr).toMatch(/invalid rule/); - expect(run(input, "--rules", fixture("rule.bend", "")).stderr).toMatch(/not supported yet/); + expect(run(input, "--rules", fixture("no_id.bend", "import Base\n\ndef main() -> IO(Unit):\n IO.print(\"x\")\n")).stderr) + .toMatch(/must define id\(\) -> String/); + expect(run(input, "--rules", fixture("broken_rule.bend", "def broken(\n")).stderr).toMatch(/does not check/); + }); + + test("a rule written in Bend runs from the CLI", () => { + const out = run(input, "--rules", fixture("cli_comma_rule.bend", COMMA_BEND)); + expect(out.status).toBe(0); + expect(out.stdout).toContain("Warning [style/comma-space]:"); }); test("--fix applies safe fixes", () => { From 3a1c8c92fd1f02264b5bfd2c5f0f76a6bd075434 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 11:37:39 -0300 Subject: [PATCH 08/39] bend-lint: more checker queries for Bend rules 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 3 +- tools/bend-lint/src/lint.bend | 28 +++++++++++++-- tools/bend-lint/src/lint.js | 7 +++- tools/bend-lint/src/lint.ts | 16 +++++++-- tools/bend-lint/test/lint.test.ts | 57 ++++++++++++++++++++++++------- 5 files changed, 91 insertions(+), 20 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 1f64460ad7..411d643857 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -72,7 +72,8 @@ A `.bend` rule imports `src/lint.bend` (by a relative path) and defines `Lint.serve`; `src/lint.bend` lists the contract. `run` gets the sources and, with `types()` true, the facts (as indexes, `Lint.Fact`); it asks about them through effects (`Lint.view`, `Lint.type_of`, `Lint.binder`, `Lint.same`, -`Lint.show`) and answers a list of `Lint.Diag`. Offsets count characters. +`Lint.show`, `Lint.normal`, `Lint.uses`, `Lint.text`) and answers a list of +`Lint.Diag`. Offsets count characters. ```sh bun tools/bend-lint/src/lint.ts file.bend --rules my_rule.bend diff --git a/tools/bend-lint/src/lint.bend b/tools/bend-lint/src/lint.bend index 0e582f846e..5da689a944 100644 --- a/tools/bend-lint/src/lint.bend +++ b/tools/bend-lint/src/lint.bend @@ -48,12 +48,22 @@ type Fix is Data: type Diag is Data: Diag{severity: Severity, message: String, span: Maybe<&2, Span>, fixes: List<&2, Fix>} +# How many times a term may be used. +type Quantity is Data: + Erased{} + Once{} + Many{} + +type Use is Data: + Use{name: String, quantity: Quantity} + # A fact as bend-lint sees it: the def it is in, whether that def is a # template instance, the kind of its term once an annotation is stripped -# (Var, App, ...), the name a Var refers to (else ""), and the spans of the -# checked term and of the stripped one. +# (Var, App, ...), the name a Var refers to (else ""), how many times the +# term is demanded, and the spans of the checked term and of the stripped +# one. type View is Data: - View{owner: String, inst: Bool, kind: String, name: String, span: Maybe<&2, Span>, inner: Maybe<&2, Span>} + View{owner: String, inst: Bool, kind: String, name: String, quantity: Quantity, span: Maybe<&2, Span>, inner: Maybe<&2, Span>} def input() -> IO(Input): import "./lint.js" @@ -80,5 +90,17 @@ def same(fact: Fact, a: Term, b: Term) -> IO(Bool): def show(fact: Fact, t: Term) -> IO(String): import "./lint.js" +# The normal form of a type, in the fact's scope. +def normal(fact: Fact, t: Term) -> IO(Term): + import "./lint.js" + +# The variables the fact's term uses, each with how many times. +def uses(fact: Fact) -> IO(List<&2, Use>): + import "./lint.js" + +# The text under a span, in the file on disk. +def text(span: Span) -> IO(String): + import "./lint.js" + def serve(run: Input -> IO(List<&2, Diag>)) -> IO(Unit): IO.bind(Input, Unit, input(), i => IO.bind(List<&2, Diag>, Unit, run(i), report)) diff --git a/tools/bend-lint/src/lint.js b/tools/bend-lint/src/lint.js index dcb6825f8e..f0e3884bea 100644 --- a/tools/bend-lint/src/lint.js +++ b/tools/bend-lint/src/lint.js @@ -3,6 +3,7 @@ // String and U32 are native JS values here; other data is {$: CID(Name), ...}. const LINT_SEVERITY = { [CID(Error)]: "error", [CID(Warning)]: "warning", [CID(Information)]: "information", [CID(Hint)]: "hint" }; +const LINT_QUANTITY = { None: CID(Erased), Lone: CID(Once), Many: CID(Many) }; const LINT_APPLICABILITY = { [CID(Safe)]: "safe", [CID(Suggested)]: "suggested", [CID(Dangerous)]: "dangerous" }; function lint_host() { @@ -67,7 +68,7 @@ function lint_report(diags) { function lint_view(fact) { const v = lint_host().view(fact.id); return { - $: CID(View), owner: v.owner, inst: v.inst, kind: v.kind, name: v.name, + $: CID(View), owner: v.owner, inst: v.inst, kind: v.kind, name: v.name, quantity: { $: LINT_QUANTITY[v.quantity] }, span: lint_maybe(v.span && lint_span(v.span)), inner: lint_maybe(v.inner && lint_span(v.inner)), }; } @@ -82,3 +83,7 @@ io_eff(CID(binder), (fact) => { }); io_eff(CID(same), (fact, a, b) => lint_host().same(fact.id, a.id, b.id)); io_eff(CID(show), (fact, t) => lint_host().show(fact.id, t.id)); +io_eff(CID(normal), (fact, t) => lint_term(lint_host().normal(fact.id, t.id))); +io_eff(CID(uses), (fact) => lint_list(lint_host().uses(fact.id).map((u) => + ({ $: CID(Use), name: u.name, quantity: { $: LINT_QUANTITY[u.quantity] } })))); +io_eff(CID(text), (span) => lint_host().text(lint_spot(span))); diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index b277f4693f..3fb75f1b90 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -100,11 +100,14 @@ type Reported = { type Channel = { input(): { sources: Array<{ path: string; text: string; root: boolean }>; facts: number }; report(diags: Reported[]): void; - view(fact: number): { owner: Name; inst: boolean; kind: string; name: string; span?: Spot; inner?: Spot }; + view(fact: number): { owner: Name; inst: boolean; kind: string; name: string; quantity: Quant["$"]; span?: Spot; inner?: Spot }; type(fact: number): number; binder(fact: number): number | undefined; same(fact: number, a: number, b: number): boolean; show(fact: number, t: number): string; + normal(fact: number, t: number): number; + uses(fact: number): Array<{ name: Name; quantity: Quant["$"] }>; + text(span: Spot): string; }; type Hooked = Book & { see?: (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn: Span | undefined, qt: Quant, us: Uses) => void }; @@ -434,7 +437,7 @@ export async function bendRule(file: string): Promise { view: (i) => { const f = pick(facts, i, "fact"); const t = Bend.term_strip(f.tm); - return { owner: f.def, inst: f.inst, kind: t.$, name: t.$ === "Var" ? t.k : "", span: spot(f.spn), inner: spot(cx.span(t.s)) }; + return { owner: f.def, inst: f.inst, kind: t.$, name: t.$ === "Var" ? t.k : "", quantity: f.qt.$, span: spot(f.spn), inner: spot(cx.span(t.s)) }; }, type: (i) => keep(pick(facts, i, "fact").ty), binder: (i) => { @@ -444,6 +447,15 @@ export async function bendRule(file: string): Promise { }, same: (i, a, b) => cx.same(pick(facts, i, "fact"), pick(terms, a, "term"), pick(terms, b, "term")), show: (i, t) => cx.show(pick(facts, i, "fact"), pick(terms, t, "term")), + normal: (i, t) => keep(Bend.term_snf(pick(facts, i, "fact").bok, pick(terms, t, "term"))), + uses: (i) => { + const f = pick(facts, i, "fact"); + return Bend.pmap_to_array(f.us).flatMap(([v, q]) => q.$ === "None" ? [] : [{ name: Bend.pmap_get(f.ctx, v)?.k ?? "", quantity: q.$ }]); + }, + text: (s) => { + const spn = span(s); + return spn.file.str.slice(spn.beg, spn.end); + }, }; let code: number; try { diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 03b709d505..c68beb45b7 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -193,8 +193,9 @@ def main() -> IO(Unit): Lint.serve(run) `; -// A typed rule written in Bend: for each Var in def id, its type and -// whether the checker finds it equal to its binder's. +// A typed rule written in Bend: for each Var in def id, its type and normal +// form, whether the checker finds it equal to its binder's, its text, how +// many times it is demanded, and what it uses. const TYPES_BEND = String.raw`import Base import ../../../src/lint.bend as Lint @@ -218,26 +219,59 @@ def compare(f: Lint.Fact, b: Maybe<&2, Lint.Term>, t: Lint.Term) -> IO(Bool): case Some{x}: Lint.same(f, x, t) -def describe(+f: Lint.Fact, name: String, span: Maybe<&2, Lint.Span>) -> IO(Maybe<&2, Lint.Diag>): +def quantity(q: Lint.Quantity) -> String: + match q: + case Lint.Erased{}: + "erased" + case Lint.Once{}: + "once" + case Lint.Many{}: + "many" + +def use_text(u: Lint.Use) -> String: + match u: + case Lint.Use{name, q}: + name ++ " " ++ quantity(q) + +def first_use(us: List<&2, Lint.Use>) -> String: + match us: + case Nil{}: + "nothing" + case Con{u, rest}: + use_text(u) + +def text_of(inner: Maybe<&2, Lint.Span>) -> IO(String): + match inner: + case None{}: + IO.pure(String, "") + case Some{s}: + Lint.text(s) + +def describe(+f: Lint.Fact, name: String, q: Lint.Quantity, span: Maybe<&2, Lint.Span>, inner: Maybe<&2, Lint.Span>) -> IO(Maybe<&2, Lint.Diag>): do IO>: t : Lint.Term <- Lint.type_of(f) shown : String <- Lint.show(f, t) + t2 : Lint.Term <- Lint.type_of(f) + n : Lint.Term <- Lint.normal(f, t2) + nf : String <- Lint.show(f, n) u : Lint.Term <- Lint.type_of(f) b : Maybe<&2, Lint.Term> <- Lint.binder(f) same : Bool <- compare(f, b, u) - return Some{Lint.Diag{Lint.Hint{}, String.append(name, String.append(": ", String.append(shown, verdict(same)))), span, []}} + here : String <- text_of(inner) + us : List<&2, Lint.Use> <- Lint.uses(f) + return Some{Lint.Diag{Lint.Hint{}, name ++ ": " ++ shown ++ " = " ++ nf ++ verdict(same) ++ ", text " ++ here ++ ", demanded " ++ quantity(q) ++ ", uses " ++ first_use(us), span, []}} -def wanted(hit: Bool, +f: Lint.Fact, name: String, span: Maybe<&2, Lint.Span>) -> IO(Maybe<&2, Lint.Diag>): +def wanted(hit: Bool, +f: Lint.Fact, name: String, q: Lint.Quantity, span: Maybe<&2, Lint.Span>, inner: Maybe<&2, Lint.Span>) -> IO(Maybe<&2, Lint.Diag>): match hit: case True{}: - describe(f, name, span) + describe(f, name, q, span, inner) case False{}: IO.pure(Maybe<&2, Lint.Diag>, None{}) def fact_diag(+f: Lint.Fact, v: Lint.View) -> IO(Maybe<&2, Lint.Diag>): match v: - case Lint.View{owner, inst, kind, name, span, inner}: - wanted(Bool.and(String.eq(owner, "id"), Bool.and(String.eq(kind, "Var"), Bool.not(inst))), f, name, span) + case Lint.View{owner, inst, kind, name, q, span, inner}: + wanted(Bool.and(String.eq(owner, "id"), Bool.and(String.eq(kind, "Var"), Bool.not(inst))), f, name, q, span, inner) def prepend(m: Maybe<&2, Lint.Diag>, xs: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: match m: @@ -706,11 +740,8 @@ describe("rules written in Bend", () => { const rule = await bendRule(fixture("types_rule.bend", TYPES_BEND)); expect(rule.needsTypes).toBe(true); const res = await lint(userland, [rule, rule]); - expect(res.diags.length).toBeGreaterThan(0); - for (const d of res.diags) { - expect(d.message).toMatch(/^x: \S.* \(same as its binder\)$/); - expect(d.spn?.file).toBe(root(res.sources).file); - } + expect(res.diags.map((d) => d.message)).toEqual(Array(2).fill("x: Alias = N (same as its binder), text x, demanded once, uses x once")); + expect(res.diags.every((d) => d.spn?.file === root(res.sources).file)).toBe(true); }); }); From 2d1069fd0b48c0f2f42f959d3580f578829e3c59 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 11:40:45 -0300 Subject: [PATCH 09/39] bend-lint: review pass; compile Bend rules once 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 21 ++++--- tools/bend-lint/src/lint.ts | 100 +++++++++++++----------------- tools/bend-lint/src/patch.ts | 61 ++++++++++-------- tools/bend-lint/test/lint.test.ts | 29 ++++++--- 4 files changed, 109 insertions(+), 102 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 411d643857..620a1741ed 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -5,8 +5,8 @@ rules on it. A rule reads the source text, the checker's types, or both, and returns diagnostics with fixes. Bend itself does not change. - `src/lint.ts`: the library and the CLI. -- `src/patch.ts`: the two changes made to `bend2/bend.ts` as Bun loads it, - and the pin. +- `src/patch.ts`: what bend-lint changes in `bend2/bend.ts` and + `bend2/comp.ts` as Bun loads them, and the pin. - `src/lint.bend`, `src/lint.js`: the contract for rules written in Bend, and its effects. @@ -60,7 +60,8 @@ found. A finding with severity `error` stops the run. spans; `fact.inst` marks the facts of an instance. - `span(s)`: a span from bend.ts (for example `term.s`), in the file on disk. -- `walk`, `binder`, `show`, `same`, `diag`: helpers over bend's terms. +- `walk`, `binder`, `show`, `same`, `normal`, `uses`, `diag`: helpers over + bend's terms. - `Bend`: the bend.ts module, for anything else. Get bend.ts from `cx.Bend`. Do not import `bend2/bend.ts` in a rule. @@ -79,23 +80,25 @@ through effects (`Lint.view`, `Lint.type_of`, `Lint.binder`, `Lint.same`, bun tools/bend-lint/src/lint.ts file.bend --rules my_rule.bend ``` -bend-lint checks the rule once; each run compiles it and runs its `main` -with bend's own IO runtime (`comp.ts` `io_run`), so a rule may also use -Base's effects. Effects run synchronously. +bend-lint checks and compiles the rule once, as `comp.ts` `io_run` does; +each run calls its `main` with bend's own IO runtime, so a rule may also +use Base's effects. Effects run synchronously. ## The patch bend.ts does not expose the checker's results, and builds file paths with -`/`. As Bun loads bend.ts, `patch.ts` changes two things; the file on disk -does not change: +`/`; comp.ts compiles and runs a program in one call. As Bun loads them, +`patch.ts` changes three things; the files on disk do not change: 1. `term_infer` and `term_check` get wrappers that record what they return. Checking gives the same results. 2. bend.ts gets `fs` and `path` from `patch.ts`, where real paths use `/` and a drive letter is a root, so imports resolve on Windows too. On POSIX these behave as node's. +3. comp.ts exports `RUNTIME_MAIN` and `js_sat`, so a rule written in Bend + is compiled once and run many times. -Each change looks for one exact anchor in bend.ts. If an anchor is not +Each change looks for one exact anchor. If an anchor is not found exactly once, or a self-check on a tiny program records no type, bend-lint stops with a `DriftError`. diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 3fb75f1b90..db01fa9166 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -1,9 +1,9 @@ #!/usr/bin/env bun // bend-lint checks a Bend file with bend's checker, then runs rules over -// its source and the checker's results. It loads bend2/bend.ts through -// ./patch.ts. Rules are TS modules, or Bend files built on ./lint.bend. Anything in bend.ts it cannot follow stops it with a -// DriftError. As a CLI, it exits 0 when ok, 1 when it found an error, 2 -// on bad usage or a tool failure. +// its source and the checker's results. Rules are TS modules, or Bend files +// built on ./lint.bend. It loads bend2 through ./patch.ts; anything there +// it cannot follow stops it with a DriftError. As a CLI, it exits 0 when +// ok, 1 when it found an error, 2 on bad usage or a tool failure. import * as url from "node:url"; import * as util from "node:util"; @@ -11,19 +11,17 @@ import * as util from "node:util"; import type * as BendModule from "../../../bend2/bend.ts"; import type * as CompModule from "../../../bend2/comp.ts"; import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "../../../bend2/bend.ts"; -import { BEND_TS, COMP_TS, DriftError, MARK, PIN_FILE, blob, current, fs, patch, path, pinned } from "./patch.ts"; +import { BEND2, DriftError, MARK, PIN_FILE, blob, current, fs, patch, path, pinned } from "./patch.ts"; // Types // ===== export type Bend = typeof BendModule; -export type Comp = typeof CompModule; export type Severity = "error" | "warning" | "information" | "hint"; // safe keeps behavior; suggested may change it; dangerous may break code. export type Applicability = "safe" | "suggested" | "dangerous"; export type Edit = { spn: Span; text: string }; export type Fix = { title: string; applicability: Applicability; edits: Edit[] }; -export type Position = { line: number; character: number }; export type Diag = { code: string; @@ -33,8 +31,6 @@ export type Diag = { def?: Name; ctx?: Ctx; bok?: Book; - obs?: string; - note?: string; fixes: Fix[]; core?: unknown; // bend's own failure, when the check failed }; @@ -45,8 +41,6 @@ export type DiagInit = { spn?: Span; fact?: Fact; def?: Name; - obs?: string; - note?: string; fixes?: Fix[]; }; @@ -73,6 +67,8 @@ export type RuleContext = { binder(fact: Fact, v: LTerm): Ann | null; show(fact: Fact, ty: HTerm): string; same(fact: Fact, a: HTerm, b: HTerm): boolean; + normal(fact: Fact, ty: HTerm): HTerm; + uses(fact: Fact): Array<{ name: Name; quantity: Quant }>; diag(init: DiagInit): Diag; }; @@ -110,6 +106,9 @@ type Channel = { text(span: Spot): string; }; +// comp.ts as patch.ts exports it. +type Comp = typeof CompModule & { RUNTIME_MAIN: string; js_sat(k: Name): string }; + type Hooked = Book & { see?: (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn: Span | undefined, qt: Quant, us: Uses) => void }; type Mapper = (s: S) => S; type Checked = { book: Book; sources: Source[]; span: Mapper; facts?: Map; failure?: Diag }; @@ -185,16 +184,6 @@ function starts(text: string): number[] { return [0, ...[...text.matchAll(/\n/g)].map((m) => m.index! + 1)]; } -function line(starts: number[], off: number): number { - let lo = 0; - let hi = starts.length - 1; - while (lo < hi) { - const mid = (lo + hi + 1) >> 1; - [lo, hi] = starts[mid] <= off ? [mid, hi] : [lo, mid - 1]; - } - return lo; -} - // bend.ts parses a copy of each file with its import lines blanked, so a // span moves to the file on disk by line and column. A copy that does not // match its file is a DriftError. @@ -223,8 +212,12 @@ export function mapper(sources: Source[]): Mapper { } const { file, from, to } = memo.get(f)!; const at = (off: number): number => { - const i = line(from, off); - return to[i] + off - from[i]; + let [lo, hi] = [0, from.length - 1]; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + [lo, hi] = from[mid] <= off ? [mid, hi] : [lo, mid - 1]; + } + return to[lo] + off - from[lo]; }; return { file, beg: at(s.beg), end: at(s.end) } as S; }; @@ -246,12 +239,12 @@ async function check(file: string, capture: boolean, signal: AbortSignal): Promi ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => void (bok.tlds[def]?.b !== true && found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us })) : undefined; const seeded = fs.existsSync(file) && /^import Base\s*$/m.test(fs.readFileSync(file, "utf8")); - const key = seeded ? blob(fs.readFileSync(Bend.BASE_BEND, "utf8")) : ""; + const key = blob(fs.readFileSync(Bend.BASE_BEND, "utf8")); if (seeded && !BASES.has(key)) { const base = Bend.book_nil(); BASES.set(key, Bend.book_load(base, Bend.BASE_BEND, "", new Map()).then(() => (Bend.book_valid(base), base))); } - const caught = await (seeded ? BASES.get(key)! : Promise.resolve(undefined)).then(async (base) => { + const caught = await Promise.resolve(seeded ? BASES.get(key) : undefined).then(async (base) => { if (base !== undefined) { Object.assign(book.tlds, Object.fromEntries(Object.entries(base.tlds).map(([k, tld]) => [k, { ...tld }]))); Object.assign(book.ctrs, base.ctrs); @@ -312,9 +305,12 @@ export async function lint(file: string, rules: LintRule[], signal = new AbortCo binder: (fact, v) => v.$ === "Var" ? Bend.pmap_get(fact.ctx, v.i) : null, show: (fact, ty) => Bend.term_show(Bend.term_lower(ty, fact.dep)), same: (fact, a, b) => Bend.term_compare("EQ", fact.bok, a, b, fact.dep), + normal: (fact, ty) => Bend.term_snf(fact.bok, ty), + uses: (fact) => Bend.pmap_to_array(fact.us).flatMap(([v, quantity]) => + quantity.$ === "None" ? [] : [{ name: Bend.pmap_get(fact.ctx, v)?.k ?? "", quantity }]), diag: (d) => ({ code: rule.id, severity: d.severity ?? "warning", message: d.message, spn: d.spn, - def: d.def ?? d.fact?.def, ctx: d.fact?.ctx, bok: d.fact?.bok ?? book, obs: d.obs, note: d.note, fixes: d.fixes ?? [], + def: d.def ?? d.fact?.def, ctx: d.fact?.ctx, bok: d.fact?.bok ?? book, fixes: d.fixes ?? [], }), }, signal); signal.throwIfAborted(); @@ -359,7 +355,7 @@ function common(a: string[], b: string[]): number { // bend's own error layout; the head names the severity and the code, and // each fix follows as a unified diff. export function render(d: Diag): string { - const err = isErr(d.core) ? d.core : Bend.Err(d.bok ?? Bend.book_nil(), d.ctx ?? Bend.ctx_nil(), d.message, d.obs, d.spn, d.def, d.note); + const err = isErr(d.core) ? d.core : Bend.Err(d.bok ?? Bend.book_nil(), d.ctx ?? Bend.ctx_nil(), d.message, undefined, d.spn, d.def); const fixes = d.fixes.map((fix) => "\n\nFix: " + fix.title + " [" + fix.applicability + "]" + [...new Set(fix.edits.map((e) => e.spn.file))].map((file) => { const old = file.str.match(LINE) ?? []; @@ -378,19 +374,9 @@ export function render(d: Diag): string { return Bend.err_show(err).replace(/^Error:/, HEAD[d.severity] + " [" + d.code + "]:") + fixes.join(""); } -// The LSP range of a span in a file on disk (0-based, UTF-16). -export function position(spn: Span): { start: Position; end: Position } { - const ss = starts(spn.file.str); - const at = (off: number): Position => { - const i = line(ss, off); - return { line: i, character: off - ss[i] }; - }; - return { start: at(spn.beg), end: at(spn.end) }; -} - -// A rule written in Bend: a file built on ./lint.bend (see there). Its -// book is checked once; each run compiles it and runs its main with bend's -// own IO runtime, while lint.js reaches bend-lint through +// A rule written in Bend: a file built on ./lint.bend (see there). It is +// checked and compiled once, as comp.ts io_run does; each run calls its +// main with bend's own IO runtime, while lint.js reaches bend-lint through // globalThis.BEND_LINT. Offsets cross as code points. export async function bendRule(file: string): Promise { const { book, failure } = await check(file, false, new AbortController().signal); @@ -406,13 +392,15 @@ export async function bendRule(file: string): Promise { if (id === undefined || !["True{}", "False{}"].includes(types) || Comp.io_type(book) === null) { throw new Error(file + " must define id() -> String, types() -> Bool and main() -> IO(Unit)"); } + const main = new Function("require", Comp.js_lib(book) + "\n" + Comp.RUNTIME_MAIN + + "\nreturn (args) => { cli_args = args; return io_run(" + Comp.js_sat("main") + "); };")(import.meta.require) as (args: string[]) => number; return { id, needsTypes: types === "True{}", run: (cx) => { - const spot = (spn: Span | undefined): Spot | undefined => spn && { - path: (spn.file as SourceFile).path, beg: [...spn.file.str.slice(0, spn.beg)].length, end: [...spn.file.str.slice(0, spn.end)].length, - }; + const points = (text: string, off: number): number => [...text.slice(0, off)].length; + const spot = (spn: Span | undefined): Spot | undefined => + spn && { path: (spn.file as SourceFile).path, beg: points(spn.file.str, spn.beg), end: points(spn.file.str, spn.end) }; const span = (s: Spot): Span => { const src = cx.sources.find((x) => x.path === s.path); if (src === undefined) { @@ -447,11 +435,8 @@ export async function bendRule(file: string): Promise { }, same: (i, a, b) => cx.same(pick(facts, i, "fact"), pick(terms, a, "term"), pick(terms, b, "term")), show: (i, t) => cx.show(pick(facts, i, "fact"), pick(terms, t, "term")), - normal: (i, t) => keep(Bend.term_snf(pick(facts, i, "fact").bok, pick(terms, t, "term"))), - uses: (i) => { - const f = pick(facts, i, "fact"); - return Bend.pmap_to_array(f.us).flatMap(([v, q]) => q.$ === "None" ? [] : [{ name: Bend.pmap_get(f.ctx, v)?.k ?? "", quantity: q.$ }]); - }, + normal: (i, t) => keep(cx.normal(pick(facts, i, "fact"), pick(terms, t, "term"))), + uses: (i) => cx.uses(pick(facts, i, "fact")).map((u) => ({ name: u.name, quantity: u.quantity.$ })), text: (s) => { const spn = span(s); return spn.file.str.slice(spn.beg, spn.end); @@ -459,7 +444,7 @@ export async function bendRule(file: string): Promise { }; let code: number; try { - code = Comp.io_run(book, [file]); + code = main([file]); } finally { shared.BEND_LINT = undefined; } @@ -474,19 +459,20 @@ export async function bendRule(file: string): Promise { }; } -// Patches bend.ts as Bun loads it, then checks a tiny program: the type of -// `x` in `id` must be recorded as N. +// Patches bend.ts and comp.ts as Bun loads them, then checks a tiny +// program: the type of `x` in `id` must be recorded as N. async function instrument(): Promise<{ Bend: Bend; Comp: Comp }> { - const patched = patch(fs.readFileSync(BEND_TS, "utf8")); + const patched = new Map(["bend.ts", "comp.ts"].map((f) => [f, patch(f, fs.readFileSync(path.join(BEND2, f), "utf8"))])); Bun.plugin({ name: "bend-lint", setup: (build) => { - build.onLoad({ filter: /[\\/]bend2[\\/]bend\.ts$/ }, () => ({ contents: patched, loader: "ts" })); + build.onLoad({ filter: /[\\/]bend2[\\/](bend|comp)\.ts$/ }, (args) => ({ contents: patched.get(path.basename(args.path))!, loader: "ts" })); }, }); - const B: Bend & { [MARK]?: number } = await import(url.pathToFileURL(BEND_TS).href); - if (B[MARK] !== 1) { - throw new DriftError("bend2/bend.ts was loaded before bend-lint could patch it; import bend-lint first"); + const [B, C]: [Bend & { [MARK]?: number }, Comp & { [MARK]?: number }] = await Promise.all( + ["bend.ts", "comp.ts"].map((f) => import(url.pathToFileURL(path.join(BEND2, f)).href))); + if (B[MARK] !== 1 || C[MARK] !== 1) { + throw new DriftError("bend2 was loaded before bend-lint could patch it; import bend-lint first"); } const sample: Hooked = B.book_nil(); const seen: Array<{ bok: Book; tm: LTerm; ty: HTerm; def: Name }> = []; @@ -498,7 +484,7 @@ async function instrument(): Promise<{ Bend: Bend; Comp: Comp }> { if (!typed) { throw new DriftError("self-check failed: the patched bend.ts recorded no type for `id`; update tools/bend-lint/src/patch.ts"); } - return { Bend: B, Comp: await import(url.pathToFileURL(COMP_TS).href) }; + return { Bend: B, Comp: C }; } async function cli(argv: string[]): Promise { diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index 297f1d3c14..84890811fc 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -1,17 +1,22 @@ -// The two changes bend-lint makes to bend2/bend.ts as Bun loads it; the -// file on disk never changes. One wraps term_infer and term_check so a -// `book.see` hook records what they return. The other gives bend.ts this -// module's fs and path: bend.ts builds paths with "/" (path.posix), so here -// real paths use "/" and a Windows drive letter counts as a root. On POSIX -// they behave as node's. bend.pin holds the hashes of the bend2 files -// bend-lint was tested on: bend.ts, which it patches, and comp.ts, whose -// io_run runs rules written in Bend. +// What bend-lint changes in bend2 as Bun loads it; the files on disk never +// change. In bend.ts, term_infer and term_check get wrappers so a +// `book.see` hook records what they return, and fs and path come from this +// module: bend.ts builds paths with "/" (path.posix), so here real paths use +// "/" and a Windows drive letter counts as a root; on POSIX they behave as +// node's. In comp.ts, RUNTIME_MAIN and js_sat are exported, so a rule +// written in Bend is compiled once and run many times. bend.pin holds the +// hashes of the files these were tested on. import * as crypto from "node:crypto"; import * as nodeFs from "node:fs"; import * as nodePath from "node:path"; import * as url from "node:url"; +// Types +// ===== + +type Patch = { name: string; at: RegExp; to: string }; + // Constants // ========= @@ -25,20 +30,17 @@ const SHIM = JSON.stringify(url.pathToFileURL(nodePath.join(HERE, "patch.ts")).h export const MARK = "BEND_LINT_PATCH"; export const PIN_FILE = nodePath.join(HERE, "..", "bend.pin"); -const PINNED = ["bend.ts", "comp.ts"]; -const PATCHES = [ - { +const PATCHES: Record = { + "bend.ts": [{ name: "the node:fs import", at: /^import \* as fs from "node:fs";/m, to: "import { fs } from " + SHIM + ";", - }, - { + }, { name: "the node:path import", at: /^import \* as path from "node:path";/m, to: "import { path } from " + SHIM + ";", - }, - { + }, { name: "term_infer", at: /^export function term_infer\(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm\[\] = \[\]\): Infer \{/m, to: [ @@ -49,8 +51,7 @@ const PATCHES = [ "}", "function lint_infer(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm[]): Infer {", ].join("\n"), - }, - { + }, { name: "term_check", at: /^export function term_check\(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number\): Check \{/m, to: [ @@ -61,8 +62,17 @@ const PATCHES = [ "}", "function lint_check(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number): Check {", ].join("\n"), - }, -]; + }], + "comp.ts": [{ + name: "RUNTIME_MAIN", + at: /^const RUNTIME_MAIN: string = /m, + to: "export const RUNTIME_MAIN: string = ", + }, { + name: "js_sat", + at: /^function js_sat\(k: Name\): string \{/m, + to: "export function js_sat(k: Name): string {", + }], +}; // fs and path for bend.ts. export const fs = { ...nodeFs, realpathSync: (p: nodeFs.PathLike): string => slash(nodeFs.realpathSync(p)) }; @@ -77,7 +87,7 @@ export const path = { // Functions // ========= -export function slash(p: string): string { +function slash(p: string): string { return p.split(nodePath.sep).join("/"); } @@ -92,11 +102,12 @@ export function relative(from: string, to: string): string { return nodePath.posix.relative(slash(from).replace(DRIVE, ""), slash(to).replace(DRIVE, "")); } -export function patch(src: string): string { - return PATCHES.reduce((out, { name, at, to }) => { +// `file` is "bend.ts" or "comp.ts"; each anchor must be found exactly once. +export function patch(file: string, src: string): string { + return PATCHES[file].reduce((out, { name, at, to }) => { const n = out.match(new RegExp(at.source, "gm"))?.length ?? 0; if (n !== 1) { - throw new DriftError("cannot patch bend2/bend.ts: found " + n + " of " + name + throw new DriftError("cannot patch bend2/" + file + ": found " + n + " of " + name + ", expected 1. Update PATCHES in tools/bend-lint/src/patch.ts."); } return out.replace(at, () => to); @@ -115,13 +126,11 @@ export function pinned(): string { } export function current(): string { - return PINNED.map((f) => f + " " + blob(nodeFs.readFileSync(path.join(BEND2, f), "utf8"))).join("\n"); + return Object.keys(PATCHES).map((f) => f + " " + blob(nodeFs.readFileSync(path.join(BEND2, f), "utf8"))).join("\n"); } // Side effects // ============ export const BEND2 = fs.realpathSync(path.join(HERE, "..", "..", "..", "bend2")); -export const BEND_TS = path.join(BEND2, "bend.ts"); -export const COMP_TS = path.join(BEND2, "comp.ts"); diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index c68beb45b7..84cd48ddd2 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -5,9 +5,9 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { Book, LTerm, Span } from "../../../bend2/bend.ts"; -import { Bend, applyFixes, bendRule, lint, mapper, render, walk } from "../src/lint.ts"; +import { Bend, Comp, applyFixes, bendRule, lint, mapper, render, walk } from "../src/lint.ts"; import type { Diag, Edit, Fact, LintRule, RuleContext, Source, SourceFile } from "../src/lint.ts"; -import { BEND_TS, DriftError, blob, current, patch, pinned, relative, resolve } from "../src/patch.ts"; +import { BEND2, DriftError, blob, current, patch, pinned, relative, resolve } from "../src/patch.ts"; // Types // ===== @@ -429,10 +429,11 @@ function module(name: string, body: string): string { afterAll(() => fs.rmSync(DIR, { recursive: true, force: true })); describe("patch", () => { - const src = fs.readFileSync(BEND_TS, "utf8"); + const src = fs.readFileSync(path.join(BEND2, "bend.ts"), "utf8"); + const comp = fs.readFileSync(path.join(BEND2, "comp.ts"), "utf8"); - test("wraps term_infer and term_check, and swaps fs and path", () => { - const out = patch(src); + test("bend.ts: wraps term_infer and term_check, and swaps fs and path", () => { + const out = patch("bend.ts", src); expect(out.match(/^export function term_infer\(/gm)?.length).toBe(1); expect(out).toContain("function lint_infer("); expect(out).toContain("function lint_check("); @@ -440,17 +441,25 @@ describe("patch", () => { expect(out).toMatch(/^import \{ path \} from "file:.*patch\.ts";/m); }); + test("comp.ts: exports RUNTIME_MAIN and js_sat", () => { + const out = patch("comp.ts", comp); + expect(out).toMatch(/^export const RUNTIME_MAIN: string = /m); + expect(out).toMatch(/^export function js_sat\(/m); + }); + test("fails loudly when an anchor changes or repeats", () => { - expect(() => patch(src.replace("term_infer(book: Book,", "term_infer(bk: Book,"))).toThrow(DriftError); - expect(() => patch(src.replace('import * as fs from "node:fs";', 'import fs from "node:fs";'))).toThrow(/node:fs import/); - expect(() => patch(src + "\n" + src.match(/^export function term_check\(.*$/m)![0] + "\n")).toThrow(/found 2 of term_check/); + expect(() => patch("bend.ts", src.replace("term_infer(book: Book,", "term_infer(bk: Book,"))).toThrow(DriftError); + expect(() => patch("bend.ts", src.replace('import * as fs from "node:fs";', 'import fs from "node:fs";'))).toThrow(/node:fs import/); + expect(() => patch("bend.ts", src + "\n" + src.match(/^export function term_check\(.*$/m)![0] + "\n")).toThrow(/found 2 of term_check/); + expect(() => patch("comp.ts", comp.replace("function js_sat(", "function js_name("))).toThrow(/comp\.ts: found 0 of js_sat/); }); - test("the loaded bend.ts is the patched one", () => { + test("the loaded bend.ts and comp.ts are the patched ones", () => { expect((Bend as unknown as Record).BEND_LINT_PATCH).toBe(1); + expect((Comp as unknown as Record).BEND_LINT_PATCH).toBe(1); }); - test("the pin is a git blob hash, and matches bend.ts", () => { + test("the pin holds git blob hashes, and matches bend2", () => { expect(blob("a\r\nb\n")).toBe(blob("a\nb\n")); expect(blob("hello\n")).toBe("ce013625030ba8dba906f756967f9e9ca394464a"); expect(pinned()).toMatch(/^bend\.ts [0-9a-f]{40}\ncomp\.ts [0-9a-f]{40}$/); From 0ddd22802c28bfc2ebc37ed34ab91b82f0d3389a Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 11:48:03 -0300 Subject: [PATCH 10/39] bend-lint: fix two type errors; track tsconfig.json 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 5 +++++ tools/bend-lint/src/lint.ts | 19 ++++++++++++------- tools/bend-lint/tsconfig.json | 4 ++++ 3 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 tools/bend-lint/tsconfig.json diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 620a1741ed..cfb2ab6b66 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -117,4 +117,9 @@ bun tools/bend-lint/src/lint.ts --pin ```sh bun test tools/bend-lint +bunx tsc -p tools/bend-lint/tsconfig.json --noEmit ``` + +Bun does not check types, so run `tsc` too. It also checks the parts of +bend2 that bend-lint imports, and today reports two errors there +(bend.ts lines 2040 and 3771); bend-lint itself has none. diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index db01fa9166..5c2de4a79e 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -62,7 +62,7 @@ export type RuleContext = { root: Source; facts?: Map; // only for a rule with needsTypes prior: readonly Diag[]; // what earlier rules found - span(s: S): S; // a bend.ts span, in the file on disk + span: Mapper; // a bend.ts span, in the file on disk walk(tm: LTerm): Generator; binder(fact: Fact, v: LTerm): Ann | null; show(fact: Fact, ty: HTerm): string; @@ -110,7 +110,7 @@ type Channel = { type Comp = typeof CompModule & { RUNTIME_MAIN: string; js_sat(k: Name): string }; type Hooked = Book & { see?: (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn: Span | undefined, qt: Quant, us: Uses) => void }; -type Mapper = (s: S) => S; +type Mapper = { (s: Span): Span; (s: Span | undefined): Span | undefined }; type Checked = { book: Book; sources: Source[]; span: Mapper; facts?: Map; failure?: Diag }; // Constants @@ -190,7 +190,9 @@ function starts(text: string): number[] { export function mapper(sources: Source[]): Mapper { const own = new Set(sources.map((s) => s.file)); const memo = new WeakMap(); - return (s: S): S => { + function map(s: Span): Span; + function map(s: Span | undefined): Span | undefined; + function map(s: Span | undefined): Span | undefined { if (s === undefined || own.has(s.file)) { return s; } @@ -219,8 +221,9 @@ export function mapper(sources: Source[]): Mapper { } return to[lo] + off - from[lo]; }; - return { file, beg: at(s.beg), end: at(s.end) } as S; - }; + return { file, beg: at(s.beg), end: at(s.end) }; + } + return map; } function isErr(e: unknown): e is Err { @@ -469,8 +472,10 @@ async function instrument(): Promise<{ Bend: Bend; Comp: Comp }> { build.onLoad({ filter: /[\\/]bend2[\\/](bend|comp)\.ts$/ }, (args) => ({ contents: patched.get(path.basename(args.path))!, loader: "ts" })); }, }); - const [B, C]: [Bend & { [MARK]?: number }, Comp & { [MARK]?: number }] = await Promise.all( - ["bend.ts", "comp.ts"].map((f) => import(url.pathToFileURL(path.join(BEND2, f)).href))); + const [B, C]: [Bend & { [MARK]?: number }, Comp & { [MARK]?: number }] = await Promise.all([ + import(url.pathToFileURL(path.join(BEND2, "bend.ts")).href), + import(url.pathToFileURL(path.join(BEND2, "comp.ts")).href), + ]); if (B[MARK] !== 1 || C[MARK] !== 1) { throw new DriftError("bend2 was loaded before bend-lint could patch it; import bend-lint first"); } diff --git a/tools/bend-lint/tsconfig.json b/tools/bend-lint/tsconfig.json new file mode 100644 index 0000000000..2461672096 --- /dev/null +++ b/tools/bend-lint/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../bend2/pack/tsconfig.json", + "include": ["src", "test"] +} From ca1da361ab4ff0f2daf6e51402cf344ed4db6539 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 14:52:50 +0000 Subject: [PATCH 11/39] bend-lint: GUIDE.md, how to run it and write rules Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- gates/repo.ts | 2 +- tools/bend-lint/GUIDE.md | 203 ++++++++++++++++++++++++++++++++++++++ tools/bend-lint/README.md | 4 +- 3 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 tools/bend-lint/GUIDE.md diff --git a/gates/repo.ts b/gates/repo.ts index 44c8a33097..3d77dcce3c 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -84,7 +84,7 @@ allow(/^tests\/[a-z]+\/[a-z0-9_]+\.(c|js)$/); allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\.json|tsconfig\.json)$/); allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); -allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json|bend\.pin)$/); +allow(/^tools\/bend-lint\/(README\.md|GUIDE\.md|package\.json|tsconfig\.json|bend\.pin)$/); allow(/^tools\/bend-lint\/src\/(lint\.ts|patch\.ts|lint\.bend|lint\.js)$/); allow("tools/bend-lint/test/lint.test.ts"); diff --git a/tools/bend-lint/GUIDE.md b/tools/bend-lint/GUIDE.md new file mode 100644 index 0000000000..7e48414be9 --- /dev/null +++ b/tools/bend-lint/GUIDE.md @@ -0,0 +1,203 @@ +# bend-lint guide + +This guide shows how to run bend-lint and how to write your own rules. The +README describes how the tool works inside. + +## What bend-lint does + +bend-lint checks a `.bend` file with bend's own checker, then runs rules on +it. A rule finds problems and can propose fixes. A rule can read: + +- the source text (style, format), and +- the checker's results for each term: its type, its scope, how many times + it is used. + +bend itself does not change. You need Bun 1.2 or newer. + +## 1. Run it + +From the repo root: + +```sh +bun tools/bend-lint/src/lint.ts my_file.bend +``` + +With no rules, bend-lint only checks the file, as `bend --check-only` does. +Give rules with `--rules`, once per module: + +```sh +bun tools/bend-lint/src/lint.ts my_file.bend --rules my_rules.ts --rules my_rule.bend +bun tools/bend-lint/src/lint.ts my_file.bend --rules my_rules.ts --fix +``` + +`--fix` writes the `safe` fixes into your files. + +A finding prints in bend's error layout, with its severity and code, and +each fix as a diff: + +``` +Warning [style/comma-space]: +- message : Add a space after the comma. +Location: +... +Fix: Insert space [safe] +--- my_file.bend ++++ my_file.bend +@@ -27,1 +27,1 @@ +- generic(~N,a) ++ generic(~N, a) +``` + +Exit codes: + +| Code | Meaning | +|---|---| +| 0 | no error | +| 1 | an error: the file does not check, or a rule found an `error` | +| 2 | bad usage, or a tool failure | + +## 2. Write a rule in TypeScript + +A rule module exports `rules`, an array of rules. Each rule has: + +- `id`: `namespace/name`. This is also the code of its findings. +- `needsTypes` (optional): `true` gives the rule the checker's results. +- `run(cx, signal)`: returns a list of findings. It can be `async`. + +### A rule that reads text + +```ts +import type { LintRule } from "./tools/bend-lint/src/lint.ts"; + +export const rules: LintRule[] = [{ + id: "style/comma-space", + run(cx) { + return [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { + const spn = { file: cx.root.file, beg: m.index! + 1, end: m.index! + 1 }; + return cx.diag({ + message: "Add a space after the comma.", + severity: "warning", + spn, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }], + }); + }); + }, +}]; +``` + +### A rule that reads types + +With `needsTypes: true`, `cx.facts` holds one fact per checked term: the +term, its type, its scope, its depth, its def, its uses, and its span in the +file on disk. + +```ts +import type { LintRule } from "./tools/bend-lint/src/lint.ts"; + +export const rules: LintRule[] = [{ + id: "demo/var-types", + needsTypes: true, + run(cx) { + return [...cx.facts!.values()] + .filter((f) => !f.inst // skip copies from template instances + && f.spn?.file === cx.root.file // only the linted file + && cx.Bend.term_strip(f.tm).$ === "Var") + .map((f) => cx.diag({ message: "type: " + cx.show(f, f.ty), severity: "hint", spn: f.spn, fact: f })); + }, +}]; +``` + +### What `cx` gives + +| Field | What it is | +|---|---| +| `root`, `sources` | the linted file and its imports, as they are on disk | +| `facts` | the checker's results (only with `needsTypes`) | +| `prior` | the findings of the rules that ran before this one | +| `span(s)` | a span from bend (for example `term.s`), moved to the file on disk | +| `walk(tm)` | every sub-term of a checked term | +| `binder(fact, v)` | the binder a variable refers to, with its type | +| `show(fact, t)` | a type as text | +| `same(fact, a, b)` | whether the checker finds two types equal | +| `normal(fact, t)` | the normal form of a type | +| `uses(fact)` | the variables a term uses, and how many times | +| `diag({...})` | makes a finding; the default severity is `warning` | +| `Bend` | the whole bend module, for anything else | + +Severities: `error` (stops the run; exit 1), `warning`, `information`, +`hint`. + +Fix levels: `safe` keeps behavior (`--fix` applies these), `suggested` may +change behavior, `dangerous` may break code. + +### Things to know + +- Get bend from `cx.Bend`. Do not import `bend2/bend.ts` in a rule. +- Base's own defs give no facts. You see only your code and its imports. +- A template body is checked once as written and once per instance + (`generic~0`), at the same spans. Use `fact.inst` to skip the instances. +- Rules run in the order you give them. When the file does not check, no + rule runs. + +## 3. Write a rule in Bend + +A Bend rule imports `tools/bend-lint/src/lint.bend` by a relative path and +defines four things: + +```python +import Base +import ../tools/bend-lint/src/lint.bend as Lint + +def id() -> String: + "demo/nothing" + +def types() -> Bool: + False{} + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + IO.pure(List<&2, Lint.Diag>, []) + +def main() -> IO(Unit): + Lint.serve(run) +``` + +- `types()` is `True{}` if the rule needs the checker's results. +- `input` holds the sources (path, text, root) and, with `types()`, the + facts. +- Ask about a fact with these effects: `Lint.view`, `Lint.type_of`, + `Lint.binder`, `Lint.same`, `Lint.show`, `Lint.normal`, `Lint.uses`, + `Lint.text`. `src/lint.bend` lists them, and all types. +- Offsets in Bend count characters. +- A Bend rule can also use Base's effects (files, printing). +- bend-lint compiles a Bend rule once. Each run after that takes about + 10 ms. + +For two complete rules, see `COMMA_BEND` (text) and `TYPES_BEND` (types) +in `test/lint.test.ts`. + +## 4. Test your rule + +Use the library in a `bun test`: + +```ts +import { applyFixes, bendRule, lint, render } from "./tools/bend-lint/src/lint.ts"; +import { rules } from "./my_rules.ts"; + +const res = await lint("example.bend", rules); // or [await bendRule("my_rule.bend")] +console.log(res.ok, res.diags.map(render)); +const root = res.sources.find((s) => s.root)!; +const fixed = applyFixes(root.file, res.diags); // the text with the safe fixes +``` + +## 5. When bend changes + +bend-lint is pinned to the `bend2/bend.ts` and `bend2/comp.ts` it was tested +with (`bend.pin`). When they change, bend-lint stops with a `DriftError`. +It does not give wrong results. To accept the new version: + +```sh +BEND_LINT_UNPINNED=1 bun test tools/bend-lint +bun tools/bend-lint/src/lint.ts --pin +``` + +If a test fails, the error names the part of bend-lint to update. diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index cfb2ab6b66..b0feede875 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -4,6 +4,8 @@ rules on it. A rule reads the source text, the checker's types, or both, and returns diagnostics with fixes. Bend itself does not change. +New here? Start with [GUIDE.md](GUIDE.md). + - `src/lint.ts`: the library and the CLI. - `src/patch.ts`: what bend-lint changes in `bend2/bend.ts` and `bend2/comp.ts` as Bun loads them, and the pin. @@ -34,7 +36,7 @@ export const rules: LintRule[] = [{ needsTypes: false, // true gives the rule cx.facts run(cx, signal) { return [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { - const spn = { file: cx.root.file, beg: m.index + 1, end: m.index + 1 }; + const spn = { file: cx.root.file, beg: m.index! + 1, end: m.index! + 1 }; return cx.diag({ message: "Add a space after the comma.", severity: "warning", spn, fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }], From 1c1a887f752bea2618ffb7a3e0c84d107c5a00e8 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 14:53:22 +0000 Subject: [PATCH 12/39] bend-lint: drop a trailing blank line Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/src/patch.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index 84890811fc..3c9a324441 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -133,4 +133,3 @@ export function current(): string { // ============ export const BEND2 = fs.realpathSync(path.join(HERE, "..", "..", "..", "bend2")); - From ce107d24f6558d402217bf3938d01444a8dfc06f Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 14:59:19 +0000 Subject: [PATCH 13/39] bend-lint: one condensed README in place of README and GUIDE Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- gates/repo.ts | 2 +- tools/bend-lint/GUIDE.md | 203 -------------------------------------- tools/bend-lint/README.md | 166 ++++++++++++++++--------------- 3 files changed, 87 insertions(+), 284 deletions(-) delete mode 100644 tools/bend-lint/GUIDE.md diff --git a/gates/repo.ts b/gates/repo.ts index 3d77dcce3c..44c8a33097 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -84,7 +84,7 @@ allow(/^tests\/[a-z]+\/[a-z0-9_]+\.(c|js)$/); allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\.json|tsconfig\.json)$/); allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); -allow(/^tools\/bend-lint\/(README\.md|GUIDE\.md|package\.json|tsconfig\.json|bend\.pin)$/); +allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json|bend\.pin)$/); allow(/^tools\/bend-lint\/src\/(lint\.ts|patch\.ts|lint\.bend|lint\.js)$/); allow("tools/bend-lint/test/lint.test.ts"); diff --git a/tools/bend-lint/GUIDE.md b/tools/bend-lint/GUIDE.md deleted file mode 100644 index 7e48414be9..0000000000 --- a/tools/bend-lint/GUIDE.md +++ /dev/null @@ -1,203 +0,0 @@ -# bend-lint guide - -This guide shows how to run bend-lint and how to write your own rules. The -README describes how the tool works inside. - -## What bend-lint does - -bend-lint checks a `.bend` file with bend's own checker, then runs rules on -it. A rule finds problems and can propose fixes. A rule can read: - -- the source text (style, format), and -- the checker's results for each term: its type, its scope, how many times - it is used. - -bend itself does not change. You need Bun 1.2 or newer. - -## 1. Run it - -From the repo root: - -```sh -bun tools/bend-lint/src/lint.ts my_file.bend -``` - -With no rules, bend-lint only checks the file, as `bend --check-only` does. -Give rules with `--rules`, once per module: - -```sh -bun tools/bend-lint/src/lint.ts my_file.bend --rules my_rules.ts --rules my_rule.bend -bun tools/bend-lint/src/lint.ts my_file.bend --rules my_rules.ts --fix -``` - -`--fix` writes the `safe` fixes into your files. - -A finding prints in bend's error layout, with its severity and code, and -each fix as a diff: - -``` -Warning [style/comma-space]: -- message : Add a space after the comma. -Location: -... -Fix: Insert space [safe] ---- my_file.bend -+++ my_file.bend -@@ -27,1 +27,1 @@ -- generic(~N,a) -+ generic(~N, a) -``` - -Exit codes: - -| Code | Meaning | -|---|---| -| 0 | no error | -| 1 | an error: the file does not check, or a rule found an `error` | -| 2 | bad usage, or a tool failure | - -## 2. Write a rule in TypeScript - -A rule module exports `rules`, an array of rules. Each rule has: - -- `id`: `namespace/name`. This is also the code of its findings. -- `needsTypes` (optional): `true` gives the rule the checker's results. -- `run(cx, signal)`: returns a list of findings. It can be `async`. - -### A rule that reads text - -```ts -import type { LintRule } from "./tools/bend-lint/src/lint.ts"; - -export const rules: LintRule[] = [{ - id: "style/comma-space", - run(cx) { - return [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { - const spn = { file: cx.root.file, beg: m.index! + 1, end: m.index! + 1 }; - return cx.diag({ - message: "Add a space after the comma.", - severity: "warning", - spn, - fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }], - }); - }); - }, -}]; -``` - -### A rule that reads types - -With `needsTypes: true`, `cx.facts` holds one fact per checked term: the -term, its type, its scope, its depth, its def, its uses, and its span in the -file on disk. - -```ts -import type { LintRule } from "./tools/bend-lint/src/lint.ts"; - -export const rules: LintRule[] = [{ - id: "demo/var-types", - needsTypes: true, - run(cx) { - return [...cx.facts!.values()] - .filter((f) => !f.inst // skip copies from template instances - && f.spn?.file === cx.root.file // only the linted file - && cx.Bend.term_strip(f.tm).$ === "Var") - .map((f) => cx.diag({ message: "type: " + cx.show(f, f.ty), severity: "hint", spn: f.spn, fact: f })); - }, -}]; -``` - -### What `cx` gives - -| Field | What it is | -|---|---| -| `root`, `sources` | the linted file and its imports, as they are on disk | -| `facts` | the checker's results (only with `needsTypes`) | -| `prior` | the findings of the rules that ran before this one | -| `span(s)` | a span from bend (for example `term.s`), moved to the file on disk | -| `walk(tm)` | every sub-term of a checked term | -| `binder(fact, v)` | the binder a variable refers to, with its type | -| `show(fact, t)` | a type as text | -| `same(fact, a, b)` | whether the checker finds two types equal | -| `normal(fact, t)` | the normal form of a type | -| `uses(fact)` | the variables a term uses, and how many times | -| `diag({...})` | makes a finding; the default severity is `warning` | -| `Bend` | the whole bend module, for anything else | - -Severities: `error` (stops the run; exit 1), `warning`, `information`, -`hint`. - -Fix levels: `safe` keeps behavior (`--fix` applies these), `suggested` may -change behavior, `dangerous` may break code. - -### Things to know - -- Get bend from `cx.Bend`. Do not import `bend2/bend.ts` in a rule. -- Base's own defs give no facts. You see only your code and its imports. -- A template body is checked once as written and once per instance - (`generic~0`), at the same spans. Use `fact.inst` to skip the instances. -- Rules run in the order you give them. When the file does not check, no - rule runs. - -## 3. Write a rule in Bend - -A Bend rule imports `tools/bend-lint/src/lint.bend` by a relative path and -defines four things: - -```python -import Base -import ../tools/bend-lint/src/lint.bend as Lint - -def id() -> String: - "demo/nothing" - -def types() -> Bool: - False{} - -def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): - IO.pure(List<&2, Lint.Diag>, []) - -def main() -> IO(Unit): - Lint.serve(run) -``` - -- `types()` is `True{}` if the rule needs the checker's results. -- `input` holds the sources (path, text, root) and, with `types()`, the - facts. -- Ask about a fact with these effects: `Lint.view`, `Lint.type_of`, - `Lint.binder`, `Lint.same`, `Lint.show`, `Lint.normal`, `Lint.uses`, - `Lint.text`. `src/lint.bend` lists them, and all types. -- Offsets in Bend count characters. -- A Bend rule can also use Base's effects (files, printing). -- bend-lint compiles a Bend rule once. Each run after that takes about - 10 ms. - -For two complete rules, see `COMMA_BEND` (text) and `TYPES_BEND` (types) -in `test/lint.test.ts`. - -## 4. Test your rule - -Use the library in a `bun test`: - -```ts -import { applyFixes, bendRule, lint, render } from "./tools/bend-lint/src/lint.ts"; -import { rules } from "./my_rules.ts"; - -const res = await lint("example.bend", rules); // or [await bendRule("my_rule.bend")] -console.log(res.ok, res.diags.map(render)); -const root = res.sources.find((s) => s.root)!; -const fixed = applyFixes(root.file, res.diags); // the text with the safe fixes -``` - -## 5. When bend changes - -bend-lint is pinned to the `bend2/bend.ts` and `bend2/comp.ts` it was tested -with (`bend.pin`). When they change, bend-lint stops with a `DriftError`. -It does not give wrong results. To accept the new version: - -```sh -BEND_LINT_UNPINNED=1 bun test tools/bend-lint -bun tools/bend-lint/src/lint.ts --pin -``` - -If a test fails, the error names the part of bend-lint to update. diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index b0feede875..98951d5e1d 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -1,114 +1,120 @@ # bend-lint -`bend-lint` checks a Bend 2 file with bend's own checker, then runs your -rules on it. A rule reads the source text, the checker's types, or both, -and returns diagnostics with fixes. Bend itself does not change. - -New here? Start with [GUIDE.md](GUIDE.md). - -- `src/lint.ts`: the library and the CLI. -- `src/patch.ts`: what bend-lint changes in `bend2/bend.ts` and - `bend2/comp.ts` as Bun loads them, and the pin. -- `src/lint.bend`, `src/lint.js`: the contract for rules written in Bend, - and its effects. +bend-lint checks a Bend 2 file with bend's own checker, then runs your rules +on it. A rule reads the source text, the checker's result for each term +(type, scope, uses), or both, and returns findings with fixes. Bend itself +does not change. It needs Bun 1.2 or newer. ## Run -Bun 1.2 or newer is required. From the repo root: +From the repo root: ```sh -bun tools/bend-lint/src/lint.ts file.bend --rules my_rules.ts [--rules more.js] [--fix] +bun tools/bend-lint/src/lint.ts file.bend [--rules rules.ts]... [--rules rule.bend]... [--fix] ``` -Exit codes: 0 no error, 1 an error was found (a failed check, or a rule -finding with severity `error`), 2 bad usage or a tool failure. `--fix` -writes the `safe` fixes to the files. +Findings print in bend's error layout, with a severity, a code, and each fix +as a diff. `--fix` writes the `safe` fixes. Exit codes: 0 no error, 1 an +error (the file does not check, or a rule found an `error`), 2 bad usage or +a tool failure. -## Rules +## Rules in TypeScript -A rule module exports `rules`, an array of rules: +A rule module exports `rules`. A rule has an `id` (`namespace/name`, also +the code of its findings), an optional `needsTypes`, and a `run` that may be +`async`: ```ts import type { LintRule } from "./tools/bend-lint/src/lint.ts"; export const rules: LintRule[] = [{ - id: "style/comma-space", // namespace/name; the code of its findings - needsTypes: false, // true gives the rule cx.facts - run(cx, signal) { - return [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { - const spn = { file: cx.root.file, beg: m.index! + 1, end: m.index! + 1 }; - return cx.diag({ - message: "Add a space after the comma.", severity: "warning", spn, - fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }], - }); + id: "style/comma-space", + run: (cx) => [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { + const spn = { file: cx.root.file, beg: m.index! + 1, end: m.index! + 1 }; + return cx.diag({ + message: "Add a space after the comma.", severity: "warning", spn, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }], }); - }, + }), +}, { + id: "demo/var-types", + needsTypes: true, + run: (cx) => [...cx.facts!.values()] + .filter((f) => !f.inst && f.spn?.file === cx.root.file && cx.Bend.term_strip(f.tm).$ === "Var") + .map((f) => cx.diag({ message: "type: " + cx.show(f, f.ty), severity: "hint", spn: f.spn, fact: f })), }]; ``` -A file with its own `import Base` starts from a copy of Base, checked once -per process (again only if base.bend changes), and facts of Base's own defs -are not recorded. +| `cx` | | +|---|---| +| `root`, `sources` | the linted file and its imports, as on disk | +| `facts` | with `needsTypes`: per checked term, its type, scope, depth, def, quantity, uses, and span on disk | +| `prior` | the findings of earlier rules | +| `span(s)` | a bend span (e.g. `term.s`), moved to the file on disk | +| `walk`, `binder`, `show`, `same`, `normal`, `uses` | helpers over bend's terms | +| `diag({...})` | a finding; the default severity is `warning` | +| `Bend` | the bend module; use it, do not import `bend2/bend.ts` | -Rules run in order, one at a time; `cx.prior` holds what earlier rules -found. A finding with severity `error` stops the run. +Severities: `error` (stops the run), `warning`, `information`, `hint`. Fix +levels: `safe` keeps behavior, `suggested` may change it, `dangerous` may +break code. -`cx` gives: +Rules run in order; none runs when the file does not check. Base's own defs +give no facts. A template body is checked as written and again per instance +(`generic~0`) at the same spans; `fact.inst` marks the instances. A file with +`import Base` reuses a Base checked once per process. -- `sources`, `root`: the files of the book as they are on disk. -- `facts` (with `needsTypes`): for each checked term, its type, context, - depth, def, quantity, uses, and span in the file on disk. A template body - is checked as written and again per instance (`generic~0`), at the same - spans; `fact.inst` marks the facts of an instance. -- `span(s)`: a span from bend.ts (for example `term.s`), in the file on - disk. -- `walk`, `binder`, `show`, `same`, `normal`, `uses`, `diag`: helpers over - bend's terms. -- `Bend`: the bend.ts module, for anything else. +## Rules in Bend -Get bend.ts from `cx.Bend`. Do not import `bend2/bend.ts` in a rule. +A `.bend` rule imports `src/lint.bend`, which lists the contract: -## Rules written in Bend +```python +import Base +import ../tools/bend-lint/src/lint.bend as Lint -A `.bend` rule imports `src/lint.bend` (by a relative path) and defines -`id()`, `types()`, `run(input)` and a `main` that hands `run` to -`Lint.serve`; `src/lint.bend` lists the contract. `run` gets the sources -and, with `types()` true, the facts (as indexes, `Lint.Fact`); it asks about them -through effects (`Lint.view`, `Lint.type_of`, `Lint.binder`, `Lint.same`, -`Lint.show`, `Lint.normal`, `Lint.uses`, `Lint.text`) and answers a list of -`Lint.Diag`. Offsets count characters. +def id() -> String: + "demo/nothing" -```sh -bun tools/bend-lint/src/lint.ts file.bend --rules my_rule.bend -``` +def types() -> Bool: # True{} to get facts + False{} -bend-lint checks and compiles the rule once, as `comp.ts` `io_run` does; -each run calls its `main` with bend's own IO runtime, so a rule may also -use Base's effects. Effects run synchronously. +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + IO.pure(List<&2, Lint.Diag>, []) -## The patch +def main() -> IO(Unit): + Lint.serve(run) +``` -bend.ts does not expose the checker's results, and builds file paths with -`/`; comp.ts compiles and runs a program in one call. As Bun loads them, -`patch.ts` changes three things; the files on disk do not change: +`input` holds the sources and, with `types()`, the facts. A rule asks about +a fact with effects (`Lint.view`, `type_of`, `binder`, `same`, `show`, +`normal`, `uses`, `text`) and may use Base's effects too. Offsets count +characters. bend-lint compiles a rule once; each run then takes about 10 ms. +`COMMA_BEND` and `TYPES_BEND` in `test/lint.test.ts` are complete examples. -1. `term_infer` and `term_check` get wrappers that record what they return. - Checking gives the same results. -2. bend.ts gets `fs` and `path` from `patch.ts`, where real paths use `/` - and a drive letter is a root, so imports resolve on Windows too. On - POSIX these behave as node's. -3. comp.ts exports `RUNTIME_MAIN` and `js_sat`, so a rule written in Bend - is compiled once and run many times. +## From code -Each change looks for one exact anchor. If an anchor is not -found exactly once, or a self-check on a tiny program records no type, -bend-lint stops with a `DriftError`. +```ts +import { applyFixes, bendRule, lint, render } from "./tools/bend-lint/src/lint.ts"; -## The pin +const res = await lint("file.bend", [...rules, await bendRule("rule.bend")]); +console.log(res.ok, res.diags.map(render)); +const fixed = applyFixes(res.sources.find((s) => s.root)!.file, res.diags); +``` -`bend.pin` holds the git blob hashes of the `bend2/bend.ts` and -`bend2/comp.ts` this tool was tested with (`git rev-parse HEAD:`). -If either differs, bend-lint stops. To bump: +## How it works + +- `src/lint.ts`: the library and the CLI. +- `src/patch.ts`: what bend-lint changes in bend2 as Bun loads it; the files + on disk never change. bend.ts gets recording wrappers around `term_infer` + and `term_check`, and an `fs` and `path` whose real paths use `/` (so + imports resolve on Windows; on POSIX they act as node's). comp.ts exports + `RUNTIME_MAIN` and `js_sat`, so a Bend rule is compiled once. +- `src/lint.bend`, `src/lint.js`: the contract for Bend rules, and its + effects. + +Each change needs one exact anchor, and a self-check runs at load. +`bend.pin` holds the git blob hashes of bend.ts and comp.ts. Any mismatch +stops bend-lint with a `DriftError`, never a wrong result. To bump: ```sh BEND_LINT_UNPINNED=1 bun test tools/bend-lint @@ -122,6 +128,6 @@ bun test tools/bend-lint bunx tsc -p tools/bend-lint/tsconfig.json --noEmit ``` -Bun does not check types, so run `tsc` too. It also checks the parts of -bend2 that bend-lint imports, and today reports two errors there -(bend.ts lines 2040 and 3771); bend-lint itself has none. +Bun does not check types, so run `tsc` too. It reports two errors in +bend2/bend.ts (lines 2040 and 3771), which are on main too; bend-lint has +none. From d43b72395bd32c1b5ccd291d928add116af26ecb Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 13:18:29 -0300 Subject: [PATCH 14/39] bend-lint: --json, and --bend / BEND_DIR to choose the bend to load --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 (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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 22 ++++++-- tools/bend-lint/src/lint.ts | 84 ++++++++++++++++++++++--------- tools/bend-lint/src/patch.ts | 18 ++++--- tools/bend-lint/test/lint.test.ts | 43 ++++++++++++++-- 4 files changed, 130 insertions(+), 37 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 98951d5e1d..ae73127be5 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -10,7 +10,7 @@ does not change. It needs Bun 1.2 or newer. From the repo root: ```sh -bun tools/bend-lint/src/lint.ts file.bend [--rules rules.ts]... [--rules rule.bend]... [--fix] +bun tools/bend-lint/src/lint.ts file.bend [--rules rules.ts]... [--rules rule.bend]... [--fix] [--json] [--bend ] ``` Findings print in bend's error layout, with a severity, a code, and each fix @@ -18,6 +18,18 @@ as a diff. `--fix` writes the `safe` fixes. Exit codes: 0 no error, 1 an error (the file does not check, or a rule found an `error`), 2 bad usage or a tool failure. +`--json` prints only this, for other tools (ranges as in LSP: 0-based, +UTF-16): + +```json +{ "ok": true, "findings": [{ "code": "style/comma-space", "severity": "warning", "message": "...", + "def": "main", "path": "/abs/file.bend", "range": { "start": { "line": 25, "character": 13 }, "end": {...} }, + "fixes": [{ "title": "...", "applicability": "safe", "edits": [{ "path": "...", "range": {...}, "text": " " }] }] }] } +``` + +`--bend ` (or `$BEND_DIR`) picks the bend to load: a bend checkout, or +its `bend2` folder. The default is this repo's. + ## Rules in TypeScript A rule module exports `rules`. A rule has an `id` (`namespace/name`, also @@ -101,6 +113,9 @@ console.log(res.ok, res.diags.map(render)); const fixed = applyFixes(res.sources.find((s) => s.root)!.file, res.diags); ``` +A library picks its bend with `$BEND_DIR`, set before it imports bend-lint. +`position(span)` gives the LSP range of a span. + ## How it works - `src/lint.ts`: the library and the CLI. @@ -113,12 +128,13 @@ const fixed = applyFixes(res.sources.find((s) => s.root)!.file, res.diags); effects. Each change needs one exact anchor, and a self-check runs at load. -`bend.pin` holds the git blob hashes of bend.ts and comp.ts. Any mismatch +`bend.pin` holds the git blob hashes of bend.ts and comp.ts. With +`--bend`, the bend you give must match it too. Any mismatch stops bend-lint with a `DriftError`, never a wrong result. To bump: ```sh BEND_LINT_UNPINNED=1 bun test tools/bend-lint -bun tools/bend-lint/src/lint.ts --pin +bun tools/bend-lint/src/lint.ts --pin # [--bend ] ``` ## Test diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 5c2de4a79e..bf5d6da0f8 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -11,7 +11,7 @@ import * as util from "node:util"; import type * as BendModule from "../../../bend2/bend.ts"; import type * as CompModule from "../../../bend2/comp.ts"; import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "../../../bend2/bend.ts"; -import { BEND2, DriftError, MARK, PIN_FILE, blob, current, fs, patch, path, pinned } from "./patch.ts"; +import { DriftError, MARK, PIN_FILE, bendDir, blob, current, fs, patch, path, pinned } from "./patch.ts"; // Types // ===== @@ -80,6 +80,9 @@ export type LintRule = { export type LintResult = { ok: boolean; diags: Diag[]; sources: Source[]; book: Book; facts?: Map }; +// An LSP position: 0-based line and character, in UTF-16 units. +export type Position = { line: number; character: number }; + // A span as lint.bend has it: a file's path and two code-point offsets. type Spot = { path: string; beg: number; end: number }; @@ -157,13 +160,15 @@ const CHILDREN: { [K in LTerm["$"]]: (tm: Extract) => LTerm[] } const OPTIONS = { rules: { type: "string", multiple: true }, fix: { type: "boolean" }, + json: { type: "boolean" }, + bend: { type: "string" }, pin: { type: "boolean" }, help: { type: "boolean", short: "h" }, } as const; const USAGE = [ - "usage: bun tools/bend-lint/src/lint.ts [--rules ]... [--fix]", - " bun tools/bend-lint/src/lint.ts --pin (pin the current bend2/bend.ts and comp.ts)", + "usage: bun tools/bend-lint/src/lint.ts [--rules ]... [--fix] [--json] [--bend ]", + " bun tools/bend-lint/src/lint.ts --pin [--bend ] (pin that bend's bend.ts and comp.ts)", ].join("\n"); // Functions @@ -184,6 +189,16 @@ function starts(text: string): number[] { return [0, ...[...text.matchAll(/\n/g)].map((m) => m.index! + 1)]; } +// The index of the line that holds `off`, given each line's start. +function line(starts: number[], off: number): number { + let [lo, hi] = [0, starts.length - 1]; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + [lo, hi] = starts[mid] <= off ? [mid, hi] : [lo, mid - 1]; + } + return lo; +} + // bend.ts parses a copy of each file with its import lines blanked, so a // span moves to the file on disk by line and column. A copy that does not // match its file is a DriftError. @@ -214,12 +229,8 @@ export function mapper(sources: Source[]): Mapper { } const { file, from, to } = memo.get(f)!; const at = (off: number): number => { - let [lo, hi] = [0, from.length - 1]; - while (lo < hi) { - const mid = (lo + hi + 1) >> 1; - [lo, hi] = from[mid] <= off ? [mid, hi] : [lo, mid - 1]; - } - return to[lo] + off - from[lo]; + const i = line(from, off); + return to[i] + off - from[i]; }; return { file, beg: at(s.beg), end: at(s.end) }; } @@ -285,8 +296,8 @@ async function check(file: string, capture: boolean, signal: AbortSignal): Promi // Rules run in order. A finding with severity error stops the run. A rule // that throws, and an abort, reach the caller. export async function lint(file: string, rules: LintRule[], signal = new AbortController().signal): Promise { - if (current() !== pinned() && process.env.BEND_LINT_UNPINNED !== "1") { - throw new DriftError("bend2 is\n" + current() + "\nbut bend-lint is pinned to\n" + pinned() + "\n" + if (current(BEND2) !== pinned() && process.env.BEND_LINT_UNPINNED !== "1") { + throw new DriftError(BEND2 + " is\n" + current(BEND2) + "\nbut bend-lint is pinned to\n" + pinned() + "\n" + "To bump: BEND_LINT_UNPINNED=1 bun test tools/bend-lint, then bun tools/bend-lint/src/lint.ts --pin"); } const bad = rules.find((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function"); @@ -355,6 +366,16 @@ function common(a: string[], b: string[]): number { return Math.min(n < 0 ? a.length : n, b.length); } +// The LSP range of a span in a file on disk. +export function position(spn: Span): { start: Position; end: Position } { + const ss = starts(spn.file.str); + const at = (off: number): Position => { + const i = line(ss, off); + return { line: i, character: off - ss[i] }; + }; + return { start: at(spn.beg), end: at(spn.end) }; +} + // bend's own error layout; the head names the severity and the code, and // each fix follows as a unified diff. export function render(d: Diag): string { @@ -462,19 +483,22 @@ export async function bendRule(file: string): Promise { }; } -// Patches bend.ts and comp.ts as Bun loads them, then checks a tiny -// program: the type of `x` in `id` must be recorded as N. -async function instrument(): Promise<{ Bend: Bend; Comp: Comp }> { - const patched = new Map(["bend.ts", "comp.ts"].map((f) => [f, patch(f, fs.readFileSync(path.join(BEND2, f), "utf8"))])); +// Finds bend2 (see bendDir), patches bend.ts and comp.ts as Bun loads +// them, then checks a tiny program: the type of `x` in `id` must be +// recorded as N. +async function instrument(given: string | undefined): Promise<{ Bend: Bend; Comp: Comp; BEND2: string }> { + const dir = bendDir(given); + const patched = new Map(["bend.ts", "comp.ts"].map((f) => [f, patch(f, fs.readFileSync(path.join(dir, f), "utf8"))])); + const filter = new RegExp("[\\\\/]" + path.basename(dir).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[\\\\/](bend|comp)\\.ts$"); Bun.plugin({ name: "bend-lint", setup: (build) => { - build.onLoad({ filter: /[\\/]bend2[\\/](bend|comp)\.ts$/ }, (args) => ({ contents: patched.get(path.basename(args.path))!, loader: "ts" })); + build.onLoad({ filter }, (args) => ({ contents: patched.get(path.basename(args.path))!, loader: "ts" })); }, }); const [B, C]: [Bend & { [MARK]?: number }, Comp & { [MARK]?: number }] = await Promise.all([ - import(url.pathToFileURL(path.join(BEND2, "bend.ts")).href), - import(url.pathToFileURL(path.join(BEND2, "comp.ts")).href), + import(url.pathToFileURL(path.join(dir, "bend.ts")).href), + import(url.pathToFileURL(path.join(dir, "comp.ts")).href), ]); if (B[MARK] !== 1 || C[MARK] !== 1) { throw new DriftError("bend2 was loaded before bend-lint could patch it; import bend-lint first"); @@ -489,7 +513,7 @@ async function instrument(): Promise<{ Bend: Bend; Comp: Comp }> { if (!typed) { throw new DriftError("self-check failed: the patched bend.ts recorded no type for `id`; update tools/bend-lint/src/patch.ts"); } - return { Bend: B, Comp: C }; + return { Bend: B, Comp: C, BEND2: dir }; } async function cli(argv: string[]): Promise { @@ -499,7 +523,7 @@ async function cli(argv: string[]): Promise { return 0; } if (values.pin) { - fs.writeFileSync(PIN_FILE, current() + "\n"); + fs.writeFileSync(PIN_FILE, current(BEND2) + "\n"); console.log("bend-lint: pinned\n" + pinned()); return 0; } @@ -520,14 +544,20 @@ async function cli(argv: string[]): Promise { const fixed = values.fix && res.ok ? res.sources.filter((s) => !s.base).map((s) => ({ s, text: applyFixes(s.file, res.diags) })).filter(({ s, text }) => text !== s.text) : []; - for (const d of res.diags) { - console.log(render(d) + "\n"); - } + const where = (spn: Span) => ({ path: (spn.file as SourceFile).path, range: position(spn) }); + console.log(values.json + ? JSON.stringify({ + ok: res.ok, + findings: res.diags.map((d) => ({ + code: d.code, severity: d.severity, message: d.message, def: d.def, ...(d.spn && where(d.spn)), + fixes: d.fixes.map((f) => ({ ...f, edits: f.edits.map((e) => ({ ...where(e.spn), text: e.text })) })), + })), + }, null, 2) + : [...res.diags.map(render), res.ok ? "bend-lint: " + res.diags.length + " finding(s)" : "bend-lint: FAIL"].join("\n\n")); for (const { s, text } of fixed) { fs.writeFileSync(s.path, text); console.error("bend-lint: fixed " + s.path); } - console.log(res.ok ? "bend-lint: " + res.diags.length + " finding(s)" : "bend-lint: FAIL"); return res.ok ? 0 : 1; } @@ -539,7 +569,11 @@ function fail(e: unknown): never { // Side effects // ============ -export const { Bend, Comp }: { Bend: Bend; Comp: Comp } = await instrument().catch((e: unknown) => import.meta.main ? fail(e) : Promise.reject(e)); +// The CLI's --bend is read here, before bend loads; a library uses $BEND_DIR. +const given = import.meta.main ? util.parseArgs({ args: process.argv.slice(2), options: OPTIONS, allowPositionals: true, strict: false }).values.bend : undefined; + +export const { Bend, Comp, BEND2 }: { Bend: Bend; Comp: Comp; BEND2: string } = + await instrument(typeof given === "string" ? given : undefined).catch((e: unknown) => import.meta.main ? fail(e) : Promise.reject(e)); if (import.meta.main) { process.exit(await cli(process.argv.slice(2)).catch(fail)); diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index 3c9a324441..b89e453622 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -125,11 +125,17 @@ export function pinned(): string { return nodeFs.readFileSync(PIN_FILE, "utf8").trim(); } -export function current(): string { - return Object.keys(PATCHES).map((f) => f + " " + blob(nodeFs.readFileSync(path.join(BEND2, f), "utf8"))).join("\n"); +export function current(bend2: string): string { + return Object.keys(PATCHES).map((f) => f + " " + blob(nodeFs.readFileSync(path.join(bend2, f), "utf8"))).join("\n"); } -// Side effects -// ============ - -export const BEND2 = fs.realpathSync(path.join(HERE, "..", "..", "..", "bend2")); +// The bend2 folder to load: `given` (from --bend), else $BEND_DIR, else the +// one in this repo. A bend checkout works too, for its bend2 folder. +export function bendDir(given: string | undefined): string { + const dir = path.resolve(given ?? process.env.BEND_DIR ?? path.join(HERE, "..", "..", "..", "bend2")); + const found = [path.join(dir, "bend2"), dir].find((d) => nodeFs.existsSync(path.join(d, "bend.ts"))); + if (found === undefined) { + throw new Error("no bend2 at " + dir + " (it needs bend.ts); give a bend checkout with --bend or BEND_DIR"); + } + return fs.realpathSync(found); +} diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 84cd48ddd2..a9b228915b 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -5,9 +5,9 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { Book, LTerm, Span } from "../../../bend2/bend.ts"; -import { Bend, Comp, applyFixes, bendRule, lint, mapper, render, walk } from "../src/lint.ts"; +import { BEND2, Bend, Comp, applyFixes, bendRule, lint, mapper, render, walk } from "../src/lint.ts"; import type { Diag, Edit, Fact, LintRule, RuleContext, Source, SourceFile } from "../src/lint.ts"; -import { BEND2, DriftError, blob, current, patch, pinned, relative, resolve } from "../src/patch.ts"; +import { DriftError, bendDir, blob, current, patch, pinned, relative, resolve } from "../src/patch.ts"; // Types // ===== @@ -463,7 +463,13 @@ describe("patch", () => { expect(blob("a\r\nb\n")).toBe(blob("a\nb\n")); expect(blob("hello\n")).toBe("ce013625030ba8dba906f756967f9e9ca394464a"); expect(pinned()).toMatch(/^bend\.ts [0-9a-f]{40}\ncomp\.ts [0-9a-f]{40}$/); - if (process.env.BEND_LINT_UNPINNED !== "1") expect(current()).toBe(pinned()); + if (process.env.BEND_LINT_UNPINNED !== "1") expect(current(BEND2)).toBe(pinned()); + }); + + test("bend2 is found from a checkout, its bend2 folder, or the repo; a wrong dir fails", () => { + const repo = path.dirname(BEND2); + expect([bendDir(repo), bendDir(BEND2), bendDir(undefined)]).toEqual([BEND2, BEND2, BEND2]); + expect(() => bendDir(DIR)).toThrow(/no bend2 at/); }); test("paths: a drive letter is a root; POSIX paths are unchanged", () => { @@ -795,6 +801,37 @@ describe("cli", () => { expect(out.stdout).toContain("Warning [style/comma-space]:"); }); + test("--json prints findings with LSP ranges, and nothing else", () => { + const comma = module("json_comma.js", `[{ id: "style/comma-space", run: (cx) => + [...cx.root.text.matchAll(/,(?=\\w)/g)].map((m) => { + const spn = { file: cx.root.file, beg: m.index + 1, end: m.index + 1 }; + return cx.diag({ message: "space", severity: "hint", spn, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }] }); + }) }]`); + const out = run(input, "--rules", comma, "--json"); + expect(out.status).toBe(0); + const json = JSON.parse(out.stdout); + const at = { line: 25, character: 13 }; + expect(json).toEqual({ + ok: true, + findings: [{ + code: "style/comma-space", severity: "hint", message: "space", path: fs.realpathSync(input).replaceAll("\\", "/"), + range: { start: at, end: at }, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ path: fs.realpathSync(input).replaceAll("\\", "/"), range: { start: at, end: at }, text: " " }] }], + }], + }); + expect(USERLAND.split("\n")[at.line].slice(0, at.character)).toBe(" generic(~N,"); + const bad = JSON.parse(run(fixture("json_bad.bend", "def broken(\n"), "--json").stdout); + expect([bad.ok, bad.findings[0].code]).toEqual([false, "bend/check"]); + }); + + test("--bend and BEND_DIR choose the bend to load; a wrong one exits 2", () => { + expect(run(input, "--bend", path.dirname(BEND2)).status).toBe(0); + const env = spawnSync(process.execPath, [CLI, input], { encoding: "utf8", env: { ...process.env, BEND_DIR: BEND2 } }); + expect(env.status).toBe(0); + expect(run(input, "--bend", DIR)).toMatchObject({ status: 2, stderr: expect.stringContaining("no bend2 at") }); + }); + test("--fix applies safe fixes", () => { const target = fixture("fix.bend", USERLAND); const comma = module("comma.js", `[{ id: "style/comma-space", run: (cx) => From 3e1392a2550191123efdaac788f42439d344f1a4 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 13:27:17 -0300 Subject: [PATCH 15/39] bend-lint: a smaller patch, with typed wrappers 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 8 +- tools/bend-lint/src/lint.ts | 2 +- tools/bend-lint/src/patch.ts | 126 ++++++++++++++++-------------- tools/bend-lint/test/lint.test.ts | 24 +++--- 4 files changed, 86 insertions(+), 74 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index ae73127be5..b24d9fcf39 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -120,14 +120,16 @@ A library picks its bend with `$BEND_DIR`, set before it imports bend-lint. - `src/lint.ts`: the library and the CLI. - `src/patch.ts`: what bend-lint changes in bend2 as Bun loads it; the files - on disk never change. bend.ts gets recording wrappers around `term_infer` - and `term_check`, and an `fs` and `path` whose real paths use `/` (so + on disk never change. bend.ts's `term_infer` and `term_check` are renamed + and wrapped by typed functions in `patch.ts` that record what they + return, and bend.ts gets an `fs` and `path` whose real paths use `/` (so imports resolve on Windows; on POSIX they act as node's). comp.ts exports `RUNTIME_MAIN` and `js_sat`, so a Bend rule is compiled once. - `src/lint.bend`, `src/lint.js`: the contract for Bend rules, and its effects. -Each change needs one exact anchor, and a self-check runs at load. +Each text edit must match exactly once, `tsc` checks the wrappers against +bend's own signatures, and a self-check runs at load. `bend.pin` holds the git blob hashes of bend.ts and comp.ts. With `--bend`, the bend you give must match it too. Any mismatch stops bend-lint with a `DriftError`, never a wrong result. To bump: diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index bf5d6da0f8..563df87cbf 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -12,6 +12,7 @@ import type * as BendModule from "../../../bend2/bend.ts"; import type * as CompModule from "../../../bend2/comp.ts"; import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "../../../bend2/bend.ts"; import { DriftError, MARK, PIN_FILE, bendDir, blob, current, fs, patch, path, pinned } from "./patch.ts"; +import type { Hooked } from "./patch.ts"; // Types // ===== @@ -112,7 +113,6 @@ type Channel = { // comp.ts as patch.ts exports it. type Comp = typeof CompModule & { RUNTIME_MAIN: string; js_sat(k: Name): string }; -type Hooked = Book & { see?: (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn: Span | undefined, qt: Quant, us: Uses) => void }; type Mapper = { (s: Span): Span; (s: Span | undefined): Span | undefined }; type Checked = { book: Book; sources: Source[]; span: Mapper; facts?: Map; failure?: Diag }; diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index b89e453622..9ec5261bcb 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -1,21 +1,34 @@ // What bend-lint changes in bend2 as Bun loads it; the files on disk never -// change. In bend.ts, term_infer and term_check get wrappers so a -// `book.see` hook records what they return, and fs and path come from this -// module: bend.ts builds paths with "/" (path.posix), so here real paths use -// "/" and a Windows drive letter counts as a root; on POSIX they behave as -// node's. In comp.ts, RUNTIME_MAIN and js_sat are exported, so a rule -// written in Bend is compiled once and run many times. bend.pin holds the -// hashes of the files these were tested on. +// change. In bend.ts, term_infer and term_check are renamed and replaced by +// the wrappers below, which tell a `book.see` hook what they return; and fs +// and path come from this module: bend.ts builds paths with "/" +// (path.posix), so here real paths use "/" and a Windows drive letter is a +// root; on POSIX they behave as node's. comp.ts exports RUNTIME_MAIN and +// js_sat, so a rule written in Bend is compiled once and run many times. +// Each text edit must match exactly once, and each name a tail exports must +// be declared once. bend.pin holds the hashes of the files these were +// tested on. import * as crypto from "node:crypto"; import * as nodeFs from "node:fs"; import * as nodePath from "node:path"; import * as url from "node:url"; +import type { Book, Ctx, HTerm, LTerm, Name, Quant, Span, Uses } from "../../../bend2/bend.ts"; +import type * as BendModule from "../../../bend2/bend.ts"; + // Types // ===== -type Patch = { name: string; at: RegExp; to: string }; +// A file's patch: exact edits, then a tail appended to the file, which may +// export only names the file declares (`needs`). +type Patch = { edits: Array<[string, string]>; tail: string; needs: string[] }; + +// A book whose checker reports to `see`: for each checked term, what it was +// checked or inferred as, where, and how it was used. +export type Hooked = Book & { + see?: (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn: Span | undefined, qt: Quant, us: Uses) => void; +}; // Constants // ========= @@ -31,47 +44,20 @@ const SHIM = JSON.stringify(url.pathToFileURL(nodePath.join(HERE, "patch.ts")).h export const MARK = "BEND_LINT_PATCH"; export const PIN_FILE = nodePath.join(HERE, "..", "bend.pin"); -const PATCHES: Record = { - "bend.ts": [{ - name: "the node:fs import", - at: /^import \* as fs from "node:fs";/m, - to: "import { fs } from " + SHIM + ";", - }, { - name: "the node:path import", - at: /^import \* as path from "node:path";/m, - to: "import { path } from " + SHIM + ";", - }, { - name: "term_infer", - at: /^export function term_infer\(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm\[\] = \[\]\): Infer \{/m, - to: [ - "export function term_infer(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm[] = []): Infer {", - " const inf = lint_infer(book, lhs, tm, qt, ctx, d, sp);", - " (book as any).see?.(book, inf.tm, inf.ty, ctx, d, lhs.def, tm.s, qt, inf.us);", - " return inf;", - "}", - "function lint_infer(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ctx: Ctx, d: number, sp: HTerm[]): Infer {", - ].join("\n"), - }, { - name: "term_check", - at: /^export function term_check\(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number\): Check \{/m, - to: [ - "export function term_check(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number): Check {", - " const chk = lint_check(book, lhs, tm, qt, ty, ctx, d);", - " (book as any).see?.(book, chk.tm, ty, ctx, d, lhs.def, tm.s, qt, chk.us);", - " return chk;", - "}", - "function lint_check(book: Book, lhs: LHS, tm: HTerm, qt: Quant, ty: HTerm, ctx: Ctx, d: number): Check {", - ].join("\n"), - }], - "comp.ts": [{ - name: "RUNTIME_MAIN", - at: /^const RUNTIME_MAIN: string = /m, - to: "export const RUNTIME_MAIN: string = ", - }, { - name: "js_sat", - at: /^function js_sat\(k: Name\): string \{/m, - to: "export function js_sat(k: Name): string {", - }], +const PATCHES: Record = { + "bend.ts": { + edits: [ + ['import * as fs from "node:fs";', "import { fs } from " + SHIM + ";"], + ['import * as path from "node:path";', "import { path } from " + SHIM + ";"], + ["export function term_infer(", "function unseen_term_infer("], + ["export function term_check(", "function unseen_term_check("], + ], + tail: "import { seeInfer, seeCheck } from " + SHIM + ";\n" + + "export const term_infer = seeInfer(unseen_term_infer);\n" + + "export const term_check = seeCheck(unseen_term_check);\n", + needs: [], + }, + "comp.ts": { edits: [], tail: "export { RUNTIME_MAIN, js_sat };\n", needs: ["RUNTIME_MAIN", "js_sat"] }, }; // fs and path for bend.ts. @@ -102,16 +88,40 @@ export function relative(from: string, to: string): string { return nodePath.posix.relative(slash(from).replace(DRIVE, ""), slash(to).replace(DRIVE, "")); } -// `file` is "bend.ts" or "comp.ts"; each anchor must be found exactly once. +// term_infer and term_check as bend.ts calls them, each also reporting to +// book.see. tsc checks them against bend.ts's own signatures. +export function seeInfer(f: typeof BendModule.term_infer): typeof BendModule.term_infer { + return (book, lhs, tm, qt, ctx, d, sp) => { + const r = f(book, lhs, tm, qt, ctx, d, sp); + (book as Hooked).see?.(book, r.tm, r.ty, ctx, d, lhs.def, tm.s, qt, r.us); + return r; + }; +} + +export function seeCheck(f: typeof BendModule.term_check): typeof BendModule.term_check { + return (book, lhs, tm, qt, ty, ctx, d) => { + const r = f(book, lhs, tm, qt, ty, ctx, d); + (book as Hooked).see?.(book, r.tm, ty, ctx, d, lhs.def, tm.s, qt, r.us); + return r; + }; +} + +// `file` is "bend.ts" or "comp.ts". export function patch(file: string, src: string): string { - return PATCHES[file].reduce((out, { name, at, to }) => { - const n = out.match(new RegExp(at.source, "gm"))?.length ?? 0; - if (n !== 1) { - throw new DriftError("cannot patch bend2/" + file + ": found " + n + " of " + name - + ", expected 1. Update PATCHES in tools/bend-lint/src/patch.ts."); - } - return out.replace(at, () => to); - }, src) + "\nexport const " + MARK + " = 1;\n"; + const { edits, tail, needs } = PATCHES[file]; + const drift = (what: string, n: number): never => { + throw new DriftError("cannot patch bend2/" + file + ": found " + n + " of " + what + + ", expected 1. Update PATCHES in tools/bend-lint/src/patch.ts."); + }; + const edited = edits.reduce((out, [at, to]) => { + const n = out.split(at).length - 1; + return n === 1 ? out.replace(at, () => to) : drift(JSON.stringify(at), n); + }, src); + for (const name of needs) { + const n = edited.match(new RegExp("^(?:export )?(?:function|const|let) " + name + "\\b", "gm"))?.length ?? 0; + if (n !== 1) drift("a declaration of " + name, n); + } + return edited + "\n" + tail + "export const " + MARK + " = 1;\n"; } // The git blob hash of a text, as `git rev-parse HEAD:` prints it diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index a9b228915b..88823ce47a 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -432,26 +432,26 @@ describe("patch", () => { const src = fs.readFileSync(path.join(BEND2, "bend.ts"), "utf8"); const comp = fs.readFileSync(path.join(BEND2, "comp.ts"), "utf8"); - test("bend.ts: wraps term_infer and term_check, and swaps fs and path", () => { + test("bend.ts: renames term_infer and term_check behind wrappers, and swaps fs and path", () => { const out = patch("bend.ts", src); - expect(out.match(/^export function term_infer\(/gm)?.length).toBe(1); - expect(out).toContain("function lint_infer("); - expect(out).toContain("function lint_check("); + expect(out).toContain("function unseen_term_infer("); + expect(out).toContain("function unseen_term_check("); + expect(out).toContain("export const term_infer = seeInfer(unseen_term_infer);"); + expect(out).toContain("export const term_check = seeCheck(unseen_term_check);"); + expect(out).not.toMatch(/^export function term_(infer|check)\(/m); expect(out).toMatch(/^import \{ fs \} from "file:.*patch\.ts";/m); expect(out).toMatch(/^import \{ path \} from "file:.*patch\.ts";/m); }); test("comp.ts: exports RUNTIME_MAIN and js_sat", () => { - const out = patch("comp.ts", comp); - expect(out).toMatch(/^export const RUNTIME_MAIN: string = /m); - expect(out).toMatch(/^export function js_sat\(/m); + expect(patch("comp.ts", comp)).toContain("export { RUNTIME_MAIN, js_sat };"); }); - test("fails loudly when an anchor changes or repeats", () => { - expect(() => patch("bend.ts", src.replace("term_infer(book: Book,", "term_infer(bk: Book,"))).toThrow(DriftError); - expect(() => patch("bend.ts", src.replace('import * as fs from "node:fs";', 'import fs from "node:fs";'))).toThrow(/node:fs import/); - expect(() => patch("bend.ts", src + "\n" + src.match(/^export function term_check\(.*$/m)![0] + "\n")).toThrow(/found 2 of term_check/); - expect(() => patch("comp.ts", comp.replace("function js_sat(", "function js_name("))).toThrow(/comp\.ts: found 0 of js_sat/); + test("fails loudly when an edit or a needed name is missing or repeated", () => { + expect(() => patch("bend.ts", src.replace("export function term_infer(", "export function term_infer2("))).toThrow(DriftError); + expect(() => patch("bend.ts", src.replace('import * as fs from "node:fs";', 'import fs from "node:fs";'))).toThrow(/node:fs/); + expect(() => patch("bend.ts", src + "\nexport function term_check(\n")).toThrow(/found 2 of "export function term_check\("/); + expect(() => patch("comp.ts", comp.replace("function js_sat(", "function js_name("))).toThrow(/comp\.ts: found 0 of a declaration of js_sat/); }); test("the loaded bend.ts and comp.ts are the patched ones", () => { From 96a89061a696fd384bf9b22a2fa864bf4e9476a7 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 13:36:01 -0300 Subject: [PATCH 16/39] bend-lint: no pin; check what it depends on instead 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- gates/repo.ts | 2 +- tools/bend-lint/README.md | 18 +++++------- tools/bend-lint/bend.pin | 2 -- tools/bend-lint/src/lint.ts | 49 +++++++++++++++++-------------- tools/bend-lint/src/patch.ts | 47 ++++++++++++++--------------- tools/bend-lint/test/lint.test.ts | 15 ++++++---- 6 files changed, 67 insertions(+), 66 deletions(-) delete mode 100644 tools/bend-lint/bend.pin diff --git a/gates/repo.ts b/gates/repo.ts index 44c8a33097..9b100ad28e 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -84,7 +84,7 @@ allow(/^tests\/[a-z]+\/[a-z0-9_]+\.(c|js)$/); allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\.json|tsconfig\.json)$/); allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); -allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json|bend\.pin)$/); +allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json)$/); allow(/^tools\/bend-lint\/src\/(lint\.ts|patch\.ts|lint\.bend|lint\.js)$/); allow("tools/bend-lint/test/lint.test.ts"); diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index b24d9fcf39..a76362067f 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -128,16 +128,14 @@ A library picks its bend with `$BEND_DIR`, set before it imports bend-lint. - `src/lint.bend`, `src/lint.js`: the contract for Bend rules, and its effects. -Each text edit must match exactly once, `tsc` checks the wrappers against -bend's own signatures, and a self-check runs at load. -`bend.pin` holds the git blob hashes of bend.ts and comp.ts. With -`--bend`, the bend you give must match it too. Any mismatch -stops bend-lint with a `DriftError`, never a wrong result. To bump: - -```sh -BEND_LINT_UNPINNED=1 bun test tools/bend-lint -bun tools/bend-lint/src/lint.ts --pin # [--bend ] -``` +bend-lint is not pinned to a bend version; it checks what it depends on +instead. Each text edit must match exactly once, and comp.ts must declare +what it exports. The wrappers pass every argument through, so bend computes +what it would without them; `tsc` checks them against bend's signatures, +and at load they must take the expected number of arguments. A self-check +then checks every field recorded for `x` in `def id(x: N) -> N: x`. Any +mismatch stops bend-lint with a `DriftError`, never a wrong result. The +tests catch subtler changes. ## Test diff --git a/tools/bend-lint/bend.pin b/tools/bend-lint/bend.pin deleted file mode 100644 index a4db2390e5..0000000000 --- a/tools/bend-lint/bend.pin +++ /dev/null @@ -1,2 +0,0 @@ -bend.ts 0a0814a0544b67f278751a5b2715bed67af37bff -comp.ts 4be7e94bb622177ba0184eb85f5bab21f41c8d11 diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 563df87cbf..eb4b0fb3da 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -11,7 +11,7 @@ import * as util from "node:util"; import type * as BendModule from "../../../bend2/bend.ts"; import type * as CompModule from "../../../bend2/comp.ts"; import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "../../../bend2/bend.ts"; -import { DriftError, MARK, PIN_FILE, bendDir, blob, current, fs, patch, path, pinned } from "./patch.ts"; +import { DriftError, MARK, bendDir, fs, patch, path } from "./patch.ts"; import type { Hooked } from "./patch.ts"; // Types @@ -127,7 +127,7 @@ const HEAD: Record = { error: "Error", warning: "Warning", inf const shared = globalThis as typeof globalThis & { BEND_LINT?: Channel }; -// Checked base.bend books, by the hash of base.bend: Base is checked once +// Checked base.bend books, by the text of base.bend: Base is checked once // per process, and again only if base.bend changes. const BASES = new Map>(); @@ -162,13 +162,11 @@ const OPTIONS = { fix: { type: "boolean" }, json: { type: "boolean" }, bend: { type: "string" }, - pin: { type: "boolean" }, help: { type: "boolean", short: "h" }, } as const; const USAGE = [ "usage: bun tools/bend-lint/src/lint.ts [--rules ]... [--fix] [--json] [--bend ]", - " bun tools/bend-lint/src/lint.ts --pin [--bend ] (pin that bend's bend.ts and comp.ts)", ].join("\n"); // Functions @@ -253,7 +251,7 @@ async function check(file: string, capture: boolean, signal: AbortSignal): Promi ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => void (bok.tlds[def]?.b !== true && found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us })) : undefined; const seeded = fs.existsSync(file) && /^import Base\s*$/m.test(fs.readFileSync(file, "utf8")); - const key = blob(fs.readFileSync(Bend.BASE_BEND, "utf8")); + const key = fs.readFileSync(Bend.BASE_BEND, "utf8"); if (seeded && !BASES.has(key)) { const base = Bend.book_nil(); BASES.set(key, Bend.book_load(base, Bend.BASE_BEND, "", new Map()).then(() => (Bend.book_valid(base), base))); @@ -296,10 +294,6 @@ async function check(file: string, capture: boolean, signal: AbortSignal): Promi // Rules run in order. A finding with severity error stops the run. A rule // that throws, and an abort, reach the caller. export async function lint(file: string, rules: LintRule[], signal = new AbortController().signal): Promise { - if (current(BEND2) !== pinned() && process.env.BEND_LINT_UNPINNED !== "1") { - throw new DriftError(BEND2 + " is\n" + current(BEND2) + "\nbut bend-lint is pinned to\n" + pinned() + "\n" - + "To bump: BEND_LINT_UNPINNED=1 bun test tools/bend-lint, then bun tools/bend-lint/src/lint.ts --pin"); - } const bad = rules.find((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function"); if (bad !== undefined) { throw new TypeError("invalid rule " + JSON.stringify(bad?.id) + ": it needs an id like ns/name and a run function"); @@ -484,8 +478,7 @@ export async function bendRule(file: string): Promise { } // Finds bend2 (see bendDir), patches bend.ts and comp.ts as Bun loads -// them, then checks a tiny program: the type of `x` in `id` must be -// recorded as N. +// them, then checks what the wrappers record for `x` in a tiny program. async function instrument(given: string | undefined): Promise<{ Bend: Bend; Comp: Comp; BEND2: string }> { const dir = bendDir(given); const patched = new Map(["bend.ts", "comp.ts"].map((f) => [f, patch(f, fs.readFileSync(path.join(dir, f), "utf8"))])); @@ -504,14 +497,31 @@ async function instrument(given: string | undefined): Promise<{ Bend: Bend; Comp throw new DriftError("bend2 was loaded before bend-lint could patch it; import bend-lint first"); } const sample: Hooked = B.book_nil(); - const seen: Array<{ bok: Book; tm: LTerm; ty: HTerm; def: Name }> = []; - sample.see = (bok, tm, ty, _ctx, _dep, def) => void seen.push({ bok, tm, ty, def }); + const seen: Fact[] = []; + sample.see = (bok, tm, ty, ctx, dep, def, spn, qt, us) => void seen.push({ tm, ty, bok, ctx, dep, def, spn, qt, us, inst: false }); B.parse_book(sample, "/", SAMPLE, "", {}); B.book_valid(sample); - const typed = seen.some((t) => t.def === "id" && B.term_strip(t.tm).$ === "Var" - && (B.term_wnf(t.bok, t.ty) as { k?: string }).k === "N"); - if (!typed) { - throw new DriftError("self-check failed: the patched bend.ts recorded no type for `id`; update tools/bend-lint/src/patch.ts"); + const xs = seen.filter((f) => f.def === "id" && B.term_strip(f.tm).$ === "Var"); + const kind = (f: Fact, t: HTerm) => (B.term_wnf(f.bok, t) as { k?: string }).k; + const tagged = (x: unknown, tags: string[]) => tags.includes((x as { $?: string } | null)?.$ ?? ""); + const wrong = xs.length < 2 ? ["facts (one from each wrapper)"] : xs.flatMap((x) => { + if (typeof x.dep !== "number" || !tagged(x.ctx, ["Emp", "Bin"]) || !tagged(x.us, ["Emp", "Bin"]) || !tagged(x.qt, ["None", "Lone", "Many"])) { + return ["kinds of values"]; + } + const v = B.term_strip(x.tm) as Extract; + const bound = B.pmap_get(x.ctx, v.i); + return ([ + ["type", kind(x, x.ty) === "N"], + ["depth", x.dep === 1], + ["scope", bound?.k === "x" && kind(x, bound.T) === "N"], + ["quantity", x.qt.$ === "Lone"], + ["uses", B.pmap_get(x.us, v.i)?.$ === "Lone"], + ["span", x.spn !== undefined && x.spn.file.str.slice(x.spn.beg, x.spn.end) === "x"], + ] as const).flatMap(([what, ok]) => ok ? [] : [what]); + }); + if (wrong.length > 0) { + throw new DriftError("self-check failed: the patched bend.ts recorded the wrong " + [...new Set(wrong)].join(", ") + + " for `x` in `def id(x: N) -> N: x`; update tools/bend-lint/src/patch.ts"); } return { Bend: B, Comp: C, BEND2: dir }; } @@ -522,11 +532,6 @@ async function cli(argv: string[]): Promise { console.log(USAGE); return 0; } - if (values.pin) { - fs.writeFileSync(PIN_FILE, current(BEND2) + "\n"); - console.log("bend-lint: pinned\n" + pinned()); - return 0; - } if (positionals.length !== 1) { throw new Error("give one .bend file\n" + USAGE); } diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index 9ec5261bcb..a0b67e990e 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -6,10 +6,10 @@ // root; on POSIX they behave as node's. comp.ts exports RUNTIME_MAIN and // js_sat, so a rule written in Bend is compiled once and run many times. // Each text edit must match exactly once, and each name a tail exports must -// be declared once. bend.pin holds the hashes of the files these were -// tested on. +// be declared once. The wrappers pass every argument through, so bend +// computes what it would without them; what they record is checked at load +// (see instrument in lint.ts). -import * as crypto from "node:crypto"; import * as nodeFs from "node:fs"; import * as nodePath from "node:path"; import * as url from "node:url"; @@ -42,7 +42,6 @@ const DRIVE = /^[A-Za-z]:(?=\/)/; const SHIM = JSON.stringify(url.pathToFileURL(nodePath.join(HERE, "patch.ts")).href); export const MARK = "BEND_LINT_PATCH"; -export const PIN_FILE = nodePath.join(HERE, "..", "bend.pin"); const PATCHES: Record = { "bend.ts": { @@ -88,24 +87,37 @@ export function relative(from: string, to: string): string { return nodePath.posix.relative(slash(from).replace(DRIVE, ""), slash(to).replace(DRIVE, "")); } -// term_infer and term_check as bend.ts calls them, each also reporting to -// book.see. tsc checks them against bend.ts's own signatures. +// term_infer and term_check as bend.ts calls them: every argument passes +// through, and each result is also reported to book.see. tsc checks the +// names against bend.ts's signatures; a function that no longer takes the +// arguments read here stops loading. export function seeInfer(f: typeof BendModule.term_infer): typeof BendModule.term_infer { - return (book, lhs, tm, qt, ctx, d, sp) => { - const r = f(book, lhs, tm, qt, ctx, d, sp); + arity(f, 6, "term_infer"); + return (...args) => { + const r = f(...args); + const [book, lhs, tm, qt, ctx, d] = args; (book as Hooked).see?.(book, r.tm, r.ty, ctx, d, lhs.def, tm.s, qt, r.us); return r; }; } export function seeCheck(f: typeof BendModule.term_check): typeof BendModule.term_check { - return (book, lhs, tm, qt, ty, ctx, d) => { - const r = f(book, lhs, tm, qt, ty, ctx, d); + arity(f, 7, "term_check"); + return (...args) => { + const r = f(...args); + const [book, lhs, tm, qt, ty, ctx, d] = args; (book as Hooked).see?.(book, r.tm, ty, ctx, d, lhs.def, tm.s, qt, r.us); return r; }; } +// f.length counts the parameters before the first default. +function arity(f: (...args: never[]) => unknown, n: number, name: string): void { + if (f.length !== n) { + throw new DriftError(name + " takes " + f.length + " parameters, not " + n + "; update seeInfer and seeCheck in tools/bend-lint/src/patch.ts"); + } +} + // `file` is "bend.ts" or "comp.ts". export function patch(file: string, src: string): string { const { edits, tail, needs } = PATCHES[file]; @@ -124,21 +136,6 @@ export function patch(file: string, src: string): string { return edited + "\n" + tail + "export const " + MARK + " = 1;\n"; } -// The git blob hash of a text, as `git rev-parse HEAD:` prints it -// (CRLF counts as LF, as git stores it). -export function blob(text: string): string { - const buf = Buffer.from(text.replace(/\r\n/g, "\n"), "utf8"); - return crypto.createHash("sha1").update("blob " + buf.length + "\0").update(buf).digest("hex"); -} - -export function pinned(): string { - return nodeFs.readFileSync(PIN_FILE, "utf8").trim(); -} - -export function current(bend2: string): string { - return Object.keys(PATCHES).map((f) => f + " " + blob(nodeFs.readFileSync(path.join(bend2, f), "utf8"))).join("\n"); -} - // The bend2 folder to load: `given` (from --bend), else $BEND_DIR, else the // one in this repo. A bend checkout works too, for its bend2 folder. export function bendDir(given: string | undefined): string { diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 88823ce47a..44fb347dd4 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; import type { Book, LTerm, Span } from "../../../bend2/bend.ts"; import { BEND2, Bend, Comp, applyFixes, bendRule, lint, mapper, render, walk } from "../src/lint.ts"; import type { Diag, Edit, Fact, LintRule, RuleContext, Source, SourceFile } from "../src/lint.ts"; -import { DriftError, bendDir, blob, current, patch, pinned, relative, resolve } from "../src/patch.ts"; +import { DriftError, bendDir, patch, relative, resolve, seeCheck, seeInfer } from "../src/patch.ts"; // Types // ===== @@ -459,11 +459,14 @@ describe("patch", () => { expect((Comp as unknown as Record).BEND_LINT_PATCH).toBe(1); }); - test("the pin holds git blob hashes, and matches bend2", () => { - expect(blob("a\r\nb\n")).toBe(blob("a\nb\n")); - expect(blob("hello\n")).toBe("ce013625030ba8dba906f756967f9e9ca394464a"); - expect(pinned()).toMatch(/^bend\.ts [0-9a-f]{40}\ncomp\.ts [0-9a-f]{40}$/); - if (process.env.BEND_LINT_UNPINNED !== "1") expect(current(BEND2)).toBe(pinned()); + test("the wrappers pass every argument through, and need the expected arity", () => { + const got: unknown[][] = []; + const fake = ((...args: unknown[]) => (got.push(args), { tm: null, us: null })) as unknown as typeof Bend.term_check; + Object.defineProperty(fake, "length", { value: 7 }); + (seeCheck(fake) as unknown as (...a: unknown[]) => unknown)({}, { def: "d" }, {}, 1, 2, 3, 4, "extra"); + expect(got).toEqual([[{}, { def: "d" }, {}, 1, 2, 3, 4, "extra"]]); + Object.defineProperty(fake, "length", { value: 5 }); + expect(() => seeInfer(fake as unknown as typeof Bend.term_infer)).toThrow(/term_infer takes 5 parameters, not 6/); }); test("bend2 is found from a checkout, its bend2 folder, or the repo; a wrong dir fails", () => { From a34a8947c685afa9ae4b82de4382b8189f55a973 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 13:50:15 -0300 Subject: [PATCH 17/39] bend-lint: Bend rules no longer slow down on large files 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 2 ++ tools/bend-lint/src/lint.ts | 29 +++++++++++++++++++++++++---- tools/bend-lint/test/lint.test.ts | 10 ++++++---- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index a76362067f..be78f19378 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -101,6 +101,8 @@ def main() -> IO(Unit): a fact with effects (`Lint.view`, `type_of`, `binder`, `same`, `show`, `normal`, `uses`, `text`) and may use Base's effects too. Offsets count characters. bend-lint compiles a rule once; each run then takes about 10 ms. +A rule runs on bend's JS runtime, where only tail calls run as loops: walk +a long text or list with a tail call, or the stack overflows. `COMMA_BEND` and `TYPES_BEND` in `test/lint.test.ts` are complete examples. ## From code diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index eb4b0fb3da..70b29f806d 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -416,15 +416,36 @@ export async function bendRule(file: string): Promise { id, needsTypes: types === "True{}", run: (cx) => { - const points = (text: string, off: number): number => [...text.slice(0, off)].length; - const spot = (spn: Span | undefined): Spot | undefined => - spn && { path: (spn.file as SourceFile).path, beg: points(spn.file.str, spn.beg), end: points(spn.file.str, spn.end) }; + // Built once per text: the character at each UTF-16 offset (points), + // and the UTF-16 offset of each character (units); both end at the end. + const tables = new Map(); + const table = (text: string): { points: number[]; units: number[] } => { + const known = tables.get(text); + if (known !== undefined) { + return known; + } + const points: number[] = []; + const units: number[] = []; + for (const ch of text) { + units.push(points.length); + points.push(...Array(ch.length).fill(units.length - 1)); + } + units.push(points.length); + points.push(units.length - 1); + tables.set(text, { points, units }); + return { points, units }; + }; + const spot = (spn: Span | undefined): Spot | undefined => { + const t = spn && table(spn.file.str); + return spn && t && { path: (spn.file as SourceFile).path, beg: t.points[spn.beg], end: t.points[spn.end] }; + }; const span = (s: Spot): Span => { const src = cx.sources.find((x) => x.path === s.path); if (src === undefined) { throw new Error("rule " + id + " reported a span in " + s.path + ", which is not in the book"); } - const at = (n: number): number => [...src.text].slice(0, n).join("").length; + const { units } = table(src.text); + const at = (n: number): number => units[Math.min(n, units.length - 1)]; return { file: src.file, beg: at(s.beg), end: at(s.end) }; }; const facts = [...cx.facts?.values() ?? []]; diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 44fb347dd4..fd911f7cb3 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -145,12 +145,14 @@ def keep(hit: Bool, +at: U32, tail: List<&2, U32>) -> List<&2, U32>: case False{}: tail -def commas(s: String, +at: U32) -> List<&2, U32>: +# A tail call: bend runs it as a loop, so a long file does not overflow the +# stack. +def commas(s: String, +at: U32, acc: List<&2, U32>) -> List<&2, U32>: match s: case SNil{}: - [] + acc case SCon{+c, +rest}: - keep(comma_before_word(c, rest), at, commas(rest, U32.add(at, 1))) + commas(rest, U32.add(at, 1), keep(comma_before_word(c, rest), at, acc)) def diag_at(+span: Lint.Span) -> Lint.Diag: Lint.Diag{Lint.Warning{}, "Add a space after the comma.", Some{span}, [Lint.Fix{"Insert space", Lint.Safe{}, [Lint.Edit{span, " "}]}]} @@ -168,7 +170,7 @@ def diags(+path: String, ats: List<&2, U32>) -> List<&2, Lint.Diag>: def choose(root: Bool, +path: String, text: String, others: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: match root: case True{}: - diags(path, commas(text, 0)) + diags(path, commas(text, 0, [])) case False{}: others From 710d0fd55dfaa80b02899ad1ce501ffd29162eaf Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 13:54:53 -0300 Subject: [PATCH 18/39] bend-lint: work outside the bend repo too 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 10 +++++++++- tools/bend-lint/package.json | 3 +++ tools/bend-lint/src/lint.ts | 6 +++--- tools/bend-lint/src/patch.ts | 13 ++++++++----- tools/bend-lint/test/lint.test.ts | 11 ++++++----- tools/bend-lint/tsconfig.json | 13 ++++++++++++- 6 files changed, 41 insertions(+), 15 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index be78f19378..4216ab1694 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -28,7 +28,7 @@ UTF-16): ``` `--bend ` (or `$BEND_DIR`) picks the bend to load: a bend checkout, or -its `bend2` folder. The default is this repo's. +its `bend2` folder. Inside the bend repo, its own bend is the default. ## Rules in TypeScript @@ -139,6 +139,14 @@ then checks every field recorded for `x` in `def id(x: N) -> N: x`. Any mismatch stops bend-lint with a `DriftError`, never a wrong result. The tests catch subtler changes. +## Outside the bend repo + +bend-lint needs a bend checkout, because it loads bend's source. Give it +with `--bend ` or `$BEND_DIR`; with neither, it stops with a clear +error. For `tsc`, run `bun install` (for `@types/bun`) and point the +`bend2/*` path in `tsconfig.json` at that checkout's `bend2/`. The drift +tests read the checkout's own `tests/`. + ## Test ```sh diff --git a/tools/bend-lint/package.json b/tools/bend-lint/package.json index a356dbb0fe..a41758de8c 100644 --- a/tools/bend-lint/package.json +++ b/tools/bend-lint/package.json @@ -12,6 +12,9 @@ "test": "bun test", "typecheck": "tsc -p tsconfig.json" }, + "devDependencies": { + "@types/bun": "^1.2.0" + }, "engines": { "bun": ">=1.2" }, diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 70b29f806d..879cbe7e32 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -8,9 +8,9 @@ import * as url from "node:url"; import * as util from "node:util"; -import type * as BendModule from "../../../bend2/bend.ts"; -import type * as CompModule from "../../../bend2/comp.ts"; -import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "../../../bend2/bend.ts"; +import type * as BendModule from "bend2/bend.ts"; +import type * as CompModule from "bend2/comp.ts"; +import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "bend2/bend.ts"; import { DriftError, MARK, bendDir, fs, patch, path } from "./patch.ts"; import type { Hooked } from "./patch.ts"; diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index a0b67e990e..2a4dde7b94 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -14,8 +14,8 @@ import * as nodeFs from "node:fs"; import * as nodePath from "node:path"; import * as url from "node:url"; -import type { Book, Ctx, HTerm, LTerm, Name, Quant, Span, Uses } from "../../../bend2/bend.ts"; -import type * as BendModule from "../../../bend2/bend.ts"; +import type { Book, Ctx, HTerm, LTerm, Name, Quant, Span, Uses } from "bend2/bend.ts"; +import type * as BendModule from "bend2/bend.ts"; // Types // ===== @@ -136,13 +136,16 @@ export function patch(file: string, src: string): string { return edited + "\n" + tail + "export const " + MARK + " = 1;\n"; } -// The bend2 folder to load: `given` (from --bend), else $BEND_DIR, else the -// one in this repo. A bend checkout works too, for its bend2 folder. +// The bend2 folder to load: `given` (from --bend), else $BEND_DIR, else +// the bend repo around tools/bend-lint, if there is one. A bend checkout +// works too, for its bend2 folder. export function bendDir(given: string | undefined): string { const dir = path.resolve(given ?? process.env.BEND_DIR ?? path.join(HERE, "..", "..", "..", "bend2")); const found = [path.join(dir, "bend2"), dir].find((d) => nodeFs.existsSync(path.join(d, "bend.ts"))); if (found === undefined) { - throw new Error("no bend2 at " + dir + " (it needs bend.ts); give a bend checkout with --bend or BEND_DIR"); + throw new Error((given ?? process.env.BEND_DIR) === undefined + ? "no bend found: give a bend checkout with --bend or BEND_DIR" + : "no bend2 at " + dir + " (it needs bend.ts); give a bend checkout with --bend or BEND_DIR"); } return fs.realpathSync(found); } diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index fd911f7cb3..2b016e760a 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -4,7 +4,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import type { Book, LTerm, Span } from "../../../bend2/bend.ts"; +import type { Book, LTerm, Span } from "bend2/bend.ts"; import { BEND2, Bend, Comp, applyFixes, bendRule, lint, mapper, render, walk } from "../src/lint.ts"; import type { Diag, Edit, Fact, LintRule, RuleContext, Source, SourceFile } from "../src/lint.ts"; import { DriftError, bendDir, patch, relative, resolve, seeCheck, seeInfer } from "../src/patch.ts"; @@ -105,7 +105,8 @@ def main() -> Sample: f(choose(a,b)) `; -// Repo tests whose first expected line says if the check passes. +// Tests in the bend checkout (bend2/../tests) whose first expected line +// says if the check passes. const DRIFT = [ "check/alpha_equivalence", "check/assert_plain_fill", "check/dependent_telescope", "check/beta_ann_body", "check/ctor_arity", "check/forward_reference", "check/hole_todo", "check/typed_let_mismatch", @@ -734,10 +735,10 @@ describe("rules", () => { }); }); -describe("drift: the copy of book_read agrees with the repo tests", () => { +describe("drift: the copy of book_read agrees with bend's own tests", () => { for (const name of DRIFT) { - test(name, async () => { - const file = fileURLToPath(new URL("../../../tests/" + name + ".bend", import.meta.url)); + const file = path.join(BEND2, "..", "tests", name + ".bend"); + test.skipIf(!fs.existsSync(file))(name, async () => { const first = fs.readFileSync(file, "utf8").match(/^#\|(.*)$/m)![1].trim(); expect((await lint(file, [])).ok).toBe(first !== "SOME PROOFS FAIL"); }); diff --git a/tools/bend-lint/tsconfig.json b/tools/bend-lint/tsconfig.json index 2461672096..02cd26864e 100644 --- a/tools/bend-lint/tsconfig.json +++ b/tools/bend-lint/tsconfig.json @@ -1,4 +1,15 @@ { - "extends": "../../bend2/pack/tsconfig.json", + "compilerOptions": { + "strict": true, + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["bun"], + "typeRoots": ["./node_modules/@types", "../../bend2/pack/node_modules/@types"], + "paths": { "bend2/*": ["../../bend2/*"] } + }, "include": ["src", "test"] } From 810ab27816024db1130f7bacdb76f43d27a610ab Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 14:20:35 -0300 Subject: [PATCH 19/39] bend-lint: rule options, and bend-lint.json 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 24 +++++++- tools/bend-lint/src/lint.bend | 73 +++++++++++++++++++++++- tools/bend-lint/src/lint.js | 8 ++- tools/bend-lint/src/lint.ts | 89 ++++++++++++++++++++++++++---- tools/bend-lint/test/lint.test.ts | 92 +++++++++++++++++++++++++++++-- 5 files changed, 266 insertions(+), 20 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 4216ab1694..815566ba6f 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -10,7 +10,7 @@ does not change. It needs Bun 1.2 or newer. From the repo root: ```sh -bun tools/bend-lint/src/lint.ts file.bend [--rules rules.ts]... [--rules rule.bend]... [--fix] [--json] [--bend ] +bun tools/bend-lint/src/lint.ts file.bend [--rules rules.ts]... [--rules rule.bend]... [--config bend-lint.json] [--fix] [--json] [--bend ] ``` Findings print in bend's error layout, with a severity, a code, and each fix @@ -76,6 +76,24 @@ give no facts. A template body is checked as written and again per instance (`generic~0`) at the same spans; `fact.inst` marks the instances. A file with `import Base` reuses a Base checked once per process. +## Options + +A rule may declare options with their defaults (`options: { tabWidth: 2, +breakLines: false }`) and read them from `cx.options`. A `bend-lint.json` in +the file's folder or above it (or the one `--config` gives) sets them, and +can also turn a rule off or change its severity: + +```json +{ "rules": { + "format/indent": { "tabWidth": 4, "breakLines": true }, + "style/comma-space": "off", + "demo/var-types": { "severity": "hint" } +} } +``` + +An unknown option, or a value whose type differs from its default, stops +bend-lint with an error. From code: `lint(file, rules, { config })`. + ## Rules in Bend A `.bend` rule imports `src/lint.bend`, which lists the contract: @@ -97,7 +115,9 @@ def main() -> IO(Unit): Lint.serve(run) ``` -`input` holds the sources and, with `types()`, the facts. A rule asks about +`input` holds the sources, the rule's options and, with `types()`, the +facts. Read an option with `Lint.option_number`, `option_flag` or +`option_text`, each with a default; numbers are whole (U32). A rule asks about a fact with effects (`Lint.view`, `type_of`, `binder`, `same`, `show`, `normal`, `uses`, `text`) and may use Base's effects too. Offsets count characters. bend-lint compiles a rule once; each run then takes about 10 ms. diff --git a/tools/bend-lint/src/lint.bend b/tools/bend-lint/src/lint.bend index 5da689a944..3efcb8618c 100644 --- a/tools/bend-lint/src/lint.bend +++ b/tools/bend-lint/src/lint.bend @@ -7,7 +7,9 @@ # def main() -> IO(Unit): # Lint.serve(run) # -# Offsets count characters (code points) from the start of a file. +# Offsets count characters (code points) from the start of a file. The +# rule's options come from bend-lint.json; read them with option_number, +# option_flag and option_text, each with a default. import Base # A checked term, and a type: indexes into bend-lint's tables for one run. @@ -21,8 +23,17 @@ type Term is Data: type Source is Data: Source{path: String, text: String, root: Bool} +# An option's value: a whole number (a U32), a boolean, or text. +type Value is Data: + Num{value: U32} + Flag{value: Bool} + Text{value: String} + +type Option is Data: + Option{key: String, value: Value} + type Input is Data: - Input{sources: List<&2, Source>, facts: List<&2, Fact>} + Input{sources: List<&2, Source>, facts: List<&2, Fact>, options: List<&2, Option>} type Span is Data: Span{path: String, beg: U32, end: U32} @@ -102,5 +113,63 @@ def uses(fact: Fact) -> IO(List<&2, Use>): def text(span: Span) -> IO(String): import "./lint.js" +def pick(hit: Bool, v: Value, later: Maybe<&2, Value>) -> Maybe<&2, Value>: + match hit: + case True{}: + Some{v} + case False{}: + later + +# The value of an option, if it is given. +def find(opts: List<&2, Option>, +key: String) -> Maybe<&2, Value>: + match opts: + case Nil{}: + None{} + case Con{Option{k, v}, rest}: + pick(String.eq(k, key), v, find(rest, key)) + +def option_number_or(m: Maybe<&2, Value>, default: U32) -> U32: + match m: + case Some{Num{n}}: + n + case Some{Flag{b}}: + default + case Some{Text{t}}: + default + case None{}: + default + +# An option's value, or `default` when it is not given or of another type. +def option_number(opts: List<&2, Option>, key: String, default: U32) -> U32: + option_number_or(find(opts, key), default) + +def option_flag_or(m: Maybe<&2, Value>, default: Bool) -> Bool: + match m: + case Some{Flag{b}}: + b + case Some{Num{n}}: + default + case Some{Text{t}}: + default + case None{}: + default + +def option_flag(opts: List<&2, Option>, key: String, default: Bool) -> Bool: + option_flag_or(find(opts, key), default) + +def option_text_or(m: Maybe<&2, Value>, default: String) -> String: + match m: + case Some{Text{t}}: + t + case Some{Num{n}}: + default + case Some{Flag{b}}: + default + case None{}: + default + +def option_text(opts: List<&2, Option>, key: String, default: String) -> String: + option_text_or(find(opts, key), default) + def serve(run: Input -> IO(List<&2, Diag>)) -> IO(Unit): IO.bind(Input, Unit, input(), i => IO.bind(List<&2, Diag>, Unit, run(i), report)) diff --git a/tools/bend-lint/src/lint.js b/tools/bend-lint/src/lint.js index f0e3884bea..9c7627554c 100644 --- a/tools/bend-lint/src/lint.js +++ b/tools/bend-lint/src/lint.js @@ -38,16 +38,22 @@ function lint_spot(s) { return { path: s.path, beg: s.beg, end: s.end }; } +function lint_value(v) { + return typeof v === "number" ? { $: CID(Num), value: v } + : typeof v === "boolean" ? { $: CID(Flag), value: v } : { $: CID(Text), value: v }; +} + function lint_term(id) { return { $: CID(Term), id }; } function lint_input() { - const { sources, facts } = lint_host().input(); + const { sources, facts, options } = lint_host().input(); return { $: CID(Input), sources: lint_list(sources.map((s) => ({ $: CID(Source), path: s.path, text: s.text, root: s.root }))), facts: lint_list(Array.from({ length: facts }, (_, id) => ({ $: CID(Fact), id }))), + options: lint_list(Object.entries(options).map(([key, v]) => ({ $: CID(Option), key, value: lint_value(v) }))), }; } diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 879cbe7e32..b9a3b6e0d1 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -61,6 +61,7 @@ export type RuleContext = { book: Book; sources: Source[]; root: Source; + options: Options; // the rule's defaults, with the config's values facts?: Map; // only for a rule with needsTypes prior: readonly Diag[]; // what earlier rules found span: Mapper; // a bend.ts span, in the file on disk @@ -73,9 +74,19 @@ export type RuleContext = { diag(init: DiagInit): Diag; }; +// A rule option's value. A default's type is its option's type. +export type OptionValue = number | boolean | string; +export type Options = Record; + +// bend-lint.json: per rule id, "off", or a severity and option values. +export type Config = { rules?: Record }; + +export type LintOptions = { signal?: AbortSignal; config?: Config }; + export type LintRule = { id: string; // namespace/name; the code of its findings needsTypes?: boolean; + options?: Options; // defaults; without them (a Bend rule), any option is passed as given run(cx: RuleContext, signal: AbortSignal): Diag[] | Promise; }; @@ -98,7 +109,7 @@ type Reported = { // What lint.js asks of bend-lint while a Bend rule runs. Facts and types // cross as indexes into the run's tables. type Channel = { - input(): { sources: Array<{ path: string; text: string; root: boolean }>; facts: number }; + input(): { sources: Array<{ path: string; text: string; root: boolean }>; facts: number; options: Options }; report(diags: Reported[]): void; view(fact: number): { owner: Name; inst: boolean; kind: string; name: string; quantity: Quant["$"]; span?: Spot; inner?: Spot }; type(fact: number): number; @@ -120,6 +131,7 @@ type Checked = { book: Book; sources: Source[]; span: Mapper; facts?: Map [--rules ]... [--fix] [--json] [--bend ]", + "usage: bun tools/bend-lint/src/lint.ts [--rules ]... [--config ] [--fix] [--json] [--bend ]", ].join("\n"); // Functions @@ -291,23 +304,73 @@ async function check(file: string, capture: boolean, signal: AbortSignal): Promi return { book, sources, span, failure: { code: "bend/check", severity: "error", message, spn: span(err?.spn), def: err?.def, fixes: [], core: caught.e } }; } -// Rules run in order. A finding with severity error stops the run. A rule +export function readConfig(file: string): Config { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (e) { + throw new Error(file + ": " + (e instanceof Error ? e.message : String(e))); + } +} + +// The bend-lint.json in the file's folder, or the nearest one above it. +export function findConfig(file: string): Config { + for (let dir = path.dirname(path.resolve(file)); ; dir = path.dirname(dir)) { + if (fs.existsSync(path.join(dir, CONFIG))) { + return readConfig(path.join(dir, CONFIG)); + } + if (path.dirname(dir) === dir) { + return {}; + } + } +} + +// What the config says for a rule: off, a severity, and its options (the +// defaults, with the given values, which must be known and of their type). +function settings(rule: LintRule, config: Config): { off: boolean; severity?: Severity; options: Options } { + const given = config.rules?.[rule.id]; + const where = CONFIG + ": " + rule.id; + if (given === undefined || given === "off") { + return { off: given === "off", options: { ...rule.options } }; + } + if (typeof given !== "object" || given === null) { + throw new Error(where + " must be \"off\" or an object"); + } + const { severity, ...options } = given; + if (severity !== undefined && !Object.hasOwn(HEAD, severity)) { + throw new Error(where + ": severity must be one of " + Object.keys(HEAD).join(", ")); + } + for (const [key, value] of Object.entries(options)) { + const want = rule.options === undefined ? typeof value : typeof rule.options[key]; + if (rule.options !== undefined && !Object.hasOwn(rule.options, key)) { + throw new Error(where + " has no option " + key); + } + if (typeof value !== want || !["number", "boolean", "string"].includes(want)) { + throw new Error(where + ": " + key + " must be a " + (rule.options === undefined ? "number, boolean or string" : want)); + } + } + return { off: false, severity, options: { ...rule.options, ...options } }; +} + +// Rules run in order; the config may turn one off, set its severity, and +// give its options. A finding with severity error stops the run. A rule // that throws, and an abort, reach the caller. -export async function lint(file: string, rules: LintRule[], signal = new AbortController().signal): Promise { +export async function lint(file: string, rules: LintRule[], + { signal = new AbortController().signal, config = findConfig(file) }: LintOptions = {}): Promise { const bad = rules.find((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function"); if (bad !== undefined) { throw new TypeError("invalid rule " + JSON.stringify(bad?.id) + ": it needs an id like ns/name and a run function"); } - const { book, sources, span, facts, failure } = await check(file, rules.some((r) => r.needsTypes === true), signal); + const plans = rules.map((rule) => ({ rule, ...settings(rule, config) })).filter((p) => !p.off); + const { book, sources, span, facts, failure } = await check(file, plans.some((p) => p.rule.needsTypes === true), signal); if (failure !== undefined) { return { ok: false, diags: [failure], sources, book }; } const root = sources.find((s) => s.root)!; let diags: Diag[] = []; - for (const rule of rules) { + for (const { rule, severity, options } of plans) { signal.throwIfAborted(); const out = await rule.run({ - Bend, book, sources, root, span, walk, + Bend, book, sources, root, span, walk, options, facts: rule.needsTypes === true ? facts : undefined, prior: diags, binder: (fact, v) => v.$ === "Var" ? Bend.pmap_get(fact.ctx, v.i) : null, @@ -326,7 +389,7 @@ export async function lint(file: string, rules: LintRule[], signal = new AbortCo throw new TypeError("rule " + rule.id + " must return an array of diagnostics"); } const settled = out.map((d): Diag => ({ - ...d, code: rule.id, spn: span(d.spn), + ...d, code: rule.id, severity: severity ?? d.severity, spn: span(d.spn), fixes: d.fixes.map((f) => ({ ...f, edits: f.edits.map((e) => ({ ...e, spn: span(e.spn) })) })), })); const stop = settled.findIndex((d) => d.severity === "error"); @@ -459,7 +522,13 @@ export async function bendRule(file: string): Promise { const keep = (t: HTerm): number => terms.push(t) - 1; const found: Reported[][] = []; shared.BEND_LINT = { - input: () => ({ sources: cx.sources.map((s) => ({ path: s.path, text: s.text, root: s.root })), facts: facts.length }), + input: () => { + const odd = Object.entries(cx.options).find(([, v]) => typeof v === "number" && !(Number.isInteger(v) && v >= 0 && v <= 0xffffffff)); + if (odd !== undefined) { + throw new Error("rule " + id + ": option " + odd[0] + " must be a whole number from 0 to 4294967295 (a U32)"); + } + return { sources: cx.sources.map((s) => ({ path: s.path, text: s.text, root: s.root })), facts: facts.length, options: cx.options }; + }, report: (diags) => void found.push(diags), view: (i) => { const f = pick(facts, i, "fact"); @@ -566,7 +635,7 @@ async function cli(argv: string[]): Promise { } return rules; })); - const res = await lint(positionals[0], modules.flat()); + const res = await lint(positionals[0], modules.flat(), { config: values.config === undefined ? undefined : readConfig(values.config) }); const fixed = values.fix && res.ok ? res.sources.filter((s) => !s.base).map((s) => ({ s, text: applyFixes(s.file, res.diags) })).filter(({ s, text }) => text !== s.text) : []; diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 2b016e760a..301dda4a39 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { Book, LTerm, Span } from "bend2/bend.ts"; -import { BEND2, Bend, Comp, applyFixes, bendRule, lint, mapper, render, walk } from "../src/lint.ts"; +import { BEND2, Bend, Comp, applyFixes, bendRule, findConfig, lint, mapper, render, walk } from "../src/lint.ts"; import type { Diag, Edit, Fact, LintRule, RuleContext, Source, SourceFile } from "../src/lint.ts"; import { DriftError, bendDir, patch, relative, resolve, seeCheck, seeInfer } from "../src/patch.ts"; @@ -189,7 +189,7 @@ def all(srcs: List<&2, Lint.Source>) -> List<&2, Lint.Diag>: def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): match input: - case Lint.Input{sources, facts}: + case Lint.Input{sources, facts, options}: IO.pure(List<&2, Lint.Diag>, all(sources)) def main() -> IO(Unit): @@ -296,7 +296,7 @@ def each(facts: List<&2, Lint.Fact>) -> IO(List<&2, Lint.Diag>): def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): match input: - case Lint.Input{sources, facts}: + case Lint.Input{sources, facts, options}: each(facts) def main() -> IO(Unit): @@ -373,6 +373,35 @@ const spacing: LintRule = { })), }; +// A Bend rule that reports its options, read with defaults. +const OPTIONS_BEND = String.raw`import Base +import ../../../src/lint.bend as Lint + +def id() -> String: + "test/options" + +def types() -> Bool: + False{} + +def summary(+opts: List<&2, Lint.Option>) -> String: + U32.show(Lint.option_number(opts, "width", 2)) ++ " " ++ Bool.show(Lint.option_flag(opts, "wrap", False{})) ++ " " ++ Lint.option_text(opts, "name", "none") + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + match input: + case Lint.Input{sources, facts, +options}: + IO.pure(List<&2, Lint.Diag>, [Lint.Diag{Lint.Hint{}, summary(options), None{}, []}]) + +def main() -> IO(Unit): + Lint.serve(run) +`; + +// A rule that reports its options. +const echo: LintRule = { + id: "test/echo", + options: { tabWidth: 2, breakLines: false }, + run: (cx) => [cx.diag({ message: JSON.stringify(cx.options), severity: "hint" })], +}; + // Functions // ========= @@ -608,8 +637,8 @@ describe("lint", () => { await expect(lint(userland, [{ id: "test/boom", run: async () => { throw new Error("rule failed"); } }])).rejects.toThrow("rule failed"); const controller = new AbortController(); const abort: LintRule = { id: "test/abort", run: async () => { controller.abort(); return []; } }; - await expect(lint(userland, [abort, neverRun], controller.signal)).rejects.toThrow(); - await expect(lint(userland, [], controller.signal)).rejects.toThrow(); + await expect(lint(userland, [abort, neverRun], { signal: controller.signal })).rejects.toThrow(); + await expect(lint(userland, [], { signal: controller.signal })).rejects.toThrow(); }); test("spans after import lines point at the right text on disk", async () => { @@ -688,6 +717,52 @@ describe("lint", () => { }); }); +describe("options", () => { + const userland = fixture("options_userland.bend", USERLAND); + const messages = async (config?: object, file = userland) => + (await lint(file, [echo], { config })).diags.map((d) => [d.severity, d.message]); + + test("a rule gets its defaults, with the config's values", async () => { + expect(await messages({})).toEqual([["hint", '{"tabWidth":2,"breakLines":false}']]); + expect(await messages({ rules: { "test/echo": { tabWidth: 4 } } })).toEqual([["hint", '{"tabWidth":4,"breakLines":false}']]); + }); + + test("the config turns a rule off, or sets its severity", async () => { + expect(await messages({ rules: { "test/echo": "off" } })).toEqual([]); + expect(await messages({ rules: { "test/echo": { severity: "warning", breakLines: true } } })) + .toEqual([["warning", '{"tabWidth":2,"breakLines":true}']]); + }); + + test("a wrong config fails loudly", async () => { + await expect(messages({ rules: { "test/echo": { tabSize: 4 } } })).rejects.toThrow(/test\/echo has no option tabSize/); + await expect(messages({ rules: { "test/echo": { tabWidth: "4" } } })).rejects.toThrow(/tabWidth must be a number/); + await expect(messages({ rules: { "test/echo": { severity: "loud" } } })).rejects.toThrow(/severity must be one of/); + await expect(messages({ rules: { "test/echo": 4 } })).rejects.toThrow(/must be "off" or an object/); + }); + + test("bend-lint.json is found in the file's folder or above it", async () => { + const top = path.join(DIR, "project"); + fs.mkdirSync(path.join(top, "deep", "er"), { recursive: true }); + fs.writeFileSync(path.join(top, "bend-lint.json"), JSON.stringify({ rules: { "test/echo": { tabWidth: 8 } } })); + const file = path.join(top, "deep", "er", "file.bend"); + fs.writeFileSync(file, USERLAND); + expect(findConfig(file)).toEqual({ rules: { "test/echo": { tabWidth: 8 } } }); + expect(await messages(undefined, file)).toEqual([["hint", '{"tabWidth":8,"breakLines":false}']]); + expect(await messages({}, file)).toEqual([["hint", '{"tabWidth":2,"breakLines":false}']]); + }); + + test("a Bend rule reads its options with defaults", async () => { + const rule = await bendRule(fixture("options_rule.bend", OPTIONS_BEND)); + const said = async (config: object) => (await lint(userland, [rule], { config })).diags.map((d) => d.message); + const [plain] = await said({}); + expect(plain).toMatch(/^2 \S+ none$/); + const [given] = await said({ rules: { "test/options": { width: 4, wrap: true, name: "x" } } }); + expect(given).toMatch(/^4 \S+ x$/); + expect(given.split(" ")[1]).not.toBe(plain.split(" ")[1]); + await expect(said({ rules: { "test/options": { width: 1.5 } } })).rejects.toThrow(/whole number/); + }); +}); + describe("rules", () => { test("erasure: four redundant annotations; the fix keeps every body", async () => { const file = fixture("erasure.bend", ERASURE); @@ -838,6 +913,13 @@ describe("cli", () => { expect(run(input, "--bend", DIR)).toMatchObject({ status: 2, stderr: expect.stringContaining("no bend2 at") }); }); + test("--config gives the config", () => { + const echoes = module("echo.js", `[{ id: "test/echo", options: { tabWidth: 2 }, run: (cx) => [cx.diag({ message: "w" + cx.options.tabWidth })] }]`); + const config = fixture("cli_config.json", JSON.stringify({ rules: { "test/echo": { tabWidth: 6, severity: "hint" } } })); + const out = run(input, "--rules", echoes, "--config", config, "--json"); + expect(JSON.parse(out.stdout).findings.map((f: { severity: string; message: string }) => [f.severity, f.message])).toEqual([["hint", "w6"]]); + }); + test("--fix applies safe fixes", () => { const target = fixture("fix.bend", USERLAND); const comma = module("comma.js", `[{ id: "style/comma-space", run: (cx) => From 511d9162c43e08bbc2ddf7dfae18b047173b00b1 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 14:30:18 -0300 Subject: [PATCH 20/39] bend-lint: fixes from the review - --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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 26 ++-- tools/bend-lint/src/lint.js | 2 +- tools/bend-lint/src/lint.ts | 195 ++++++++++++++++++++---------- tools/bend-lint/src/patch.ts | 21 ++-- tools/bend-lint/test/lint.test.ts | 107 +++++++++++++++- 5 files changed, 260 insertions(+), 91 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 815566ba6f..5cecc571e6 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -14,9 +14,14 @@ bun tools/bend-lint/src/lint.ts file.bend [--rules rules.ts]... [--rules rule.be ``` Findings print in bend's error layout, with a severity, a code, and each fix -as a diff. `--fix` writes the `safe` fixes. Exit codes: 0 no error, 1 an -error (the file does not check, or a rule found an `error`), 2 bad usage or -a tool failure. +as a diff. `--fix` writes the `safe` fixes to the linted file only, never +to its imports (a BendHub package under `~/.bend/lib` is never edited). Equal +edits merge; a fix that clashes with an earlier one is skipped and counted, +and another `--fix` run applies it. Exit codes: 0 no error, 1 an error (the +file does not check, or a rule found an `error`), 2 bad usage or a tool +failure. "Does not check" means bend's checker rejects it: unlike +`bend --check-only`, bend-lint does not fail a file for relying on +`@unsafe` or foreign code. `--json` prints only this, for other tools (ranges as in LSP: 0-based, UTF-16): @@ -60,7 +65,7 @@ export const rules: LintRule[] = [{ | `cx` | | |---|---| | `root`, `sources` | the linted file and its imports, as on disk | -| `facts` | with `needsTypes`: per checked term, its type, scope, depth, def, quantity, uses, and span on disk | +| `facts` | with `needsTypes`: per checked term of the linted file, its type, scope, depth, def, quantity, uses, and span on disk | | `prior` | the findings of earlier rules | | `span(s)` | a bend span (e.g. `term.s`), moved to the file on disk | | `walk`, `binder`, `show`, `same`, `normal`, `uses` | helpers over bend's terms | @@ -71,10 +76,13 @@ Severities: `error` (stops the run), `warning`, `information`, `hint`. Fix levels: `safe` keeps behavior, `suggested` may change it, `dangerous` may break code. -Rules run in order; none runs when the file does not check. Base's own defs -give no facts. A template body is checked as written and again per instance -(`generic~0`) at the same spans; `fact.inst` marks the instances. A file with -`import Base` reuses a Base checked once per process. +Rules run in order; none runs when the file does not check. Facts cover only +the linted file, so none come from Base or other imports. A template body is +checked as written and again per instance (`generic~0`) at the same spans; +`fact.inst` marks the instances. A file with a line exactly `import Base` +(as `bend --checkup` reads it) reuses a Base checked once per process. Every +checked term of the file gives a fact, so a very large file costs memory: a +rule with `needsTypes` on a 3,200-proof file holds about 1.3M facts. ## Options @@ -132,7 +140,7 @@ import { applyFixes, bendRule, lint, render } from "./tools/bend-lint/src/lint.t const res = await lint("file.bend", [...rules, await bendRule("rule.bend")]); console.log(res.ok, res.diags.map(render)); -const fixed = applyFixes(res.sources.find((s) => s.root)!.file, res.diags); +const { text, skipped } = applyFixes(res.sources.find((s) => s.root)!.file, res.diags); ``` A library picks its bend with `$BEND_DIR`, set before it imports bend-lint. diff --git a/tools/bend-lint/src/lint.js b/tools/bend-lint/src/lint.js index 9c7627554c..9c390b1f32 100644 --- a/tools/bend-lint/src/lint.js +++ b/tools/bend-lint/src/lint.js @@ -9,7 +9,7 @@ const LINT_APPLICABILITY = { [CID(Safe)]: "safe", [CID(Suggested)]: "suggested", function lint_host() { const host = globalThis.BEND_LINT; if (host === undefined) { - throw new Error("bend-lint: this program is a rule; run it with bun tools/bend-lint/src/lint.ts --rules "); + throw new Error("this program is a rule; run it with bun tools/bend-lint/src/lint.ts --rules "); } return host; } diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index b9a3b6e0d1..b0e228907a 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -143,6 +143,9 @@ const shared = globalThis as typeof globalThis & { BEND_LINT?: Channel }; // per process, and again only if base.bend changes. const BASES = new Map>(); +// Line starts per file object (see starts). +const STARTS = new WeakMap(); + // Binders are explicit in checked terms, so Var.v cells are not followed. // A new term kind is a type error here, and a DriftError when walked. const CHILDREN: { [K in LTerm["$"]]: (tm: Extract) => LTerm[] } = { @@ -185,19 +188,29 @@ const USAGE = [ // Functions // ========= +// Every sub-term, parents first, with an explicit stack: linear in the +// size of the term, at any depth. export function* walk(tm: LTerm): Generator { - const children = (CHILDREN as Record LTerm[]) | undefined>)[tm.$]; - if (children === undefined) { - throw new DriftError("unknown term kind " + tm.$ + "; update CHILDREN in tools/bend-lint/src/lint.ts"); - } - yield tm; - for (const child of children(tm)) { - yield* walk(child); + const stack = [tm]; + for (let t = stack.pop(); t !== undefined; t = stack.pop()) { + const children = (CHILDREN as Record LTerm[]) | undefined>)[t.$]; + if (children === undefined) { + throw new DriftError("unknown term kind " + t.$ + "; update CHILDREN in tools/bend-lint/src/lint.ts"); + } + yield t; + stack.push(...children(t).reverse()); } } -function starts(text: string): number[] { - return [0, ...[...text.matchAll(/\n/g)].map((m) => m.index! + 1)]; +// Where each line of a file starts, computed once per file object. +function starts(file: Span["file"]): number[] { + const known = STARTS.get(file); + if (known !== undefined) { + return known; + } + const out = [0, ...[...file.str.matchAll(/\n/g)].map((m) => m.index! + 1)]; + STARTS.set(file, out); + return out; } // The index of the line that holds `off`, given each line's start. @@ -211,8 +224,9 @@ function line(starts: number[], off: number): number { } // bend.ts parses a copy of each file with its import lines blanked, so a -// span moves to the file on disk by line and column. A copy that does not -// match its file is a DriftError. +// span moves to the file on disk by line and column. The copy's namespace, +// folder and lines pick the file; a copy that matches no file, or two, is +// a DriftError. export function mapper(sources: Source[]): Mapper { const own = new Set(sources.map((s) => s.file)); const memo = new WeakMap(); @@ -227,7 +241,8 @@ export function mapper(sources: Source[]): Mapper { const lines = f.str.split("\n"); const found = sources.filter((src) => { const theirs = src.text.split("\n"); - return (f.dir === undefined || f.dir === src.path.slice(0, src.path.lastIndexOf("/") + 1)) + return f.ns === src.ns + && (f.dir === undefined || f.dir === src.path.slice(0, src.path.lastIndexOf("/") + 1)) && lines.length === theirs.length && lines.every((l, i) => l === theirs[i] || l.trim() === "" && /^\s*import(\s|$)/.test(theirs[i])); }); @@ -236,7 +251,7 @@ export function mapper(sources: Source[]): Mapper { + " candidates); bend.ts may mask imports another way now"); } Object.assign(found[0].file.al, f.al); - memo.set(f, { file: found[0].file, from: starts(f.str), to: starts(found[0].text) }); + memo.set(f, { file: found[0].file, from: starts(f), to: starts(found[0].file) }); } const { file, from, to } = memo.get(f)!; const at = (off: number): number => { @@ -252,18 +267,27 @@ function isErr(e: unknown): e is Err { return typeof e === "object" && e !== null && (e as { $?: unknown }).$ === "Err"; } -// Loads and checks a book as bend2/main.ts book_read does. A file with its -// own `import Base` starts from a copy of the checked Base, as `bend -// --checkup` does. Facts of Base's own defs are not recorded. +// Loads and checks a book as bend2/main.ts book_read does; it does not give +// main.ts's verdict on @unsafe and foreign code. A file with a line exactly +// `import Base` starts from a copy of the checked Base, as `bend --checkup` +// does. Facts are kept only for terms in the linted file: those from its +// folder and namespace while checking, and those whose span maps to it. async function check(file: string, capture: boolean, signal: AbortSignal): Promise { signal.throwIfAborted(); const book: Hooked = Bend.book_nil(); const seen = new Map(); const found: Array> = []; + const real = fs.existsSync(file) ? fs.realpathSync(file) : ""; + const home = real.slice(0, real.lastIndexOf("/") + 1); book.see = capture - ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => void (bok.tlds[def]?.b !== true && found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us })) + ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => { + const at = spn?.file as { dir?: string; ns?: string } | undefined; + if (at?.dir === home && at.ns === "") { + found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us }); + } + } : undefined; - const seeded = fs.existsSync(file) && /^import Base\s*$/m.test(fs.readFileSync(file, "utf8")); + const seeded = real !== "" && /^import Base$/m.test(fs.readFileSync(real, "utf8")); const key = fs.readFileSync(Bend.BASE_BEND, "utf8"); if (seeded && !BASES.has(key)) { const base = Bend.book_nil(); @@ -297,11 +321,23 @@ async function check(file: string, capture: boolean, signal: AbortSignal): Promi const span = mapper(sources); if (caught === undefined) { const insts = new Set(Object.values(book.tmps).flatMap((m) => [...m.values()])); - return { book, sources, span, facts: capture ? new Map(found.map((f) => [f.tm, { ...f, inst: insts.has(f.def), spn: span(f.spn) }])) : undefined }; + const mine = sources.find((s) => s.root)?.file; + const facts = found.flatMap((f): Array<[LTerm, Fact]> => { + const spn = span(f.spn); + return spn?.file === mine ? [[f.tm, { ...f, inst: insts.has(f.def), spn }]] : []; + }); + return { book, sources, span, facts: capture ? new Map(facts) : undefined }; } const err = isErr(caught.e) ? caught.e : undefined; const message = err !== undefined ? Bend.expr_show(err.bok, err.exp) : caught.e instanceof RangeError ? STACK : String(caught.e); - return { book, sources, span, failure: { code: "bend/check", severity: "error", message, spn: span(err?.spn), def: err?.def, fixes: [], core: caught.e } }; + // A file bend did not finish loading may not map; the failure still reports. + let spn: Span | undefined; + try { + spn = span(err?.spn); + } catch { + spn = undefined; + } + return { book, sources, span, failure: { code: "bend/check", severity: "error", message, spn, def: err?.def, fixes: [], core: caught.e } }; } export function readConfig(file: string): Config { @@ -356,9 +392,9 @@ function settings(rule: LintRule, config: Config): { off: boolean; severity?: Se // that throws, and an abort, reach the caller. export async function lint(file: string, rules: LintRule[], { signal = new AbortController().signal, config = findConfig(file) }: LintOptions = {}): Promise { - const bad = rules.find((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function"); - if (bad !== undefined) { - throw new TypeError("invalid rule " + JSON.stringify(bad?.id) + ": it needs an id like ns/name and a run function"); + const bad = rules.findIndex((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function"); + if (bad >= 0) { + throw new TypeError("invalid rule at " + bad + " (" + JSON.stringify(rules[bad]?.id) + "): it needs an id like ns/name and a run function"); } const plans = rules.map((rule) => ({ rule, ...settings(rule, config) })).filter((p) => !p.off); const { book, sources, span, facts, failure } = await check(file, plans.some((p) => p.rule.needsTypes === true), signal); @@ -392,6 +428,11 @@ export async function lint(file: string, rules: LintRule[], ...d, code: rule.id, severity: severity ?? d.severity, spn: span(d.spn), fixes: d.fixes.map((f) => ({ ...f, edits: f.edits.map((e) => ({ ...e, spn: span(e.spn) })) })), })); + const broken = settled.flatMap((d) => d.fixes).find((f) => f.edits.some(({ spn }, i) => + spn.beg < 0 || spn.beg > spn.end || spn.end > spn.file.str.length || f.edits.some((o, j) => j !== i && clash(f.edits[i], o)))); + if (broken !== undefined) { + throw new TypeError("rule " + rule.id + ": fix \"" + broken.title + "\" has an edit out of bounds, or two that clash"); + } const stop = settled.findIndex((d) => d.severity === "error"); diags = [...diags, ...(stop < 0 ? settled : settled.slice(0, stop + 1))]; if (stop >= 0) { @@ -401,52 +442,54 @@ export async function lint(file: string, rules: LintRule[], return { ok: true, diags, sources, book, facts }; } -// The text of `file` with the edits on it applied. Edits must not overlap. -function edit(file: Span["file"], edits: Edit[]): string { - const mine = edits.filter((e) => e.spn.file === file).sort((a, b) => b.spn.beg - a.spn.beg); - const sound = mine.every(({ spn }, i) => { - const wall = i === 0 ? file.str.length + 1 : mine[i - 1].spn.beg; - return spn.beg >= 0 && spn.beg <= spn.end && spn.end <= file.str.length && spn.beg < wall && spn.end <= wall; - }); - if (!sound) { - throw new Error("bend-lint: fixes are out of bounds or overlap"); - } - return mine.reduce((s, { spn, text }) => s.slice(0, spn.beg) + text + s.slice(spn.end), file.str); -} - -export function applyFixes(file: SourceFile, diags: Diag[], levels: Applicability[] = ["safe"]): string { - return edit(file, diags.flatMap((d) => d.fixes).filter((f) => levels.includes(f.applicability)).flatMap((f) => f.edits)); +// Whether two edits to one file cannot both apply: their ranges overlap, +// or they insert at the same point. +function clash(a: Edit, b: Edit): boolean { + return a.spn.file === b.spn.file && (a.spn.beg < b.spn.end && b.spn.beg < a.spn.end + || a.spn.beg === b.spn.beg && a.spn.end === b.spn.end); } -function common(a: string[], b: string[]): number { - const n = a.findIndex((x, i) => x !== b[i]); - return Math.min(n < 0 ? a.length : n, b.length); +// `text` with edits applied; their spans are offsets into `text`, and they +// do not clash. +function apply(text: string, edits: Edit[]): string { + return [...edits].sort((a, b) => b.spn.beg - a.spn.beg || b.spn.end - a.spn.end) + .reduce((s, { spn, text: t }) => s.slice(0, spn.beg) + t + s.slice(spn.end), text); } -// The LSP range of a span in a file on disk. -export function position(spn: Span): { start: Position; end: Position } { - const ss = starts(spn.file.str); - const at = (off: number): Position => { - const i = line(ss, off); - return { line: i, character: off - ss[i] }; - }; - return { start: at(spn.beg), end: at(spn.end) }; +// The text of `file` with the fixes of the given levels applied, in order. +// An edit equal to one already taken is merged; a fix with an edit that +// clashes with one already taken is skipped and counted. +export function applyFixes(file: SourceFile, diags: Diag[], levels: Applicability[] = ["safe"]): { text: string; skipped: number } { + const kept: Edit[] = []; + let skipped = 0; + for (const fix of diags.flatMap((d) => d.fixes).filter((f) => levels.includes(f.applicability))) { + const fresh = fix.edits.filter((e) => e.spn.file === file + && !kept.some((k) => k.spn.beg === e.spn.beg && k.spn.end === e.spn.end && k.text === e.text)); + if (fresh.some((e) => kept.some((k) => clash(k, e)))) { + skipped += 1; + } else { + kept.push(...fresh); + } + } + return { text: apply(file.str, kept), skipped }; } // bend's own error layout; the head names the severity and the code, and -// each fix follows as a unified diff. +// each fix follows as a unified diff of the lines it touches. export function render(d: Diag): string { const err = isErr(d.core) ? d.core : Bend.Err(d.bok ?? Bend.book_nil(), d.ctx ?? Bend.ctx_nil(), d.message, undefined, d.spn, d.def); const fixes = d.fixes.map((fix) => "\n\nFix: " + fix.title + " [" + fix.applicability + "]" + [...new Set(fix.edits.map((e) => e.spn.file))].map((file) => { - const old = file.str.match(LINE) ?? []; - const now = edit(file, fix.edits).match(LINE) ?? []; - const pre = common(old, now); - const suf = common(old.slice(pre).reverse(), now.slice(pre).reverse()); - const gone = old.slice(pre, old.length - suf); - const came = now.slice(pre, now.length - suf); + const mine = fix.edits.filter((e) => e.spn.file === file); + const ls = starts(file); + const first = line(ls, Math.min(...mine.map((e) => e.spn.beg))); + const from = ls[first]; + const to = ls[line(ls, Math.max(...mine.map((e) => e.spn.end))) + 1] ?? file.str.length; + const old = file.str.slice(from, to); + const gone = old.match(LINE) ?? []; + const came = apply(old, mine.map((e) => ({ ...e, spn: { ...e.spn, beg: e.spn.beg - from, end: e.spn.end - from } }))).match(LINE) ?? []; const name = (file as SourceFile).path; - const range = (n: number): string => (n === 0 ? pre : pre + 1) + "," + n; + const range = (n: number): string => (n === 0 ? first : first + 1) + "," + n; const show = (xs: string[], sign: string): string[] => xs.map((l) => sign + l.replace(/\r?\n$/, "") + (l.endsWith("\n") ? "" : "\n\\ No newline at end of file")); return "\n--- " + name + "\n+++ " + name + "\n@@ -" + range(gone.length) + " +" + range(came.length) + " @@\n" @@ -455,6 +498,16 @@ export function render(d: Diag): string { return Bend.err_show(err).replace(/^Error:/, HEAD[d.severity] + " [" + d.code + "]:") + fixes.join(""); } +// The LSP range of a span in a file on disk. +export function position(spn: Span): { start: Position; end: Position } { + const ss = starts(spn.file); + const at = (off: number): Position => { + const i = line(ss, off); + return { line: i, character: off - ss[i] }; + }; + return { start: at(spn.beg), end: at(spn.end) }; +} + // A rule written in Bend: a file built on ./lint.bend (see there). It is // checked and compiled once, as comp.ts io_run does; each run calls its // main with bend's own IO runtime, while lint.js reaches bend-lint through @@ -571,12 +624,20 @@ export async function bendRule(file: string): Promise { // them, then checks what the wrappers record for `x` in a tiny program. async function instrument(given: string | undefined): Promise<{ Bend: Bend; Comp: Comp; BEND2: string }> { const dir = bendDir(given); - const patched = new Map(["bend.ts", "comp.ts"].map((f) => [f, patch(f, fs.readFileSync(path.join(dir, f), "utf8"))])); - const filter = new RegExp("[\\\\/]" + path.basename(dir).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[\\\\/](bend|comp)\\.ts$"); + // Bun.plugin is process-wide: match this checkout's two files by full path. + const patched = new Map(["bend.ts", "comp.ts"].map((f) => [path.join(dir, f), patch(f, fs.readFileSync(path.join(dir, f), "utf8"))])); + const exact = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll("/", "[\\\\/]"); + const filter = new RegExp("^" + exact + "[\\\\/](bend|comp)\\.ts$", process.platform === "win32" ? "i" : ""); Bun.plugin({ name: "bend-lint", setup: (build) => { - build.onLoad({ filter }, (args) => ({ contents: patched.get(path.basename(args.path))!, loader: "ts" })); + build.onLoad({ filter }, (args) => { + const contents = patched.get(fs.realpathSync(args.path)); + if (contents === undefined) { + throw new DriftError("bend-lint matched " + args.path + " but did not patch it"); + } + return { contents, loader: "ts" }; + }); }, }); const [B, C]: [Bend & { [MARK]?: number }, Comp & { [MARK]?: number }] = await Promise.all([ @@ -636,9 +697,8 @@ async function cli(argv: string[]): Promise { return rules; })); const res = await lint(positionals[0], modules.flat(), { config: values.config === undefined ? undefined : readConfig(values.config) }); - const fixed = values.fix && res.ok - ? res.sources.filter((s) => !s.base).map((s) => ({ s, text: applyFixes(s.file, res.diags) })).filter(({ s, text }) => text !== s.text) - : []; + const root = res.sources.find((s) => s.root); + const fixed = values.fix && res.ok && root !== undefined ? applyFixes(root.file, res.diags) : undefined; const where = (spn: Span) => ({ path: (spn.file as SourceFile).path, range: position(spn) }); console.log(values.json ? JSON.stringify({ @@ -649,9 +709,12 @@ async function cli(argv: string[]): Promise { })), }, null, 2) : [...res.diags.map(render), res.ok ? "bend-lint: " + res.diags.length + " finding(s)" : "bend-lint: FAIL"].join("\n\n")); - for (const { s, text } of fixed) { - fs.writeFileSync(s.path, text); - console.error("bend-lint: fixed " + s.path); + if (root !== undefined && fixed !== undefined && fixed.text !== root.text) { + fs.writeFileSync(root.path, fixed.text); + console.error("bend-lint: fixed " + root.path); + } + if (fixed !== undefined && fixed.skipped > 0) { + console.error("bend-lint: skipped " + fixed.skipped + " fix(es) that clash with earlier ones; run --fix again to apply them"); } return res.ok ? 0 : 1; } diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index 2a4dde7b94..0ee6295567 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -20,9 +20,9 @@ import type * as BendModule from "bend2/bend.ts"; // Types // ===== -// A file's patch: exact edits, then a tail appended to the file, which may -// export only names the file declares (`needs`). -type Patch = { edits: Array<[string, string]>; tail: string; needs: string[] }; +// A file's patch: exact edits, a tail appended to the file, and names it +// must declare once and export (the tail exports those it does not). +type Patch = { edits: Array<[string, string]>; tail: string; exports: string[] }; // A book whose checker reports to `see`: for each checked term, what it was // checked or inferred as, where, and how it was used. @@ -54,9 +54,9 @@ const PATCHES: Record = { tail: "import { seeInfer, seeCheck } from " + SHIM + ";\n" + "export const term_infer = seeInfer(unseen_term_infer);\n" + "export const term_check = seeCheck(unseen_term_check);\n", - needs: [], + exports: [], }, - "comp.ts": { edits: [], tail: "export { RUNTIME_MAIN, js_sat };\n", needs: ["RUNTIME_MAIN", "js_sat"] }, + "comp.ts": { edits: [], tail: "", exports: ["RUNTIME_MAIN", "js_sat"] }, }; // fs and path for bend.ts. @@ -120,7 +120,7 @@ function arity(f: (...args: never[]) => unknown, n: number, name: string): void // `file` is "bend.ts" or "comp.ts". export function patch(file: string, src: string): string { - const { edits, tail, needs } = PATCHES[file]; + const { edits, tail, exports } = PATCHES[file]; const drift = (what: string, n: number): never => { throw new DriftError("cannot patch bend2/" + file + ": found " + n + " of " + what + ", expected 1. Update PATCHES in tools/bend-lint/src/patch.ts."); @@ -129,11 +129,12 @@ export function patch(file: string, src: string): string { const n = out.split(at).length - 1; return n === 1 ? out.replace(at, () => to) : drift(JSON.stringify(at), n); }, src); - for (const name of needs) { + const missing = exports.filter((name) => { const n = edited.match(new RegExp("^(?:export )?(?:function|const|let) " + name + "\\b", "gm"))?.length ?? 0; - if (n !== 1) drift("a declaration of " + name, n); - } - return edited + "\n" + tail + "export const " + MARK + " = 1;\n"; + return n === 1 ? !new RegExp("^export (?:function|const|let) " + name + "\\b", "m").test(edited) : drift("a declaration of " + name, n); + }); + return edited + "\n" + tail + (missing.length === 0 ? "" : "export { " + missing.join(", ") + " };\n") + + "export const " + MARK + " = 1;\n"; } // The bend2 folder to load: `given` (from --bend), else $BEND_DIR, else diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 301dda4a39..17037a6d74 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -405,6 +405,10 @@ const echo: LintRule = { // Functions // ========= +function source2(p: string, ns: string): Source { + return { path: p, ns, text: "x\n", root: false, base: false, file: { str: "x\n", ns, al: {}, path: p } }; +} + function fixture(name: string, text: string): string { const file = path.join(DIR, name); fs.writeFileSync(file, text); @@ -634,6 +638,12 @@ describe("lint", () => { test("a bad rule, a rule that throws, and an abort reach the caller", async () => { await expect(lint(userland, [{ id: "bad", run: () => [] }])).rejects.toThrow(/invalid rule/); + await expect(lint(userland, [undefined as unknown as LintRule])).rejects.toThrow(/invalid rule at 0/); + const broken: LintRule = { id: "test/broken-fix", run: (cx) => { + const spn = { file: cx.root.file, beg: 0, end: 4 }; + return [cx.diag({ message: "b", fixes: [{ title: "two", applicability: "safe", edits: [{ spn, text: "a" }, { spn, text: "b" }] }] })]; + } }; + await expect(lint(userland, [broken])).rejects.toThrow(/fix "two" has an edit out of bounds, or two that clash/); await expect(lint(userland, [{ id: "test/boom", run: async () => { throw new Error("rule failed"); } }])).rejects.toThrow("rule failed"); const controller = new AbortController(); const abort: LintRule = { id: "test/abort", run: async () => { controller.abort(); return []; } }; @@ -763,12 +773,80 @@ describe("options", () => { }); }); +describe("review fixes", () => { + test("facts come only from the linted file: none from Base, with or without seeding", async () => { + for (const [name, head] of [["base_comment.bend", "import Base # comment"], ["base_plain.bend", "import Base"]]) { + const file = fixture(name, head + "\n\ntype P is Data:\n P{n: Nat}\n\ndef main() -> Nat:\n 1n\n"); + const where: LintRule = { + id: "test/where", + needsTypes: true, + run: (cx) => { + const facts = [...cx.facts!.values()]; + expect(facts.length).toBeGreaterThan(0); + expect(facts.every((f) => f.spn?.file === cx.root.file)).toBe(true); + return []; + }, + }; + expect((await lint(file, [where])).ok).toBe(true); + } + }); + + test("identical files map by namespace, not only by text", async () => { + const dir = path.join(DIR, "twins"); + fs.mkdirSync(dir, { recursive: true }); + for (const f of ["a.bend", "b.bend"]) fs.writeFileSync(path.join(dir, f), "def one() -> Type:\n Type\n"); + fs.writeFileSync(path.join(dir, "m.bend"), "import ./a.bend as A\nimport ./b.bend as B\n\ndef main() -> Type:\n A.one()\n"); + const all: LintRule = { id: "test/all", needsTypes: true, run: (cx) => [...cx.facts!.values()].map((f) => cx.diag({ message: "f", spn: f.spn })) }; + const res = await lint(path.join(dir, "m.bend"), [all]); + expect(res.ok).toBe(true); + expect(res.diags.length).toBeGreaterThan(0); + const a = source2("/p/a.bend", "a"), b = source2("/p/b.bend", "b"); + expect(mapper([a, b])({ file: { str: "x\n", ns: "b", dir: "/p/", al: {} }, beg: 0, end: 1 } as Span).file).toBe(b.file); + }); + + test("walk is linear in a long list (it took 408 ms at 2,000 elements)", async () => { + const n = 2000; + const file = fixture("long_list.bend", "import Base\n\ndef xs() -> List<&2, U32>:\n [" + Array.from({ length: n }, (_, i) => i).join(", ") + "]\n"); + const res = await lint(file, []); + expect(res.diags.map(render)).toEqual([]); + const t = performance.now(); + const count = [...walk((res.book.tlds.xs as { e: LTerm }).e)].length; + expect(count).toBeGreaterThan(n); + expect(performance.now() - t).toBeLessThan(100); + }); + + test("clashing fixes are skipped, not fatal; equal ones merge", () => { + const file: SourceFile = { str: "abcdef", ns: "", al: {}, path: "" }; + const fix = (beg: number, end: number, text: string): Diag => ({ code: "t/f", severity: "hint", message: "", + fixes: [{ title: "f", applicability: "safe", edits: [{ spn: { file, beg, end }, text }] }] }); + expect(applyFixes(file, [fix(1, 1, " "), fix(1, 1, " "), fix(3, 5, "X"), fix(4, 6, "Y"), fix(1, 1, "-")])) + .toEqual({ text: "a bcXf", skipped: 2 }); + }); + + test("comp.ts that already exports js_sat still loads", () => { + const comp = fs.readFileSync(path.join(BEND2, "comp.ts"), "utf8").replace("\nfunction js_sat(", "\nexport function js_sat("); + const out = patch("comp.ts", comp); + expect(out).toContain("export { RUNTIME_MAIN };"); + expect(() => new Bun.Transpiler({ loader: "ts" }).transformSync(out)).not.toThrow(); + }); + + test("another checkout's bend.ts loads as its own code", () => { + const other = path.join(DIR, "other"); + fs.cpSync(BEND2, path.join(other, "bend2"), { recursive: true }); + fs.appendFileSync(path.join(other, "bend2", "bend.ts"), "\nexport const OTHER_CHECKOUT = 1;\n"); + const script = fixture("other.ts", "import " + JSON.stringify(CLI) + ";\nconst o = await import(" + JSON.stringify(path.join(other, "bend2", "bend.ts")) + ");\n" + + "console.log(JSON.stringify([o.OTHER_CHECKOUT, o.BEND_LINT_PATCH ?? null]));\n"); + const out = spawnSync(process.execPath, [script], { encoding: "utf8" }); + expect(JSON.parse(out.stdout.trim().split("\n").at(-1)!)).toEqual([1, null]); + }); +}); + describe("rules", () => { test("erasure: four redundant annotations; the fix keeps every body", async () => { const file = fixture("erasure.bend", ERASURE); const res = await lint(file, [redundantAnnotation]); expect(res.diags.map((d) => d.def).sort()).toEqual(["alias", "dependent", "direct", "generic"]); - const cleaned = applyFixes(root(res.sources).file, res.diags, ["suggested"]); + const cleaned = applyFixes(root(res.sources).file, res.diags, ["suggested"]).text; expect(cleaned).toContain("f : N -> N = y => y"); expect(cleaned).toContain("value : N = Z{}"); const after = await lint(fixture("erasure_fixed.bend", cleaned), [redundantAnnotation]); @@ -788,17 +866,17 @@ describe("rules", () => { const file: SourceFile = { str: source, ns: "", al: {}, path: "" }; const diags = spacingEdits(file).map((edit): Diag => ({ code: spacing.id, severity: "hint", message: "", fixes: [{ title: "", applicability: "safe", edits: [edit] }] })); - const formatted = applyFixes(file, diags); + const formatted = applyFixes(file, diags).text; expect(formatted).toBe(source.replace(/^([abcde])=/gm, "$1 =")); expect(tokens(formatted).map((t) => t.text)).toEqual(tokens(source).map((t) => t.text)); - expect(() => applyFixes(file, [diags[0], diags[0]])).toThrow(/overlap/); + expect(applyFixes(file, [diags[0], diags[0]])).toEqual({ text: applyFixes(file, [diags[0]]).text, skipped: 0 }); }); test("format: one rule gives editor findings and formatter edits", async () => { const res = await lint(fixture("format.bend", FORMAT), [spacing]); expect(res.facts).toBeUndefined(); expect(res.diags.length).toBe(8); - const formatted = applyFixes(root(res.sources).file, res.diags); + const formatted = applyFixes(root(res.sources).file, res.diags).text; for (const line of ["def choose(x: Sample, y: Sample)", "a : Sample = SampleValue{} # preserve,this=comment", "b : Sample = SampleValue{}", "f : Sample -> Sample = y=>y", "f(choose(a, b))"]) { expect(formatted).toContain(line); @@ -829,7 +907,7 @@ describe("rules written in Bend", () => { const res = await lint(userland, [rule]); expect(res.diags.map((d) => [d.code, d.severity])).toEqual([["style/comma-space", "warning"]]); expect(render(res.diags[0])).toContain("generic(~N, a)"); - expect(applyFixes(root(res.sources).file, res.diags)).toContain("generic(~N, a)"); + expect(applyFixes(root(res.sources).file, res.diags).text).toContain("generic(~N, a)"); }); test("a typed rule asks the checker through effects", async () => { @@ -920,6 +998,25 @@ describe("cli", () => { expect(JSON.parse(out.stdout).findings.map((f: { severity: string; message: string }) => [f.severity, f.message])).toEqual([["hint", "w6"]]); }); + test("--fix writes only the linted file, and prints findings when fixes clash", () => { + const dir = path.join(DIR, "fixroot"); + fs.mkdirSync(dir, { recursive: true }); + const dep = path.join(dir, "dep.bend"); + fs.writeFileSync(dep, "def one() -> Type:\n Type\n"); + const main = path.join(dir, "main.bend"); + fs.writeFileSync(main, "import ./dep.bend as D\n\ndef main() -> Type:\n D.one()\n"); + const everywhere = module("everywhere.js", `[{ id: "test/everywhere", run: (cx) => cx.sources.filter((s) => !s.base).flatMap((s) => [0, 0, 1].map((n) => { + const spn = { file: s.file, beg: n, end: n }; + return cx.diag({ message: "x", spn, fixes: [{ title: "x", applicability: "safe", edits: [{ spn, text: n === 0 ? "#" : "!" }] }] }); + })) }]`); + const out = run(main, "--rules", everywhere, "--fix"); + expect(out.status).toBe(0); + expect(out.stdout).toContain("Warning [test/everywhere]:"); + expect(fs.readFileSync(dep, "utf8")).toBe("def one() -> Type:\n Type\n"); + expect(fs.readFileSync(main, "utf8").startsWith("#i!mport")).toBe(true); + expect(out.stderr).not.toContain("skipped"); + }); + test("--fix applies safe fixes", () => { const target = fixture("fix.bend", USERLAND); const comma = module("comma.js", `[{ id: "style/comma-space", run: (cx) => From 439332334363c231656573124d6c3cbb32974c74 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 14:30:27 -0300 Subject: [PATCH 21/39] bend-lint: link to its own repo in the README Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 5cecc571e6..ca73e1f386 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -5,6 +5,9 @@ on it. A rule reads the source text, the checker's result for each term (type, scope, uses), or both, and returns findings with fixes. Bend itself does not change. It needs Bun 1.2 or newer. +Tools will eventually live in their own repos, so bend-lint is maintained +separately at [github.com/MattCozendey/bend-lint](https://github.com/MattCozendey/bend-lint). + ## Run From the repo root: From b8e01787c0bc9c62f8eb614dfac6922329c67ca5 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 14:34:56 -0300 Subject: [PATCH 22/39] gates: run tools/bend-lint's suite in the test gate 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- gates/test.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/gates/test.ts b/gates/test.ts index 5560a7cef9..509c5d975d 100644 --- a/gates/test.ts +++ b/gates/test.ts @@ -10,8 +10,11 @@ // run, its JS run and its C run all print its `#|` lines; a test whose main // the compiler refuses to print (a function, a Type, an erased or dependent // field) is checked and interpreted only; a foreign def with no twin for a -// lane drops that lane; any other build failure fails its lanes. +// lane drops that lane; any other build failure fails its lanes. Meanwhile, +// tools/bend-lint's own suite runs here, beside the cluster, as one test: it +// patches bend.ts in memory, so it is the first to see a change it relies on. +import * as child from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -195,6 +198,21 @@ async function shard_run(shard: Test[], pack: Buffer, tag: number, } } +// bend-lint +// ========= + +// `bun test tools/bend-lint` (about 3 s): its failure, or null. +function lint_run(): Promise { + return new Promise((done) => { + const kid = child.spawn(BUN, ["test", "tools/bend-lint"], { cwd: lib.ROOT, stdio: ["ignore", "pipe", "pipe"] }); + let out = ""; + kid.stdout.on("data", (d: Buffer) => { out += d.toString(); }); + kid.stderr.on("data", (d: Buffer) => { out += d.toString(); }); + kid.on("close", (code) => done(code === 0 ? null + : { name: "tools/bend-lint", probe: "bun test", want: "exit 0", got: out.trim().split("\n").slice(-20).join("\n") })); + }); +} + // Main // ==== @@ -208,8 +226,10 @@ if (import.meta.main) { const pack = lib.pack(TESTS); const shards = shard_split(tests, nodes.length); const fails: Fail[] = []; + const lint = lint_run(); await lib.node_pool(nodes, shards.map((shard, tag) => (node: number) => shard_run(shard, pack, tag, node, fails))); + fails.push(...[await lint].filter((f): f is Fail => f !== null)); fails.sort((a, b) => a.name < b.name ? -1 : 1); if (!lib.GATE) { for (const f of fails) { @@ -219,5 +239,5 @@ if (import.meta.main) { } } const bad = new Set(fails.map((f) => f.name)); - lib.verdict(tests.length - bad.size, tests.length); + lib.verdict(tests.length + 1 - bad.size, tests.length + 1); } From cfad2d235a5de28049da4defadeea170e6ba59c1 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 15:13:04 -0300 Subject: [PATCH 23/39] bend-lint: rules/ folder, and --fix-suggested / --fix-dangerously 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- gates/repo.ts | 1 + tools/bend-lint/README.md | 24 ++++++- tools/bend-lint/rules/line_length.bend | 45 +++++++++++++ tools/bend-lint/rules/shared.bend | 69 ++++++++++++++++++++ tools/bend-lint/rules/shared.ts | 20 ++++++ tools/bend-lint/rules/trailing_whitespace.ts | 21 ++++++ tools/bend-lint/src/lint.ts | 16 ++++- tools/bend-lint/test/lint.test.ts | 43 ++++++++++++ tools/bend-lint/tsconfig.json | 2 +- 9 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 tools/bend-lint/rules/line_length.bend create mode 100644 tools/bend-lint/rules/shared.bend create mode 100644 tools/bend-lint/rules/shared.ts create mode 100644 tools/bend-lint/rules/trailing_whitespace.ts diff --git a/gates/repo.ts b/gates/repo.ts index 9b100ad28e..651235b21d 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -87,6 +87,7 @@ allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json)$/); allow(/^tools\/bend-lint\/src\/(lint\.ts|patch\.ts|lint\.bend|lint\.js)$/); allow("tools/bend-lint/test/lint.test.ts"); +allow(/^tools\/bend-lint\/rules\/[a-z_]+\.(ts|bend)$/); // Gate // ==== diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index ca73e1f386..bb6628b5d9 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -13,14 +13,16 @@ separately at [github.com/MattCozendey/bend-lint](https://github.com/MattCozende From the repo root: ```sh -bun tools/bend-lint/src/lint.ts file.bend [--rules rules.ts]... [--rules rule.bend]... [--config bend-lint.json] [--fix] [--json] [--bend ] +bun tools/bend-lint/src/lint.ts file.bend [--rules rules.ts]... [--rules rule.bend]... [--config bend-lint.json] [--fix | --fix-suggested | --fix-dangerously] [--json] [--bend ] ``` Findings print in bend's error layout, with a severity, a code, and each fix as a diff. `--fix` writes the `safe` fixes to the linted file only, never to its imports (a BendHub package under `~/.bend/lib` is never edited). Equal edits merge; a fix that clashes with an earlier one is skipped and counted, -and another `--fix` run applies it. Exit codes: 0 no error, 1 an error (the +and another run applies it. `--fix-suggested` also writes the `suggested` +fixes, which may change behavior; `--fix-dangerously` writes every fix, even +one that may break code. Exit codes: 0 no error, 1 an error (the file does not check, or a rule found an `error`), 2 bad usage or a tool failure. "Does not check" means bend's checker rejects it: unlike `bend --check-only`, bend-lint does not fail a file for relying on @@ -136,6 +138,24 @@ A rule runs on bend's JS runtime, where only tail calls run as loops: walk a long text or list with a tail call, or the stack overflows. `COMMA_BEND` and `TYPES_BEND` in `test/lint.test.ts` are complete examples. +## Rules shipped here + +`rules/` holds ready rules, one per file. None runs unless you pass it with +`--rules`, and `bend-lint.json` can turn one `"off"`: + +| File | Rule | What | Fix | +| --- | --- | --- | --- | +| `trailing_whitespace.ts` | `style/trailing-whitespace` | spaces or tabs at the end of a line | safe: delete them | +| `line_length.bend` | `style/line-length` | a line longer than `max` characters (default 100) | none | + +```sh +bun tools/bend-lint/src/lint.ts file.bend --rules tools/bend-lint/rules/trailing_whitespace.ts --rules tools/bend-lint/rules/line_length.bend +``` + +To add a rule, add one file: a `.ts` file that exports `rules`, or a `.bend` +file as above. Helpers used by more than one rule go in `rules/shared.ts` +or `rules/shared.bend`. + ## From code ```ts diff --git a/tools/bend-lint/rules/line_length.bend b/tools/bend-lint/rules/line_length.bend new file mode 100644 index 0000000000..156163db5b --- /dev/null +++ b/tools/bend-lint/rules/line_length.bend @@ -0,0 +1,45 @@ +# style/line-length: a line longer than the option `max` (100 by default), +# in characters. The finding covers the part past the limit. No fix. +import Base +import ../src/lint.bend as Lint +import ./shared.bend as Shared + +def id() -> String: + "style/line-length" + +def types() -> Bool: + False{} + +def diag(path: String, +max: U32, +beg: U32, +len: U32) -> Lint.Diag: + Lint.Diag{Lint.Warning{}, "Keep the line to " ++ U32.show(max) ++ " characters; it has " ++ U32.show(len) ++ ".", Some{Lint.Span{path, U32.add(beg, max), U32.add(beg, len)}}, []} + +def keep(long: Bool, +path: String, +max: U32, +beg: U32, +len: U32, acc: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: + match long: + case True{}: + diag(path, max, beg, len) <> acc + case False{}: + acc + +# The lines come last first, so adding each finding to the front puts them +# in order. A tail call. +def scan(+path: String, +max: U32, lines: List<&2, Shared.Line>, acc: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: + match lines: + case Nil{}: + acc + case Con{Shared.Line{+beg, +len}, rest}: + scan(path, max, rest, keep(U32.is_gt(len, max), path, max, beg, len, acc)) + +def check(+max: U32, src: Maybe<&2, Lint.Source>) -> List<&2, Lint.Diag>: + match src: + case None{}: + [] + case Some{Lint.Source{path, text, root}}: + scan(path, max, Shared.lines(text), []) + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + match input: + case Lint.Input{sources, facts, options}: + IO.pure(List<&2, Lint.Diag>, check(Lint.option_number(options, "max", 100), Shared.root(sources))) + +def main() -> IO(Unit): + Lint.serve(run) diff --git a/tools/bend-lint/rules/shared.bend b/tools/bend-lint/rules/shared.bend new file mode 100644 index 0000000000..f4eb974854 --- /dev/null +++ b/tools/bend-lint/rules/shared.bend @@ -0,0 +1,69 @@ +# Helpers for the rules in this folder that are written in Bend. +import Base +import ../src/lint.bend as Lint + +# A line of a text: the offset of its first character, and its length +# without the line break (nor a \r before it). +type Line is Data: + Line{beg: U32, len: U32} + +def lines.beg(nl: Bool, +at: U32, beg: U32) -> U32: + match nl: + case True{}: + U32.add(at, 1) + case False{}: + beg + +def lines.len(nl: Bool, len: U32) -> U32: + match nl: + case True{}: + 0 + case False{}: + U32.add(len, 1) + +def lines.cut(cr: Bool, len: U32) -> U32: + match cr: + case True{}: + U32.sub(len, 1) + case False{}: + len + +def lines.push(nl: Bool, beg: U32, len: U32, cr: Bool, acc: List<&2, Line>) -> List<&2, Line>: + match nl: + case True{}: + Line{beg, lines.cut(cr, len)} <> acc + case False{}: + acc + +# A tail call: bend runs it as a loop, so a long text does not overflow the +# stack. `cr` says the last character was a \r. +def lines.go(s: String, +at: U32, +beg: U32, +len: U32, cr: Bool, acc: List<&2, Line>) -> List<&2, Line>: + match s: + case SNil{}: + Line{beg, len} <> acc + case SCon{+c, rest}: + lines.go(rest, U32.add(at, 1), lines.beg(Char.is_eq(c, '\n'), at, beg), lines.len(Char.is_eq(c, '\n'), len), Char.is_eq(c, '\r'), lines.push(Char.is_eq(c, '\n'), beg, len, cr, acc)) + +# The lines of a text, the last one first. +def lines(s: String) -> List<&2, Line>: + lines.go(s, 0, 0, 0, False{}, []) + +def root_or(root: Bool, s: Lint.Source, later: Maybe<&2, Lint.Source>) -> Maybe<&2, Lint.Source>: + match root: + case True{}: + Some{s} + case False{}: + later + +def is_root(s: Lint.Source) -> Bool: + match s: + case Lint.Source{path, text, root}: + root + +# The source of the file linted. +def root(srcs: List<&2, Lint.Source>) -> Maybe<&2, Lint.Source>: + match srcs: + case Nil{}: + None{} + case Con{+s, rest}: + root_or(is_root(s), s, root(rest)) diff --git a/tools/bend-lint/rules/shared.ts b/tools/bend-lint/rules/shared.ts new file mode 100644 index 0000000000..eb4c22363d --- /dev/null +++ b/tools/bend-lint/rules/shared.ts @@ -0,0 +1,20 @@ +// Helpers for the rules in this folder that are written in TypeScript. + +import type { SourceFile } from "../src/lint.ts"; + +// Types +// ===== + +// A line of a file: its text without the line break (nor a \r before it), +// and the offsets where that text begins and ends. +export type Line = { text: string; beg: number; end: number }; + +// Functions +// ========= + +export function lines(file: SourceFile): Line[] { + return file.str.split("\n").reduce<{ at: number; out: Line[] }>(({ at, out }, raw) => { + const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw; + return { at: at + raw.length + 1, out: [...out, { text, beg: at, end: at + text.length }] }; + }, { at: 0, out: [] }).out; +} diff --git a/tools/bend-lint/rules/trailing_whitespace.ts b/tools/bend-lint/rules/trailing_whitespace.ts new file mode 100644 index 0000000000..0c44be30b8 --- /dev/null +++ b/tools/bend-lint/rules/trailing_whitespace.ts @@ -0,0 +1,21 @@ +// style/trailing-whitespace: spaces or tabs at the end of a line. The fix +// deletes them, so it is safe. + +import type { LintRule } from "../src/lint.ts"; +import { lines } from "./shared.ts"; + +// Constants +// ========= + +export const rules: LintRule[] = [{ + id: "style/trailing-whitespace", + run: (cx) => lines(cx.root.file).flatMap(({ text, end }) => { + const width = text.length - text.trimEnd().length; + const spn = { file: cx.root.file, beg: end - width, end }; + return width === 0 ? [] : [cx.diag({ + message: "Remove the whitespace at the end of the line.", + severity: "warning", spn, + fixes: [{ title: "Remove trailing whitespace", applicability: "safe", edits: [{ spn, text: "" }] }], + })]; + }), +}]; diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index b0e228907a..96e7a8a32b 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -175,6 +175,8 @@ const CHILDREN: { [K in LTerm["$"]]: (tm: Extract) => LTerm[] } const OPTIONS = { rules: { type: "string", multiple: true }, fix: { type: "boolean" }, + "fix-suggested": { type: "boolean" }, + "fix-dangerously": { type: "boolean" }, json: { type: "boolean" }, config: { type: "string" }, bend: { type: "string" }, @@ -182,9 +184,16 @@ const OPTIONS = { } as const; const USAGE = [ - "usage: bun tools/bend-lint/src/lint.ts [--rules ]... [--config ] [--fix] [--json] [--bend ]", + "usage: bun tools/bend-lint/src/lint.ts [--rules ]... [--config ] [--fix | --fix-suggested | --fix-dangerously] [--json] [--bend ]", ].join("\n"); +// The fix levels each flag applies; the widest flag given wins. +const FIXES: ReadonlyArray = [ + ["fix-dangerously", ["safe", "suggested", "dangerous"]], + ["fix-suggested", ["safe", "suggested"]], + ["fix", ["safe"]], +]; + // Functions // ========= @@ -698,7 +707,8 @@ async function cli(argv: string[]): Promise { })); const res = await lint(positionals[0], modules.flat(), { config: values.config === undefined ? undefined : readConfig(values.config) }); const root = res.sources.find((s) => s.root); - const fixed = values.fix && res.ok && root !== undefined ? applyFixes(root.file, res.diags) : undefined; + const levels = FIXES.find(([flag]) => values[flag])?.[1]; + const fixed = levels !== undefined && res.ok && root !== undefined ? applyFixes(root.file, res.diags, levels) : undefined; const where = (spn: Span) => ({ path: (spn.file as SourceFile).path, range: position(spn) }); console.log(values.json ? JSON.stringify({ @@ -714,7 +724,7 @@ async function cli(argv: string[]): Promise { console.error("bend-lint: fixed " + root.path); } if (fixed !== undefined && fixed.skipped > 0) { - console.error("bend-lint: skipped " + fixed.skipped + " fix(es) that clash with earlier ones; run --fix again to apply them"); + console.error("bend-lint: skipped " + fixed.skipped + " fix(es) that clash with earlier ones; run the fix again to apply them"); } return res.ok ? 0 : 1; } diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 17037a6d74..fe2033e823 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import type { Book, LTerm, Span } from "bend2/bend.ts"; import { BEND2, Bend, Comp, applyFixes, bendRule, findConfig, lint, mapper, render, walk } from "../src/lint.ts"; import type { Diag, Edit, Fact, LintRule, RuleContext, Source, SourceFile } from "../src/lint.ts"; +import { rules as trailing } from "../rules/trailing_whitespace.ts"; import { DriftError, bendDir, patch, relative, resolve, seeCheck, seeInfer } from "../src/patch.ts"; // Types @@ -118,6 +119,11 @@ const DRIFT = [ const TMP = fileURLToPath(new URL("./.tmp", import.meta.url)); const DIR = (fs.mkdirSync(TMP, { recursive: true }), fs.mkdtempSync(path.join(TMP, "run-"))); const CLI = fileURLToPath(new URL("../src/lint.ts", import.meta.url)); +const LINE_LENGTH = fileURLToPath(new URL("../rules/line_length.bend", import.meta.url)); + +// A file for the rules in rules/: trailing spaces and tabs, CRLF, and +// lines of 100, 101 and 103 characters (the last two end in a \r). +const STYLE = "import Base\n\ndef main() -> U32: \r\n 1\t\n#" + "x".repeat(99) + "\n#" + "y".repeat(100) + "\r\n#" + "z".repeat(102) + "\r\n"; // The comma-space rule, written in Bend: source text only. const COMMA_BEND = String.raw`import Base @@ -919,6 +925,30 @@ describe("rules written in Bend", () => { }); }); +describe("the rules in rules/", () => { + test("style/trailing-whitespace finds spaces and tabs, and its fix keeps the line breaks", async () => { + const res = await lint(fixture("trailing.bend", STYLE), trailing); + const found = res.diags.map((d) => d.spn!.file.str.slice(d.spn!.beg, d.spn!.end)); + expect([res.ok, found]).toEqual([true, [" ", "\t"]]); + expect(res.diags.every((d) => d.code === "style/trailing-whitespace" && d.fixes[0].applicability === "safe")).toBe(true); + expect(applyFixes(root(res.sources).file, res.diags).text).toBe(STYLE.replace("U32: \r\n", "U32:\r\n").replace("1\t", "1")); + }); + + test("style/line-length finds lines past max, without the \\r, and covers the overflow", async () => { + const file = fixture("long.bend", STYLE); + const res = await lint(file, [await bendRule(LINE_LENGTH)]); + const over = (d: Diag) => d.spn!.file.str.slice(d.spn!.beg, d.spn!.end); + expect(res.diags.map((d) => [d.code, d.severity, d.message, over(d)])).toEqual([ + ["style/line-length", "warning", "Keep the line to 100 characters; it has 101.", "y"], + ["style/line-length", "warning", "Keep the line to 100 characters; it has 103.", "zzz"], + ]); + const narrow = await lint(file, [await bendRule(LINE_LENGTH)], { config: { rules: { "style/line-length": { max: 18 } } } }); + expect(narrow.diags.map(over)).toEqual([" ", "x".repeat(82), "y".repeat(83), "z".repeat(85)]); + const off = await lint(file, [await bendRule(LINE_LENGTH)], { config: { rules: { "style/line-length": "off" } } }); + expect(off.diags).toEqual([]); + }); +}); + describe("cli", () => { const input = fixture("cli.bend", USERLAND); const first = module("first.js", `[{ id: "cli/first", needsTypes: true, run(cx) { @@ -1017,6 +1047,19 @@ describe("cli", () => { expect(out.stderr).not.toContain("skipped"); }); + test("--fix-suggested and --fix-dangerously apply wider levels", () => { + const levels = module("levels.js", `[{ id: "test/levels", run: (cx) => ["safe", "suggested", "dangerous"].map((applicability, n) => { + const spn = { file: cx.root.file, beg: n, end: n }; + return cx.diag({ message: applicability, spn, fixes: [{ title: applicability, applicability, edits: [{ spn, text: String(n) }] }] }); + }) }]`); + const fixed = (flag: string) => { + const target = fixture("levels" + flag + ".bend", "#abc\n"); + expect(run(target, "--rules", levels, flag).status).toBe(0); + return fs.readFileSync(target, "utf8"); + }; + expect(["--fix", "--fix-suggested", "--fix-dangerously"].map(fixed)).toEqual(["0#abc\n", "0#1abc\n", "0#1a2bc\n"]); + }); + test("--fix applies safe fixes", () => { const target = fixture("fix.bend", USERLAND); const comma = module("comma.js", `[{ id: "style/comma-space", run: (cx) => diff --git a/tools/bend-lint/tsconfig.json b/tools/bend-lint/tsconfig.json index 02cd26864e..374ff85ad6 100644 --- a/tools/bend-lint/tsconfig.json +++ b/tools/bend-lint/tsconfig.json @@ -11,5 +11,5 @@ "typeRoots": ["./node_modules/@types", "../../bend2/pack/node_modules/@types"], "paths": { "bend2/*": ["../../bend2/*"] } }, - "include": ["src", "test"] + "include": ["src", "test", "rules"] } From 1ff393148ea407ac24883048d35b617977bfa87f Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 16:11:35 -0300 Subject: [PATCH 24/39] bend-lint: rules ask for the facts they need 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 64 ++++++++--- tools/bend-lint/rules/line_length.bend | 6 +- tools/bend-lint/src/lint.bend | 46 +++++++- tools/bend-lint/src/lint.js | 7 +- tools/bend-lint/src/lint.ts | 122 +++++++++++++++----- tools/bend-lint/test/lint.test.ts | 149 +++++++++++++++++++------ 6 files changed, 306 insertions(+), 88 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index bb6628b5d9..83ff2499ad 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -43,8 +43,8 @@ its `bend2` folder. Inside the bend repo, its own bend is the default. ## Rules in TypeScript A rule module exports `rules`. A rule has an `id` (`namespace/name`, also -the code of its findings), an optional `needsTypes`, and a `run` that may be -`async`: +the code of its findings), an optional `facts` (see [Facts](#facts)), and a +`run` that may be `async`: ```ts import type { LintRule } from "./tools/bend-lint/src/lint.ts"; @@ -60,9 +60,9 @@ export const rules: LintRule[] = [{ }), }, { id: "demo/var-types", - needsTypes: true, + facts: { kinds: ["Var"] }, run: (cx) => [...cx.facts!.values()] - .filter((f) => !f.inst && f.spn?.file === cx.root.file && cx.Bend.term_strip(f.tm).$ === "Var") + .filter((f) => !f.inst) .map((f) => cx.diag({ message: "type: " + cx.show(f, f.ty), severity: "hint", spn: f.spn, fact: f })), }]; ``` @@ -70,7 +70,7 @@ export const rules: LintRule[] = [{ | `cx` | | |---|---| | `root`, `sources` | the linted file and its imports, as on disk | -| `facts` | with `needsTypes`: per checked term of the linted file, its type, scope, depth, def, quantity, uses, and span on disk | +| `facts` | the facts the rule asked for: per checked term, its type, scope, depth, def, quantity, uses, and span on disk | | `prior` | the findings of earlier rules | | `span(s)` | a bend span (e.g. `term.s`), moved to the file on disk | | `walk`, `binder`, `show`, `same`, `normal`, `uses` | helpers over bend's terms | @@ -81,13 +81,31 @@ Severities: `error` (stops the run), `warning`, `information`, `hint`. Fix levels: `safe` keeps behavior, `suggested` may change it, `dangerous` may break code. -Rules run in order; none runs when the file does not check. Facts cover only -the linted file, so none come from Base or other imports. A template body is +Rules run in order; none runs when the file does not check. A file with a +line exactly `import Base` (as `bend --checkup` reads it) reuses a Base +checked once per process. + +## Facts + +A fact is what the checker found for one term. A rule gets facts only if it +asks with `facts`: `true` for all of the linted file's, or a filter. A fact +must match each list given; an absent or empty list matches all: + +```ts +facts: { + scope: "file", // "file" (default): the linted file; "program": its imports too, never Base + kinds: ["Var"], // the term's kind, annotations stripped: Var, Ref, App, Lam, Lit, ... + defs: ["main"], // the def whose body holds the term; a template's instances count as it + names: ["foo"], // the name a Var or Ref points to +} +``` + +bend-lint keeps a fact only if some rule asks for it, and drops the rest as +the checker gives them, so a narrow filter costs little. Each rule gets only +its own. On a 3,200-proof file (1.27M facts) peak memory was 0.77 GB with no +facts, 2.6 GB with all, 1.5 GB with `kinds: ["Var"]`. A template body is checked as written and again per instance (`generic~0`) at the same spans; -`fact.inst` marks the instances. A file with a line exactly `import Base` -(as `bend --checkup` reads it) reuses a Base checked once per process. Every -checked term of the file gives a fact, so a very large file costs memory: a -rule with `needsTypes` on a 3,200-proof file holds about 1.3M facts. +`fact.inst` marks the instances. ## Options @@ -118,8 +136,8 @@ import ../tools/bend-lint/src/lint.bend as Lint def id() -> String: "demo/nothing" -def types() -> Bool: # True{} to get facts - False{} +def facts() -> Lint.Want: # Lint.NoFacts{}, or Lint.Want{scope, kinds, defs, names} + Lint.NoFacts{} def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): IO.pure(List<&2, Lint.Diag>, []) @@ -128,15 +146,29 @@ def main() -> IO(Unit): Lint.serve(run) ``` -`input` holds the sources, the rule's options and, with `types()`, the -facts. Read an option with `Lint.option_number`, `option_flag` or +`input` holds the sources and the rule's options. A rule that wants facts +returns a filter, as in [Facts](#facts), with empty lists for "all": +`Lint.Want{Lint.File{}, ["Var"], [], []}`. It then reads them one at a time, +so a big file never becomes one big list: + +```python +def step(found: List<&2, Lint.Diag>, +f: Lint.Fact) -> IO(List<&2, Lint.Diag>): + ... + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + Lint.fold_facts(~List<&2, Lint.Diag>, ~step, []) +``` + +`fold_facts` takes `step` as a template (`~step`), and `step` takes its fact +as `+f`. Read an option with `Lint.option_number`, `option_flag` or `option_text`, each with a default; numbers are whole (U32). A rule asks about a fact with effects (`Lint.view`, `type_of`, `binder`, `same`, `show`, `normal`, `uses`, `text`) and may use Base's effects too. Offsets count characters. bend-lint compiles a rule once; each run then takes about 10 ms. A rule runs on bend's JS runtime, where only tail calls run as loops: walk a long text or list with a tail call, or the stack overflows. -`COMMA_BEND` and `TYPES_BEND` in `test/lint.test.ts` are complete examples. +`COMMA_BEND`, `TYPES_BEND` and `COUNT_BEND` in `test/lint.test.ts` are +complete examples. ## Rules shipped here diff --git a/tools/bend-lint/rules/line_length.bend b/tools/bend-lint/rules/line_length.bend index 156163db5b..e4774991af 100644 --- a/tools/bend-lint/rules/line_length.bend +++ b/tools/bend-lint/rules/line_length.bend @@ -7,8 +7,8 @@ import ./shared.bend as Shared def id() -> String: "style/line-length" -def types() -> Bool: - False{} +def facts() -> Lint.Want: + Lint.NoFacts{} def diag(path: String, +max: U32, +beg: U32, +len: U32) -> Lint.Diag: Lint.Diag{Lint.Warning{}, "Keep the line to " ++ U32.show(max) ++ " characters; it has " ++ U32.show(len) ++ ".", Some{Lint.Span{path, U32.add(beg, max), U32.add(beg, len)}}, []} @@ -38,7 +38,7 @@ def check(+max: U32, src: Maybe<&2, Lint.Source>) -> List<&2, Lint.Diag>: def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): match input: - case Lint.Input{sources, facts, options}: + case Lint.Input{sources, options}: IO.pure(List<&2, Lint.Diag>, check(Lint.option_number(options, "max", 100), Shared.root(sources))) def main() -> IO(Unit): diff --git a/tools/bend-lint/src/lint.bend b/tools/bend-lint/src/lint.bend index 3efcb8618c..6d31bce7c5 100644 --- a/tools/bend-lint/src/lint.bend +++ b/tools/bend-lint/src/lint.bend @@ -2,12 +2,13 @@ # imports this file and defines: # # def id() -> String: # namespace/name, the code of its findings -# def types() -> Bool: # True{} to get the checker's facts +# def facts() -> Lint.Want: # Lint.NoFacts{}, or the facts it needs # def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): # def main() -> IO(Unit): # Lint.serve(run) # -# Offsets count characters (code points) from the start of a file. The +# A rule with facts reads them one at a time with next_fact. Offsets +# count characters (code points) from the start of a file. The # rule's options come from bend-lint.json; read them with option_number, # option_flag and option_text, each with a default. import Base @@ -33,7 +34,20 @@ type Option is Data: Option{key: String, value: Value} type Input is Data: - Input{sources: List<&2, Source>, facts: List<&2, Fact>, options: List<&2, Option>} + Input{sources: List<&2, Source>, options: List<&2, Option>} + +# File: facts of the linted file. Program: of its imports too, never Base. +type Scope is Data: + File{} + Program{} + +# The facts a rule needs. A fact must match each list; an empty list +# matches all. kinds: the term's kind, annotations stripped (Var, Ref, App, +# ...). defs: the def whose body holds the term (a template's instances +# count as the template). names: the name a Var or Ref points to. +type Want is Data: + NoFacts{} + Want{scope: Scope, kinds: List<&2, String>, defs: List<&2, String>, names: List<&2, String>} type Span is Data: Span{path: String, beg: U32, end: U32} @@ -70,9 +84,9 @@ type Use is Data: # A fact as bend-lint sees it: the def it is in, whether that def is a # template instance, the kind of its term once an annotation is stripped -# (Var, App, ...), the name a Var refers to (else ""), how many times the -# term is demanded, and the spans of the checked term and of the stripped -# one. +# (Var, App, ...), the name a Var or Ref points to (else ""), how many +# times the term is demanded, and the spans of the checked term and of the +# stripped one. type View is Data: View{owner: String, inst: Bool, kind: String, name: String, quantity: Quantity, span: Maybe<&2, Span>, inner: Maybe<&2, Span>} @@ -82,6 +96,26 @@ def input() -> IO(Input): def report(diags: List<&2, Diag>) -> IO(Unit): import "./lint.js" +# The next fact the rule asked for, or None{} when none are left. +def next_fact() -> IO(Maybe<&2, Fact>): + import "./lint.js" + +def fold_next(~A: Type, ~step: A -> @+f: Fact -> IO(A), m: Maybe<&2, Fact>, acc: A, again: A -> IO(A)) -> IO(A): + match m: + case None{}: + IO.pure(A, acc) + case Some{f}: + IO.bind(A, A, step(acc, f), again) + +# Runs step on each fact the rule asked for, in order, from acc; step is +# a def, given as ~name, that takes its fact as +f. Facts come one at a +# time, so a big file does not build one big list. +@unsafe +def fold_facts(~A: Type, ~step: A -> @+f: Fact -> IO(A), acc: A) -> IO(A): + do IO: + m : Maybe<&2, Fact> <- next_fact() + fold_next(~A, ~step, m, acc, a => fold_facts(~A, ~step, a)) + def view(fact: Fact) -> IO(View): import "./lint.js" diff --git a/tools/bend-lint/src/lint.js b/tools/bend-lint/src/lint.js index 9c390b1f32..8300179c43 100644 --- a/tools/bend-lint/src/lint.js +++ b/tools/bend-lint/src/lint.js @@ -48,11 +48,10 @@ function lint_term(id) { } function lint_input() { - const { sources, facts, options } = lint_host().input(); + const { sources, options } = lint_host().input(); return { $: CID(Input), sources: lint_list(sources.map((s) => ({ $: CID(Source), path: s.path, text: s.text, root: s.root }))), - facts: lint_list(Array.from({ length: facts }, (_, id) => ({ $: CID(Fact), id }))), options: lint_list(Object.entries(options).map(([key, v]) => ({ $: CID(Option), key, value: lint_value(v) }))), }; } @@ -81,6 +80,10 @@ function lint_view(fact) { io_eff(CID(input), lint_input); io_eff(CID(report), lint_report); +io_eff(CID(next_fact), () => { + const id = lint_host().next(); + return lint_maybe(id === undefined ? undefined : { $: CID(Fact), id }); +}); io_eff(CID(view), lint_view); io_eff(CID(type_of), (fact) => lint_term(lint_host().type(fact.id))); io_eff(CID(binder), (fact) => { diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 96e7a8a32b..f1a84ae3b6 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -56,13 +56,23 @@ export type Source = { path: string; ns: string; text: string; root: boolean; ba // `inst` marks the facts of an instance. export type Fact = { tm: LTerm; ty: HTerm; bok: Book; ctx: Ctx; dep: number; def: Name; qt: Quant; us: Uses; inst: boolean; spn?: Span }; +// The checker's facts a rule gets. A fact must match each list given; an +// absent or empty list matches all. In defs, a template's instances +// (generic~0) count as the template. +export type FactFilter = { + scope?: "file" | "program"; // file (the default): the linted file; program: its imports too, never Base + kinds?: string[]; // the term's kind, annotations stripped: Var, Ref, App, ... + defs?: Name[]; // the def whose body holds the term + names?: Name[]; // the name a Var or Ref points to +}; + export type RuleContext = { Bend: Bend; book: Book; sources: Source[]; root: Source; options: Options; // the rule's defaults, with the config's values - facts?: Map; // only for a rule with needsTypes + facts?: Map; // only for a rule with facts, and only those it asked for prior: readonly Diag[]; // what earlier rules found span: Mapper; // a bend.ts span, in the file on disk walk(tm: LTerm): Generator; @@ -85,7 +95,7 @@ export type LintOptions = { signal?: AbortSignal; config?: Config }; export type LintRule = { id: string; // namespace/name; the code of its findings - needsTypes?: boolean; + facts?: true | FactFilter; // the checker's facts it needs; true: all of the linted file's options?: Options; // defaults; without them (a Bend rule), any option is passed as given run(cx: RuleContext, signal: AbortSignal): Diag[] | Promise; }; @@ -109,7 +119,8 @@ type Reported = { // What lint.js asks of bend-lint while a Bend rule runs. Facts and types // cross as indexes into the run's tables. type Channel = { - input(): { sources: Array<{ path: string; text: string; root: boolean }>; facts: number; options: Options }; + input(): { sources: Array<{ path: string; text: string; root: boolean }>; options: Options }; + next(): number | undefined; report(diags: Reported[]): void; view(fact: number): { owner: Name; inst: boolean; kind: string; name: string; quantity: Quant["$"]; span?: Spot; inner?: Spot }; type(fact: number): number; @@ -131,6 +142,7 @@ type Checked = { book: Book; sources: Source[]; span: Mapper; facts?: Map { +// does. A fact is kept as the checker gives it only if a filter wants it; +// with scope file, only if it is from the linted file's folder and +// namespace. After the check, only facts whose span maps to the linted file +// (or, with scope program, to any file but Base) stay. +async function check(file: string, filters: FactFilter[], signal: AbortSignal): Promise { signal.throwIfAborted(); const book: Hooked = Bend.book_nil(); const seen = new Map(); const found: Array> = []; const real = fs.existsSync(file) ? fs.realpathSync(file) : ""; const home = real.slice(0, real.lastIndexOf("/") + 1); - book.see = capture + const far = filters.filter((f) => f.scope === "program"); + const program = far.length > 0; + book.see = filters.length > 0 ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => { const at = spn?.file as { dir?: string; ns?: string } | undefined; - if (at?.dir === home && at.ns === "") { + const near = at?.dir === home && at.ns === "" ? filters : far; + if (near.some((f) => open(f) || matches(f, shape(tm).kind, def, shape(tm).name))) { found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us }); } } @@ -330,12 +347,12 @@ async function check(file: string, capture: boolean, signal: AbortSignal): Promi const span = mapper(sources); if (caught === undefined) { const insts = new Set(Object.values(book.tmps).flatMap((m) => [...m.values()])); - const mine = sources.find((s) => s.root)?.file; + const own = new Set(sources.filter((s) => program ? !s.base : s.root).map((s) => s.file)); const facts = found.flatMap((f): Array<[LTerm, Fact]> => { const spn = span(f.spn); - return spn?.file === mine ? [[f.tm, { ...f, inst: insts.has(f.def), spn }]] : []; + return spn !== undefined && own.has(spn.file) ? [[f.tm, { ...f, inst: insts.has(f.def), spn }]] : []; }); - return { book, sources, span, facts: capture ? new Map(facts) : undefined }; + return { book, sources, span, facts: filters.length > 0 ? new Map(facts) : undefined }; } const err = isErr(caught.e) ? caught.e : undefined; const message = err !== undefined ? Bend.expr_show(err.bok, err.exp) : caught.e instanceof RangeError ? STACK : String(caught.e); @@ -349,6 +366,23 @@ async function check(file: string, capture: boolean, signal: AbortSignal): Promi return { book, sources, span, failure: { code: "bend/check", severity: "error", message, spn, def: err?.def, fixes: [], core: caught.e } }; } +// A term's kind, annotations stripped, and the name a Var or Ref points to. +function shape(tm: LTerm): { kind: string; name: Name } { + const t = Bend.term_strip(tm); + return { kind: t.$, name: t.$ === "Var" || t.$ === "Ref" ? t.k : "" }; +} + +// Whether a filter matches every fact in its scope. +function open(f: FactFilter): boolean { + return [f.kinds, f.defs, f.names].every((xs) => xs === undefined || xs.length === 0); +} + +// Whether a fact passes a filter, its scope aside. +function matches(f: FactFilter, kind: string, def: Name, name: Name): boolean { + const all = (xs: string[] | undefined, x: string): boolean => xs === undefined || xs.length === 0 || xs.includes(x); + return all(f.kinds, kind) && (all(f.defs, def) || all(f.defs, def.replace(/~\d+$/, ""))) && all(f.names, name); +} + export function readConfig(file: string): Config { try { return JSON.parse(fs.readFileSync(file, "utf8")); @@ -401,22 +435,31 @@ function settings(rule: LintRule, config: Config): { off: boolean; severity?: Se // that throws, and an abort, reach the caller. export async function lint(file: string, rules: LintRule[], { signal = new AbortController().signal, config = findConfig(file) }: LintOptions = {}): Promise { - const bad = rules.findIndex((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function"); + const names = (xs: unknown): boolean => xs === undefined || Array.isArray(xs) && xs.every((x) => typeof x === "string"); + const bad = rules.findIndex((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function" + || !(r.facts === undefined || r.facts === true || typeof r.facts === "object" && r.facts !== null + && (r.facts.scope === undefined || SCOPES.includes(r.facts.scope)) && names(r.facts.kinds) && names(r.facts.defs) && names(r.facts.names))); if (bad >= 0) { - throw new TypeError("invalid rule at " + bad + " (" + JSON.stringify(rules[bad]?.id) + "): it needs an id like ns/name and a run function"); + throw new TypeError("invalid rule at " + bad + " (" + JSON.stringify(rules[bad]?.id) + + "): it needs an id like ns/name, a run function, and facts, if given, true or a FactFilter"); } - const plans = rules.map((rule) => ({ rule, ...settings(rule, config) })).filter((p) => !p.off); - const { book, sources, span, facts, failure } = await check(file, plans.some((p) => p.rule.needsTypes === true), signal); + const plans = rules.map((rule) => ({ rule, ...settings(rule, config), want: rule.facts === true ? {} : rule.facts })) + .filter((p) => !p.off); + const { book, sources, span, facts, failure } = await check(file, plans.flatMap((p) => p.want === undefined ? [] : [p.want]), signal); if (failure !== undefined) { return { ok: false, diags: [failure], sources, book }; } const root = sources.find((s) => s.root)!; let diags: Diag[] = []; - for (const { rule, severity, options } of plans) { + for (const { rule, severity, options, want } of plans) { signal.throwIfAborted(); const out = await rule.run({ Bend, book, sources, root, span, walk, options, - facts: rule.needsTypes === true ? facts : undefined, + // A filter that matches all, with the scope every kept fact has, gets the map as is. + facts: want === undefined || facts === undefined ? undefined + : open(want) && (want.scope === "program" || !plans.some((p) => p.want?.scope === "program")) ? facts + : new Map([...facts].filter(([tm, f]) => (want.scope === "program" || f.spn?.file === root.file) + && matches(want, shape(tm).kind, f.def, shape(tm).name))), prior: diags, binder: (fact, v) => v.$ === "Var" ? Bend.pmap_get(fact.ctx, v.i) : null, show: (fact, ty) => Bend.term_show(Bend.term_lower(ty, fact.dep)), @@ -522,24 +565,47 @@ export function position(spn: Span): { start: Position; end: Position } { // main with bend's own IO runtime, while lint.js reaches bend-lint through // globalThis.BEND_LINT. Offsets cross as code points. export async function bendRule(file: string): Promise { - const { book, failure } = await check(file, false, new AbortController().signal); + const { book, failure } = await check(file, [], new AbortController().signal); if (failure !== undefined) { throw new Error(file + " does not check:\n" + render(failure)); } - const value = (k: Name): string => { + const value = (k: Name): LTerm | undefined => { const tld = book.tlds[k]; - return tld?.$ === "Def" && tld.n === 0 && tld.v !== null ? Bend.term_show(Bend.term_lower(Bend.term_snf(book, tld.v))) : ""; + return tld?.$ === "Def" && tld.n === 0 && tld.v !== null ? Bend.term_lower(Bend.term_snf(book, tld.v)) : undefined; + }; + const shown = value("id"); + const id = shown === undefined ? undefined : Bend.term_show(shown).match(/^"([^"\\]*)"$/)?.[1]; + // facts(), checked: NoFacts{} gives null, Want{...} a filter; else undefined. + const args = (t: LTerm | undefined, ctr: string): LTerm[] | undefined => + t?.$ === "Ctr" && (t.k === ctr || t.k.endsWith(":" + ctr)) ? t.x : undefined; + const texts = (t: LTerm | undefined): string[] | undefined => { + const out: string[] = []; + for (let at = t; ; at = args(at, "Con")?.[1]) { + const [head] = args(at, "Con") ?? []; + if (args(at, "Nil") !== undefined) { + return out; + } + if (head?.$ !== "Lit" || typeof head.v !== "string") { + return undefined; + } + out.push(head.v); + } + }; + const asked = value("facts"); + const [scope, kinds, defs, names] = args(asked, "Want") ?? []; + const want = args(asked, "NoFacts") !== undefined ? null : { + scope: args(scope, "File") !== undefined ? "file" as const : args(scope, "Program") !== undefined ? "program" as const : undefined, + kinds: texts(kinds), defs: texts(defs), names: texts(names), }; - const id = value("id").match(/^"([^"\\]*)"$/)?.[1]; - const types = value("types"); - if (id === undefined || !["True{}", "False{}"].includes(types) || Comp.io_type(book) === null) { - throw new Error(file + " must define id() -> String, types() -> Bool and main() -> IO(Unit)"); + if (id === undefined || Comp.io_type(book) === null + || want !== null && [want.scope, want.kinds, want.defs, want.names].includes(undefined)) { + throw new Error(file + " must define id() -> String, facts() -> Lint.Want and main() -> IO(Unit)"); } const main = new Function("require", Comp.js_lib(book) + "\n" + Comp.RUNTIME_MAIN + "\nreturn (args) => { cli_args = args; return io_run(" + Comp.js_sat("main") + "); };")(import.meta.require) as (args: string[]) => number; return { id, - needsTypes: types === "True{}", + ...(want === null ? {} : { facts: want as FactFilter }), run: (cx) => { // Built once per text: the character at each UTF-16 offset (points), // and the UTF-16 offset of each character (units); both end at the end. @@ -574,6 +640,7 @@ export async function bendRule(file: string): Promise { return { file: src.file, beg: at(s.beg), end: at(s.end) }; }; const facts = [...cx.facts?.values() ?? []]; + let given = 0; // facts handed to the rule so far const terms: HTerm[] = []; const pick = (xs: T[], i: number, what: string): T => { if (xs[i] === undefined) { @@ -589,13 +656,14 @@ export async function bendRule(file: string): Promise { if (odd !== undefined) { throw new Error("rule " + id + ": option " + odd[0] + " must be a whole number from 0 to 4294967295 (a U32)"); } - return { sources: cx.sources.map((s) => ({ path: s.path, text: s.text, root: s.root })), facts: facts.length, options: cx.options }; + return { sources: cx.sources.map((s) => ({ path: s.path, text: s.text, root: s.root })), options: cx.options }; }, + next: () => given < facts.length ? given++ : undefined, report: (diags) => void found.push(diags), view: (i) => { const f = pick(facts, i, "fact"); const t = Bend.term_strip(f.tm); - return { owner: f.def, inst: f.inst, kind: t.$, name: t.$ === "Var" ? t.k : "", quantity: f.qt.$, span: spot(f.spn), inner: spot(cx.span(t.s)) }; + return { owner: f.def, inst: f.inst, ...shape(f.tm), quantity: f.qt.$, span: spot(f.spn), inner: spot(cx.span(t.s)) }; }, type: (i) => keep(pick(facts, i, "fact").ty), binder: (i) => { diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index fe2033e823..270a90fc4a 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -15,6 +15,7 @@ import { DriftError, bendDir, patch, relative, resolve, seeCheck, seeInfer } fro type Loose = Fact & { tm: { x?: { $: string; k?: string; i?: number } } }; type Token = { text: string; beg: number; end: number }; +type Seen = { kind: string; def: string; name: string; path: string }; // Constants // ========= @@ -132,8 +133,8 @@ import ../../../src/lint.bend as Lint def id() -> String: "style/comma-space" -def types() -> Bool: - False{} +def facts() -> Lint.Want: + Lint.NoFacts{} def is_word(+c: Char) -> Bool: Bool.or(Char.is_alpha(c), Bool.or(Char.is_digit(c), Char.is_eq(c, '_'))) @@ -195,7 +196,7 @@ def all(srcs: List<&2, Lint.Source>) -> List<&2, Lint.Diag>: def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): match input: - case Lint.Input{sources, facts, options}: + case Lint.Input{sources, options}: IO.pure(List<&2, Lint.Diag>, all(sources)) def main() -> IO(Unit): @@ -211,8 +212,8 @@ import ../../../src/lint.bend as Lint def id() -> String: "test/types" -def types() -> Bool: - True{} +def facts() -> Lint.Want: + Lint.Want{Lint.File{}, ["Var"], ["id"], []} def verdict(same: Bool) -> String: match same: @@ -289,21 +290,14 @@ def prepend(m: Maybe<&2, Lint.Diag>, xs: List<&2, Lint.Diag>) -> List<&2, Lint.D case Some{d}: d <> xs -def each(facts: List<&2, Lint.Fact>) -> IO(List<&2, Lint.Diag>): - match facts: - case Nil{}: - IO.pure(List<&2, Lint.Diag>, []) - case Con{+f, rest}: - do IO>: - v : Lint.View <- Lint.view(f) - here : Maybe<&2, Lint.Diag> <- fact_diag(f, v) - later : List<&2, Lint.Diag> <- each(rest) - return prepend(here, later) +def step(found: List<&2, Lint.Diag>, +f: Lint.Fact) -> IO(List<&2, Lint.Diag>): + do IO>: + v : Lint.View <- Lint.view(f) + here : Maybe<&2, Lint.Diag> <- fact_diag(f, v) + return prepend(here, found) def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): - match input: - case Lint.Input{sources, facts, options}: - each(facts) + Lint.fold_facts(~List<&2, Lint.Diag>, ~step, []) def main() -> IO(Unit): Lint.serve(run) @@ -322,7 +316,7 @@ const commaSpace: LintRule = { const identity: LintRule = { id: "example/identity", - needsTypes: true, + facts: true, run: async (cx, signal) => { await Promise.resolve(); signal.throwIfAborted(); @@ -337,7 +331,7 @@ const identity: LintRule = { const neverRun: LintRule = { id: "test/never-run", - needsTypes: true, + facts: true, run: () => { throw new Error("this rule must not run"); }, }; @@ -346,7 +340,7 @@ const neverRun: LintRule = { // instances repeat the facts of the def as written, so they are skipped. const redundantAnnotation: LintRule = { id: "erasure/redundant-local-annotation", - needsTypes: true, + facts: true, run: (cx) => [...cx.facts!.values()].flatMap((fact): Diag[] => { const term = cx.Bend.term_strip(fact.tm); const value = cx.span(term.s); @@ -386,21 +380,44 @@ import ../../../src/lint.bend as Lint def id() -> String: "test/options" -def types() -> Bool: - False{} +def facts() -> Lint.Want: + Lint.NoFacts{} def summary(+opts: List<&2, Lint.Option>) -> String: U32.show(Lint.option_number(opts, "width", 2)) ++ " " ++ Bool.show(Lint.option_flag(opts, "wrap", False{})) ++ " " ++ Lint.option_text(opts, "name", "none") def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): match input: - case Lint.Input{sources, facts, +options}: + case Lint.Input{sources, +options}: IO.pure(List<&2, Lint.Diag>, [Lint.Diag{Lint.Hint{}, summary(options), None{}, []}]) def main() -> IO(Unit): Lint.serve(run) `; +// A Bend rule that counts the facts it pulls, from the linted file and its +// imports: the Vars. +const COUNT_BEND = String.raw`import Base +import ../../../src/lint.bend as Lint + +def id() -> String: + "test/count" + +def facts() -> Lint.Want: + Lint.Want{Lint.Program{}, ["Var"], [], []} + +def add(n: U32, +f: Lint.Fact) -> IO(U32): + IO.pure(U32, U32.add(n, 1)) + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + do IO>: + n : U32 <- Lint.fold_facts(~U32, ~add, 0) + return [Lint.Diag{Lint.Hint{}, U32.show(n), None{}, []}] + +def main() -> IO(Unit): + Lint.serve(run) +`; + // A rule that reports its options. const echo: LintRule = { id: "test/echo", @@ -457,6 +474,16 @@ function spacingEdits(file: SourceFile): Edit[] { ]); } +// The facts a rule with this filter gets: kind, def, name and file. +async function seen(file: string, want: LintRule["facts"]): Promise { + let got: Fact[] = []; + await lint(file, [{ id: "test/seen", facts: want, run: (cx) => (got = [...cx.facts!.values()], []) }]); + return got.map((f) => { + const t = Bend.term_strip(f.tm); + return { kind: t.$, def: f.def, name: t.$ === "Var" || t.$ === "Ref" ? t.k : "", path: (f.spn!.file as SourceFile).path }; + }); +} + function run(...args: string[]) { return spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8" }); } @@ -579,7 +606,7 @@ describe("lint", () => { expect(render(res.diags[1])).toContain("Context:"); }); - test("only rules with needsTypes get facts", async () => { + test("only rules with facts get facts", async () => { const look: LintRule = { id: "test/look", run: (cx) => { expect(cx.facts).toBeUndefined(); return []; } }; expect((await lint(userland, [look, identity])).ok).toBe(true); expect((await lint(userland, [commaSpace])).facts).toBeUndefined(); @@ -588,7 +615,7 @@ describe("lint", () => { test("facts cover templates, proofs, matches and fields", async () => { const probe: LintRule = { id: "test/probe", - needsTypes: true, + facts: true, run: (cx) => { const generic = facts(cx, "generic").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "x")!; expect(generic.bok.tlds["generic~T"]).toBeDefined(); @@ -662,7 +689,7 @@ describe("lint", () => { const main = fixture("main.bend", "import ./dep.bend as D\n\ndef main() -> D.N:\n D.id(D.Z{})\n"); const look: LintRule = { id: "test/imports", - needsTypes: true, + facts: true, run: (cx) => { expect(cx.root.path).toEndWith("/main.bend"); expect(cx.sources.find((s) => s.path.endsWith("/dep.bend"))!.ns).toBe("dep"); @@ -683,7 +710,7 @@ describe("lint", () => { const withBase = fixture("with_base.bend", "import Base\n\ndef main() -> Nat:\n 1n\n"); const probe: LintRule = { id: "test/base", - needsTypes: true, + facts: true, run: (cx) => { expect(cx.root.path).toEndWith("/with_base.bend"); expect(cx.sources.some((s) => s.base)).toBe(true); @@ -718,7 +745,7 @@ describe("lint", () => { const server = Bun.serve({ port: 0, fetch: async (req) => new Response("advice for " + (await req.json()).type) }); const remote: LintRule = { id: "test/api", - needsTypes: true, + facts: true, run: async (cx, signal) => { const fact = cx.facts!.get((cx.book.tlds.id as { e: LTerm }).e)!; const type = cx.Bend.term_wnf(fact.bok, fact.ty) as { A: typeof fact.ty }; @@ -785,7 +812,7 @@ describe("review fixes", () => { const file = fixture(name, head + "\n\ntype P is Data:\n P{n: Nat}\n\ndef main() -> Nat:\n 1n\n"); const where: LintRule = { id: "test/where", - needsTypes: true, + facts: true, run: (cx) => { const facts = [...cx.facts!.values()]; expect(facts.length).toBeGreaterThan(0); @@ -802,7 +829,7 @@ describe("review fixes", () => { fs.mkdirSync(dir, { recursive: true }); for (const f of ["a.bend", "b.bend"]) fs.writeFileSync(path.join(dir, f), "def one() -> Type:\n Type\n"); fs.writeFileSync(path.join(dir, "m.bend"), "import ./a.bend as A\nimport ./b.bend as B\n\ndef main() -> Type:\n A.one()\n"); - const all: LintRule = { id: "test/all", needsTypes: true, run: (cx) => [...cx.facts!.values()].map((f) => cx.diag({ message: "f", spn: f.spn })) }; + const all: LintRule = { id: "test/all", facts: true, run: (cx) => [...cx.facts!.values()].map((f) => cx.diag({ message: "f", spn: f.spn })) }; const res = await lint(path.join(dir, "m.bend"), [all]); expect(res.ok).toBe(true); expect(res.diags.length).toBeGreaterThan(0); @@ -909,7 +936,7 @@ describe("rules written in Bend", () => { test("a source rule finds the comma, with a fix on the file on disk", async () => { const rule = await bendRule(fixture("comma_rule.bend", COMMA_BEND)); - expect([rule.id, rule.needsTypes]).toEqual(["style/comma-space", false]); + expect([rule.id, rule.facts]).toEqual(["style/comma-space", undefined]); const res = await lint(userland, [rule]); expect(res.diags.map((d) => [d.code, d.severity])).toEqual([["style/comma-space", "warning"]]); expect(render(res.diags[0])).toContain("generic(~N, a)"); @@ -918,13 +945,67 @@ describe("rules written in Bend", () => { test("a typed rule asks the checker through effects", async () => { const rule = await bendRule(fixture("types_rule.bend", TYPES_BEND)); - expect(rule.needsTypes).toBe(true); + expect(rule.facts).toEqual({ scope: "file", kinds: ["Var"], defs: ["id"], names: [] }); const res = await lint(userland, [rule, rule]); expect(res.diags.map((d) => d.message)).toEqual(Array(2).fill("x: Alias = N (same as its binder), text x, demanded once, uses x once")); expect(res.diags.every((d) => d.spn?.file === root(res.sources).file)).toBe(true); }); }); +describe("fact filters", () => { + const userland = fixture("filter_userland.bend", USERLAND); + + test("kinds, defs and names narrow the facts; an empty or absent list matches all", async () => { + const all = await seen(userland, true); + const only = async (want: LintRule["facts"], keep: (f: Seen) => boolean) => { + const got = await seen(userland, want); + expect(got.length).toBeGreaterThan(0); + expect(got).toEqual(all.filter(keep)); + }; + await only({ kinds: ["Var"] }, (f) => f.kind === "Var"); + await only({ defs: ["generic"] }, (f) => f.def === "generic" || f.def.startsWith("generic~")); + await only({ kinds: ["Ref"], names: ["id"] }, (f) => f.kind === "Ref" && f.name === "id"); + await only({ kinds: [], defs: [], names: [] }, () => true); + expect(all.some((f) => f.def.startsWith("generic~"))).toBe(true); + }); + + test("each rule gets its own facts; bend-lint keeps only what some rule asked for", async () => { + const got: Record = {}; + const rule = (id: string, kinds: string[]): LintRule => ({ id, facts: { kinds }, run: (cx) => (got[id] = [...cx.facts!.values()].map((f) => Bend.term_strip(f.tm).$), []) }); + const res = await lint(userland, [rule("test/vars", ["Var"]), rule("test/refs", ["Ref"])]); + expect(new Set(got["test/vars"])).toEqual(new Set(["Var"])); + expect(new Set(got["test/refs"])).toEqual(new Set(["Ref"])); + expect(res.facts!.size).toBe(got["test/vars"].length + got["test/refs"].length); + expect(res.facts!.size).toBeLessThan((await lint(userland, [rule("test/all", [])])).facts!.size); + }); + + test("scope program adds the imports' facts, never Base's", async () => { + const dir = path.join(DIR, "program"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "dep.bend"), "import Base\n\ndef one(x: Nat) -> Nat:\n x\n"); + for (const head of ["import Base", "import Base # comment"]) { + const main = path.join(dir, "main.bend"); + fs.writeFileSync(main, head + "\nimport ./dep.bend as D\n\ndef main() -> Nat:\n D.one(1n)\n"); + const where = async (scope: "file" | "program") => new Set((await seen(main, { scope })).map((f) => path.basename(f.path))); + expect(await where("file")).toEqual(new Set(["main.bend"])); + expect(await where("program")).toEqual(new Set(["main.bend", "dep.bend"])); + } + }); + + test("a bad filter is an invalid rule", async () => { + for (const facts of [{ scope: "all" }, { kinds: "Var" }, { names: [1] }, 3]) { + await expect(lint(userland, [{ id: "test/bad", facts, run: () => [] } as unknown as LintRule])).rejects.toThrow(/invalid rule at 0/); + } + }); + + test("a Bend rule pulls its facts one at a time, any number of them", async () => { + const rule = await bendRule(fixture("count_rule.bend", COUNT_BEND)); + expect(rule.facts).toEqual({ scope: "program", kinds: ["Var"], defs: [], names: [] }); + const many = fixture("many_vars.bend", "import Base\n\n" + Array.from({ length: 5000 }, (_, i) => "def f" + i + "(x: U32) -> U32:\n x\n").join("\n")); + expect((await lint(many, [rule])).diags.map((d) => d.message)).toEqual(["5000"]); + }); +}); + describe("the rules in rules/", () => { test("style/trailing-whitespace finds spaces and tabs, and its fix keeps the line breaks", async () => { const res = await lint(fixture("trailing.bend", STYLE), trailing); @@ -951,7 +1032,7 @@ describe("the rules in rules/", () => { describe("cli", () => { const input = fixture("cli.bend", USERLAND); - const first = module("first.js", `[{ id: "cli/first", needsTypes: true, run(cx) { + const first = module("first.js", `[{ id: "cli/first", facts: true, run(cx) { if (!cx.facts?.size || !cx.sources.length) throw new Error("missing metadata"); return [cx.diag({ message: "First rule", severity: "warning" })]; } }]`); From a5ebb6650c53b603c70efab4e4778e61927a604f Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 16:13:42 -0300 Subject: [PATCH 25/39] bend-lint: review of the fact filters - 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/src/lint.bend | 9 +++++---- tools/bend-lint/src/lint.ts | 17 +++++++---------- tools/bend-lint/test/lint.test.ts | 2 ++ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tools/bend-lint/src/lint.bend b/tools/bend-lint/src/lint.bend index 6d31bce7c5..d0784c1bce 100644 --- a/tools/bend-lint/src/lint.bend +++ b/tools/bend-lint/src/lint.bend @@ -7,10 +7,10 @@ # def main() -> IO(Unit): # Lint.serve(run) # -# A rule with facts reads them one at a time with next_fact. Offsets -# count characters (code points) from the start of a file. The -# rule's options come from bend-lint.json; read them with option_number, -# option_flag and option_text, each with a default. +# A rule with facts reads them one at a time, with fold_facts or +# next_fact. Offsets count characters (code points) from the start of a +# file. The rule's options come from bend-lint.json; read them with +# option_number, option_flag and option_text, each with a default. import Base # A checked term, and a type: indexes into bend-lint's tables for one run. @@ -100,6 +100,7 @@ def report(diags: List<&2, Diag>) -> IO(Unit): def next_fact() -> IO(Maybe<&2, Fact>): import "./lint.js" +# One step of fold_facts: stop at None{}, else run step and go on. def fold_next(~A: Type, ~step: A -> @+f: Fact -> IO(A), m: Maybe<&2, Fact>, acc: A, again: A -> IO(A)) -> IO(A): match m: case None{}: diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index f1a84ae3b6..b374df8978 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -308,7 +308,8 @@ async function check(file: string, filters: FactFilter[], signal: AbortSignal): ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => { const at = spn?.file as { dir?: string; ns?: string } | undefined; const near = at?.dir === home && at.ns === "" ? filters : far; - if (near.some((f) => open(f) || matches(f, shape(tm).kind, def, shape(tm).name))) { + const { kind, name } = near.length > 0 ? shape(tm) : { kind: "", name: "" }; + if (near.some((f) => matches(f, kind, def, name))) { found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us }); } } @@ -372,11 +373,6 @@ function shape(tm: LTerm): { kind: string; name: Name } { return { kind: t.$, name: t.$ === "Var" || t.$ === "Ref" ? t.k : "" }; } -// Whether a filter matches every fact in its scope. -function open(f: FactFilter): boolean { - return [f.kinds, f.defs, f.names].every((xs) => xs === undefined || xs.length === 0); -} - // Whether a fact passes a filter, its scope aside. function matches(f: FactFilter, kind: string, def: Name, name: Name): boolean { const all = (xs: string[] | undefined, x: string): boolean => xs === undefined || xs.length === 0 || xs.includes(x); @@ -435,16 +431,17 @@ function settings(rule: LintRule, config: Config): { off: boolean; severity?: Se // that throws, and an abort, reach the caller. export async function lint(file: string, rules: LintRule[], { signal = new AbortController().signal, config = findConfig(file) }: LintOptions = {}): Promise { - const names = (xs: unknown): boolean => xs === undefined || Array.isArray(xs) && xs.every((x) => typeof x === "string"); + const strings = (xs: unknown): boolean => xs === undefined || Array.isArray(xs) && xs.every((x) => typeof x === "string"); const bad = rules.findIndex((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function" || !(r.facts === undefined || r.facts === true || typeof r.facts === "object" && r.facts !== null - && (r.facts.scope === undefined || SCOPES.includes(r.facts.scope)) && names(r.facts.kinds) && names(r.facts.defs) && names(r.facts.names))); + && (r.facts.scope === undefined || SCOPES.includes(r.facts.scope)) && strings(r.facts.kinds) && strings(r.facts.defs) && strings(r.facts.names))); if (bad >= 0) { throw new TypeError("invalid rule at " + bad + " (" + JSON.stringify(rules[bad]?.id) + "): it needs an id like ns/name, a run function, and facts, if given, true or a FactFilter"); } const plans = rules.map((rule) => ({ rule, ...settings(rule, config), want: rule.facts === true ? {} : rule.facts })) .filter((p) => !p.off); + const program = plans.some((p) => p.want?.scope === "program"); const { book, sources, span, facts, failure } = await check(file, plans.flatMap((p) => p.want === undefined ? [] : [p.want]), signal); if (failure !== undefined) { return { ok: false, diags: [failure], sources, book }; @@ -455,9 +452,9 @@ export async function lint(file: string, rules: LintRule[], signal.throwIfAborted(); const out = await rule.run({ Bend, book, sources, root, span, walk, options, - // A filter that matches all, with the scope every kept fact has, gets the map as is. + // A filter that matches all, in the scope of every kept fact, gets the map as is. facts: want === undefined || facts === undefined ? undefined - : open(want) && (want.scope === "program" || !plans.some((p) => p.want?.scope === "program")) ? facts + : [want.kinds, want.defs, want.names].every((xs) => !xs?.length) && (want.scope === "program" || !program) ? facts : new Map([...facts].filter(([tm, f]) => (want.scope === "program" || f.spn?.file === root.file) && matches(want, shape(tm).kind, f.def, shape(tm).name))), prior: diags, diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 270a90fc4a..59ac2c0cdb 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -1063,6 +1063,8 @@ describe("cli", () => { expect(run(input, "--rules", fixture("no_id.bend", "import Base\n\ndef main() -> IO(Unit):\n IO.print(\"x\")\n")).stderr) .toMatch(/must define id\(\) -> String/); expect(run(input, "--rules", fixture("broken_rule.bend", "def broken(\n")).stderr).toMatch(/does not check/); + expect(run(input, "--rules", fixture("no_facts.bend", COMMA_BEND.replace("def facts() -> Lint.Want:\n Lint.NoFacts{}\n", ""))).stderr) + .toMatch(/must define id\(\) -> String, facts\(\) -> Lint.Want/); }); test("a rule written in Bend runs from the CLI", () => { From d0d7ab79e09ab910a59363c4e85e140f06e5b67c Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 18:04:25 -0300 Subject: [PATCH 26/39] bend-lint: download bend's source when none is given 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//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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/README.md | 15 +++- tools/bend-lint/src/lint.ts | 2 +- tools/bend-lint/src/patch.ts | 140 ++++++++++++++++++++++++++++-- tools/bend-lint/test/lint.test.ts | 71 ++++++++++++++- 4 files changed, 211 insertions(+), 17 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 83ff2499ad..b5798376e4 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -39,6 +39,8 @@ UTF-16): `--bend ` (or `$BEND_DIR`) picks the bend to load: a bend checkout, or its `bend2` folder. Inside the bend repo, its own bend is the default. +Elsewhere, bend-lint downloads bend's source (see +[Outside the bend repo](#outside-the-bend-repo)). ## Rules in TypeScript @@ -224,9 +226,16 @@ tests catch subtler changes. ## Outside the bend repo -bend-lint needs a bend checkout, because it loads bend's source. Give it -with `--bend ` or `$BEND_DIR`; with neither, it stops with a clear -error. For `tsc`, run `bun install` (for `@types/bun`) and point the +bend-lint loads bend's source, not the `bend` program. With no `--bend` +or `$BEND_DIR`, and no bend repo around it, it downloads the source of one +bend release from GitHub: the version of the `bend` on your PATH (`bend +version`), else the newest release (asked once a day). It takes only +`bend2/bend.ts`, `comp.ts`, `base.bend` and the `effs/` files Base imports +(about 0.6 MB), and keeps them in `~/.cache/bend-lint//` +(`$XDG_CACHE_HOME`, or `%LOCALAPPDATA%` on Windows). Later runs use the +cache, with no network; offline, it uses the newest release cached. If a +release changes what `src/patch.ts` edits, bend-lint stops with a +DriftError, as it does for a checkout. For `tsc`, run `bun install` (for `@types/bun`) and point the `bend2/*` path in `tsconfig.json` at that checkout's `bend2/`. The drift tests read the checkout's own `tests/`. diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index b374df8978..41d263db38 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -697,7 +697,7 @@ export async function bendRule(file: string): Promise { // Finds bend2 (see bendDir), patches bend.ts and comp.ts as Bun loads // them, then checks what the wrappers record for `x` in a tiny program. async function instrument(given: string | undefined): Promise<{ Bend: Bend; Comp: Comp; BEND2: string }> { - const dir = bendDir(given); + const dir = await bendDir(given); // Bun.plugin is process-wide: match this checkout's two files by full path. const patched = new Map(["bend.ts", "comp.ts"].map((f) => [path.join(dir, f), patch(f, fs.readFileSync(path.join(dir, f), "utf8"))])); const exact = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll("/", "[\\\\/]"); diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index 0ee6295567..9ba3d1b060 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -11,6 +11,7 @@ // (see instrument in lint.ts). import * as nodeFs from "node:fs"; +import * as os from "node:os"; import * as nodePath from "node:path"; import * as url from "node:url"; @@ -30,6 +31,14 @@ export type Hooked = Book & { see?: (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn: Span | undefined, qt: Quant, us: Uses) => void; }; +// An HTTP GET. +export type Get = (url: string) => Promise; + +// How bendDir reaches the network, runs `bend`, where it caches, and where +// the bend repo would be; tests give their own. run gives a command's +// stdout, or undefined if it fails. +export type FindOptions = { get?: Get; run?: (cmd: string[]) => string | undefined; cache?: string; repo?: string }; + // Constants // ========= @@ -43,6 +52,17 @@ const SHIM = JSON.stringify(url.pathToFileURL(nodePath.join(HERE, "patch.ts")).h export const MARK = "BEND_LINT_PATCH"; +const GIT = "https://github.com/bendlang/bend.git/info/refs?service=git-upload-pack"; +const RAW = "https://raw.githubusercontent.com/bendlang/bend/"; +const RELEASE = /^v\d+\.\d+\.\d+$/; +const DAY = 24 * 60 * 60 * 1000; +const EFF = /^effs\/[\w.-]+$/; + +// Downloaded bends, one folder per release. +const CACHE = nodePath.join(process.env.XDG_CACHE_HOME + ?? (process.platform === "win32" ? process.env.LOCALAPPDATA ?? nodePath.join(os.homedir(), "AppData", "Local") : nodePath.join(os.homedir(), ".cache")), + "bend-lint"); + const PATCHES: Record = { "bend.ts": { edits: [ @@ -138,15 +158,117 @@ export function patch(file: string, src: string): string { } // The bend2 folder to load: `given` (from --bend), else $BEND_DIR, else -// the bend repo around tools/bend-lint, if there is one. A bend checkout -// works too, for its bend2 folder. -export function bendDir(given: string | undefined): string { - const dir = path.resolve(given ?? process.env.BEND_DIR ?? path.join(HERE, "..", "..", "..", "bend2")); +// the bend repo around tools/bend-lint, if there is one, else a release +// from GitHub: the installed bend's version, or the newest. A bend +// checkout works too, for its bend2 folder. +export async function bendDir(given: string | undefined, { get = download, run = spawn, cache = CACHE, repo }: FindOptions = {}): Promise { + const chosen = given ?? process.env.BEND_DIR; + const dir = path.resolve(chosen ?? repo ?? path.join(HERE, "..", "..", "..", "bend2")); const found = [path.join(dir, "bend2"), dir].find((d) => nodeFs.existsSync(path.join(d, "bend.ts"))); - if (found === undefined) { - throw new Error((given ?? process.env.BEND_DIR) === undefined - ? "no bend found: give a bend checkout with --bend or BEND_DIR" - : "no bend2 at " + dir + " (it needs bend.ts); give a bend checkout with --bend or BEND_DIR"); + if (found !== undefined) { + return fs.realpathSync(found); + } + if (chosen !== undefined) { + throw new Error("no bend2 at " + dir + " (it needs bend.ts); give a bend checkout with --bend or BEND_DIR"); + } + const tag = installedTag(run) ?? await latestTag(cache, get); + const fresh = !nodeFs.existsSync(nodePath.join(cache, tag, "bend2")); + const got = await fetchBend(tag, cache, get).catch((e: unknown) => { + throw new Error("could not download bend " + tag + " (" + (e instanceof Error ? e.message : String(e)) + + "); give a bend checkout with --bend or BEND_DIR"); + }); + if (fresh) { + console.error("bend-lint: downloaded bend " + tag + " to " + got); + } + return fs.realpathSync(got); +} + +// fetch, given up after 30 seconds. +function download(url: string): Promise { + return fetch(url, { signal: AbortSignal.timeout(30_000) }); +} + +// A command's stdout, or undefined if it does not run or fails. +function spawn(cmd: string[]): string | undefined { + try { + const r = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "ignore" }); + return r.exitCode === 0 ? r.stdout.toString() : undefined; + } catch { + return undefined; + } +} + +// The installed bend's release ("v2.0.36"), or undefined if `bend version` +// does not run. +export function installedTag(run: (cmd: string[]) => string | undefined = spawn): string | undefined { + const version = run(["bend", "version"])?.match(/^bend (\d+\.\d+\.\d+)\b/)?.[1]; + return version === undefined ? undefined : "v" + version; +} + +// The newest release, from git's list of refs (GitHub's API limits calls). +// It asks at most once a day; offline, it takes the newest one cached. +export async function latestTag(cache: string = CACHE, get: Get = download): Promise { + const note = nodePath.join(cache, "latest.json"); + let known: { tag?: unknown; at?: unknown } | undefined; + try { + known = JSON.parse(nodeFs.readFileSync(note, "utf8")); + } catch { + known = undefined; // no note yet, or a damaged one + } + if (typeof known?.tag === "string" && RELEASE.test(known.tag) && typeof known.at === "number" && Date.now() - known.at < DAY) { + return known.tag; + } + const newest = (tags: string[]): string | undefined => tags.filter((t) => RELEASE.test(t)) + .map((t) => t.slice(1).split(".").map(Number)).sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2]) + .map((v) => "v" + v.join("."))[0]; + const listed = await get(GIT).then((res) => res.ok ? res.text() : Promise.reject(new Error("GitHub answered " + res.status))) + .then((refs) => newest([...refs.matchAll(/refs\/tags\/(v[\d.]+)$/gm)].map((m) => m[1])), (e: unknown) => { + const cached = newest(nodeFs.existsSync(cache) ? nodeFs.readdirSync(cache) : []); + return cached ?? Promise.reject(new Error("cannot list bend's releases (" + (e instanceof Error ? e.message : String(e)) + + "); give a bend checkout with --bend or BEND_DIR")); + }); + if (listed === undefined) { + throw new Error("bend has no release tags; give a bend checkout with --bend or BEND_DIR"); + } + nodeFs.mkdirSync(cache, { recursive: true }); + nodeFs.writeFileSync(note, JSON.stringify({ tag: listed, at: Date.now() })); + return listed; +} + +// bend2 at a release: bend.ts, comp.ts, base.bend, and the effs/ files +// base.bend imports, kept in //bend2. A download goes to a +// temporary folder first, so the cache never holds a partial one. +export async function fetchBend(tag: string, cache: string = CACHE, get: Get = download): Promise { + if (!RELEASE.test(tag)) { + throw new Error("not a bend release: " + tag); + } + const dir = nodePath.join(cache, tag, "bend2"); + if (nodeFs.existsSync(dir)) { + return dir; + } + const text = (file: string): Promise<[string, string]> => get(RAW + tag + "/bend2/" + file) + .then((res) => res.ok ? res.text() : Promise.reject(new Error("GitHub answered " + res.status + " for bend2/" + file))) + .then((body) => [file, body]); + const base = await text("base.bend"); + const effs = [...new Set([...base[1].matchAll(/^\s*import "\.\/(effs\/[^"]+)"/gm)].map((m) => m[1]))]; + const odd = effs.find((f) => !EFF.test(f)); + if (odd !== undefined) { + throw new Error("base.bend imports " + odd + ", which is not a plain file in effs/"); + } + const files = [base, ...await Promise.all(["bend.ts", "comp.ts", ...effs].map(text))]; + nodeFs.mkdirSync(nodePath.join(cache, tag), { recursive: true }); + const part = nodeFs.mkdtempSync(nodePath.join(cache, tag, ".part-")); + files.forEach(([file, body]) => { + nodeFs.mkdirSync(nodePath.dirname(nodePath.join(part, file)), { recursive: true }); + nodeFs.writeFileSync(nodePath.join(part, file), body); + }); + try { + nodeFs.renameSync(part, dir); + } catch (e) { + nodeFs.rmSync(part, { recursive: true, force: true }); + if (!nodeFs.existsSync(dir)) { + throw e; + } } - return fs.realpathSync(found); + return dir; } diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 59ac2c0cdb..78767f4794 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -8,7 +8,7 @@ import type { Book, LTerm, Span } from "bend2/bend.ts"; import { BEND2, Bend, Comp, applyFixes, bendRule, findConfig, lint, mapper, render, walk } from "../src/lint.ts"; import type { Diag, Edit, Fact, LintRule, RuleContext, Source, SourceFile } from "../src/lint.ts"; import { rules as trailing } from "../rules/trailing_whitespace.ts"; -import { DriftError, bendDir, patch, relative, resolve, seeCheck, seeInfer } from "../src/patch.ts"; +import { DriftError, bendDir, fetchBend, installedTag, latestTag, patch, relative, resolve, seeCheck, seeInfer } from "../src/patch.ts"; // Types // ===== @@ -484,6 +484,20 @@ async function seen(file: string, want: LintRule["facts"]): Promise { }); } +// A GitHub that lists `tags` and serves this checkout's bend2 at any +// release; it records the URLs it is asked for. +function github(tags: string[]): { get: (url: string) => Promise; asked: string[] } { + const asked: string[] = []; + const get = async (url: string): Promise => { + asked.push(url); + const file = url.match(/^https:\/\/raw\.githubusercontent\.com\/bendlang\/bend\/v[\d.]+\/bend2\/(.+)$/)?.[1]; + return url.includes("/info/refs") + ? new Response(["0000 HEAD\0caps", ...tags.flatMap((t) => ["0000 refs/tags/" + t, "0000 refs/tags/" + t + "^{}"]), "0000 refs/heads/main"].join("\n") + "\n") + : file !== undefined && fs.existsSync(path.join(BEND2, file)) ? new Response(fs.readFileSync(path.join(BEND2, file), "utf8")) : new Response("", { status: 404 }); + }; + return { get, asked }; +} + function run(...args: string[]) { return spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8" }); } @@ -538,10 +552,10 @@ describe("patch", () => { expect(() => seeInfer(fake as unknown as typeof Bend.term_infer)).toThrow(/term_infer takes 5 parameters, not 6/); }); - test("bend2 is found from a checkout, its bend2 folder, or the repo; a wrong dir fails", () => { + test("bend2 is found from a checkout, its bend2 folder, or the repo; a wrong dir fails", async () => { const repo = path.dirname(BEND2); - expect([bendDir(repo), bendDir(BEND2), bendDir(undefined)]).toEqual([BEND2, BEND2, BEND2]); - expect(() => bendDir(DIR)).toThrow(/no bend2 at/); + expect([await bendDir(repo), await bendDir(BEND2), await bendDir(undefined)]).toEqual([BEND2, BEND2, BEND2]); + await expect(bendDir(DIR)).rejects.toThrow(/no bend2 at/); }); test("paths: a drive letter is a root; POSIX paths are unchanged", () => { @@ -554,6 +568,55 @@ describe("patch", () => { }); }); +describe("downloading bend", () => { + const cache = () => fs.mkdtempSync(path.join(DIR, "cache-")); + const offline = () => Promise.reject(new Error("offline")); + + test("the installed bend's version comes first; else the newest release, asked once a day", async () => { + expect([installedTag(() => "bend 2.0.36\n"), installedTag(() => undefined), installedTag(() => "nope")]).toEqual(["v2.0.36", undefined, undefined]); + const gh = github(["v2.0.9", "v2.0.36", "v2.0.10", "nightly"]); + const at = cache(); + expect([await latestTag(at, gh.get), await latestTag(at, gh.get)]).toEqual(["v2.0.36", "v2.0.36"]); + expect(gh.asked.length).toBe(1); + fs.writeFileSync(path.join(at, "latest.json"), "{ damaged"); + expect(await latestTag(at, gh.get)).toBe("v2.0.36"); + expect(installedTag(() => "bend 2.0.36\nA newer bend is out.\n")).toBe("v2.0.36"); + }); + + test("offline, the newest release in the cache is used", async () => { + const at = cache(); + ["v2.0.3", "v2.0.12", "latest-ish"].forEach((d) => fs.mkdirSync(path.join(at, d))); + expect(await latestTag(at, offline)).toBe("v2.0.12"); + await expect(latestTag(cache(), offline)).rejects.toThrow(/cannot list bend's releases \(offline\)/); + }); + + test("a release downloads once and whole, and bend-lint runs on it", async () => { + const gh = github([]); + const at = cache(); + const dir = await fetchBend("v2.0.36", at, gh.get); + const effs = fs.readdirSync(path.join(BEND2, "effs")); + expect(fs.readFileSync(path.join(dir, "bend.ts"), "utf8")).toBe(fs.readFileSync(path.join(BEND2, "bend.ts"), "utf8")); + expect(fs.readdirSync(path.join(dir, "effs")).sort()).toEqual(effs.sort()); + expect(gh.asked.length).toBe(3 + effs.length); + expect(await fetchBend("v2.0.36", at, gh.get)).toBe(dir); + expect(gh.asked.length).toBe(3 + effs.length); + expect(fs.readdirSync(path.join(at, "v2.0.36"))).toEqual(["bend2"]); + const out = spawnSync(process.execPath, [CLI, fixture("downloaded.bend", USERLAND)], { encoding: "utf8", env: { ...process.env, BEND_DIR: dir } }); + expect([out.status, out.stderr]).toEqual([0, ""]); + await expect(fetchBend("../x", at, gh.get)).rejects.toThrow(/not a bend release/); + }); + + test("bendDir downloads only when no bend is given or around it", async () => { + const gh = github(["v2.0.35"]); + const at = cache(); + const away = { get: gh.get, cache: at, repo: DIR }; + expect(await bendDir(undefined, { ...away, run: () => "bend 2.0.36\n" })).toBe(fs.realpathSync(path.join(at, "v2.0.36", "bend2"))); + expect(await bendDir(undefined, { ...away, run: () => undefined })).toBe(fs.realpathSync(path.join(at, "v2.0.35", "bend2"))); + await expect(bendDir(undefined, { get: offline, cache: at, repo: DIR, run: () => "bend 2.0.40\n" })).rejects.toThrow(/could not download bend v2\.0\.40 \(offline\)/); + expect(await bendDir(undefined, { get: offline, cache: at, run: () => "bend 2.0.40\n" })).toBe(BEND2); + }); +}); + describe("spans", () => { const source = (p: string, text: string): Source => ({ path: p, ns: "", text, root: true, base: false, file: { str: text, ns: "", al: {}, path: p } }); From 2494f5b10d70a4f5900705fcab8f22d7cf8c31b8 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 21:19:35 +0000 Subject: [PATCH 27/39] bend-lint: compare downloaded paths with "/" in the test 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/test/lint.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 78767f4794..029dcaba23 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -610,8 +610,8 @@ describe("downloading bend", () => { const gh = github(["v2.0.35"]); const at = cache(); const away = { get: gh.get, cache: at, repo: DIR }; - expect(await bendDir(undefined, { ...away, run: () => "bend 2.0.36\n" })).toBe(fs.realpathSync(path.join(at, "v2.0.36", "bend2"))); - expect(await bendDir(undefined, { ...away, run: () => undefined })).toBe(fs.realpathSync(path.join(at, "v2.0.35", "bend2"))); + expect(await bendDir(undefined, { ...away, run: () => "bend 2.0.36\n" })).toBe(fs.realpathSync(path.join(at, "v2.0.36", "bend2")).replaceAll("\\", "/")); + expect(await bendDir(undefined, { ...away, run: () => undefined })).toBe(fs.realpathSync(path.join(at, "v2.0.35", "bend2")).replaceAll("\\", "/")); await expect(bendDir(undefined, { get: offline, cache: at, repo: DIR, run: () => "bend 2.0.40\n" })).rejects.toThrow(/could not download bend v2\.0\.40 \(offline\)/); expect(await bendDir(undefined, { get: offline, cache: at, run: () => "bend 2.0.40\n" })).toBe(BEND2); }); From 00e547350cac7d0532e2b60df50e43fd0e20ef70 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Wed, 7 Oct 2026 21:21:55 +0000 Subject: [PATCH 28/39] bend-lint: the bendDir test gives its own bend repo, and unsets BEND_DIR 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 Claude-Session: https://claude.ai/code/session_01CFwieXFxW49vQzucYb8Js4 --- tools/bend-lint/test/lint.test.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/test/lint.test.ts index 029dcaba23..40413b02bf 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/test/lint.test.ts @@ -607,13 +607,21 @@ describe("downloading bend", () => { }); test("bendDir downloads only when no bend is given or around it", async () => { - const gh = github(["v2.0.35"]); - const at = cache(); - const away = { get: gh.get, cache: at, repo: DIR }; - expect(await bendDir(undefined, { ...away, run: () => "bend 2.0.36\n" })).toBe(fs.realpathSync(path.join(at, "v2.0.36", "bend2")).replaceAll("\\", "/")); - expect(await bendDir(undefined, { ...away, run: () => undefined })).toBe(fs.realpathSync(path.join(at, "v2.0.35", "bend2")).replaceAll("\\", "/")); - await expect(bendDir(undefined, { get: offline, cache: at, repo: DIR, run: () => "bend 2.0.40\n" })).rejects.toThrow(/could not download bend v2\.0\.40 \(offline\)/); - expect(await bendDir(undefined, { get: offline, cache: at, run: () => "bend 2.0.40\n" })).toBe(BEND2); + const saved = process.env.BEND_DIR; + delete process.env.BEND_DIR; // a given bend would win over all below + try { + const gh = github(["v2.0.35"]); + const at = cache(); + const away = { get: gh.get, cache: at, repo: DIR }; + expect(await bendDir(undefined, { ...away, run: () => "bend 2.0.36\n" })).toBe(fs.realpathSync(path.join(at, "v2.0.36", "bend2")).replaceAll("\\", "/")); + expect(await bendDir(undefined, { ...away, run: () => undefined })).toBe(fs.realpathSync(path.join(at, "v2.0.35", "bend2")).replaceAll("\\", "/")); + await expect(bendDir(undefined, { get: offline, cache: at, repo: DIR, run: () => "bend 2.0.40\n" })).rejects.toThrow(/could not download bend v2\.0\.40 \(offline\)/); + expect(await bendDir(undefined, { get: offline, cache: at, repo: BEND2, run: () => "bend 2.0.40\n" })).toBe(BEND2); + } finally { + if (saved !== undefined) { + process.env.BEND_DIR = saved; + } + } }); }); From 69a57c36513bf0a549e5ff7553562764ba5cd0a2 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Thu, 8 Oct 2026 00:36:14 +0000 Subject: [PATCH 29/39] bend-lint: sync with the standalone repo 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 Claude-Session: https://claude.ai/code/session_01LoGeAfkpDBwvxzCtR9REJ5 --- gates/repo.ts | 273 ++++++----- tools/bend-lint/README.md | 476 ++++++++++++------- tools/bend-lint/rules/file_length.bend | 46 ++ tools/bend-lint/rules/file_length.test.ts | 59 +++ tools/bend-lint/rules/format.test.ts | 180 +++++++ tools/bend-lint/rules/format.ts | 370 ++++++++++++++ tools/bend-lint/rules/line_length.bend | 45 -- tools/bend-lint/rules/shared.bend | 66 +-- tools/bend-lint/rules/shared.ts | 20 - tools/bend-lint/rules/trailing_whitespace.ts | 21 - tools/bend-lint/src/{lint.js => effects.js} | 0 tools/bend-lint/src/lint.bend | 22 +- tools/bend-lint/{test => src}/lint.test.ts | 238 +++++----- tools/bend-lint/src/lint.ts | 59 ++- tools/bend-lint/tsconfig.json | 2 +- 15 files changed, 1283 insertions(+), 594 deletions(-) create mode 100644 tools/bend-lint/rules/file_length.bend create mode 100644 tools/bend-lint/rules/file_length.test.ts create mode 100644 tools/bend-lint/rules/format.test.ts create mode 100644 tools/bend-lint/rules/format.ts delete mode 100644 tools/bend-lint/rules/line_length.bend delete mode 100644 tools/bend-lint/rules/shared.ts delete mode 100644 tools/bend-lint/rules/trailing_whitespace.ts rename tools/bend-lint/src/{lint.js => effects.js} (100%) rename tools/bend-lint/{test => src}/lint.test.ts (88%) diff --git a/gates/repo.ts b/gates/repo.ts index 651235b21d..1e92892fb1 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -1,137 +1,136 @@ -#!/usr/bin/env bun -// The shape of the repo: every tracked file must match one allow line, -// and the capped files below stay under their ttok caps. Anything else in -// the tree is a failure. -// -// THE CAPS ARE PERMANENT: bend.ts 48k, comp.ts 64k, main.ts 16k, safe.ts -// 24k, bendtt.lean 64k, README.md 4k, GUIDE.md 8k, and no other file has a -// cap. Do NOT raise, lower, add or remove a cap without Taelin's explicit -// authorization. A file over its cap is made smaller by simplification, -// never by moving code out. -// evals/ is not counted: it is the models' arena, not the repo's shape. - -import * as child from "node:child_process"; -import * as fs from "node:fs"; -import * as path from "node:path"; - -import * as lib from "./_lib"; - -// Types -// ===== - -type Rule = { at: RegExp; cap: number }; - -// Constants -// ========= - -const RULES: Rule[] = []; - -// Allow -// ===== - -function allow(at: string | RegExp, cap = Infinity): void { - RULES.push({ at: typeof at === "string" ? new RegExp("^" + at - .replace(/[.]/g, "\\.") + "$") : at, cap }); -} - -allow(/^\.github\/ISSUE_TEMPLATE\/(bug|feature|config)\.yml$/); -allow(".github/workflows/repo-gate.yml"); -allow(".gitattributes"); -allow(".gitignore"); -allow("AGENTS.md"); -allow("CHANGELOG.md"); -allow("README.md", 4000); -allow("WONTFIX.txt"); -allow("LICENSE"); -allow("flake.nix"); -allow("bend2/base.bend"); -allow("bend2/bend.ts", 48000); -allow("bend2/comp.ts", 64000); -allow("bend2/main.ts", 16000); -allow("bend2/safe.ts", 24000); -allow("bend2/bendtt.lean", 64000); -allow(/^bend2\/effs\/[a-z0-9_]+\.(c|js)$/); -allow(/^bend2\/pack\/(\.gitignore|package\.json|tsconfig\.json|bun\.lock)$/); -allow(/^bend2\/docs\/(BendRT|BendTT)\/(main\.typ|refs\.bib)$/); -allow("bend2/docs/bend.sublime-syntax"); -allow(/^bend2\/docs\/film\/(film\.ts|[a-z]+\.json)$/); -allow("bend2/docs/gen_anim.ts"); -allow("bend2/docs/gen_charts.ts"); -allow("bend2/docs/gen_gifs.ts"); -allow("bend2/docs/gen_pins.ts"); -allow(/^bend2\/docs\/intro\/[a-z.]+$/); -allow(/^bench\/checker\/[a-z]+_[0-9]+\/main\.(bend|agda|lean|thy|v)$/); -allow(/^bench\/checker\/_pin_\/[a-z0-9_]+\.txt$/); -allow(/^bench\/runtime\/[a-z-]+\/main\.(bend|c|lean|ts)$/); -allow(/^bench\/runtime\/_pin_\/[a-z0-9_]+\.txt$/); -allow(/^demos\/[a-z0-9_]+\/[A-Za-z0-9_]+\.bend$/); -allow(/^demos\/[a-z0-9_]+\/[A-Za-z_]+\.(c|sh|md)$/); -allow(/^demos\/[a-z0-9_]+\/web\/(index\.html|main\.js|bunfig\.toml)$/); -allow("guide/GUIDE.md", 8000); -allow("guide/EFFECTS.md"); -allow("guide/SHADERS.md"); -allow(/^paper\/(BendRT|BendTT)\.pdf$/); -allow(/^media\/intro\.(gif|mp4)$/); -allow("media/kind_devil_theorem.mp4"); -allow(/^media\/(runtime|checker|parallel)\.gif$/); -allow(/^media\/hero(_dark)?\.gif$/); -allow(/^media\/logo_(bend|hoc)\.png$/); -allow(/^media\/game_[a-z_]+\.gif$/); -allow(/^media\/slash_boss_3d\/[a-z_]+\.wav$/); -allow(/^gates\/(_lib|_run|perf|ping|repo|test|safe|safe_node|safe_diag)\.ts$/); -allow(/^tests\/[a-z]+\/([a-z0-9-]+\/)?[A-Za-z0-9_]+\.bend$/); -allow(/^tests\/[a-z]+\/[a-z0-9_]+\.(c|js)$/); -allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\.json|tsconfig\.json)$/); -allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); -allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); -allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json)$/); -allow(/^tools\/bend-lint\/src\/(lint\.ts|patch\.ts|lint\.bend|lint\.js)$/); -allow("tools/bend-lint/test/lint.test.ts"); -allow(/^tools\/bend-lint\/rules\/[a-z_]+\.(ts|bend)$/); - -// Gate -// ==== - -function ttok(file: string): number { - const got = child.spawnSync("ttok", [], { input: fs.readFileSync(file) }); - const n = Number(got.stdout?.toString().trim() || NaN); - if (got.status !== 0 || !(n > 0)) { - throw new Error("ttok counted nothing for " + file + " (pipx install ttok)"); - } - return n; -} - -function gate(): string[] { - const fails: string[] = []; - const files = child.execFileSync("git", ["ls-files"], { cwd: lib.ROOT, - encoding: "utf8" }).trim().split("\n"); - for (const file of files) { - if (file.startsWith("evals/")) continue; - const rule = RULES.find((r) => r.at.test(file)); - if (rule === undefined) { - fails.push(file + ": not in the allow list"); - continue; - } - const full = path.join(lib.ROOT, file); - const size = fs.statSync(full).size; - const n = size <= rule.cap ? size : ttok(full); - if (n > rule.cap) { - fails.push(file + ": " + String(n) + " > " + String(rule.cap) + " ttok"); - } - } - return fails; -} - -// Main -// ==== - -if (import.meta.main) { - const fails = gate(); - if (!lib.GATE) { - for (const f of fails) { - console.log("FAIL " + f); - } - } - lib.verdict(RULES.length - Math.min(RULES.length, fails.length), - RULES.length); -} +#!/usr/bin/env bun +// The shape of the repo: every tracked file must match one allow line, +// and the capped files below stay under their ttok caps. Anything else in +// the tree is a failure. +// +// THE CAPS ARE PERMANENT: bend.ts 48k, comp.ts 64k, main.ts 16k, safe.ts +// 24k, bendtt.lean 64k, README.md 4k, GUIDE.md 8k, and no other file has a +// cap. Do NOT raise, lower, add or remove a cap without Taelin's explicit +// authorization. A file over its cap is made smaller by simplification, +// never by moving code out. +// evals/ is not counted: it is the models' arena, not the repo's shape. + +import * as child from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +import * as lib from "./_lib"; + +// Types +// ===== + +type Rule = { at: RegExp; cap: number }; + +// Constants +// ========= + +const RULES: Rule[] = []; + +// Allow +// ===== + +function allow(at: string | RegExp, cap = Infinity): void { + RULES.push({ at: typeof at === "string" ? new RegExp("^" + at + .replace(/[.]/g, "\\.") + "$") : at, cap }); +} + +allow(/^\.github\/ISSUE_TEMPLATE\/(bug|feature|config)\.yml$/); +allow(".github/workflows/repo-gate.yml"); +allow(".gitattributes"); +allow(".gitignore"); +allow("AGENTS.md"); +allow("CHANGELOG.md"); +allow("README.md", 4000); +allow("WONTFIX.txt"); +allow("LICENSE"); +allow("flake.nix"); +allow("bend2/base.bend"); +allow("bend2/bend.ts", 48000); +allow("bend2/comp.ts", 64000); +allow("bend2/main.ts", 16000); +allow("bend2/safe.ts", 24000); +allow("bend2/bendtt.lean", 64000); +allow(/^bend2\/effs\/[a-z0-9_]+\.(c|js)$/); +allow(/^bend2\/pack\/(\.gitignore|package\.json|tsconfig\.json|bun\.lock)$/); +allow(/^bend2\/docs\/(BendRT|BendTT)\/(main\.typ|refs\.bib)$/); +allow("bend2/docs/bend.sublime-syntax"); +allow(/^bend2\/docs\/film\/(film\.ts|[a-z]+\.json)$/); +allow("bend2/docs/gen_anim.ts"); +allow("bend2/docs/gen_charts.ts"); +allow("bend2/docs/gen_gifs.ts"); +allow("bend2/docs/gen_pins.ts"); +allow(/^bend2\/docs\/intro\/[a-z.]+$/); +allow(/^bench\/checker\/[a-z]+_[0-9]+\/main\.(bend|agda|lean|thy|v)$/); +allow(/^bench\/checker\/_pin_\/[a-z0-9_]+\.txt$/); +allow(/^bench\/runtime\/[a-z-]+\/main\.(bend|c|lean|ts)$/); +allow(/^bench\/runtime\/_pin_\/[a-z0-9_]+\.txt$/); +allow(/^demos\/[a-z0-9_]+\/[A-Za-z0-9_]+\.bend$/); +allow(/^demos\/[a-z0-9_]+\/[A-Za-z_]+\.(c|sh|md)$/); +allow(/^demos\/[a-z0-9_]+\/web\/(index\.html|main\.js|bunfig\.toml)$/); +allow("guide/GUIDE.md", 8000); +allow("guide/EFFECTS.md"); +allow("guide/SHADERS.md"); +allow(/^paper\/(BendRT|BendTT)\.pdf$/); +allow(/^media\/intro\.(gif|mp4)$/); +allow("media/kind_devil_theorem.mp4"); +allow(/^media\/(runtime|checker|parallel)\.gif$/); +allow(/^media\/hero(_dark)?\.gif$/); +allow(/^media\/logo_(bend|hoc)\.png$/); +allow(/^media\/game_[a-z_]+\.gif$/); +allow(/^media\/slash_boss_3d\/[a-z_]+\.wav$/); +allow(/^gates\/(_lib|_run|perf|ping|repo|test|safe|safe_node|safe_diag)\.ts$/); +allow(/^tests\/[a-z]+\/([a-z0-9-]+\/)?[A-Za-z0-9_]+\.bend$/); +allow(/^tests\/[a-z]+\/[a-z0-9_]+\.(c|js)$/); +allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\.json|tsconfig\.json)$/); +allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); +allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); +allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json)$/); +allow(/^tools\/bend-lint\/src\/(lint\.ts|patch\.ts|lint\.bend|effects\.js|lint\.test\.ts)$/); +allow(/^tools\/bend-lint\/rules\/[a-z_]+(\.test)?\.(ts|bend)$/); + +// Gate +// ==== + +function ttok(file: string): number { + const got = child.spawnSync("ttok", [], { input: fs.readFileSync(file) }); + const n = Number(got.stdout?.toString().trim() || NaN); + if (got.status !== 0 || !(n > 0)) { + throw new Error("ttok counted nothing for " + file + " (pipx install ttok)"); + } + return n; +} + +function gate(): string[] { + const fails: string[] = []; + const files = child.execFileSync("git", ["ls-files"], { cwd: lib.ROOT, + encoding: "utf8" }).trim().split("\n"); + for (const file of files) { + if (file.startsWith("evals/")) continue; + const rule = RULES.find((r) => r.at.test(file)); + if (rule === undefined) { + fails.push(file + ": not in the allow list"); + continue; + } + const full = path.join(lib.ROOT, file); + const size = fs.statSync(full).size; + const n = size <= rule.cap ? size : ttok(full); + if (n > rule.cap) { + fails.push(file + ": " + String(n) + " > " + String(rule.cap) + " ttok"); + } + } + return fails; +} + +// Main +// ==== + +if (import.meta.main) { + const fails = gate(); + if (!lib.GATE) { + for (const f of fails) { + console.log("FAIL " + f); + } + } + lib.verdict(RULES.length - Math.min(RULES.length, fails.length), + RULES.length); +} diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index b5798376e4..c53db222b7 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -1,144 +1,239 @@ # bend-lint -bend-lint checks a Bend 2 file with bend's own checker, then runs your rules -on it. A rule reads the source text, the checker's result for each term -(type, scope, uses), or both, and returns findings with fixes. Bend itself -does not change. It needs Bun 1.2 or newer. +A linter and formatter for Bend 2. -Tools will eventually live in their own repos, so bend-lint is maintained -separately at [github.com/MattCozendey/bend-lint](https://github.com/MattCozendey/bend-lint). +It runs your file through Bend's type checker first. If that passes, it runs +whatever rules you loaded. Rules are TypeScript or Bend files, and they can see +the source text, the types, variable scope and how often each variable is used. +A rule can also suggest a fix. -## Run +bend-lint is also maintained at +[github.com/MattCozendey/bend-lint](https://github.com/MattCozendey/bend-lint). -From the repo root: +## Setup + +You need Bun 1.2 or newer. Run everything from the repo root. Type checking +needs `bun install` in `tools/bend-lint` first. + +## Running it ```sh -bun tools/bend-lint/src/lint.ts file.bend [--rules rules.ts]... [--rules rule.bend]... [--config bend-lint.json] [--fix | --fix-suggested | --fix-dangerously] [--json] [--bend ] +bun tools/bend-lint/src/lint.ts file.bend --rules tools/bend-lint/rules/format.ts ``` -Findings print in bend's error layout, with a severity, a code, and each fix -as a diff. `--fix` writes the `safe` fixes to the linted file only, never -to its imports (a BendHub package under `~/.bend/lib` is never edited). Equal -edits merge; a fix that clashes with an earlier one is skipped and counted, -and another run applies it. `--fix-suggested` also writes the `suggested` -fixes, which may change behavior; `--fix-dangerously` writes every fix, even -one that may break code. Exit codes: 0 no error, 1 an error (the -file does not check, or a rule found an `error`), 2 bad usage or a tool -failure. "Does not check" means bend's checker rejects it: unlike -`bend --check-only`, bend-lint does not fail a file for relying on -`@unsafe` or foreign code. - -`--json` prints only this, for other tools (ranges as in LSP: 0-based, -UTF-16): +Nothing runs by default. You have to pass `--rules`, and you can pass it more +than once: -```json -{ "ok": true, "findings": [{ "code": "style/comma-space", "severity": "warning", "message": "...", - "def": "main", "path": "/abs/file.bend", "range": { "start": { "line": 25, "character": 13 }, "end": {...} }, - "fixes": [{ "title": "...", "applicability": "safe", "edits": [{ "path": "...", "range": {...}, "text": " " }] }] }] } +```sh +bun tools/bend-lint/src/lint.ts file.bend --rules tools/bend-lint/rules/format.ts --rules tools/bend-lint/rules/file_length.bend ``` -`--bend ` (or `$BEND_DIR`) picks the bend to load: a bend checkout, or -its `bend2` folder. Inside the bend repo, its own bend is the default. -Elsewhere, bend-lint downloads bend's source (see -[Outside the bend repo](#outside-the-bend-repo)). +Add `--fix` to write the fixes back to the file. -## Rules in TypeScript +``` +bun tools/bend-lint/src/lint.ts [--rules ]... [--config ] + [--fix | --fix-suggested | --fix-dangerously] + [--json] [--bend ] +``` -A rule module exports `rules`. A rule has an `id` (`namespace/name`, also -the code of its findings), an optional `facts` (see [Facts](#facts)), and a -`run` that may be `async`: +Every fix is marked `safe`, `suggested` or `dangerous`: -```ts -import type { LintRule } from "./tools/bend-lint/src/lint.ts"; +- `--fix` applies the safe ones. They shouldn't change what the program does. +- `--fix-suggested` adds the suggested ones, which might. +- `--fix-dangerously` applies everything. Expect breakage sometimes. -export const rules: LintRule[] = [{ - id: "style/comma-space", - run: (cx) => [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { - const spn = { file: cx.root.file, beg: m.index! + 1, end: m.index! + 1 }; - return cx.diag({ - message: "Add a space after the comma.", severity: "warning", spn, - fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }], - }); - }), -}, { - id: "demo/var-types", - facts: { kinds: ["Var"] }, - run: (cx) => [...cx.facts!.values()] - .filter((f) => !f.inst) - .map((f) => cx.diag({ message: "type: " + cx.show(f, f.ty), severity: "hint", spn: f.spn, fact: f })), -}]; +Fixes go into the file you passed in, and only if the run had no errors. When +two fixes overlap, the later one is skipped. Run it again to get it. + +Exit code is 0 if there were no errors (warnings don't count), 1 for a checker +or rule error, and 2 for bad arguments or a crash. + +`--json` prints the findings as JSON. The format is in +[JSON output](#json-output). + +## Where Bend comes from + +bend-lint loads Bend's TypeScript source and patches it in memory. To find that +source it tries, in order: + +1. `--bend ` or `$BEND_DIR`. A Bend checkout or its `bend2` folder both work. +2. The Bend checkout around it. This is the case in `tools/bend-lint`. +3. A release downloaded from GitHub. It matches `bend version` if Bend is on + your PATH, otherwise it takes the latest. + +Downloads are cached in `~/.cache/bend-lint/`. `XDG_CACHE_HOME` moves that, and +on Windows it's under `LOCALAPPDATA`. With no network it falls back to the +newest cached release. + +## Rules included + +`format/layout` (`tools/bend-lint/rules/format.ts`) is the formatter. It fixes indentation, +spacing, comments, blank lines between declarations, wrapping and the final +newline. Options: `tabWidth` (default 2) and `wrapAtWidth` (default 100). Both +must be positive integers, or `"never"` for `wrapAtWidth`. The width is a target; +long literals and comments can run past it. Its fix is `safe`. + +Declaration order and literals stay as written. The only comment change is a +space added after `#` when it's missing. Before offering a fix it parses +its own output and compares that to the original program. If they differ you +get a warning and no fix. + +`style/file-length` (`tools/bend-lint/rules/file_length.bend`) warns when the file has more than +`maxLines` lines (default 500). Imports don't count. No fix. + +## Config + +Put a `bend-lint.json`, `bend-lint.js` or `bend-lint.ts` next to the file you're +linting or in any parent folder, or point at one with `--config`. The closest +folder wins. Inside a folder, JSON beats JS beats TS. Only one file is read. + +```json +{ + "rules": { + "format/layout": { "tabWidth": 2, "wrapAtWidth": 100 }, + "style/file-length": { "maxLines": 400, "severity": "warning" } + } +} +``` + +JS and TS configs export the same thing as a named `config`. + +```ts +export const config = { + rules: { + "style/file-length": { maxLines: 400 }, + }, +}; ``` -| `cx` | | -|---|---| -| `root`, `sources` | the linted file and its imports, as on disk | -| `facts` | the facts the rule asked for: per checked term, its type, scope, depth, def, quantity, uses, and span on disk | -| `prior` | the findings of earlier rules | -| `span(s)` | a bend span (e.g. `term.s`), moved to the file on disk | -| `walk`, `binder`, `show`, `same`, `normal`, `uses` | helpers over bend's terms | -| `diag({...})` | a finding; the default severity is `warning` | -| `Bend` | the bend module; use it, do not import `bend2/bend.ts` | +Rules still come from `--rules`; config sets their options and severity. +`"off"` turns a rule off. Severities are `error`, `warning`, `information` and `hint`, and an +`error` stops the rules after it. + +If a rule declares defaults for its options, unknown keys and wrong types are +rejected. Otherwise the rule checks its own options. + +## What you get back + +If Bend's checker rejects the file you get a `bend/check` error and no rules +run. + +## Writing a rule + +A rule is a TypeScript or Bend file. Save one under `rules/`, or anywhere, and load it with `--rules`. + +### TypeScript rules -Severities: `error` (stops the run), `warning`, `information`, `hint`. Fix -levels: `safe` keeps behavior, `suggested` may change it, `dangerous` may -break code. +A module exports `rules: LintRule[]`. A rule has an `id` (`namespace/name`, it +becomes the finding's `code`) and `run(cx, signal)`, which returns diagnostics +and can be async. It can also have `facts` and `options`. If `run` throws or the +signal aborts, the error goes to whoever called the library. -Rules run in order; none runs when the file does not check. A file with a -line exactly `import Base` (as `bend --checkup` reads it) reuses a Base -checked once per process. +Build findings with `cx.diag()`. Severity defaults to `warning`. Rules run in +order and see earlier findings in `cx.prior`. The first `error` ends the run: +the rest of that rule's findings and every later rule are dropped. -## Facts +What's on `cx`: -A fact is what the checker found for one term. A rule gets facts only if it -asks with `facts`: `true` for all of the linted file's, or a filter. A fact -must match each list given; an absent or empty list matches all: +- `root`, `sources`: the linted file and its imports, text as on disk +- `book`: the checked Bend program +- `options`: defaults merged with config +- `facts`: just the facts this rule asked for +- `prior`: findings from earlier rules +- `span(s)`: Bend span to a file on disk +- `walk(tm)`: walk Bend terms +- `binder(fact, v)`: find a variable's binder +- `show`, `same`, `normal`: print, compare and normalize types in a fact's scope +- `uses(fact)`: used variables with their quantities +- `diag(init)`: make a diagnostic with this rule's ID +- `Bend`: the loaded Bend module. Use it instead of importing `bend2/bend.ts` + +The exported types in [src/lint.ts](src/lint.ts) are the full contract. + +#### Fixes + +A fix is a title, an applicability (`safe`, `suggested`, `dangerous`) and edits. +An edit swaps a span for text. A zero-length span inserts. Offsets are integer +UTF-16 units inside the source, and edits in one fix can't overlap. + +```ts +const spn = { file: cx.root.file, beg: 0, end: 1 }; +return [cx.diag({ + message: "Replace the first character with a space.", + spn, + fixes: [{ + title: "Replace character", + applicability: "suggested", + edits: [{ spn, text: " " }], + }], +})]; +``` + +(Needs a nonempty file.) + +#### Facts + +A fact is one checked term with its type (`ty`), scope (`ctx`), depth (`dep`), +enclosing definition (`def`), demanded quantity (`qt`), variable uses (`us`), +book (`bok`) and source span (`spn`, when there is one). The term itself is `tm`. + +Ask for them with `facts: true` (everything in the linted file) or a filter: ```ts facts: { - scope: "file", // "file" (default): the linted file; "program": its imports too, never Base - kinds: ["Var"], // the term's kind, annotations stripped: Var, Ref, App, Lam, Lit, ... - defs: ["main"], // the def whose body holds the term; a template's instances count as it - names: ["foo"], // the name a Var or Ref points to + scope: "file", // default. "program" adds imports, not Base + kinds: ["Var"], // term kind without annotations: Var, Ref, App, ... + defs: ["main"], // enclosing definition, instances count as their template + names: ["foo"], // name used by a Var or Ref } ``` -bend-lint keeps a fact only if some rule asks for it, and drops the rest as -the checker gives them, so a narrow filter costs little. Each rule gets only -its own. On a 3,200-proof file (1.27M facts) peak memory was 0.77 GB with no -facts, 2.6 GB with all, 1.5 GB with `kinds: ["Var"]`. A template body is -checked as written and again per instance (`generic~0`) at the same spans; -`fact.inst` marks the instances. +All the lists you give must match. Leave one out or empty and it matches +everything. Keep filters narrow, since memory goes up with the number of facts. -## Options +Template bodies get checked as written and again per instance (`generic~0`), +sometimes at the same span. `fact.inst` marks instances. Skip them to avoid +double reports: -A rule may declare options with their defaults (`options: { tabWidth: 2, -breakLines: false }`) and read them from `cx.options`. A `bend-lint.json` in -the file's folder or above it (or the one `--config` gives) sets them, and -can also turn a rule off or change its severity: +```ts +import type { LintRule } from "../src/lint.ts"; -```json -{ "rules": { - "format/indent": { "tabWidth": 4, "breakLines": true }, - "style/comma-space": "off", - "demo/var-types": { "severity": "hint" } -} } +export const rules: LintRule[] = [{ + id: "demo/var-types", + facts: { kinds: ["Var"] }, + run: (cx) => [...cx.facts!.values()] + .filter((fact) => !fact.inst) + .map((fact) => cx.diag({ + message: "type: " + cx.show(fact, fact.ty), + severity: "hint", + spn: fact.spn, + fact, + })), +}]; ``` -An unknown option, or a value whose type differs from its default, stops -bend-lint with an error. From code: `lint(file, rules, { config })`. +(Saved under `rules/`.) + +#### Options -## Rules in Bend +`options: { tabWidth: 2, breakLines: false }` sets defaults. They also set which +keys are allowed and their types (`number`, `boolean`, `string`). Unknown keys +and wrong types are errors. A rule with no defaults gets the raw options and has +to check them itself. -A `.bend` rule imports `src/lint.bend`, which lists the contract: +### Bend + +A `.bend` rule imports [src/lint.bend](src/lint.bend). Here's one that reports +nothing, saved under `rules/`: ```python import Base -import ../tools/bend-lint/src/lint.bend as Lint +import ../src/lint.bend as Lint def id() -> String: "demo/nothing" -def facts() -> Lint.Want: # Lint.NoFacts{}, or Lint.Want{scope, kinds, defs, names} +def facts() -> Lint.Want: Lint.NoFacts{} def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): @@ -148,10 +243,11 @@ def main() -> IO(Unit): Lint.serve(run) ``` -`input` holds the sources and the rule's options. A rule that wants facts -returns a filter, as in [Facts](#facts), with empty lists for "all": -`Lint.Want{Lint.File{}, ["Var"], [], []}`. It then reads them one at a time, -so a big file never becomes one big list: +`input` has the sources and options. To get facts, return a filter from +`facts()`, e.g. `Lint.Want{Lint.File{}, ["Var"], [], []}`. Empty lists match +everything, and `Lint.Program{}` adds imports (not Base). + +Read facts one by one with `next_fact`, or fold: ```python def step(found: List<&2, Lint.Diag>, +f: Lint.Fact) -> IO(List<&2, Lint.Diag>): @@ -161,91 +257,129 @@ def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): Lint.fold_facts(~List<&2, Lint.Diag>, ~step, []) ``` -`fold_facts` takes `step` as a template (`~step`), and `step` takes its fact -as `+f`. Read an option with `Lint.option_number`, `option_flag` or -`option_text`, each with a default; numbers are whole (U32). A rule asks about -a fact with effects (`Lint.view`, `type_of`, `binder`, `same`, `show`, -`normal`, `uses`, `text`) and may use Base's effects too. Offsets count -characters. bend-lint compiles a rule once; each run then takes about 10 ms. -A rule runs on bend's JS runtime, where only tail calls run as loops: walk -a long text or list with a tail call, or the stack overflows. -`COMMA_BEND`, `TYPES_BEND` and `COUNT_BEND` in `test/lint.test.ts` are -complete examples. +`step` goes in as a template (`~step`) and takes the fact as `+f`. Effects: +`view`, `type_of`, `binder`, `same`, `show`, `normal`, `uses`, `text`, plus +Base's. Options come from `Lint.option_number`, `option_flag` and `option_text`, +each with a default. Numbers are whole, 0 to 4294967295 (U32). -## Rules shipped here +Spans count Unicode code points here. TypeScript counts UTF-16 units. -`rules/` holds ready rules, one per file. None runs unless you pass it with -`--rules`, and `bend-lint.json` can turn one `"off"`: +The rule is compiled once and runs on Bend's JavaScript runtime. Use tail +recursion over long text or lists to stay inside the stack. Streaming facts +saves building a list of all of them. -| File | Rule | What | Fix | -| --- | --- | --- | --- | -| `trailing_whitespace.ts` | `style/trailing-whitespace` | spaces or tabs at the end of a line | safe: delete them | -| `line_length.bend` | `style/line-length` | a line longer than `max` characters (default 100) | none | +For examples see [file_length.bend](rules/file_length.bend) and +[shared.bend](rules/shared.bend). `COMMA_BEND`, `TYPES_BEND` and `COUNT_BEND` in +[src/lint.test.ts](src/lint.test.ts) cover fixes and facts. -```sh -bun tools/bend-lint/src/lint.ts file.bend --rules tools/bend-lint/rules/trailing_whitespace.ts --rules tools/bend-lint/rules/line_length.bend +### As a library + +From a file in `tools/bend-lint`: + +```ts +import { applyFixes, bendRule, lint, render } from "./src/lint.ts"; +import { rules } from "./rules/format.ts"; + +const result = await lint("example.bend", [ + ...rules, + await bendRule("rules/file_length.bend"), +]); +console.log(result.ok, result.diags.map(render)); + +if (result.ok) { + const root = result.sources.find((source) => source.root)!; + const { text, skipped } = applyFixes(root.file, result.diags); + // text is the edited source; saving it is up to you + console.log(text, skipped); +} ``` -To add a rule, add one file: a `.ts` file that exports `rules`, or a `.bend` -file as above. Helpers used by more than one rule go in `rules/shared.ts` -or `rules/shared.bend`. +Set `BEND_DIR` before importing bend-lint to pick a Bend checkout. Import +bend-lint before you load Bend yourself, so the patching happens first. `lint` +takes `{ config, signal }` as a third argument. Without `config` it looks next to +the file. `position(span)` gives an LSP range. -## From code +`findConfig(file)` checks the file's directory, then each parent, for +`bend-lint.json`, `.js` or `.ts`. Closest directory wins, then JSON, JS, TS. +`readConfig(file)` loads a path you give it. Both are synchronous. JS and TS +configs export a named `config` object and go through Bun's loader, cache +included. They can import relative files. Rule settings and option validation +are the same for all three formats. -```ts -import { applyFixes, bendRule, lint, render } from "./tools/bend-lint/src/lint.ts"; +### JSON output + +`--json` prints one JSON object on stdout. Status and failure messages can still +show up on stderr. A finding with a fix: -const res = await lint("file.bend", [...rules, await bendRule("rule.bend")]); -console.log(res.ok, res.diags.map(render)); -const { text, skipped } = applyFixes(res.sources.find((s) => s.root)!.file, res.diags); +```json +{ + "ok": true, + "findings": [{ + "code": "style/example", + "severity": "warning", + "message": "Replace this character.", + "def": "main", + "path": "/abs/example.bend", + "range": { + "start": { "line": 2, "character": 0 }, + "end": { "line": 2, "character": 1 } + }, + "fixes": [{ + "title": "Replace character", + "applicability": "suggested", + "edits": [{ + "path": "/abs/example.bend", + "range": { + "start": { "line": 2, "character": 0 }, + "end": { "line": 2, "character": 1 } + }, + "text": " " + }] + }] + }] +} ``` -A library picks its bend with `$BEND_DIR`, set before it imports bend-lint. -`position(span)` gives the LSP range of a span. +`def`, `path` and `range` are left out when there's nothing to put. Lines start +at 0, characters are UTF-16. With a fix flag, findings describe the source as it +was before the fixes. ## How it works -- `src/lint.ts`: the library and the CLI. -- `src/patch.ts`: what bend-lint changes in bend2 as Bun loads it; the files - on disk never change. bend.ts's `term_infer` and `term_check` are renamed - and wrapped by typed functions in `patch.ts` that record what they - return, and bend.ts gets an `fs` and `path` whose real paths use `/` (so - imports resolve on Windows; on POSIX they act as node's). comp.ts exports - `RUNTIME_MAIN` and `js_sat`, so a Bend rule is compiled once. -- `src/lint.bend`, `src/lint.js`: the contract for Bend rules, and its - effects. - -bend-lint is not pinned to a bend version; it checks what it depends on -instead. Each text edit must match exactly once, and comp.ts must declare -what it exports. The wrappers pass every argument through, so bend computes -what it would without them; `tsc` checks them against bend's signatures, -and at load they must take the expected number of arguments. A self-check -then checks every field recorded for `x` in `def id(x: N) -> N: x`. Any -mismatch stops bend-lint with a `DriftError`, never a wrong result. The -tests catch subtler changes. - -## Outside the bend repo - -bend-lint loads bend's source, not the `bend` program. With no `--bend` -or `$BEND_DIR`, and no bend repo around it, it downloads the source of one -bend release from GitHub: the version of the `bend` on your PATH (`bend -version`), else the newest release (asked once a day). It takes only -`bend2/bend.ts`, `comp.ts`, `base.bend` and the `effs/` files Base imports -(about 0.6 MB), and keeps them in `~/.cache/bend-lint//` -(`$XDG_CACHE_HOME`, or `%LOCALAPPDATA%` on Windows). Later runs use the -cache, with no network; offline, it uses the newest release cached. If a -release changes what `src/patch.ts` edits, bend-lint stops with a -DriftError, as it does for a checkout. For `tsc`, run `bun install` (for `@types/bun`) and point the -`bend2/*` path in `tsconfig.json` at that checkout's `bend2/`. The drift -tests read the checkout's own `tests/`. - -## Test +### Patching + +Bun patches Bend's modules while they load. `term_infer` and `term_check` are +renamed and wrapped to record what they return. The wrappers pass every argument +on. The `fs` and `path` adapters turn real paths into `/` paths so imports +resolve on Windows. `comp.ts` also exports `RUNTIME_MAIN` and `js_sat`, which +compiling Bend rules needs. + +If a file has a line that's exactly `import Base` (what `bend --checkup` reads), +it reuses one Base, checked once per process. Base facts are left out of rule +requests. + +### Staying compatible + +We don't pin a Bend version. Instead, on load, for checkouts and downloads +alike: + +- every source edit has to match exactly once, and the compiler exports we need + have to be declared +- wrapper signatures are checked against Bend's at type-check time, and argument + counts at runtime +- a self-check looks at the type, depth, scope, quantity, uses and span recorded + for `x` in `def id(x: N) -> N: x` + +Any mismatch throws `DriftError` and loading stops. The tests add more, including +drift against the chosen checkout's own tests. + +## Tests ```sh bun test tools/bend-lint bunx tsc -p tools/bend-lint/tsconfig.json --noEmit ``` -Bun does not check types, so run `tsc` too. It reports two errors in -bend2/bend.ts (lines 2040 and 3771), which are on main too; bend-lint has -none. +Bun doesn't type check, so run `tsc` as well (`bun install` in `tools/bend-lint` provides `@types/bun`). +`tsc` reports two errors in `bend2/bend.ts` (lines 2040 and 3771), which are in +Bend itself. diff --git a/tools/bend-lint/rules/file_length.bend b/tools/bend-lint/rules/file_length.bend new file mode 100644 index 0000000000..1ab8e10904 --- /dev/null +++ b/tools/bend-lint/rules/file_length.bend @@ -0,0 +1,46 @@ +# style/file-length: warn when a source file exceeds maxLines (default 500). +# Formatting cannot shorten a file's logical contents; no fix is offered. +import Base +import ../src/lint.bend as Lint +import ./shared.bend as Shared + +def id() -> String: + "style/file-length" + +def facts() -> Lint.Want: + Lint.NoFacts{} + +def finding(long: Bool, path: String, max: U32, count: U32) -> List<&2, Lint.Diag>: + match long: + case True{}: + [ + Lint.Diag{ + Lint.Warning{}, + "Keep the file to " ++ U32.show(max) ++ " lines; it has " ++ U32.show(count) ++ ".", + Some{Lint.Span{path, 0, 0}}, + [] + } + ] + case False{}: + [] + +def check_source(+max: U32, path: String, +count: U32) -> List<&2, Lint.Diag>: + finding(U32.is_gt(count, max), path, max, count) + +def check(+max: U32, source: Maybe<&2, Lint.Source>) -> List<&2, Lint.Diag>: + match source: + case None{}: + [] + case Some{Lint.Source{path, text, root}}: + check_source(max, path, Shared.line_count(text)) + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + match input: + case Lint.Input{sources, options}: + IO.pure( + List<&2, Lint.Diag>, + check(Lint.option_number(options, "maxLines", 500), Shared.root(sources)) + ) + +def main() -> IO(Unit): + Lint.serve(run) diff --git a/tools/bend-lint/rules/file_length.test.ts b/tools/bend-lint/rules/file_length.test.ts new file mode 100644 index 0000000000..357153a469 --- /dev/null +++ b/tools/bend-lint/rules/file_length.test.ts @@ -0,0 +1,59 @@ +import { afterAll, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { bendRule, lint } from "../src/lint.ts"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-file-length-")); +afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); +const rule = await bendRule(fileURLToPath(new URL("./file_length.bend", import.meta.url))); +const fixture = (text: string, name = "main.bend") => { + const file = path.join(dir, name); + fs.writeFileSync(file, text); + return file; +}; +const config = (maxLines: number) => ({ rules: { "style/file-length": { maxLines } } }); + +test("Bend file length uses a default limit of 500 and offers no fix", async () => { + const file = fixture("import Base\n" + "# padding\n".repeat(498) + "def main() -> U32:\n 1\n"); + const result = await lint(file, [rule]); + expect(result.ok).toBe(true); + expect(result.diags.map((d) => [d.code, d.message, d.fixes])).toEqual([ + ["style/file-length", "Keep the file to 500 lines; it has 501.", []], + ]); + expect((await lint(file, [rule], { config: config(501) })).diags).toEqual([]); +}); + +test("physical line counting handles empty files, LF, CRLF and absent final newlines", async () => { + for (const [text, count] of [ + ["", 0], ["# one", 1], ["# one\n", 1], ["# one\r\n", 1], + ["\n", 1], ["\r\n", 1], ["# one\n\n", 2], ["# one\r\n\r\n", 2], + ] as const) { + const result = await lint(fixture(text), [rule], { config: config(count) }); + expect([result.ok, result.diags]).toEqual([true, []]); + if (count > 0) { + const over = await lint(fixture(text), [rule], { config: config(count - 1) }); + expect(over.diags[0].message).toBe("Keep the file to " + (count - 1) + " lines; it has " + count + "."); + } + } +}); + +test("only the root file counts, not its imports", async () => { + fixture("import Base\n" + "# dependency\n".repeat(600) + "def value() -> U32:\n 1\n", "dependency.bend"); + const file = fixture("import Base\nimport ./dependency.bend as D\ndef main() -> U32:\n D.value()\n"); + expect((await lint(file, [rule], { config: config(4) })).diags).toEqual([]); +}); + +test("the Bend rule honors disable and severity configuration", async () => { + const file = fixture("# first\n# second\n"); + expect((await lint(file, [rule], { config: { rules: { "style/file-length": "off" } } })).diags).toEqual([]); + const result = await lint(file, [rule], { config: { rules: { "style/file-length": { maxLines: 1, severity: "hint" } } } }); + expect(result.diags.map((d) => d.severity)).toEqual(["hint"]); +}); + +test("the Bend line scanner handles long files without overflowing the JS stack", async () => { + const result = await lint(fixture("# padding\n".repeat(6000)), [rule]); + expect(result.ok).toBe(true); + expect(result.diags[0].message).toBe("Keep the file to 500 lines; it has 6000."); +}); diff --git a/tools/bend-lint/rules/format.test.ts b/tools/bend-lint/rules/format.test.ts new file mode 100644 index 0000000000..0dbd853fb3 --- /dev/null +++ b/tools/bend-lint/rules/format.test.ts @@ -0,0 +1,180 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { Bend, lint, applyFixes, walk, render } from "../src/lint.ts"; +import { format, formatOptions, rules, sameProgram } from "./format.ts"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-format-")); +afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); +const opts = { tabWidth: 2, wrapAtWidth: 100 } as const; +const fixture = (text: string, name = "main.bend") => { + const file = path.join(dir, name); + fs.writeFileSync(file, text); + return file; +}; +async function fixed(text: string, options = opts as { tabWidth: number; wrapAtWidth: number | "never" }) { + const result = await lint(fixture(text), rules, { config: { rules: { "format/layout": options } } }); + if (!result.ok) throw new Error(result.diags.map(render).join("\n")); + expect(result.ok).toBe(true); + expect(result.diags.every((d) => d.fixes.length === 1)).toBe(true); + const source = result.sources.find((s) => s.root)!; + const output = applyFixes(source.file, result.diags).text; + expect(format(output, options)).toBe(output); + const again = await lint(fixture(output), rules, { config: { rules: { "format/layout": options } } }); + expect([again.ok, again.diags]).toEqual([true, []]); + const bodies = [result.book, again.book].map((book) => Object.fromEntries(book.order.flatMap((name) => { + const tld = book.tlds[name]; + return tld.$ === "Def" && tld.e !== undefined && !tld.b ? [[name, Bend.term_show(tld.e)]] : []; + }))); + expect(bodies[1]).toEqual(bodies[0]); + return output; +} + +describe("format/layout", () => { + test("ordinary names cannot open delimiter groups", () => { + const source = "import Base\ndef constructor() -> U32: 1\ndef toString() -> U32: 2\ndef valueOf() -> U32: 3\n"; + expect(format(source, opts)).toBe("import Base\n\ndef constructor() -> U32:\n 1\n\ndef toString() -> U32:\n 2\n\ndef valueOf() -> U32:\n 3\n"); + }); + test("validates its own defaults, union and unknown options", () => { + expect(formatOptions({})).toEqual(opts); + expect(formatOptions({ wrapAtWidth: "never" })).toEqual({ ...opts, wrapAtWidth: "never" }); + for (const bad of [0, -1, 1.5, NaN, Infinity, "2", true]) { + expect(() => formatOptions({ tabWidth: bad })).toThrow("positive integer"); + expect(() => formatOptions({ wrapAtWidth: bad })).toThrow('"never" or a positive integer'); + } + expect(() => formatOptions({ wrapAtWidth: "always" })).toThrow(); + expect(() => formatOptions({ breakLines: true })).toThrow("no option breakLines"); + }); + + test("one sweep formats spacing, indentation, gaps, CRLF and final newline", async () => { + const output = await fixed('import Base\r\n\r\n\r\ntype N is Data:\r\n Z{} \r\n S{p:N}\r\ndef id(x:N)->N:\r\n\tx \r\ndef main()->N: id(S{Z{}})'); + expect(output).toBe("import Base\n\ntype N is Data:\n Z{}\n S{p: N}\n\ndef id(x: N) -> N:\n x\n\ndef main() -> N:\n id(S{Z{}})\n"); + }); + + test("wrapped lists use one item per line and relative indentation", async () => { + const source = "import Base\ndef choose(first: U32, second: U32, third: U32) -> U32:\n first\ndef main() -> U32:\n choose(123456789, 234567890, 345678901)\n"; + const output = await fixed(source, { tabWidth: 4, wrapAtWidth: 28 }); + expect(output).toContain("def choose(\n first: U32,\n second: U32,\n third: U32\n) -> U32:"); + expect(output).toContain(" choose(\n 123456789,\n 234567890,\n 345678901\n )"); + }); + + test('"never" collapses optional wrapping but retains statement boundaries', async () => { + const source = "import Base\ndef f(x: U32, y: U32) -> U32:\n x\ndef main() -> U32:\n x = f(\n 1,\n 2\n )\n f(x, 3)\n"; + expect(await fixed(source, { tabWidth: 2, wrapAtWidth: "never" })).toContain(" x = f(1, 2)\n f(x, 3)"); + }); + + test("preserves strings, escapes, char literals and comments", async () => { + const source = String.raw`import Base +# header,= and http://example.org +def main() -> String: + "a,b=c # quoted \"" ++ "longer-than-width" # trailing,=comment +`; + const output = await fixed(source, { tabWidth: 2, wrapAtWidth: 15 }); + expect(output).toContain('"a,b=c # quoted \\\""'); + expect(output).toContain("# header,= and http://example.org"); + expect(output).toContain("# trailing,=comment"); + expect(await fixed("import Base\ndef main() -> Char:\n '='\n")).toContain(" '='"); + }); + + test("keeps inline argument comments attached when wrapping", async () => { + const source = "import Base\ndef f(x: U32, y: U32) -> U32:\n x\ndef main() -> U32:\n f(123, # first\n 456 # second\n )\n"; + const output = await fixed(source, { tabWidth: 2, wrapAtWidth: 20 }); + expect(output).toContain("123, # first\n"); + expect(output).toContain("456 # second\n"); + }); + + test("comment markers get a space, while literals and special markers stay intact", async () => { + const source = String.raw`import Base #import-comment +#header +##Heading +#!directive +#|expectation +# +# already spaced +def apostrophe() -> Char: + '\'' +def main() -> String: + "${String.fromCodePoint(0x1f600)}#inside \"#escaped\"" #tail +`; + const output = await fixed(source.replaceAll("\n", "\r\n")); + expect(output).toContain("import Base # import-comment"); + expect(output).toContain("# header\n##Heading\n#!directive\n#|expectation\n#\n# already spaced"); + const literal = source.split("\n").find((line) => line.includes("#inside"))!.split(" #tail")[0].trim(); + expect(output).toContain(literal); + expect(output).toContain(String.fromCodePoint(0x1f600)); + expect(output).toContain(" # tail"); + const multiline = 'import Base\ndef main() -> String:\n "first line\n#inside literal\nlast line" #outside\n'; + expect(await fixed(multiline)).toContain('#inside literal\nlast line" # outside'); + }); + + test("handles nested type arguments, successors, quantities and templates", async () => { + await fixed("import Base\ndef identity(~T: Type, x: T) -> T:\n x\ndef main() -> List>:\n identity(~List>, [[1, 2], [3]])\n", { tabWidth: 2, wrapAtWidth: 25 }); + await fixed("import Base\ndef successor(+x: Nat) -> Nat:\n 1n+x\ndef main() -> Nat:\n successor(2n)\n"); + await fixed("import Base\ntype Box<-Value: Data> is Data:\n Box{value: Value}\ndef main() -> Box:\n Box{1}\n", { tabWidth: 4, wrapAtWidth: 18 }); + await fixed("import Base\ndef main() -> List:\n List.map(~U32, ~U32, ~(x => (x + 123456789 : U32)), [1, 2, 3, 4])\n", { tabWidth: 4, wrapAtWidth: 30 }); + await fixed("import Base\ndef identity(-T: Type, x: T) -> T:\n x\ndef main() -> U32:\n identity(U32 {1 : U32})\n"); + }); + + test("preserves nested match, do and lambda body scopes", async () => { + await fixed("import Base\ndef f(x: Nat) -> Nat:\n match x:\n case 0n:\n 0n\n case 1n+p:\n p\ndef main() -> Nat:\n f(1n)\n", { tabWidth: 4, wrapAtWidth: 30 }); + await fixed("import Base\ndef main() -> IO(Unit):\n do IO:\n IO.print(\"a long message\")\n", { tabWidth: 4, wrapAtWidth: 18 }); + await fixed("import Base\ndef main() -> U32:\n f: U32 -> U32 = x => (\n y = x\n y\n )\n f(1)\n", { tabWidth: 4, wrapAtWidth: 20 }); + }); + + test("law/proof and unsafe syntax are preserved", async () => { + await fixed("import Base\nlaw same:\n for x: U32\n {x == x : U32}\ndef same(x):\n {==}\n@unsafe\ndef identity(x: U32) -> U32:\n x\ndef main() -> U32:\n identity(1)\n"); + }); + + test("imports and imported law fills survive the parser guard", async () => { + fixture("import Base\nlaw same:\n for x: U32\n {x == x : U32}\n", "LAWS.bend"); + await fixed("import Base\nimport ./LAWS.bend as Laws\ndef Laws.same(x):\n {==}\n", opts); + }); + + test("the guard rejects a changed value or scope", async () => { + const result = await lint(fixture("import Base\ndef main() -> U32:\n 1\n"), []); + const cx = { Bend, book: result.book, sources: result.sources, root: result.sources.find((s) => s.root)!, walk }; + expect(sameProgram(cx, "import Base\ndef main() -> U32:\n 2\n")).toBe(false); + expect(sameProgram(cx, format(cx.root.text, opts))).toBe(true); + }); + + test("configuration passes the union through the unchanged engine", async () => { + await fixed("import Base\ndef main() -> U32: 1\n", { tabWidth: 2, wrapAtWidth: "never" }); + await expect(lint(fixture("import Base\ndef main() -> U32: 1\n"), rules, + { config: { rules: { "format/layout": { wrapAtWidth: 0 } } } })).rejects.toThrow("positive integer"); + }); + + test("operator chains wrap at boundaries and stay stable", async () => { + const source = 'import Base\ndef main() -> String:\n "aaaaaaaa" ++ "bbbbbbbb" ++ "cccccccc"\n'; + const output = await fixed(source, { tabWidth: 2, wrapAtWidth: 20 }); + expect(output).toContain(' "aaaaaaaa"\n ++ "bbbbbbbb"\n ++ "cccccccc"'); + expect(await fixed(source, { tabWidth: 2, wrapAtWidth: "never" })).toContain(' "aaaaaaaa" ++ "bbbbbbbb" ++ "cccccccc"'); + }); + + test("empty files, comments and indivisible tokens have a canonical ending", () => { + expect(format("", opts)).toBe("\n"); + expect(format("\n\n# comment\n\n", opts)).toBe("# comment\n"); + const source = 'import Base\ndef main() -> String:\n "an indivisible literal with spaces"\n'; + const output = format(source, { tabWidth: 1, wrapAtWidth: 1 }); + expect(output).toContain(' "an indivisible literal with spaces"'); + expect(format(output, { tabWidth: 1, wrapAtWidth: 1 })).toBe(output); + }); + + test("CLI --fix writes the same whole-file formatting in one sweep", () => { + const file = fixture("import Base\ndef main()->U32: 1 \n"); + const config = fixture(JSON.stringify({ rules: { "format/layout": { tabWidth: 4, wrapAtWidth: "never" } } }), "config.json"); + const cli = fileURLToPath(new URL("../src/lint.ts", import.meta.url)); + const rule = fileURLToPath(new URL("./format.ts", import.meta.url)); + const args = [cli, file, "--rules", rule, "--config", config, "--fix", "--json"]; + const result = spawnSync(process.execPath, args, { encoding: "utf8" }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("bend-lint: fixed "); + const text = fs.readFileSync(file, "utf8"); + expect(text).toBe("import Base\n\ndef main() -> U32:\n 1\n"); + const again = spawnSync(process.execPath, args, { encoding: "utf8" }); + expect([again.status, again.stderr, JSON.parse(again.stdout)]).toEqual([0, "", { ok: true, findings: [] }]); + expect(fs.readFileSync(file, "utf8")).toBe(text); + }); +}); diff --git a/tools/bend-lint/rules/format.ts b/tools/bend-lint/rules/format.ts new file mode 100644 index 0000000000..6d446fe61b --- /dev/null +++ b/tools/bend-lint/rules/format.ts @@ -0,0 +1,370 @@ +// One formatter owns indentation, spacing, declaration gaps and wrapping. +// No engine option defaults: wrapAtWidth is a number | "never", and the +// existing engine accepts mixed types when the rule validates its options. +// Run: bun src/lint.ts file.bend --rules rules/format.ts --fix +// Config: { "rules": { "format/layout": { "tabWidth": 2, "wrapAtWidth": 100 } } } +// Both numbers are positive integers; "never" disables optional wrapping. +import * as path from "node:path"; +import type { Options, RuleContext, LintRule } from "../src/lint.ts"; +import type { Book, HTerm } from "bend2/bend.ts"; + +export type FormatOptions = { tabWidth: number; wrapAtWidth: number | "never" }; +type Token = { text: string; beg: number; end: number; col: number; kind: "code" | "literal" | "comment" | "newline" }; +type Node = Token | { open: Token; close: Token; children: Node[] }; +type Doc = string | { kind: "line"; flat: string; hard?: boolean; offset?: number } + | { kind: "group" | "nest"; doc: Doc; amount?: number } | Doc[]; +type Frame = { doc: Doc; indent: number; flat: boolean }; +const DELIMITERS = new Map([["(", ")"], ["[", "]"], ["{", "}"], ["<", ">"]]); + +export function formatOptions(options: Options): FormatOptions { + const { tabWidth = 2, wrapAtWidth = 100 } = options; + const unknown = Object.keys(options).find((k) => k !== "tabWidth" && k !== "wrapAtWidth"); + if (unknown) throw new Error("format/layout has no option " + unknown); + const positive = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v > 0; + if (!positive(tabWidth)) throw new Error("format/layout: tabWidth must be a positive integer"); + if (wrapAtWidth !== "never" && !positive(wrapAtWidth)) { + throw new Error('format/layout: wrapAtWidth must be "never" or a positive integer'); + } + return { tabWidth, wrapAtWidth }; +} + +// Preserve literals and comment text verbatim. In particular, a quote or # +// inside a string is not syntax; successor notation and quantities are atoms. +function tokens(source: string): Token[] { + const out: Token[] = []; + let at = 0, start = 0; + while (at < source.length) { + const beg = at, c = source[at]; + if (c === " " || c === "\t" || c === "\r") { at++; continue; } + let kind: Token["kind"] = "code"; + if (c === "\n") { at++; kind = "newline"; } + else if (c === "#") { + while (at < source.length && source[at] !== "\n" && source[at] !== "\r") at++; + kind = "comment"; + } else if (c === '"' || c === "'") { + at++; + while (at < source.length) { + if (source[at] === "\\") { at += 2; continue; } + if (source[at++] === c) break; + } + kind = "literal"; + } else { + const word = /^[A-Za-z_][\w]*(?:\.[A-Za-z_]\w*)*/.exec(source.slice(at)); + const number = /^\d+(?:n\+?|\.\d+(?:[eE][+-]?\d+)?)/.exec(source.slice(at)) + ?? /^\d+/.exec(source.slice(at)); + const symbol = /^(?:<&>|\.&\.|\.\|\.|\.\^\.|->|=>|<-|==|!=|<=|>=|<<|&&|\|\||\+\+|<>|&[012])/.exec(source.slice(at)); + if (source.startsWith("<-", at) && /[\w.]/.test(source[at - 1] ?? "")) at++; + else if (source.startsWith(">>", at) && /\s/.test(source[at - 1] ?? "")) at += 2; + else at += (word?.[0] ?? number?.[0] ?? symbol?.[0] ?? c).length; + } + out.push({ text: source.slice(beg, at), beg, end: at, col: beg - start, kind }); + if (kind === "newline") start = at; + else if (kind === "literal") { + const newline = out[out.length - 1].text.lastIndexOf("\n"); + if (newline >= 0) start = beg + newline + 1; + } + } + return out; +} + +function tree(ts: Token[]): Node[] { + const root: Node[] = []; + const stack: Array<{ children: Node[]; group?: Extract }> = [{ children: root }]; + for (let i = 0; i < ts.length; i++) { + const t = ts[i], prev = ts[i - 1]; + const close = DELIMITERS.get(t.text); + // Bend distinguishes type arguments from comparisons by a glued '<'. + const angle = t.text === "<" && prev?.end === t.beg && /^[\w.]+$/.test(prev.text); + const frame = stack[stack.length - 1]; + if (close && (t.text !== "<" || angle)) { + const group = { open: t, close: { ...t, text: close }, children: [] as Node[] }; + frame.children.push(group); + stack.push({ children: group.children, group }); + } else if (frame.group?.close.text === t.text) { + frame.group.close = t; + stack.pop(); + } else frame.children.push(t); + } + if (stack.length !== 1) throw new Error("format/layout: unbalanced delimiters"); + return root; +} + +const first = (n: Node): Token => "open" in n ? n.open : n; +const last = (n: Node): Token => "open" in n ? n.close : n; +const soft = (flat = " "): Doc => ({ kind: "line", flat }); +const hard = (offset = 0): Doc => ({ kind: "line", flat: "", hard: true, offset }); +const nest = (doc: Doc, amount: number): Doc => ({ kind: "nest", doc, amount }); +const group = (doc: Doc): Doc => ({ kind: "group", doc }); +const OPERATORS = new Set(["+", "-", "*", "/", "%", "^", "++", "&&", "||", "<&>", + ".&.", ".|.", ".^.", "<<", ">>", "<>", "<", ">", "<=", ">=", "==", "!=", "&", "|"]); + +function gluedPrefix(a: Token, b: Node | undefined): boolean { + return ["+", "-", "%", "&"].includes(a.text) && b !== undefined && a.end === first(b).beg; +} + +function separator(a: Node, b: Node): string { + const x = last(a), y = first(b), l = x.text, r = y.text; + if (y.kind === "comment") return " "; + if (r === "," || r === ";" || r === ":" || r === "?" || r === "!" || r === ".") return ""; + if (l === "," || l === ";" || l === ":") return " "; + if (["~", "@", "?", "!", "\\"].includes(l)) return ""; + if (gluedPrefix(x, b)) return ""; + if (/n\+$/.test(l)) return ""; + // A constructor brace must be glued to its name; a separated brace can + // instead be the next (annotated) argument in a comma-optional call. + if ("open" in b && r === "{" && x.end !== y.beg) return " "; + if ("open" in b && ["(", "[", "{", "<"].includes(r)) { + if ((/^[\w.]+$/.test(l) || l === "!" || [")", "]", "}"].includes(l)) + && !["return", "case", "match", "for", "exs", "is"].includes(l)) return ""; + } + // Module paths are printed separately; other atoms need a separator. + return " "; +} + +function sequence(nodes: Node[], source: string, opts: FormatOptions, base: number, raw = false): Doc { + const out: Doc[] = []; + const levels = [base]; + let continuation: Doc[] | undefined; + let prev: Node | undefined; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i], t = first(n); + if (!("open" in n) && t.kind === "newline") { + if (raw || prev && last(prev).kind === "comment") { + let next = i + 1; + while (next < nodes.length && first(nodes[next]).kind === "newline") next++; + if (next < nodes.length) { + const col = first(nodes[next]).col; + while (levels.length > 1 && col < levels[levels.length - 1]) levels.pop(); + if (col > levels[levels.length - 1]) levels.push(col); + (continuation ?? out).push(hard((levels.length - 1) * opts.tabWidth)); + } else if (prev && last(prev).kind === "comment") (continuation ?? out).push(hard()); + i = next - 1; + prev = undefined; + } + continue; + } + if (prev) { + const prefix = gluedPrefix(t, nodes[i + 1]); + if (!raw && !("open" in n) && OPERATORS.has(t.text) && !prefix) { + if (!continuation) { continuation = []; out.push(nest(continuation, opts.tabWidth)); } + continuation.push(soft()); + } else (continuation ?? out).push(separator(prev, n)); + } + (continuation ?? out).push(nodeDoc(n, source, opts, base)); + prev = n; + } + return group(out); +} + +function nodeDoc(n: Node, source: string, opts: FormatOptions, base: number): Doc { + if (!("open" in n)) return n.kind === "newline" ? "" : n.kind === "comment" ? commentText(n.text) : n.text; + const children = n.children; + if (!children.length) return n.open.text + n.close.text; + // Embedded statement bodies have significant line/column boundaries. + // They retain those boundaries, while nested ordinary calls still wrap. + const block = children.some((c) => !("open" in c) && ["match", "case", "do", "=", ";", "%", "\\"].includes(c.text)); + if (block) return [n.open.text, sequence(children, source, opts, base, true), n.close.text]; + const parts: Node[][] = [[]]; + for (let i = 0; i < children.length; i++) { + const c = children[i]; + if (!("open" in c) && c.text === ",") { + const next = children[i + 1]; + if (next && first(next).kind === "comment" && !source.slice(c.end, first(next).beg).includes("\n")) { + parts[parts.length - 1].push(c, next); + i++; + } + parts.push([]); + } + else parts[parts.length - 1].push(c); + } + while (parts.length > 1 && parts[parts.length - 1].every((c) => first(c).kind === "newline")) parts.pop(); + const content: Doc[] = []; + for (let i = 0; i < parts.length; i++) { + if (i) { + const prev = parts[i - 1].filter((n) => first(n).kind !== "newline"); + if (prev.length && last(prev[prev.length - 1]).kind === "comment") content.push(hard()); + else content.push(",", soft()); + } + content.push(sequence(parts[i], source, opts, base)); + } + const comments = children.some((c) => first(c).kind === "comment"); + // A generic closing '>' must stay glued to its preceding term: a spaced + // '>' is parsed as a comparison by Bend, even across a newline. + const closing: Doc = n.close.text === ">" ? "" : comments ? hard() : soft(""); + return group([n.open.text, nest([comments ? hard() : soft(""), content], opts.tabWidth), closing, n.close.text]); +} + +function commentText(text: string): string { + // Keep empty comments, whitespace, test expectations, directives and headings. + return /^#[^\s#!|]/u.test(text) ? "# " + text.slice(1) : text; +} + +// A small document printer: a group is entirely flat if it fits; otherwise +// its list separators break together. Nesting is relative, never alignment +// under a function name. The width is a target, not a license to split atoms. +function print(doc: Doc, width: number, indent: number): string { + const stack: Frame[] = [{ doc, indent, flat: false }]; + let out = " ".repeat(indent), col = indent; + const fits = (remaining: number, pending: Frame[]): boolean => { + while (remaining >= 0 && pending.length) { + const f = pending.pop()!, d = f.doc; + if (typeof d === "string") { + if (d.includes("\n")) return true; + remaining -= d.length; + } else if (Array.isArray(d)) pending.push(...d.map((doc) => ({ ...f, doc })).reverse()); + else if (d.kind === "line") { + if (d.hard || !f.flat) return true; + remaining -= d.flat.length; + } else pending.push({ doc: d.doc, indent: f.indent + (d.amount ?? 0), flat: true }); + } + return remaining >= 0; + }; + while (stack.length) { + const f = stack.pop()!, d = f.doc; + if (typeof d === "string") { + out += d; + col = d.includes("\n") ? d.length - d.lastIndexOf("\n") - 1 : col + d.length; + } else if (Array.isArray(d)) stack.push(...d.map((doc) => ({ ...f, doc })).reverse()); + else if (d.kind === "line") { + if (!d.hard && (f.flat || width === Infinity)) { out += d.flat; col += d.flat.length; } + else { + col = f.indent + (d.offset ?? 0); + out = out.trimEnd() + "\n" + " ".repeat(col); + } + } else if (d.kind === "nest") stack.push({ doc: d.doc, indent: f.indent + d.amount!, flat: f.flat }); + else stack.push({ doc: d.doc, indent: f.indent, + flat: f.flat || width === Infinity || fits(width - col, [...stack, { doc: d.doc, indent: f.indent, flat: true }]) }); + } + return out.trimEnd(); +} + +export function format(source: string, opts: FormatOptions): string { + const nodes = tree(tokens(source)); + const records: Node[][] = [[]]; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + if (!("open" in n) && n.kind === "newline") { + const next = nodes[i + 1], following = nodes[i + 2]; + const prefix = next && gluedPrefix(first(next), following); + const previous = records[records.length - 1].at(-1); + if (next && previous && last(previous).kind !== "comment" && first(next).kind !== "comment" + && (!prefix && !("open" in next) && OPERATORS.has(next.text) + || ["->", "=", "++", "&&", "||"].includes(last(previous).text))) continue; + records.push([]); + } + else records[records.length - 1].push(n); + } + const rows: string[] = [], levels = [0]; + let blank = false, previous = "", previousComment = false; + for (const record of records) { + if (!record.length) { blank = true; continue; } + const head = first(record[0]), col = head.col; + while (levels.length > 1 && col < levels[levels.length - 1]) levels.pop(); + if (col > levels[levels.length - 1]) levels.push(col); + const indent = (levels.length - 1) * opts.tabWidth; + const declaration = /^(def|type|law)$/.test(head.text) || head.text === "@"; + const comment = head.kind === "comment"; + if (rows.length && col === 0 && ((declaration && !previousComment && previous !== "@unsafe") || comment && blank) + && rows[rows.length - 1] !== "") rows.push(""); + else if (rows.length && blank && col > 0 && rows[rows.length - 1] !== "") rows.push(""); + let doc: Doc; + if (head.text === "import") { + // Paths and aliases use their own grammar; do not treat / or - as operators. + const text = source.slice(head.beg, last(record[record.length - 1]).end).trimEnd(); + const at = text.indexOf("#"); + doc = (at < 0 ? text : text.slice(0, at)).trim().replace(/^import\s+/, "import ").replace(/\s+as\s+/, " as "); + if (at >= 0) doc = [doc, " ", commentText(text.slice(at))]; + } else { + // Canonicalize an inline declaration body into the indented body form. + const colon = col === 0 && declaration ? record.findIndex((n) => !("open" in n) && n.text === ":") : -1; + if (colon >= 0 && colon < record.length - 1 && first(record[colon + 1]).kind !== "comment") { + doc = [sequence(record.slice(0, colon + 1), source, opts, col), + nest([hard(), sequence(record.slice(colon + 1), source, opts, col)], opts.tabWidth)]; + } else doc = sequence(record, source, opts, col); + } + rows.push(print(doc, opts.wrapAtWidth === "never" ? Infinity : opts.wrapAtWidth, indent)); + previous = record.map((n) => first(n).text).join(""); + previousComment = comment; + blank = false; + } + return rows.join("\n").trimEnd() + "\n"; +} + +// Reparse both versions with the same imported declarations. No typecheck, +// disk writes or import fetches. Compare parsed terms, not checked terms: +// checking unfolds definitions and would hide changes in source meaning. +export function sameProgram(cx: Pick, text: string): boolean { + const B = cx.Bend, root = cx.root; + const body = (s: string) => s.split("\n").map((line) => /^import\s/.test(line) ? "" : line).join("\n"); + const original = body(root.text); + let aliases: Record = {}; + if (/^import\s+\S+\s+as\s+/m.test(root.text)) { + search: for (const tld of Object.values(cx.book.tlds)) { + for (const term of [tld.T, ...(tld.$ === "Def" && tld.v ? [tld.v] : [])]) { + for (const tm of cx.walk(B.term_lower(term))) { + if (tm.s?.file.str === original) { aliases = tm.s.file.al; break search; } + } + } + } + } + // A proof consisting solely of {==} has no body span. Resolve local + // aliases from the already loaded sources in that case. + for (const m of root.text.matchAll(/^import\s+(\S+)\s+as\s+(\w+)/gm)) { + if (aliases[m[2]] !== undefined) continue; + const importedPath = path.resolve(path.dirname(root.path), m[1]).replaceAll("\\", "/"); + const imported = cx.sources.find((s) => s.path === importedPath); + if (imported) aliases = { ...aliases, [m[2]]: imported.ns }; + } + const qualify = (name: string) => { + const dot = name.indexOf("."); + return dot >= 0 && aliases[name.slice(0, dot)] !== undefined + ? aliases[name.slice(0, dot)] + ":" + name.slice(dot + 1) + : (root.ns ? root.ns + ":" : "") + name; + }; + const own = new Set([...root.text.matchAll(/^(?:@unsafe\s+)?(?:def|type|law)\s+([\w.]+)/gm)].map((m) => qualify(m[1]))); + const seed = (): Book => { + const book = B.book_nil(); + book.tlds = { ...cx.book.tlds }; + book.ctrs = { ...cx.book.ctrs }; + for (const k of own) { + const tld = book.tlds[k]; + // A proof of an imported law fills its declaration rather than creating one. + if (k.includes(":") && !k.startsWith(root.ns + ":") && tld?.$ === "Def") { + book.tlds[k] = { ...tld, v: null, i: undefined, u: false }; + } else { + if (tld?.$ === "ADT") for (const c of tld.c) delete book.ctrs[c.k]; + delete book.tlds[k]; + } + } + return book; + }; + const snapshot = (s: string) => { + const book = seed(); + const dir = root.path.slice(0, root.path.lastIndexOf("/") + 1); + B.parse_book(book, dir, body(s), root.ns, aliases); + const lower = (t: HTerm | null) => t === null ? null : B.term_lower(t); + return JSON.stringify(book.order.map((k) => { + const t = book.tlds[k]; + return t.$ === "ADT" ? [k, t.n, t.g, lower(t.T), t.c.map((c) => [c.k, c.n, lower(c.T)])] + : [k, t.n, t.x, t.u ?? false, t.i, lower(t.T), lower(t.v)]; + }), (k, v) => k === "s" ? undefined : v); + }; + try { return snapshot(root.text) === snapshot(text); } + catch { return false; } +} + +export const rules: LintRule[] = [{ + id: "format/layout", + run(cx) { + const opts = formatOptions(cx.options); + const text = format(cx.root.text, opts); + if (text === cx.root.text) return []; + const spn = { file: cx.root.file, beg: 0, end: cx.root.text.length }; + if (!sameProgram(cx, text)) return [cx.diag({ + message: "Cannot safely format this syntax: the result parses differently. No fix was offered.", spn, + })]; + return [cx.diag({ message: "Apply canonical formatting.", spn, + fixes: [{ title: "Format file", applicability: "safe", edits: [{ spn, text }] }], + })]; + }, +}]; diff --git a/tools/bend-lint/rules/line_length.bend b/tools/bend-lint/rules/line_length.bend deleted file mode 100644 index e4774991af..0000000000 --- a/tools/bend-lint/rules/line_length.bend +++ /dev/null @@ -1,45 +0,0 @@ -# style/line-length: a line longer than the option `max` (100 by default), -# in characters. The finding covers the part past the limit. No fix. -import Base -import ../src/lint.bend as Lint -import ./shared.bend as Shared - -def id() -> String: - "style/line-length" - -def facts() -> Lint.Want: - Lint.NoFacts{} - -def diag(path: String, +max: U32, +beg: U32, +len: U32) -> Lint.Diag: - Lint.Diag{Lint.Warning{}, "Keep the line to " ++ U32.show(max) ++ " characters; it has " ++ U32.show(len) ++ ".", Some{Lint.Span{path, U32.add(beg, max), U32.add(beg, len)}}, []} - -def keep(long: Bool, +path: String, +max: U32, +beg: U32, +len: U32, acc: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: - match long: - case True{}: - diag(path, max, beg, len) <> acc - case False{}: - acc - -# The lines come last first, so adding each finding to the front puts them -# in order. A tail call. -def scan(+path: String, +max: U32, lines: List<&2, Shared.Line>, acc: List<&2, Lint.Diag>) -> List<&2, Lint.Diag>: - match lines: - case Nil{}: - acc - case Con{Shared.Line{+beg, +len}, rest}: - scan(path, max, rest, keep(U32.is_gt(len, max), path, max, beg, len, acc)) - -def check(+max: U32, src: Maybe<&2, Lint.Source>) -> List<&2, Lint.Diag>: - match src: - case None{}: - [] - case Some{Lint.Source{path, text, root}}: - scan(path, max, Shared.lines(text), []) - -def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): - match input: - case Lint.Input{sources, options}: - IO.pure(List<&2, Lint.Diag>, check(Lint.option_number(options, "max", 100), Shared.root(sources))) - -def main() -> IO(Unit): - Lint.serve(run) diff --git a/tools/bend-lint/rules/shared.bend b/tools/bend-lint/rules/shared.bend index f4eb974854..f91ec37164 100644 --- a/tools/bend-lint/rules/shared.bend +++ b/tools/bend-lint/rules/shared.bend @@ -2,52 +2,6 @@ import Base import ../src/lint.bend as Lint -# A line of a text: the offset of its first character, and its length -# without the line break (nor a \r before it). -type Line is Data: - Line{beg: U32, len: U32} - -def lines.beg(nl: Bool, +at: U32, beg: U32) -> U32: - match nl: - case True{}: - U32.add(at, 1) - case False{}: - beg - -def lines.len(nl: Bool, len: U32) -> U32: - match nl: - case True{}: - 0 - case False{}: - U32.add(len, 1) - -def lines.cut(cr: Bool, len: U32) -> U32: - match cr: - case True{}: - U32.sub(len, 1) - case False{}: - len - -def lines.push(nl: Bool, beg: U32, len: U32, cr: Bool, acc: List<&2, Line>) -> List<&2, Line>: - match nl: - case True{}: - Line{beg, lines.cut(cr, len)} <> acc - case False{}: - acc - -# A tail call: bend runs it as a loop, so a long text does not overflow the -# stack. `cr` says the last character was a \r. -def lines.go(s: String, +at: U32, +beg: U32, +len: U32, cr: Bool, acc: List<&2, Line>) -> List<&2, Line>: - match s: - case SNil{}: - Line{beg, len} <> acc - case SCon{+c, rest}: - lines.go(rest, U32.add(at, 1), lines.beg(Char.is_eq(c, '\n'), at, beg), lines.len(Char.is_eq(c, '\n'), len), Char.is_eq(c, '\r'), lines.push(Char.is_eq(c, '\n'), beg, len, cr, acc)) - -# The lines of a text, the last one first. -def lines(s: String) -> List<&2, Line>: - lines.go(s, 0, 0, 0, False{}, []) - def root_or(root: Bool, s: Lint.Source, later: Maybe<&2, Lint.Source>) -> Maybe<&2, Lint.Source>: match root: case True{}: @@ -67,3 +21,23 @@ def root(srcs: List<&2, Lint.Source>) -> Maybe<&2, Lint.Source>: None{} case Con{+s, rest}: root_or(is_root(s), s, root(rest)) + +# Count physical lines in one tail-recursive scan, without allocating a +# line list. An empty file has zero lines; a final newline adds no sentinel. +def line_count.go(s: String, count: U32, last: Bool) -> U32: + match s: + case SNil{}: + match last: + case True{}: + count + case False{}: + U32.add(count, 1) + case SCon{c, rest}: + match c: + case '\n': + line_count.go(rest, U32.add(count, 1), True{}) + case _: + line_count.go(rest, count, False{}) + +def line_count(text: String) -> U32: + line_count.go(text, 0, True{}) diff --git a/tools/bend-lint/rules/shared.ts b/tools/bend-lint/rules/shared.ts deleted file mode 100644 index eb4c22363d..0000000000 --- a/tools/bend-lint/rules/shared.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Helpers for the rules in this folder that are written in TypeScript. - -import type { SourceFile } from "../src/lint.ts"; - -// Types -// ===== - -// A line of a file: its text without the line break (nor a \r before it), -// and the offsets where that text begins and ends. -export type Line = { text: string; beg: number; end: number }; - -// Functions -// ========= - -export function lines(file: SourceFile): Line[] { - return file.str.split("\n").reduce<{ at: number; out: Line[] }>(({ at, out }, raw) => { - const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw; - return { at: at + raw.length + 1, out: [...out, { text, beg: at, end: at + text.length }] }; - }, { at: 0, out: [] }).out; -} diff --git a/tools/bend-lint/rules/trailing_whitespace.ts b/tools/bend-lint/rules/trailing_whitespace.ts deleted file mode 100644 index 0c44be30b8..0000000000 --- a/tools/bend-lint/rules/trailing_whitespace.ts +++ /dev/null @@ -1,21 +0,0 @@ -// style/trailing-whitespace: spaces or tabs at the end of a line. The fix -// deletes them, so it is safe. - -import type { LintRule } from "../src/lint.ts"; -import { lines } from "./shared.ts"; - -// Constants -// ========= - -export const rules: LintRule[] = [{ - id: "style/trailing-whitespace", - run: (cx) => lines(cx.root.file).flatMap(({ text, end }) => { - const width = text.length - text.trimEnd().length; - const spn = { file: cx.root.file, beg: end - width, end }; - return width === 0 ? [] : [cx.diag({ - message: "Remove the whitespace at the end of the line.", - severity: "warning", spn, - fixes: [{ title: "Remove trailing whitespace", applicability: "safe", edits: [{ spn, text: "" }] }], - })]; - }), -}]; diff --git a/tools/bend-lint/src/lint.js b/tools/bend-lint/src/effects.js similarity index 100% rename from tools/bend-lint/src/lint.js rename to tools/bend-lint/src/effects.js diff --git a/tools/bend-lint/src/lint.bend b/tools/bend-lint/src/lint.bend index d0784c1bce..263f901dba 100644 --- a/tools/bend-lint/src/lint.bend +++ b/tools/bend-lint/src/lint.bend @@ -91,14 +91,14 @@ type View is Data: View{owner: String, inst: Bool, kind: String, name: String, quantity: Quantity, span: Maybe<&2, Span>, inner: Maybe<&2, Span>} def input() -> IO(Input): - import "./lint.js" + import "./effects.js" def report(diags: List<&2, Diag>) -> IO(Unit): - import "./lint.js" + import "./effects.js" # The next fact the rule asked for, or None{} when none are left. def next_fact() -> IO(Maybe<&2, Fact>): - import "./lint.js" + import "./effects.js" # One step of fold_facts: stop at None{}, else run step and go on. def fold_next(~A: Type, ~step: A -> @+f: Fact -> IO(A), m: Maybe<&2, Fact>, acc: A, again: A -> IO(A)) -> IO(A): @@ -118,35 +118,35 @@ def fold_facts(~A: Type, ~step: A -> @+f: Fact -> IO(A), acc: A) -> IO(A): fold_next(~A, ~step, m, acc, a => fold_facts(~A, ~step, a)) def view(fact: Fact) -> IO(View): - import "./lint.js" + import "./effects.js" # The type the fact's term was checked against, or inferred. def type_of(fact: Fact) -> IO(Term): - import "./lint.js" + import "./effects.js" # The type of the binder a Var fact refers to. def binder(fact: Fact) -> IO(Maybe<&2, Term>): - import "./lint.js" + import "./effects.js" # Whether two types are equal, as the checker compares them in the fact's # scope. def same(fact: Fact, a: Term, b: Term) -> IO(Bool): - import "./lint.js" + import "./effects.js" def show(fact: Fact, t: Term) -> IO(String): - import "./lint.js" + import "./effects.js" # The normal form of a type, in the fact's scope. def normal(fact: Fact, t: Term) -> IO(Term): - import "./lint.js" + import "./effects.js" # The variables the fact's term uses, each with how many times. def uses(fact: Fact) -> IO(List<&2, Use>): - import "./lint.js" + import "./effects.js" # The text under a span, in the file on disk. def text(span: Span) -> IO(String): - import "./lint.js" + import "./effects.js" def pick(hit: Bool, v: Value, later: Maybe<&2, Value>) -> Maybe<&2, Value>: match hit: diff --git a/tools/bend-lint/test/lint.test.ts b/tools/bend-lint/src/lint.test.ts similarity index 88% rename from tools/bend-lint/test/lint.test.ts rename to tools/bend-lint/src/lint.test.ts index 40413b02bf..5c410ae6e9 100644 --- a/tools/bend-lint/test/lint.test.ts +++ b/tools/bend-lint/src/lint.test.ts @@ -5,16 +5,14 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { Book, LTerm, Span } from "bend2/bend.ts"; -import { BEND2, Bend, Comp, applyFixes, bendRule, findConfig, lint, mapper, render, walk } from "../src/lint.ts"; -import type { Diag, Edit, Fact, LintRule, RuleContext, Source, SourceFile } from "../src/lint.ts"; -import { rules as trailing } from "../rules/trailing_whitespace.ts"; -import { DriftError, bendDir, fetchBend, installedTag, latestTag, patch, relative, resolve, seeCheck, seeInfer } from "../src/patch.ts"; +import { BEND2, Bend, Comp, applyFixes, bendRule, findConfig, lint, mapper, readConfig, render, walk } from "./lint.ts"; +import type { Diag, Fact, LintRule, RuleContext, Source, SourceFile } from "./lint.ts"; +import { DriftError, bendDir, fetchBend, installedTag, latestTag, patch, relative, resolve, seeCheck, seeInfer } from "./patch.ts"; // Types // ===== type Loose = Fact & { tm: { x?: { $: string; k?: string; i?: number } } }; -type Token = { text: string; beg: number; end: number }; type Seen = { kind: string; def: string; name: string; path: string }; // Constants @@ -83,30 +81,6 @@ def main() -> N: direct(alias(generic(~N, required_lambda(required_constructor())))) `; -const FORMAT = String.raw`import Base - -type Sample is Data: - SampleValue{} - -def choose(x: Sample,y: Sample) -> Sample: - x - -def text() -> String: - "a,b=c # still text; escaped quote: \"x,y=z\"" - -def marker() -> Char: - '=' - -def proof(-x: Sample) -> {x==x : Sample}: - {==} - -def main() -> Sample: - a : Sample=SampleValue{} # preserve,this=comment - b : Sample =SampleValue{} - f : Sample -> Sample=y=>y - f(choose(a,b)) -`; - // Tests in the bend checkout (bend2/../tests) whose first expected line // says if the check passes. const DRIFT = [ @@ -115,20 +89,15 @@ const DRIFT = [ "import/base_prelude", "import/string_literal", "import/alias_shadow", "import/duplicate_name", ]; -// Inside the repo, so a Bend rule here can import ../../../src/lint.bend; +// Inside the repo, so a Bend rule here can import ../../lint.bend; // .tmp/ is ignored by git. const TMP = fileURLToPath(new URL("./.tmp", import.meta.url)); const DIR = (fs.mkdirSync(TMP, { recursive: true }), fs.mkdtempSync(path.join(TMP, "run-"))); -const CLI = fileURLToPath(new URL("../src/lint.ts", import.meta.url)); -const LINE_LENGTH = fileURLToPath(new URL("../rules/line_length.bend", import.meta.url)); - -// A file for the rules in rules/: trailing spaces and tabs, CRLF, and -// lines of 100, 101 and 103 characters (the last two end in a \r). -const STYLE = "import Base\n\ndef main() -> U32: \r\n 1\t\n#" + "x".repeat(99) + "\n#" + "y".repeat(100) + "\r\n#" + "z".repeat(102) + "\r\n"; +const CLI = fileURLToPath(new URL("./lint.ts", import.meta.url)); // The comma-space rule, written in Bend: source text only. const COMMA_BEND = String.raw`import Base -import ../../../src/lint.bend as Lint +import ../../lint.bend as Lint def id() -> String: "style/comma-space" @@ -207,7 +176,7 @@ def main() -> IO(Unit): // form, whether the checker finds it equal to its binder's, its text, how // many times it is demanded, and what it uses. const TYPES_BEND = String.raw`import Base -import ../../../src/lint.bend as Lint +import ../../lint.bend as Lint def id() -> String: "test/types" @@ -362,20 +331,9 @@ const redundantAnnotation: LintRule = { }), }; -// One space after a comma and on each side of an assignment, in the file -// linted. Quoted literals, comments and compound operators stay whole. -const spacing: LintRule = { - id: "format/spacing", - run: (cx) => spacingEdits(cx.root.file).map((edit) => cx.diag({ - message: "Use one space after a comma and on each side of an assignment.", - severity: "hint", spn: edit.spn, - fixes: [{ title: "Normalize spacing", applicability: "safe", edits: [edit] }], - })), -}; - // A Bend rule that reports its options, read with defaults. const OPTIONS_BEND = String.raw`import Base -import ../../../src/lint.bend as Lint +import ../../lint.bend as Lint def id() -> String: "test/options" @@ -398,7 +356,7 @@ def main() -> IO(Unit): // A Bend rule that counts the facts it pulls, from the linted file and its // imports: the Vars. const COUNT_BEND = String.raw`import Base -import ../../../src/lint.bend as Lint +import ../../lint.bend as Lint def id() -> String: "test/count" @@ -455,25 +413,6 @@ function root(sources: Source[]): Source { return sources.find((s) => s.root)!; } -function tokens(source: string): Token[] { - const pattern = /#[^\r\n]*|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|==|=>|!=|<=|>=|[^\s]/g; - return [...source.matchAll(pattern)].map((m) => ({ text: m[0], beg: m.index!, end: m.index! + m[0].length })); -} - -function spacingEdits(file: SourceFile): Edit[] { - const stream = tokens(file.str); - const gap = (left?: Token, right?: Token): Edit[] => { - const between = left === undefined || right === undefined ? "#" : file.str.slice(left.end, right.beg); - return right?.text.startsWith("#") || !/^[ \t]*$/.test(between) || between === " " - ? [] : [{ spn: { file, beg: left!.end, end: right!.beg }, text: " " }]; - }; - return stream.flatMap((token, i) => [ - ...(token.text === "=" ? gap(stream[i - 1], token) : []), - ...((token.text === "=" || token.text === ",") && !["}", ")", "]"].includes(stream[i + 1]?.text ?? "}") - ? gap(token, stream[i + 1]) : []), - ]); -} - // The facts a rule with this filter gets: kind, def, name and file. async function seen(file: string, want: LintRule["facts"]): Promise { let got: Fact[] = []; @@ -720,6 +659,18 @@ describe("lint", () => { expect(() => walk({ $: "Nope" } as unknown as LTerm).next()).toThrow(DriftError); }); + test("walk preserves constructor and type argument order across traversals", () => { + const children: LTerm[] = [{ $: "Ref", k: "first" }, { $: "Ref", k: "second" }]; + for (const $ of ["Ctr", "ADT"] as const) { + const term = { $, k: "Pair", x: children } as LTerm; + const original = [...children]; + expect([...walk(term)]).toEqual([term, ...original]); + expect(children).toEqual(original); + expect([...walk(term)]).toEqual([term, ...original]); + expect(children).toEqual(original); + } + }); + test("a failed check is one bend/check error, and no rule runs", async () => { const parse = await lint(fixture("parse.bend", "type N is Data:\n Z{}\ndef broken(\n"), [neverRun]); expect(parse.ok).toBe(false); @@ -777,6 +728,17 @@ describe("lint", () => { expect(res.sources.length).toBe(2); }); + test("fix offsets must be integers within the source file", async () => { + for (const [beg, end] of [[NaN, 0], [0, NaN], [Infinity, Infinity], [0.5, 1], [0, 0.5], [-1, 0], [2, 1], [0, 1000000]]) { + const rule: LintRule = { id: "test/bad-offset", run: (cx) => [cx.diag({ + message: "bad offset", fixes: [{ title: "bad offset", applicability: "safe", + edits: [{ spn: { file: cx.root.file, beg, end }, text: "X" }] }], + })] }; + await expect(lint(userland, [rule])).rejects.toThrow("out of bounds"); + } + expect(fs.readFileSync(userland, "utf8")).toBe(USERLAND); + }); + test("Base is reused, its facts are left out, and it stays out of files that do not import it", async () => { const withBase = fixture("with_base.bend", "import Base\n\ndef main() -> Nat:\n 1n\n"); const probe: LintRule = { @@ -865,6 +827,61 @@ describe("options", () => { expect(await messages({}, file)).toEqual([["hint", '{"tabWidth":2,"breakLines":false}']]); }); + for (const extension of ["js", "ts"]) { + test(`bend-lint.${extension} loads a named config export and relative imports`, async () => { + const top = path.join(DIR, "config_" + extension); + fs.mkdirSync(path.join(top, "nested"), { recursive: true }); + fs.writeFileSync(path.join(top, "width.ts"), "export const width: number = 8;"); + fs.writeFileSync(path.join(top, "bend-lint." + extension), + 'import { width } from "./width.ts"; export const config = { rules: { "test/echo": { tabWidth: width, severity: "warning" } } };'); + const file = path.join(top, "nested", "file.bend"); + fs.writeFileSync(file, USERLAND); + expect(findConfig(file)).toEqual({ rules: { "test/echo": { tabWidth: 8, severity: "warning" } } }); + expect(await messages(undefined, file)).toEqual([["warning", '{"tabWidth":8,"breakLines":false}']]); + expect(await messages({}, file)).toEqual([["hint", '{"tabWidth":2,"breakLines":false}']]); + }); + } + + test("nearest config wins; JSON precedes JS, then TS in the same directory", () => { + const configs = [ + ["bend-lint.ts", 'export const config = { rules: { "test/echo": { tabWidth: 6 } } };', { tabWidth: 6 }], + ["bend-lint.js", 'export const config = { rules: { "test/echo": { tabWidth: 8 } } };', { tabWidth: 8 }], + ["bend-lint.json", '{"rules":{"test/echo":"off"}}', "off"], + ] as const; + for (let count = 1; count <= configs.length; count++) { + const top = path.join(DIR, "config_precedence_" + count); + const child = path.join(top, "child"); + fs.mkdirSync(child, { recursive: true }); + fs.writeFileSync(path.join(top, "bend-lint.json"), '{"rules":{"test/echo":{"tabWidth":4}}}'); + for (const [name, text] of configs.slice(0, count)) { + fs.writeFileSync(path.join(child, name), text); + } + expect(findConfig(path.join(child, "file.bend"))).toEqual({ rules: { "test/echo": configs[count - 1][2] } }); + } + }); + + test("module configs require a named object export and preserve load errors", () => { + for (const extension of ["js", "ts"]) { + for (const [name, text, error] of [ + ["default", "export default {};", /must export a named `config` object/], + ["missing", "export const rules = {};", /must export a named `config` object/], + ["null", "export const config = null;", /`config` must be an object/], + ["array", "export const config = [];", /`config` must be an object/], + ["function", "export const config = () => ({});", /`config` must be an object/], + ["throws", 'throw new Error("config exploded");', /config exploded/], + ] as const) { + const file = fixture(`config_${name}.${extension}`, text); + expect(() => readConfig(file)).toThrow(error); + expect(() => readConfig(file)).toThrow(file); + } + } + }); + + test("module configs use the same rule option validation as JSON", async () => { + const file = fixture("invalid_options.ts", 'export const config = { rules: { "test/echo": { tabWidth: "4" } } };'); + await expect(messages(readConfig(file))).rejects.toThrow(/tabWidth must be a number/); + }); + test("a Bend rule reads its options with defaults", async () => { const rule = await bendRule(fixture("options_rule.bend", OPTIONS_BEND)); const said = async (config: object) => (await lint(userland, [rule], { config })).diags.map((d) => d.message); @@ -963,33 +980,6 @@ describe("rules", () => { expect(fs.readFileSync(file, "utf8")).toBe(ERASURE); }); - test("format: strings, characters, comments, operators and CRLF stay whole", () => { - const source = String.raw`a= "x,y=z#text\"still,string" # comment,a=b` + "\r\n" - + String.raw`b= '='` + "\r\n" + "c= ','\r\nd= '#'\r\n" + String.raw`e= '\''` + "\r\n" - + "f(a,\r\n b)\r\nx==y\r\nx=>y\r\nx!=y\r\nx<=y\r\nx>=y\r\nf(a,)\r\n"; - const file: SourceFile = { str: source, ns: "", al: {}, path: "" }; - const diags = spacingEdits(file).map((edit): Diag => - ({ code: spacing.id, severity: "hint", message: "", fixes: [{ title: "", applicability: "safe", edits: [edit] }] })); - const formatted = applyFixes(file, diags).text; - expect(formatted).toBe(source.replace(/^([abcde])=/gm, "$1 =")); - expect(tokens(formatted).map((t) => t.text)).toEqual(tokens(source).map((t) => t.text)); - expect(applyFixes(file, [diags[0], diags[0]])).toEqual({ text: applyFixes(file, [diags[0]]).text, skipped: 0 }); - }); - - test("format: one rule gives editor findings and formatter edits", async () => { - const res = await lint(fixture("format.bend", FORMAT), [spacing]); - expect(res.facts).toBeUndefined(); - expect(res.diags.length).toBe(8); - const formatted = applyFixes(root(res.sources).file, res.diags).text; - for (const line of ["def choose(x: Sample, y: Sample)", "a : Sample = SampleValue{} # preserve,this=comment", - "b : Sample = SampleValue{}", "f : Sample -> Sample = y=>y", "f(choose(a, b))"]) { - expect(formatted).toContain(line); - } - expect(tokens(formatted).map((t) => t.text)).toEqual(tokens(FORMAT).map((t) => t.text)); - const after = await lint(fixture("format_fixed.bend", formatted), [spacing]); - expect(after.diags).toEqual([]); - expect(bodies(after.book)).toEqual(bodies(res.book)); - }); }); describe("drift: the copy of book_read agrees with bend's own tests", () => { @@ -1021,6 +1011,22 @@ describe("rules written in Bend", () => { expect(res.diags.map((d) => d.message)).toEqual(Array(2).fill("x: Alias = N (same as its binder), text x, demanded once, uses x once")); expect(res.diags.every((d) => d.spn?.file === root(res.sources).file)).toBe(true); }); + + test("Bend fixes reject out-of-bounds code-point offsets instead of clamping them", async () => { + for (const source of [USERLAND, "# 😀\n" + USERLAND]) { + const end = [...source].length + 1; + const code = COMMA_BEND.replace("Lint.Span{path, at, at}", "Lint.Span{path, at, " + end + "}"); + const rule = await bendRule(fixture("bad_offset_rule.bend", code)); + const file = fixture("bad_offset_input.bend", source); + await expect(lint(file, [rule])).rejects.toThrow('fix "Insert space" has an edit out of bounds'); + expect(fs.readFileSync(file, "utf8")).toBe(source); + const diagnosticOnly = code.replace('[Lint.Fix{"Insert space", Lint.Safe{}, [Lint.Edit{span, " "}]}]', "[]"); + const diagnosticRule = await bendRule(fixture("clamped_diagnostic_rule.bend", diagnosticOnly)); + const result = await lint(file, [diagnosticRule]); + expect(result.ok).toBe(true); + expect(result.diags[0].spn!.end).toBe(source.length); + } + }); }); describe("fact filters", () => { @@ -1077,30 +1083,6 @@ describe("fact filters", () => { }); }); -describe("the rules in rules/", () => { - test("style/trailing-whitespace finds spaces and tabs, and its fix keeps the line breaks", async () => { - const res = await lint(fixture("trailing.bend", STYLE), trailing); - const found = res.diags.map((d) => d.spn!.file.str.slice(d.spn!.beg, d.spn!.end)); - expect([res.ok, found]).toEqual([true, [" ", "\t"]]); - expect(res.diags.every((d) => d.code === "style/trailing-whitespace" && d.fixes[0].applicability === "safe")).toBe(true); - expect(applyFixes(root(res.sources).file, res.diags).text).toBe(STYLE.replace("U32: \r\n", "U32:\r\n").replace("1\t", "1")); - }); - - test("style/line-length finds lines past max, without the \\r, and covers the overflow", async () => { - const file = fixture("long.bend", STYLE); - const res = await lint(file, [await bendRule(LINE_LENGTH)]); - const over = (d: Diag) => d.spn!.file.str.slice(d.spn!.beg, d.spn!.end); - expect(res.diags.map((d) => [d.code, d.severity, d.message, over(d)])).toEqual([ - ["style/line-length", "warning", "Keep the line to 100 characters; it has 101.", "y"], - ["style/line-length", "warning", "Keep the line to 100 characters; it has 103.", "zzz"], - ]); - const narrow = await lint(file, [await bendRule(LINE_LENGTH)], { config: { rules: { "style/line-length": { max: 18 } } } }); - expect(narrow.diags.map(over)).toEqual([" ", "x".repeat(82), "y".repeat(83), "z".repeat(85)]); - const off = await lint(file, [await bendRule(LINE_LENGTH)], { config: { rules: { "style/line-length": "off" } } }); - expect(off.diags).toEqual([]); - }); -}); - describe("cli", () => { const input = fixture("cli.bend", USERLAND); const first = module("first.js", `[{ id: "cli/first", facts: true, run(cx) { @@ -1175,11 +1157,17 @@ describe("cli", () => { expect(run(input, "--bend", DIR)).toMatchObject({ status: 2, stderr: expect.stringContaining("no bend2 at") }); }); - test("--config gives the config", () => { + test("--config selects JSON, JS or TS explicitly", () => { const echoes = module("echo.js", `[{ id: "test/echo", options: { tabWidth: 2 }, run: (cx) => [cx.diag({ message: "w" + cx.options.tabWidth })] }]`); - const config = fixture("cli_config.json", JSON.stringify({ rules: { "test/echo": { tabWidth: 6, severity: "hint" } } })); - const out = run(input, "--rules", echoes, "--config", config, "--json"); - expect(JSON.parse(out.stdout).findings.map((f: { severity: string; message: string }) => [f.severity, f.message])).toEqual([["hint", "w6"]]); + const settings = JSON.stringify({ rules: { "test/echo": { tabWidth: 6, severity: "hint" } } }); + for (const extension of ["json", "js", "ts"]) { + const config = fixture("cli_config." + extension, extension === "json" ? settings : "export const config = " + settings + ";"); + const out = run(input, "--rules", echoes, "--config", config, "--json"); + expect(out.status).toBe(0); + expect(JSON.parse(out.stdout).findings.map((f: { severity: string; message: string }) => [f.severity, f.message])).toEqual([["hint", "w6"]]); + } + const invalid = fixture("cli_default_config.ts", "export default {};"); + expect(run(input, "--config", invalid)).toMatchObject({ status: 2, stderr: expect.stringContaining("must export a named `config` object") }); }); test("--fix writes only the linted file, and prints findings when fixes clash", () => { diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 41d263db38..76cf776ea2 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -88,7 +88,7 @@ export type RuleContext = { export type OptionValue = number | boolean | string; export type Options = Record; -// bend-lint.json: per rule id, "off", or a severity and option values. +// Config: per rule id, "off", or a severity and option values. export type Config = { rules?: Record }; export type LintOptions = { signal?: AbortSignal; config?: Config }; @@ -108,7 +108,7 @@ export type Position = { line: number; character: number }; // A span as lint.bend has it: a file's path and two code-point offsets. type Spot = { path: string; beg: number; end: number }; -// What a Bend rule reports, as lint.js reads it. +// What a Bend rule reports, as effects.js reads it. type Reported = { severity: Severity; message: string; @@ -116,7 +116,7 @@ type Reported = { fixes: Array<{ title: string; applicability: Applicability; edits: Array<{ span: Spot; text: string }> }>; }; -// What lint.js asks of bend-lint while a Bend rule runs. Facts and types +// What effects.js asks of bend-lint while a Bend rule runs. Facts and types // cross as indexes into the run's tables. type Channel = { input(): { sources: Array<{ path: string; text: string; root: boolean }>; options: Options }; @@ -143,7 +143,7 @@ type Checked = { book: Book; sources: Source[]; span: Mapper; facts?: Map [--rules ]... [--config ] [--fix | --fix-suggested | --fix-dangerously] [--json] [--bend ]", -].join("\n"); +const USAGE = "usage: bun src/lint.ts [--rules ]... [--config ] [--fix | --fix-suggested | --fix-dangerously] [--json] [--bend ]"; // The fix levels each flag applies; the widest flag given wins. const FIXES: ReadonlyArray = [ @@ -219,7 +217,10 @@ export function* walk(tm: LTerm): Generator { throw new DriftError("unknown term kind " + t.$ + "; update CHILDREN in tools/bend-lint/src/lint.ts"); } yield t; - stack.push(...children(t).reverse()); + const next = children(t); + for (let i = next.length - 1; i >= 0; i--) { + stack.push(next[i]); + } } } @@ -381,17 +382,28 @@ function matches(f: FactFilter, kind: string, def: Name, name: Name): boolean { export function readConfig(file: string): Config { try { + if ([".js", ".ts"].includes(path.extname(file))) { + const module = import.meta.require(url.pathToFileURL(path.resolve(file)).href); + if (!Object.hasOwn(module, "config")) { + throw new Error("must export a named `config` object"); + } + if (typeof module.config !== "object" || module.config === null || Array.isArray(module.config)) { + throw new Error("`config` must be an object"); + } + return module.config; + } return JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { throw new Error(file + ": " + (e instanceof Error ? e.message : String(e))); } } -// The bend-lint.json in the file's folder, or the nearest one above it. +// The nearest config; JSON, JS, then TS within each directory. export function findConfig(file: string): Config { for (let dir = path.dirname(path.resolve(file)); ; dir = path.dirname(dir)) { - if (fs.existsSync(path.join(dir, CONFIG))) { - return readConfig(path.join(dir, CONFIG)); + const config = CONFIG_FILES.map((name) => path.join(dir, name)).find((candidate) => fs.existsSync(candidate)); + if (config !== undefined) { + return readConfig(config); } if (path.dirname(dir) === dir) { return {}; @@ -403,7 +415,7 @@ export function findConfig(file: string): Config { // defaults, with the given values, which must be known and of their type). function settings(rule: LintRule, config: Config): { off: boolean; severity?: Severity; options: Options } { const given = config.rules?.[rule.id]; - const where = CONFIG + ": " + rule.id; + const where = "bend-lint config: " + rule.id; if (given === undefined || given === "off") { return { off: given === "off", options: { ...rule.options } }; } @@ -455,8 +467,11 @@ export async function lint(file: string, rules: LintRule[], // A filter that matches all, in the scope of every kept fact, gets the map as is. facts: want === undefined || facts === undefined ? undefined : [want.kinds, want.defs, want.names].every((xs) => !xs?.length) && (want.scope === "program" || !program) ? facts - : new Map([...facts].filter(([tm, f]) => (want.scope === "program" || f.spn?.file === root.file) - && matches(want, shape(tm).kind, f.def, shape(tm).name))), + : new Map([...facts].filter(([tm, f]) => { + if (want.scope !== "program" && f.spn?.file !== root.file) return false; + const { kind, name } = shape(tm); + return matches(want, kind, f.def, name); + })), prior: diags, binder: (fact, v) => v.$ === "Var" ? Bend.pmap_get(fact.ctx, v.i) : null, show: (fact, ty) => Bend.term_show(Bend.term_lower(ty, fact.dep)), @@ -478,7 +493,7 @@ export async function lint(file: string, rules: LintRule[], fixes: d.fixes.map((f) => ({ ...f, edits: f.edits.map((e) => ({ ...e, spn: span(e.spn) })) })), })); const broken = settled.flatMap((d) => d.fixes).find((f) => f.edits.some(({ spn }, i) => - spn.beg < 0 || spn.beg > spn.end || spn.end > spn.file.str.length || f.edits.some((o, j) => j !== i && clash(f.edits[i], o)))); + !validRange(spn.beg, spn.end, spn.file.str.length) || f.edits.some((o, j) => j !== i && clash(f.edits[i], o)))); if (broken !== undefined) { throw new TypeError("rule " + rule.id + ": fix \"" + broken.title + "\" has an edit out of bounds, or two that clash"); } @@ -491,6 +506,10 @@ export async function lint(file: string, rules: LintRule[], return { ok: true, diags, sources, book, facts }; } +function validRange(beg: number, end: number, length: number): boolean { + return Number.isInteger(beg) && Number.isInteger(end) && beg >= 0 && beg <= end && end <= length; +} + // Whether two edits to one file cannot both apply: their ranges overlap, // or they insert at the same point. function clash(a: Edit, b: Edit): boolean { @@ -559,7 +578,7 @@ export function position(spn: Span): { start: Position; end: Position } { // A rule written in Bend: a file built on ./lint.bend (see there). It is // checked and compiled once, as comp.ts io_run does; each run calls its -// main with bend's own IO runtime, while lint.js reaches bend-lint through +// main with bend's own IO runtime, while effects.js reaches bend-lint through // globalThis.BEND_LINT. Offsets cross as code points. export async function bendRule(file: string): Promise { const { book, failure } = await check(file, [], new AbortController().signal); @@ -688,7 +707,13 @@ export async function bendRule(file: string): Promise { } return found[0].map((d) => cx.diag({ message: d.message, severity: d.severity, spn: d.span && span(d.span), - fixes: d.fixes.map((f) => ({ ...f, edits: f.edits.map((e) => ({ spn: span(e.span), text: e.text })) })), + fixes: d.fixes.map((f) => ({ ...f, edits: f.edits.map((e) => { + const spn = span(e.span); + if (!validRange(e.span.beg, e.span.end, table(spn.file.str).units.length - 1)) { + throw new TypeError("rule " + id + ": fix \"" + f.title + "\" has an edit out of bounds"); + } + return { spn, text: e.text }; + }) })), })); }, }; diff --git a/tools/bend-lint/tsconfig.json b/tools/bend-lint/tsconfig.json index 374ff85ad6..81d67c09a9 100644 --- a/tools/bend-lint/tsconfig.json +++ b/tools/bend-lint/tsconfig.json @@ -11,5 +11,5 @@ "typeRoots": ["./node_modules/@types", "../../bend2/pack/node_modules/@types"], "paths": { "bend2/*": ["../../bend2/*"] } }, - "include": ["src", "test", "rules"] + "include": ["src", "rules"] } From c81c42f8c081179343e93ec7f2174a1182aa00f1 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Thu, 8 Oct 2026 00:46:00 +0000 Subject: [PATCH 30/39] bend-lint: lint takes unsaved text 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 Claude-Session: https://claude.ai/code/session_01LoGeAfkpDBwvxzCtR9REJ5 --- tools/bend-lint/README.md | 6 ++++-- tools/bend-lint/src/lint.test.ts | 16 ++++++++++++++++ tools/bend-lint/src/lint.ts | 32 ++++++++++++++++++++++++++++---- tools/bend-lint/src/patch.ts | 15 ++++++++++++++- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index c53db222b7..a7bc2f1974 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -296,8 +296,10 @@ if (result.ok) { Set `BEND_DIR` before importing bend-lint to pick a Bend checkout. Import bend-lint before you load Bend yourself, so the patching happens first. `lint` -takes `{ config, signal }` as a third argument. Without `config` it looks next to -the file. `position(span)` gives an LSP range. +takes `{ config, signal, unsaved }` as a third argument. Without `config` it looks +next to the file. `unsaved` maps file paths to editor text that isn't saved yet. +Bend and the rules read that text instead of the file, for that run only. A run +with `unsaved` can't overlap another run. `position(span)` gives an LSP range. `findConfig(file)` checks the file's directory, then each parent, for `bend-lint.json`, `.js` or `.ts`. Closest directory wins, then JSON, JS, TS. diff --git a/tools/bend-lint/src/lint.test.ts b/tools/bend-lint/src/lint.test.ts index 5c410ae6e9..3480c5c9b6 100644 --- a/tools/bend-lint/src/lint.test.ts +++ b/tools/bend-lint/src/lint.test.ts @@ -706,6 +706,22 @@ describe("lint", () => { await expect(lint(userland, [], { signal: controller.signal })).rejects.toThrow(); }); + test("unsaved text is what bend and the rules see, for that run only", async () => { + const saved = "import Base\ndef main() -> U32:\n 1\n"; + const edited = "import Base\ndef main() -> U32:\n 2\n"; + const file = fixture("unsaved.bend", saved); + const seen: LintRule = { id: "test/seen", run: (cx) => [cx.diag({ message: cx.root.text, severity: "hint" })] }; + const text = async (unsaved?: Map): Promise => (await lint(file, [seen], { unsaved })).diags[0].message; + expect(await text(new Map([[file, edited]]))).toBe(edited); + expect(await text()).toBe(saved); + const broken = await lint(file, [seen], { unsaved: new Map([[file, "import Base\ndef main() -> U32:\n nope\n"]]) }); + expect([broken.ok, broken.diags[0].code]).toEqual([false, "bend/check"]); + const first = text(new Map([[file, edited]])); + await expect(text(new Map([[file, edited]]))).rejects.toThrow(/unsaved text/); + expect(await first).toBe(edited); + expect(await text()).toBe(saved); + }); + test("spans after import lines point at the right text on disk", async () => { fixture("dep.bend", "type N is Data:\n Z{}\ndef id(x: N) -> N:\n x\n"); const main = fixture("main.bend", "import ./dep.bend as D\n\ndef main() -> D.N:\n D.id(D.Z{})\n"); diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 76cf776ea2..09426022cc 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -11,7 +11,7 @@ import * as util from "node:util"; import type * as BendModule from "bend2/bend.ts"; import type * as CompModule from "bend2/comp.ts"; import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "bend2/bend.ts"; -import { DriftError, MARK, bendDir, fs, patch, path } from "./patch.ts"; +import { DriftError, MARK, bendDir, fs, patch, path, unsaved } from "./patch.ts"; import type { Hooked } from "./patch.ts"; // Types @@ -91,7 +91,9 @@ export type Options = Record; // Config: per rule id, "off", or a severity and option values. export type Config = { rules?: Record }; -export type LintOptions = { signal?: AbortSignal; config?: Config }; +// `unsaved` maps file paths to text an editor holds but has not saved. bend +// and the rules read it in place of the file, for that run only. +export type LintOptions = { signal?: AbortSignal; config?: Config; unsaved?: ReadonlyMap }; export type LintRule = { id: string; // namespace/name; the code of its findings @@ -438,11 +440,33 @@ function settings(rule: LintRule, config: Config): { off: boolean; severity?: Se return { off: false, severity, options: { ...rule.options, ...options } }; } +// Runs `load` with the unsaved text in place of the files on disk. It is +// global to bend's loader, so a run with unsaved text cannot overlap another +// run, and it is cleared at the end. +async function holding(text: ReadonlyMap | undefined, load: () => Promise): Promise { + if (text === undefined || text.size === 0) { + return load(); + } + if (unsaved.size > 0) { + throw new Error("another run is checking unsaved text; wait for it to end"); + } + for (const [file, str] of text) { + if (fs.existsSync(file)) { + unsaved.set(fs.realpathSync(file), str); + } + } + try { + return await load(); + } finally { + unsaved.clear(); + } +} + // Rules run in order; the config may turn one off, set its severity, and // give its options. A finding with severity error stops the run. A rule // that throws, and an abort, reach the caller. export async function lint(file: string, rules: LintRule[], - { signal = new AbortController().signal, config = findConfig(file) }: LintOptions = {}): Promise { + { signal = new AbortController().signal, config = findConfig(file), unsaved: held }: LintOptions = {}): Promise { const strings = (xs: unknown): boolean => xs === undefined || Array.isArray(xs) && xs.every((x) => typeof x === "string"); const bad = rules.findIndex((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function" || !(r.facts === undefined || r.facts === true || typeof r.facts === "object" && r.facts !== null @@ -454,7 +478,7 @@ export async function lint(file: string, rules: LintRule[], const plans = rules.map((rule) => ({ rule, ...settings(rule, config), want: rule.facts === true ? {} : rule.facts })) .filter((p) => !p.off); const program = plans.some((p) => p.want?.scope === "program"); - const { book, sources, span, facts, failure } = await check(file, plans.flatMap((p) => p.want === undefined ? [] : [p.want]), signal); + const { book, sources, span, facts, failure } = await holding(held, () => check(file, plans.flatMap((p) => p.want === undefined ? [] : [p.want]), signal)); if (failure !== undefined) { return { ok: false, diags: [failure], sources, book }; } diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index 9ba3d1b060..b4affde6eb 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -79,8 +79,17 @@ const PATCHES: Record = { "comp.ts": { edits: [], tail: "", exports: ["RUNTIME_MAIN", "js_sat"] }, }; +// Text an editor holds unsaved, by real path, set by lint() for one run. +// bend.ts reads it in place of the file on disk. +export const unsaved = new Map(); + // fs and path for bend.ts. -export const fs = { ...nodeFs, realpathSync: (p: nodeFs.PathLike): string => slash(nodeFs.realpathSync(p)) }; +export const fs = { + ...nodeFs, + realpathSync: (p: nodeFs.PathLike): string => slash(nodeFs.realpathSync(p)), + readFileSync: ((p: nodeFs.PathOrFileDescriptor, ...rest: unknown[]) => + held(p) ?? (nodeFs.readFileSync as (...a: unknown[]) => unknown)(p, ...rest)) as typeof nodeFs.readFileSync, +}; export const path = { ...nodePath, @@ -92,6 +101,10 @@ export const path = { // Functions // ========= +function held(p: nodeFs.PathOrFileDescriptor): string | undefined { + return typeof p === "string" && unsaved.size > 0 && nodeFs.existsSync(p) ? unsaved.get(slash(nodeFs.realpathSync(p))) : undefined; +} + function slash(p: string): string { return p.split(nodePath.sep).join("/"); } From 0de3855c2e4afacdae240325b54b1adea941caeb Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Thu, 8 Oct 2026 08:33:30 -0300 Subject: [PATCH 31/39] bend-lint: endOfLine option for format/layout 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 Claude-Session: https://claude.ai/code/session_01KXvuzGKEyMUQ8ZTB1Z2Uqk --- tools/bend-lint/README.md | 4 ++- tools/bend-lint/rules/format.test.ts | 38 ++++++++++++++++++++++++++-- tools/bend-lint/rules/format.ts | 22 ++++++++++------ 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index a7bc2f1974..ccd6006b7a 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -71,7 +71,9 @@ newest cached release. spacing, comments, blank lines between declarations, wrapping and the final newline. Options: `tabWidth` (default 2) and `wrapAtWidth` (default 100). Both must be positive integers, or `"never"` for `wrapAtWidth`. The width is a target; -long literals and comments can run past it. Its fix is `safe`. +long literals and comments can run past it. `endOfLine` is `"lf"` (default), +`"crlf"` or `"preserve"`; preserve uses the first line ending, or LF if there +is none. Line endings inside literals stay as written. Its fix is `safe`. Declaration order and literals stay as written. The only comment change is a space added after `#` when it's missing. Before offering a fix it parses diff --git a/tools/bend-lint/rules/format.test.ts b/tools/bend-lint/rules/format.test.ts index 0dbd853fb3..eaa2650e51 100644 --- a/tools/bend-lint/rules/format.test.ts +++ b/tools/bend-lint/rules/format.test.ts @@ -6,16 +6,17 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { Bend, lint, applyFixes, walk, render } from "../src/lint.ts"; import { format, formatOptions, rules, sameProgram } from "./format.ts"; +import type { FormatOptions } from "./format.ts"; const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-format-")); afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); -const opts = { tabWidth: 2, wrapAtWidth: 100 } as const; +const opts = { tabWidth: 2, wrapAtWidth: 100, endOfLine: "lf" } as const; const fixture = (text: string, name = "main.bend") => { const file = path.join(dir, name); fs.writeFileSync(file, text); return file; }; -async function fixed(text: string, options = opts as { tabWidth: number; wrapAtWidth: number | "never" }) { +async function fixed(text: string, options: FormatOptions = opts) { const result = await lint(fixture(text), rules, { config: { rules: { "format/layout": options } } }); if (!result.ok) throw new Error(result.diags.map(render).join("\n")); expect(result.ok).toBe(true); @@ -46,6 +47,12 @@ describe("format/layout", () => { expect(() => formatOptions({ wrapAtWidth: bad })).toThrow('"never" or a positive integer'); } expect(() => formatOptions({ wrapAtWidth: "always" })).toThrow(); + for (const endOfLine of ["lf", "crlf", "preserve"] as const) { + expect(formatOptions({ endOfLine })).toEqual({ ...opts, endOfLine }); + } + for (const endOfLine of ["auto", "LF", "", 1, true]) { + expect(() => formatOptions({ endOfLine })).toThrow("endOfLine must be"); + } expect(() => formatOptions({ breakLines: true })).toThrow("no option breakLines"); }); @@ -54,6 +61,33 @@ describe("format/layout", () => { expect(output).toBe("import Base\n\ntype N is Data:\n Z{}\n S{p: N}\n\ndef id(x: N) -> N:\n x\n\ndef main() -> N:\n id(S{Z{}})\n"); }); + test("CRLF applies to declarations, wrapping, comments and the final newline", async () => { + const source = "import Base\n#header\ndef f(first: U32, second: U32) -> U32: first\ndef main() -> U32: f(123456789, 234567890)"; + const options = { ...opts, wrapAtWidth: 20 }; + const lf = await fixed(source, options); + expect(await fixed(source, { ...options, endOfLine: "crlf" })).toBe(lf.replaceAll("\n", "\r\n")); + }); + + test("preserve uses the first ending in mixed files and falls back to LF", async () => { + const options = { ...opts, endOfLine: "preserve" } as const; + const source = "import Base\ndef main() -> U32: 1\n"; + const lf = await fixed(source); + expect(await fixed(source.replace("\n", "\r\n"), options)).toBe(lf.replaceAll("\n", "\r\n")); + expect(await fixed(source.replace(/\n$/, "\r\n"), options)).toBe(lf); + expect(await fixed("def main() -> Type: Type", options)).toBe("def main() -> Type:\n Type\n"); + }); + + test("line ending options preserve literal contents", () => { + const source = 'def main() -> String:\n "first\r\nsecond\nthird"\n'; + for (const endOfLine of ["lf", "crlf", "preserve"] as const) { + const options = { ...opts, endOfLine }; + const newline = endOfLine === "crlf" ? "\r\n" : "\n"; + const output = format(source, options); + expect(output).toBe('def main() -> String:' + newline + ' "first\r\nsecond\nthird"' + newline); + expect(format(output, options)).toBe(output); + } + }); + test("wrapped lists use one item per line and relative indentation", async () => { const source = "import Base\ndef choose(first: U32, second: U32, third: U32) -> U32:\n first\ndef main() -> U32:\n choose(123456789, 234567890, 345678901)\n"; const output = await fixed(source, { tabWidth: 4, wrapAtWidth: 28 }); diff --git a/tools/bend-lint/rules/format.ts b/tools/bend-lint/rules/format.ts index 6d446fe61b..0e5e670a8a 100644 --- a/tools/bend-lint/rules/format.ts +++ b/tools/bend-lint/rules/format.ts @@ -4,11 +4,12 @@ // Run: bun src/lint.ts file.bend --rules rules/format.ts --fix // Config: { "rules": { "format/layout": { "tabWidth": 2, "wrapAtWidth": 100 } } } // Both numbers are positive integers; "never" disables optional wrapping. +// endOfLine: "lf" (default), "crlf", or "preserve" (first ending, LF fallback). import * as path from "node:path"; import type { Options, RuleContext, LintRule } from "../src/lint.ts"; import type { Book, HTerm } from "bend2/bend.ts"; -export type FormatOptions = { tabWidth: number; wrapAtWidth: number | "never" }; +export type FormatOptions = { tabWidth: number; wrapAtWidth: number | "never"; endOfLine?: "lf" | "crlf" | "preserve" }; type Token = { text: string; beg: number; end: number; col: number; kind: "code" | "literal" | "comment" | "newline" }; type Node = Token | { open: Token; close: Token; children: Node[] }; type Doc = string | { kind: "line"; flat: string; hard?: boolean; offset?: number } @@ -17,15 +18,18 @@ type Frame = { doc: Doc; indent: number; flat: boolean }; const DELIMITERS = new Map([["(", ")"], ["[", "]"], ["{", "}"], ["<", ">"]]); export function formatOptions(options: Options): FormatOptions { - const { tabWidth = 2, wrapAtWidth = 100 } = options; - const unknown = Object.keys(options).find((k) => k !== "tabWidth" && k !== "wrapAtWidth"); + const { tabWidth = 2, wrapAtWidth = 100, endOfLine = "lf" } = options; + const unknown = Object.keys(options).find((k) => k !== "tabWidth" && k !== "wrapAtWidth" && k !== "endOfLine"); if (unknown) throw new Error("format/layout has no option " + unknown); const positive = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v > 0; if (!positive(tabWidth)) throw new Error("format/layout: tabWidth must be a positive integer"); if (wrapAtWidth !== "never" && !positive(wrapAtWidth)) { throw new Error('format/layout: wrapAtWidth must be "never" or a positive integer'); } - return { tabWidth, wrapAtWidth }; + if (endOfLine !== "lf" && endOfLine !== "crlf" && endOfLine !== "preserve") { + throw new Error('format/layout: endOfLine must be "lf", "crlf" or "preserve"'); + } + return { tabWidth, wrapAtWidth, endOfLine }; } // Preserve literals and comment text verbatim. In particular, a quote or # @@ -202,7 +206,7 @@ function commentText(text: string): string { // A small document printer: a group is entirely flat if it fits; otherwise // its list separators break together. Nesting is relative, never alignment // under a function name. The width is a target, not a license to split atoms. -function print(doc: Doc, width: number, indent: number): string { +function print(doc: Doc, width: number, indent: number, newline: string): string { const stack: Frame[] = [{ doc, indent, flat: false }]; let out = " ".repeat(indent), col = indent; const fits = (remaining: number, pending: Frame[]): boolean => { @@ -229,7 +233,7 @@ function print(doc: Doc, width: number, indent: number): string { if (!d.hard && (f.flat || width === Infinity)) { out += d.flat; col += d.flat.length; } else { col = f.indent + (d.offset ?? 0); - out = out.trimEnd() + "\n" + " ".repeat(col); + out = out.trimEnd() + newline + " ".repeat(col); } } else if (d.kind === "nest") stack.push({ doc: d.doc, indent: f.indent + d.amount!, flat: f.flat }); else stack.push({ doc: d.doc, indent: f.indent, @@ -239,6 +243,8 @@ function print(doc: Doc, width: number, indent: number): string { } export function format(source: string, opts: FormatOptions): string { + const newline = opts.endOfLine === "preserve" ? /\r?\n/.exec(source)?.[0] ?? "\n" + : opts.endOfLine === "crlf" ? "\r\n" : "\n"; const nodes = tree(tokens(source)); const records: Node[][] = [[]]; for (let i = 0; i < nodes.length; i++) { @@ -282,12 +288,12 @@ export function format(source: string, opts: FormatOptions): string { nest([hard(), sequence(record.slice(colon + 1), source, opts, col)], opts.tabWidth)]; } else doc = sequence(record, source, opts, col); } - rows.push(print(doc, opts.wrapAtWidth === "never" ? Infinity : opts.wrapAtWidth, indent)); + rows.push(print(doc, opts.wrapAtWidth === "never" ? Infinity : opts.wrapAtWidth, indent, newline)); previous = record.map((n) => first(n).text).join(""); previousComment = comment; blank = false; } - return rows.join("\n").trimEnd() + "\n"; + return rows.join(newline).trimEnd() + newline; } // Reparse both versions with the same imported declarations. No typecheck, From fa8461532cc9ed94b2aff59306339917e9793beb Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Thu, 8 Oct 2026 09:05:57 -0300 Subject: [PATCH 32/39] bend-lint: check with bend's own book_read, not a copy 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 Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK --- tools/bend-lint/README.md | 20 ++-- tools/bend-lint/src/lint.test.ts | 45 ++++++-- tools/bend-lint/src/lint.ts | 169 ++++++++++++++++--------------- tools/bend-lint/src/patch.ts | 68 ++++++++----- 4 files changed, 183 insertions(+), 119 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index ccd6006b7a..ddbaa43dbc 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -300,8 +300,9 @@ Set `BEND_DIR` before importing bend-lint to pick a Bend checkout. Import bend-lint before you load Bend yourself, so the patching happens first. `lint` takes `{ config, signal, unsaved }` as a third argument. Without `config` it looks next to the file. `unsaved` maps file paths to editor text that isn't saved yet. -Bend and the rules read that text instead of the file, for that run only. A run -with `unsaved` can't overlap another run. `position(span)` gives an LSP range. +Bend and the rules read that text instead of the file, for that run only. Runs +at the same time wait for each other's check, one at a time; rules still run +side by side. `position(span)` gives an LSP range. `findConfig(file)` checks the file's directory, then each parent, for `bend-lint.json`, `.js` or `.ts`. Closest directory wins, then JSON, JS, TS. @@ -353,15 +354,22 @@ was before the fixes. ### Patching Bun patches Bend's modules while they load. `term_infer` and `term_check` are -renamed and wrapped to record what they return. The wrappers pass every argument -on. The `fs` and `path` adapters turn real paths into `/` paths so imports -resolve on Windows. `comp.ts` also exports `RUNTIME_MAIN` and `js_sat`, which -compiling Bend rules needs. +renamed and wrapped to record what they return to `hook.see` in patch.ts. The +wrappers pass every argument on. The hook is global, so checks run one at a +time. The `fs` and `path` adapters, in `bend.ts` and `main.ts`, turn real paths +into `/` paths so imports resolve on Windows. `comp.ts` also exports +`RUNTIME_MAIN` and `js_sat`, which compiling Bend rules needs. `main.ts` exports +`book_read`, `book_err` and `Check_Fail`: a file is read and checked by the same +code as `bend `, never a copy of it. Imported, `main.ts` also registers +Bend's loader for `import "x.bend"`. If a file has a line that's exactly `import Base` (what `bend --checkup` reads), it reuses one Base, checked once per process. Base facts are left out of rule requests. +A failed check is one `bend/check` finding. Its message is Bend's, without the +location: expected and observed, with the names in scope, and Bend's note. + ### Staying compatible We don't pin a Bend version. Instead, on load, for checkouts and downloads diff --git a/tools/bend-lint/src/lint.test.ts b/tools/bend-lint/src/lint.test.ts index 3480c5c9b6..68478d2b93 100644 --- a/tools/bend-lint/src/lint.test.ts +++ b/tools/bend-lint/src/lint.test.ts @@ -453,6 +453,7 @@ afterAll(() => fs.rmSync(DIR, { recursive: true, force: true })); describe("patch", () => { const src = fs.readFileSync(path.join(BEND2, "bend.ts"), "utf8"); const comp = fs.readFileSync(path.join(BEND2, "comp.ts"), "utf8"); + const main = fs.readFileSync(path.join(BEND2, "main.ts"), "utf8"); test("bend.ts: renames term_infer and term_check behind wrappers, and swaps fs and path", () => { const out = patch("bend.ts", src); @@ -469,6 +470,14 @@ describe("patch", () => { expect(patch("comp.ts", comp)).toContain("export { RUNTIME_MAIN, js_sat };"); }); + test("main.ts: exports how bend reads a book and words a failure, with the same fs and path", () => { + const out = patch("main.ts", main); + expect(out).toContain("export { book_read, book_err, Check_Fail };"); + expect(out).toMatch(/^import \{ fs \} from "file:.*patch\.ts";/m); + expect(out).toMatch(/^import \{ path \} from "file:.*patch\.ts";/m); + expect(() => patch("main.ts", main.replace("async function book_read(", "async function book_load_file("))).toThrow(/main\.ts: found 0 of a declaration of book_read/); + }); + test("fails loudly when an edit or a needed name is missing or repeated", () => { expect(() => patch("bend.ts", src.replace("export function term_infer(", "export function term_infer2("))).toThrow(DriftError); expect(() => patch("bend.ts", src.replace('import * as fs from "node:fs";', 'import fs from "node:fs";'))).toThrow(/node:fs/); @@ -481,6 +490,14 @@ describe("patch", () => { expect((Comp as unknown as Record).BEND_LINT_PATCH).toBe(1); }); + test("a check is bend's own: a PROOF.bend that skips ./LAWS.bend fails as bend says", async () => { + const dir = path.join(DIR, "proof"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "LAWS.bend"), "def one() -> Type:\n Type\n"); + const res = await lint(fixture("proof/PROOF.bend", "def two() -> Type:\n Type\n"), []); + expect([res.ok, res.diags[0].message]).toEqual([false, "PROOF.bend must import ./LAWS.bend"]); + }); + test("the wrappers pass every argument through, and need the expected arity", () => { const got: unknown[][] = []; const fake = ((...args: unknown[]) => (got.push(args), { tm: null, us: null })) as unknown as typeof Bend.term_check; @@ -535,10 +552,15 @@ describe("downloading bend", () => { const dir = await fetchBend("v2.0.36", at, gh.get); const effs = fs.readdirSync(path.join(BEND2, "effs")); expect(fs.readFileSync(path.join(dir, "bend.ts"), "utf8")).toBe(fs.readFileSync(path.join(BEND2, "bend.ts"), "utf8")); + expect(fs.readFileSync(path.join(dir, "main.ts"), "utf8")).toBe(fs.readFileSync(path.join(BEND2, "main.ts"), "utf8")); expect(fs.readdirSync(path.join(dir, "effs")).sort()).toEqual(effs.sort()); - expect(gh.asked.length).toBe(3 + effs.length); + expect(gh.asked.length).toBe(5 + effs.length); + expect(await fetchBend("v2.0.36", at, gh.get)).toBe(dir); + expect(gh.asked.length).toBe(5 + effs.length); + fs.rmSync(path.join(dir, "main.ts")); // a cache from before main.ts was patched expect(await fetchBend("v2.0.36", at, gh.get)).toBe(dir); - expect(gh.asked.length).toBe(3 + effs.length); + expect(gh.asked.length).toBe(2 * (5 + effs.length)); + expect(fs.existsSync(path.join(dir, "main.ts"))).toBe(true); expect(fs.readdirSync(path.join(at, "v2.0.36"))).toEqual(["bend2"]); const out = spawnSync(process.execPath, [CLI, fixture("downloaded.bend", USERLAND)], { encoding: "utf8", env: { ...process.env, BEND_DIR: dir } }); expect([out.status, out.stderr]).toEqual([0, ""]); @@ -678,7 +700,11 @@ describe("lint", () => { expect(render(parse.diags[0])).toStartWith("Error [bend/check]:"); expect((await lint(fixture("name.bend", "type N is Data:\n Z{}\ndef broken() -> N:\n missing\n"), [neverRun])).ok).toBe(false); const todo = await lint(fixture("todo.bend", "type N is Data:\n Z{}\ndef broken() -> N:\n ?TODO\n"), [neverRun]); - expect(todo.diags[0].message).toContain("1 TODO found"); + expect(todo.diags[0].message).toStartWith("1 TODO found"); + // The message is bend's, its location aside: expected and observed, with the names in scope. + const types = "type N is Data:\n Z{}\n\ntype M is Data:\n W{}\n\n"; + expect((await lint(fixture("mismatch.bend", types + "def bad(x: N) -> M:\n x\n"), [neverRun])).diags[0].message).toBe("expected: M\nobserved: N"); + expect((await lint(fixture("bound.bend", types + "def bad(A: Type, x: N) -> A:\n x\n"), [neverRun])).diags[0].message).toBe("expected: A\nobserved: N"); expect((await lint(path.join(DIR, "missing.bend"), [neverRun])).ok).toBe(false); }); @@ -716,9 +742,9 @@ describe("lint", () => { expect(await text()).toBe(saved); const broken = await lint(file, [seen], { unsaved: new Map([[file, "import Base\ndef main() -> U32:\n nope\n"]]) }); expect([broken.ok, broken.diags[0].code]).toEqual([false, "bend/check"]); - const first = text(new Map([[file, edited]])); - await expect(text(new Map([[file, edited]]))).rejects.toThrow(/unsaved text/); - expect(await first).toBe(edited); + // Runs at the same time wait for each other's check, and each sees its own text. + const other = "import Base\ndef main() -> U32:\n 3\n"; + expect(await Promise.all([text(new Map([[file, edited]])), text(), text(new Map([[file, other]]))])).toEqual([edited, saved, other]); expect(await text()).toBe(saved); }); @@ -928,6 +954,13 @@ describe("review fixes", () => { } }); + test("checks at the same time keep their facts apart", async () => { + const files = ["one", "two"].map((name) => fixture("apart_" + name + ".bend", "type N is Data:\n Z{}\n\ndef " + name + "(x: N) -> N:\n x\n")); + const where: LintRule = { id: "test/where", facts: true, run: (cx) => [cx.diag({ message: [...new Set([...cx.facts!.values()].map((f) => f.def))].join(), severity: "hint" })] }; + const got = await Promise.all([...files, ...files].map(async (file) => (await lint(file, [where])).diags[0].message)); + expect(got).toEqual(["N,one", "N,two", "N,one", "N,two"]); + }); + test("identical files map by namespace, not only by text", async () => { const dir = path.join(DIR, "twins"); fs.mkdirSync(dir, { recursive: true }); diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 09426022cc..9896883cf8 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -11,8 +11,7 @@ import * as util from "node:util"; import type * as BendModule from "bend2/bend.ts"; import type * as CompModule from "bend2/comp.ts"; import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "bend2/bend.ts"; -import { DriftError, MARK, bendDir, fs, patch, path, unsaved } from "./patch.ts"; -import type { Hooked } from "./patch.ts"; +import { DriftError, MARK, PATCHED, bendDir, fs, hook, patch, path, unsaved } from "./patch.ts"; // Types // ===== @@ -137,6 +136,14 @@ type Channel = { // comp.ts as patch.ts exports it. type Comp = typeof CompModule & { RUNTIME_MAIN: string; js_sat(k: Name): string }; +// What bend-lint uses of main.ts, as patch.ts exports it: book_read throws +// a Check_Fail holding why the check failed. +type Main = { + book_read(file: string, base?: Book, seen?: Map): Promise; + book_err(e: unknown): string; + Check_Fail: new (why: unknown) => { why: unknown }; +}; + type Mapper = { (s: Span): Span; (s: Span | undefined): Span | undefined }; type Checked = { book: Book; sources: Source[]; span: Mapper; facts?: Map; failure?: Diag }; @@ -147,7 +154,6 @@ const RULE_ID = /^[^/\s]+\/[^/\s]+$/; const SCOPES = ["file", "program"]; const CONFIG_FILES = ["bend-lint.json", "bend-lint.js", "bend-lint.ts"]; const LINE = /[^\n]*\n|[^\n]+$/g; -const STACK = "the machine stack overflowed (a deep recursion, or a literal too large to expand)"; const SAMPLE = "type N is Data:\n Z{}\n\ndef id(x: N) -> N:\n x\n"; const HEAD: Record = { error: "Error", warning: "Warning", information: "Information", hint: "Hint" }; @@ -157,6 +163,9 @@ const shared = globalThis as typeof globalThis & { BEND_LINT?: Channel }; // per process, and again only if base.bend changes. const BASES = new Map>(); +// The check running now, or done; the next check waits for it. +let queue: Promise = Promise.resolve(); + // Line starts per file object (see starts). const STARTS = new WeakMap(); @@ -291,56 +300,62 @@ function isErr(e: unknown): e is Err { return typeof e === "object" && e !== null && (e as { $?: unknown }).$ === "Err"; } -// Loads and checks a book as bend2/main.ts book_read does; it does not give -// main.ts's verdict on @unsafe and foreign code. A file with a line exactly -// `import Base` starts from a copy of the checked Base, as `bend --checkup` -// does. A fact is kept as the checker gives it only if a filter wants it; -// with scope file, only if it is from the linted file's folder and -// namespace. After the check, only facts whose span maps to the linted file -// (or, with scope program, to any file but Base) stay. -async function check(file: string, filters: FactFilter[], signal: AbortSignal): Promise { - signal.throwIfAborted(); - const book: Hooked = Bend.book_nil(); +// Checks a book with bend2/main.ts book_read, as `bend ` does; it +// does not give main.ts's verdict on @unsafe and foreign code. A file with +// a line exactly `import Base` starts from the checked Base, as `bend +// --checkup` does. `text` (unsaved editor text) is read in place of the +// files on disk. The hook and the text are global to bend, so checks run +// one at a time, in the order asked. A fact is kept as the checker gives +// it only if a filter wants it; with scope file, only if it is from the +// linted file's folder and namespace. After the check, only facts whose +// span maps to the linted file (or, with scope program, to any file but +// Base) stay. +function check(file: string, filters: FactFilter[], signal: AbortSignal, text?: ReadonlyMap): Promise { + const turn = queue.then(async (): Promise => { + signal.throwIfAborted(); + for (const [at, str] of text ?? []) { + if (fs.existsSync(at)) { + unsaved.set(fs.realpathSync(at), str); + } + } + try { + return await checked(file, filters, signal); + } finally { + hook.see = undefined; + unsaved.clear(); + } + }); + queue = turn.catch(() => undefined); + return turn; +} + +// check's work, in its turn. +async function checked(file: string, filters: FactFilter[], signal: AbortSignal): Promise { const seen = new Map(); const found: Array> = []; const real = fs.existsSync(file) ? fs.realpathSync(file) : ""; const home = real.slice(0, real.lastIndexOf("/") + 1); const far = filters.filter((f) => f.scope === "program"); const program = far.length > 0; - book.see = filters.length > 0 - ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => { - const at = spn?.file as { dir?: string; ns?: string } | undefined; - const near = at?.dir === home && at.ns === "" ? filters : far; - const { kind, name } = near.length > 0 ? shape(tm) : { kind: "", name: "" }; - if (near.some((f) => matches(f, kind, def, name))) { - found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us }); - } - } - : undefined; const seeded = real !== "" && /^import Base$/m.test(fs.readFileSync(real, "utf8")); const key = fs.readFileSync(Bend.BASE_BEND, "utf8"); if (seeded && !BASES.has(key)) { - const base = Bend.book_nil(); - BASES.set(key, Bend.book_load(base, Bend.BASE_BEND, "", new Map()).then(() => (Bend.book_valid(base), base))); + BASES.set(key, Main.book_read(Bend.BASE_BEND)); } + let book: Book = Bend.book_nil(); const caught = await Promise.resolve(seeded ? BASES.get(key) : undefined).then(async (base) => { - if (base !== undefined) { - Object.assign(book.tlds, Object.fromEntries(Object.entries(base.tlds).map(([k, tld]) => [k, { ...tld }]))); - Object.assign(book.ctrs, base.ctrs); - Object.assign(book.tmps, Object.fromEntries(Object.entries(base.tmps).map(([k, m]) => [k, new Map(m)]))); - book.order.push(...base.order); - seen.set(Bend.BASE_BEND, ""); - } - await Bend.book_load(book, file, "", seen); - const laws = path.join(path.dirname(file), "LAWS.bend"); - if (path.basename(file) === "PROOF.bend" && fs.existsSync(laws) && !seen.has(fs.realpathSync(laws))) { - throw "PROOF.bend must import ./LAWS.bend"; - } - Bend.book_valid(book, base?.order.length ?? 0); - if (book.hols > 0) { - throw book.hols + " TODO" + (book.hols === 1 ? "" : "s") + " found.\nThe code is incomplete, and not a valid proof yet."; - } - }).then(() => undefined, (e: unknown) => ({ e })); + hook.see = filters.length > 0 + ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => { + const at = spn?.file as { dir?: string; ns?: string } | undefined; + const near = at?.dir === home && at.ns === "" ? filters : far; + const { kind, name } = near.length > 0 ? shape(tm) : { kind: "", name: "" }; + if (near.some((f) => matches(f, kind, def, name))) { + found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us }); + } + } + : undefined; + book = await Main.book_read(file, base, seen); + }).then(() => undefined, (e: unknown) => ({ e: e instanceof Main.Check_Fail ? e.why : e })); signal.throwIfAborted(); const root = [...seen.keys()].find((real) => real !== Bend.BASE_BEND || !seeded); const sources = [...seen.keys()].filter((real) => fs.existsSync(real)).map((real): Source => { @@ -359,7 +374,12 @@ async function check(file: string, filters: FactFilter[], signal: AbortSignal): return { book, sources, span, facts: filters.length > 0 ? new Map(facts) : undefined }; } const err = isErr(caught.e) ? caught.e : undefined; - const message = err !== undefined ? Bend.expr_show(err.bok, err.exp) : caught.e instanceof RangeError ? STACK : String(caught.e); + // bend's words, its location aside (a finding has its own): expected and + // observed, with the names in scope, and the note. + const show = (x: Err["exp"]): string => Bend.expr_show(err!.bok, x, Bend.ctx_scope(err!.ctx), err!.spn?.file); + const message = err === undefined ? Main.book_err(caught.e).replace(/^Error: /, "") + : (err.obs === undefined ? show(err.exp) : "expected: " + show(err.exp) + "\nobserved: " + show(err.obs)) + + (err.nte === undefined ? "" : "\n" + err.nte); // A file bend did not finish loading may not map; the failure still reports. let spn: Span | undefined; try { @@ -440,28 +460,6 @@ function settings(rule: LintRule, config: Config): { off: boolean; severity?: Se return { off: false, severity, options: { ...rule.options, ...options } }; } -// Runs `load` with the unsaved text in place of the files on disk. It is -// global to bend's loader, so a run with unsaved text cannot overlap another -// run, and it is cleared at the end. -async function holding(text: ReadonlyMap | undefined, load: () => Promise): Promise { - if (text === undefined || text.size === 0) { - return load(); - } - if (unsaved.size > 0) { - throw new Error("another run is checking unsaved text; wait for it to end"); - } - for (const [file, str] of text) { - if (fs.existsSync(file)) { - unsaved.set(fs.realpathSync(file), str); - } - } - try { - return await load(); - } finally { - unsaved.clear(); - } -} - // Rules run in order; the config may turn one off, set its severity, and // give its options. A finding with severity error stops the run. A rule // that throws, and an abort, reach the caller. @@ -478,7 +476,7 @@ export async function lint(file: string, rules: LintRule[], const plans = rules.map((rule) => ({ rule, ...settings(rule, config), want: rule.facts === true ? {} : rule.facts })) .filter((p) => !p.off); const program = plans.some((p) => p.want?.scope === "program"); - const { book, sources, span, facts, failure } = await holding(held, () => check(file, plans.flatMap((p) => p.want === undefined ? [] : [p.want]), signal)); + const { book, sources, span, facts, failure } = await check(file, plans.flatMap((p) => p.want === undefined ? [] : [p.want]), signal, held); if (failure !== undefined) { return { ok: false, diags: [failure], sources, book }; } @@ -743,14 +741,15 @@ export async function bendRule(file: string): Promise { }; } -// Finds bend2 (see bendDir), patches bend.ts and comp.ts as Bun loads -// them, then checks what the wrappers record for `x` in a tiny program. -async function instrument(given: string | undefined): Promise<{ Bend: Bend; Comp: Comp; BEND2: string }> { +// Finds bend2 (see bendDir), patches its PATCHED files as Bun loads them, +// then checks what the wrappers record for `x` in a tiny program. +async function instrument(given: string | undefined): Promise<{ Bend: Bend; Comp: Comp; Main: Main; BEND2: string }> { const dir = await bendDir(given); - // Bun.plugin is process-wide: match this checkout's two files by full path. - const patched = new Map(["bend.ts", "comp.ts"].map((f) => [path.join(dir, f), patch(f, fs.readFileSync(path.join(dir, f), "utf8"))])); + // Bun.plugin is process-wide: match this checkout's files by full path. + const patched = new Map(PATCHED.map((f) => [path.join(dir, f), patch(f, fs.readFileSync(path.join(dir, f), "utf8"))])); const exact = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll("/", "[\\\\/]"); - const filter = new RegExp("^" + exact + "[\\\\/](bend|comp)\\.ts$", process.platform === "win32" ? "i" : ""); + const names = PATCHED.map((f) => f.replace(/\.ts$/, "")).join("|"); + const filter = new RegExp("^" + exact + "[\\\\/](" + names + ")\\.ts$", process.platform === "win32" ? "i" : ""); Bun.plugin({ name: "bend-lint", setup: (build) => { @@ -763,18 +762,24 @@ async function instrument(given: string | undefined): Promise<{ Bend: Bend; Comp }); }, }); - const [B, C]: [Bend & { [MARK]?: number }, Comp & { [MARK]?: number }] = await Promise.all([ + // Imported, main.ts also registers bend's own loader for `import "x.bend"`. + const [B, C, M]: [Bend & { [MARK]?: number }, Comp & { [MARK]?: number }, Main & { [MARK]?: number }] = await Promise.all([ import(url.pathToFileURL(path.join(dir, "bend.ts")).href), import(url.pathToFileURL(path.join(dir, "comp.ts")).href), + import(url.pathToFileURL(path.join(dir, "main.ts")).href), ]); - if (B[MARK] !== 1 || C[MARK] !== 1) { + if (B[MARK] !== 1 || C[MARK] !== 1 || M[MARK] !== 1) { throw new DriftError("bend2 was loaded before bend-lint could patch it; import bend-lint first"); } - const sample: Hooked = B.book_nil(); + const sample = B.book_nil(); const seen: Fact[] = []; - sample.see = (bok, tm, ty, ctx, dep, def, spn, qt, us) => void seen.push({ tm, ty, bok, ctx, dep, def, spn, qt, us, inst: false }); - B.parse_book(sample, "/", SAMPLE, "", {}); - B.book_valid(sample); + hook.see = (bok, tm, ty, ctx, dep, def, spn, qt, us) => void seen.push({ tm, ty, bok, ctx, dep, def, spn, qt, us, inst: false }); + try { + B.parse_book(sample, "/", SAMPLE, "", {}); + B.book_valid(sample); + } finally { + hook.see = undefined; + } const xs = seen.filter((f) => f.def === "id" && B.term_strip(f.tm).$ === "Var"); const kind = (f: Fact, t: HTerm) => (B.term_wnf(f.bok, t) as { k?: string }).k; const tagged = (x: unknown, tags: string[]) => tags.includes((x as { $?: string } | null)?.$ ?? ""); @@ -797,7 +802,7 @@ async function instrument(given: string | undefined): Promise<{ Bend: Bend; Comp throw new DriftError("self-check failed: the patched bend.ts recorded the wrong " + [...new Set(wrong)].join(", ") + " for `x` in `def id(x: N) -> N: x`; update tools/bend-lint/src/patch.ts"); } - return { Bend: B, Comp: C, BEND2: dir }; + return { Bend: B, Comp: C, Main: M, BEND2: dir }; } async function cli(argv: string[]): Promise { @@ -854,8 +859,10 @@ function fail(e: unknown): never { // The CLI's --bend is read here, before bend loads; a library uses $BEND_DIR. const given = import.meta.main ? util.parseArgs({ args: process.argv.slice(2), options: OPTIONS, allowPositionals: true, strict: false }).values.bend : undefined; -export const { Bend, Comp, BEND2 }: { Bend: Bend; Comp: Comp; BEND2: string } = - await instrument(typeof given === "string" ? given : undefined).catch((e: unknown) => import.meta.main ? fail(e) : Promise.reject(e)); +const loaded = await instrument(typeof given === "string" ? given : undefined).catch((e: unknown) => import.meta.main ? fail(e) : Promise.reject(e)); + +export const { Bend, Comp, BEND2 }: { Bend: Bend; Comp: Comp; BEND2: string } = loaded; +const { Main } = loaded; if (import.meta.main) { process.exit(await cli(process.argv.slice(2)).catch(fail)); diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts index b4affde6eb..67bc2d9cfa 100644 --- a/tools/bend-lint/src/patch.ts +++ b/tools/bend-lint/src/patch.ts @@ -1,10 +1,13 @@ // What bend-lint changes in bend2 as Bun loads it; the files on disk never // change. In bend.ts, term_infer and term_check are renamed and replaced by -// the wrappers below, which tell a `book.see` hook what they return; and fs -// and path come from this module: bend.ts builds paths with "/" -// (path.posix), so here real paths use "/" and a Windows drive letter is a -// root; on POSIX they behave as node's. comp.ts exports RUNTIME_MAIN and -// js_sat, so a rule written in Bend is compiled once and run many times. +// the wrappers below, which tell `hook.see` what they return; and fs and +// path come from this module, in bend.ts and main.ts: bend.ts builds paths +// with "/" (path.posix), so here real paths use "/" and a Windows drive +// letter is a root; on POSIX they behave as node's. comp.ts exports +// RUNTIME_MAIN and js_sat, so a rule written in Bend is compiled once and +// run many times. main.ts exports how `bend` reads a book (book_read) and +// words a failure (book_err, Check_Fail), so bend-lint checks a file +// exactly as bend does. // Each text edit must match exactly once, and each name a tail exports must // be declared once. The wrappers pass every argument through, so bend // computes what it would without them; what they record is checked at load @@ -25,11 +28,9 @@ import type * as BendModule from "bend2/bend.ts"; // must declare once and export (the tail exports those it does not). type Patch = { edits: Array<[string, string]>; tail: string; exports: string[] }; -// A book whose checker reports to `see`: for each checked term, what it was -// checked or inferred as, where, and how it was used. -export type Hooked = Book & { - see?: (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn: Span | undefined, qt: Quant, us: Uses) => void; -}; +// What the checker reports for each checked term: what it was checked or +// inferred as, where, and how it was used. +export type See = (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn: Span | undefined, qt: Quant, us: Uses) => void; // An HTTP GET. export type Get = (url: string) => Promise; @@ -63,11 +64,15 @@ const CACHE = nodePath.join(process.env.XDG_CACHE_HOME ?? (process.platform === "win32" ? process.env.LOCALAPPDATA ?? nodePath.join(os.homedir(), "AppData", "Local") : nodePath.join(os.homedir(), ".cache")), "bend-lint"); +const SHIMMED: Array<[string, string]> = [ + ['import * as fs from "node:fs";', "import { fs } from " + SHIM + ";"], + ['import * as path from "node:path";', "import { path } from " + SHIM + ";"], +]; + const PATCHES: Record = { "bend.ts": { edits: [ - ['import * as fs from "node:fs";', "import { fs } from " + SHIM + ";"], - ['import * as path from "node:path";', "import { path } from " + SHIM + ";"], + ...SHIMMED, ["export function term_infer(", "function unseen_term_infer("], ["export function term_check(", "function unseen_term_check("], ], @@ -77,10 +82,17 @@ const PATCHES: Record = { exports: [], }, "comp.ts": { edits: [], tail: "", exports: ["RUNTIME_MAIN", "js_sat"] }, + "main.ts": { edits: SHIMMED, tail: "", exports: ["book_read", "book_err", "Check_Fail"] }, }; -// Text an editor holds unsaved, by real path, set by lint() for one run. -// bend.ts reads it in place of the file on disk. +// The bend2 files bend-lint patches and imports. +export const PATCHED = Object.keys(PATCHES); + +// Where the wrappers report, set by lint.ts for one check at a time. +export const hook: { see?: See } = {}; + +// Text an editor holds unsaved, by real path, set by lint.ts for one check +// at a time. bend.ts reads it in place of the file on disk. export const unsaved = new Map(); // fs and path for bend.ts. @@ -121,7 +133,7 @@ export function relative(from: string, to: string): string { } // term_infer and term_check as bend.ts calls them: every argument passes -// through, and each result is also reported to book.see. tsc checks the +// through, and each result is also reported to hook.see. tsc checks the // names against bend.ts's signatures; a function that no longer takes the // arguments read here stops loading. export function seeInfer(f: typeof BendModule.term_infer): typeof BendModule.term_infer { @@ -129,7 +141,7 @@ export function seeInfer(f: typeof BendModule.term_infer): typeof BendModule.ter return (...args) => { const r = f(...args); const [book, lhs, tm, qt, ctx, d] = args; - (book as Hooked).see?.(book, r.tm, r.ty, ctx, d, lhs.def, tm.s, qt, r.us); + hook.see?.(book, r.tm, r.ty, ctx, d, lhs.def, tm.s, qt, r.us); return r; }; } @@ -139,7 +151,7 @@ export function seeCheck(f: typeof BendModule.term_check): typeof BendModule.ter return (...args) => { const r = f(...args); const [book, lhs, tm, qt, ty, ctx, d] = args; - (book as Hooked).see?.(book, r.tm, ty, ctx, d, lhs.def, tm.s, qt, r.us); + hook.see?.(book, r.tm, ty, ctx, d, lhs.def, tm.s, qt, r.us); return r; }; } @@ -151,7 +163,7 @@ function arity(f: (...args: never[]) => unknown, n: number, name: string): void } } -// `file` is "bend.ts" or "comp.ts". +// `file` is one of PATCHED. export function patch(file: string, src: string): string { const { edits, tail, exports } = PATCHES[file]; const drift = (what: string, n: number): never => { @@ -163,8 +175,8 @@ export function patch(file: string, src: string): string { return n === 1 ? out.replace(at, () => to) : drift(JSON.stringify(at), n); }, src); const missing = exports.filter((name) => { - const n = edited.match(new RegExp("^(?:export )?(?:function|const|let) " + name + "\\b", "gm"))?.length ?? 0; - return n === 1 ? !new RegExp("^export (?:function|const|let) " + name + "\\b", "m").test(edited) : drift("a declaration of " + name, n); + const n = edited.match(new RegExp("^(?:export )?(?:async function|function|class|const|let) " + name + "\\b", "gm"))?.length ?? 0; + return n === 1 ? !new RegExp("^export (?:async function|function|class|const|let) " + name + "\\b", "m").test(edited) : drift("a declaration of " + name, n); }); return edited + "\n" + tail + (missing.length === 0 ? "" : "export { " + missing.join(", ") + " };\n") + "export const " + MARK + " = 1;\n"; @@ -248,15 +260,18 @@ export async function latestTag(cache: string = CACHE, get: Get = download): Pro return listed; } -// bend2 at a release: bend.ts, comp.ts, base.bend, and the effs/ files -// base.bend imports, kept in //bend2. A download goes to a -// temporary folder first, so the cache never holds a partial one. +// bend2 at a release: the PATCHED files, safe.ts (main.ts imports it), +// base.bend, and the effs/ files base.bend imports, kept in +// //bend2. A download goes to a temporary folder first, so the +// cache never holds a partial one; a cached folder without main.ts (from an +// older bend-lint) is downloaded again. export async function fetchBend(tag: string, cache: string = CACHE, get: Get = download): Promise { if (!RELEASE.test(tag)) { throw new Error("not a bend release: " + tag); } const dir = nodePath.join(cache, tag, "bend2"); - if (nodeFs.existsSync(dir)) { + const whole = (): boolean => [...PATCHED, "safe.ts", "base.bend"].every((f) => nodeFs.existsSync(nodePath.join(dir, f))); + if (whole()) { return dir; } const text = (file: string): Promise<[string, string]> => get(RAW + tag + "/bend2/" + file) @@ -268,7 +283,7 @@ export async function fetchBend(tag: string, cache: string = CACHE, get: Get = d if (odd !== undefined) { throw new Error("base.bend imports " + odd + ", which is not a plain file in effs/"); } - const files = [base, ...await Promise.all(["bend.ts", "comp.ts", ...effs].map(text))]; + const files = [base, ...await Promise.all([...PATCHED, "safe.ts", ...effs].map(text))]; nodeFs.mkdirSync(nodePath.join(cache, tag), { recursive: true }); const part = nodeFs.mkdtempSync(nodePath.join(cache, tag, ".part-")); files.forEach(([file, body]) => { @@ -276,10 +291,11 @@ export async function fetchBend(tag: string, cache: string = CACHE, get: Get = d nodeFs.writeFileSync(nodePath.join(part, file), body); }); try { + nodeFs.rmSync(dir, { recursive: true, force: true }); nodeFs.renameSync(part, dir); } catch (e) { nodeFs.rmSync(part, { recursive: true, force: true }); - if (!nodeFs.existsSync(dir)) { + if (!whole()) { throw e; } } From 357932f1cd8a50178fae5de863b93c882934241c Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Thu, 8 Oct 2026 10:03:26 -0300 Subject: [PATCH 33/39] bend-lint: seam.ts, and a public API that does not depend on bend2 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 Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK --- gates/repo.ts | 2 +- tools/bend-lint/README.md | 58 +- tools/bend-lint/rules/file_length.test.ts | 31 +- tools/bend-lint/rules/format.test.ts | 167 +++- tools/bend-lint/rules/format.ts | 365 ++++--- tools/bend-lint/src/effects.js | 73 +- tools/bend-lint/src/lint.test.ts | 1094 ++++++++++++++++----- tools/bend-lint/src/lint.ts | 961 ++++++++---------- tools/bend-lint/src/patch.ts | 303 ------ tools/bend-lint/src/sample.bend | 8 + tools/bend-lint/src/seam.ts | 999 +++++++++++++++++++ 11 files changed, 2721 insertions(+), 1340 deletions(-) delete mode 100644 tools/bend-lint/src/patch.ts create mode 100644 tools/bend-lint/src/sample.bend create mode 100644 tools/bend-lint/src/seam.ts diff --git a/gates/repo.ts b/gates/repo.ts index 1e92892fb1..d1a7d06271 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -85,7 +85,7 @@ allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\ allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json)$/); -allow(/^tools\/bend-lint\/src\/(lint\.ts|patch\.ts|lint\.bend|effects\.js|lint\.test\.ts)$/); +allow(/^tools\/bend-lint\/src\/(lint\.ts|seam\.ts|sample\.bend|lint\.bend|effects\.js|lint\.test\.ts)$/); allow(/^tools\/bend-lint\/rules\/[a-z_]+(\.test)?\.(ts|bend)$/); // Gate diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index ddbaa43dbc..9a59331e32 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -138,35 +138,39 @@ the rest of that rule's findings and every later rule are dropped. What's on `cx`: - `root`, `sources`: the linted file and its imports, text as on disk -- `book`: the checked Bend program - `options`: defaults merged with config - `facts`: just the facts this rule asked for - `prior`: findings from earlier rules -- `span(s)`: Bend span to a file on disk -- `walk(tm)`: walk Bend terms -- `binder(fact, v)`: find a variable's binder +- `view(fact)`: what the fact's term is (see Facts) +- `type(fact)`: the type the term was checked as +- `binder(fact)`: the declared type of the variable a `Var` uses - `show`, `same`, `normal`: print, compare and normalize types in a fact's scope - `uses(fact)`: used variables with their quantities +- `sameDeclarations(text)`: whether `text` declares what the linted file + declares, compared as parsed, not checked - `diag(init)`: make a diagnostic with this rule's ID -- `Bend`: the loaded Bend module. Use it instead of importing `bend2/bend.ts` +- `unstable`: Bend's own objects (`Bend`, the checked `book`, `raw(fact)`). + Code that uses them breaks when Bend changes; nothing else on `cx` does. -The exported types in [src/lint.ts](src/lint.ts) are the full contract. +The exported types in [src/lint.ts](src/lint.ts) are the full contract. They +are bend-lint's own: facts and types are handles, and only `cx` reads them. #### Fixes A fix is a title, an applicability (`safe`, `suggested`, `dangerous`) and edits. -An edit swaps a span for text. A zero-length span inserts. Offsets are integer -UTF-16 units inside the source, and edits in one fix can't overlap. +An edit swaps a span for text. A span is a source from `cx.sources` and two +offsets. A zero-length span inserts. Offsets are integer UTF-16 units inside the +source, and edits in one fix can't overlap. ```ts -const spn = { file: cx.root.file, beg: 0, end: 1 }; +const span = { file: cx.root, beg: 0, end: 1 }; return [cx.diag({ message: "Replace the first character with a space.", - spn, + span, fixes: [{ title: "Replace character", applicability: "suggested", - edits: [{ spn, text: " " }], + edits: [{ span, text: " " }], }], })]; ``` @@ -175,9 +179,12 @@ return [cx.diag({ #### Facts -A fact is one checked term with its type (`ty`), scope (`ctx`), depth (`dep`), -enclosing definition (`def`), demanded quantity (`qt`), variable uses (`us`), -book (`bok`) and source span (`spn`, when there is one). The term itself is `tm`. +A fact is one checked term. `cx.view(fact)` gives its kind (annotations +stripped: `Var`, `Ref`, `App`, ...), the name a `Var` or `Ref` points to, its +enclosing definition (`owner`), how many times it is demanded (`quantity`: +`erased`, `once` or `many`), and its source span (`span`, and `inner` without +the annotations), when there is one. Its type and its scope are reached through +the other `cx` operations. Ask for them with `facts: true` (everything in the linted file) or a filter: @@ -194,8 +201,8 @@ All the lists you give must match. Leave one out or empty and it matches everything. Keep filters narrow, since memory goes up with the number of facts. Template bodies get checked as written and again per instance (`generic~0`), -sometimes at the same span. `fact.inst` marks instances. Skip them to avoid -double reports: +sometimes at the same span. `view(fact).inst` marks instances. Skip them to +avoid double reports: ```ts import type { LintRule } from "../src/lint.ts"; @@ -203,12 +210,12 @@ import type { LintRule } from "../src/lint.ts"; export const rules: LintRule[] = [{ id: "demo/var-types", facts: { kinds: ["Var"] }, - run: (cx) => [...cx.facts!.values()] - .filter((fact) => !fact.inst) + run: (cx) => cx.facts! + .filter((fact) => !cx.view(fact).inst) .map((fact) => cx.diag({ - message: "type: " + cx.show(fact, fact.ty), + message: "type: " + cx.show(fact, cx.type(fact)), severity: "hint", - spn: fact.spn, + span: cx.view(fact).span, fact, })), }]; @@ -290,7 +297,7 @@ console.log(result.ok, result.diags.map(render)); if (result.ok) { const root = result.sources.find((source) => source.root)!; - const { text, skipped } = applyFixes(root.file, result.diags); + const { text, skipped } = applyFixes(root, result.diags); // text is the edited source; saving it is up to you console.log(text, skipped); } @@ -354,7 +361,7 @@ was before the fixes. ### Patching Bun patches Bend's modules while they load. `term_infer` and `term_check` are -renamed and wrapped to record what they return to `hook.see` in patch.ts. The +renamed and wrapped to record what they return to `hook.see` in seam.ts. The wrappers pass every argument on. The hook is global, so checks run one at a time. The `fs` and `path` adapters, in `bend.ts` and `main.ts`, turn real paths into `/` paths so imports resolve on Windows. `comp.ts` also exports @@ -379,8 +386,11 @@ alike: have to be declared - wrapper signatures are checked against Bend's at type-check time, and argument counts at runtime -- a self-check looks at the type, depth, scope, quantity, uses and span recorded - for `x` in `def id(x: N) -> N: x` +- `book_read`, `book_err` and `Check_Fail` from `main.ts` must take the + arguments bend-lint gives them +- a self-check reads `src/sample.bend` with `book_read`, and looks at the type, + depth, scope, quantity, uses and span recorded for `x` in + `def id(x: N) -> N: x`; a missing file must fail with a `Check_Fail` Any mismatch throws `DriftError` and loading stops. The tests add more, including drift against the chosen checkout's own tests. diff --git a/tools/bend-lint/rules/file_length.test.ts b/tools/bend-lint/rules/file_length.test.ts index 357153a469..b936da9672 100644 --- a/tools/bend-lint/rules/file_length.test.ts +++ b/tools/bend-lint/rules/file_length.test.ts @@ -27,28 +27,45 @@ test("Bend file length uses a default limit of 500 and offers no fix", async () test("physical line counting handles empty files, LF, CRLF and absent final newlines", async () => { for (const [text, count] of [ - ["", 0], ["# one", 1], ["# one\n", 1], ["# one\r\n", 1], - ["\n", 1], ["\r\n", 1], ["# one\n\n", 2], ["# one\r\n\r\n", 2], + ["", 0], + ["# one", 1], + ["# one\n", 1], + ["# one\r\n", 1], + ["\n", 1], + ["\r\n", 1], + ["# one\n\n", 2], + ["# one\r\n\r\n", 2], ] as const) { const result = await lint(fixture(text), [rule], { config: config(count) }); expect([result.ok, result.diags]).toEqual([true, []]); if (count > 0) { const over = await lint(fixture(text), [rule], { config: config(count - 1) }); - expect(over.diags[0].message).toBe("Keep the file to " + (count - 1) + " lines; it has " + count + "."); + expect(over.diags[0].message).toBe( + "Keep the file to " + (count - 1) + " lines; it has " + count + ".", + ); } } }); test("only the root file counts, not its imports", async () => { - fixture("import Base\n" + "# dependency\n".repeat(600) + "def value() -> U32:\n 1\n", "dependency.bend"); - const file = fixture("import Base\nimport ./dependency.bend as D\ndef main() -> U32:\n D.value()\n"); + fixture( + "import Base\n" + "# dependency\n".repeat(600) + "def value() -> U32:\n 1\n", + "dependency.bend", + ); + const file = fixture( + "import Base\nimport ./dependency.bend as D\ndef main() -> U32:\n D.value()\n", + ); expect((await lint(file, [rule], { config: config(4) })).diags).toEqual([]); }); test("the Bend rule honors disable and severity configuration", async () => { const file = fixture("# first\n# second\n"); - expect((await lint(file, [rule], { config: { rules: { "style/file-length": "off" } } })).diags).toEqual([]); - const result = await lint(file, [rule], { config: { rules: { "style/file-length": { maxLines: 1, severity: "hint" } } } }); + expect( + (await lint(file, [rule], { config: { rules: { "style/file-length": "off" } } })).diags, + ).toEqual([]); + const result = await lint(file, [rule], { + config: { rules: { "style/file-length": { maxLines: 1, severity: "hint" } } }, + }); expect(result.diags.map((d) => d.severity)).toEqual(["hint"]); }); diff --git a/tools/bend-lint/rules/format.test.ts b/tools/bend-lint/rules/format.test.ts index eaa2650e51..7325320eec 100644 --- a/tools/bend-lint/rules/format.test.ts +++ b/tools/bend-lint/rules/format.test.ts @@ -4,8 +4,9 @@ import * as os from "node:os"; import * as path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { Bend, lint, applyFixes, walk, render } from "../src/lint.ts"; -import { format, formatOptions, rules, sameProgram } from "./format.ts"; +import { lint, applyFixes, render } from "../src/lint.ts"; +import type { LintRule } from "../src/lint.ts"; +import { format, formatOptions, rules } from "./format.ts"; import type { FormatOptions } from "./format.ts"; const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bend-format-")); @@ -17,27 +18,41 @@ const fixture = (text: string, name = "main.bend") => { return file; }; async function fixed(text: string, options: FormatOptions = opts) { - const result = await lint(fixture(text), rules, { config: { rules: { "format/layout": options } } }); + const result = await lint(fixture(text), rules, { + config: { rules: { "format/layout": options } }, + }); if (!result.ok) throw new Error(result.diags.map(render).join("\n")); expect(result.ok).toBe(true); expect(result.diags.every((d) => d.fixes.length === 1)).toBe(true); const source = result.sources.find((s) => s.root)!; - const output = applyFixes(source.file, result.diags).text; + const output = applyFixes(source, result.diags).text; expect(format(output, options)).toBe(output); - const again = await lint(fixture(output), rules, { config: { rules: { "format/layout": options } } }); + const again = await lint(fixture(output), rules, { + config: { rules: { "format/layout": options } }, + }); expect([again.ok, again.diags]).toEqual([true, []]); - const bodies = [result.book, again.book].map((book) => Object.fromEntries(book.order.flatMap((name) => { - const tld = book.tlds[name]; - return tld.$ === "Def" && tld.e !== undefined && !tld.b ? [[name, Bend.term_show(tld.e)]] : []; - }))); + const Bend = result.unstable.Bend; + const bodies = [result.unstable.book, again.unstable.book].map((book) => + Object.fromEntries( + book.order.flatMap((name) => { + const tld = book.tlds[name]; + return tld.$ === "Def" && tld.e !== undefined && !tld.b + ? [[name, Bend.term_show(tld.e)]] + : []; + }), + ), + ); expect(bodies[1]).toEqual(bodies[0]); return output; } describe("format/layout", () => { test("ordinary names cannot open delimiter groups", () => { - const source = "import Base\ndef constructor() -> U32: 1\ndef toString() -> U32: 2\ndef valueOf() -> U32: 3\n"; - expect(format(source, opts)).toBe("import Base\n\ndef constructor() -> U32:\n 1\n\ndef toString() -> U32:\n 2\n\ndef valueOf() -> U32:\n 3\n"); + const source = + "import Base\ndef constructor() -> U32: 1\ndef toString() -> U32: 2\ndef valueOf() -> U32: 3\n"; + expect(format(source, opts)).toBe( + "import Base\n\ndef constructor() -> U32:\n 1\n\ndef toString() -> U32:\n 2\n\ndef valueOf() -> U32:\n 3\n", + ); }); test("validates its own defaults, union and unknown options", () => { expect(formatOptions({})).toEqual(opts); @@ -57,15 +72,22 @@ describe("format/layout", () => { }); test("one sweep formats spacing, indentation, gaps, CRLF and final newline", async () => { - const output = await fixed('import Base\r\n\r\n\r\ntype N is Data:\r\n Z{} \r\n S{p:N}\r\ndef id(x:N)->N:\r\n\tx \r\ndef main()->N: id(S{Z{}})'); - expect(output).toBe("import Base\n\ntype N is Data:\n Z{}\n S{p: N}\n\ndef id(x: N) -> N:\n x\n\ndef main() -> N:\n id(S{Z{}})\n"); + const output = await fixed( + "import Base\r\n\r\n\r\ntype N is Data:\r\n Z{} \r\n S{p:N}\r\ndef id(x:N)->N:\r\n\tx \r\ndef main()->N: id(S{Z{}})", + ); + expect(output).toBe( + "import Base\n\ntype N is Data:\n Z{}\n S{p: N}\n\ndef id(x: N) -> N:\n x\n\ndef main() -> N:\n id(S{Z{}})\n", + ); }); test("CRLF applies to declarations, wrapping, comments and the final newline", async () => { - const source = "import Base\n#header\ndef f(first: U32, second: U32) -> U32: first\ndef main() -> U32: f(123456789, 234567890)"; + const source = + "import Base\n#header\ndef f(first: U32, second: U32) -> U32: first\ndef main() -> U32: f(123456789, 234567890)"; const options = { ...opts, wrapAtWidth: 20 }; const lf = await fixed(source, options); - expect(await fixed(source, { ...options, endOfLine: "crlf" })).toBe(lf.replaceAll("\n", "\r\n")); + expect(await fixed(source, { ...options, endOfLine: "crlf" })).toBe( + lf.replaceAll("\n", "\r\n"), + ); }); test("preserve uses the first ending in mixed files and falls back to LF", async () => { @@ -83,21 +105,31 @@ describe("format/layout", () => { const options = { ...opts, endOfLine }; const newline = endOfLine === "crlf" ? "\r\n" : "\n"; const output = format(source, options); - expect(output).toBe('def main() -> String:' + newline + ' "first\r\nsecond\nthird"' + newline); + expect(output).toBe( + "def main() -> String:" + newline + ' "first\r\nsecond\nthird"' + newline, + ); expect(format(output, options)).toBe(output); } }); test("wrapped lists use one item per line and relative indentation", async () => { - const source = "import Base\ndef choose(first: U32, second: U32, third: U32) -> U32:\n first\ndef main() -> U32:\n choose(123456789, 234567890, 345678901)\n"; + const source = + "import Base\ndef choose(first: U32, second: U32, third: U32) -> U32:\n first\ndef main() -> U32:\n choose(123456789, 234567890, 345678901)\n"; const output = await fixed(source, { tabWidth: 4, wrapAtWidth: 28 }); - expect(output).toContain("def choose(\n first: U32,\n second: U32,\n third: U32\n) -> U32:"); - expect(output).toContain(" choose(\n 123456789,\n 234567890,\n 345678901\n )"); + expect(output).toContain( + "def choose(\n first: U32,\n second: U32,\n third: U32\n) -> U32:", + ); + expect(output).toContain( + " choose(\n 123456789,\n 234567890,\n 345678901\n )", + ); }); test('"never" collapses optional wrapping but retains statement boundaries', async () => { - const source = "import Base\ndef f(x: U32, y: U32) -> U32:\n x\ndef main() -> U32:\n x = f(\n 1,\n 2\n )\n f(x, 3)\n"; - expect(await fixed(source, { tabWidth: 2, wrapAtWidth: "never" })).toContain(" x = f(1, 2)\n f(x, 3)"); + const source = + "import Base\ndef f(x: U32, y: U32) -> U32:\n x\ndef main() -> U32:\n x = f(\n 1,\n 2\n )\n f(x, 3)\n"; + expect(await fixed(source, { tabWidth: 2, wrapAtWidth: "never" })).toContain( + " x = f(1, 2)\n f(x, 3)", + ); }); test("preserves strings, escapes, char literals and comments", async () => { @@ -114,7 +146,8 @@ def main() -> String: }); test("keeps inline argument comments attached when wrapping", async () => { - const source = "import Base\ndef f(x: U32, y: U32) -> U32:\n x\ndef main() -> U32:\n f(123, # first\n 456 # second\n )\n"; + const source = + "import Base\ndef f(x: U32, y: U32) -> U32:\n x\ndef main() -> U32:\n f(123, # first\n 456 # second\n )\n"; const output = await fixed(source, { tabWidth: 2, wrapAtWidth: 20 }); expect(output).toContain("123, # first\n"); expect(output).toContain("456 # second\n"); @@ -135,31 +168,62 @@ def main() -> String: `; const output = await fixed(source.replaceAll("\n", "\r\n")); expect(output).toContain("import Base # import-comment"); - expect(output).toContain("# header\n##Heading\n#!directive\n#|expectation\n#\n# already spaced"); - const literal = source.split("\n").find((line) => line.includes("#inside"))!.split(" #tail")[0].trim(); + expect(output).toContain( + "# header\n##Heading\n#!directive\n#|expectation\n#\n# already spaced", + ); + const literal = source + .split("\n") + .find((line) => line.includes("#inside"))! + .split(" #tail")[0] + .trim(); expect(output).toContain(literal); expect(output).toContain(String.fromCodePoint(0x1f600)); expect(output).toContain(" # tail"); - const multiline = 'import Base\ndef main() -> String:\n "first line\n#inside literal\nlast line" #outside\n'; + const multiline = + 'import Base\ndef main() -> String:\n "first line\n#inside literal\nlast line" #outside\n'; expect(await fixed(multiline)).toContain('#inside literal\nlast line" # outside'); }); test("handles nested type arguments, successors, quantities and templates", async () => { - await fixed("import Base\ndef identity(~T: Type, x: T) -> T:\n x\ndef main() -> List>:\n identity(~List>, [[1, 2], [3]])\n", { tabWidth: 2, wrapAtWidth: 25 }); - await fixed("import Base\ndef successor(+x: Nat) -> Nat:\n 1n+x\ndef main() -> Nat:\n successor(2n)\n"); - await fixed("import Base\ntype Box<-Value: Data> is Data:\n Box{value: Value}\ndef main() -> Box:\n Box{1}\n", { tabWidth: 4, wrapAtWidth: 18 }); - await fixed("import Base\ndef main() -> List:\n List.map(~U32, ~U32, ~(x => (x + 123456789 : U32)), [1, 2, 3, 4])\n", { tabWidth: 4, wrapAtWidth: 30 }); - await fixed("import Base\ndef identity(-T: Type, x: T) -> T:\n x\ndef main() -> U32:\n identity(U32 {1 : U32})\n"); + await fixed( + "import Base\ndef identity(~T: Type, x: T) -> T:\n x\ndef main() -> List>:\n identity(~List>, [[1, 2], [3]])\n", + { tabWidth: 2, wrapAtWidth: 25 }, + ); + await fixed( + "import Base\ndef successor(+x: Nat) -> Nat:\n 1n+x\ndef main() -> Nat:\n successor(2n)\n", + ); + await fixed( + "import Base\ntype Box<-Value: Data> is Data:\n Box{value: Value}\ndef main() -> Box:\n Box{1}\n", + { tabWidth: 4, wrapAtWidth: 18 }, + ); + await fixed( + "import Base\ndef main() -> List:\n List.map(~U32, ~U32, ~(x => (x + 123456789 : U32)), [1, 2, 3, 4])\n", + { tabWidth: 4, wrapAtWidth: 30 }, + ); + await fixed( + "import Base\ndef identity(-T: Type, x: T) -> T:\n x\ndef main() -> U32:\n identity(U32 {1 : U32})\n", + ); }); test("preserves nested match, do and lambda body scopes", async () => { - await fixed("import Base\ndef f(x: Nat) -> Nat:\n match x:\n case 0n:\n 0n\n case 1n+p:\n p\ndef main() -> Nat:\n f(1n)\n", { tabWidth: 4, wrapAtWidth: 30 }); - await fixed("import Base\ndef main() -> IO(Unit):\n do IO:\n IO.print(\"a long message\")\n", { tabWidth: 4, wrapAtWidth: 18 }); - await fixed("import Base\ndef main() -> U32:\n f: U32 -> U32 = x => (\n y = x\n y\n )\n f(1)\n", { tabWidth: 4, wrapAtWidth: 20 }); + await fixed( + "import Base\ndef f(x: Nat) -> Nat:\n match x:\n case 0n:\n 0n\n case 1n+p:\n p\ndef main() -> Nat:\n f(1n)\n", + { tabWidth: 4, wrapAtWidth: 30 }, + ); + await fixed( + 'import Base\ndef main() -> IO(Unit):\n do IO:\n IO.print("a long message")\n', + { tabWidth: 4, wrapAtWidth: 18 }, + ); + await fixed( + "import Base\ndef main() -> U32:\n f: U32 -> U32 = x => (\n y = x\n y\n )\n f(1)\n", + { tabWidth: 4, wrapAtWidth: 20 }, + ); }); test("law/proof and unsafe syntax are preserved", async () => { - await fixed("import Base\nlaw same:\n for x: U32\n {x == x : U32}\ndef same(x):\n {==}\n@unsafe\ndef identity(x: U32) -> U32:\n x\ndef main() -> U32:\n identity(1)\n"); + await fixed( + "import Base\nlaw same:\n for x: U32\n {x == x : U32}\ndef same(x):\n {==}\n@unsafe\ndef identity(x: U32) -> U32:\n x\ndef main() -> U32:\n identity(1)\n", + ); }); test("imports and imported law fills survive the parser guard", async () => { @@ -168,23 +232,33 @@ def main() -> String: }); test("the guard rejects a changed value or scope", async () => { - const result = await lint(fixture("import Base\ndef main() -> U32:\n 1\n"), []); - const cx = { Bend, book: result.book, sources: result.sources, root: result.sources.find((s) => s.root)!, walk }; - expect(sameProgram(cx, "import Base\ndef main() -> U32:\n 2\n")).toBe(false); - expect(sameProgram(cx, format(cx.root.text, opts))).toBe(true); + const guard: LintRule = { + id: "test/guard", + run: (cx) => { + expect(cx.sameDeclarations("import Base\ndef main() -> U32:\n 2\n")).toBe(false); + expect(cx.sameDeclarations(format(cx.root.text, opts))).toBe(true); + return []; + }, + }; + expect((await lint(fixture("import Base\ndef main() -> U32:\n 1\n"), [guard])).ok).toBe(true); }); test("configuration passes the union through the unchanged engine", async () => { await fixed("import Base\ndef main() -> U32: 1\n", { tabWidth: 2, wrapAtWidth: "never" }); - await expect(lint(fixture("import Base\ndef main() -> U32: 1\n"), rules, - { config: { rules: { "format/layout": { wrapAtWidth: 0 } } } })).rejects.toThrow("positive integer"); + await expect( + lint(fixture("import Base\ndef main() -> U32: 1\n"), rules, { + config: { rules: { "format/layout": { wrapAtWidth: 0 } } }, + }), + ).rejects.toThrow("positive integer"); }); test("operator chains wrap at boundaries and stay stable", async () => { const source = 'import Base\ndef main() -> String:\n "aaaaaaaa" ++ "bbbbbbbb" ++ "cccccccc"\n'; const output = await fixed(source, { tabWidth: 2, wrapAtWidth: 20 }); expect(output).toContain(' "aaaaaaaa"\n ++ "bbbbbbbb"\n ++ "cccccccc"'); - expect(await fixed(source, { tabWidth: 2, wrapAtWidth: "never" })).toContain(' "aaaaaaaa" ++ "bbbbbbbb" ++ "cccccccc"'); + expect(await fixed(source, { tabWidth: 2, wrapAtWidth: "never" })).toContain( + ' "aaaaaaaa" ++ "bbbbbbbb" ++ "cccccccc"', + ); }); test("empty files, comments and indivisible tokens have a canonical ending", () => { @@ -198,7 +272,10 @@ def main() -> String: test("CLI --fix writes the same whole-file formatting in one sweep", () => { const file = fixture("import Base\ndef main()->U32: 1 \n"); - const config = fixture(JSON.stringify({ rules: { "format/layout": { tabWidth: 4, wrapAtWidth: "never" } } }), "config.json"); + const config = fixture( + JSON.stringify({ rules: { "format/layout": { tabWidth: 4, wrapAtWidth: "never" } } }), + "config.json", + ); const cli = fileURLToPath(new URL("../src/lint.ts", import.meta.url)); const rule = fileURLToPath(new URL("./format.ts", import.meta.url)); const args = [cli, file, "--rules", rule, "--config", config, "--fix", "--json"]; @@ -208,7 +285,11 @@ def main() -> String: const text = fs.readFileSync(file, "utf8"); expect(text).toBe("import Base\n\ndef main() -> U32:\n 1\n"); const again = spawnSync(process.execPath, args, { encoding: "utf8" }); - expect([again.status, again.stderr, JSON.parse(again.stdout)]).toEqual([0, "", { ok: true, findings: [] }]); + expect([again.status, again.stderr, JSON.parse(again.stdout)]).toEqual([ + 0, + "", + { ok: true, findings: [] }, + ]); expect(fs.readFileSync(file, "utf8")).toBe(text); }); }); diff --git a/tools/bend-lint/rules/format.ts b/tools/bend-lint/rules/format.ts index 0e5e670a8a..ab8b4084eb 100644 --- a/tools/bend-lint/rules/format.ts +++ b/tools/bend-lint/rules/format.ts @@ -5,23 +5,42 @@ // Config: { "rules": { "format/layout": { "tabWidth": 2, "wrapAtWidth": 100 } } } // Both numbers are positive integers; "never" disables optional wrapping. // endOfLine: "lf" (default), "crlf", or "preserve" (first ending, LF fallback). -import * as path from "node:path"; -import type { Options, RuleContext, LintRule } from "../src/lint.ts"; -import type { Book, HTerm } from "bend2/bend.ts"; +import type { Options, LintRule } from "../src/lint.ts"; -export type FormatOptions = { tabWidth: number; wrapAtWidth: number | "never"; endOfLine?: "lf" | "crlf" | "preserve" }; -type Token = { text: string; beg: number; end: number; col: number; kind: "code" | "literal" | "comment" | "newline" }; +export type FormatOptions = { + tabWidth: number; + wrapAtWidth: number | "never"; + endOfLine?: "lf" | "crlf" | "preserve"; +}; +type Token = { + text: string; + beg: number; + end: number; + col: number; + kind: "code" | "literal" | "comment" | "newline"; +}; type Node = Token | { open: Token; close: Token; children: Node[] }; -type Doc = string | { kind: "line"; flat: string; hard?: boolean; offset?: number } - | { kind: "group" | "nest"; doc: Doc; amount?: number } | Doc[]; +type Doc = + | string + | { kind: "line"; flat: string; hard?: boolean; offset?: number } + | { kind: "group" | "nest"; doc: Doc; amount?: number } + | Doc[]; type Frame = { doc: Doc; indent: number; flat: boolean }; -const DELIMITERS = new Map([["(", ")"], ["[", "]"], ["{", "}"], ["<", ">"]]); +const DELIMITERS = new Map([ + ["(", ")"], + ["[", "]"], + ["{", "}"], + ["<", ">"], +]); export function formatOptions(options: Options): FormatOptions { const { tabWidth = 2, wrapAtWidth = 100, endOfLine = "lf" } = options; - const unknown = Object.keys(options).find((k) => k !== "tabWidth" && k !== "wrapAtWidth" && k !== "endOfLine"); + const unknown = Object.keys(options).find( + (k) => k !== "tabWidth" && k !== "wrapAtWidth" && k !== "endOfLine", + ); if (unknown) throw new Error("format/layout has no option " + unknown); - const positive = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v > 0; + const positive = (v: unknown): v is number => + typeof v === "number" && Number.isSafeInteger(v) && v > 0; if (!positive(tabWidth)) throw new Error("format/layout: tabWidth must be a positive integer"); if (wrapAtWidth !== "never" && !positive(wrapAtWidth)) { throw new Error('format/layout: wrapAtWidth must be "never" or a positive integer'); @@ -36,27 +55,41 @@ export function formatOptions(options: Options): FormatOptions { // inside a string is not syntax; successor notation and quantities are atoms. function tokens(source: string): Token[] { const out: Token[] = []; - let at = 0, start = 0; + let at = 0, + start = 0; while (at < source.length) { - const beg = at, c = source[at]; - if (c === " " || c === "\t" || c === "\r") { at++; continue; } + const beg = at, + c = source[at]; + if (c === " " || c === "\t" || c === "\r") { + at++; + continue; + } let kind: Token["kind"] = "code"; - if (c === "\n") { at++; kind = "newline"; } - else if (c === "#") { + if (c === "\n") { + at++; + kind = "newline"; + } else if (c === "#") { while (at < source.length && source[at] !== "\n" && source[at] !== "\r") at++; kind = "comment"; } else if (c === '"' || c === "'") { at++; while (at < source.length) { - if (source[at] === "\\") { at += 2; continue; } + if (source[at] === "\\") { + at += 2; + continue; + } if (source[at++] === c) break; } kind = "literal"; } else { const word = /^[A-Za-z_][\w]*(?:\.[A-Za-z_]\w*)*/.exec(source.slice(at)); - const number = /^\d+(?:n\+?|\.\d+(?:[eE][+-]?\d+)?)/.exec(source.slice(at)) - ?? /^\d+/.exec(source.slice(at)); - const symbol = /^(?:<&>|\.&\.|\.\|\.|\.\^\.|->|=>|<-|==|!=|<=|>=|<<|&&|\|\||\+\+|<>|&[012])/.exec(source.slice(at)); + const number = + /^\d+(?:n\+?|\.\d+(?:[eE][+-]?\d+)?)/.exec(source.slice(at)) ?? + /^\d+/.exec(source.slice(at)); + const symbol = + /^(?:<&>|\.&\.|\.\|\.|\.\^\.|->|=>|<-|==|!=|<=|>=|<<|&&|\|\||\+\+|<>|&[012])/.exec( + source.slice(at), + ); if (source.startsWith("<-", at) && /[\w.]/.test(source[at - 1] ?? "")) at++; else if (source.startsWith(">>", at) && /\s/.test(source[at - 1] ?? "")) at += 2; else at += (word?.[0] ?? number?.[0] ?? symbol?.[0] ?? c).length; @@ -73,9 +106,12 @@ function tokens(source: string): Token[] { function tree(ts: Token[]): Node[] { const root: Node[] = []; - const stack: Array<{ children: Node[]; group?: Extract }> = [{ children: root }]; + const stack: Array<{ children: Node[]; group?: Extract }> = [ + { children: root }, + ]; for (let i = 0; i < ts.length; i++) { - const t = ts[i], prev = ts[i - 1]; + const t = ts[i], + prev = ts[i - 1]; const close = DELIMITERS.get(t.text); // Bend distinguishes type arguments from comparisons by a glued '<'. const angle = t.text === "<" && prev?.end === t.beg && /^[\w.]+$/.test(prev.text); @@ -93,21 +129,48 @@ function tree(ts: Token[]): Node[] { return root; } -const first = (n: Node): Token => "open" in n ? n.open : n; -const last = (n: Node): Token => "open" in n ? n.close : n; +const first = (n: Node): Token => ("open" in n ? n.open : n); +const last = (n: Node): Token => ("open" in n ? n.close : n); const soft = (flat = " "): Doc => ({ kind: "line", flat }); const hard = (offset = 0): Doc => ({ kind: "line", flat: "", hard: true, offset }); const nest = (doc: Doc, amount: number): Doc => ({ kind: "nest", doc, amount }); const group = (doc: Doc): Doc => ({ kind: "group", doc }); -const OPERATORS = new Set(["+", "-", "*", "/", "%", "^", "++", "&&", "||", "<&>", - ".&.", ".|.", ".^.", "<<", ">>", "<>", "<", ">", "<=", ">=", "==", "!=", "&", "|"]); +const OPERATORS = new Set([ + "+", + "-", + "*", + "/", + "%", + "^", + "++", + "&&", + "||", + "<&>", + ".&.", + ".|.", + ".^.", + "<<", + ">>", + "<>", + "<", + ">", + "<=", + ">=", + "==", + "!=", + "&", + "|", +]); function gluedPrefix(a: Token, b: Node | undefined): boolean { return ["+", "-", "%", "&"].includes(a.text) && b !== undefined && a.end === first(b).beg; } function separator(a: Node, b: Node): string { - const x = last(a), y = first(b), l = x.text, r = y.text; + const x = last(a), + y = first(b), + l = x.text, + r = y.text; if (y.kind === "comment") return " "; if (r === "," || r === ";" || r === ":" || r === "?" || r === "!" || r === ".") return ""; if (l === "," || l === ";" || l === ":") return " "; @@ -118,22 +181,32 @@ function separator(a: Node, b: Node): string { // instead be the next (annotated) argument in a comma-optional call. if ("open" in b && r === "{" && x.end !== y.beg) return " "; if ("open" in b && ["(", "[", "{", "<"].includes(r)) { - if ((/^[\w.]+$/.test(l) || l === "!" || [")", "]", "}"].includes(l)) - && !["return", "case", "match", "for", "exs", "is"].includes(l)) return ""; + if ( + (/^[\w.]+$/.test(l) || l === "!" || [")", "]", "}"].includes(l)) && + !["return", "case", "match", "for", "exs", "is"].includes(l) + ) + return ""; } // Module paths are printed separately; other atoms need a separator. return " "; } -function sequence(nodes: Node[], source: string, opts: FormatOptions, base: number, raw = false): Doc { +function sequence( + nodes: Node[], + source: string, + opts: FormatOptions, + base: number, + raw = false, +): Doc { const out: Doc[] = []; const levels = [base]; let continuation: Doc[] | undefined; let prev: Node | undefined; for (let i = 0; i < nodes.length; i++) { - const n = nodes[i], t = first(n); + const n = nodes[i], + t = first(n); if (!("open" in n) && t.kind === "newline") { - if (raw || prev && last(prev).kind === "comment") { + if (raw || (prev && last(prev).kind === "comment")) { let next = i + 1; while (next < nodes.length && first(nodes[next]).kind === "newline") next++; if (next < nodes.length) { @@ -150,7 +223,10 @@ function sequence(nodes: Node[], source: string, opts: FormatOptions, base: numb if (prev) { const prefix = gluedPrefix(t, nodes[i + 1]); if (!raw && !("open" in n) && OPERATORS.has(t.text) && !prefix) { - if (!continuation) { continuation = []; out.push(nest(continuation, opts.tabWidth)); } + if (!continuation) { + continuation = []; + out.push(nest(continuation, opts.tabWidth)); + } continuation.push(soft()); } else (continuation ?? out).push(separator(prev, n)); } @@ -161,27 +237,34 @@ function sequence(nodes: Node[], source: string, opts: FormatOptions, base: numb } function nodeDoc(n: Node, source: string, opts: FormatOptions, base: number): Doc { - if (!("open" in n)) return n.kind === "newline" ? "" : n.kind === "comment" ? commentText(n.text) : n.text; + if (!("open" in n)) + return n.kind === "newline" ? "" : n.kind === "comment" ? commentText(n.text) : n.text; const children = n.children; if (!children.length) return n.open.text + n.close.text; // Embedded statement bodies have significant line/column boundaries. // They retain those boundaries, while nested ordinary calls still wrap. - const block = children.some((c) => !("open" in c) && ["match", "case", "do", "=", ";", "%", "\\"].includes(c.text)); + const block = children.some( + (c) => !("open" in c) && ["match", "case", "do", "=", ";", "%", "\\"].includes(c.text), + ); if (block) return [n.open.text, sequence(children, source, opts, base, true), n.close.text]; const parts: Node[][] = [[]]; for (let i = 0; i < children.length; i++) { const c = children[i]; if (!("open" in c) && c.text === ",") { const next = children[i + 1]; - if (next && first(next).kind === "comment" && !source.slice(c.end, first(next).beg).includes("\n")) { + if ( + next && + first(next).kind === "comment" && + !source.slice(c.end, first(next).beg).includes("\n") + ) { parts[parts.length - 1].push(c, next); i++; } parts.push([]); - } - else parts[parts.length - 1].push(c); + } else parts[parts.length - 1].push(c); } - while (parts.length > 1 && parts[parts.length - 1].every((c) => first(c).kind === "newline")) parts.pop(); + while (parts.length > 1 && parts[parts.length - 1].every((c) => first(c).kind === "newline")) + parts.pop(); const content: Doc[] = []; for (let i = 0; i < parts.length; i++) { if (i) { @@ -195,7 +278,12 @@ function nodeDoc(n: Node, source: string, opts: FormatOptions, base: number): Do // A generic closing '>' must stay glued to its preceding term: a spaced // '>' is parsed as a comparison by Bend, even across a newline. const closing: Doc = n.close.text === ">" ? "" : comments ? hard() : soft(""); - return group([n.open.text, nest([comments ? hard() : soft(""), content], opts.tabWidth), closing, n.close.text]); + return group([ + n.open.text, + nest([comments ? hard() : soft(""), content], opts.tabWidth), + closing, + n.close.text, + ]); } function commentText(text: string): string { @@ -208,10 +296,12 @@ function commentText(text: string): string { // under a function name. The width is a target, not a license to split atoms. function print(doc: Doc, width: number, indent: number, newline: string): string { const stack: Frame[] = [{ doc, indent, flat: false }]; - let out = " ".repeat(indent), col = indent; + let out = " ".repeat(indent), + col = indent; const fits = (remaining: number, pending: Frame[]): boolean => { while (remaining >= 0 && pending.length) { - const f = pending.pop()!, d = f.doc; + const f = pending.pop()!, + d = f.doc; if (typeof d === "string") { if (d.includes("\n")) return true; remaining -= d.length; @@ -224,71 +314,112 @@ function print(doc: Doc, width: number, indent: number, newline: string): string return remaining >= 0; }; while (stack.length) { - const f = stack.pop()!, d = f.doc; + const f = stack.pop()!, + d = f.doc; if (typeof d === "string") { out += d; col = d.includes("\n") ? d.length - d.lastIndexOf("\n") - 1 : col + d.length; } else if (Array.isArray(d)) stack.push(...d.map((doc) => ({ ...f, doc })).reverse()); else if (d.kind === "line") { - if (!d.hard && (f.flat || width === Infinity)) { out += d.flat; col += d.flat.length; } - else { + if (!d.hard && (f.flat || width === Infinity)) { + out += d.flat; + col += d.flat.length; + } else { col = f.indent + (d.offset ?? 0); out = out.trimEnd() + newline + " ".repeat(col); } - } else if (d.kind === "nest") stack.push({ doc: d.doc, indent: f.indent + d.amount!, flat: f.flat }); - else stack.push({ doc: d.doc, indent: f.indent, - flat: f.flat || width === Infinity || fits(width - col, [...stack, { doc: d.doc, indent: f.indent, flat: true }]) }); + } else if (d.kind === "nest") + stack.push({ doc: d.doc, indent: f.indent + d.amount!, flat: f.flat }); + else + stack.push({ + doc: d.doc, + indent: f.indent, + flat: + f.flat || + width === Infinity || + fits(width - col, [...stack, { doc: d.doc, indent: f.indent, flat: true }]), + }); } return out.trimEnd(); } export function format(source: string, opts: FormatOptions): string { - const newline = opts.endOfLine === "preserve" ? /\r?\n/.exec(source)?.[0] ?? "\n" - : opts.endOfLine === "crlf" ? "\r\n" : "\n"; + const newline = + opts.endOfLine === "preserve" + ? (/\r?\n/.exec(source)?.[0] ?? "\n") + : opts.endOfLine === "crlf" + ? "\r\n" + : "\n"; const nodes = tree(tokens(source)); const records: Node[][] = [[]]; for (let i = 0; i < nodes.length; i++) { const n = nodes[i]; if (!("open" in n) && n.kind === "newline") { - const next = nodes[i + 1], following = nodes[i + 2]; + const next = nodes[i + 1], + following = nodes[i + 2]; const prefix = next && gluedPrefix(first(next), following); const previous = records[records.length - 1].at(-1); - if (next && previous && last(previous).kind !== "comment" && first(next).kind !== "comment" - && (!prefix && !("open" in next) && OPERATORS.has(next.text) - || ["->", "=", "++", "&&", "||"].includes(last(previous).text))) continue; + if ( + next && + previous && + last(previous).kind !== "comment" && + first(next).kind !== "comment" && + ((!prefix && !("open" in next) && OPERATORS.has(next.text)) || + ["->", "=", "++", "&&", "||"].includes(last(previous).text)) + ) + continue; records.push([]); - } - else records[records.length - 1].push(n); + } else records[records.length - 1].push(n); } - const rows: string[] = [], levels = [0]; - let blank = false, previous = "", previousComment = false; + const rows: string[] = [], + levels = [0]; + let blank = false, + previous = "", + previousComment = false; for (const record of records) { - if (!record.length) { blank = true; continue; } - const head = first(record[0]), col = head.col; + if (!record.length) { + blank = true; + continue; + } + const head = first(record[0]), + col = head.col; while (levels.length > 1 && col < levels[levels.length - 1]) levels.pop(); if (col > levels[levels.length - 1]) levels.push(col); const indent = (levels.length - 1) * opts.tabWidth; const declaration = /^(def|type|law)$/.test(head.text) || head.text === "@"; const comment = head.kind === "comment"; - if (rows.length && col === 0 && ((declaration && !previousComment && previous !== "@unsafe") || comment && blank) - && rows[rows.length - 1] !== "") rows.push(""); + if ( + rows.length && + col === 0 && + ((declaration && !previousComment && previous !== "@unsafe") || (comment && blank)) && + rows[rows.length - 1] !== "" + ) + rows.push(""); else if (rows.length && blank && col > 0 && rows[rows.length - 1] !== "") rows.push(""); let doc: Doc; if (head.text === "import") { // Paths and aliases use their own grammar; do not treat / or - as operators. const text = source.slice(head.beg, last(record[record.length - 1]).end).trimEnd(); const at = text.indexOf("#"); - doc = (at < 0 ? text : text.slice(0, at)).trim().replace(/^import\s+/, "import ").replace(/\s+as\s+/, " as "); + doc = (at < 0 ? text : text.slice(0, at)) + .trim() + .replace(/^import\s+/, "import ") + .replace(/\s+as\s+/, " as "); if (at >= 0) doc = [doc, " ", commentText(text.slice(at))]; } else { // Canonicalize an inline declaration body into the indented body form. - const colon = col === 0 && declaration ? record.findIndex((n) => !("open" in n) && n.text === ":") : -1; + const colon = + col === 0 && declaration ? record.findIndex((n) => !("open" in n) && n.text === ":") : -1; if (colon >= 0 && colon < record.length - 1 && first(record[colon + 1]).kind !== "comment") { - doc = [sequence(record.slice(0, colon + 1), source, opts, col), - nest([hard(), sequence(record.slice(colon + 1), source, opts, col)], opts.tabWidth)]; + doc = [ + sequence(record.slice(0, colon + 1), source, opts, col), + nest([hard(), sequence(record.slice(colon + 1), source, opts, col)], opts.tabWidth), + ]; } else doc = sequence(record, source, opts, col); } - rows.push(print(doc, opts.wrapAtWidth === "never" ? Infinity : opts.wrapAtWidth, indent, newline)); + rows.push( + print(doc, opts.wrapAtWidth === "never" ? Infinity : opts.wrapAtWidth, indent, newline), + ); previous = record.map((n) => first(n).text).join(""); previousComment = comment; blank = false; @@ -296,81 +427,29 @@ export function format(source: string, opts: FormatOptions): string { return rows.join(newline).trimEnd() + newline; } -// Reparse both versions with the same imported declarations. No typecheck, -// disk writes or import fetches. Compare parsed terms, not checked terms: -// checking unfolds definitions and would hide changes in source meaning. -export function sameProgram(cx: Pick, text: string): boolean { - const B = cx.Bend, root = cx.root; - const body = (s: string) => s.split("\n").map((line) => /^import\s/.test(line) ? "" : line).join("\n"); - const original = body(root.text); - let aliases: Record = {}; - if (/^import\s+\S+\s+as\s+/m.test(root.text)) { - search: for (const tld of Object.values(cx.book.tlds)) { - for (const term of [tld.T, ...(tld.$ === "Def" && tld.v ? [tld.v] : [])]) { - for (const tm of cx.walk(B.term_lower(term))) { - if (tm.s?.file.str === original) { aliases = tm.s.file.al; break search; } - } - } - } - } - // A proof consisting solely of {==} has no body span. Resolve local - // aliases from the already loaded sources in that case. - for (const m of root.text.matchAll(/^import\s+(\S+)\s+as\s+(\w+)/gm)) { - if (aliases[m[2]] !== undefined) continue; - const importedPath = path.resolve(path.dirname(root.path), m[1]).replaceAll("\\", "/"); - const imported = cx.sources.find((s) => s.path === importedPath); - if (imported) aliases = { ...aliases, [m[2]]: imported.ns }; - } - const qualify = (name: string) => { - const dot = name.indexOf("."); - return dot >= 0 && aliases[name.slice(0, dot)] !== undefined - ? aliases[name.slice(0, dot)] + ":" + name.slice(dot + 1) - : (root.ns ? root.ns + ":" : "") + name; - }; - const own = new Set([...root.text.matchAll(/^(?:@unsafe\s+)?(?:def|type|law)\s+([\w.]+)/gm)].map((m) => qualify(m[1]))); - const seed = (): Book => { - const book = B.book_nil(); - book.tlds = { ...cx.book.tlds }; - book.ctrs = { ...cx.book.ctrs }; - for (const k of own) { - const tld = book.tlds[k]; - // A proof of an imported law fills its declaration rather than creating one. - if (k.includes(":") && !k.startsWith(root.ns + ":") && tld?.$ === "Def") { - book.tlds[k] = { ...tld, v: null, i: undefined, u: false }; - } else { - if (tld?.$ === "ADT") for (const c of tld.c) delete book.ctrs[c.k]; - delete book.tlds[k]; - } - } - return book; - }; - const snapshot = (s: string) => { - const book = seed(); - const dir = root.path.slice(0, root.path.lastIndexOf("/") + 1); - B.parse_book(book, dir, body(s), root.ns, aliases); - const lower = (t: HTerm | null) => t === null ? null : B.term_lower(t); - return JSON.stringify(book.order.map((k) => { - const t = book.tlds[k]; - return t.$ === "ADT" ? [k, t.n, t.g, lower(t.T), t.c.map((c) => [c.k, c.n, lower(c.T)])] - : [k, t.n, t.x, t.u ?? false, t.i, lower(t.T), lower(t.v)]; - }), (k, v) => k === "s" ? undefined : v); - }; - try { return snapshot(root.text) === snapshot(text); } - catch { return false; } -} - -export const rules: LintRule[] = [{ - id: "format/layout", - run(cx) { - const opts = formatOptions(cx.options); - const text = format(cx.root.text, opts); - if (text === cx.root.text) return []; - const spn = { file: cx.root.file, beg: 0, end: cx.root.text.length }; - if (!sameProgram(cx, text)) return [cx.diag({ - message: "Cannot safely format this syntax: the result parses differently. No fix was offered.", spn, - })]; - return [cx.diag({ message: "Apply canonical formatting.", spn, - fixes: [{ title: "Format file", applicability: "safe", edits: [{ spn, text }] }], - })]; +export const rules: LintRule[] = [ + { + id: "format/layout", + run(cx) { + const opts = formatOptions(cx.options); + const text = format(cx.root.text, opts); + if (text === cx.root.text) return []; + const span = { file: cx.root, beg: 0, end: cx.root.text.length }; + if (!cx.sameDeclarations(text)) + return [ + cx.diag({ + message: + "Cannot safely format this syntax: the result parses differently. No fix was offered.", + span, + }), + ]; + return [ + cx.diag({ + message: "Apply canonical formatting.", + span, + fixes: [{ title: "Format file", applicability: "safe", edits: [{ span, text }] }], + }), + ]; + }, }, -}]; +]; diff --git a/tools/bend-lint/src/effects.js b/tools/bend-lint/src/effects.js index 8300179c43..9cbb045d4b 100644 --- a/tools/bend-lint/src/effects.js +++ b/tools/bend-lint/src/effects.js @@ -2,14 +2,25 @@ // globalThis.BEND_LINT before it runs a rule, and removes it after. Bool, // String and U32 are native JS values here; other data is {$: CID(Name), ...}. -const LINT_SEVERITY = { [CID(Error)]: "error", [CID(Warning)]: "warning", [CID(Information)]: "information", [CID(Hint)]: "hint" }; -const LINT_QUANTITY = { None: CID(Erased), Lone: CID(Once), Many: CID(Many) }; -const LINT_APPLICABILITY = { [CID(Safe)]: "safe", [CID(Suggested)]: "suggested", [CID(Dangerous)]: "dangerous" }; +const LINT_SEVERITY = { + [CID(Error)]: "error", + [CID(Warning)]: "warning", + [CID(Information)]: "information", + [CID(Hint)]: "hint", +}; +const LINT_QUANTITY = { erased: CID(Erased), once: CID(Once), many: CID(Many) }; +const LINT_APPLICABILITY = { + [CID(Safe)]: "safe", + [CID(Suggested)]: "suggested", + [CID(Dangerous)]: "dangerous", +}; function lint_host() { const host = globalThis.BEND_LINT; if (host === undefined) { - throw new Error("this program is a rule; run it with bun tools/bend-lint/src/lint.ts --rules "); + throw new Error( + "this program is a rule; run it with bun tools/bend-lint/src/lint.ts --rules ", + ); } return host; } @@ -39,8 +50,11 @@ function lint_spot(s) { } function lint_value(v) { - return typeof v === "number" ? { $: CID(Num), value: v } - : typeof v === "boolean" ? { $: CID(Flag), value: v } : { $: CID(Text), value: v }; + return typeof v === "number" + ? { $: CID(Num), value: v } + : typeof v === "boolean" + ? { $: CID(Flag), value: v } + : { $: CID(Text), value: v }; } function lint_term(id) { @@ -51,30 +65,42 @@ function lint_input() { const { sources, options } = lint_host().input(); return { $: CID(Input), - sources: lint_list(sources.map((s) => ({ $: CID(Source), path: s.path, text: s.text, root: s.root }))), - options: lint_list(Object.entries(options).map(([key, v]) => ({ $: CID(Option), key, value: lint_value(v) }))), + sources: lint_list( + sources.map((s) => ({ $: CID(Source), path: s.path, text: s.text, root: s.root })), + ), + options: lint_list( + Object.entries(options).map(([key, v]) => ({ $: CID(Option), key, value: lint_value(v) })), + ), }; } function lint_report(diags) { - lint_host().report(lint_unlist(diags).map((d) => ({ - severity: LINT_SEVERITY[d.severity.$], - message: d.message, - span: d.span.$ === CID(Some) ? lint_spot(d.span.value) : undefined, - fixes: lint_unlist(d.fixes).map((f) => ({ - title: f.title, - applicability: LINT_APPLICABILITY[f.applicability.$], - edits: lint_unlist(f.edits).map((e) => ({ span: lint_spot(e.span), text: e.text })), + lint_host().report( + lint_unlist(diags).map((d) => ({ + severity: LINT_SEVERITY[d.severity.$], + message: d.message, + span: d.span.$ === CID(Some) ? lint_spot(d.span.value) : undefined, + fixes: lint_unlist(d.fixes).map((f) => ({ + title: f.title, + applicability: LINT_APPLICABILITY[f.applicability.$], + edits: lint_unlist(f.edits).map((e) => ({ span: lint_spot(e.span), text: e.text })), + })), })), - }))); + ); return { $: CID(Unit) }; } function lint_view(fact) { const v = lint_host().view(fact.id); return { - $: CID(View), owner: v.owner, inst: v.inst, kind: v.kind, name: v.name, quantity: { $: LINT_QUANTITY[v.quantity] }, - span: lint_maybe(v.span && lint_span(v.span)), inner: lint_maybe(v.inner && lint_span(v.inner)), + $: CID(View), + owner: v.owner, + inst: v.inst, + kind: v.kind, + name: v.name, + quantity: { $: LINT_QUANTITY[v.quantity] }, + span: lint_maybe(v.span && lint_span(v.span)), + inner: lint_maybe(v.inner && lint_span(v.inner)), }; } @@ -93,6 +119,11 @@ io_eff(CID(binder), (fact) => { io_eff(CID(same), (fact, a, b) => lint_host().same(fact.id, a.id, b.id)); io_eff(CID(show), (fact, t) => lint_host().show(fact.id, t.id)); io_eff(CID(normal), (fact, t) => lint_term(lint_host().normal(fact.id, t.id))); -io_eff(CID(uses), (fact) => lint_list(lint_host().uses(fact.id).map((u) => - ({ $: CID(Use), name: u.name, quantity: { $: LINT_QUANTITY[u.quantity] } })))); +io_eff(CID(uses), (fact) => + lint_list( + lint_host() + .uses(fact.id) + .map((u) => ({ $: CID(Use), name: u.name, quantity: { $: LINT_QUANTITY[u.quantity] } })), + ), +); io_eff(CID(text), (span) => lint_host().text(lint_spot(span))); diff --git a/tools/bend-lint/src/lint.test.ts b/tools/bend-lint/src/lint.test.ts index 68478d2b93..8a4250d8f4 100644 --- a/tools/bend-lint/src/lint.test.ts +++ b/tools/bend-lint/src/lint.test.ts @@ -2,22 +2,42 @@ import { afterAll, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; - -import type { Book, LTerm, Span } from "bend2/bend.ts"; -import { BEND2, Bend, Comp, applyFixes, bendRule, findConfig, lint, mapper, readConfig, render, walk } from "./lint.ts"; -import type { Diag, Fact, LintRule, RuleContext, Source, SourceFile } from "./lint.ts"; -import { DriftError, bendDir, fetchBend, installedTag, latestTag, patch, relative, resolve, seeCheck, seeInfer } from "./patch.ts"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import type { Book, LTerm, Span as BendSpan } from "bend2/bend.ts"; +import type * as BendModule from "bend2/bend.ts"; +import { BEND2, applyFixes, bendRule, findConfig, lint, readConfig, render } from "./lint.ts"; +import type { Diag, Fact, LintRule, RuleContext, Source } from "./lint.ts"; +import { + DriftError, + bendDir, + fetchBend, + guardMain, + installedTag, + latestTag, + mapper, + patch, + relative, + resolve, + seeCheck, + seeInfer, + walk, +} from "./seam.ts"; +import type { Raw } from "./seam.ts"; // Types // ===== -type Loose = Fact & { tm: { x?: { $: string; k?: string; i?: number } } }; +type Loose = Raw & { tm: { x?: { $: string; k?: string; i?: number } } }; type Seen = { kind: string; def: string; name: string; path: string }; // Constants // ========= +// bend2's modules as bend-lint patched them, for what the tests inspect. +const Bend: typeof BendModule = await import(pathToFileURL(path.join(BEND2, "bend.ts")).href); +const Comp: Record = await import(pathToFileURL(path.join(BEND2, "comp.ts")).href); + const USERLAND = String.raw`type N is Data: Z{} S{p: N} @@ -84,9 +104,18 @@ def main() -> N: // Tests in the bend checkout (bend2/../tests) whose first expected line // says if the check passes. const DRIFT = [ - "check/alpha_equivalence", "check/assert_plain_fill", "check/dependent_telescope", "check/beta_ann_body", - "check/ctor_arity", "check/forward_reference", "check/hole_todo", "check/typed_let_mismatch", - "import/base_prelude", "import/string_literal", "import/alias_shadow", "import/duplicate_name", + "check/alpha_equivalence", + "check/assert_plain_fill", + "check/dependent_telescope", + "check/beta_ann_body", + "check/ctor_arity", + "check/forward_reference", + "check/hole_todo", + "check/typed_let_mismatch", + "import/base_prelude", + "import/string_literal", + "import/alias_shadow", + "import/duplicate_name", ]; // Inside the repo, so a Bend rule here can import ../../lint.bend; @@ -274,13 +303,16 @@ def main() -> IO(Unit): const commaSpace: LintRule = { id: "style/comma-space", - run: (cx) => [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { - const spn = { file: cx.root.file, beg: m.index! + 1, end: m.index! + 1 }; - return cx.diag({ - message: "Add a space after the comma.", severity: "warning", spn, - fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }], - }); - }), + run: (cx) => + [...cx.root.text.matchAll(/,(?=\w)/g)].map((m) => { + const span = { file: cx.root, beg: m.index! + 1, end: m.index! + 1 }; + return cx.diag({ + message: "Add a space after the comma.", + severity: "warning", + span, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ span, text: " " }] }], + }); + }), }; const identity: LintRule = { @@ -289,19 +321,31 @@ const identity: LintRule = { run: async (cx, signal) => { await Promise.resolve(); signal.throwIfAborted(); - const body = facts(cx, "id").find((f) => f.dep === 1 && f.tm.x?.$ === "Var")!; - const ann = cx.Bend.pmap_get(body.ctx, body.tm.x!.i!)!; + const body = facts(cx, "id").find( + (f) => loose(cx, f).dep === 1 && loose(cx, f).tm.x?.$ === "Var", + )!; + const raw = loose(cx, body); + const ann = cx.unstable.Bend.pmap_get(raw.ctx, raw.tm.x!.i!)!; expect(ann.k).toBe("x"); - expect((cx.Bend.term_wnf(body.bok, body.ty) as { k?: string }).k).toBe("N"); - expect(cx.same(body, body.ty, ann.T)).toBe(true); - return [cx.diag({ message: "This function returns its parameter.", severity: "information", spn: body.spn, fact: body })]; + expect((cx.unstable.Bend.term_wnf(raw.bok, raw.ty) as { k?: string }).k).toBe("N"); + expect(cx.same(body, cx.type(body), cx.binder(body)!)).toBe(true); + return [ + cx.diag({ + message: "This function returns its parameter.", + severity: "information", + span: cx.view(body).span, + fact: body, + }), + ]; }, }; const neverRun: LintRule = { id: "test/never-run", facts: true, - run: () => { throw new Error("this rule must not run"); }, + run: () => { + throw new Error("this rule must not run"); + }, }; // x : T = v, where v is a variable that already has type T. Constructors @@ -310,25 +354,57 @@ const neverRun: LintRule = { const redundantAnnotation: LintRule = { id: "erasure/redundant-local-annotation", facts: true, - run: (cx) => [...cx.facts!.values()].flatMap((fact): Diag[] => { - const term = cx.Bend.term_strip(fact.tm); - const value = cx.span(term.s); - const v = cx.binder(fact, term); - if (fact.inst || term.$ !== "Var" || fact.spn === undefined || value === undefined || v === null || !cx.same(fact, v.T, fact.ty)) { - return []; - } - const prefix = fact.spn.file.str.slice(fact.spn.beg, value.beg); - const name = prefix.match(/^([A-Za-z_][A-Za-z_0-9]*)\s*:/); - if (fact.spn.file !== value.file || fact.spn.beg >= value.beg || prefix.includes("#") || name === null || !prefix.trimEnd().endsWith("=")) { - return []; - } - const spn = { file: fact.spn.file, beg: fact.spn.beg + name[1].length, end: fact.spn.beg + prefix.lastIndexOf("=") }; - return [cx.diag({ - message: "Remove the redundant annotation: " + v.k + " already has type " + cx.show(fact, v.T) + ".", - severity: "hint", spn, fact, - fixes: [{ title: "Remove redundant local type annotation", applicability: "suggested", edits: [{ spn, text: " " }] }], - })]; - }), + run: (cx) => + cx.facts!.flatMap((fact): Diag[] => { + const { inst, kind, name: used, span, inner } = cx.view(fact); + const declared = cx.binder(fact); + if ( + inst || + kind !== "Var" || + span === undefined || + inner === undefined || + declared === undefined || + !cx.same(fact, declared, cx.type(fact)) + ) { + return []; + } + const prefix = span.file.text.slice(span.beg, inner.beg); + const name = prefix.match(/^([A-Za-z_][A-Za-z_0-9]*)\s*:/); + if ( + span.file !== inner.file || + span.beg >= inner.beg || + prefix.includes("#") || + name === null || + !prefix.trimEnd().endsWith("=") + ) { + return []; + } + const at = { + file: span.file, + beg: span.beg + name[1].length, + end: span.beg + prefix.lastIndexOf("="), + }; + return [ + cx.diag({ + message: + "Remove the redundant annotation: " + + used + + " already has type " + + cx.show(fact, declared) + + ".", + severity: "hint", + span: at, + fact, + fixes: [ + { + title: "Remove redundant local type annotation", + applicability: "suggested", + edits: [{ span: at, text: " " }], + }, + ], + }), + ]; + }), }; // A Bend rule that reports its options, read with defaults. @@ -386,8 +462,8 @@ const echo: LintRule = { // Functions // ========= -function source2(p: string, ns: string): Source { - return { path: p, ns, text: "x\n", root: false, base: false, file: { str: "x\n", ns, al: {}, path: p } }; +function file2(p: string, ns: string): { str: string; ns: string; al: {}; path: string } { + return { str: "x\n", ns, al: {}, path: p }; } function fixture(name: string, text: string): string { @@ -396,17 +472,29 @@ function fixture(name: string, text: string): string { return file; } -function facts(cx: Pick, name: string): Loose[] { - const tld = cx.book.tlds[name]; - return tld.$ !== "Def" || tld.e === undefined ? [] - : [...cx.walk(tld.e)].map((tm) => cx.facts!.get(tm)).filter((f): f is Loose => f !== undefined); +// The facts of a def's checked body, in walk order. +function facts(cx: RuleContext, name: string): Fact[] { + const tld = cx.unstable.book.tlds[name]; + const byTerm = new Map((cx.facts ?? []).map((f) => [cx.unstable.raw(f).tm, f])); + return tld.$ !== "Def" || tld.e === undefined + ? [] + : [...walk(tld.e)].flatMap((tm) => byTerm.get(tm) ?? []); +} + +// A fact as bend2 gives it. +function loose(cx: RuleContext, fact: Fact): Loose { + return cx.unstable.raw(fact) as Loose; } function bodies(book: Book): Record { - return Object.fromEntries(book.order.flatMap((name) => { - const tld = book.tlds[name]; - return tld.$ === "Def" && tld.e !== undefined && !tld.b ? [[name, Bend.term_show(tld.e)]] : []; - })); + return Object.fromEntries( + book.order.flatMap((name) => { + const tld = book.tlds[name]; + return tld.$ === "Def" && tld.e !== undefined && !tld.b + ? [[name, Bend.term_show(tld.e)]] + : []; + }), + ); } function root(sources: Source[]): Source { @@ -415,12 +503,21 @@ function root(sources: Source[]): Source { // The facts a rule with this filter gets: kind, def, name and file. async function seen(file: string, want: LintRule["facts"]): Promise { - let got: Fact[] = []; - await lint(file, [{ id: "test/seen", facts: want, run: (cx) => (got = [...cx.facts!.values()], []) }]); - return got.map((f) => { - const t = Bend.term_strip(f.tm); - return { kind: t.$, def: f.def, name: t.$ === "Var" || t.$ === "Ref" ? t.k : "", path: (f.spn!.file as SourceFile).path }; - }); + let got: Seen[] = []; + await lint(file, [ + { + id: "test/seen", + facts: want, + run: (cx) => { + got = cx.facts!.map((f) => { + const v = cx.view(f); + return { kind: v.kind, def: v.owner, name: v.name, path: v.span!.file.path }; + }); + return []; + }, + }, + ]); + return got; } // A GitHub that lists `tags` and serves this checkout's bend2 at any @@ -429,10 +526,20 @@ function github(tags: string[]): { get: (url: string) => Promise; aske const asked: string[] = []; const get = async (url: string): Promise => { asked.push(url); - const file = url.match(/^https:\/\/raw\.githubusercontent\.com\/bendlang\/bend\/v[\d.]+\/bend2\/(.+)$/)?.[1]; + const file = url.match( + /^https:\/\/raw\.githubusercontent\.com\/bendlang\/bend\/v[\d.]+\/bend2\/(.+)$/, + )?.[1]; return url.includes("/info/refs") - ? new Response(["0000 HEAD\0caps", ...tags.flatMap((t) => ["0000 refs/tags/" + t, "0000 refs/tags/" + t + "^{}"]), "0000 refs/heads/main"].join("\n") + "\n") - : file !== undefined && fs.existsSync(path.join(BEND2, file)) ? new Response(fs.readFileSync(path.join(BEND2, file), "utf8")) : new Response("", { status: 404 }); + ? new Response( + [ + "0000 HEAD\0caps", + ...tags.flatMap((t) => ["0000 refs/tags/" + t, "0000 refs/tags/" + t + "^{}"]), + "0000 refs/heads/main", + ].join("\n") + "\n", + ) + : file !== undefined && fs.existsSync(path.join(BEND2, file)) + ? new Response(fs.readFileSync(path.join(BEND2, file), "utf8")) + : new Response("", { status: 404 }); }; return { get, asked }; } @@ -462,8 +569,8 @@ describe("patch", () => { expect(out).toContain("export const term_infer = seeInfer(unseen_term_infer);"); expect(out).toContain("export const term_check = seeCheck(unseen_term_check);"); expect(out).not.toMatch(/^export function term_(infer|check)\(/m); - expect(out).toMatch(/^import \{ fs \} from "file:.*patch\.ts";/m); - expect(out).toMatch(/^import \{ path \} from "file:.*patch\.ts";/m); + expect(out).toMatch(/^import \{ fs \} from "file:.*seam\.ts";/m); + expect(out).toMatch(/^import \{ path \} from "file:.*seam\.ts";/m); }); test("comp.ts: exports RUNTIME_MAIN and js_sat", () => { @@ -473,16 +580,45 @@ describe("patch", () => { test("main.ts: exports how bend reads a book and words a failure, with the same fs and path", () => { const out = patch("main.ts", main); expect(out).toContain("export { book_read, book_err, Check_Fail };"); - expect(out).toMatch(/^import \{ fs \} from "file:.*patch\.ts";/m); - expect(out).toMatch(/^import \{ path \} from "file:.*patch\.ts";/m); - expect(() => patch("main.ts", main.replace("async function book_read(", "async function book_load_file("))).toThrow(/main\.ts: found 0 of a declaration of book_read/); + expect(out).toMatch(/^import \{ fs \} from "file:.*seam\.ts";/m); + expect(out).toMatch(/^import \{ path \} from "file:.*seam\.ts";/m); + expect(() => + patch("main.ts", main.replace("async function book_read(", "async function book_load_file(")), + ).toThrow(/main\.ts: found 0 of a declaration of book_read/); }); test("fails loudly when an edit or a needed name is missing or repeated", () => { - expect(() => patch("bend.ts", src.replace("export function term_infer(", "export function term_infer2("))).toThrow(DriftError); - expect(() => patch("bend.ts", src.replace('import * as fs from "node:fs";', 'import fs from "node:fs";'))).toThrow(/node:fs/); - expect(() => patch("bend.ts", src + "\nexport function term_check(\n")).toThrow(/found 2 of "export function term_check\("/); - expect(() => patch("comp.ts", comp.replace("function js_sat(", "function js_name("))).toThrow(/comp\.ts: found 0 of a declaration of js_sat/); + expect(() => + patch("bend.ts", src.replace("export function term_infer(", "export function term_infer2(")), + ).toThrow(DriftError); + expect(() => + patch("bend.ts", src.replace('import * as fs from "node:fs";', 'import fs from "node:fs";')), + ).toThrow(/node:fs/); + expect(() => patch("bend.ts", src + "\nexport function term_check(\n")).toThrow( + /found 2 of "export function term_check\("/, + ); + expect(() => patch("comp.ts", comp.replace("function js_sat(", "function js_name("))).toThrow( + /comp\.ts: found 0 of a declaration of js_sat/, + ); + }); + + test("main.ts must give book_read, book_err and Check_Fail as bend-lint calls them", () => { + const Check_Fail = function (this: { why: unknown }, why: unknown) { + this.why = why; + } as unknown as new (why: unknown) => { why: unknown }; + const good = { + book_read: (_file: string, _base?: unknown) => Promise.reject(), + book_err: (_e: unknown) => "", + Check_Fail, + }; + expect(() => guardMain(good as never)).not.toThrow(); + expect(() => + guardMain({ ...good, book_read: (_file: string) => Promise.reject() } as never), + ).toThrow(/main\.ts no longer has book_read\(file, base, seen = \.\.\.\)/); + expect(() => guardMain({ ...good, Check_Fail: function () {} } as never)).toThrow( + /main\.ts no longer has new Check_Fail\(why\)\.why/, + ); + expect(() => guardMain({ ...good, Check_Fail: function () {} } as never)).toThrow(DriftError); }); test("the loaded bend.ts and comp.ts are the patched ones", () => { @@ -500,17 +636,35 @@ describe("patch", () => { test("the wrappers pass every argument through, and need the expected arity", () => { const got: unknown[][] = []; - const fake = ((...args: unknown[]) => (got.push(args), { tm: null, us: null })) as unknown as typeof Bend.term_check; + const fake = ((...args: unknown[]) => ( + got.push(args), + { tm: null, us: null } + )) as unknown as typeof Bend.term_check; Object.defineProperty(fake, "length", { value: 7 }); - (seeCheck(fake) as unknown as (...a: unknown[]) => unknown)({}, { def: "d" }, {}, 1, 2, 3, 4, "extra"); + (seeCheck(fake) as unknown as (...a: unknown[]) => unknown)( + {}, + { def: "d" }, + {}, + 1, + 2, + 3, + 4, + "extra", + ); expect(got).toEqual([[{}, { def: "d" }, {}, 1, 2, 3, 4, "extra"]]); Object.defineProperty(fake, "length", { value: 5 }); - expect(() => seeInfer(fake as unknown as typeof Bend.term_infer)).toThrow(/term_infer takes 5 parameters, not 6/); + expect(() => seeInfer(fake as unknown as typeof Bend.term_infer)).toThrow( + /term_infer takes 5 parameters, not 6/, + ); }); test("bend2 is found from a checkout, its bend2 folder, or the repo; a wrong dir fails", async () => { const repo = path.dirname(BEND2); - expect([await bendDir(repo), await bendDir(BEND2), await bendDir(undefined)]).toEqual([BEND2, BEND2, BEND2]); + expect([await bendDir(repo), await bendDir(BEND2), await bendDir(undefined)]).toEqual([ + BEND2, + BEND2, + BEND2, + ]); await expect(bendDir(DIR)).rejects.toThrow(/no bend2 at/); }); @@ -529,10 +683,17 @@ describe("downloading bend", () => { const offline = () => Promise.reject(new Error("offline")); test("the installed bend's version comes first; else the newest release, asked once a day", async () => { - expect([installedTag(() => "bend 2.0.36\n"), installedTag(() => undefined), installedTag(() => "nope")]).toEqual(["v2.0.36", undefined, undefined]); + expect([ + installedTag(() => "bend 2.0.36\n"), + installedTag(() => undefined), + installedTag(() => "nope"), + ]).toEqual(["v2.0.36", undefined, undefined]); const gh = github(["v2.0.9", "v2.0.36", "v2.0.10", "nightly"]); const at = cache(); - expect([await latestTag(at, gh.get), await latestTag(at, gh.get)]).toEqual(["v2.0.36", "v2.0.36"]); + expect([await latestTag(at, gh.get), await latestTag(at, gh.get)]).toEqual([ + "v2.0.36", + "v2.0.36", + ]); expect(gh.asked.length).toBe(1); fs.writeFileSync(path.join(at, "latest.json"), "{ damaged"); expect(await latestTag(at, gh.get)).toBe("v2.0.36"); @@ -543,7 +704,9 @@ describe("downloading bend", () => { const at = cache(); ["v2.0.3", "v2.0.12", "latest-ish"].forEach((d) => fs.mkdirSync(path.join(at, d))); expect(await latestTag(at, offline)).toBe("v2.0.12"); - await expect(latestTag(cache(), offline)).rejects.toThrow(/cannot list bend's releases \(offline\)/); + await expect(latestTag(cache(), offline)).rejects.toThrow( + /cannot list bend's releases \(offline\)/, + ); }); test("a release downloads once and whole, and bend-lint runs on it", async () => { @@ -551,8 +714,12 @@ describe("downloading bend", () => { const at = cache(); const dir = await fetchBend("v2.0.36", at, gh.get); const effs = fs.readdirSync(path.join(BEND2, "effs")); - expect(fs.readFileSync(path.join(dir, "bend.ts"), "utf8")).toBe(fs.readFileSync(path.join(BEND2, "bend.ts"), "utf8")); - expect(fs.readFileSync(path.join(dir, "main.ts"), "utf8")).toBe(fs.readFileSync(path.join(BEND2, "main.ts"), "utf8")); + expect(fs.readFileSync(path.join(dir, "bend.ts"), "utf8")).toBe( + fs.readFileSync(path.join(BEND2, "bend.ts"), "utf8"), + ); + expect(fs.readFileSync(path.join(dir, "main.ts"), "utf8")).toBe( + fs.readFileSync(path.join(BEND2, "main.ts"), "utf8"), + ); expect(fs.readdirSync(path.join(dir, "effs")).sort()).toEqual(effs.sort()); expect(gh.asked.length).toBe(5 + effs.length); expect(await fetchBend("v2.0.36", at, gh.get)).toBe(dir); @@ -562,7 +729,10 @@ describe("downloading bend", () => { expect(gh.asked.length).toBe(2 * (5 + effs.length)); expect(fs.existsSync(path.join(dir, "main.ts"))).toBe(true); expect(fs.readdirSync(path.join(at, "v2.0.36"))).toEqual(["bend2"]); - const out = spawnSync(process.execPath, [CLI, fixture("downloaded.bend", USERLAND)], { encoding: "utf8", env: { ...process.env, BEND_DIR: dir } }); + const out = spawnSync(process.execPath, [CLI, fixture("downloaded.bend", USERLAND)], { + encoding: "utf8", + env: { ...process.env, BEND_DIR: dir }, + }); expect([out.status, out.stderr]).toEqual([0, ""]); await expect(fetchBend("../x", at, gh.get)).rejects.toThrow(/not a bend release/); }); @@ -574,10 +744,23 @@ describe("downloading bend", () => { const gh = github(["v2.0.35"]); const at = cache(); const away = { get: gh.get, cache: at, repo: DIR }; - expect(await bendDir(undefined, { ...away, run: () => "bend 2.0.36\n" })).toBe(fs.realpathSync(path.join(at, "v2.0.36", "bend2")).replaceAll("\\", "/")); - expect(await bendDir(undefined, { ...away, run: () => undefined })).toBe(fs.realpathSync(path.join(at, "v2.0.35", "bend2")).replaceAll("\\", "/")); - await expect(bendDir(undefined, { get: offline, cache: at, repo: DIR, run: () => "bend 2.0.40\n" })).rejects.toThrow(/could not download bend v2\.0\.40 \(offline\)/); - expect(await bendDir(undefined, { get: offline, cache: at, repo: BEND2, run: () => "bend 2.0.40\n" })).toBe(BEND2); + expect(await bendDir(undefined, { ...away, run: () => "bend 2.0.36\n" })).toBe( + fs.realpathSync(path.join(at, "v2.0.36", "bend2")).replaceAll("\\", "/"), + ); + expect(await bendDir(undefined, { ...away, run: () => undefined })).toBe( + fs.realpathSync(path.join(at, "v2.0.35", "bend2")).replaceAll("\\", "/"), + ); + await expect( + bendDir(undefined, { get: offline, cache: at, repo: DIR, run: () => "bend 2.0.40\n" }), + ).rejects.toThrow(/could not download bend v2\.0\.40 \(offline\)/); + expect( + await bendDir(undefined, { + get: offline, + cache: at, + repo: BEND2, + run: () => "bend 2.0.40\n", + }), + ).toBe(BEND2); } finally { if (saved !== undefined) { process.env.BEND_DIR = saved; @@ -587,23 +770,31 @@ describe("downloading bend", () => { }); describe("spans", () => { - const source = (p: string, text: string): Source => - ({ path: p, ns: "", text, root: true, base: false, file: { str: text, ns: "", al: {}, path: p } }); + const source = (p: string, text: string) => ({ + str: text, + ns: "", + al: {} as Record, + path: p, + }); for (const eol of ["\n", "\r\n"]) { const text = ["import Base", "import ./a.bend as A", "", "def f() -> N:", " x", ""].join(eol); const lines = text.split("\n"); for (const [kind, masked] of [ - ["empty", lines.map((l) => l.startsWith("import") ? "" : l).join("\n")], - ["spaces", lines.map((l) => l.startsWith("import") ? " ".repeat(l.length) : l).join("\n")], + ["empty", lines.map((l) => (l.startsWith("import") ? "" : l)).join("\n")], + ["spaces", lines.map((l) => (l.startsWith("import") ? " ".repeat(l.length) : l)).join("\n")], ]) { test("map past " + kind + " import lines (" + JSON.stringify(eol) + ")", () => { const src = source("/p/m.bend", text); const beg = masked.indexOf("x"); - const got = mapper([src])({ file: { str: masked, ns: "", dir: "/p/", al: { A: "a" } }, beg, end: beg + 1 } as Span); - expect(got.file).toBe(src.file); + const got = mapper([src])({ + file: { str: masked, ns: "", dir: "/p/", al: { A: "a" } }, + beg, + end: beg + 1, + } as BendSpan)!; + expect(got.file).toBe(src); expect(text.slice(got.beg, got.end)).toBe("x"); - expect(src.file.al.A).toBe("a"); + expect(src.al.A).toBe("a"); }); } } @@ -611,17 +802,23 @@ describe("spans", () => { test("a span on disk passes through; a copy that does not match fails", () => { const src = source("/p/m.bend", "import Base\ndef f() -> N:\n x\n"); const map = mapper([src]); - const spn = { file: src.file, beg: 2, end: 3 }; + const spn = { file: src, beg: 2, end: 3 }; expect(map(spn)).toBe(spn); - expect(() => map({ file: { str: "\ndef g() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 })).toThrow(DriftError); - expect(() => map({ file: { str: "def f() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 })).toThrow(DriftError); + expect(() => + map({ file: { str: "\ndef g() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 }), + ).toThrow(DriftError); + expect(() => + map({ file: { str: "def f() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 }), + ).toThrow(DriftError); }); test("the directory picks between equal files", () => { const a = source("/a/m.bend", "import Base\nx\n"); const b = source("/b/m.bend", "import Base\nx\n"); const map = mapper([a, b]); - expect(map({ file: { str: "\nx\n", ns: "", dir: "/b/", al: {} }, beg: 1, end: 2 } as Span).file).toBe(b.file); + expect( + map({ file: { str: "\nx\n", ns: "", dir: "/b/", al: {} }, beg: 1, end: 2 } as BendSpan)!.file, + ).toBe(b); }); }); @@ -632,14 +829,20 @@ describe("lint", () => { const res = await lint(userland, [commaSpace, identity]); expect(res.ok).toBe(true); expect(res.diags.map((d) => d.code)).toEqual([commaSpace.id, identity.id]); - expect(res.diags[0].fixes[0].edits[0].spn.file.str).toBe(USERLAND); + expect(res.diags[0].fixes[0].edits[0].span.file.text).toBe(USERLAND); expect(render(res.diags[0])).toStartWith("Warning [style/comma-space]:"); expect(render(res.diags[0])).toContain("generic(~N, a)"); expect(render(res.diags[1])).toContain("Context:"); }); test("only rules with facts get facts", async () => { - const look: LintRule = { id: "test/look", run: (cx) => { expect(cx.facts).toBeUndefined(); return []; } }; + const look: LintRule = { + id: "test/look", + run: (cx) => { + expect(cx.facts).toBeUndefined(); + return []; + }, + }; expect((await lint(userland, [look, identity])).ok).toBe(true); expect((await lint(userland, [commaSpace])).facts).toBeUndefined(); }); @@ -649,22 +852,28 @@ describe("lint", () => { id: "test/probe", facts: true, run: (cx) => { - const generic = facts(cx, "generic").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "x")!; - expect(generic.bok.tlds["generic~T"]).toBeDefined(); - expect(generic.inst).toBe(false); - const inst = facts(cx, "generic~0").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "x")!; - expect(inst.inst).toBe(true); - expect(inst.spn).toEqual(generic.spn); - expect(["None", "Lone", "Many"]).toContain(generic.qt.$); - expect(generic.us.$).toBeDefined(); - const proof = facts(cx, "proof").find((f) => f.tm.x?.$ === "Rfl")!; - expect(proof.ty.$).toBe("Eql"); - expect(proof.dep).toBe(1); - const peel = [...cx.walk((cx.book.tlds.peel as { e: LTerm }).e)]; + const named = (def: string, kind: string, name = "") => + facts(cx, def).find((f) => { + const x = loose(cx, f).tm.x; + return x?.$ === kind && (name === "" || x.k === name); + })!; + const generic = named("generic", "Var", "x"); + expect(loose(cx, generic).bok.tlds["generic~T"]).toBeDefined(); + expect(cx.view(generic).inst).toBe(false); + const inst = named("generic~0", "Var", "x"); + expect(cx.view(inst).inst).toBe(true); + expect(cx.view(inst).span).toEqual(cx.view(generic).span); + expect(["erased", "once", "many"]).toContain(cx.view(generic).quantity); + expect(loose(cx, generic).us.$).toBeDefined(); + const proof = named("proof", "Rfl"); + expect(loose(cx, proof).ty.$).toBe("Eql"); + expect(loose(cx, proof).dep).toBe(1); + const kept = new Set(cx.facts!.map((f) => loose(cx, f).tm)); + const peel = [...walk((cx.unstable.book.tlds.peel as { e: LTerm }).e)]; expect(peel.some((tm) => tm.$ === "Mat")).toBe(true); - expect(peel.some((tm) => tm.$ === "Ann" && tm.x.$ === "Efq" && !cx.facts!.has(tm))).toBe(true); - const field = facts(cx, "peel").find((f) => f.tm.x?.$ === "Var" && f.tm.x.k === "p")!; - expect(cx.Bend.pmap_get(field.ctx, field.tm.x!.i!)!.k).toBe("p"); + expect(peel.some((tm) => tm.$ === "Ann" && tm.x.$ === "Efq" && !kept.has(tm))).toBe(true); + const field = loose(cx, named("peel", "Var", "p")); + expect(cx.unstable.Bend.pmap_get(field.ctx, field.tm.x!.i!)!.k).toBe("p"); return []; }, }; @@ -673,16 +882,23 @@ describe("lint", () => { test("walk reaches every kind in checked bodies; an unknown kind fails", async () => { const res = await lint(userland, []); - const kinds = new Set(res.book.order.flatMap((name) => { - const tld = res.book.tlds[name]; - return tld.$ === "Def" && tld.e !== undefined ? [...walk(tld.e)].map((tm) => tm.$) : []; - })); - for (const k of ["Mat", "Lam", "App", "Var", "Ctr"]) expect(kinds.has(k as LTerm["$"])).toBe(true); + const { book } = res.unstable; + const kinds = new Set( + book.order.flatMap((name) => { + const tld = book.tlds[name]; + return tld.$ === "Def" && tld.e !== undefined ? [...walk(tld.e)].map((tm) => tm.$) : []; + }), + ); + for (const k of ["Mat", "Lam", "App", "Var", "Ctr"]) + expect(kinds.has(k as LTerm["$"])).toBe(true); expect(() => walk({ $: "Nope" } as unknown as LTerm).next()).toThrow(DriftError); }); test("walk preserves constructor and type argument order across traversals", () => { - const children: LTerm[] = [{ $: "Ref", k: "first" }, { $: "Ref", k: "second" }]; + const children: LTerm[] = [ + { $: "Ref", k: "first" }, + { $: "Ref", k: "second" }, + ]; for (const $ of ["Ctr", "ADT"] as const) { const term = { $, k: "Pair", x: children } as LTerm; const original = [...children]; @@ -694,24 +910,48 @@ describe("lint", () => { }); test("a failed check is one bend/check error, and no rule runs", async () => { - const parse = await lint(fixture("parse.bend", "type N is Data:\n Z{}\ndef broken(\n"), [neverRun]); + const parse = await lint(fixture("parse.bend", "type N is Data:\n Z{}\ndef broken(\n"), [ + neverRun, + ]); expect(parse.ok).toBe(false); expect(parse.diags.map((d) => d.code)).toEqual(["bend/check"]); expect(render(parse.diags[0])).toStartWith("Error [bend/check]:"); - expect((await lint(fixture("name.bend", "type N is Data:\n Z{}\ndef broken() -> N:\n missing\n"), [neverRun])).ok).toBe(false); - const todo = await lint(fixture("todo.bend", "type N is Data:\n Z{}\ndef broken() -> N:\n ?TODO\n"), [neverRun]); + expect( + ( + await lint( + fixture("name.bend", "type N is Data:\n Z{}\ndef broken() -> N:\n missing\n"), + [neverRun], + ) + ).ok, + ).toBe(false); + const todo = await lint( + fixture("todo.bend", "type N is Data:\n Z{}\ndef broken() -> N:\n ?TODO\n"), + [neverRun], + ); expect(todo.diags[0].message).toStartWith("1 TODO found"); // The message is bend's, its location aside: expected and observed, with the names in scope. const types = "type N is Data:\n Z{}\n\ntype M is Data:\n W{}\n\n"; - expect((await lint(fixture("mismatch.bend", types + "def bad(x: N) -> M:\n x\n"), [neverRun])).diags[0].message).toBe("expected: M\nobserved: N"); - expect((await lint(fixture("bound.bend", types + "def bad(A: Type, x: N) -> A:\n x\n"), [neverRun])).diags[0].message).toBe("expected: A\nobserved: N"); + expect( + (await lint(fixture("mismatch.bend", types + "def bad(x: N) -> M:\n x\n"), [neverRun])) + .diags[0].message, + ).toBe("expected: M\nobserved: N"); + expect( + (await lint(fixture("bound.bend", types + "def bad(A: Type, x: N) -> A:\n x\n"), [neverRun])) + .diags[0].message, + ).toBe("expected: A\nobserved: N"); expect((await lint(path.join(DIR, "missing.bend"), [neverRun])).ok).toBe(false); }); test("an error finding stops later rules; the code is always the rule's id", async () => { - const stamp: LintRule = { id: "test/stamp", run: (cx) => [{ ...cx.diag({ message: "stamped", severity: "hint" }), code: "other/code" }] }; + const stamp: LintRule = { + id: "test/stamp", + run: (cx) => [{ ...cx.diag({ message: "stamped", severity: "hint" }), code: "other/code" }], + }; expect((await lint(userland, [stamp])).diags[0].code).toBe(stamp.id); - const stop: LintRule = { id: "test/error", run: (cx) => [cx.diag({ message: "failed", severity: "error" })] }; + const stop: LintRule = { + id: "test/error", + run: (cx) => [cx.diag({ message: "failed", severity: "error" })], + }; const res = await lint(userland, [stop, neverRun]); expect(res.ok).toBe(false); expect(res.diags.map((d) => d.code)).toEqual([stop.id]); @@ -719,16 +959,54 @@ describe("lint", () => { test("a bad rule, a rule that throws, and an abort reach the caller", async () => { await expect(lint(userland, [{ id: "bad", run: () => [] }])).rejects.toThrow(/invalid rule/); - await expect(lint(userland, [undefined as unknown as LintRule])).rejects.toThrow(/invalid rule at 0/); - const broken: LintRule = { id: "test/broken-fix", run: (cx) => { - const spn = { file: cx.root.file, beg: 0, end: 4 }; - return [cx.diag({ message: "b", fixes: [{ title: "two", applicability: "safe", edits: [{ spn, text: "a" }, { spn, text: "b" }] }] })]; - } }; - await expect(lint(userland, [broken])).rejects.toThrow(/fix "two" has an edit out of bounds, or two that clash/); - await expect(lint(userland, [{ id: "test/boom", run: async () => { throw new Error("rule failed"); } }])).rejects.toThrow("rule failed"); + await expect(lint(userland, [undefined as unknown as LintRule])).rejects.toThrow( + /invalid rule at 0/, + ); + const broken: LintRule = { + id: "test/broken-fix", + run: (cx) => { + const span = { file: cx.root, beg: 0, end: 4 }; + return [ + cx.diag({ + message: "b", + fixes: [ + { + title: "two", + applicability: "safe", + edits: [ + { span, text: "a" }, + { span, text: "b" }, + ], + }, + ], + }), + ]; + }, + }; + await expect(lint(userland, [broken])).rejects.toThrow( + /fix "two" has an edit out of bounds, or two that clash/, + ); + await expect( + lint(userland, [ + { + id: "test/boom", + run: async () => { + throw new Error("rule failed"); + }, + }, + ]), + ).rejects.toThrow("rule failed"); const controller = new AbortController(); - const abort: LintRule = { id: "test/abort", run: async () => { controller.abort(); return []; } }; - await expect(lint(userland, [abort, neverRun], { signal: controller.signal })).rejects.toThrow(); + const abort: LintRule = { + id: "test/abort", + run: async () => { + controller.abort(); + return []; + }, + }; + await expect( + lint(userland, [abort, neverRun], { signal: controller.signal }), + ).rejects.toThrow(); await expect(lint(userland, [], { signal: controller.signal })).rejects.toThrow(); }); @@ -736,31 +1014,44 @@ describe("lint", () => { const saved = "import Base\ndef main() -> U32:\n 1\n"; const edited = "import Base\ndef main() -> U32:\n 2\n"; const file = fixture("unsaved.bend", saved); - const seen: LintRule = { id: "test/seen", run: (cx) => [cx.diag({ message: cx.root.text, severity: "hint" })] }; - const text = async (unsaved?: Map): Promise => (await lint(file, [seen], { unsaved })).diags[0].message; + const seen: LintRule = { + id: "test/seen", + run: (cx) => [cx.diag({ message: cx.root.text, severity: "hint" })], + }; + const text = async (unsaved?: Map): Promise => + (await lint(file, [seen], { unsaved })).diags[0].message; expect(await text(new Map([[file, edited]]))).toBe(edited); expect(await text()).toBe(saved); - const broken = await lint(file, [seen], { unsaved: new Map([[file, "import Base\ndef main() -> U32:\n nope\n"]]) }); + const broken = await lint(file, [seen], { + unsaved: new Map([[file, "import Base\ndef main() -> U32:\n nope\n"]]), + }); expect([broken.ok, broken.diags[0].code]).toEqual([false, "bend/check"]); // Runs at the same time wait for each other's check, and each sees its own text. const other = "import Base\ndef main() -> U32:\n 3\n"; - expect(await Promise.all([text(new Map([[file, edited]])), text(), text(new Map([[file, other]]))])).toEqual([edited, saved, other]); + expect( + await Promise.all([text(new Map([[file, edited]])), text(), text(new Map([[file, other]]))]), + ).toEqual([edited, saved, other]); expect(await text()).toBe(saved); }); test("spans after import lines point at the right text on disk", async () => { fixture("dep.bend", "type N is Data:\n Z{}\ndef id(x: N) -> N:\n x\n"); - const main = fixture("main.bend", "import ./dep.bend as D\n\ndef main() -> D.N:\n D.id(D.Z{})\n"); + const main = fixture( + "main.bend", + "import ./dep.bend as D\n\ndef main() -> D.N:\n D.id(D.Z{})\n", + ); const look: LintRule = { id: "test/imports", facts: true, run: (cx) => { expect(cx.root.path).toEndWith("/main.bend"); - expect(cx.sources.find((s) => s.path.endsWith("/dep.bend"))!.ns).toBe("dep"); - const texts = [...cx.facts!.values()].filter((f) => f.def === "main" && f.spn !== undefined).map((f) => { - expect(f.spn!.file).toBe(cx.root.file); - return cx.root.text.slice(f.spn!.beg, f.spn!.end); - }); + const texts = cx + .facts!.map((f) => cx.view(f)) + .filter((v) => v.owner === "main" && v.span !== undefined) + .map(({ span }) => { + expect(span!.file).toBe(cx.root); + return cx.root.text.slice(span!.beg, span!.end); + }); expect(texts.some((t) => t.startsWith("D.id") || t.startsWith("D.Z"))).toBe(true); return []; }, @@ -771,11 +1062,31 @@ describe("lint", () => { }); test("fix offsets must be integers within the source file", async () => { - for (const [beg, end] of [[NaN, 0], [0, NaN], [Infinity, Infinity], [0.5, 1], [0, 0.5], [-1, 0], [2, 1], [0, 1000000]]) { - const rule: LintRule = { id: "test/bad-offset", run: (cx) => [cx.diag({ - message: "bad offset", fixes: [{ title: "bad offset", applicability: "safe", - edits: [{ spn: { file: cx.root.file, beg, end }, text: "X" }] }], - })] }; + for (const [beg, end] of [ + [NaN, 0], + [0, NaN], + [Infinity, Infinity], + [0.5, 1], + [0, 0.5], + [-1, 0], + [2, 1], + [0, 1000000], + ]) { + const rule: LintRule = { + id: "test/bad-offset", + run: (cx) => [ + cx.diag({ + message: "bad offset", + fixes: [ + { + title: "bad offset", + applicability: "safe", + edits: [{ span: { file: cx.root, beg, end }, text: "X" }], + }, + ], + }), + ], + }; await expect(lint(userland, [rule])).rejects.toThrow("out of bounds"); } expect(fs.readFileSync(userland, "utf8")).toBe(USERLAND); @@ -789,16 +1100,19 @@ describe("lint", () => { run: (cx) => { expect(cx.root.path).toEndWith("/with_base.bend"); expect(cx.sources.some((s) => s.base)).toBe(true); - expect([...cx.facts!.values()].some((f) => f.def === "main")).toBe(true); - expect([...cx.facts!.values()].every((f) => cx.book.tlds[f.def]?.b !== true)).toBe(true); + const owners = cx.facts!.map((f) => cx.view(f).owner); + expect(owners.some((def) => def === "main")).toBe(true); + expect(owners.every((def) => cx.unstable.book.tlds[def]?.b !== true)).toBe(true); return []; }, }; const first = await lint(withBase, [probe]); const second = await lint(withBase, [probe]); expect([first.ok, second.ok]).toEqual([true, true]); - expect(second.book.order).toEqual(first.book.order); - expect((await lint(fixture("without_base.bend", "def main() -> Nat:\n 1n\n"), [])).ok).toBe(false); + expect(second.unstable.book.order).toEqual(first.unstable.book.order); + expect((await lint(fixture("without_base.bend", "def main() -> Nat:\n 1n\n"), [])).ok).toBe( + false, + ); }); test("lints in flight keep their own rules and facts", async () => { @@ -806,30 +1120,52 @@ describe("lint", () => { const entered = Promise.withResolvers(); const paused: LintRule = { id: "test/paused", - run: async (cx) => { expect(cx.facts).toBeUndefined(); entered.resolve(); await gate.promise; return []; }, + run: async (cx) => { + expect(cx.facts).toBeUndefined(); + entered.resolve(); + await gate.promise; + return []; + }, }; const first = lint(userland, [paused]); await entered.promise; const second = await lint(userland, [identity]).finally(() => gate.resolve()); - expect(second.facts!.size).toBeGreaterThan(0); + expect(second.facts!.length).toBeGreaterThan(0); expect(second.diags.map((d) => d.code)).toEqual([identity.id]); expect((await first).diags).toEqual([]); }); test("a rule may await I/O, and later rules see earlier findings", async () => { - const server = Bun.serve({ port: 0, fetch: async (req) => new Response("advice for " + (await req.json()).type) }); + const server = Bun.serve({ + port: 0, + fetch: async (req) => new Response("advice for " + (await req.json()).type), + }); const remote: LintRule = { id: "test/api", facts: true, run: async (cx, signal) => { - const fact = cx.facts!.get((cx.book.tlds.id as { e: LTerm }).e)!; - const type = cx.Bend.term_wnf(fact.bok, fact.ty) as { A: typeof fact.ty }; - const arg = cx.Bend.term_wnf(fact.bok, type.A) as { k?: string }; - const res = await fetch(server.url, { method: "POST", body: JSON.stringify({ type: arg.k }), signal }); + const body = (cx.unstable.book.tlds.id as { e: LTerm }).e; + const fact = loose( + cx, + cx.facts!.find((f) => loose(cx, f).tm === body)!, + ); + const type = cx.unstable.Bend.term_wnf(fact.bok, fact.ty) as { A: typeof fact.ty }; + const arg = cx.unstable.Bend.term_wnf(fact.bok, type.A) as { k?: string }; + const res = await fetch(server.url, { + method: "POST", + body: JSON.stringify({ type: arg.k }), + signal, + }); return [cx.diag({ message: await res.text(), severity: "hint" })]; }, }; - const next: LintRule = { id: "test/after-api", run: (cx) => { expect(cx.prior[0].message).toBe("advice for N"); return []; } }; + const next: LintRule = { + id: "test/after-api", + run: (cx) => { + expect(cx.prior[0].message).toBe("advice for N"); + return []; + }, + }; const res = await lint(userland, [remote, next]).finally(() => server.stop(true)); expect(res.ok).toBe(true); }); @@ -842,30 +1178,46 @@ describe("options", () => { test("a rule gets its defaults, with the config's values", async () => { expect(await messages({})).toEqual([["hint", '{"tabWidth":2,"breakLines":false}']]); - expect(await messages({ rules: { "test/echo": { tabWidth: 4 } } })).toEqual([["hint", '{"tabWidth":4,"breakLines":false}']]); + expect(await messages({ rules: { "test/echo": { tabWidth: 4 } } })).toEqual([ + ["hint", '{"tabWidth":4,"breakLines":false}'], + ]); }); test("the config turns a rule off, or sets its severity", async () => { expect(await messages({ rules: { "test/echo": "off" } })).toEqual([]); - expect(await messages({ rules: { "test/echo": { severity: "warning", breakLines: true } } })) - .toEqual([["warning", '{"tabWidth":2,"breakLines":true}']]); + expect( + await messages({ rules: { "test/echo": { severity: "warning", breakLines: true } } }), + ).toEqual([["warning", '{"tabWidth":2,"breakLines":true}']]); }); test("a wrong config fails loudly", async () => { - await expect(messages({ rules: { "test/echo": { tabSize: 4 } } })).rejects.toThrow(/test\/echo has no option tabSize/); - await expect(messages({ rules: { "test/echo": { tabWidth: "4" } } })).rejects.toThrow(/tabWidth must be a number/); - await expect(messages({ rules: { "test/echo": { severity: "loud" } } })).rejects.toThrow(/severity must be one of/); - await expect(messages({ rules: { "test/echo": 4 } })).rejects.toThrow(/must be "off" or an object/); + await expect(messages({ rules: { "test/echo": { tabSize: 4 } } })).rejects.toThrow( + /test\/echo has no option tabSize/, + ); + await expect(messages({ rules: { "test/echo": { tabWidth: "4" } } })).rejects.toThrow( + /tabWidth must be a number/, + ); + await expect(messages({ rules: { "test/echo": { severity: "loud" } } })).rejects.toThrow( + /severity must be one of/, + ); + await expect(messages({ rules: { "test/echo": 4 } })).rejects.toThrow( + /must be "off" or an object/, + ); }); test("bend-lint.json is found in the file's folder or above it", async () => { const top = path.join(DIR, "project"); fs.mkdirSync(path.join(top, "deep", "er"), { recursive: true }); - fs.writeFileSync(path.join(top, "bend-lint.json"), JSON.stringify({ rules: { "test/echo": { tabWidth: 8 } } })); + fs.writeFileSync( + path.join(top, "bend-lint.json"), + JSON.stringify({ rules: { "test/echo": { tabWidth: 8 } } }), + ); const file = path.join(top, "deep", "er", "file.bend"); fs.writeFileSync(file, USERLAND); expect(findConfig(file)).toEqual({ rules: { "test/echo": { tabWidth: 8 } } }); - expect(await messages(undefined, file)).toEqual([["hint", '{"tabWidth":8,"breakLines":false}']]); + expect(await messages(undefined, file)).toEqual([ + ["hint", '{"tabWidth":8,"breakLines":false}'], + ]); expect(await messages({}, file)).toEqual([["hint", '{"tabWidth":2,"breakLines":false}']]); }); @@ -874,20 +1226,34 @@ describe("options", () => { const top = path.join(DIR, "config_" + extension); fs.mkdirSync(path.join(top, "nested"), { recursive: true }); fs.writeFileSync(path.join(top, "width.ts"), "export const width: number = 8;"); - fs.writeFileSync(path.join(top, "bend-lint." + extension), - 'import { width } from "./width.ts"; export const config = { rules: { "test/echo": { tabWidth: width, severity: "warning" } } };'); + fs.writeFileSync( + path.join(top, "bend-lint." + extension), + 'import { width } from "./width.ts"; export const config = { rules: { "test/echo": { tabWidth: width, severity: "warning" } } };', + ); const file = path.join(top, "nested", "file.bend"); fs.writeFileSync(file, USERLAND); - expect(findConfig(file)).toEqual({ rules: { "test/echo": { tabWidth: 8, severity: "warning" } } }); - expect(await messages(undefined, file)).toEqual([["warning", '{"tabWidth":8,"breakLines":false}']]); + expect(findConfig(file)).toEqual({ + rules: { "test/echo": { tabWidth: 8, severity: "warning" } }, + }); + expect(await messages(undefined, file)).toEqual([ + ["warning", '{"tabWidth":8,"breakLines":false}'], + ]); expect(await messages({}, file)).toEqual([["hint", '{"tabWidth":2,"breakLines":false}']]); }); } test("nearest config wins; JSON precedes JS, then TS in the same directory", () => { const configs = [ - ["bend-lint.ts", 'export const config = { rules: { "test/echo": { tabWidth: 6 } } };', { tabWidth: 6 }], - ["bend-lint.js", 'export const config = { rules: { "test/echo": { tabWidth: 8 } } };', { tabWidth: 8 }], + [ + "bend-lint.ts", + 'export const config = { rules: { "test/echo": { tabWidth: 6 } } };', + { tabWidth: 6 }, + ], + [ + "bend-lint.js", + 'export const config = { rules: { "test/echo": { tabWidth: 8 } } };', + { tabWidth: 8 }, + ], ["bend-lint.json", '{"rules":{"test/echo":"off"}}', "off"], ] as const; for (let count = 1; count <= configs.length; count++) { @@ -898,7 +1264,9 @@ describe("options", () => { for (const [name, text] of configs.slice(0, count)) { fs.writeFileSync(path.join(child, name), text); } - expect(findConfig(path.join(child, "file.bend"))).toEqual({ rules: { "test/echo": configs[count - 1][2] } }); + expect(findConfig(path.join(child, "file.bend"))).toEqual({ + rules: { "test/echo": configs[count - 1][2] }, + }); } }); @@ -920,33 +1288,44 @@ describe("options", () => { }); test("module configs use the same rule option validation as JSON", async () => { - const file = fixture("invalid_options.ts", 'export const config = { rules: { "test/echo": { tabWidth: "4" } } };'); + const file = fixture( + "invalid_options.ts", + 'export const config = { rules: { "test/echo": { tabWidth: "4" } } };', + ); await expect(messages(readConfig(file))).rejects.toThrow(/tabWidth must be a number/); }); test("a Bend rule reads its options with defaults", async () => { const rule = await bendRule(fixture("options_rule.bend", OPTIONS_BEND)); - const said = async (config: object) => (await lint(userland, [rule], { config })).diags.map((d) => d.message); + const said = async (config: object) => + (await lint(userland, [rule], { config })).diags.map((d) => d.message); const [plain] = await said({}); expect(plain).toMatch(/^2 \S+ none$/); const [given] = await said({ rules: { "test/options": { width: 4, wrap: true, name: "x" } } }); expect(given).toMatch(/^4 \S+ x$/); expect(given.split(" ")[1]).not.toBe(plain.split(" ")[1]); - await expect(said({ rules: { "test/options": { width: 1.5 } } })).rejects.toThrow(/whole number/); + await expect(said({ rules: { "test/options": { width: 1.5 } } })).rejects.toThrow( + /whole number/, + ); }); }); describe("review fixes", () => { test("facts come only from the linted file: none from Base, with or without seeding", async () => { - for (const [name, head] of [["base_comment.bend", "import Base # comment"], ["base_plain.bend", "import Base"]]) { - const file = fixture(name, head + "\n\ntype P is Data:\n P{n: Nat}\n\ndef main() -> Nat:\n 1n\n"); + for (const [name, head] of [ + ["base_comment.bend", "import Base # comment"], + ["base_plain.bend", "import Base"], + ]) { + const file = fixture( + name, + head + "\n\ntype P is Data:\n P{n: Nat}\n\ndef main() -> Nat:\n 1n\n", + ); const where: LintRule = { id: "test/where", facts: true, run: (cx) => { - const facts = [...cx.facts!.values()]; - expect(facts.length).toBeGreaterThan(0); - expect(facts.every((f) => f.spn?.file === cx.root.file)).toBe(true); + expect(cx.facts!.length).toBeGreaterThan(0); + expect(cx.facts!.every((f) => cx.view(f).span?.file === cx.root)).toBe(true); return []; }, }; @@ -955,46 +1334,95 @@ describe("review fixes", () => { }); test("checks at the same time keep their facts apart", async () => { - const files = ["one", "two"].map((name) => fixture("apart_" + name + ".bend", "type N is Data:\n Z{}\n\ndef " + name + "(x: N) -> N:\n x\n")); - const where: LintRule = { id: "test/where", facts: true, run: (cx) => [cx.diag({ message: [...new Set([...cx.facts!.values()].map((f) => f.def))].join(), severity: "hint" })] }; - const got = await Promise.all([...files, ...files].map(async (file) => (await lint(file, [where])).diags[0].message)); + const files = ["one", "two"].map((name) => + fixture( + "apart_" + name + ".bend", + "type N is Data:\n Z{}\n\ndef " + name + "(x: N) -> N:\n x\n", + ), + ); + const where: LintRule = { + id: "test/where", + facts: true, + run: (cx) => [ + cx.diag({ + message: [...new Set(cx.facts!.map((f) => cx.view(f).owner))].join(), + severity: "hint", + }), + ], + }; + const got = await Promise.all( + [...files, ...files].map(async (file) => (await lint(file, [where])).diags[0].message), + ); expect(got).toEqual(["N,one", "N,two", "N,one", "N,two"]); }); test("identical files map by namespace, not only by text", async () => { const dir = path.join(DIR, "twins"); fs.mkdirSync(dir, { recursive: true }); - for (const f of ["a.bend", "b.bend"]) fs.writeFileSync(path.join(dir, f), "def one() -> Type:\n Type\n"); - fs.writeFileSync(path.join(dir, "m.bend"), "import ./a.bend as A\nimport ./b.bend as B\n\ndef main() -> Type:\n A.one()\n"); - const all: LintRule = { id: "test/all", facts: true, run: (cx) => [...cx.facts!.values()].map((f) => cx.diag({ message: "f", spn: f.spn })) }; + for (const f of ["a.bend", "b.bend"]) + fs.writeFileSync(path.join(dir, f), "def one() -> Type:\n Type\n"); + fs.writeFileSync( + path.join(dir, "m.bend"), + "import ./a.bend as A\nimport ./b.bend as B\n\ndef main() -> Type:\n A.one()\n", + ); + const all: LintRule = { + id: "test/all", + facts: true, + run: (cx) => cx.facts!.map((f) => cx.diag({ message: "f", span: cx.view(f).span })), + }; const res = await lint(path.join(dir, "m.bend"), [all]); expect(res.ok).toBe(true); expect(res.diags.length).toBeGreaterThan(0); - const a = source2("/p/a.bend", "a"), b = source2("/p/b.bend", "b"); - expect(mapper([a, b])({ file: { str: "x\n", ns: "b", dir: "/p/", al: {} }, beg: 0, end: 1 } as Span).file).toBe(b.file); + const a = file2("/p/a.bend", "a"), + b = file2("/p/b.bend", "b"); + expect( + mapper([a, b])({ + file: { str: "x\n", ns: "b", dir: "/p/", al: {} }, + beg: 0, + end: 1, + } as BendSpan)!.file, + ).toBe(b); }); test("walk is linear in a long list (it took 408 ms at 2,000 elements)", async () => { const n = 2000; - const file = fixture("long_list.bend", "import Base\n\ndef xs() -> List<&2, U32>:\n [" + Array.from({ length: n }, (_, i) => i).join(", ") + "]\n"); + const file = fixture( + "long_list.bend", + "import Base\n\ndef xs() -> List<&2, U32>:\n [" + + Array.from({ length: n }, (_, i) => i).join(", ") + + "]\n", + ); const res = await lint(file, []); expect(res.diags.map(render)).toEqual([]); const t = performance.now(); - const count = [...walk((res.book.tlds.xs as { e: LTerm }).e)].length; + const count = [...walk((res.unstable.book.tlds.xs as { e: LTerm }).e)].length; expect(count).toBeGreaterThan(n); expect(performance.now() - t).toBeLessThan(100); }); test("clashing fixes are skipped, not fatal; equal ones merge", () => { - const file: SourceFile = { str: "abcdef", ns: "", al: {}, path: "" }; - const fix = (beg: number, end: number, text: string): Diag => ({ code: "t/f", severity: "hint", message: "", - fixes: [{ title: "f", applicability: "safe", edits: [{ spn: { file, beg, end }, text }] }] }); - expect(applyFixes(file, [fix(1, 1, " "), fix(1, 1, " "), fix(3, 5, "X"), fix(4, 6, "Y"), fix(1, 1, "-")])) - .toEqual({ text: "a bcXf", skipped: 2 }); + const file: Source = { path: "", text: "abcdef", root: true, base: false }; + const fix = (beg: number, end: number, text: string): Diag => ({ + code: "t/f", + severity: "hint", + message: "", + fixes: [{ title: "f", applicability: "safe", edits: [{ span: { file, beg, end }, text }] }], + }); + expect( + applyFixes(file, [ + fix(1, 1, " "), + fix(1, 1, " "), + fix(3, 5, "X"), + fix(4, 6, "Y"), + fix(1, 1, "-"), + ]), + ).toEqual({ text: "a bcXf", skipped: 2 }); }); test("comp.ts that already exports js_sat still loads", () => { - const comp = fs.readFileSync(path.join(BEND2, "comp.ts"), "utf8").replace("\nfunction js_sat(", "\nexport function js_sat("); + const comp = fs + .readFileSync(path.join(BEND2, "comp.ts"), "utf8") + .replace("\nfunction js_sat(", "\nexport function js_sat("); const out = patch("comp.ts", comp); expect(out).toContain("export { RUNTIME_MAIN };"); expect(() => new Bun.Transpiler({ loader: "ts" }).transformSync(out)).not.toThrow(); @@ -1004,8 +1432,15 @@ describe("review fixes", () => { const other = path.join(DIR, "other"); fs.cpSync(BEND2, path.join(other, "bend2"), { recursive: true }); fs.appendFileSync(path.join(other, "bend2", "bend.ts"), "\nexport const OTHER_CHECKOUT = 1;\n"); - const script = fixture("other.ts", "import " + JSON.stringify(CLI) + ";\nconst o = await import(" + JSON.stringify(path.join(other, "bend2", "bend.ts")) + ");\n" - + "console.log(JSON.stringify([o.OTHER_CHECKOUT, o.BEND_LINT_PATCH ?? null]));\n"); + const script = fixture( + "other.ts", + "import " + + JSON.stringify(CLI) + + ";\nconst o = await import(" + + JSON.stringify(path.join(other, "bend2", "bend.ts")) + + ");\n" + + "console.log(JSON.stringify([o.OTHER_CHECKOUT, o.BEND_LINT_PATCH ?? null]));\n", + ); const out = spawnSync(process.execPath, [script], { encoding: "utf8" }); expect(JSON.parse(out.stdout.trim().split("\n").at(-1)!)).toEqual([1, null]); }); @@ -1016,26 +1451,34 @@ describe("rules", () => { const file = fixture("erasure.bend", ERASURE); const res = await lint(file, [redundantAnnotation]); expect(res.diags.map((d) => d.def).sort()).toEqual(["alias", "dependent", "direct", "generic"]); - const cleaned = applyFixes(root(res.sources).file, res.diags, ["suggested"]).text; + const cleaned = applyFixes(root(res.sources), res.diags, ["suggested"]).text; expect(cleaned).toContain("f : N -> N = y => y"); expect(cleaned).toContain("value : N = Z{}"); const after = await lint(fixture("erasure_fixed.bend", cleaned), [redundantAnnotation]); expect(after.diags).toEqual([]); - expect(bodies(after.book)).toEqual(bodies(res.book)); - for (const [annotation, binding] of [["f : N -> N =", "f ="], ["value : N =", "value ="]]) { - const broken = await lint(fixture("erasure_broken.bend", cleaned.replace(annotation, binding)), []); + expect(bodies(after.unstable.book)).toEqual(bodies(res.unstable.book)); + for (const [annotation, binding] of [ + ["f : N -> N =", "f ="], + ["value : N =", "value ="], + ]) { + const broken = await lint( + fixture("erasure_broken.bend", cleaned.replace(annotation, binding)), + [], + ); expect(broken.diags.map((d) => d.code)).toEqual(["bend/check"]); } expect(fs.readFileSync(file, "utf8")).toBe(ERASURE); }); - }); -describe("drift: the copy of book_read agrees with bend's own tests", () => { +describe("drift: bend-lint agrees with bend's own tests", () => { for (const name of DRIFT) { const file = path.join(BEND2, "..", "tests", name + ".bend"); test.skipIf(!fs.existsSync(file))(name, async () => { - const first = fs.readFileSync(file, "utf8").match(/^#\|(.*)$/m)![1].trim(); + const first = fs + .readFileSync(file, "utf8") + .match(/^#\|(.*)$/m)![1] + .trim(); expect((await lint(file, [])).ok).toBe(first !== "SOME PROOFS FAIL"); }); } @@ -1050,30 +1493,42 @@ describe("rules written in Bend", () => { const res = await lint(userland, [rule]); expect(res.diags.map((d) => [d.code, d.severity])).toEqual([["style/comma-space", "warning"]]); expect(render(res.diags[0])).toContain("generic(~N, a)"); - expect(applyFixes(root(res.sources).file, res.diags).text).toContain("generic(~N, a)"); + expect(applyFixes(root(res.sources), res.diags).text).toContain("generic(~N, a)"); }); test("a typed rule asks the checker through effects", async () => { const rule = await bendRule(fixture("types_rule.bend", TYPES_BEND)); expect(rule.facts).toEqual({ scope: "file", kinds: ["Var"], defs: ["id"], names: [] }); const res = await lint(userland, [rule, rule]); - expect(res.diags.map((d) => d.message)).toEqual(Array(2).fill("x: Alias = N (same as its binder), text x, demanded once, uses x once")); - expect(res.diags.every((d) => d.spn?.file === root(res.sources).file)).toBe(true); + expect(res.diags.map((d) => d.message)).toEqual( + Array(2).fill("x: Alias = N (same as its binder), text x, demanded once, uses x once"), + ); + expect(res.diags.every((d) => d.span?.file === root(res.sources))).toBe(true); }); test("Bend fixes reject out-of-bounds code-point offsets instead of clamping them", async () => { for (const source of [USERLAND, "# 😀\n" + USERLAND]) { const end = [...source].length + 1; - const code = COMMA_BEND.replace("Lint.Span{path, at, at}", "Lint.Span{path, at, " + end + "}"); + const code = COMMA_BEND.replace( + "Lint.Span{path, at, at}", + "Lint.Span{path, at, " + end + "}", + ); const rule = await bendRule(fixture("bad_offset_rule.bend", code)); const file = fixture("bad_offset_input.bend", source); - await expect(lint(file, [rule])).rejects.toThrow('fix "Insert space" has an edit out of bounds'); + await expect(lint(file, [rule])).rejects.toThrow( + 'fix "Insert space" has an edit out of bounds', + ); expect(fs.readFileSync(file, "utf8")).toBe(source); - const diagnosticOnly = code.replace('[Lint.Fix{"Insert space", Lint.Safe{}, [Lint.Edit{span, " "}]}]', "[]"); - const diagnosticRule = await bendRule(fixture("clamped_diagnostic_rule.bend", diagnosticOnly)); + const diagnosticOnly = code.replace( + '[Lint.Fix{"Insert space", Lint.Safe{}, [Lint.Edit{span, " "}]}]', + "[]", + ); + const diagnosticRule = await bendRule( + fixture("clamped_diagnostic_rule.bend", diagnosticOnly), + ); const result = await lint(file, [diagnosticRule]); expect(result.ok).toBe(true); - expect(result.diags[0].spn!.end).toBe(source.length); + expect(result.diags[0].span!.end).toBe(source.length); } }); }); @@ -1097,12 +1552,18 @@ describe("fact filters", () => { test("each rule gets its own facts; bend-lint keeps only what some rule asked for", async () => { const got: Record = {}; - const rule = (id: string, kinds: string[]): LintRule => ({ id, facts: { kinds }, run: (cx) => (got[id] = [...cx.facts!.values()].map((f) => Bend.term_strip(f.tm).$), []) }); + const rule = (id: string, kinds: string[]): LintRule => ({ + id, + facts: { kinds }, + run: (cx) => ((got[id] = cx.facts!.map((f) => cx.view(f).kind)), []), + }); const res = await lint(userland, [rule("test/vars", ["Var"]), rule("test/refs", ["Ref"])]); expect(new Set(got["test/vars"])).toEqual(new Set(["Var"])); expect(new Set(got["test/refs"])).toEqual(new Set(["Ref"])); - expect(res.facts!.size).toBe(got["test/vars"].length + got["test/refs"].length); - expect(res.facts!.size).toBeLessThan((await lint(userland, [rule("test/all", [])])).facts!.size); + expect(res.facts!.length).toBe(got["test/vars"].length + got["test/refs"].length); + expect(res.facts!.length).toBeLessThan( + (await lint(userland, [rule("test/all", [])])).facts!.length, + ); }); test("scope program adds the imports' facts, never Base's", async () => { @@ -1111,8 +1572,12 @@ describe("fact filters", () => { fs.writeFileSync(path.join(dir, "dep.bend"), "import Base\n\ndef one(x: Nat) -> Nat:\n x\n"); for (const head of ["import Base", "import Base # comment"]) { const main = path.join(dir, "main.bend"); - fs.writeFileSync(main, head + "\nimport ./dep.bend as D\n\ndef main() -> Nat:\n D.one(1n)\n"); - const where = async (scope: "file" | "program") => new Set((await seen(main, { scope })).map((f) => path.basename(f.path))); + fs.writeFileSync( + main, + head + "\nimport ./dep.bend as D\n\ndef main() -> Nat:\n D.one(1n)\n", + ); + const where = async (scope: "file" | "program") => + new Set((await seen(main, { scope })).map((f) => path.basename(f.path))); expect(await where("file")).toEqual(new Set(["main.bend"])); expect(await where("program")).toEqual(new Set(["main.bend", "dep.bend"])); } @@ -1120,28 +1585,40 @@ describe("fact filters", () => { test("a bad filter is an invalid rule", async () => { for (const facts of [{ scope: "all" }, { kinds: "Var" }, { names: [1] }, 3]) { - await expect(lint(userland, [{ id: "test/bad", facts, run: () => [] } as unknown as LintRule])).rejects.toThrow(/invalid rule at 0/); + await expect( + lint(userland, [{ id: "test/bad", facts, run: () => [] } as unknown as LintRule]), + ).rejects.toThrow(/invalid rule at 0/); } }); test("a Bend rule pulls its facts one at a time, any number of them", async () => { const rule = await bendRule(fixture("count_rule.bend", COUNT_BEND)); expect(rule.facts).toEqual({ scope: "program", kinds: ["Var"], defs: [], names: [] }); - const many = fixture("many_vars.bend", "import Base\n\n" + Array.from({ length: 5000 }, (_, i) => "def f" + i + "(x: U32) -> U32:\n x\n").join("\n")); + const many = fixture( + "many_vars.bend", + "import Base\n\n" + + Array.from({ length: 5000 }, (_, i) => "def f" + i + "(x: U32) -> U32:\n x\n").join("\n"), + ); expect((await lint(many, [rule])).diags.map((d) => d.message)).toEqual(["5000"]); }); }); describe("cli", () => { const input = fixture("cli.bend", USERLAND); - const first = module("first.js", `[{ id: "cli/first", facts: true, run(cx) { - if (!cx.facts?.size || !cx.sources.length) throw new Error("missing metadata"); + const first = module( + "first.js", + `[{ id: "cli/first", facts: true, run(cx) { + if (!cx.facts?.length || !cx.sources.length) throw new Error("missing metadata"); return [cx.diag({ message: "First rule", severity: "warning" })]; - } }]`); - const second = module("second.ts", `[{ id: "cli/second", run(cx) { + } }]`, + ); + const second = module( + "second.ts", + `[{ id: "cli/second", run(cx) { if (cx.prior[0]?.code !== "cli/first") throw new Error("wrong rule order"); return [cx.diag({ message: "Second rule", severity: "hint" })]; - } }]`); + } }]`, + ); test("rules run in order and print bend's layout", () => { const out = run(input, "--rules", first, "--rules", second); @@ -1150,9 +1627,18 @@ describe("cli", () => { }); test("an error finding, or a failed check, exits 1", () => { - const blocking = module("error.js", `[{ id: "cli/error", run: (cx) => [cx.diag({ message: "Blocked", severity: "error" })] }]`); - expect(run(input, "--rules", blocking)).toMatchObject({ status: 1, stdout: expect.stringContaining("Error [cli/error]:") }); - expect(run(fixture("cli_bad.bend", "def broken(\n"))).toMatchObject({ status: 1, stdout: expect.stringContaining("Error [bend/check]:") }); + const blocking = module( + "error.js", + `[{ id: "cli/error", run: (cx) => [cx.diag({ message: "Blocked", severity: "error" })] }]`, + ); + expect(run(input, "--rules", blocking)).toMatchObject({ + status: 1, + stdout: expect.stringContaining("Error [cli/error]:"), + }); + expect(run(fixture("cli_bad.bend", "def broken(\n"))).toMatchObject({ + status: 1, + stdout: expect.stringContaining("Error [bend/check]:"), + }); }); test("bad usage and bad modules exit 2", () => { @@ -1161,12 +1647,29 @@ describe("cli", () => { expect(run(input, "--nope").status).toBe(2); expect(run(input, "--rules", path.join(DIR, "missing.js")).status).toBe(2); expect(run(input, "--rules", module("object.js", "{}")).stderr).toMatch(/must export `rules`/); - expect(run(input, "--rules", module("bad.js", `[{ id: "bad", run() { return []; } }]`)).stderr).toMatch(/invalid rule/); - expect(run(input, "--rules", fixture("no_id.bend", "import Base\n\ndef main() -> IO(Unit):\n IO.print(\"x\")\n")).stderr) - .toMatch(/must define id\(\) -> String/); - expect(run(input, "--rules", fixture("broken_rule.bend", "def broken(\n")).stderr).toMatch(/does not check/); - expect(run(input, "--rules", fixture("no_facts.bend", COMMA_BEND.replace("def facts() -> Lint.Want:\n Lint.NoFacts{}\n", ""))).stderr) - .toMatch(/must define id\(\) -> String, facts\(\) -> Lint.Want/); + expect( + run(input, "--rules", module("bad.js", `[{ id: "bad", run() { return []; } }]`)).stderr, + ).toMatch(/invalid rule/); + expect( + run( + input, + "--rules", + fixture("no_id.bend", 'import Base\n\ndef main() -> IO(Unit):\n IO.print("x")\n'), + ).stderr, + ).toMatch(/must define id\(\) -> String/); + expect(run(input, "--rules", fixture("broken_rule.bend", "def broken(\n")).stderr).toMatch( + /does not check/, + ); + expect( + run( + input, + "--rules", + fixture( + "no_facts.bend", + COMMA_BEND.replace("def facts() -> Lint.Want:\n Lint.NoFacts{}\n", ""), + ), + ).stderr, + ).toMatch(/must define id\(\) -> String, facts\(\) -> Lint.Want/); }); test("a rule written in Bend runs from the CLI", () => { @@ -1176,23 +1679,43 @@ describe("cli", () => { }); test("--json prints findings with LSP ranges, and nothing else", () => { - const comma = module("json_comma.js", `[{ id: "style/comma-space", run: (cx) => + const comma = module( + "json_comma.js", + `[{ id: "style/comma-space", run: (cx) => [...cx.root.text.matchAll(/,(?=\\w)/g)].map((m) => { - const spn = { file: cx.root.file, beg: m.index + 1, end: m.index + 1 }; - return cx.diag({ message: "space", severity: "hint", spn, - fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }] }); - }) }]`); + const span = { file: cx.root, beg: m.index + 1, end: m.index + 1 }; + return cx.diag({ message: "space", severity: "hint", span, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ span, text: " " }] }] }); + }) }]`, + ); const out = run(input, "--rules", comma, "--json"); expect(out.status).toBe(0); const json = JSON.parse(out.stdout); const at = { line: 25, character: 13 }; expect(json).toEqual({ ok: true, - findings: [{ - code: "style/comma-space", severity: "hint", message: "space", path: fs.realpathSync(input).replaceAll("\\", "/"), - range: { start: at, end: at }, - fixes: [{ title: "Insert space", applicability: "safe", edits: [{ path: fs.realpathSync(input).replaceAll("\\", "/"), range: { start: at, end: at }, text: " " }] }], - }], + findings: [ + { + code: "style/comma-space", + severity: "hint", + message: "space", + path: fs.realpathSync(input).replaceAll("\\", "/"), + range: { start: at, end: at }, + fixes: [ + { + title: "Insert space", + applicability: "safe", + edits: [ + { + path: fs.realpathSync(input).replaceAll("\\", "/"), + range: { start: at, end: at }, + text: " ", + }, + ], + }, + ], + }, + ], }); expect(USERLAND.split("\n")[at.line].slice(0, at.character)).toBe(" generic(~N,"); const bad = JSON.parse(run(fixture("json_bad.bend", "def broken(\n"), "--json").stdout); @@ -1201,22 +1724,42 @@ describe("cli", () => { test("--bend and BEND_DIR choose the bend to load; a wrong one exits 2", () => { expect(run(input, "--bend", path.dirname(BEND2)).status).toBe(0); - const env = spawnSync(process.execPath, [CLI, input], { encoding: "utf8", env: { ...process.env, BEND_DIR: BEND2 } }); + const env = spawnSync(process.execPath, [CLI, input], { + encoding: "utf8", + env: { ...process.env, BEND_DIR: BEND2 }, + }); expect(env.status).toBe(0); - expect(run(input, "--bend", DIR)).toMatchObject({ status: 2, stderr: expect.stringContaining("no bend2 at") }); + expect(run(input, "--bend", DIR)).toMatchObject({ + status: 2, + stderr: expect.stringContaining("no bend2 at"), + }); }); test("--config selects JSON, JS or TS explicitly", () => { - const echoes = module("echo.js", `[{ id: "test/echo", options: { tabWidth: 2 }, run: (cx) => [cx.diag({ message: "w" + cx.options.tabWidth })] }]`); + const echoes = module( + "echo.js", + `[{ id: "test/echo", options: { tabWidth: 2 }, run: (cx) => [cx.diag({ message: "w" + cx.options.tabWidth })] }]`, + ); const settings = JSON.stringify({ rules: { "test/echo": { tabWidth: 6, severity: "hint" } } }); for (const extension of ["json", "js", "ts"]) { - const config = fixture("cli_config." + extension, extension === "json" ? settings : "export const config = " + settings + ";"); + const config = fixture( + "cli_config." + extension, + extension === "json" ? settings : "export const config = " + settings + ";", + ); const out = run(input, "--rules", echoes, "--config", config, "--json"); expect(out.status).toBe(0); - expect(JSON.parse(out.stdout).findings.map((f: { severity: string; message: string }) => [f.severity, f.message])).toEqual([["hint", "w6"]]); + expect( + JSON.parse(out.stdout).findings.map((f: { severity: string; message: string }) => [ + f.severity, + f.message, + ]), + ).toEqual([["hint", "w6"]]); } const invalid = fixture("cli_default_config.ts", "export default {};"); - expect(run(input, "--config", invalid)).toMatchObject({ status: 2, stderr: expect.stringContaining("must export a named `config` object") }); + expect(run(input, "--config", invalid)).toMatchObject({ + status: 2, + stderr: expect.stringContaining("must export a named `config` object"), + }); }); test("--fix writes only the linted file, and prints findings when fixes clash", () => { @@ -1226,10 +1769,13 @@ describe("cli", () => { fs.writeFileSync(dep, "def one() -> Type:\n Type\n"); const main = path.join(dir, "main.bend"); fs.writeFileSync(main, "import ./dep.bend as D\n\ndef main() -> Type:\n D.one()\n"); - const everywhere = module("everywhere.js", `[{ id: "test/everywhere", run: (cx) => cx.sources.filter((s) => !s.base).flatMap((s) => [0, 0, 1].map((n) => { - const spn = { file: s.file, beg: n, end: n }; - return cx.diag({ message: "x", spn, fixes: [{ title: "x", applicability: "safe", edits: [{ spn, text: n === 0 ? "#" : "!" }] }] }); - })) }]`); + const everywhere = module( + "everywhere.js", + `[{ id: "test/everywhere", run: (cx) => cx.sources.filter((s) => !s.base).flatMap((s) => [0, 0, 1].map((n) => { + const span = { file: s, beg: n, end: n }; + return cx.diag({ message: "x", span, fixes: [{ title: "x", applicability: "safe", edits: [{ span, text: n === 0 ? "#" : "!" }] }] }); + })) }]`, + ); const out = run(main, "--rules", everywhere, "--fix"); expect(out.status).toBe(0); expect(out.stdout).toContain("Warning [test/everywhere]:"); @@ -1239,26 +1785,36 @@ describe("cli", () => { }); test("--fix-suggested and --fix-dangerously apply wider levels", () => { - const levels = module("levels.js", `[{ id: "test/levels", run: (cx) => ["safe", "suggested", "dangerous"].map((applicability, n) => { - const spn = { file: cx.root.file, beg: n, end: n }; - return cx.diag({ message: applicability, spn, fixes: [{ title: applicability, applicability, edits: [{ spn, text: String(n) }] }] }); - }) }]`); + const levels = module( + "levels.js", + `[{ id: "test/levels", run: (cx) => ["safe", "suggested", "dangerous"].map((applicability, n) => { + const span = { file: cx.root, beg: n, end: n }; + return cx.diag({ message: applicability, span, fixes: [{ title: applicability, applicability, edits: [{ span, text: String(n) }] }] }); + }) }]`, + ); const fixed = (flag: string) => { const target = fixture("levels" + flag + ".bend", "#abc\n"); expect(run(target, "--rules", levels, flag).status).toBe(0); return fs.readFileSync(target, "utf8"); }; - expect(["--fix", "--fix-suggested", "--fix-dangerously"].map(fixed)).toEqual(["0#abc\n", "0#1abc\n", "0#1a2bc\n"]); + expect(["--fix", "--fix-suggested", "--fix-dangerously"].map(fixed)).toEqual([ + "0#abc\n", + "0#1abc\n", + "0#1a2bc\n", + ]); }); test("--fix applies safe fixes", () => { const target = fixture("fix.bend", USERLAND); - const comma = module("comma.js", `[{ id: "style/comma-space", run: (cx) => + const comma = module( + "comma.js", + `[{ id: "style/comma-space", run: (cx) => [...cx.root.text.matchAll(/,(?=\\w)/g)].map((m) => { - const spn = { file: cx.root.file, beg: m.index + 1, end: m.index + 1 }; - return cx.diag({ message: "space", severity: "hint", spn, - fixes: [{ title: "Insert space", applicability: "safe", edits: [{ spn, text: " " }] }] }); - }) }]`); + const span = { file: cx.root, beg: m.index + 1, end: m.index + 1 }; + return cx.diag({ message: "space", severity: "hint", span, + fixes: [{ title: "Insert space", applicability: "safe", edits: [{ span, text: " " }] }] }); + }) }]`, + ); expect(run(target, "--rules", comma, "--fix").status).toBe(0); expect(fs.readFileSync(target, "utf8")).toContain("generic(~N, a)"); }); diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 9896883cf8..d3c1710834 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -1,36 +1,77 @@ #!/usr/bin/env bun // bend-lint checks a Bend file with bend's checker, then runs rules over // its source and the checker's results. Rules are TS modules, or Bend files -// built on ./lint.bend. It loads bend2 through ./patch.ts; anything there -// it cannot follow stops it with a DriftError. As a CLI, it exits 0 when -// ok, 1 when it found an error, 2 on bad usage or a tool failure. +// built on ./lint.bend. It reaches bend2 only through ./seam.ts; anything +// there it cannot follow stops it with a DriftError. The types below are +// bend-lint's own, so a rule does not depend on bend2's internals. As a +// CLI, it exits 0 when ok, 1 when it found an error, 2 on bad usage or a +// tool failure. import * as url from "node:url"; import * as util from "node:util"; -import type * as BendModule from "bend2/bend.ts"; -import type * as CompModule from "bend2/comp.ts"; -import type { Ann, Book, Ctx, Err, HTerm, LTerm, Name, Quant, Span, Uses } from "bend2/bend.ts"; -import { DriftError, MARK, PATCHED, bendDir, fs, hook, patch, path, unsaved } from "./patch.ts"; +import { + check, + compile, + fs, + layout, + line, + load, + operations, + path, + select, + starts, +} from "./seam.ts"; +import type { Unstable } from "./seam.ts"; // Types // ===== -export type Bend = typeof BendModule; export type Severity = "error" | "warning" | "information" | "hint"; // safe keeps behavior; suggested may change it; dangerous may break code. export type Applicability = "safe" | "suggested" | "dangerous"; -export type Edit = { spn: Span; text: string }; + +// A file of the book, as it is on disk (or as the editor holds it unsaved). +export type Source = { path: string; text: string; root: boolean; base: boolean }; + +// A range of a source, in UTF-16 offsets. +export type Span = { file: Source; beg: number; end: number }; + +export type Edit = { span: Span; text: string }; export type Fix = { title: string; applicability: Applicability; edits: Edit[] }; +declare const OPAQUE: unique symbol; + +// A checked term, and a type, as handles: what they hold is bend2's, and a +// rule reaches it only through the RuleContext. +export type Fact = { readonly [OPAQUE]: "fact" }; +export type Type = { readonly [OPAQUE]: "type" }; + +// How many times a term is demanded, or a variable is used. +export type Quantity = "erased" | "once" | "many"; + +// A fact's term: its kind (annotations stripped: Var, Ref, App, ...), the +// name a Var or Ref points to, the def whose body holds it, and how many +// times it is demanded. A template body is checked as written, then again +// for each instance (generic~0) at the same spans; `inst` marks the facts +// of an instance. `inner` is the term's span without its annotations. +export type View = { + owner: string; + inst: boolean; + kind: string; + name: string; + quantity: Quantity; + span?: Span; + inner?: Span; +}; + export type Diag = { code: string; severity: Severity; message: string; - spn?: Span; - def?: Name; - ctx?: Ctx; - bok?: Book; + span?: Span; + def?: string; + fact?: Fact; // its scope shows as the finding's context fixes: Fix[]; core?: unknown; // bend's own failure, when the check failed }; @@ -38,49 +79,41 @@ export type Diag = { export type DiagInit = { message: string; severity?: Severity; - spn?: Span; + span?: Span; fact?: Fact; - def?: Name; + def?: string; fixes?: Fix[]; }; -// A file of the book, as it is on disk. -export type SourceFile = { str: string; ns: string; al: Record; path: string }; -export type Source = { path: string; ns: string; text: string; root: boolean; base: boolean; file: SourceFile }; - -// `tm` checked (or inferred) as `ty` at depth `dep` in `ctx`, in def `def`, -// demanded `qt` times, using the variables in `us`. `bok` is the book it was -// checked in (a template body has its own). A template body is checked as -// written, then again for each instance (generic~0) at the same spans; -// `inst` marks the facts of an instance. -export type Fact = { tm: LTerm; ty: HTerm; bok: Book; ctx: Ctx; dep: number; def: Name; qt: Quant; us: Uses; inst: boolean; spn?: Span }; - // The checker's facts a rule gets. A fact must match each list given; an // absent or empty list matches all. In defs, a template's instances // (generic~0) count as the template. export type FactFilter = { scope?: "file" | "program"; // file (the default): the linted file; program: its imports too, never Base - kinds?: string[]; // the term's kind, annotations stripped: Var, Ref, App, ... - defs?: Name[]; // the def whose body holds the term - names?: Name[]; // the name a Var or Ref points to + kinds?: string[]; // the term's kind, annotations stripped: Var, Ref, App, ... + defs?: string[]; // the def whose body holds the term + names?: string[]; // the name a Var or Ref points to }; +// `type`, `binder`, `same`, `show` and `normal` work in the fact's scope. +// `unstable` is bend2's own objects: code that uses it breaks when bend2 +// changes. export type RuleContext = { - Bend: Bend; - book: Book; sources: Source[]; root: Source; - options: Options; // the rule's defaults, with the config's values - facts?: Map; // only for a rule with facts, and only those it asked for - prior: readonly Diag[]; // what earlier rules found - span: Mapper; // a bend.ts span, in the file on disk - walk(tm: LTerm): Generator; - binder(fact: Fact, v: LTerm): Ann | null; - show(fact: Fact, ty: HTerm): string; - same(fact: Fact, a: HTerm, b: HTerm): boolean; - normal(fact: Fact, ty: HTerm): HTerm; - uses(fact: Fact): Array<{ name: Name; quantity: Quant }>; + options: Options; // the rule's defaults, with the config's values + facts?: Fact[]; // only for a rule with facts, and only those it asked for + prior: readonly Diag[]; // what earlier rules found + view(fact: Fact): View; + type(fact: Fact): Type; + binder(fact: Fact): Type | undefined; // the declared type of the variable a Var fact uses + same(fact: Fact, a: Type, b: Type): boolean; + show(fact: Fact, type: Type): string; + normal(fact: Fact, type: Type): Type; + uses(fact: Fact): Array<{ name: string; quantity: Quantity }>; + sameDeclarations(text: string): boolean; // whether `text` declares what the linted file declares diag(init: DiagInit): Diag; + unstable: Unstable; }; // A rule option's value. A default's type is its option's type. @@ -92,7 +125,11 @@ export type Config = { rules?: Record }; +export type LintOptions = { + signal?: AbortSignal; + config?: Config; + unsaved?: ReadonlyMap; +}; export type LintRule = { id: string; // namespace/name; the code of its findings @@ -101,7 +138,13 @@ export type LintRule = { run(cx: RuleContext, signal: AbortSignal): Diag[] | Promise; }; -export type LintResult = { ok: boolean; diags: Diag[]; sources: Source[]; book: Book; facts?: Map }; +export type LintResult = { + ok: boolean; + diags: Diag[]; + sources: Source[]; + facts?: Fact[]; + unstable: Unstable; +}; // An LSP position: 0-based line and character, in UTF-16 units. export type Position = { line: number; character: number }; @@ -114,7 +157,11 @@ type Reported = { severity: Severity; message: string; span?: Spot; - fixes: Array<{ title: string; applicability: Applicability; edits: Array<{ span: Spot; text: string }> }>; + fixes: Array<{ + title: string; + applicability: Applicability; + edits: Array<{ span: Spot; text: string }>; + }>; }; // What effects.js asks of bend-lint while a Bend rule runs. Facts and types @@ -123,30 +170,16 @@ type Channel = { input(): { sources: Array<{ path: string; text: string; root: boolean }>; options: Options }; next(): number | undefined; report(diags: Reported[]): void; - view(fact: number): { owner: Name; inst: boolean; kind: string; name: string; quantity: Quant["$"]; span?: Spot; inner?: Spot }; + view(fact: number): Omit & { span?: Spot; inner?: Spot }; type(fact: number): number; binder(fact: number): number | undefined; same(fact: number, a: number, b: number): boolean; show(fact: number, t: number): string; normal(fact: number, t: number): number; - uses(fact: number): Array<{ name: Name; quantity: Quant["$"] }>; + uses(fact: number): Array<{ name: string; quantity: Quantity }>; text(span: Spot): string; }; -// comp.ts as patch.ts exports it. -type Comp = typeof CompModule & { RUNTIME_MAIN: string; js_sat(k: Name): string }; - -// What bend-lint uses of main.ts, as patch.ts exports it: book_read throws -// a Check_Fail holding why the check failed. -type Main = { - book_read(file: string, base?: Book, seen?: Map): Promise; - book_err(e: unknown): string; - Check_Fail: new (why: unknown) => { why: unknown }; -}; - -type Mapper = { (s: Span): Span; (s: Span | undefined): Span | undefined }; -type Checked = { book: Book; sources: Source[]; span: Mapper; facts?: Map; failure?: Diag }; - // Constants // ========= @@ -154,47 +187,15 @@ const RULE_ID = /^[^/\s]+\/[^/\s]+$/; const SCOPES = ["file", "program"]; const CONFIG_FILES = ["bend-lint.json", "bend-lint.js", "bend-lint.ts"]; const LINE = /[^\n]*\n|[^\n]+$/g; -const SAMPLE = "type N is Data:\n Z{}\n\ndef id(x: N) -> N:\n x\n"; -const HEAD: Record = { error: "Error", warning: "Warning", information: "Information", hint: "Hint" }; +const HEAD: Record = { + error: "Error", + warning: "Warning", + information: "Information", + hint: "Hint", +}; const shared = globalThis as typeof globalThis & { BEND_LINT?: Channel }; -// Checked base.bend books, by the text of base.bend: Base is checked once -// per process, and again only if base.bend changes. -const BASES = new Map>(); - -// The check running now, or done; the next check waits for it. -let queue: Promise = Promise.resolve(); - -// Line starts per file object (see starts). -const STARTS = new WeakMap(); - -// Binders are explicit in checked terms, so Var.v cells are not followed. -// A new term kind is a type error here, and a DriftError when walked. -const CHILDREN: { [K in LTerm["$"]]: (tm: Extract) => LTerm[] } = { - Let: (tm) => [...tm.v, tm.f], - Lam: (tm) => [tm.f], - Sub: (tm) => [tm.f], - App: (tm) => [tm.f, tm.x], - Ctr: (tm) => tm.x, - ADT: (tm) => tm.x, - Mat: (tm) => [tm.h, tm.m], - Rwt: (tm) => [tm.e, tm.p, tm.f], - Typ: (tm) => [tm.g], - Min: (tm) => [tm.a, tm.b], - All: (tm) => [tm.A, tm.B], - Eql: (tm) => [tm.a, tm.b, tm.T], - Ann: (tm) => [tm.x, tm.T], - Var: () => [], - Ref: () => [], - Qnt: () => [], - Qua: () => [], - Lit: () => [], - Efq: () => [], - Rfl: () => [], - Hol: () => [], -}; - const OPTIONS = { rules: { type: "string", multiple: true }, fix: { type: "boolean" }, @@ -206,10 +207,13 @@ const OPTIONS = { help: { type: "boolean", short: "h" }, } as const; -const USAGE = "usage: bun src/lint.ts [--rules ]... [--config ] [--fix | --fix-suggested | --fix-dangerously] [--json] [--bend ]"; +const USAGE = + "usage: bun src/lint.ts [--rules ]... [--config ] [--fix | --fix-suggested | --fix-dangerously] [--json] [--bend ]"; // The fix levels each flag applies; the widest flag given wins. -const FIXES: ReadonlyArray = [ +const FIXES: ReadonlyArray< + readonly ["fix" | "fix-suggested" | "fix-dangerously", Applicability[]] +> = [ ["fix-dangerously", ["safe", "suggested", "dangerous"]], ["fix-suggested", ["safe", "suggested"]], ["fix", ["safe"]], @@ -218,190 +222,6 @@ const FIXES: ReadonlyArray { - const stack = [tm]; - for (let t = stack.pop(); t !== undefined; t = stack.pop()) { - const children = (CHILDREN as Record LTerm[]) | undefined>)[t.$]; - if (children === undefined) { - throw new DriftError("unknown term kind " + t.$ + "; update CHILDREN in tools/bend-lint/src/lint.ts"); - } - yield t; - const next = children(t); - for (let i = next.length - 1; i >= 0; i--) { - stack.push(next[i]); - } - } -} - -// Where each line of a file starts, computed once per file object. -function starts(file: Span["file"]): number[] { - const known = STARTS.get(file); - if (known !== undefined) { - return known; - } - const out = [0, ...[...file.str.matchAll(/\n/g)].map((m) => m.index! + 1)]; - STARTS.set(file, out); - return out; -} - -// The index of the line that holds `off`, given each line's start. -function line(starts: number[], off: number): number { - let [lo, hi] = [0, starts.length - 1]; - while (lo < hi) { - const mid = (lo + hi + 1) >> 1; - [lo, hi] = starts[mid] <= off ? [mid, hi] : [lo, mid - 1]; - } - return lo; -} - -// bend.ts parses a copy of each file with its import lines blanked, so a -// span moves to the file on disk by line and column. The copy's namespace, -// folder and lines pick the file; a copy that matches no file, or two, is -// a DriftError. -export function mapper(sources: Source[]): Mapper { - const own = new Set(sources.map((s) => s.file)); - const memo = new WeakMap(); - function map(s: Span): Span; - function map(s: Span | undefined): Span | undefined; - function map(s: Span | undefined): Span | undefined { - if (s === undefined || own.has(s.file)) { - return s; - } - const f = s.file as Span["file"] & { dir?: string }; - if (!memo.has(f)) { - const lines = f.str.split("\n"); - const found = sources.filter((src) => { - const theirs = src.text.split("\n"); - return f.ns === src.ns - && (f.dir === undefined || f.dir === src.path.slice(0, src.path.lastIndexOf("/") + 1)) - && lines.length === theirs.length - && lines.every((l, i) => l === theirs[i] || l.trim() === "" && /^\s*import(\s|$)/.test(theirs[i])); - }); - if (found.length !== 1) { - throw new DriftError("cannot map a bend.ts span to a file on disk (" + found.length - + " candidates); bend.ts may mask imports another way now"); - } - Object.assign(found[0].file.al, f.al); - memo.set(f, { file: found[0].file, from: starts(f), to: starts(found[0].file) }); - } - const { file, from, to } = memo.get(f)!; - const at = (off: number): number => { - const i = line(from, off); - return to[i] + off - from[i]; - }; - return { file, beg: at(s.beg), end: at(s.end) }; - } - return map; -} - -function isErr(e: unknown): e is Err { - return typeof e === "object" && e !== null && (e as { $?: unknown }).$ === "Err"; -} - -// Checks a book with bend2/main.ts book_read, as `bend ` does; it -// does not give main.ts's verdict on @unsafe and foreign code. A file with -// a line exactly `import Base` starts from the checked Base, as `bend -// --checkup` does. `text` (unsaved editor text) is read in place of the -// files on disk. The hook and the text are global to bend, so checks run -// one at a time, in the order asked. A fact is kept as the checker gives -// it only if a filter wants it; with scope file, only if it is from the -// linted file's folder and namespace. After the check, only facts whose -// span maps to the linted file (or, with scope program, to any file but -// Base) stay. -function check(file: string, filters: FactFilter[], signal: AbortSignal, text?: ReadonlyMap): Promise { - const turn = queue.then(async (): Promise => { - signal.throwIfAborted(); - for (const [at, str] of text ?? []) { - if (fs.existsSync(at)) { - unsaved.set(fs.realpathSync(at), str); - } - } - try { - return await checked(file, filters, signal); - } finally { - hook.see = undefined; - unsaved.clear(); - } - }); - queue = turn.catch(() => undefined); - return turn; -} - -// check's work, in its turn. -async function checked(file: string, filters: FactFilter[], signal: AbortSignal): Promise { - const seen = new Map(); - const found: Array> = []; - const real = fs.existsSync(file) ? fs.realpathSync(file) : ""; - const home = real.slice(0, real.lastIndexOf("/") + 1); - const far = filters.filter((f) => f.scope === "program"); - const program = far.length > 0; - const seeded = real !== "" && /^import Base$/m.test(fs.readFileSync(real, "utf8")); - const key = fs.readFileSync(Bend.BASE_BEND, "utf8"); - if (seeded && !BASES.has(key)) { - BASES.set(key, Main.book_read(Bend.BASE_BEND)); - } - let book: Book = Bend.book_nil(); - const caught = await Promise.resolve(seeded ? BASES.get(key) : undefined).then(async (base) => { - hook.see = filters.length > 0 - ? (bok, tm, ty, ctx, dep, def, spn, qt, us) => { - const at = spn?.file as { dir?: string; ns?: string } | undefined; - const near = at?.dir === home && at.ns === "" ? filters : far; - const { kind, name } = near.length > 0 ? shape(tm) : { kind: "", name: "" }; - if (near.some((f) => matches(f, kind, def, name))) { - found.push({ tm, ty, bok, ctx, dep, def, spn, qt, us }); - } - } - : undefined; - book = await Main.book_read(file, base, seen); - }).then(() => undefined, (e: unknown) => ({ e: e instanceof Main.Check_Fail ? e.why : e })); - signal.throwIfAborted(); - const root = [...seen.keys()].find((real) => real !== Bend.BASE_BEND || !seeded); - const sources = [...seen.keys()].filter((real) => fs.existsSync(real)).map((real): Source => { - const text = fs.readFileSync(real, "utf8"); - const ns = seen.get(real) ?? ""; - return { path: real, ns, text, root: real === root, base: real === Bend.BASE_BEND, file: { str: text, ns, al: {}, path: real } }; - }); - const span = mapper(sources); - if (caught === undefined) { - const insts = new Set(Object.values(book.tmps).flatMap((m) => [...m.values()])); - const own = new Set(sources.filter((s) => program ? !s.base : s.root).map((s) => s.file)); - const facts = found.flatMap((f): Array<[LTerm, Fact]> => { - const spn = span(f.spn); - return spn !== undefined && own.has(spn.file) ? [[f.tm, { ...f, inst: insts.has(f.def), spn }]] : []; - }); - return { book, sources, span, facts: filters.length > 0 ? new Map(facts) : undefined }; - } - const err = isErr(caught.e) ? caught.e : undefined; - // bend's words, its location aside (a finding has its own): expected and - // observed, with the names in scope, and the note. - const show = (x: Err["exp"]): string => Bend.expr_show(err!.bok, x, Bend.ctx_scope(err!.ctx), err!.spn?.file); - const message = err === undefined ? Main.book_err(caught.e).replace(/^Error: /, "") - : (err.obs === undefined ? show(err.exp) : "expected: " + show(err.exp) + "\nobserved: " + show(err.obs)) - + (err.nte === undefined ? "" : "\n" + err.nte); - // A file bend did not finish loading may not map; the failure still reports. - let spn: Span | undefined; - try { - spn = span(err?.spn); - } catch { - spn = undefined; - } - return { book, sources, span, failure: { code: "bend/check", severity: "error", message, spn, def: err?.def, fixes: [], core: caught.e } }; -} - -// A term's kind, annotations stripped, and the name a Var or Ref points to. -function shape(tm: LTerm): { kind: string; name: Name } { - const t = Bend.term_strip(tm); - return { kind: t.$, name: t.$ === "Var" || t.$ === "Ref" ? t.k : "" }; -} - -// Whether a fact passes a filter, its scope aside. -function matches(f: FactFilter, kind: string, def: Name, name: Name): boolean { - const all = (xs: string[] | undefined, x: string): boolean => xs === undefined || xs.length === 0 || xs.includes(x); - return all(f.kinds, kind) && (all(f.defs, def) || all(f.defs, def.replace(/~\d+$/, ""))) && all(f.names, name); -} - export function readConfig(file: string): Config { try { if ([".js", ".ts"].includes(path.extname(file))) { @@ -409,7 +229,11 @@ export function readConfig(file: string): Config { if (!Object.hasOwn(module, "config")) { throw new Error("must export a named `config` object"); } - if (typeof module.config !== "object" || module.config === null || Array.isArray(module.config)) { + if ( + typeof module.config !== "object" || + module.config === null || + Array.isArray(module.config) + ) { throw new Error("`config` must be an object"); } return module.config; @@ -423,7 +247,9 @@ export function readConfig(file: string): Config { // The nearest config; JSON, JS, then TS within each directory. export function findConfig(file: string): Config { for (let dir = path.dirname(path.resolve(file)); ; dir = path.dirname(dir)) { - const config = CONFIG_FILES.map((name) => path.join(dir, name)).find((candidate) => fs.existsSync(candidate)); + const config = CONFIG_FILES.map((name) => path.join(dir, name)).find((candidate) => + fs.existsSync(candidate), + ); if (config !== undefined) { return readConfig(config); } @@ -435,14 +261,17 @@ export function findConfig(file: string): Config { // What the config says for a rule: off, a severity, and its options (the // defaults, with the given values, which must be known and of their type). -function settings(rule: LintRule, config: Config): { off: boolean; severity?: Severity; options: Options } { +function settings( + rule: LintRule, + config: Config, +): { off: boolean; severity?: Severity; options: Options } { const given = config.rules?.[rule.id]; const where = "bend-lint config: " + rule.id; if (given === undefined || given === "off") { return { off: given === "off", options: { ...rule.options } }; } if (typeof given !== "object" || given === null) { - throw new Error(where + " must be \"off\" or an object"); + throw new Error(where + ' must be "off" or an object'); } const { severity, ...options } = given; if (severity !== undefined && !Object.hasOwn(HEAD, severity)) { @@ -454,7 +283,13 @@ function settings(rule: LintRule, config: Config): { off: boolean; severity?: Se throw new Error(where + " has no option " + key); } if (typeof value !== want || !["number", "boolean", "string"].includes(want)) { - throw new Error(where + ": " + key + " must be a " + (rule.options === undefined ? "number, boolean or string" : want)); + throw new Error( + where + + ": " + + key + + " must be a " + + (rule.options === undefined ? "number, boolean or string" : want), + ); } } return { off: false, severity, options: { ...rule.options, ...options } }; @@ -463,69 +298,119 @@ function settings(rule: LintRule, config: Config): { off: boolean; severity?: Se // Rules run in order; the config may turn one off, set its severity, and // give its options. A finding with severity error stops the run. A rule // that throws, and an abort, reach the caller. -export async function lint(file: string, rules: LintRule[], - { signal = new AbortController().signal, config = findConfig(file), unsaved: held }: LintOptions = {}): Promise { - const strings = (xs: unknown): boolean => xs === undefined || Array.isArray(xs) && xs.every((x) => typeof x === "string"); - const bad = rules.findIndex((r) => !RULE_ID.test(String(r?.id)) || typeof r?.run !== "function" - || !(r.facts === undefined || r.facts === true || typeof r.facts === "object" && r.facts !== null - && (r.facts.scope === undefined || SCOPES.includes(r.facts.scope)) && strings(r.facts.kinds) && strings(r.facts.defs) && strings(r.facts.names))); +export async function lint( + file: string, + rules: LintRule[], + { + signal = new AbortController().signal, + config = findConfig(file), + unsaved: held, + }: LintOptions = {}, +): Promise { + const strings = (xs: unknown): boolean => + xs === undefined || (Array.isArray(xs) && xs.every((x) => typeof x === "string")); + const bad = rules.findIndex( + (r) => + !RULE_ID.test(String(r?.id)) || + typeof r?.run !== "function" || + !( + r.facts === undefined || + r.facts === true || + (typeof r.facts === "object" && + r.facts !== null && + (r.facts.scope === undefined || SCOPES.includes(r.facts.scope)) && + strings(r.facts.kinds) && + strings(r.facts.defs) && + strings(r.facts.names)) + ), + ); if (bad >= 0) { - throw new TypeError("invalid rule at " + bad + " (" + JSON.stringify(rules[bad]?.id) - + "): it needs an id like ns/name, a run function, and facts, if given, true or a FactFilter"); + throw new TypeError( + "invalid rule at " + + bad + + " (" + + JSON.stringify(rules[bad]?.id) + + "): it needs an id like ns/name, a run function, and facts, if given, true or a FactFilter", + ); } - const plans = rules.map((rule) => ({ rule, ...settings(rule, config), want: rule.facts === true ? {} : rule.facts })) + const plans = rules + .map((rule) => ({ + rule, + ...settings(rule, config), + want: rule.facts === true ? {} : rule.facts, + })) .filter((p) => !p.off); const program = plans.some((p) => p.want?.scope === "program"); - const { book, sources, span, facts, failure } = await check(file, plans.flatMap((p) => p.want === undefined ? [] : [p.want]), signal, held); + const checked = await check( + loaded, + file, + plans.flatMap((p) => (p.want === undefined ? [] : [p.want])), + signal, + held, + ); + const { sources, facts, failure } = checked; + const ops = operations(loaded, checked); if (failure !== undefined) { - return { ok: false, diags: [failure], sources, book }; + return { ok: false, diags: [failure], sources, unstable: ops.unstable }; } const root = sources.find((s) => s.root)!; let diags: Diag[] = []; for (const { rule, severity, options, want } of plans) { signal.throwIfAborted(); - const out = await rule.run({ - Bend, book, sources, root, span, walk, options, - // A filter that matches all, in the scope of every kept fact, gets the map as is. - facts: want === undefined || facts === undefined ? undefined - : [want.kinds, want.defs, want.names].every((xs) => !xs?.length) && (want.scope === "program" || !program) ? facts - : new Map([...facts].filter(([tm, f]) => { - if (want.scope !== "program" && f.spn?.file !== root.file) return false; - const { kind, name } = shape(tm); - return matches(want, kind, f.def, name); - })), - prior: diags, - binder: (fact, v) => v.$ === "Var" ? Bend.pmap_get(fact.ctx, v.i) : null, - show: (fact, ty) => Bend.term_show(Bend.term_lower(ty, fact.dep)), - same: (fact, a, b) => Bend.term_compare("EQ", fact.bok, a, b, fact.dep), - normal: (fact, ty) => Bend.term_snf(fact.bok, ty), - uses: (fact) => Bend.pmap_to_array(fact.us).flatMap(([v, quantity]) => - quantity.$ === "None" ? [] : [{ name: Bend.pmap_get(fact.ctx, v)?.k ?? "", quantity }]), - diag: (d) => ({ - code: rule.id, severity: d.severity ?? "warning", message: d.message, spn: d.spn, - def: d.def ?? d.fact?.def, ctx: d.fact?.ctx, bok: d.fact?.bok ?? book, fixes: d.fixes ?? [], - }), - }, signal); + const out = await rule.run( + { + ...ops, + sources, + root, + options, + facts: want === undefined ? undefined : select(loaded, checked, want, program), + prior: diags, + diag: (d) => ({ + code: rule.id, + severity: d.severity ?? "warning", + message: d.message, + span: d.span, + def: d.def ?? (d.fact && ops.view(d.fact).owner), + fact: d.fact, + fixes: d.fixes ?? [], + }), + }, + signal, + ); signal.throwIfAborted(); if (!Array.isArray(out)) { throw new TypeError("rule " + rule.id + " must return an array of diagnostics"); } const settled = out.map((d): Diag => ({ - ...d, code: rule.id, severity: severity ?? d.severity, spn: span(d.spn), - fixes: d.fixes.map((f) => ({ ...f, edits: f.edits.map((e) => ({ ...e, spn: span(e.spn) })) })), + ...d, + code: rule.id, + severity: severity ?? d.severity, })); - const broken = settled.flatMap((d) => d.fixes).find((f) => f.edits.some(({ spn }, i) => - !validRange(spn.beg, spn.end, spn.file.str.length) || f.edits.some((o, j) => j !== i && clash(f.edits[i], o)))); + const broken = settled + .flatMap((d) => d.fixes) + .find((f) => + f.edits.some( + ({ span }, i) => + !validRange(span.beg, span.end, span.file.text.length) || + f.edits.some((o, j) => j !== i && clash(f.edits[i], o)), + ), + ); if (broken !== undefined) { - throw new TypeError("rule " + rule.id + ": fix \"" + broken.title + "\" has an edit out of bounds, or two that clash"); + throw new TypeError( + "rule " + + rule.id + + ': fix "' + + broken.title + + '" has an edit out of bounds, or two that clash', + ); } const stop = settled.findIndex((d) => d.severity === "error"); diags = [...diags, ...(stop < 0 ? settled : settled.slice(0, stop + 1))]; if (stop >= 0) { - return { ok: false, diags, sources, book, facts }; + return { ok: false, diags, sources, facts, unstable: ops.unstable }; } } - return { ok: true, diags, sources, book, facts }; + return { ok: true, diags, sources, facts, unstable: ops.unstable }; } function validRange(beg: number, end: number, length: number): boolean { @@ -535,67 +420,112 @@ function validRange(beg: number, end: number, length: number): boolean { // Whether two edits to one file cannot both apply: their ranges overlap, // or they insert at the same point. function clash(a: Edit, b: Edit): boolean { - return a.spn.file === b.spn.file && (a.spn.beg < b.spn.end && b.spn.beg < a.spn.end - || a.spn.beg === b.spn.beg && a.spn.end === b.spn.end); + return ( + a.span.file === b.span.file && + ((a.span.beg < b.span.end && b.span.beg < a.span.end) || + (a.span.beg === b.span.beg && a.span.end === b.span.end)) + ); } // `text` with edits applied; their spans are offsets into `text`, and they // do not clash. function apply(text: string, edits: Edit[]): string { - return [...edits].sort((a, b) => b.spn.beg - a.spn.beg || b.spn.end - a.spn.end) - .reduce((s, { spn, text: t }) => s.slice(0, spn.beg) + t + s.slice(spn.end), text); + return [...edits] + .sort((a, b) => b.span.beg - a.span.beg || b.span.end - a.span.end) + .reduce((s, { span, text: t }) => s.slice(0, span.beg) + t + s.slice(span.end), text); } // The text of `file` with the fixes of the given levels applied, in order. // An edit equal to one already taken is merged; a fix with an edit that // clashes with one already taken is skipped and counted. -export function applyFixes(file: SourceFile, diags: Diag[], levels: Applicability[] = ["safe"]): { text: string; skipped: number } { +export function applyFixes( + file: Source, + diags: Diag[], + levels: Applicability[] = ["safe"], +): { text: string; skipped: number } { const kept: Edit[] = []; let skipped = 0; for (const fix of diags.flatMap((d) => d.fixes).filter((f) => levels.includes(f.applicability))) { - const fresh = fix.edits.filter((e) => e.spn.file === file - && !kept.some((k) => k.spn.beg === e.spn.beg && k.spn.end === e.spn.end && k.text === e.text)); + const fresh = fix.edits.filter( + (e) => + e.span.file === file && + !kept.some( + (k) => k.span.beg === e.span.beg && k.span.end === e.span.end && k.text === e.text, + ), + ); if (fresh.some((e) => kept.some((k) => clash(k, e)))) { skipped += 1; } else { kept.push(...fresh); } } - return { text: apply(file.str, kept), skipped }; + return { text: apply(file.text, kept), skipped }; } // bend's own error layout; the head names the severity and the code, and // each fix follows as a unified diff of the lines it touches. export function render(d: Diag): string { - const err = isErr(d.core) ? d.core : Bend.Err(d.bok ?? Bend.book_nil(), d.ctx ?? Bend.ctx_nil(), d.message, undefined, d.spn, d.def); - const fixes = d.fixes.map((fix) => "\n\nFix: " + fix.title + " [" + fix.applicability + "]" - + [...new Set(fix.edits.map((e) => e.spn.file))].map((file) => { - const mine = fix.edits.filter((e) => e.spn.file === file); - const ls = starts(file); - const first = line(ls, Math.min(...mine.map((e) => e.spn.beg))); - const from = ls[first]; - const to = ls[line(ls, Math.max(...mine.map((e) => e.spn.end))) + 1] ?? file.str.length; - const old = file.str.slice(from, to); - const gone = old.match(LINE) ?? []; - const came = apply(old, mine.map((e) => ({ ...e, spn: { ...e.spn, beg: e.spn.beg - from, end: e.spn.end - from } }))).match(LINE) ?? []; - const name = (file as SourceFile).path; - const range = (n: number): string => (n === 0 ? first : first + 1) + "," + n; - const show = (xs: string[], sign: string): string[] => xs.map((l) => - sign + l.replace(/\r?\n$/, "") + (l.endsWith("\n") ? "" : "\n\\ No newline at end of file")); - return "\n--- " + name + "\n+++ " + name + "\n@@ -" + range(gone.length) + " +" + range(came.length) + " @@\n" - + [...show(gone, "-"), ...show(came, "+")].join("\n"); - }).join("")); - return Bend.err_show(err).replace(/^Error:/, HEAD[d.severity] + " [" + d.code + "]:") + fixes.join(""); + const fixes = d.fixes.map( + (fix) => + "\n\nFix: " + + fix.title + + " [" + + fix.applicability + + "]" + + [...new Set(fix.edits.map((e) => e.span.file))] + .map((file) => { + const mine = fix.edits.filter((e) => e.span.file === file); + const ls = starts(file, file.text); + const first = line(ls, Math.min(...mine.map((e) => e.span.beg))); + const from = ls[first]; + const to = ls[line(ls, Math.max(...mine.map((e) => e.span.end))) + 1] ?? file.text.length; + const old = file.text.slice(from, to); + const gone = old.match(LINE) ?? []; + const came = + apply( + old, + mine.map((e) => ({ + ...e, + span: { ...e.span, beg: e.span.beg - from, end: e.span.end - from }, + })), + ).match(LINE) ?? []; + const name = file.path; + const range = (n: number): string => (n === 0 ? first : first + 1) + "," + n; + const show = (xs: string[], sign: string): string[] => + xs.map( + (l) => + sign + + l.replace(/\r?\n$/, "") + + (l.endsWith("\n") ? "" : "\n\\ No newline at end of file"), + ); + return ( + "\n--- " + + name + + "\n+++ " + + name + + "\n@@ -" + + range(gone.length) + + " +" + + range(came.length) + + " @@\n" + + [...show(gone, "-"), ...show(came, "+")].join("\n") + ); + }) + .join(""), + ); + return ( + layout(loaded, d).replace(/^Error:/, HEAD[d.severity] + " [" + d.code + "]:") + fixes.join("") + ); } // The LSP range of a span in a file on disk. -export function position(spn: Span): { start: Position; end: Position } { - const ss = starts(spn.file); +export function position(span: Span): { start: Position; end: Position } { + const ss = starts(span.file, span.file.text); const at = (off: number): Position => { const i = line(ss, off); return { line: i, character: off - ss[i] }; }; - return { start: at(spn.beg), end: at(spn.end) }; + return { start: at(span.beg), end: at(span.end) }; } // A rule written in Bend: a file built on ./lint.bend (see there). It is @@ -603,50 +533,15 @@ export function position(spn: Span): { start: Position; end: Position } { // main with bend's own IO runtime, while effects.js reaches bend-lint through // globalThis.BEND_LINT. Offsets cross as code points. export async function bendRule(file: string): Promise { - const { book, failure } = await check(file, [], new AbortController().signal); + const { book, failure } = await check(loaded, file, [], new AbortController().signal); if (failure !== undefined) { throw new Error(file + " does not check:\n" + render(failure)); } - const value = (k: Name): LTerm | undefined => { - const tld = book.tlds[k]; - return tld?.$ === "Def" && tld.n === 0 && tld.v !== null ? Bend.term_lower(Bend.term_snf(book, tld.v)) : undefined; - }; - const shown = value("id"); - const id = shown === undefined ? undefined : Bend.term_show(shown).match(/^"([^"\\]*)"$/)?.[1]; - // facts(), checked: NoFacts{} gives null, Want{...} a filter; else undefined. - const args = (t: LTerm | undefined, ctr: string): LTerm[] | undefined => - t?.$ === "Ctr" && (t.k === ctr || t.k.endsWith(":" + ctr)) ? t.x : undefined; - const texts = (t: LTerm | undefined): string[] | undefined => { - const out: string[] = []; - for (let at = t; ; at = args(at, "Con")?.[1]) { - const [head] = args(at, "Con") ?? []; - if (args(at, "Nil") !== undefined) { - return out; - } - if (head?.$ !== "Lit" || typeof head.v !== "string") { - return undefined; - } - out.push(head.v); - } - }; - const asked = value("facts"); - const [scope, kinds, defs, names] = args(asked, "Want") ?? []; - const want = args(asked, "NoFacts") !== undefined ? null : { - scope: args(scope, "File") !== undefined ? "file" as const : args(scope, "Program") !== undefined ? "program" as const : undefined, - kinds: texts(kinds), defs: texts(defs), names: texts(names), - }; - if (id === undefined || Comp.io_type(book) === null - || want !== null && [want.scope, want.kinds, want.defs, want.names].includes(undefined)) { - throw new Error(file + " must define id() -> String, facts() -> Lint.Want and main() -> IO(Unit)"); - } - const main = new Function("require", Comp.js_lib(book) + "\n" + Comp.RUNTIME_MAIN - + "\nreturn (args) => { cli_args = args; return io_run(" + Comp.js_sat("main") + "); };")(import.meta.require) as (args: string[]) => number; + const { id, want, main } = compile(loaded, book, file); return { id, - ...(want === null ? {} : { facts: want as FactFilter }), + ...(want === null ? {} : { facts: want }), run: (cx) => { - // Built once per text: the character at each UTF-16 offset (points), - // and the UTF-16 offset of each character (units); both end at the end. const tables = new Map(); const table = (text: string): { points: number[]; units: number[] } => { const known = tables.get(text); @@ -664,58 +559,74 @@ export async function bendRule(file: string): Promise { tables.set(text, { points, units }); return { points, units }; }; - const spot = (spn: Span | undefined): Spot | undefined => { - const t = spn && table(spn.file.str); - return spn && t && { path: (spn.file as SourceFile).path, beg: t.points[spn.beg], end: t.points[spn.end] }; + const spot = (span: Span | undefined): Spot | undefined => { + const t = span && table(span.file.text); + return ( + span && t && { path: span.file.path, beg: t.points[span.beg], end: t.points[span.end] } + ); }; const span = (s: Spot): Span => { const src = cx.sources.find((x) => x.path === s.path); if (src === undefined) { - throw new Error("rule " + id + " reported a span in " + s.path + ", which is not in the book"); + throw new Error( + "rule " + id + " reported a span in " + s.path + ", which is not in the book", + ); } const { units } = table(src.text); const at = (n: number): number => units[Math.min(n, units.length - 1)]; - return { file: src.file, beg: at(s.beg), end: at(s.end) }; + return { file: src, beg: at(s.beg), end: at(s.end) }; }; - const facts = [...cx.facts?.values() ?? []]; - let given = 0; // facts handed to the rule so far - const terms: HTerm[] = []; + const facts = cx.facts ?? []; + let given = 0; + const types: Type[] = []; const pick = (xs: T[], i: number, what: string): T => { if (xs[i] === undefined) { - throw new Error("rule " + id + " asked about " + what + " " + i + ", which it was not given"); + throw new Error( + "rule " + id + " asked about " + what + " " + i + ", which it was not given", + ); } return xs[i]; }; - const keep = (t: HTerm): number => terms.push(t) - 1; + const keep = (t: Type): number => types.push(t) - 1; const found: Reported[][] = []; shared.BEND_LINT = { input: () => { - const odd = Object.entries(cx.options).find(([, v]) => typeof v === "number" && !(Number.isInteger(v) && v >= 0 && v <= 0xffffffff)); + const odd = Object.entries(cx.options).find( + ([, v]) => typeof v === "number" && !(Number.isInteger(v) && v >= 0 && v <= 0xffffffff), + ); if (odd !== undefined) { - throw new Error("rule " + id + ": option " + odd[0] + " must be a whole number from 0 to 4294967295 (a U32)"); + throw new Error( + "rule " + + id + + ": option " + + odd[0] + + " must be a whole number from 0 to 4294967295 (a U32)", + ); } - return { sources: cx.sources.map((s) => ({ path: s.path, text: s.text, root: s.root })), options: cx.options }; + return { + sources: cx.sources.map((s) => ({ path: s.path, text: s.text, root: s.root })), + options: cx.options, + }; }, - next: () => given < facts.length ? given++ : undefined, + next: () => (given < facts.length ? given++ : undefined), report: (diags) => void found.push(diags), view: (i) => { - const f = pick(facts, i, "fact"); - const t = Bend.term_strip(f.tm); - return { owner: f.def, inst: f.inst, ...shape(f.tm), quantity: f.qt.$, span: spot(f.spn), inner: spot(cx.span(t.s)) }; + const { span, inner, ...rest } = cx.view(pick(facts, i, "fact")); + return { ...rest, span: spot(span), inner: spot(inner) }; }, - type: (i) => keep(pick(facts, i, "fact").ty), + type: (i) => keep(cx.type(pick(facts, i, "fact"))), binder: (i) => { - const f = pick(facts, i, "fact"); - const ann = cx.binder(f, Bend.term_strip(f.tm)); - return ann === null ? undefined : keep(ann.T); + const t = cx.binder(pick(facts, i, "fact")); + return t === undefined ? undefined : keep(t); }, - same: (i, a, b) => cx.same(pick(facts, i, "fact"), pick(terms, a, "term"), pick(terms, b, "term")), - show: (i, t) => cx.show(pick(facts, i, "fact"), pick(terms, t, "term")), - normal: (i, t) => keep(cx.normal(pick(facts, i, "fact"), pick(terms, t, "term"))), - uses: (i) => cx.uses(pick(facts, i, "fact")).map((u) => ({ name: u.name, quantity: u.quantity.$ })), + same: (i, a, b) => + cx.same(pick(facts, i, "fact"), pick(types, a, "term"), pick(types, b, "term")), + show: (i, t) => cx.show(pick(facts, i, "fact"), pick(types, t, "term")), + normal: (i, t) => keep(cx.normal(pick(facts, i, "fact"), pick(types, t, "term"))), + uses: (i) => cx.uses(pick(facts, i, "fact")), text: (s) => { - const spn = span(s); - return spn.file.str.slice(spn.beg, spn.end); + const { file, beg, end } = span(s); + return file.text.slice(beg, end); }, }; let code: number; @@ -725,88 +636,45 @@ export async function bendRule(file: string): Promise { shared.BEND_LINT = undefined; } if (code !== 0 || found.length !== 1) { - throw new Error("rule " + id + " exited with " + code + " after " + found.length + " reports; it must report once"); + throw new Error( + "rule " + + id + + " exited with " + + code + + " after " + + found.length + + " reports; it must report once", + ); } - return found[0].map((d) => cx.diag({ - message: d.message, severity: d.severity, spn: d.span && span(d.span), - fixes: d.fixes.map((f) => ({ ...f, edits: f.edits.map((e) => { - const spn = span(e.span); - if (!validRange(e.span.beg, e.span.end, table(spn.file.str).units.length - 1)) { - throw new TypeError("rule " + id + ": fix \"" + f.title + "\" has an edit out of bounds"); - } - return { spn, text: e.text }; - }) })), - })); + return found[0].map((d) => + cx.diag({ + message: d.message, + severity: d.severity, + span: d.span && span(d.span), + fixes: d.fixes.map((f) => ({ + ...f, + edits: f.edits.map((e) => { + const at = span(e.span); + if (!validRange(e.span.beg, e.span.end, table(at.file.text).units.length - 1)) { + throw new TypeError( + "rule " + id + ': fix "' + f.title + '" has an edit out of bounds', + ); + } + return { span: at, text: e.text }; + }), + })), + }), + ); }, }; } -// Finds bend2 (see bendDir), patches its PATCHED files as Bun loads them, -// then checks what the wrappers record for `x` in a tiny program. -async function instrument(given: string | undefined): Promise<{ Bend: Bend; Comp: Comp; Main: Main; BEND2: string }> { - const dir = await bendDir(given); - // Bun.plugin is process-wide: match this checkout's files by full path. - const patched = new Map(PATCHED.map((f) => [path.join(dir, f), patch(f, fs.readFileSync(path.join(dir, f), "utf8"))])); - const exact = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll("/", "[\\\\/]"); - const names = PATCHED.map((f) => f.replace(/\.ts$/, "")).join("|"); - const filter = new RegExp("^" + exact + "[\\\\/](" + names + ")\\.ts$", process.platform === "win32" ? "i" : ""); - Bun.plugin({ - name: "bend-lint", - setup: (build) => { - build.onLoad({ filter }, (args) => { - const contents = patched.get(fs.realpathSync(args.path)); - if (contents === undefined) { - throw new DriftError("bend-lint matched " + args.path + " but did not patch it"); - } - return { contents, loader: "ts" }; - }); - }, - }); - // Imported, main.ts also registers bend's own loader for `import "x.bend"`. - const [B, C, M]: [Bend & { [MARK]?: number }, Comp & { [MARK]?: number }, Main & { [MARK]?: number }] = await Promise.all([ - import(url.pathToFileURL(path.join(dir, "bend.ts")).href), - import(url.pathToFileURL(path.join(dir, "comp.ts")).href), - import(url.pathToFileURL(path.join(dir, "main.ts")).href), - ]); - if (B[MARK] !== 1 || C[MARK] !== 1 || M[MARK] !== 1) { - throw new DriftError("bend2 was loaded before bend-lint could patch it; import bend-lint first"); - } - const sample = B.book_nil(); - const seen: Fact[] = []; - hook.see = (bok, tm, ty, ctx, dep, def, spn, qt, us) => void seen.push({ tm, ty, bok, ctx, dep, def, spn, qt, us, inst: false }); - try { - B.parse_book(sample, "/", SAMPLE, "", {}); - B.book_valid(sample); - } finally { - hook.see = undefined; - } - const xs = seen.filter((f) => f.def === "id" && B.term_strip(f.tm).$ === "Var"); - const kind = (f: Fact, t: HTerm) => (B.term_wnf(f.bok, t) as { k?: string }).k; - const tagged = (x: unknown, tags: string[]) => tags.includes((x as { $?: string } | null)?.$ ?? ""); - const wrong = xs.length < 2 ? ["facts (one from each wrapper)"] : xs.flatMap((x) => { - if (typeof x.dep !== "number" || !tagged(x.ctx, ["Emp", "Bin"]) || !tagged(x.us, ["Emp", "Bin"]) || !tagged(x.qt, ["None", "Lone", "Many"])) { - return ["kinds of values"]; - } - const v = B.term_strip(x.tm) as Extract; - const bound = B.pmap_get(x.ctx, v.i); - return ([ - ["type", kind(x, x.ty) === "N"], - ["depth", x.dep === 1], - ["scope", bound?.k === "x" && kind(x, bound.T) === "N"], - ["quantity", x.qt.$ === "Lone"], - ["uses", B.pmap_get(x.us, v.i)?.$ === "Lone"], - ["span", x.spn !== undefined && x.spn.file.str.slice(x.spn.beg, x.spn.end) === "x"], - ] as const).flatMap(([what, ok]) => ok ? [] : [what]); - }); - if (wrong.length > 0) { - throw new DriftError("self-check failed: the patched bend.ts recorded the wrong " + [...new Set(wrong)].join(", ") - + " for `x` in `def id(x: N) -> N: x`; update tools/bend-lint/src/patch.ts"); - } - return { Bend: B, Comp: C, Main: M, BEND2: dir }; -} - async function cli(argv: string[]): Promise { - const { values, positionals } = util.parseArgs({ args: argv, options: OPTIONS, allowPositionals: true }); + const { values, positionals } = util.parseArgs({ + args: argv, + options: OPTIONS, + allowPositionals: true, + }); if (values.help) { console.log(USAGE); return 0; @@ -814,36 +682,63 @@ async function cli(argv: string[]): Promise { if (positionals.length !== 1) { throw new Error("give one .bend file\n" + USAGE); } - const modules = await Promise.all((values.rules ?? []).map(async (file): Promise => { - if (file.endsWith(".bend")) { - return [await bendRule(file)]; - } - const { rules } = await import(url.pathToFileURL(path.resolve(file)).href); - if (!Array.isArray(rules)) { - throw new Error(file + " must export `rules`, an array of rules"); - } - return rules; - })); - const res = await lint(positionals[0], modules.flat(), { config: values.config === undefined ? undefined : readConfig(values.config) }); + const modules = await Promise.all( + (values.rules ?? []).map(async (file): Promise => { + if (file.endsWith(".bend")) { + return [await bendRule(file)]; + } + const { rules } = await import(url.pathToFileURL(path.resolve(file)).href); + if (!Array.isArray(rules)) { + throw new Error(file + " must export `rules`, an array of rules"); + } + return rules; + }), + ); + const res = await lint(positionals[0], modules.flat(), { + config: values.config === undefined ? undefined : readConfig(values.config), + }); const root = res.sources.find((s) => s.root); const levels = FIXES.find(([flag]) => values[flag])?.[1]; - const fixed = levels !== undefined && res.ok && root !== undefined ? applyFixes(root.file, res.diags, levels) : undefined; - const where = (spn: Span) => ({ path: (spn.file as SourceFile).path, range: position(spn) }); - console.log(values.json - ? JSON.stringify({ - ok: res.ok, - findings: res.diags.map((d) => ({ - code: d.code, severity: d.severity, message: d.message, def: d.def, ...(d.spn && where(d.spn)), - fixes: d.fixes.map((f) => ({ ...f, edits: f.edits.map((e) => ({ ...where(e.spn), text: e.text })) })), - })), - }, null, 2) - : [...res.diags.map(render), res.ok ? "bend-lint: " + res.diags.length + " finding(s)" : "bend-lint: FAIL"].join("\n\n")); + const fixed = + levels !== undefined && res.ok && root !== undefined + ? applyFixes(root, res.diags, levels) + : undefined; + const where = (span: Span) => ({ path: span.file.path, range: position(span) }); + console.log( + values.json + ? JSON.stringify( + { + ok: res.ok, + findings: res.diags.map((d) => ({ + code: d.code, + severity: d.severity, + message: d.message, + def: d.def, + ...(d.span && where(d.span)), + fixes: d.fixes.map((f) => ({ + ...f, + edits: f.edits.map((e) => ({ ...where(e.span), text: e.text })), + })), + })), + }, + null, + 2, + ) + : [ + ...res.diags.map(render), + res.ok ? "bend-lint: " + res.diags.length + " finding(s)" : "bend-lint: FAIL", + ].join("\n\n"), + ); if (root !== undefined && fixed !== undefined && fixed.text !== root.text) { fs.writeFileSync(root.path, fixed.text); console.error("bend-lint: fixed " + root.path); } if (fixed !== undefined && fixed.skipped > 0) { - console.error("bend-lint: skipped " + fixed.skipped + " fix(es) that clash with earlier ones; run the fix again to apply them"); + console.error( + "bend-lint: skipped " + + fixed.skipped + + " fix(es) that clash with earlier ones; run the fix again to apply them", + ); } return res.ok ? 0 : 1; } @@ -857,12 +752,20 @@ function fail(e: unknown): never { // ============ // The CLI's --bend is read here, before bend loads; a library uses $BEND_DIR. -const given = import.meta.main ? util.parseArgs({ args: process.argv.slice(2), options: OPTIONS, allowPositionals: true, strict: false }).values.bend : undefined; - -const loaded = await instrument(typeof given === "string" ? given : undefined).catch((e: unknown) => import.meta.main ? fail(e) : Promise.reject(e)); - -export const { Bend, Comp, BEND2 }: { Bend: Bend; Comp: Comp; BEND2: string } = loaded; -const { Main } = loaded; +const given = import.meta.main + ? util.parseArgs({ + args: process.argv.slice(2), + options: OPTIONS, + allowPositionals: true, + strict: false, + }).values.bend + : undefined; + +const loaded = await load(typeof given === "string" ? given : undefined).catch((e: unknown) => + import.meta.main ? fail(e) : Promise.reject(e), +); + +export const { BEND2 } = loaded; if (import.meta.main) { process.exit(await cli(process.argv.slice(2)).catch(fail)); diff --git a/tools/bend-lint/src/patch.ts b/tools/bend-lint/src/patch.ts deleted file mode 100644 index 67bc2d9cfa..0000000000 --- a/tools/bend-lint/src/patch.ts +++ /dev/null @@ -1,303 +0,0 @@ -// What bend-lint changes in bend2 as Bun loads it; the files on disk never -// change. In bend.ts, term_infer and term_check are renamed and replaced by -// the wrappers below, which tell `hook.see` what they return; and fs and -// path come from this module, in bend.ts and main.ts: bend.ts builds paths -// with "/" (path.posix), so here real paths use "/" and a Windows drive -// letter is a root; on POSIX they behave as node's. comp.ts exports -// RUNTIME_MAIN and js_sat, so a rule written in Bend is compiled once and -// run many times. main.ts exports how `bend` reads a book (book_read) and -// words a failure (book_err, Check_Fail), so bend-lint checks a file -// exactly as bend does. -// Each text edit must match exactly once, and each name a tail exports must -// be declared once. The wrappers pass every argument through, so bend -// computes what it would without them; what they record is checked at load -// (see instrument in lint.ts). - -import * as nodeFs from "node:fs"; -import * as os from "node:os"; -import * as nodePath from "node:path"; -import * as url from "node:url"; - -import type { Book, Ctx, HTerm, LTerm, Name, Quant, Span, Uses } from "bend2/bend.ts"; -import type * as BendModule from "bend2/bend.ts"; - -// Types -// ===== - -// A file's patch: exact edits, a tail appended to the file, and names it -// must declare once and export (the tail exports those it does not). -type Patch = { edits: Array<[string, string]>; tail: string; exports: string[] }; - -// What the checker reports for each checked term: what it was checked or -// inferred as, where, and how it was used. -export type See = (bok: Book, tm: LTerm, ty: HTerm, ctx: Ctx, dep: number, def: Name, spn: Span | undefined, qt: Quant, us: Uses) => void; - -// An HTTP GET. -export type Get = (url: string) => Promise; - -// How bendDir reaches the network, runs `bend`, where it caches, and where -// the bend repo would be; tests give their own. run gives a command's -// stdout, or undefined if it fails. -export type FindOptions = { get?: Get; run?: (cmd: string[]) => string | undefined; cache?: string; repo?: string }; - -// Constants -// ========= - -export class DriftError extends Error { - override name = "DriftError"; -} - -const HERE = url.fileURLToPath(new URL(".", import.meta.url)); -const DRIVE = /^[A-Za-z]:(?=\/)/; -const SHIM = JSON.stringify(url.pathToFileURL(nodePath.join(HERE, "patch.ts")).href); - -export const MARK = "BEND_LINT_PATCH"; - -const GIT = "https://github.com/bendlang/bend.git/info/refs?service=git-upload-pack"; -const RAW = "https://raw.githubusercontent.com/bendlang/bend/"; -const RELEASE = /^v\d+\.\d+\.\d+$/; -const DAY = 24 * 60 * 60 * 1000; -const EFF = /^effs\/[\w.-]+$/; - -// Downloaded bends, one folder per release. -const CACHE = nodePath.join(process.env.XDG_CACHE_HOME - ?? (process.platform === "win32" ? process.env.LOCALAPPDATA ?? nodePath.join(os.homedir(), "AppData", "Local") : nodePath.join(os.homedir(), ".cache")), - "bend-lint"); - -const SHIMMED: Array<[string, string]> = [ - ['import * as fs from "node:fs";', "import { fs } from " + SHIM + ";"], - ['import * as path from "node:path";', "import { path } from " + SHIM + ";"], -]; - -const PATCHES: Record = { - "bend.ts": { - edits: [ - ...SHIMMED, - ["export function term_infer(", "function unseen_term_infer("], - ["export function term_check(", "function unseen_term_check("], - ], - tail: "import { seeInfer, seeCheck } from " + SHIM + ";\n" - + "export const term_infer = seeInfer(unseen_term_infer);\n" - + "export const term_check = seeCheck(unseen_term_check);\n", - exports: [], - }, - "comp.ts": { edits: [], tail: "", exports: ["RUNTIME_MAIN", "js_sat"] }, - "main.ts": { edits: SHIMMED, tail: "", exports: ["book_read", "book_err", "Check_Fail"] }, -}; - -// The bend2 files bend-lint patches and imports. -export const PATCHED = Object.keys(PATCHES); - -// Where the wrappers report, set by lint.ts for one check at a time. -export const hook: { see?: See } = {}; - -// Text an editor holds unsaved, by real path, set by lint.ts for one check -// at a time. bend.ts reads it in place of the file on disk. -export const unsaved = new Map(); - -// fs and path for bend.ts. -export const fs = { - ...nodeFs, - realpathSync: (p: nodeFs.PathLike): string => slash(nodeFs.realpathSync(p)), - readFileSync: ((p: nodeFs.PathOrFileDescriptor, ...rest: unknown[]) => - held(p) ?? (nodeFs.readFileSync as (...a: unknown[]) => unknown)(p, ...rest)) as typeof nodeFs.readFileSync, -}; - -export const path = { - ...nodePath, - join: (...ps: string[]): string => slash(nodePath.join(...ps)), - resolve: (...ps: string[]): string => slash(nodePath.resolve(...ps)), - posix: { ...nodePath.posix, resolve: (...ps: string[]): string => resolve(process.cwd(), ...ps), relative }, -}; - -// Functions -// ========= - -function held(p: nodeFs.PathOrFileDescriptor): string | undefined { - return typeof p === "string" && unsaved.size > 0 && nodeFs.existsSync(p) ? unsaved.get(slash(nodeFs.realpathSync(p))) : undefined; -} - -function slash(p: string): string { - return p.split(nodePath.sep).join("/"); -} - -// path.posix.resolve from `cwd`, where a drive letter is a root. -export function resolve(cwd: string, ...ps: string[]): string { - const all = [cwd, ...ps].map(slash); - const drive = all.filter((p) => DRIVE.test(p)).at(-1)?.slice(0, 2) ?? ""; - return drive + nodePath.posix.resolve(...all.map((p) => p.replace(DRIVE, ""))); -} - -export function relative(from: string, to: string): string { - return nodePath.posix.relative(slash(from).replace(DRIVE, ""), slash(to).replace(DRIVE, "")); -} - -// term_infer and term_check as bend.ts calls them: every argument passes -// through, and each result is also reported to hook.see. tsc checks the -// names against bend.ts's signatures; a function that no longer takes the -// arguments read here stops loading. -export function seeInfer(f: typeof BendModule.term_infer): typeof BendModule.term_infer { - arity(f, 6, "term_infer"); - return (...args) => { - const r = f(...args); - const [book, lhs, tm, qt, ctx, d] = args; - hook.see?.(book, r.tm, r.ty, ctx, d, lhs.def, tm.s, qt, r.us); - return r; - }; -} - -export function seeCheck(f: typeof BendModule.term_check): typeof BendModule.term_check { - arity(f, 7, "term_check"); - return (...args) => { - const r = f(...args); - const [book, lhs, tm, qt, ty, ctx, d] = args; - hook.see?.(book, r.tm, ty, ctx, d, lhs.def, tm.s, qt, r.us); - return r; - }; -} - -// f.length counts the parameters before the first default. -function arity(f: (...args: never[]) => unknown, n: number, name: string): void { - if (f.length !== n) { - throw new DriftError(name + " takes " + f.length + " parameters, not " + n + "; update seeInfer and seeCheck in tools/bend-lint/src/patch.ts"); - } -} - -// `file` is one of PATCHED. -export function patch(file: string, src: string): string { - const { edits, tail, exports } = PATCHES[file]; - const drift = (what: string, n: number): never => { - throw new DriftError("cannot patch bend2/" + file + ": found " + n + " of " + what - + ", expected 1. Update PATCHES in tools/bend-lint/src/patch.ts."); - }; - const edited = edits.reduce((out, [at, to]) => { - const n = out.split(at).length - 1; - return n === 1 ? out.replace(at, () => to) : drift(JSON.stringify(at), n); - }, src); - const missing = exports.filter((name) => { - const n = edited.match(new RegExp("^(?:export )?(?:async function|function|class|const|let) " + name + "\\b", "gm"))?.length ?? 0; - return n === 1 ? !new RegExp("^export (?:async function|function|class|const|let) " + name + "\\b", "m").test(edited) : drift("a declaration of " + name, n); - }); - return edited + "\n" + tail + (missing.length === 0 ? "" : "export { " + missing.join(", ") + " };\n") - + "export const " + MARK + " = 1;\n"; -} - -// The bend2 folder to load: `given` (from --bend), else $BEND_DIR, else -// the bend repo around tools/bend-lint, if there is one, else a release -// from GitHub: the installed bend's version, or the newest. A bend -// checkout works too, for its bend2 folder. -export async function bendDir(given: string | undefined, { get = download, run = spawn, cache = CACHE, repo }: FindOptions = {}): Promise { - const chosen = given ?? process.env.BEND_DIR; - const dir = path.resolve(chosen ?? repo ?? path.join(HERE, "..", "..", "..", "bend2")); - const found = [path.join(dir, "bend2"), dir].find((d) => nodeFs.existsSync(path.join(d, "bend.ts"))); - if (found !== undefined) { - return fs.realpathSync(found); - } - if (chosen !== undefined) { - throw new Error("no bend2 at " + dir + " (it needs bend.ts); give a bend checkout with --bend or BEND_DIR"); - } - const tag = installedTag(run) ?? await latestTag(cache, get); - const fresh = !nodeFs.existsSync(nodePath.join(cache, tag, "bend2")); - const got = await fetchBend(tag, cache, get).catch((e: unknown) => { - throw new Error("could not download bend " + tag + " (" + (e instanceof Error ? e.message : String(e)) - + "); give a bend checkout with --bend or BEND_DIR"); - }); - if (fresh) { - console.error("bend-lint: downloaded bend " + tag + " to " + got); - } - return fs.realpathSync(got); -} - -// fetch, given up after 30 seconds. -function download(url: string): Promise { - return fetch(url, { signal: AbortSignal.timeout(30_000) }); -} - -// A command's stdout, or undefined if it does not run or fails. -function spawn(cmd: string[]): string | undefined { - try { - const r = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "ignore" }); - return r.exitCode === 0 ? r.stdout.toString() : undefined; - } catch { - return undefined; - } -} - -// The installed bend's release ("v2.0.36"), or undefined if `bend version` -// does not run. -export function installedTag(run: (cmd: string[]) => string | undefined = spawn): string | undefined { - const version = run(["bend", "version"])?.match(/^bend (\d+\.\d+\.\d+)\b/)?.[1]; - return version === undefined ? undefined : "v" + version; -} - -// The newest release, from git's list of refs (GitHub's API limits calls). -// It asks at most once a day; offline, it takes the newest one cached. -export async function latestTag(cache: string = CACHE, get: Get = download): Promise { - const note = nodePath.join(cache, "latest.json"); - let known: { tag?: unknown; at?: unknown } | undefined; - try { - known = JSON.parse(nodeFs.readFileSync(note, "utf8")); - } catch { - known = undefined; // no note yet, or a damaged one - } - if (typeof known?.tag === "string" && RELEASE.test(known.tag) && typeof known.at === "number" && Date.now() - known.at < DAY) { - return known.tag; - } - const newest = (tags: string[]): string | undefined => tags.filter((t) => RELEASE.test(t)) - .map((t) => t.slice(1).split(".").map(Number)).sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2]) - .map((v) => "v" + v.join("."))[0]; - const listed = await get(GIT).then((res) => res.ok ? res.text() : Promise.reject(new Error("GitHub answered " + res.status))) - .then((refs) => newest([...refs.matchAll(/refs\/tags\/(v[\d.]+)$/gm)].map((m) => m[1])), (e: unknown) => { - const cached = newest(nodeFs.existsSync(cache) ? nodeFs.readdirSync(cache) : []); - return cached ?? Promise.reject(new Error("cannot list bend's releases (" + (e instanceof Error ? e.message : String(e)) - + "); give a bend checkout with --bend or BEND_DIR")); - }); - if (listed === undefined) { - throw new Error("bend has no release tags; give a bend checkout with --bend or BEND_DIR"); - } - nodeFs.mkdirSync(cache, { recursive: true }); - nodeFs.writeFileSync(note, JSON.stringify({ tag: listed, at: Date.now() })); - return listed; -} - -// bend2 at a release: the PATCHED files, safe.ts (main.ts imports it), -// base.bend, and the effs/ files base.bend imports, kept in -// //bend2. A download goes to a temporary folder first, so the -// cache never holds a partial one; a cached folder without main.ts (from an -// older bend-lint) is downloaded again. -export async function fetchBend(tag: string, cache: string = CACHE, get: Get = download): Promise { - if (!RELEASE.test(tag)) { - throw new Error("not a bend release: " + tag); - } - const dir = nodePath.join(cache, tag, "bend2"); - const whole = (): boolean => [...PATCHED, "safe.ts", "base.bend"].every((f) => nodeFs.existsSync(nodePath.join(dir, f))); - if (whole()) { - return dir; - } - const text = (file: string): Promise<[string, string]> => get(RAW + tag + "/bend2/" + file) - .then((res) => res.ok ? res.text() : Promise.reject(new Error("GitHub answered " + res.status + " for bend2/" + file))) - .then((body) => [file, body]); - const base = await text("base.bend"); - const effs = [...new Set([...base[1].matchAll(/^\s*import "\.\/(effs\/[^"]+)"/gm)].map((m) => m[1]))]; - const odd = effs.find((f) => !EFF.test(f)); - if (odd !== undefined) { - throw new Error("base.bend imports " + odd + ", which is not a plain file in effs/"); - } - const files = [base, ...await Promise.all([...PATCHED, "safe.ts", ...effs].map(text))]; - nodeFs.mkdirSync(nodePath.join(cache, tag), { recursive: true }); - const part = nodeFs.mkdtempSync(nodePath.join(cache, tag, ".part-")); - files.forEach(([file, body]) => { - nodeFs.mkdirSync(nodePath.dirname(nodePath.join(part, file)), { recursive: true }); - nodeFs.writeFileSync(nodePath.join(part, file), body); - }); - try { - nodeFs.rmSync(dir, { recursive: true, force: true }); - nodeFs.renameSync(part, dir); - } catch (e) { - nodeFs.rmSync(part, { recursive: true, force: true }); - if (!whole()) { - throw e; - } - } - return dir; -} diff --git a/tools/bend-lint/src/sample.bend b/tools/bend-lint/src/sample.bend new file mode 100644 index 0000000000..6f409a6469 --- /dev/null +++ b/tools/bend-lint/src/sample.bend @@ -0,0 +1,8 @@ +type N is Data: + Z{} + +def id(x: N) -> N: + x + +def main() -> N: + id(Z{}) diff --git a/tools/bend-lint/src/seam.ts b/tools/bend-lint/src/seam.ts new file mode 100644 index 0000000000..e2b20a155f --- /dev/null +++ b/tools/bend-lint/src/seam.ts @@ -0,0 +1,999 @@ +// Everything bend-lint knows about bend2's internals: finding (or +// downloading) bend2, patching it as Bun loads it, and every call into it. +// The docs say what the patch does and how drift stops it. Each text edit +// must match exactly once; the wrappers pass every argument through, so +// bend computes what it would without them. + +import * as nodeFs from "node:fs"; +import * as os from "node:os"; +import * as nodePath from "node:path"; +import * as url from "node:url"; + +import type { Book, Ctx, Err, HTerm, LTerm, Name, Quant, Uses } from "bend2/bend.ts"; +import type { Span as BendSpan } from "bend2/bend.ts"; +import type * as BendModule from "bend2/bend.ts"; +import type * as CompModule from "bend2/comp.ts"; +import type { + Diag, + Fact, + FactFilter, + Quantity, + RuleContext, + Source, + Span, + Type, + View, +} from "./lint.ts"; + +// Types +// ===== + +// A file's patch: exact edits, a tail appended to the file, and names it +// must declare once and export (the tail exports those it does not). +type Patch = { edits: Array<[string, string]>; tail: string; exports: string[] }; + +export type Get = (url: string) => Promise; + +// How bendDir reaches the network, runs `bend`, where it caches, and where +// the bend repo would be; tests give their own. run gives a command's +// stdout, or undefined if it fails. +export type FindOptions = { + get?: Get; + run?: (cmd: string[]) => string | undefined; + cache?: string; + repo?: string; +}; + +// comp.ts as the patch exports it. +type Comp = typeof CompModule & { RUNTIME_MAIN: string; js_sat(k: Name): string }; + +// What bend-lint uses of main.ts, as the patch exports it: book_read throws +// a Check_Fail holding why the check failed. +type Main = { + book_read(file: string, base?: Book, seen?: Map): Promise; + book_err(e: unknown): string; + Check_Fail: new (why: unknown) => { why: unknown }; +}; + +// bend2 as load gives it: the patched modules and their folder. +export type Loaded = { Bend: typeof BendModule; Comp: Comp; Main: Main; BEND2: string }; + +// A source as bend.ts spans point at it. +type File = { str: string; ns: string; al: Record; path: string }; + +type Mapper = (s: BendSpan | undefined) => BendSpan | undefined; + +// A fact as the checker gives it: `tm` checked (or inferred) as `ty` at +// depth `dep` in `ctx`, in def `def`, demanded `qt` times, using the +// variables in `us`, in book `bok` (a template body has its own). `inst` +// marks an instance's fact; `map` moves the run's spans to the files on disk. +export type Raw = { + tm: LTerm; + ty: HTerm; + bok: Book; + ctx: Ctx; + dep: number; + def: Name; + qt: Quant; + us: Uses; + inst: boolean; + spn?: BendSpan; + map: Mapper; +}; + +// What the wrappers report for each checked term. +type Report = Omit; + +// bend2's own objects, for code that accepts to break when bend2 changes. +export type Unstable = { Bend: typeof BendModule; book: Book; raw: (fact: Fact) => Raw }; + +// The rule context's operations that ask bend2. +export type Operations = Omit< + RuleContext, + "sources" | "root" | "options" | "facts" | "prior" | "diag" +>; + +export type Checked = { book: Book; sources: Source[]; facts?: Fact[]; failure?: Diag }; + +// A rule written in Bend, checked and compiled: what its id(), facts() and +// main() give. +type Compiled = { id: string; want: FactFilter | null; main: (args: string[]) => number }; + +// Constants +// ========= + +export const DriftError: new (message?: string, options?: ErrorOptions) => Error = function ( + message?: string, + options?: ErrorOptions, +): Error { + return Object.setPrototypeOf( + Object.assign(new Error(message, options), { name: "DriftError" }), + new.target!.prototype, + ); +} as ErrorConstructor; + +const HERE = url.fileURLToPath(new URL(".", import.meta.url)); +const DRIVE = /^[A-Za-z]:(?=\/)/; +const SHIM = JSON.stringify(url.pathToFileURL(nodePath.join(HERE, "seam.ts")).href); +const SAMPLE = nodePath.join(HERE, "sample.bend"); + +export const MARK = "BEND_LINT_PATCH"; + +const GIT = "https://github.com/bendlang/bend.git/info/refs?service=git-upload-pack"; +const RAW = "https://raw.githubusercontent.com/bendlang/bend/"; +const RELEASE = /^v\d+\.\d+\.\d+$/; +const DAY = 24 * 60 * 60 * 1000; +const EFF = /^effs\/[\w.-]+$/; +const GIVE = "give a bend checkout with --bend or BEND_DIR"; + +// Downloaded bends, one folder per release. +const CACHE = nodePath.join( + process.env.XDG_CACHE_HOME ?? + (process.platform === "win32" + ? (process.env.LOCALAPPDATA ?? nodePath.join(os.homedir(), "AppData", "Local")) + : nodePath.join(os.homedir(), ".cache")), + "bend-lint", +); + +const SHIMMED: Array<[string, string]> = [ + ['import * as fs from "node:fs";', "import { fs } from " + SHIM + ";"], + ['import * as path from "node:path";', "import { path } from " + SHIM + ";"], +]; + +const PATCHES: Record = { + "bend.ts": { + edits: [ + ...SHIMMED, + ["export function term_infer(", "function unseen_term_infer("], + ["export function term_check(", "function unseen_term_check("], + ], + tail: `import { seeInfer, seeCheck } from ${SHIM};\nexport const term_infer = seeInfer(unseen_term_infer);\nexport const term_check = seeCheck(unseen_term_check);\n`, + exports: [], + }, + "comp.ts": { edits: [], tail: "", exports: ["RUNTIME_MAIN", "js_sat"] }, + "main.ts": { edits: SHIMMED, tail: "", exports: ["book_read", "book_err", "Check_Fail"] }, +}; + +// The bend2 files bend-lint patches and imports. +export const PATCHED = Object.keys(PATCHES); + +// Where the wrappers report, set for one check at a time. +export const hook: { see?: (report: Report) => void } = {}; + +// Text an editor holds unsaved, by real path, set for one check at a time. +// bend.ts reads it in place of the file on disk. +export const unsaved = new Map(); + +// Checked base.bend books, by the text of base.bend: Base is checked once +// per process, and again only if base.bend changes. +const BASES = new Map>(); + +// The check running now, or done; the next check waits for it. +let queue: Promise = Promise.resolve(); + +const STARTS = new WeakMap(); + +// Each checked source's File, and back. +const FILES = new WeakMap(); +const SOURCES = new WeakMap(); + +const QUANTITY: Record = { None: "erased", Lone: "once", Many: "many" }; + +// Binders are explicit in checked terms, so Var.v cells are not followed. +// A new term kind is a type error here, and a DriftError when walked. +const CHILDREN: { [K in LTerm["$"]]: (tm: Extract) => LTerm[] } = { + Let: (tm) => [...tm.v, tm.f], + Lam: (tm) => [tm.f], + Sub: (tm) => [tm.f], + App: (tm) => [tm.f, tm.x], + Ctr: (tm) => tm.x, + ADT: (tm) => tm.x, + Mat: (tm) => [tm.h, tm.m], + Rwt: (tm) => [tm.e, tm.p, tm.f], + Typ: (tm) => [tm.g], + Min: (tm) => [tm.a, tm.b], + All: (tm) => [tm.A, tm.B], + Eql: (tm) => [tm.a, tm.b, tm.T], + Ann: (tm) => [tm.x, tm.T], + Var: () => [], + Ref: () => [], + Qnt: () => [], + Qua: () => [], + Lit: () => [], + Efq: () => [], + Rfl: () => [], + Hol: () => [], +}; + +// Functions +// ========= + +const slash = (p: string): string => p.split(nodePath.sep).join("/"); + +const held = (p: nodeFs.PathOrFileDescriptor): string | undefined => + typeof p === "string" && unsaved.size > 0 && nodeFs.existsSync(p) + ? unsaved.get(slash(nodeFs.realpathSync(p))) + : undefined; + +// path.posix.resolve from `cwd`, where a drive letter is a root. +export const resolve = (cwd: string, ...ps: string[]): string => { + const all = [cwd, ...ps].map(slash); + const drive = + all + .filter((p) => DRIVE.test(p)) + .at(-1) + ?.slice(0, 2) ?? ""; + return drive + nodePath.posix.resolve(...all.map((p) => p.replace(DRIVE, ""))); +}; + +export const relative = (from: string, to: string): string => + nodePath.posix.relative(slash(from).replace(DRIVE, ""), slash(to).replace(DRIVE, "")); + +// fs and path for bend.ts and main.ts. +export const fs = { + ...nodeFs, + realpathSync: (p: nodeFs.PathLike): string => slash(nodeFs.realpathSync(p)), + readFileSync: ((p: nodeFs.PathOrFileDescriptor, ...rest: unknown[]) => + held(p) ?? + (nodeFs.readFileSync as (...a: unknown[]) => unknown)( + p, + ...rest, + )) as typeof nodeFs.readFileSync, +}; + +export const path = { + ...nodePath, + join: (...ps: string[]): string => slash(nodePath.join(...ps)), + resolve: (...ps: string[]): string => slash(nodePath.resolve(...ps)), + posix: { + ...nodePath.posix, + resolve: (...ps: string[]): string => resolve(process.cwd(), ...ps), + relative, + }, +}; + +// f.length counts the parameters before the first default. +const arity = (f: (...args: never[]) => unknown, n: number, name: string): void => { + if (f.length !== n) { + throw new DriftError( + `${name} takes ${f.length} parameters, not ${n}; update seeInfer and seeCheck in tools/bend-lint/src/seam.ts`, + ); + } +}; + +// term_infer and term_check as bend.ts calls them: every argument passes +// through, and each result is also reported to hook.see. tsc checks the +// names against bend.ts's signatures; a function that no longer takes the +// arguments read here stops loading. +export const seeInfer = (f: typeof BendModule.term_infer): typeof BendModule.term_infer => { + arity(f, 6, "term_infer"); + return (...args) => { + const r = f(...args); + const [bok, lhs, tm, qt, ctx, dep] = args; + hook.see?.({ tm: r.tm, ty: r.ty, bok, ctx, dep, def: lhs.def, qt, us: r.us, spn: tm.s }); + return r; + }; +}; + +export const seeCheck = (f: typeof BendModule.term_check): typeof BendModule.term_check => { + arity(f, 7, "term_check"); + return (...args) => { + const r = f(...args); + const [bok, lhs, tm, qt, ty, ctx, dep] = args; + hook.see?.({ tm: r.tm, ty, bok, ctx, dep, def: lhs.def, qt, us: r.us, spn: tm.s }); + return r; + }; +}; + +// `file` is one of PATCHED. +export const patch = (file: string, src: string): string => { + const { edits, tail, exports } = PATCHES[file]; + const drift = (what: string, n: number): never => { + throw new DriftError( + `cannot patch bend2/${file}: found ${n} of ${what}, expected 1. Update PATCHES in tools/bend-lint/src/seam.ts.`, + ); + }; + const edited = edits.reduce((out, [at, to]) => { + const n = out.split(at).length - 1; + return n === 1 ? out.replace(at, () => to) : drift(JSON.stringify(at), n); + }, src); + const declared = (name: string, exported: string): RegExp => + new RegExp(`^${exported}(?:async function|function|class|const|let) ${name}\\b`, "gm"); + const missing = exports.filter((name) => { + const n = edited.match(declared(name, "(?:export )?"))?.length ?? 0; + return n === 1 ? !declared(name, "export ").test(edited) : drift("a declaration of " + name, n); + }); + const named = missing.length === 0 ? "" : `export { ${missing.join(", ")} };\n`; + return `${edited}\n${tail}${named}export const ${MARK} = 1;\n`; +}; + +const message = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +// A response's text, or a rejection naming its status. +const text = (res: Response, what: string): Promise => + res.ok ? res.text() : Promise.reject(new Error(`GitHub answered ${res.status}${what}`)); + +// fetch, given up after 30 seconds. +const download = (url: string): Promise => + fetch(url, { signal: AbortSignal.timeout(30_000) }); + +// A command's stdout, or undefined if it does not run or fails. +const spawn = (cmd: string[]): string | undefined => { + try { + const r = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "ignore" }); + return r.exitCode === 0 ? r.stdout.toString() : undefined; + } catch { + return undefined; + } +}; + +// The installed bend's release ("v2.0.36"), or undefined if `bend version` +// does not run. +export const installedTag = ( + run: (cmd: string[]) => string | undefined = spawn, +): string | undefined => { + const version = run(["bend", "version"])?.match(/^bend (\d+\.\d+\.\d+)\b/)?.[1]; + return version === undefined ? undefined : "v" + version; +}; + +// The newest release, from git's list of refs (GitHub's API limits calls). +// It asks at most once a day (a missing or damaged note asks again). +export const latestTag = async (cache: string = CACHE, get: Get = download): Promise => { + const note = nodePath.join(cache, "latest.json"); + const known = await Bun.file(note) + .json() + .catch(() => undefined); + if (RELEASE.test(String(known?.tag)) && Date.now() - Number(known?.at) < DAY) { + return String(known.tag); + } + const newest = (tags: string[]): string | undefined => + tags + .filter((t) => RELEASE.test(t)) + .map((t) => t.slice(1).split(".").map(Number)) + .sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2]) + .map((v) => "v" + v.join("."))[0]; + const listed = await get(GIT) + .then((res) => text(res, "")) + .then( + (refs) => newest([...refs.matchAll(/refs\/tags\/(v[\d.]+)$/gm)].map((m) => m[1])), + (e: unknown) => { + const cached = newest(nodeFs.existsSync(cache) ? nodeFs.readdirSync(cache) : []); + return ( + cached ?? + Promise.reject(new Error(`cannot list bend's releases (${message(e)}); ${GIVE}`)) + ); + }, + ); + if (listed === undefined) { + throw new Error(`bend has no release tags; ${GIVE}`); + } + nodeFs.mkdirSync(cache, { recursive: true }); + nodeFs.writeFileSync(note, JSON.stringify({ tag: listed, at: Date.now() })); + return listed; +}; + +// bend2 at a release (the PATCHED files, safe.ts, base.bend and the effs/ +// files Base imports), kept in //bend2. +// A download goes to a temporary folder first, so the cache never holds a +// partial one. +export const fetchBend = async ( + tag: string, + cache: string = CACHE, + get: Get = download, +): Promise => { + if (!RELEASE.test(tag)) { + throw new Error("not a bend release: " + tag); + } + const dir = nodePath.join(cache, tag, "bend2"); + const whole = (): boolean => + [...PATCHED, "safe.ts", "base.bend"].every((f) => nodeFs.existsSync(nodePath.join(dir, f))); + if (whole()) { + return dir; + } + const fetched = (file: string): Promise<[string, string]> => + get(`${RAW}${tag}/bend2/${file}`) + .then((res) => text(res, ` for bend2/${file}`)) + .then((body) => [file, body]); + const base = await fetched("base.bend"); + const effs = [ + ...new Set([...base[1].matchAll(/^\s*import "\.\/(effs\/[^"]+)"/gm)].map((m) => m[1])), + ]; + const odd = effs.find((f) => !EFF.test(f)); + if (odd !== undefined) { + throw new Error("base.bend imports " + odd + ", which is not a plain file in effs/"); + } + const files = [base, ...(await Promise.all([...PATCHED, "safe.ts", ...effs].map(fetched)))]; + nodeFs.mkdirSync(nodePath.join(cache, tag), { recursive: true }); + const part = nodeFs.mkdtempSync(nodePath.join(cache, tag, ".part-")); + files.forEach(([file, body]) => { + nodeFs.mkdirSync(nodePath.dirname(nodePath.join(part, file)), { recursive: true }); + nodeFs.writeFileSync(nodePath.join(part, file), body); + }); + try { + nodeFs.rmSync(dir, { recursive: true, force: true }); + nodeFs.renameSync(part, dir); + } catch (e) { + nodeFs.rmSync(part, { recursive: true, force: true }); + if (!whole()) { + throw e; + } + } + return dir; +}; + +// The bend2 folder to load, found in the order the docs give. +export const bendDir = async ( + given: string | undefined, + { get = download, run = spawn, cache = CACHE, repo }: FindOptions = {}, +): Promise => { + const chosen = given ?? process.env.BEND_DIR; + const dir = path.resolve(chosen ?? repo ?? path.join(HERE, "..", "..", "..", "bend2")); + const found = [path.join(dir, "bend2"), dir].find((d) => + nodeFs.existsSync(path.join(d, "bend.ts")), + ); + if (found !== undefined) { + return fs.realpathSync(found); + } + if (chosen !== undefined) { + throw new Error(`no bend2 at ${dir} (it needs bend.ts); ${GIVE}`); + } + const tag = installedTag(run) ?? (await latestTag(cache, get)); + const fresh = !nodeFs.existsSync(nodePath.join(cache, tag, "bend2")); + const got = await fetchBend(tag, cache, get).catch((e: unknown) => { + throw new Error(`could not download bend ${tag} (${message(e)}); ${GIVE}`); + }); + if (fresh) { + console.error("bend-lint: downloaded bend " + tag + " to " + got); + } + return fs.realpathSync(got); +}; + +// Every sub-term, parents first, with an explicit stack: linear in the +// size of the term, at any depth. +export function* walk(tm: LTerm): Generator { + const stack = [tm]; + for (let t = stack.pop(); t !== undefined; t = stack.pop()) { + const children = (CHILDREN as Record LTerm[]) | undefined>)[t.$]; + if (children === undefined) { + throw new DriftError( + `unknown term kind ${t.$}; update CHILDREN in tools/bend-lint/src/seam.ts`, + ); + } + yield t; + const next = children(t); + for (let i = next.length - 1; i >= 0; i--) { + stack.push(next[i]); + } + } +} + +// Where each line of `text` starts, computed once per owner of the text. +export const starts = (owner: object, text: string): number[] => { + const known = STARTS.get(owner); + if (known !== undefined) { + return known; + } + const out = [0, ...[...text.matchAll(/\n/g)].map((m) => m.index! + 1)]; + STARTS.set(owner, out); + return out; +}; + +// The index of the line that holds `off`, given each line's start. +export const line = (starts: number[], off: number): number => { + let [lo, hi] = [0, starts.length - 1]; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + [lo, hi] = starts[mid] <= off ? [mid, hi] : [lo, mid - 1]; + } + return lo; +}; + +// bend.ts parses a copy of each file with its import lines blanked, so a +// span moves to the file on disk by line and column. The copy's namespace, +// folder and lines pick the file; a copy that matches no file, or two, is +// a DriftError. +export const mapper = (files: File[]): Mapper => { + const own = new Set(files); + const memo = new WeakMap(); + const find = (f: BendSpan["file"] & { dir?: string }) => { + const lines = f.str.split("\n"); + const found = files.filter((file) => { + const theirs = file.str.split("\n"); + return ( + f.ns === file.ns && + (f.dir === undefined || f.dir === file.path.slice(0, file.path.lastIndexOf("/") + 1)) && + lines.length === theirs.length && + lines.every( + (l, i) => l === theirs[i] || (l.trim() === "" && /^\s*import(\s|$)/.test(theirs[i])), + ) + ); + }); + if (found.length !== 1) { + throw new DriftError( + `cannot map a bend.ts span to a file on disk (${found.length} candidates); bend.ts may mask imports another way now`, + ); + } + Object.assign(found[0].al, f.al); + const known = { file: found[0], from: starts(f, f.str), to: starts(found[0], found[0].str) }; + memo.set(f, known); + return known; + }; + return (s) => { + if (s === undefined || own.has(s.file)) { + return s; + } + const { file, from, to } = memo.get(s.file) ?? find(s.file); + const at = (off: number): number => { + const i = line(from, off); + return to[i] + off - from[i]; + }; + return { file, beg: at(s.beg), end: at(s.end) }; + }; +}; + +// A mapped bend.ts span as a span of a checked source, and back. +const toSpan = (s: BendSpan | undefined): Span | undefined => { + const file = s && SOURCES.get(s.file); + return s && file && { file, beg: s.beg, end: s.end }; +}; + +const fromSpan = (s: Span | undefined): BendSpan | undefined => + s && { file: FILES.get(s.file)!, beg: s.beg, end: s.end }; + +// A fact or a type handle as what it holds, and back. +const raw = (fact: Fact): Raw => fact as unknown as Raw; +const handle = (r: Raw): Fact => r as unknown as Fact; +const term = (t: Type): HTerm => t as unknown as HTerm; +const typed = (t: HTerm): Type => t as unknown as Type; + +// Throws a DriftError that names each check that failed. +const demand = (checks: Array<[string, boolean]>, say: (wrong: string) => string): void => { + const wrong = checks.flatMap(([what, ok]) => (ok ? [] : [what])); + if (wrong.length > 0) { + throw new DriftError(say(wrong.join(", "))); + } +}; + +const isErr = (e: unknown): e is Err => + typeof e === "object" && e !== null && (e as { $?: unknown }).$ === "Err"; + +// A term's kind, annotations stripped, and the name a Var or Ref points to. +const shape = (m: Loaded, tm: LTerm): { kind: string; name: Name } => { + const t = m.Bend.term_strip(tm); + return { kind: t.$, name: t.$ === "Var" || t.$ === "Ref" ? t.k : "" }; +}; + +// Whether a fact passes a filter, its scope aside. +const matches = (f: FactFilter, kind: string, def: Name, name: Name): boolean => { + const all = (xs: string[] | undefined, x: string): boolean => + xs === undefined || xs.length === 0 || xs.includes(x); + return ( + all(f.kinds, kind) && + (all(f.defs, def) || all(f.defs, def.replace(/~\d+$/, ""))) && + all(f.names, name) + ); +}; + +// bend's words for a failed check, its location aside (a finding has its +// own): expected and observed, with the names in scope, and the note. +const failure = (m: Loaded, e: unknown): string => { + if (!isErr(e)) { + return m.Main.book_err(e).replace(/^Error: /, ""); + } + const show = (x: Err["exp"]): string => + m.Bend.expr_show(e.bok, x, m.Bend.ctx_scope(e.ctx), e.spn?.file); + return ( + (e.obs === undefined + ? show(e.exp) + : "expected: " + show(e.exp) + "\nobserved: " + show(e.obs)) + + (e.nte === undefined ? "" : "\n" + e.nte) + ); +}; + +// check's work, in its turn. A failure in a file bend did not finish +// loading may not map; it still reports, without a span. +const checked = async ( + m: Loaded, + file: string, + filters: FactFilter[], + signal: AbortSignal, +): Promise => { + const { Bend, Main } = m; + const seen = new Map(); + const found: Report[] = []; + const real = fs.existsSync(file) ? fs.realpathSync(file) : ""; + const home = real.slice(0, real.lastIndexOf("/") + 1); + const far = filters.filter((f) => f.scope === "program"); + const program = far.length > 0; + const seeded = real !== "" && /^import Base$/m.test(fs.readFileSync(real, "utf8")); + const key = fs.readFileSync(Bend.BASE_BEND, "utf8"); + if (seeded && !BASES.has(key)) { + BASES.set(key, Main.book_read(Bend.BASE_BEND)); + } + const see = (report: Report): void => { + const at = report.spn?.file as { dir?: string; ns?: string } | undefined; + const near = at?.dir === home && at.ns === "" ? filters : far; + const { kind, name } = near.length > 0 ? shape(m, report.tm) : { kind: "", name: "" }; + if (near.some((f) => matches(f, kind, report.def, name))) { + found.push(report); + } + }; + const read = await Promise.resolve(seeded ? BASES.get(key) : undefined) + .then((base) => { + hook.see = filters.length > 0 ? see : undefined; + return Main.book_read(file, base, seen); + }) + .then( + (book) => ({ book }), + (e: unknown) => ({ e: e instanceof Main.Check_Fail ? e.why : e }), + ); + signal.throwIfAborted(); + const root = [...seen.keys()].find((real) => real !== Bend.BASE_BEND || !seeded); + const sources = [...seen.keys()] + .filter((real) => fs.existsSync(real)) + .map((real): Source => { + const text = fs.readFileSync(real, "utf8"); + const source = { path: real, text, root: real === root, base: real === Bend.BASE_BEND }; + const file = { str: text, ns: seen.get(real) ?? "", al: {}, path: real }; + FILES.set(source, file); + SOURCES.set(file, source); + return source; + }); + const map = mapper(sources.map((s) => FILES.get(s)!)); + if ("book" in read) { + const { book } = read; + const insts = new Set(Object.values(book.tmps).flatMap((t) => [...t.values()])); + const own = new Set( + sources.filter((s) => (program ? !s.base : s.root)).map((s) => FILES.get(s)), + ); + const facts = found.flatMap((f): Array<[LTerm, Fact]> => { + const spn = map(f.spn); + return spn !== undefined && own.has(spn.file) + ? [[f.tm, handle({ ...f, inst: insts.has(f.def), spn, map })]] + : []; + }); + return { book, sources, facts: filters.length > 0 ? [...new Map(facts).values()] : undefined }; + } + const err = isErr(read.e) ? read.e : undefined; + let span: Span | undefined; + try { + span = toSpan(map(err?.spn)); + } catch { + span = undefined; + } + return { + book: Bend.book_nil(), + sources, + failure: { + code: "bend/check", + severity: "error", + message: failure(m, read.e), + span, + def: err?.def, + fixes: [], + core: read.e, + }, + }; +}; + +// Checks a book as `bend ` does (not main.ts's verdict on @unsafe and +// foreign code), with `text` in place of the files on disk, one check at a +// time. A fact is kept only if a filter wants it, and only if its span maps +// to the linted file (or, with scope program, to any file but Base). +export const check = ( + m: Loaded, + file: string, + filters: FactFilter[], + signal: AbortSignal, + text?: ReadonlyMap, +): Promise => { + const turn = queue.then(async (): Promise => { + signal.throwIfAborted(); + [...(text ?? [])] + .filter(([at]) => fs.existsSync(at)) + .forEach(([at, str]) => unsaved.set(fs.realpathSync(at), str)); + try { + return await checked(m, file, filters, signal); + } finally { + hook.see = undefined; + unsaved.clear(); + } + }); + queue = turn.catch(() => undefined); + return turn; +}; + +// The facts one rule asked for, of those kept for all rules. A filter that +// matches all, in the scope of every kept fact, gets them as they are. +export const select = ( + m: Loaded, + { sources, facts }: Checked, + want: FactFilter, + program: boolean, +): Fact[] | undefined => { + const root = FILES.get(sources.find((s) => s.root)!); + const all = [want.kinds, want.defs, want.names].every((xs) => !xs?.length); + return facts === undefined || (all && (want.scope === "program" || !program)) + ? facts + : facts.filter((fact) => { + const r = raw(fact); + const { kind, name } = shape(m, r.tm); + return ( + (want.scope === "program" || r.spn?.file === root) && matches(want, kind, r.def, name) + ); + }); +}; + +// Whether `text` declares what the linted file declares: both are parsed +// with the same imported declarations, and compared as parsed terms, not +// checked ones, since checking unfolds definitions and would hide changes +// in meaning. No typecheck, disk writes or import fetches. A proof made +// only of {==} has no body span, so its aliases come from the sources. A +// proof of an imported law fills its declaration rather than creating one. +const sameDeclarations = (m: Loaded, { book, sources }: Checked, text: string): boolean => { + const { Bend } = m; + const root = sources.find((s) => s.root)!; + const ns = FILES.get(root)!.ns; + const dir = root.path.slice(0, root.path.lastIndexOf("/") + 1); + const body = (s: string) => s.replace(/^import[^\S\n].*$/gm, ""); + const spanned = (): Record => { + const original = body(root.text); + for (const tld of Object.values(book.tlds)) { + for (const t of [tld.T, ...(tld.$ === "Def" && tld.v ? [tld.v] : [])]) { + for (const tm of walk(Bend.term_lower(t))) { + if (tm.s?.file.str === original) { + return tm.s.file.al; + } + } + } + } + return {}; + }; + const aliases = [...root.text.matchAll(/^import\s+(\S+)\s+as\s+(\w+)/gm)].reduce< + Record + >( + (known, [, at, alias]) => { + const imported = sources.find((s) => s.path === resolve(root.path, "..", at)); + return known[alias] !== undefined || imported === undefined + ? known + : { ...known, [alias]: FILES.get(imported)!.ns }; + }, + /^import\s+\S+\s+as\s+/m.test(root.text) ? spanned() : {}, + ); + const qualify = (name: string) => { + const dot = name.indexOf("."); + return dot >= 0 && aliases[name.slice(0, dot)] !== undefined + ? aliases[name.slice(0, dot)] + ":" + name.slice(dot + 1) + : (ns ? ns + ":" : "") + name; + }; + const own = new Set( + [...root.text.matchAll(/^(?:@unsafe\s+)?(?:def|type|law)\s+([\w.]+)/gm)].map((match) => + qualify(match[1]), + ), + ); + const seed = (): Book => { + const fresh = Bend.book_nil(); + fresh.tlds = { ...book.tlds }; + fresh.ctrs = { ...book.ctrs }; + for (const k of own) { + const tld = fresh.tlds[k]; + if (k.includes(":") && !k.startsWith(ns + ":") && tld?.$ === "Def") { + fresh.tlds[k] = { ...tld, v: null, i: undefined, u: false }; + } else { + if (tld?.$ === "ADT") for (const c of tld.c) delete fresh.ctrs[c.k]; + delete fresh.tlds[k]; + } + } + return fresh; + }; + const snapshot = (s: string) => { + const parsed = seed(); + Bend.parse_book(parsed, dir, body(s), ns, aliases); + const lower = (t: HTerm | null) => (t === null ? null : Bend.term_lower(t)); + return JSON.stringify( + parsed.order.map((k) => { + const t = parsed.tlds[k]; + return t.$ === "ADT" + ? [k, t.n, t.g, lower(t.T), t.c.map((c) => [c.k, c.n, lower(c.T)])] + : [k, t.n, t.x, t.u ?? false, t.i, lower(t.T), lower(t.v)]; + }), + (k, v) => (k === "s" ? undefined : v), + ); + }; + try { + return snapshot(root.text) === snapshot(text); + } catch { + return false; + } +}; + +// What a rule asks bend2, over one check's book and facts. +export const operations = (m: Loaded, run: Checked): Operations => { + const { Bend } = m; + return { + view: (fact): View => { + const r = raw(fact); + return { + owner: r.def, + inst: r.inst, + ...shape(m, r.tm), + quantity: QUANTITY[r.qt.$], + span: toSpan(r.spn), + inner: toSpan(r.map(Bend.term_strip(r.tm).s)), + }; + }, + type: (fact) => typed(raw(fact).ty), + binder: (fact) => { + const r = raw(fact); + const v = Bend.term_strip(r.tm); + const ann = v.$ === "Var" ? Bend.pmap_get(r.ctx, v.i) : null; + return ann === null ? undefined : typed(ann.T); + }, + same: (fact, a, b) => Bend.term_compare("EQ", raw(fact).bok, term(a), term(b), raw(fact).dep), + show: (fact, t) => Bend.term_show(Bend.term_lower(term(t), raw(fact).dep)), + normal: (fact, t) => typed(Bend.term_snf(raw(fact).bok, term(t))), + uses: (fact) => { + const r = raw(fact); + return Bend.pmap_to_array(r.us).flatMap(([v, q]) => + q.$ === "None" ? [] : [{ name: Bend.pmap_get(r.ctx, v)?.k ?? "", quantity: QUANTITY[q.$] }], + ); + }, + sameDeclarations: (text) => sameDeclarations(m, run, text), + unstable: { Bend, book: run.book, raw }, + }; +}; + +// bend's own error layout for a finding: its message, context and location. +export const layout = (m: Loaded, d: Diag): string => { + const r = d.fact && raw(d.fact); + return m.Bend.err_show( + isErr(d.core) + ? d.core + : m.Bend.Err( + r?.bok ?? m.Bend.book_nil(), + r?.ctx ?? m.Bend.ctx_nil(), + d.message, + undefined, + fromSpan(d.span), + d.def, + ), + ); +}; + +// A Bend rule's id(), facts() and main(), compiled as comp.ts io_run does. +// facts(), checked: NoFacts{} gives null, Want{...} a filter. +export const compile = (m: Loaded, book: Book, file: string): Compiled => { + const { Bend, Comp } = m; + const value = (k: Name): LTerm | undefined => { + const tld = book.tlds[k]; + return tld?.$ === "Def" && tld.n === 0 && tld.v !== null + ? Bend.term_lower(Bend.term_snf(book, tld.v)) + : undefined; + }; + const shown = value("id"); + const id = shown === undefined ? undefined : Bend.term_show(shown).match(/^"([^"\\]*)"$/)?.[1]; + const args = (t: LTerm | undefined, ctr: string): LTerm[] | undefined => + t?.$ === "Ctr" && (t.k === ctr || t.k.endsWith(":" + ctr)) ? t.x : undefined; + const texts = (t: LTerm | undefined): string[] | undefined => { + const [head, tail] = args(t, "Con") ?? []; + const rest = tail && texts(tail); + return args(t, "Nil") !== undefined + ? [] + : head?.$ === "Lit" && typeof head.v === "string" && rest + ? [head.v, ...rest] + : undefined; + }; + const asked = value("facts"); + const [scope, kinds, defs, names] = args(asked, "Want") ?? []; + const want = + args(asked, "NoFacts") !== undefined + ? null + : { + scope: (["file", "program"] as const).find( + (s) => args(scope, s === "file" ? "File" : "Program") !== undefined, + ), + kinds: texts(kinds), + defs: texts(defs), + names: texts(names), + }; + if ( + id === undefined || + Comp.io_type(book) === null || + (want !== null && [want.scope, want.kinds, want.defs, want.names].includes(undefined)) + ) { + throw new Error( + file + " must define id() -> String, facts() -> Lint.Want and main() -> IO(Unit)", + ); + } + const main = new Function( + "require", + `${Comp.js_lib(book)}\n${Comp.RUNTIME_MAIN}\nreturn (args) => { cli_args = args; return io_run(${Comp.js_sat("main")}); };`, + )(import.meta.require) as (args: string[]) => number; + return { id, want: want as FactFilter | null, main }; +}; + +// What main.ts must give, as bend-lint calls it; a mismatch is a +// DriftError. +export const guardMain = (Main: Main): void => + demand( + [ + ["book_read(file, base, seen = ...)", Main.book_read?.length === 2], + ["book_err(e)", Main.book_err?.length === 1], + [ + "new Check_Fail(why).why", + typeof Main.Check_Fail === "function" && new Main.Check_Fail(MARK).why === MARK, + ], + ], + (wrong) => + `bend2/main.ts no longer has ${wrong}; update PATCHES in tools/bend-lint/src/seam.ts`, + ); + +// Checks src/sample.bend as a rule sees it. `x` in `def id(x: N) -> N: x` +// must be a Var typed N, bound as an N, used once, at its own span; `id` in +// main is reported only by term_infer, and the Lam only by term_check. +const selfCheck = async (m: Loaded): Promise => { + const run = await check(m, SAMPLE, [{}], new AbortController().signal); + const ops = operations(m, run); + const views = (run.facts ?? []).map((fact) => ({ fact, ...ops.view(fact) })); + const x = views.find((v) => v.kind === "Var" && v.name === "x"); + const bound = x && ops.binder(x.fact); + demand( + [ + ["check", run.failure === undefined], + ["Ref id (term_infer)", views.some((v) => v.kind === "Ref" && v.name === "id")], + ["Lam (term_check)", views.some((v) => v.kind === "Lam")], + ["type", x !== undefined && ops.show(x.fact, ops.type(x.fact)) === "N"], + ["scope", x !== undefined && bound !== undefined && ops.show(x.fact, bound) === "N"], + ["quantity", x?.quantity === "once"], + ["uses", JSON.stringify(x && ops.uses(x.fact)) === '[{"name":"x","quantity":"once"}]'], + ["span", x?.span?.file.text.slice(x.span.beg, x.span.end) === "x"], + ], + (wrong) => + `self-check failed: the patched bend2 gave the wrong ${wrong} for src/sample.bend; update tools/bend-lint/src/seam.ts`, + ); +}; + +// Finds bend2, patches its PATCHED files as Bun loads them, imports them, +// and checks them (guardMain, selfCheck). +export const load = async (given: string | undefined): Promise => { + const dir = await bendDir(given); + const patched = new Map( + PATCHED.map((f) => [path.join(dir, f), patch(f, fs.readFileSync(path.join(dir, f), "utf8"))]), + ); + const exact = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll("/", "[\\\\/]"); + const names = PATCHED.map((f) => f.replace(/\.ts$/, "")).join("|"); + const filter = new RegExp( + `^${exact}[\\\\/](${names})\\.ts$`, + process.platform === "win32" ? "i" : "", + ); + Bun.plugin({ + name: "bend-lint", + setup: (build) => { + build.onLoad({ filter }, (args) => { + const contents = patched.get(fs.realpathSync(args.path)); + if (contents === undefined) { + throw new DriftError(`bend-lint matched ${args.path} but did not patch it`); + } + return { contents, loader: "ts" }; + }); + }, + }); + const modules = await Promise.all( + PATCHED.map((f) => import(url.pathToFileURL(path.join(dir, f)).href)), + ); + if (modules.some((module) => module[MARK] !== 1)) { + throw new DriftError( + "bend2 was loaded before bend-lint could patch it; import bend-lint first", + ); + } + const [B, C, M] = modules as [typeof BendModule, Comp, Main]; + guardMain(M); + const loaded = { Bend: B, Comp: C, Main: M, BEND2: dir }; + await selfCheck(loaded); + return loaded; +}; + +// Side effects +// ============ + +Object.setPrototypeOf(DriftError.prototype, Error.prototype); From 2ccbfba99c198de67edd7ca352f00810344f8e8c Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Thu, 8 Oct 2026 10:20:14 -0300 Subject: [PATCH 34/39] bend-lint: term view for TS and Bend rules; drift errors carry a symbol 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 Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK --- tools/bend-lint/README.md | 32 +++-- tools/bend-lint/src/effects.js | 29 ++++- tools/bend-lint/src/lint.bend | 27 ++++- tools/bend-lint/src/lint.test.ts | 201 ++++++++++++++++++++++++++----- tools/bend-lint/src/lint.ts | 43 ++++++- tools/bend-lint/src/seam.ts | 128 ++++++++++++-------- 6 files changed, 361 insertions(+), 99 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index 9a59331e32..bd0bcb03b2 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -148,12 +148,15 @@ What's on `cx`: - `uses(fact)`: used variables with their quantities - `sameDeclarations(text)`: whether `text` declares what the linted file declares, compared as parsed, not checked +- `body(name)`, `node(fact)`, `shape(node)`, `fact(node)`: the term view (see + Nodes) - `diag(init)`: make a diagnostic with this rule's ID - `unstable`: Bend's own objects (`Bend`, the checked `book`, `raw(fact)`). Code that uses them breaks when Bend changes; nothing else on `cx` does. The exported types in [src/lint.ts](src/lint.ts) are the full contract. They -are bend-lint's own: facts and types are handles, and only `cx` reads them. +are bend-lint's own: facts, types and nodes are handles, and only `cx` reads +them. #### Fixes @@ -223,6 +226,20 @@ export const rules: LintRule[] = [{ (Saved under `rules/`.) +#### Nodes + +A node is one term of a def's checked body. `cx.body(name)` gives the root, +`cx.shape(node)` its kind (annotations kept: `Ann`, `Var`, `App`, ...), the name +a `Var` or `Ref` points to, its span and its children, in Bend's order. +`cx.node(fact)` is the node a fact is about, and `cx.fact(node)` is the node's +fact, if this rule asked for it. Kinds and child order are Bend's, so a rule +that reads them may need changes when Bend's terms change; facts alone do not. + +```ts +const nodes = (node: Node): Node[] => [node, ...cx.shape(node).children.flatMap(nodes)]; +const matches = nodes(cx.body("main")!).filter((n) => cx.shape(n).kind === "Mat"); +``` + #### Options `options: { tabWidth: 2, breakLines: false }` sets defaults. They also set which @@ -267,8 +284,8 @@ def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): ``` `step` goes in as a template (`~step`) and takes the fact as `+f`. Effects: -`view`, `type_of`, `binder`, `same`, `show`, `normal`, `uses`, `text`, plus -Base's. Options come from `Lint.option_number`, `option_flag` and `option_text`, +`view`, `type_of`, `binder`, `same`, `show`, `normal`, `uses`, `text`, and the +term view `body`, `node`, `shape` and `fact`, plus Base's. Options come from `Lint.option_number`, `option_flag` and `option_text`, each with a default. Numbers are whole, 0 to 4294967295 (U32). Spans count Unicode code points here. TypeScript counts UTF-16 units. @@ -388,11 +405,12 @@ alike: counts at runtime - `book_read`, `book_err` and `Check_Fail` from `main.ts` must take the arguments bend-lint gives them -- a self-check reads `src/sample.bend` with `book_read`, and looks at the type, - depth, scope, quantity, uses and span recorded for `x` in - `def id(x: N) -> N: x`; a missing file must fail with a `Check_Fail` +- a self-check runs `src/sample.bend` through the check and the rule + operations: `x` in `def id(x: N) -> N: x` must have the right type, binder, + quantity, uses and span, and each wrapper must report the terms only it sees -Any mismatch throws `DriftError` and loading stops. The tests add more, including +Any mismatch throws a drift error (`name` is `DriftError`, and `e[DRIFT]` is +true, with `DRIFT` from `src/seam.ts`) and loading stops. The tests add more, including drift against the chosen checkout's own tests. ## Tests diff --git a/tools/bend-lint/src/effects.js b/tools/bend-lint/src/effects.js index 9cbb045d4b..57ad38dc02 100644 --- a/tools/bend-lint/src/effects.js +++ b/tools/bend-lint/src/effects.js @@ -61,6 +61,14 @@ function lint_term(id) { return { $: CID(Term), id }; } +function lint_node(id) { + return { $: CID(Node), id }; +} + +function lint_fact(id) { + return lint_maybe(id === undefined ? undefined : { $: CID(Fact), id }); +} + function lint_input() { const { sources, options } = lint_host().input(); return { @@ -106,10 +114,7 @@ function lint_view(fact) { io_eff(CID(input), lint_input); io_eff(CID(report), lint_report); -io_eff(CID(next_fact), () => { - const id = lint_host().next(); - return lint_maybe(id === undefined ? undefined : { $: CID(Fact), id }); -}); +io_eff(CID(next_fact), () => lint_fact(lint_host().next())); io_eff(CID(view), lint_view); io_eff(CID(type_of), (fact) => lint_term(lint_host().type(fact.id))); io_eff(CID(binder), (fact) => { @@ -127,3 +132,19 @@ io_eff(CID(uses), (fact) => ), ); io_eff(CID(text), (span) => lint_host().text(lint_spot(span))); +io_eff(CID(body), (name) => { + const id = lint_host().body(name); + return lint_maybe(id === undefined ? undefined : lint_node(id)); +}); +io_eff(CID(node), (fact) => lint_node(lint_host().node(fact.id))); +io_eff(CID(shape), (n) => { + const s = lint_host().shape(n.id); + return { + $: CID(Shape), + kind: s.kind, + name: s.name, + span: lint_maybe(s.span && lint_span(s.span)), + children: lint_list(s.children.map(lint_node)), + }; +}); +io_eff(CID(fact), (n) => lint_fact(lint_host().fact(n.id))); diff --git a/tools/bend-lint/src/lint.bend b/tools/bend-lint/src/lint.bend index 263f901dba..13674ca770 100644 --- a/tools/bend-lint/src/lint.bend +++ b/tools/bend-lint/src/lint.bend @@ -13,14 +13,17 @@ # option_number, option_flag and option_text, each with a default. import Base -# A checked term, and a type: indexes into bend-lint's tables for one run. -# Only the effects below read them. +# A checked term, a type, and a node of a checked body: indexes into +# bend-lint's tables for one run. Only the effects below read them. type Fact is Data: Fact{id: U32} type Term is Data: Term{id: U32} +type Node is Data: + Node{id: U32} + type Source is Data: Source{path: String, text: String, root: Bool} @@ -90,6 +93,11 @@ type Use is Data: type View is Data: View{owner: String, inst: Bool, kind: String, name: String, quantity: Quantity, span: Maybe<&2, Span>, inner: Maybe<&2, Span>} +# A node: its kind, annotations kept (Ann, Var, App, ...), the name a Var +# or Ref points to (else ""), its span, and its children, in bend's order. +type Shape is Data: + Shape{kind: String, name: String, span: Maybe<&2, Span>, children: List<&2, Node>} + def input() -> IO(Input): import "./effects.js" @@ -148,6 +156,21 @@ def uses(fact: Fact) -> IO(List<&2, Use>): def text(span: Span) -> IO(String): import "./effects.js" +# A def's checked body, if it has one. +def body(name: String) -> IO(Maybe<&2, Node>): + import "./effects.js" + +# The node a fact is about. +def node(fact: Fact) -> IO(Node): + import "./effects.js" + +def shape(n: Node) -> IO(Shape): + import "./effects.js" + +# The node's fact, if the rule asked for it. +def fact(n: Node) -> IO(Maybe<&2, Fact>): + import "./effects.js" + def pick(hit: Bool, v: Value, later: Maybe<&2, Value>) -> Maybe<&2, Value>: match hit: case True{}: diff --git a/tools/bend-lint/src/lint.test.ts b/tools/bend-lint/src/lint.test.ts index 8a4250d8f4..e55ec413a0 100644 --- a/tools/bend-lint/src/lint.test.ts +++ b/tools/bend-lint/src/lint.test.ts @@ -7,9 +7,9 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import type { Book, LTerm, Span as BendSpan } from "bend2/bend.ts"; import type * as BendModule from "bend2/bend.ts"; import { BEND2, applyFixes, bendRule, findConfig, lint, readConfig, render } from "./lint.ts"; -import type { Diag, Fact, LintRule, RuleContext, Source } from "./lint.ts"; +import type { Diag, Fact, LintRule, Node, RuleContext, Source } from "./lint.ts"; import { - DriftError, + DRIFT, bendDir, fetchBend, guardMain, @@ -103,7 +103,7 @@ def main() -> N: // Tests in the bend checkout (bend2/../tests) whose first expected line // says if the check passes. -const DRIFT = [ +const BEND_TESTS = [ "check/alpha_equivalence", "check/assert_plain_fill", "check/dependent_telescope", @@ -452,6 +452,87 @@ def main() -> IO(Unit): Lint.serve(run) `; +// A Bend rule that reads the term view: the root of id's body and its +// first child, a Var fact's node, its first child and its fact back, and +// a def that does not exist. +const TREE_BEND = String.raw`import Base +import ../../lint.bend as Lint + +def id() -> String: + "test/tree" + +def facts() -> Lint.Want: + Lint.Want{Lint.File{}, ["Var"], ["id"], []} + +def kind_of(s: Lint.Shape) -> String: + match s: + case Lint.Shape{kind, name, span, children}: + kind + +def first(s: Lint.Shape) -> Maybe<&2, Lint.Node>: + match s: + case Lint.Shape{kind, name, span, children}: + match children: + case Nil{}: + None{} + case Con{c, rest}: + Some{c} + +def child_kind(m: Maybe<&2, Lint.Node>) -> IO(String): + match m: + case None{}: + IO.pure(String, "nothing") + case Some{n}: + do IO: + +s : Lint.Shape <- Lint.shape(n) + return kind_of(s) + +def top(m: Maybe<&2, Lint.Node>) -> IO(String): + match m: + case None{}: + IO.pure(String, "no body") + case Some{n}: + do IO: + +s : Lint.Shape <- Lint.shape(n) + c : String <- child_kind(first(s)) + return kind_of(s) ++ " > " ++ c + +def found(m: Maybe<&2, Lint.Fact>) -> String: + match m: + case None{}: + "lost" + case Some{f}: + "found" + +def about(+f: Lint.Fact) -> IO(String): + do IO: + +n : Lint.Node <- Lint.node(f) + +s : Lint.Shape <- Lint.shape(n) + g : Maybe<&2, Lint.Fact> <- Lint.fact(n) + c : String <- child_kind(first(s)) + return kind_of(s) ++ " > " ++ c ++ " " ++ found(g) + +def back(m: Maybe<&2, Lint.Fact>) -> IO(String): + match m: + case None{}: + IO.pure(String, "no fact") + case Some{f}: + about(f) + +def run(input: Lint.Input) -> IO(List<&2, Lint.Diag>): + do IO>: + b : Maybe<&2, Lint.Node> <- Lint.body("id") + t : String <- top(b) + m : Maybe<&2, Lint.Fact> <- Lint.next_fact() + k : String <- back(m) + none : Maybe<&2, Lint.Node> <- Lint.body("nope") + t2 : String <- top(none) + return [Lint.Diag{Lint.Hint{}, t ++ "; " ++ k ++ "; " ++ t2, None{}, []}] + +def main() -> IO(Unit): + Lint.serve(run) +`; + // A rule that reports its options. const echo: LintRule = { id: "test/echo", @@ -462,6 +543,16 @@ const echo: LintRule = { // Functions // ========= +// Whether `f` throws a drift error. +function drifts(f: () => unknown): boolean { + try { + f(); + } catch (e) { + return (e as { [DRIFT]?: boolean })[DRIFT] === true; + } + return false; +} + function file2(p: string, ns: string): { str: string; ns: string; al: {}; path: string } { return { str: "x\n", ns, al: {}, path: p }; } @@ -472,13 +563,15 @@ function fixture(name: string, text: string): string { return file; } -// The facts of a def's checked body, in walk order. +// Every node under `node`, parents first. +function nodes(cx: RuleContext, node: Node): Node[] { + return [node, ...cx.shape(node).children.flatMap((child) => nodes(cx, child))]; +} + +// The facts of a def's checked body, parents first. function facts(cx: RuleContext, name: string): Fact[] { - const tld = cx.unstable.book.tlds[name]; - const byTerm = new Map((cx.facts ?? []).map((f) => [cx.unstable.raw(f).tm, f])); - return tld.$ !== "Def" || tld.e === undefined - ? [] - : [...walk(tld.e)].flatMap((tm) => byTerm.get(tm) ?? []); + const body = cx.body(name); + return body === undefined ? [] : nodes(cx, body).flatMap((n) => cx.fact(n) ?? []); } // A fact as bend2 gives it. @@ -588,9 +681,14 @@ describe("patch", () => { }); test("fails loudly when an edit or a needed name is missing or repeated", () => { - expect(() => - patch("bend.ts", src.replace("export function term_infer(", "export function term_infer2(")), - ).toThrow(DriftError); + expect( + drifts(() => + patch( + "bend.ts", + src.replace("export function term_infer(", "export function term_infer2("), + ), + ), + ).toBe(true); expect(() => patch("bend.ts", src.replace('import * as fs from "node:fs";', 'import fs from "node:fs";')), ).toThrow(/node:fs/); @@ -618,7 +716,7 @@ describe("patch", () => { expect(() => guardMain({ ...good, Check_Fail: function () {} } as never)).toThrow( /main\.ts no longer has new Check_Fail\(why\)\.why/, ); - expect(() => guardMain({ ...good, Check_Fail: function () {} } as never)).toThrow(DriftError); + expect(drifts(() => guardMain({ ...good, Check_Fail: function () {} } as never))).toBe(true); }); test("the loaded bend.ts and comp.ts are the patched ones", () => { @@ -804,12 +902,14 @@ describe("spans", () => { const map = mapper([src]); const spn = { file: src, beg: 2, end: 3 }; expect(map(spn)).toBe(spn); - expect(() => - map({ file: { str: "\ndef g() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 }), - ).toThrow(DriftError); - expect(() => - map({ file: { str: "def f() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 }), - ).toThrow(DriftError); + expect( + drifts(() => + map({ file: { str: "\ndef g() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 }), + ), + ).toBe(true); + expect( + drifts(() => map({ file: { str: "def f() -> N:\n x\n", ns: "", al: {} }, beg: 0, end: 0 })), + ).toBe(true); }); test("the directory picks between equal files", () => { @@ -847,6 +947,39 @@ describe("lint", () => { expect((await lint(userland, [commaSpace])).facts).toBeUndefined(); }); + test("the term view: bodies, nodes, shapes, and a node's fact only if the rule asked for it", async () => { + const tree: LintRule = { + id: "test/tree", + facts: { kinds: ["Var"], defs: ["id"] }, + run: (cx) => { + const all = nodes(cx, cx.body("id")!); + const x = cx.facts!.find((f) => cx.view(f).name === "x" && !cx.view(f).inst)!; + expect(all).toContain(cx.node(x)); + expect(cx.fact(cx.node(x))).toBe(x); + expect(cx.shape(cx.node(x)).kind).toBe("Ann"); + expect(cx.shape(cx.shape(cx.node(x)).children[0])).toMatchObject({ + kind: "Var", + name: "x", + }); + const theirs = all.map((n) => cx.fact(n)).filter((f) => f !== undefined); + expect(theirs.length).toBeGreaterThan(0); + expect(theirs.every((f) => cx.view(f).kind === "Var")).toBe(true); + expect(cx.body("nope")).toBeUndefined(); + return []; + }, + }; + const every: LintRule = { + id: "test/every", + facts: true, + run: (cx) => { + const kept = nodes(cx, cx.body("id")!).flatMap((n) => cx.fact(n) ?? []); + expect(kept.some((f) => cx.view(f).kind !== "Var")).toBe(true); + return []; + }, + }; + expect((await lint(userland, [tree, every])).ok).toBe(true); + }); + test("facts cover templates, proofs, matches and fields", async () => { const probe: LintRule = { id: "test/probe", @@ -868,10 +1001,16 @@ describe("lint", () => { const proof = named("proof", "Rfl"); expect(loose(cx, proof).ty.$).toBe("Eql"); expect(loose(cx, proof).dep).toBe(1); - const kept = new Set(cx.facts!.map((f) => loose(cx, f).tm)); - const peel = [...walk((cx.unstable.book.tlds.peel as { e: LTerm }).e)]; - expect(peel.some((tm) => tm.$ === "Mat")).toBe(true); - expect(peel.some((tm) => tm.$ === "Ann" && tm.x.$ === "Efq" && !kept.has(tm))).toBe(true); + const peel = nodes(cx, cx.body("peel")!).map((n) => ({ n, ...cx.shape(n) })); + expect(peel.some((s) => s.kind === "Mat")).toBe(true); + expect( + peel.some( + (s) => + s.kind === "Ann" && + cx.shape(s.children[0]).kind === "Efq" && + cx.fact(s.n) === undefined, + ), + ).toBe(true); const field = loose(cx, named("peel", "Var", "p")); expect(cx.unstable.Bend.pmap_get(field.ctx, field.tm.x!.i!)!.k).toBe("p"); return []; @@ -891,7 +1030,7 @@ describe("lint", () => { ); for (const k of ["Mat", "Lam", "App", "Var", "Ctr"]) expect(kinds.has(k as LTerm["$"])).toBe(true); - expect(() => walk({ $: "Nope" } as unknown as LTerm).next()).toThrow(DriftError); + expect(drifts(() => walk({ $: "Nope" } as unknown as LTerm).next())).toBe(true); }); test("walk preserves constructor and type argument order across traversals", () => { @@ -1144,11 +1283,7 @@ describe("lint", () => { id: "test/api", facts: true, run: async (cx, signal) => { - const body = (cx.unstable.book.tlds.id as { e: LTerm }).e; - const fact = loose( - cx, - cx.facts!.find((f) => loose(cx, f).tm === body)!, - ); + const fact = loose(cx, cx.fact(cx.body("id")!)!); const type = cx.unstable.Bend.term_wnf(fact.bok, fact.ty) as { A: typeof fact.ty }; const arg = cx.unstable.Bend.term_wnf(fact.bok, type.A) as { k?: string }; const res = await fetch(server.url, { @@ -1472,7 +1607,7 @@ describe("rules", () => { }); describe("drift: bend-lint agrees with bend's own tests", () => { - for (const name of DRIFT) { + for (const name of BEND_TESTS) { const file = path.join(BEND2, "..", "tests", name + ".bend"); test.skipIf(!fs.existsSync(file))(name, async () => { const first = fs @@ -1506,6 +1641,12 @@ describe("rules written in Bend", () => { expect(res.diags.every((d) => d.span?.file === root(res.sources))).toBe(true); }); + test("a rule reads the term view through effects", async () => { + const rule = await bendRule(fixture("tree_rule.bend", TREE_BEND)); + const res = await lint(userland, [rule]); + expect(res.diags.map((d) => d.message)).toEqual(["Ann > Lam; Ann > Var found; no body"]); + }); + test("Bend fixes reject out-of-bounds code-point offsets instead of clamping them", async () => { for (const source of [USERLAND, "# 😀\n" + USERLAND]) { const end = [...source].length + 1; diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index d3c1710834..e29026d92f 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -2,7 +2,7 @@ // bend-lint checks a Bend file with bend's checker, then runs rules over // its source and the checker's results. Rules are TS modules, or Bend files // built on ./lint.bend. It reaches bend2 only through ./seam.ts; anything -// there it cannot follow stops it with a DriftError. The types below are +// there it cannot follow stops it with a drift error. The types below are // bend-lint's own, so a rule does not depend on bend2's internals. As a // CLI, it exits 0 when ok, 1 when it found an error, 2 on bad usage or a // tool failure. @@ -42,10 +42,15 @@ export type Fix = { title: string; applicability: Applicability; edits: Edit[] } declare const OPAQUE: unique symbol; -// A checked term, and a type, as handles: what they hold is bend2's, and a -// rule reaches it only through the RuleContext. +// A checked term, a type, and a node of a checked body, as handles: what +// they hold is bend2's, and a rule reaches it only through the RuleContext. export type Fact = { readonly [OPAQUE]: "fact" }; export type Type = { readonly [OPAQUE]: "type" }; +export type Node = { readonly [OPAQUE]: "node" }; + +// A node: its kind, annotations kept (Ann, Var, App, ...), the name a Var +// or Ref points to (else ""), its span, and its children, in bend's order. +export type Shape = { kind: string; name: string; span?: Span; children: Node[] }; // How many times a term is demanded, or a variable is used. export type Quantity = "erased" | "once" | "many"; @@ -112,6 +117,10 @@ export type RuleContext = { normal(fact: Fact, type: Type): Type; uses(fact: Fact): Array<{ name: string; quantity: Quantity }>; sameDeclarations(text: string): boolean; // whether `text` declares what the linted file declares + body(name: string): Node | undefined; // a def's checked body + node(fact: Fact): Node; // the node a fact is about + shape(node: Node): Shape; + fact(node: Node): Fact | undefined; // the node's fact, if this rule asked for it diag(init: DiagInit): Diag; unstable: Unstable; }; @@ -178,6 +187,10 @@ type Channel = { normal(fact: number, t: number): number; uses(fact: number): Array<{ name: string; quantity: Quantity }>; text(span: Spot): string; + body(name: string): number | undefined; + node(fact: number): number; + shape(node: number): Omit & { span?: Spot; children: number[] }; + fact(node: number): number | undefined; }; // Constants @@ -357,13 +370,19 @@ export async function lint( let diags: Diag[] = []; for (const { rule, severity, options, want } of plans) { signal.throwIfAborted(); + const mine = want === undefined ? undefined : select(loaded, checked, want, program); + const asked = new Set(mine); const out = await rule.run( { ...ops, sources, root, options, - facts: want === undefined ? undefined : select(loaded, checked, want, program), + facts: mine, + fact: (node) => { + const fact = ops.fact(node); + return fact !== undefined && asked.has(fact) ? fact : undefined; + }, prior: diags, diag: (d) => ({ code: rule.id, @@ -579,6 +598,8 @@ export async function bendRule(file: string): Promise { const facts = cx.facts ?? []; let given = 0; const types: Type[] = []; + const nodes: Node[] = []; + const index = new Map(facts.map((f, i) => [f, i])); const pick = (xs: T[], i: number, what: string): T => { if (xs[i] === undefined) { throw new Error( @@ -588,6 +609,7 @@ export async function bendRule(file: string): Promise { return xs[i]; }; const keep = (t: Type): number => types.push(t) - 1; + const hold = (n: Node): number => nodes.push(n) - 1; const found: Reported[][] = []; shared.BEND_LINT = { input: () => { @@ -628,6 +650,19 @@ export async function bendRule(file: string): Promise { const { file, beg, end } = span(s); return file.text.slice(beg, end); }, + body: (name) => { + const n = cx.body(name); + return n === undefined ? undefined : hold(n); + }, + node: (i) => hold(cx.node(pick(facts, i, "fact"))), + shape: (i) => { + const { span, children, ...rest } = cx.shape(pick(nodes, i, "node")); + return { ...rest, span: spot(span), children: children.map(hold) }; + }, + fact: (i) => { + const f = cx.fact(pick(nodes, i, "node")); + return f === undefined ? undefined : index.get(f); + }, }; let code: number; try { diff --git a/tools/bend-lint/src/seam.ts b/tools/bend-lint/src/seam.ts index e2b20a155f..7c7f6ef51f 100644 --- a/tools/bend-lint/src/seam.ts +++ b/tools/bend-lint/src/seam.ts @@ -17,8 +17,10 @@ import type { Diag, Fact, FactFilter, + Node, Quantity, RuleContext, + Shape, Source, Span, Type, @@ -66,7 +68,7 @@ type Mapper = (s: BendSpan | undefined) => BendSpan | undefined; // A fact as the checker gives it: `tm` checked (or inferred) as `ty` at // depth `dep` in `ctx`, in def `def`, demanded `qt` times, using the // variables in `us`, in book `bok` (a template body has its own). `inst` -// marks an instance's fact; `map` moves the run's spans to the files on disk. +// marks an instance's fact. export type Raw = { tm: LTerm; ty: HTerm; @@ -78,11 +80,10 @@ export type Raw = { us: Uses; inst: boolean; spn?: BendSpan; - map: Mapper; }; // What the wrappers report for each checked term. -type Report = Omit; +type Report = Omit; // bend2's own objects, for code that accepts to break when bend2 changes. export type Unstable = { Bend: typeof BendModule; book: Book; raw: (fact: Fact) => Raw }; @@ -93,7 +94,13 @@ export type Operations = Omit< "sources" | "root" | "options" | "facts" | "prior" | "diag" >; -export type Checked = { book: Book; sources: Source[]; facts?: Fact[]; failure?: Diag }; +export type Checked = { + book: Book; + sources: Source[]; + map: Mapper; + facts?: Fact[]; + failure?: Diag; +}; // A rule written in Bend, checked and compiled: what its id(), facts() and // main() give. @@ -102,15 +109,8 @@ type Compiled = { id: string; want: FactFilter | null; main: (args: string[]) => // Constants // ========= -export const DriftError: new (message?: string, options?: ErrorOptions) => Error = function ( - message?: string, - options?: ErrorOptions, -): Error { - return Object.setPrototypeOf( - Object.assign(new Error(message, options), { name: "DriftError" }), - new.target!.prototype, - ); -} as ErrorConstructor; +// Marks an error as drift: bend2 changed in a way bend-lint does not follow. +export const DRIFT = Symbol("DriftError"); const HERE = url.fileURLToPath(new URL(".", import.meta.url)); const DRIVE = /^[A-Za-z]:(?=\/)/; @@ -180,7 +180,7 @@ const SOURCES = new WeakMap(); const QUANTITY: Record = { None: "erased", Lone: "once", Many: "many" }; // Binders are explicit in checked terms, so Var.v cells are not followed. -// A new term kind is a type error here, and a DriftError when walked. +// A new term kind is a type error here, and a drift error when walked. const CHILDREN: { [K in LTerm["$"]]: (tm: Extract) => LTerm[] } = { Let: (tm) => [...tm.v, tm.f], Lam: (tm) => [tm.f], @@ -255,7 +255,7 @@ export const path = { // f.length counts the parameters before the first default. const arity = (f: (...args: never[]) => unknown, n: number, name: string): void => { if (f.length !== n) { - throw new DriftError( + throw drift( `${name} takes ${f.length} parameters, not ${n}; update seeInfer and seeCheck in tools/bend-lint/src/seam.ts`, ); } @@ -288,25 +288,30 @@ export const seeCheck = (f: typeof BendModule.term_check): typeof BendModule.ter // `file` is one of PATCHED. export const patch = (file: string, src: string): string => { const { edits, tail, exports } = PATCHES[file]; - const drift = (what: string, n: number): never => { - throw new DriftError( + const mismatch = (what: string, n: number): never => { + throw drift( `cannot patch bend2/${file}: found ${n} of ${what}, expected 1. Update PATCHES in tools/bend-lint/src/seam.ts.`, ); }; const edited = edits.reduce((out, [at, to]) => { const n = out.split(at).length - 1; - return n === 1 ? out.replace(at, () => to) : drift(JSON.stringify(at), n); + return n === 1 ? out.replace(at, () => to) : mismatch(JSON.stringify(at), n); }, src); const declared = (name: string, exported: string): RegExp => new RegExp(`^${exported}(?:async function|function|class|const|let) ${name}\\b`, "gm"); const missing = exports.filter((name) => { const n = edited.match(declared(name, "(?:export )?"))?.length ?? 0; - return n === 1 ? !declared(name, "export ").test(edited) : drift("a declaration of " + name, n); + return n === 1 + ? !declared(name, "export ").test(edited) + : mismatch("a declaration of " + name, n); }); const named = missing.length === 0 ? "" : `export { ${missing.join(", ")} };\n`; return `${edited}\n${tail}${named}export const ${MARK} = 1;\n`; }; +export const drift = (message: string): Error => + Object.assign(new Error(message), { name: "DriftError", [DRIFT]: true }); + const message = (e: unknown): string => (e instanceof Error ? e.message : String(e)); // A response's text, or a rejection naming its status. @@ -453,20 +458,23 @@ export const bendDir = async ( export function* walk(tm: LTerm): Generator { const stack = [tm]; for (let t = stack.pop(); t !== undefined; t = stack.pop()) { - const children = (CHILDREN as Record LTerm[]) | undefined>)[t.$]; - if (children === undefined) { - throw new DriftError( - `unknown term kind ${t.$}; update CHILDREN in tools/bend-lint/src/seam.ts`, - ); - } - yield t; const next = children(t); + yield t; for (let i = next.length - 1; i >= 0; i--) { stack.push(next[i]); } } } +// A term's sub-terms, in bend's order. +const children = (t: LTerm): LTerm[] => { + const of = (CHILDREN as Record LTerm[]) | undefined>)[t.$]; + if (of === undefined) { + throw drift(`unknown term kind ${t.$}; update CHILDREN in tools/bend-lint/src/seam.ts`); + } + return of(t); +}; + // Where each line of `text` starts, computed once per owner of the text. export const starts = (owner: object, text: string): number[] => { const known = STARTS.get(owner); @@ -491,7 +499,7 @@ export const line = (starts: number[], off: number): number => { // bend.ts parses a copy of each file with its import lines blanked, so a // span moves to the file on disk by line and column. The copy's namespace, // folder and lines pick the file; a copy that matches no file, or two, is -// a DriftError. +// a drift error. export const mapper = (files: File[]): Mapper => { const own = new Set(files); const memo = new WeakMap(); @@ -509,7 +517,7 @@ export const mapper = (files: File[]): Mapper => { ); }); if (found.length !== 1) { - throw new DriftError( + throw drift( `cannot map a bend.ts span to a file on disk (${found.length} candidates); bend.ts may mask imports another way now`, ); } @@ -545,23 +553,25 @@ const raw = (fact: Fact): Raw => fact as unknown as Raw; const handle = (r: Raw): Fact => r as unknown as Fact; const term = (t: Type): HTerm => t as unknown as HTerm; const typed = (t: HTerm): Type => t as unknown as Type; +const tree = (n: Node): LTerm => n as unknown as LTerm; +const node = (t: LTerm): Node => t as unknown as Node; -// Throws a DriftError that names each check that failed. +// Throws a drift error that names each check that failed. const demand = (checks: Array<[string, boolean]>, say: (wrong: string) => string): void => { const wrong = checks.flatMap(([what, ok]) => (ok ? [] : [what])); if (wrong.length > 0) { - throw new DriftError(say(wrong.join(", "))); + throw drift(say(wrong.join(", "))); } }; const isErr = (e: unknown): e is Err => typeof e === "object" && e !== null && (e as { $?: unknown }).$ === "Err"; -// A term's kind, annotations stripped, and the name a Var or Ref points to. -const shape = (m: Loaded, tm: LTerm): { kind: string; name: Name } => { - const t = m.Bend.term_strip(tm); - return { kind: t.$, name: t.$ === "Var" || t.$ === "Ref" ? t.k : "" }; -}; +// A term's kind, and the name a Var or Ref points to. +const kindOf = (t: LTerm): { kind: string; name: Name } => ({ + kind: t.$, + name: t.$ === "Var" || t.$ === "Ref" ? t.k : "", +}); // Whether a fact passes a filter, its scope aside. const matches = (f: FactFilter, kind: string, def: Name, name: Name): boolean => { @@ -613,7 +623,8 @@ const checked = async ( const see = (report: Report): void => { const at = report.spn?.file as { dir?: string; ns?: string } | undefined; const near = at?.dir === home && at.ns === "" ? filters : far; - const { kind, name } = near.length > 0 ? shape(m, report.tm) : { kind: "", name: "" }; + const { kind, name } = + near.length > 0 ? kindOf(Bend.term_strip(report.tm)) : { kind: "", name: "" }; if (near.some((f) => matches(f, kind, report.def, name))) { found.push(report); } @@ -649,10 +660,15 @@ const checked = async ( const facts = found.flatMap((f): Array<[LTerm, Fact]> => { const spn = map(f.spn); return spn !== undefined && own.has(spn.file) - ? [[f.tm, handle({ ...f, inst: insts.has(f.def), spn, map })]] + ? [[f.tm, handle({ ...f, inst: insts.has(f.def), spn })]] : []; }); - return { book, sources, facts: filters.length > 0 ? [...new Map(facts).values()] : undefined }; + return { + book, + sources, + map, + facts: filters.length > 0 ? [...new Map(facts).values()] : undefined, + }; } const err = isErr(read.e) ? read.e : undefined; let span: Span | undefined; @@ -664,6 +680,7 @@ const checked = async ( return { book: Bend.book_nil(), sources, + map, failure: { code: "bend/check", severity: "error", @@ -717,7 +734,7 @@ export const select = ( ? facts : facts.filter((fact) => { const r = raw(fact); - const { kind, name } = shape(m, r.tm); + const { kind, name } = kindOf(m.Bend.term_strip(r.tm)); return ( (want.scope === "program" || r.spn?.file === root) && matches(want, kind, r.def, name) ); @@ -810,16 +827,17 @@ const sameDeclarations = (m: Loaded, { book, sources }: Checked, text: string): // What a rule asks bend2, over one check's book and facts. export const operations = (m: Loaded, run: Checked): Operations => { const { Bend } = m; + let byTerm: Map | undefined; return { view: (fact): View => { const r = raw(fact); return { owner: r.def, inst: r.inst, - ...shape(m, r.tm), + ...kindOf(Bend.term_strip(r.tm)), quantity: QUANTITY[r.qt.$], span: toSpan(r.spn), - inner: toSpan(r.map(Bend.term_strip(r.tm).s)), + inner: toSpan(run.map(Bend.term_strip(r.tm).s)), }; }, type: (fact) => typed(raw(fact).ty), @@ -839,6 +857,19 @@ export const operations = (m: Loaded, run: Checked): Operations => { ); }, sameDeclarations: (text) => sameDeclarations(m, run, text), + body: (name) => { + const tld = run.book.tlds[name]; + return tld?.$ === "Def" && tld.e !== undefined ? node(tld.e) : undefined; + }, + node: (fact) => node(raw(fact).tm), + shape: (n): Shape => { + const t = tree(n); + return { ...kindOf(t), span: toSpan(run.map(t.s)), children: children(t).map(node) }; + }, + fact: (n) => { + byTerm ??= new Map((run.facts ?? []).map((f) => [raw(f).tm, f])); + return byTerm.get(tree(n)); + }, unstable: { Bend, book: run.book, raw }, }; }; @@ -912,8 +943,8 @@ export const compile = (m: Loaded, book: Book, file: string): Compiled => { return { id, want: want as FactFilter | null, main }; }; -// What main.ts must give, as bend-lint calls it; a mismatch is a -// DriftError. +// What main.ts must give, as bend-lint calls it; a mismatch is a drift +// error. export const guardMain = (Main: Main): void => demand( [ @@ -972,7 +1003,7 @@ export const load = async (given: string | undefined): Promise => { build.onLoad({ filter }, (args) => { const contents = patched.get(fs.realpathSync(args.path)); if (contents === undefined) { - throw new DriftError(`bend-lint matched ${args.path} but did not patch it`); + throw drift(`bend-lint matched ${args.path} but did not patch it`); } return { contents, loader: "ts" }; }); @@ -982,9 +1013,7 @@ export const load = async (given: string | undefined): Promise => { PATCHED.map((f) => import(url.pathToFileURL(path.join(dir, f)).href)), ); if (modules.some((module) => module[MARK] !== 1)) { - throw new DriftError( - "bend2 was loaded before bend-lint could patch it; import bend-lint first", - ); + throw drift("bend2 was loaded before bend-lint could patch it; import bend-lint first"); } const [B, C, M] = modules as [typeof BendModule, Comp, Main]; guardMain(M); @@ -992,8 +1021,3 @@ export const load = async (given: string | undefined): Promise => { await selfCheck(loaded); return loaded; }; - -// Side effects -// ============ - -Object.setPrototypeOf(DriftError.prototype, Error.prototype); From c9ab361d0b255afa7f7ce902c392ebd88817f590 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Thu, 8 Oct 2026 10:26:35 -0300 Subject: [PATCH 35/39] bend-lint: seam.ts: guard the runtime names a Bend rule's run calls 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 Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK --- tools/bend-lint/README.md | 5 +++-- tools/bend-lint/src/lint.test.ts | 3 +++ tools/bend-lint/src/seam.ts | 27 ++++++++++++++++++--------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index bd0bcb03b2..c51e5afa2b 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -399,8 +399,9 @@ location: expected and observed, with the names in scope, and Bend's note. We don't pin a Bend version. Instead, on load, for checkouts and downloads alike: -- every source edit has to match exactly once, and the compiler exports we need - have to be declared +- every source edit has to match exactly once, the compiler exports we need + have to be declared, and the runtime names a Bend rule's run calls + (`cli_args`, `io_run`) have to be there once - wrapper signatures are checked against Bend's at type-check time, and argument counts at runtime - `book_read`, `book_err` and `Check_Fail` from `main.ts` must take the diff --git a/tools/bend-lint/src/lint.test.ts b/tools/bend-lint/src/lint.test.ts index e55ec413a0..f0622778e2 100644 --- a/tools/bend-lint/src/lint.test.ts +++ b/tools/bend-lint/src/lint.test.ts @@ -698,6 +698,9 @@ describe("patch", () => { expect(() => patch("comp.ts", comp.replace("function js_sat(", "function js_name("))).toThrow( /comp\.ts: found 0 of a declaration of js_sat/, ); + expect(() => + patch("comp.ts", comp.replace("function io_run(m) {", "function run(m) {")), + ).toThrow(/comp\.ts: found 0 of "function io_run\(m\) \{"/); }); test("main.ts must give book_read, book_err and Check_Fail as bend-lint calls them", () => { diff --git a/tools/bend-lint/src/seam.ts b/tools/bend-lint/src/seam.ts index 7c7f6ef51f..fb5d0c5aa1 100644 --- a/tools/bend-lint/src/seam.ts +++ b/tools/bend-lint/src/seam.ts @@ -30,9 +30,10 @@ import type { // Types // ===== -// A file's patch: exact edits, a tail appended to the file, and names it -// must declare once and export (the tail exports those it does not). -type Patch = { edits: Array<[string, string]>; tail: string; exports: string[] }; +// A file's patch: exact edits, text it must hold once unchanged (needs), a +// tail appended to the file, and names it must declare once and export (the +// tail exports those it does not). +type Patch = { edits: Array<[string, string]>; needs?: string[]; tail: string; exports: string[] }; export type Get = (url: string) => Promise; @@ -150,7 +151,12 @@ const PATCHES: Record = { tail: `import { seeInfer, seeCheck } from ${SHIM};\nexport const term_infer = seeInfer(unseen_term_infer);\nexport const term_check = seeCheck(unseen_term_check);\n`, exports: [], }, - "comp.ts": { edits: [], tail: "", exports: ["RUNTIME_MAIN", "js_sat"] }, + "comp.ts": { + edits: [], + needs: ["let cli_args = [];", "function io_run(m) {"], + tail: "", + exports: ["RUNTIME_MAIN", "js_sat"], + }, "main.ts": { edits: SHIMMED, tail: "", exports: ["book_read", "book_err", "Check_Fail"] }, }; @@ -287,16 +293,19 @@ export const seeCheck = (f: typeof BendModule.term_check): typeof BendModule.ter // `file` is one of PATCHED. export const patch = (file: string, src: string): string => { - const { edits, tail, exports } = PATCHES[file]; + const { edits, needs = [], tail, exports } = PATCHES[file]; const mismatch = (what: string, n: number): never => { throw drift( `cannot patch bend2/${file}: found ${n} of ${what}, expected 1. Update PATCHES in tools/bend-lint/src/seam.ts.`, ); }; - const edited = edits.reduce((out, [at, to]) => { - const n = out.split(at).length - 1; - return n === 1 ? out.replace(at, () => to) : mismatch(JSON.stringify(at), n); - }, src); + const edited = [...edits, ...needs.map((t): [string, string] => [t, t])].reduce( + (out, [at, to]) => { + const n = out.split(at).length - 1; + return n === 1 ? out.replace(at, () => to) : mismatch(JSON.stringify(at), n); + }, + src, + ); const declared = (name: string, exported: string): RegExp => new RegExp(`^${exported}(?:async function|function|class|const|let) ${name}\\b`, "gm"); const missing = exports.filter((name) => { From e4e9b6cf4bb3ef881df47f9b5aeaade0da7b91bb Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Thu, 8 Oct 2026 10:30:09 -0300 Subject: [PATCH 36/39] bend-lint: lint.ts: no bend2 knowledge left - 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 Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK --- tools/bend-lint/src/lint.ts | 17 +++++++---------- tools/bend-lint/src/seam.ts | 9 +++++---- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index e29026d92f..7b0fe5fbff 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -532,9 +532,7 @@ export function render(d: Diag): string { }) .join(""), ); - return ( - layout(loaded, d).replace(/^Error:/, HEAD[d.severity] + " [" + d.code + "]:") + fixes.join("") - ); + return layout(loaded, d, HEAD[d.severity] + " [" + d.code + "]:") + fixes.join(""); } // The LSP range of a span in a file on disk. @@ -548,15 +546,14 @@ export function position(span: Span): { start: Position; end: Position } { } // A rule written in Bend: a file built on ./lint.bend (see there). It is -// checked and compiled once, as comp.ts io_run does; each run calls its -// main with bend's own IO runtime, while effects.js reaches bend-lint through -// globalThis.BEND_LINT. Offsets cross as code points. +// checked and compiled once; each run calls its main, while effects.js +// reaches bend-lint through globalThis.BEND_LINT. Offsets cross as code points. export async function bendRule(file: string): Promise { - const { book, failure } = await check(loaded, file, [], new AbortController().signal); - if (failure !== undefined) { - throw new Error(file + " does not check:\n" + render(failure)); + const checked = await check(loaded, file, [], new AbortController().signal); + if (checked.failure !== undefined) { + throw new Error(file + " does not check:\n" + render(checked.failure)); } - const { id, want, main } = compile(loaded, book, file); + const { id, want, main } = compile(loaded, checked, file); return { id, ...(want === null ? {} : { facts: want }), diff --git a/tools/bend-lint/src/seam.ts b/tools/bend-lint/src/seam.ts index fb5d0c5aa1..49c0319f52 100644 --- a/tools/bend-lint/src/seam.ts +++ b/tools/bend-lint/src/seam.ts @@ -883,8 +883,9 @@ export const operations = (m: Loaded, run: Checked): Operations => { }; }; -// bend's own error layout for a finding: its message, context and location. -export const layout = (m: Loaded, d: Diag): string => { +// bend's own error layout for a finding, under `head`: its message, +// context and location. +export const layout = (m: Loaded, d: Diag, head: string): string => { const r = d.fact && raw(d.fact); return m.Bend.err_show( isErr(d.core) @@ -897,12 +898,12 @@ export const layout = (m: Loaded, d: Diag): string => { fromSpan(d.span), d.def, ), - ); + ).replace(/^Error:/, head); }; // A Bend rule's id(), facts() and main(), compiled as comp.ts io_run does. // facts(), checked: NoFacts{} gives null, Want{...} a filter. -export const compile = (m: Loaded, book: Book, file: string): Compiled => { +export const compile = (m: Loaded, { book }: Checked, file: string): Compiled => { const { Bend, Comp } = m; const value = (k: Name): LTerm | undefined => { const tld = book.tlds[k]; From 94bbaf7eefa2668600b929dba272706d3d78a962 Mon Sep 17 00:00:00 2001 From: Mathews Fernando Date: Thu, 8 Oct 2026 10:32:42 -0300 Subject: [PATCH 37/39] bend-lint: src/bend/: the Bend side in its own folder 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 Claude-Session: https://claude.ai/code/session_01Lzp6cJXnt5wr1EMz1WAvoK --- gates/repo.ts | 3 ++- tools/bend-lint/README.md | 6 +++--- tools/bend-lint/rules/file_length.bend | 2 +- tools/bend-lint/rules/shared.bend | 2 +- tools/bend-lint/src/{ => bend}/effects.js | 0 tools/bend-lint/src/{ => bend}/lint.bend | 0 tools/bend-lint/src/{ => bend}/sample.bend | 0 tools/bend-lint/src/lint.test.ts | 12 ++++++------ tools/bend-lint/src/lint.ts | 4 ++-- tools/bend-lint/src/seam.ts | 6 +++--- 10 files changed, 18 insertions(+), 17 deletions(-) rename tools/bend-lint/src/{ => bend}/effects.js (100%) rename tools/bend-lint/src/{ => bend}/lint.bend (100%) rename tools/bend-lint/src/{ => bend}/sample.bend (100%) diff --git a/gates/repo.ts b/gates/repo.ts index d1a7d06271..3b05052181 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -85,7 +85,8 @@ allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\ allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json)$/); -allow(/^tools\/bend-lint\/src\/(lint\.ts|seam\.ts|sample\.bend|lint\.bend|effects\.js|lint\.test\.ts)$/); +allow(/^tools\/bend-lint\/src\/(lint\.ts|seam\.ts|lint\.test\.ts)$/); +allow(/^tools\/bend-lint\/src\/bend\/(lint\.bend|effects\.js|sample\.bend)$/); allow(/^tools\/bend-lint\/rules\/[a-z_]+(\.test)?\.(ts|bend)$/); // Gate diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index c51e5afa2b..db0aeaeec6 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -249,12 +249,12 @@ to check them itself. ### Bend -A `.bend` rule imports [src/lint.bend](src/lint.bend). Here's one that reports +A `.bend` rule imports [src/bend/lint.bend](src/bend/lint.bend). Here's one that reports nothing, saved under `rules/`: ```python import Base -import ../src/lint.bend as Lint +import ../src/bend/lint.bend as Lint def id() -> String: "demo/nothing" @@ -406,7 +406,7 @@ alike: counts at runtime - `book_read`, `book_err` and `Check_Fail` from `main.ts` must take the arguments bend-lint gives them -- a self-check runs `src/sample.bend` through the check and the rule +- a self-check runs `src/bend/sample.bend` through the check and the rule operations: `x` in `def id(x: N) -> N: x` must have the right type, binder, quantity, uses and span, and each wrapper must report the terms only it sees diff --git a/tools/bend-lint/rules/file_length.bend b/tools/bend-lint/rules/file_length.bend index 1ab8e10904..49c956c8f5 100644 --- a/tools/bend-lint/rules/file_length.bend +++ b/tools/bend-lint/rules/file_length.bend @@ -1,7 +1,7 @@ # style/file-length: warn when a source file exceeds maxLines (default 500). # Formatting cannot shorten a file's logical contents; no fix is offered. import Base -import ../src/lint.bend as Lint +import ../src/bend/lint.bend as Lint import ./shared.bend as Shared def id() -> String: diff --git a/tools/bend-lint/rules/shared.bend b/tools/bend-lint/rules/shared.bend index f91ec37164..6496509baa 100644 --- a/tools/bend-lint/rules/shared.bend +++ b/tools/bend-lint/rules/shared.bend @@ -1,6 +1,6 @@ # Helpers for the rules in this folder that are written in Bend. import Base -import ../src/lint.bend as Lint +import ../src/bend/lint.bend as Lint def root_or(root: Bool, s: Lint.Source, later: Maybe<&2, Lint.Source>) -> Maybe<&2, Lint.Source>: match root: diff --git a/tools/bend-lint/src/effects.js b/tools/bend-lint/src/bend/effects.js similarity index 100% rename from tools/bend-lint/src/effects.js rename to tools/bend-lint/src/bend/effects.js diff --git a/tools/bend-lint/src/lint.bend b/tools/bend-lint/src/bend/lint.bend similarity index 100% rename from tools/bend-lint/src/lint.bend rename to tools/bend-lint/src/bend/lint.bend diff --git a/tools/bend-lint/src/sample.bend b/tools/bend-lint/src/bend/sample.bend similarity index 100% rename from tools/bend-lint/src/sample.bend rename to tools/bend-lint/src/bend/sample.bend diff --git a/tools/bend-lint/src/lint.test.ts b/tools/bend-lint/src/lint.test.ts index f0622778e2..760318ca65 100644 --- a/tools/bend-lint/src/lint.test.ts +++ b/tools/bend-lint/src/lint.test.ts @@ -118,7 +118,7 @@ const BEND_TESTS = [ "import/duplicate_name", ]; -// Inside the repo, so a Bend rule here can import ../../lint.bend; +// Inside the repo, so a Bend rule here can import ../../bend/lint.bend; // .tmp/ is ignored by git. const TMP = fileURLToPath(new URL("./.tmp", import.meta.url)); const DIR = (fs.mkdirSync(TMP, { recursive: true }), fs.mkdtempSync(path.join(TMP, "run-"))); @@ -126,7 +126,7 @@ const CLI = fileURLToPath(new URL("./lint.ts", import.meta.url)); // The comma-space rule, written in Bend: source text only. const COMMA_BEND = String.raw`import Base -import ../../lint.bend as Lint +import ../../bend/lint.bend as Lint def id() -> String: "style/comma-space" @@ -205,7 +205,7 @@ def main() -> IO(Unit): // form, whether the checker finds it equal to its binder's, its text, how // many times it is demanded, and what it uses. const TYPES_BEND = String.raw`import Base -import ../../lint.bend as Lint +import ../../bend/lint.bend as Lint def id() -> String: "test/types" @@ -409,7 +409,7 @@ const redundantAnnotation: LintRule = { // A Bend rule that reports its options, read with defaults. const OPTIONS_BEND = String.raw`import Base -import ../../lint.bend as Lint +import ../../bend/lint.bend as Lint def id() -> String: "test/options" @@ -432,7 +432,7 @@ def main() -> IO(Unit): // A Bend rule that counts the facts it pulls, from the linted file and its // imports: the Vars. const COUNT_BEND = String.raw`import Base -import ../../lint.bend as Lint +import ../../bend/lint.bend as Lint def id() -> String: "test/count" @@ -456,7 +456,7 @@ def main() -> IO(Unit): // first child, a Var fact's node, its first child and its fact back, and // a def that does not exist. const TREE_BEND = String.raw`import Base -import ../../lint.bend as Lint +import ../../bend/lint.bend as Lint def id() -> String: "test/tree" diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index 7b0fe5fbff..d277ec439e 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun // bend-lint checks a Bend file with bend's checker, then runs rules over // its source and the checker's results. Rules are TS modules, or Bend files -// built on ./lint.bend. It reaches bend2 only through ./seam.ts; anything +// built on ./bend/lint.bend. It reaches bend2 only through ./seam.ts; anything // there it cannot follow stops it with a drift error. The types below are // bend-lint's own, so a rule does not depend on bend2's internals. As a // CLI, it exits 0 when ok, 1 when it found an error, 2 on bad usage or a @@ -545,7 +545,7 @@ export function position(span: Span): { start: Position; end: Position } { return { start: at(span.beg), end: at(span.end) }; } -// A rule written in Bend: a file built on ./lint.bend (see there). It is +// A rule written in Bend: a file built on ./bend/lint.bend (see there). It is // checked and compiled once; each run calls its main, while effects.js // reaches bend-lint through globalThis.BEND_LINT. Offsets cross as code points. export async function bendRule(file: string): Promise { diff --git a/tools/bend-lint/src/seam.ts b/tools/bend-lint/src/seam.ts index 49c0319f52..de768f3d8b 100644 --- a/tools/bend-lint/src/seam.ts +++ b/tools/bend-lint/src/seam.ts @@ -116,7 +116,7 @@ export const DRIFT = Symbol("DriftError"); const HERE = url.fileURLToPath(new URL(".", import.meta.url)); const DRIVE = /^[A-Za-z]:(?=\/)/; const SHIM = JSON.stringify(url.pathToFileURL(nodePath.join(HERE, "seam.ts")).href); -const SAMPLE = nodePath.join(HERE, "sample.bend"); +const SAMPLE = nodePath.join(HERE, "bend", "sample.bend"); export const MARK = "BEND_LINT_PATCH"; @@ -969,7 +969,7 @@ export const guardMain = (Main: Main): void => `bend2/main.ts no longer has ${wrong}; update PATCHES in tools/bend-lint/src/seam.ts`, ); -// Checks src/sample.bend as a rule sees it. `x` in `def id(x: N) -> N: x` +// Checks src/bend/sample.bend as a rule sees it. `x` in `def id(x: N) -> N: x` // must be a Var typed N, bound as an N, used once, at its own span; `id` in // main is reported only by term_infer, and the Lam only by term_check. const selfCheck = async (m: Loaded): Promise => { @@ -990,7 +990,7 @@ const selfCheck = async (m: Loaded): Promise => { ["span", x?.span?.file.text.slice(x.span.beg, x.span.end) === "x"], ], (wrong) => - `self-check failed: the patched bend2 gave the wrong ${wrong} for src/sample.bend; update tools/bend-lint/src/seam.ts`, + `self-check failed: the patched bend2 gave the wrong ${wrong} for src/bend/sample.bend; update tools/bend-lint/src/seam.ts`, ); }; From f05a9e9d9a3a9521b59800263ecdba6db810116f Mon Sep 17 00:00:00 2001 From: Nicolas Abril Date: Thu, 8 Oct 2026 16:21:29 +0200 Subject: [PATCH 38/39] gates: drop bend-lint from the test gate, keep repo.ts LF 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) --- gates/repo.ts | 274 +++++++++++++++++++++++++------------------------- gates/test.ts | 24 +---- 2 files changed, 139 insertions(+), 159 deletions(-) diff --git a/gates/repo.ts b/gates/repo.ts index 3b05052181..1792989e84 100644 --- a/gates/repo.ts +++ b/gates/repo.ts @@ -1,137 +1,137 @@ -#!/usr/bin/env bun -// The shape of the repo: every tracked file must match one allow line, -// and the capped files below stay under their ttok caps. Anything else in -// the tree is a failure. -// -// THE CAPS ARE PERMANENT: bend.ts 48k, comp.ts 64k, main.ts 16k, safe.ts -// 24k, bendtt.lean 64k, README.md 4k, GUIDE.md 8k, and no other file has a -// cap. Do NOT raise, lower, add or remove a cap without Taelin's explicit -// authorization. A file over its cap is made smaller by simplification, -// never by moving code out. -// evals/ is not counted: it is the models' arena, not the repo's shape. - -import * as child from "node:child_process"; -import * as fs from "node:fs"; -import * as path from "node:path"; - -import * as lib from "./_lib"; - -// Types -// ===== - -type Rule = { at: RegExp; cap: number }; - -// Constants -// ========= - -const RULES: Rule[] = []; - -// Allow -// ===== - -function allow(at: string | RegExp, cap = Infinity): void { - RULES.push({ at: typeof at === "string" ? new RegExp("^" + at - .replace(/[.]/g, "\\.") + "$") : at, cap }); -} - -allow(/^\.github\/ISSUE_TEMPLATE\/(bug|feature|config)\.yml$/); -allow(".github/workflows/repo-gate.yml"); -allow(".gitattributes"); -allow(".gitignore"); -allow("AGENTS.md"); -allow("CHANGELOG.md"); -allow("README.md", 4000); -allow("WONTFIX.txt"); -allow("LICENSE"); -allow("flake.nix"); -allow("bend2/base.bend"); -allow("bend2/bend.ts", 48000); -allow("bend2/comp.ts", 64000); -allow("bend2/main.ts", 16000); -allow("bend2/safe.ts", 24000); -allow("bend2/bendtt.lean", 64000); -allow(/^bend2\/effs\/[a-z0-9_]+\.(c|js)$/); -allow(/^bend2\/pack\/(\.gitignore|package\.json|tsconfig\.json|bun\.lock)$/); -allow(/^bend2\/docs\/(BendRT|BendTT)\/(main\.typ|refs\.bib)$/); -allow("bend2/docs/bend.sublime-syntax"); -allow(/^bend2\/docs\/film\/(film\.ts|[a-z]+\.json)$/); -allow("bend2/docs/gen_anim.ts"); -allow("bend2/docs/gen_charts.ts"); -allow("bend2/docs/gen_gifs.ts"); -allow("bend2/docs/gen_pins.ts"); -allow(/^bend2\/docs\/intro\/[a-z.]+$/); -allow(/^bench\/checker\/[a-z]+_[0-9]+\/main\.(bend|agda|lean|thy|v)$/); -allow(/^bench\/checker\/_pin_\/[a-z0-9_]+\.txt$/); -allow(/^bench\/runtime\/[a-z-]+\/main\.(bend|c|lean|ts)$/); -allow(/^bench\/runtime\/_pin_\/[a-z0-9_]+\.txt$/); -allow(/^demos\/[a-z0-9_]+\/[A-Za-z0-9_]+\.bend$/); -allow(/^demos\/[a-z0-9_]+\/[A-Za-z_]+\.(c|sh|md)$/); -allow(/^demos\/[a-z0-9_]+\/web\/(index\.html|main\.js|bunfig\.toml)$/); -allow("guide/GUIDE.md", 8000); -allow("guide/EFFECTS.md"); -allow("guide/SHADERS.md"); -allow(/^paper\/(BendRT|BendTT)\.pdf$/); -allow(/^media\/intro\.(gif|mp4)$/); -allow("media/kind_devil_theorem.mp4"); -allow(/^media\/(runtime|checker|parallel)\.gif$/); -allow(/^media\/hero(_dark)?\.gif$/); -allow(/^media\/logo_(bend|hoc)\.png$/); -allow(/^media\/game_[a-z_]+\.gif$/); -allow(/^media\/slash_boss_3d\/[a-z_]+\.wav$/); -allow(/^gates\/(_lib|_run|perf|ping|repo|test|safe|safe_node|safe_diag)\.ts$/); -allow(/^tests\/[a-z]+\/([a-z0-9-]+\/)?[A-Za-z0-9_]+\.bend$/); -allow(/^tests\/[a-z]+\/[a-z0-9_]+\.(c|js)$/); -allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\.json|tsconfig\.json)$/); -allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); -allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); -allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json)$/); -allow(/^tools\/bend-lint\/src\/(lint\.ts|seam\.ts|lint\.test\.ts)$/); -allow(/^tools\/bend-lint\/src\/bend\/(lint\.bend|effects\.js|sample\.bend)$/); -allow(/^tools\/bend-lint\/rules\/[a-z_]+(\.test)?\.(ts|bend)$/); - -// Gate -// ==== - -function ttok(file: string): number { - const got = child.spawnSync("ttok", [], { input: fs.readFileSync(file) }); - const n = Number(got.stdout?.toString().trim() || NaN); - if (got.status !== 0 || !(n > 0)) { - throw new Error("ttok counted nothing for " + file + " (pipx install ttok)"); - } - return n; -} - -function gate(): string[] { - const fails: string[] = []; - const files = child.execFileSync("git", ["ls-files"], { cwd: lib.ROOT, - encoding: "utf8" }).trim().split("\n"); - for (const file of files) { - if (file.startsWith("evals/")) continue; - const rule = RULES.find((r) => r.at.test(file)); - if (rule === undefined) { - fails.push(file + ": not in the allow list"); - continue; - } - const full = path.join(lib.ROOT, file); - const size = fs.statSync(full).size; - const n = size <= rule.cap ? size : ttok(full); - if (n > rule.cap) { - fails.push(file + ": " + String(n) + " > " + String(rule.cap) + " ttok"); - } - } - return fails; -} - -// Main -// ==== - -if (import.meta.main) { - const fails = gate(); - if (!lib.GATE) { - for (const f of fails) { - console.log("FAIL " + f); - } - } - lib.verdict(RULES.length - Math.min(RULES.length, fails.length), - RULES.length); -} +#!/usr/bin/env bun +// The shape of the repo: every tracked file must match one allow line, +// and the capped files below stay under their ttok caps. Anything else in +// the tree is a failure. +// +// THE CAPS ARE PERMANENT: bend.ts 48k, comp.ts 64k, main.ts 16k, safe.ts +// 24k, bendtt.lean 64k, README.md 4k, GUIDE.md 8k, and no other file has a +// cap. Do NOT raise, lower, add or remove a cap without Taelin's explicit +// authorization. A file over its cap is made smaller by simplification, +// never by moving code out. +// evals/ is not counted: it is the models' arena, not the repo's shape. + +import * as child from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +import * as lib from "./_lib"; + +// Types +// ===== + +type Rule = { at: RegExp; cap: number }; + +// Constants +// ========= + +const RULES: Rule[] = []; + +// Allow +// ===== + +function allow(at: string | RegExp, cap = Infinity): void { + RULES.push({ at: typeof at === "string" ? new RegExp("^" + at + .replace(/[.]/g, "\\.") + "$") : at, cap }); +} + +allow(/^\.github\/ISSUE_TEMPLATE\/(bug|feature|config)\.yml$/); +allow(".github/workflows/repo-gate.yml"); +allow(".gitattributes"); +allow(".gitignore"); +allow("AGENTS.md"); +allow("CHANGELOG.md"); +allow("README.md", 4000); +allow("WONTFIX.txt"); +allow("LICENSE"); +allow("flake.nix"); +allow("bend2/base.bend"); +allow("bend2/bend.ts", 48000); +allow("bend2/comp.ts", 64000); +allow("bend2/main.ts", 16000); +allow("bend2/safe.ts", 24000); +allow("bend2/bendtt.lean", 64000); +allow(/^bend2\/effs\/[a-z0-9_]+\.(c|js)$/); +allow(/^bend2\/pack\/(\.gitignore|package\.json|tsconfig\.json|bun\.lock)$/); +allow(/^bend2\/docs\/(BendRT|BendTT)\/(main\.typ|refs\.bib)$/); +allow("bend2/docs/bend.sublime-syntax"); +allow(/^bend2\/docs\/film\/(film\.ts|[a-z]+\.json)$/); +allow("bend2/docs/gen_anim.ts"); +allow("bend2/docs/gen_charts.ts"); +allow("bend2/docs/gen_gifs.ts"); +allow("bend2/docs/gen_pins.ts"); +allow(/^bend2\/docs\/intro\/[a-z.]+$/); +allow(/^bench\/checker\/[a-z]+_[0-9]+\/main\.(bend|agda|lean|thy|v)$/); +allow(/^bench\/checker\/_pin_\/[a-z0-9_]+\.txt$/); +allow(/^bench\/runtime\/[a-z-]+\/main\.(bend|c|lean|ts)$/); +allow(/^bench\/runtime\/_pin_\/[a-z0-9_]+\.txt$/); +allow(/^demos\/[a-z0-9_]+\/[A-Za-z0-9_]+\.bend$/); +allow(/^demos\/[a-z0-9_]+\/[A-Za-z_]+\.(c|sh|md)$/); +allow(/^demos\/[a-z0-9_]+\/web\/(index\.html|main\.js|bunfig\.toml)$/); +allow("guide/GUIDE.md", 8000); +allow("guide/EFFECTS.md"); +allow("guide/SHADERS.md"); +allow(/^paper\/(BendRT|BendTT)\.pdf$/); +allow(/^media\/intro\.(gif|mp4)$/); +allow("media/kind_devil_theorem.mp4"); +allow(/^media\/(runtime|checker|parallel)\.gif$/); +allow(/^media\/hero(_dark)?\.gif$/); +allow(/^media\/logo_(bend|hoc)\.png$/); +allow(/^media\/game_[a-z_]+\.gif$/); +allow(/^media\/slash_boss_3d\/[a-z_]+\.wav$/); +allow(/^gates\/(_lib|_run|perf|ping|repo|test|safe|safe_node|safe_diag)\.ts$/); +allow(/^tests\/[a-z]+\/([a-z0-9-]+\/)?[A-Za-z0-9_]+\.bend$/); +allow(/^tests\/[a-z]+\/[a-z0-9_]+\.(c|js)$/); +allow(/^tools\/bend-fmt-lsp\/(\.gitignore|README\.md|package\.json|package-lock\.json|tsconfig\.json)$/); +allow(/^tools\/bend-fmt-lsp\/src\/(formatter|server)\.ts$/); +allow(/^tools\/bend-fmt-lsp\/src\/test\/[a-z_]+\.test\.ts$/); +allow(/^tools\/bend-lint\/(README\.md|package\.json|tsconfig\.json)$/); +allow(/^tools\/bend-lint\/src\/(lint\.ts|seam\.ts|lint\.test\.ts)$/); +allow(/^tools\/bend-lint\/src\/bend\/(lint\.bend|effects\.js|sample\.bend)$/); +allow(/^tools\/bend-lint\/rules\/[a-z_]+(\.test)?\.(ts|bend)$/); + +// Gate +// ==== + +function ttok(file: string): number { + const got = child.spawnSync("ttok", [], { input: fs.readFileSync(file) }); + const n = Number(got.stdout?.toString().trim() || NaN); + if (got.status !== 0 || !(n > 0)) { + throw new Error("ttok counted nothing for " + file + " (pipx install ttok)"); + } + return n; +} + +function gate(): string[] { + const fails: string[] = []; + const files = child.execFileSync("git", ["ls-files"], { cwd: lib.ROOT, + encoding: "utf8" }).trim().split("\n"); + for (const file of files) { + if (file.startsWith("evals/")) continue; + const rule = RULES.find((r) => r.at.test(file)); + if (rule === undefined) { + fails.push(file + ": not in the allow list"); + continue; + } + const full = path.join(lib.ROOT, file); + const size = fs.statSync(full).size; + const n = size <= rule.cap ? size : ttok(full); + if (n > rule.cap) { + fails.push(file + ": " + String(n) + " > " + String(rule.cap) + " ttok"); + } + } + return fails; +} + +// Main +// ==== + +if (import.meta.main) { + const fails = gate(); + if (!lib.GATE) { + for (const f of fails) { + console.log("FAIL " + f); + } + } + lib.verdict(RULES.length - Math.min(RULES.length, fails.length), + RULES.length); +} diff --git a/gates/test.ts b/gates/test.ts index 509c5d975d..5560a7cef9 100644 --- a/gates/test.ts +++ b/gates/test.ts @@ -10,11 +10,8 @@ // run, its JS run and its C run all print its `#|` lines; a test whose main // the compiler refuses to print (a function, a Type, an erased or dependent // field) is checked and interpreted only; a foreign def with no twin for a -// lane drops that lane; any other build failure fails its lanes. Meanwhile, -// tools/bend-lint's own suite runs here, beside the cluster, as one test: it -// patches bend.ts in memory, so it is the first to see a change it relies on. +// lane drops that lane; any other build failure fails its lanes. -import * as child from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -198,21 +195,6 @@ async function shard_run(shard: Test[], pack: Buffer, tag: number, } } -// bend-lint -// ========= - -// `bun test tools/bend-lint` (about 3 s): its failure, or null. -function lint_run(): Promise { - return new Promise((done) => { - const kid = child.spawn(BUN, ["test", "tools/bend-lint"], { cwd: lib.ROOT, stdio: ["ignore", "pipe", "pipe"] }); - let out = ""; - kid.stdout.on("data", (d: Buffer) => { out += d.toString(); }); - kid.stderr.on("data", (d: Buffer) => { out += d.toString(); }); - kid.on("close", (code) => done(code === 0 ? null - : { name: "tools/bend-lint", probe: "bun test", want: "exit 0", got: out.trim().split("\n").slice(-20).join("\n") })); - }); -} - // Main // ==== @@ -226,10 +208,8 @@ if (import.meta.main) { const pack = lib.pack(TESTS); const shards = shard_split(tests, nodes.length); const fails: Fail[] = []; - const lint = lint_run(); await lib.node_pool(nodes, shards.map((shard, tag) => (node: number) => shard_run(shard, pack, tag, node, fails))); - fails.push(...[await lint].filter((f): f is Fail => f !== null)); fails.sort((a, b) => a.name < b.name ? -1 : 1); if (!lib.GATE) { for (const f of fails) { @@ -239,5 +219,5 @@ if (import.meta.main) { } } const bad = new Set(fails.map((f) => f.name)); - lib.verdict(tests.length + 1 - bad.size, tests.length + 1); + lib.verdict(tests.length - bad.size, tests.length); } From e867ad32ef6cf2e3921be6fbe106534caf06689a Mon Sep 17 00:00:00 2001 From: Nicolas Abril Date: Thu, 8 Oct 2026 16:24:14 +0200 Subject: [PATCH 39/39] bend-lint: fixes from the review of #1389 - 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) --- tools/bend-lint/README.md | 5 +- tools/bend-lint/src/lint.test.ts | 43 ++++++++++++++++- tools/bend-lint/src/lint.ts | 29 +++++++++--- tools/bend-lint/src/seam.ts | 79 +++++++++++++++++++++----------- 4 files changed, 119 insertions(+), 37 deletions(-) diff --git a/tools/bend-lint/README.md b/tools/bend-lint/README.md index db0aeaeec6..b4b9f09103 100644 --- a/tools/bend-lint/README.md +++ b/tools/bend-lint/README.md @@ -42,8 +42,9 @@ Every fix is marked `safe`, `suggested` or `dangerous`: - `--fix-suggested` adds the suggested ones, which might. - `--fix-dangerously` applies everything. Expect breakage sometimes. -Fixes go into the file you passed in, and only if the run had no errors. When -two fixes overlap, the later one is skipped. Run it again to get it. +Fixes go into the file you passed in, even if the run had errors. A fix that +also edits another file is skipped. When two fixes overlap, the later one is +skipped. Run it again to get it. Exit code is 0 if there were no errors (warnings don't count), 1 for a checker or rule error, and 2 for bad arguments or a crash. diff --git a/tools/bend-lint/src/lint.test.ts b/tools/bend-lint/src/lint.test.ts index 760318ca65..52cfc814d9 100644 --- a/tools/bend-lint/src/lint.test.ts +++ b/tools/bend-lint/src/lint.test.ts @@ -7,7 +7,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import type { Book, LTerm, Span as BendSpan } from "bend2/bend.ts"; import type * as BendModule from "bend2/bend.ts"; import { BEND2, applyFixes, bendRule, findConfig, lint, readConfig, render } from "./lint.ts"; -import type { Diag, Fact, LintRule, Node, RuleContext, Source } from "./lint.ts"; +import type { Diag, Edit, Fact, LintRule, Node, RuleContext, Source } from "./lint.ts"; import { DRIFT, bendDir, @@ -805,6 +805,10 @@ describe("downloading bend", () => { const at = cache(); ["v2.0.3", "v2.0.12", "latest-ish"].forEach((d) => fs.mkdirSync(path.join(at, d))); expect(await latestTag(at, offline)).toBe("v2.0.12"); + expect(fs.existsSync(path.join(at, "latest.json"))).toBe(false); + const gh = github(["v2.0.40"]); + expect(await latestTag(at, gh.get)).toBe("v2.0.40"); + expect(gh.asked.length).toBe(1); await expect(latestTag(cache(), offline)).rejects.toThrow( /cannot list bend's releases \(offline\)/, ); @@ -1554,7 +1558,27 @@ describe("review fixes", () => { fix(4, 6, "Y"), fix(1, 1, "-"), ]), - ).toEqual({ text: "a bcXf", skipped: 2 }); + ).toEqual({ text: "a bcXf", skipped: 2, elsewhere: 0 }); + }); + + test("a fix that also edits another file is skipped whole", () => { + const file: Source = { path: "", text: "abc", root: true, base: false }; + const other: Source = { path: "", text: "xyz", root: false, base: false }; + const fix = (edits: Edit[]): Diag => ({ + code: "t/f", + severity: "hint", + message: "", + fixes: [{ title: "f", applicability: "safe", edits }], + }); + expect( + applyFixes(file, [ + fix([ + { span: { file, beg: 0, end: 1 }, text: "A" }, + { span: { file: other, beg: 0, end: 1 }, text: "X" }, + ]), + fix([{ span: { file, beg: 2, end: 3 }, text: "C" }]), + ]), + ).toEqual({ text: "abC", skipped: 0, elsewhere: 1 }); }); test("comp.ts that already exports js_sat still loads", () => { @@ -1928,6 +1952,21 @@ describe("cli", () => { expect(out.stderr).not.toContain("skipped"); }); + test("--fix applies fixes even when a finding is an error", () => { + const target = fixture("fix_error.bend", "#abc\n"); + const strict = module( + "strict.js", + `[{ id: "test/strict", run: (cx) => { + const span = { file: cx.root, beg: 0, end: 0 }; + return [cx.diag({ message: "e", severity: "error", span, fixes: [{ title: "e", applicability: "safe", edits: [{ span, text: "!" }] }] })]; + } }]`, + ); + const out = run(target, "--rules", strict, "--fix"); + expect(out.status).toBe(1); + expect(out.stdout).toContain("bend-lint: FAIL"); + expect(fs.readFileSync(target, "utf8")).toBe("!#abc\n"); + }); + test("--fix-suggested and --fix-dangerously apply wider levels", () => { const levels = module( "levels.js", diff --git a/tools/bend-lint/src/lint.ts b/tools/bend-lint/src/lint.ts index d277ec439e..934f0af637 100644 --- a/tools/bend-lint/src/lint.ts +++ b/tools/bend-lint/src/lint.ts @@ -456,18 +456,27 @@ function apply(text: string, edits: Edit[]): string { // The text of `file` with the fixes of the given levels applied, in order. // An edit equal to one already taken is merged; a fix with an edit that -// clashes with one already taken is skipped and counted. +// clashes with one already taken is skipped and counted. A fix for other +// files is left out; one that edits `file` and another file is skipped +// whole, never half applied, and counted apart. export function applyFixes( file: Source, diags: Diag[], levels: Applicability[] = ["safe"], -): { text: string; skipped: number } { +): { text: string; skipped: number; elsewhere: number } { const kept: Edit[] = []; let skipped = 0; + let elsewhere = 0; for (const fix of diags.flatMap((d) => d.fixes).filter((f) => levels.includes(f.applicability))) { + if (fix.edits.every((e) => e.span.file !== file)) { + continue; + } + if (fix.edits.some((e) => e.span.file !== file)) { + elsewhere += 1; + continue; + } const fresh = fix.edits.filter( (e) => - e.span.file === file && !kept.some( (k) => k.span.beg === e.span.beg && k.span.end === e.span.end && k.text === e.text, ), @@ -478,7 +487,7 @@ export function applyFixes( kept.push(...fresh); } } - return { text: apply(file.text, kept), skipped }; + return { text: apply(file.text, kept), skipped, elsewhere }; } // bend's own error layout; the head names the severity and the code, and @@ -732,9 +741,7 @@ async function cli(argv: string[]): Promise { const root = res.sources.find((s) => s.root); const levels = FIXES.find(([flag]) => values[flag])?.[1]; const fixed = - levels !== undefined && res.ok && root !== undefined - ? applyFixes(root, res.diags, levels) - : undefined; + levels !== undefined && root !== undefined ? applyFixes(root, res.diags, levels) : undefined; const where = (span: Span) => ({ path: span.file.path, range: position(span) }); console.log( values.json @@ -772,6 +779,14 @@ async function cli(argv: string[]): Promise { " fix(es) that clash with earlier ones; run the fix again to apply them", ); } + if (fixed !== undefined && fixed.elsewhere > 0) { + console.error( + "bend-lint: skipped " + + fixed.elsewhere + + " fix(es) that also edit other files; --fix writes only " + + root!.path, + ); + } return res.ok ? 0 : 1; } diff --git a/tools/bend-lint/src/seam.ts b/tools/bend-lint/src/seam.ts index de768f3d8b..d321d7732d 100644 --- a/tools/bend-lint/src/seam.ts +++ b/tools/bend-lint/src/seam.ts @@ -170,9 +170,15 @@ export const hook: { see?: (report: Report) => void } = {}; // bend.ts reads it in place of the file on disk. export const unsaved = new Map(); -// Checked base.bend books, by the text of base.bend: Base is checked once -// per process, and again only if base.bend changes. -const BASES = new Map>(); +// The text bend.ts read in the check running now, by real path. Sources +// take their text from it, so a save during the check cannot set them +// apart from what bend parsed. +let reads: Map | undefined; + +// The checked base.bend book, with the text of base.bend it was checked +// from: Base is checked once per process, and again only if base.bend +// changes. Only the latest is kept. +let BASE: { key: string; book: Promise } | undefined; // The check running now, or done; the next check waits for it. let queue: Promise = Promise.resolve(); @@ -239,12 +245,14 @@ export const relative = (from: string, to: string): string => export const fs = { ...nodeFs, realpathSync: (p: nodeFs.PathLike): string => slash(nodeFs.realpathSync(p)), - readFileSync: ((p: nodeFs.PathOrFileDescriptor, ...rest: unknown[]) => - held(p) ?? - (nodeFs.readFileSync as (...a: unknown[]) => unknown)( - p, - ...rest, - )) as typeof nodeFs.readFileSync, + readFileSync: ((p: nodeFs.PathOrFileDescriptor, ...rest: unknown[]) => { + const out = + held(p) ?? (nodeFs.readFileSync as (...a: unknown[]) => unknown)(p, ...rest); + if (reads !== undefined && typeof p === "string" && typeof out === "string") { + reads.set(slash(nodeFs.realpathSync(p)), out); + } + return out; + }) as typeof nodeFs.readFileSync, }; export const path = { @@ -366,18 +374,22 @@ export const latestTag = async (cache: string = CACHE, get: Get = download): Pro .map((t) => t.slice(1).split(".").map(Number)) .sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2]) .map((v) => "v" + v.join("."))[0]; - const listed = await get(GIT) + const got = await get(GIT) .then((res) => text(res, "")) .then( - (refs) => newest([...refs.matchAll(/refs\/tags\/(v[\d.]+)$/gm)].map((m) => m[1])), - (e: unknown) => { - const cached = newest(nodeFs.existsSync(cache) ? nodeFs.readdirSync(cache) : []); - return ( - cached ?? - Promise.reject(new Error(`cannot list bend's releases (${message(e)}); ${GIVE}`)) - ); - }, + (refs) => ({ refs }), + (e: unknown) => ({ e }), ); + if ("e" in got) { + // Offline: the newest cached release, left out of the note, so the + // next run asks again. + const cached = newest(nodeFs.existsSync(cache) ? nodeFs.readdirSync(cache) : []); + if (cached === undefined) { + throw new Error(`cannot list bend's releases (${message(got.e)}); ${GIVE}`); + } + return cached; + } + const listed = newest([...got.refs.matchAll(/refs\/tags\/(v[\d.]+)$/gm)].map((m) => m[1])); if (listed === undefined) { throw new Error(`bend has no release tags; ${GIVE}`); } @@ -512,13 +524,24 @@ export const line = (starts: number[], off: number): number => { export const mapper = (files: File[]): Mapper => { const own = new Set(files); const memo = new WeakMap(); + // Each file's lines, split once. + const split = new Map(); + const linesOf = (file: File): string[] => { + const known = split.get(file) ?? file.str.split("\n"); + split.set(file, known); + return known; + }; const find = (f: BendSpan["file"] & { dir?: string }) => { const lines = f.str.split("\n"); const found = files.filter((file) => { - const theirs = file.str.split("\n"); + if ( + f.ns !== file.ns || + (f.dir !== undefined && f.dir !== file.path.slice(0, file.path.lastIndexOf("/") + 1)) + ) { + return false; + } + const theirs = linesOf(file); return ( - f.ns === file.ns && - (f.dir === undefined || f.dir === file.path.slice(0, file.path.lastIndexOf("/") + 1)) && lines.length === theirs.length && lines.every( (l, i) => l === theirs[i] || (l.trim() === "" && /^\s*import(\s|$)/.test(theirs[i])), @@ -624,10 +647,12 @@ const checked = async ( const home = real.slice(0, real.lastIndexOf("/") + 1); const far = filters.filter((f) => f.scope === "program"); const program = far.length > 0; + const texts = new Map(); + reads = texts; const seeded = real !== "" && /^import Base$/m.test(fs.readFileSync(real, "utf8")); const key = fs.readFileSync(Bend.BASE_BEND, "utf8"); - if (seeded && !BASES.has(key)) { - BASES.set(key, Main.book_read(Bend.BASE_BEND)); + if (seeded && BASE?.key !== key) { + BASE = { key, book: Main.book_read(Bend.BASE_BEND) }; } const see = (report: Report): void => { const at = report.spn?.file as { dir?: string; ns?: string } | undefined; @@ -638,7 +663,7 @@ const checked = async ( found.push(report); } }; - const read = await Promise.resolve(seeded ? BASES.get(key) : undefined) + const read = await Promise.resolve(seeded ? BASE?.book : undefined) .then((base) => { hook.see = filters.length > 0 ? see : undefined; return Main.book_read(file, base, seen); @@ -647,12 +672,13 @@ const checked = async ( (book) => ({ book }), (e: unknown) => ({ e: e instanceof Main.Check_Fail ? e.why : e }), ); + reads = undefined; signal.throwIfAborted(); const root = [...seen.keys()].find((real) => real !== Bend.BASE_BEND || !seeded); const sources = [...seen.keys()] - .filter((real) => fs.existsSync(real)) + .filter((real) => texts.has(real) || fs.existsSync(real)) .map((real): Source => { - const text = fs.readFileSync(real, "utf8"); + const text = texts.get(real) ?? fs.readFileSync(real, "utf8"); const source = { path: real, text, root: real === root, base: real === Bend.BASE_BEND }; const file = { str: text, ns: seen.get(real) ?? "", al: {}, path: real }; FILES.set(source, file); @@ -722,6 +748,7 @@ export const check = ( return await checked(m, file, filters, signal); } finally { hook.see = undefined; + reads = undefined; unsaved.clear(); } });