diff --git a/.github/workflows/ores-lint.yml b/.github/workflows/ores-lint.yml index dd1d982..722a91c 100644 --- a/.github/workflows/ores-lint.yml +++ b/.github/workflows/ores-lint.yml @@ -15,11 +15,19 @@ jobs: - name: Detect project types id: detect run: | - [ -f package.json ] && echo "js=true" >> "$GITHUB_OUTPUT" || echo "js=false" >> "$GITHUB_OUTPUT" - [ -f Cargo.toml ] && echo "rs=true" >> "$GITHUB_OUTPUT" || echo "rs=false" >> "$GITHUB_OUTPUT" + { [ -f package.json ] || git ls-files '*.js' '*.mjs' '*.cjs' '*.ts' '*.tsx' | grep -q .; } \ + && echo "js=true" >> "$GITHUB_OUTPUT" || echo "js=false" >> "$GITHUB_OUTPUT" + { [ -f Cargo.toml ] || git ls-files '*.rs' | grep -q .; } \ + && echo "rs=true" >> "$GITHUB_OUTPUT" || echo "rs=false" >> "$GITHUB_OUTPUT" + { [ -f pubspec.yaml ] || git ls-files '*.dart' | grep -q .; } \ + && echo "dart=true" >> "$GITHUB_OUTPUT" || echo "dart=false" >> "$GITHUB_OUTPUT" + { [ -f gleam.toml ] || git ls-files '*.gleam' | grep -q .; } \ + && echo "gleam=true" >> "$GITHUB_OUTPUT" || echo "gleam=false" >> "$GITHUB_OUTPUT" + # Node is the runner for ESLint and for the cross-language require-send + # scanner, so it is installed whenever any supported language is present. - uses: actions/setup-node@v4 - if: steps.detect.outputs.js == 'true' + if: steps.detect.outputs.js == 'true' || steps.detect.outputs.rs == 'true' || steps.detect.outputs.dart == 'true' || steps.detect.outputs.gleam == 'true' with: node-version: '22' @@ -33,6 +41,12 @@ jobs: if: steps.detect.outputs.rs == 'true' run: rustup component add clippy || true + # Dart/Gleam SDKs are optional. When absent, dart.sh / gleam.sh skip + # with an actionable message; require-send.mjs still runs via Node. + - uses: dart-lang/setup-dart@v1 + if: steps.detect.outputs.dart == 'true' + continue-on-error: true + - name: Run ores-lint run: sh .ores-lint/lint.sh env: diff --git a/.ores-lint/README.md b/.ores-lint/README.md index f631380..6fe9cd4 100644 --- a/.ores-lint/README.md +++ b/.ores-lint/README.md @@ -1,8 +1,24 @@ # ores-lint -A vendored, dependency-free lint baseline for every JavaScript/TypeScript and -Rust repo in the org fleet. Everything it needs is in this directory — there is -nothing to install from a registry and nothing to keep in version sync. +A vendored, dependency-free lint baseline for every JavaScript/TypeScript, +Rust, Dart/Flutter and Gleam repo in the org fleet. Everything it needs is in +this directory — there is nothing to install from a registry and nothing to +keep in version sync. + +Only universally accepted linters are used as hosts: + +| language | host linter | custom house rules | +|---|---|---| +| TypeScript / JS | ESLint 9+ (flat config) | `ores/require-send`, `ores/semi` | +| Rust | clippy | `implicit_return` house style; `#[must_use]` on log `Event`; require-send scanner | +| Dart / Flutter | `dart analyze` | require-send scanner | +| Gleam | `gleam format --check` + `gleam check` | require-send scanner | + +There is no ESLint-quality plugin host for Gleam, and Dart's `custom_lint` / +Rust's `dylint` would pull registry packages into every repo. The one house +rule that those hosts cannot express — **logger chains must end in `send()`** — +is implemented as a vendored ESLint rule for TS and a small Node scanner for +the other three languages. ## Running it @@ -16,15 +32,15 @@ before `npm run build` (`prebuild`) and before `npm publish` (`prepublishOnly`). ## What it enforces -**JavaScript / TypeScript** — via the repo's own ESLint (flat config): +**JavaScript / TypeScript** — via ESLint (flat config): | rule | why | |---|---| | `semi` | house style: semicolons are required, missing ones warn | -| `ores/require-send` | a logging chain that reaches `.info()`/`.warn()`/… but never calls `.send()` builds an event that is never delivered | +| `ores/require-send` | a logging chain that reaches `.info()`/`.warn()`/… but never calls `.send()` or `.send(boolean)` builds an event that is never delivered | | correctness set | `eqeqeq`, `no-unreachable`, `no-dupe-keys`, `use-isnan`, `valid-typeof`, `no-async-promise-executor`, and similar low-false-positive checks | -**Rust** — via clippy: +**Rust** — via clippy, plus rustc `#[must_use]` on ores-otel `Event`: | lint | why | |---|---| @@ -32,8 +48,50 @@ before `npm run build` (`prebuild`) and before `npm publish` (`prepublishOnly`). | `clippy::correctness`, `clippy::suspicious` | real defects | | `unwrap_used`, `expect_used`, `panic_in_result_fn`, `todo`, `dbg_macro` | things that should not reach a publish | +**Dart / Flutter** — via `dart analyze` (or `flutter analyze`). If the repo has +no `analysis_options.yaml`, rollout drops a baseline of analyzer-shipped +correctness lints. Existing files are never overwritten. Prefer +`package:lints` / `package:flutter_lints` when the package already depends on +them — those are the Dart equivalents of `eslint:recommended`. + +**Gleam** — via `gleam format --check` and `gleam check`. Unused values already +catch many forgotten `send` calls; the scanner catches the rest (assigned +events, unfinished pipes). + +**require-send (all four languages).** The ores-otel logger builds an event +through method chaining (TS/Rust/Dart) or pipes (Gleam). Delivery is +`send()`, `send(boolean)`, or `send_with_store(...)`. Forgetting that call +means the event is built and then dropped — unless shutdown recovers it, which +is a fallback, not the API. Tests that deliberately build unsent events are +skipped unless `ORES_LINT_REQUIRE_SEND_INCLUDE_TESTS=1`. + +## Overriding a finding on one line + +This is expected. Some tests, shutdown-recovery fixtures, and rare control +flow should not call `send()`. Use the comment that matches the host linter, +or the unified ores-lint form which works in every language: + +``` +// ores-lint-disable-next-line require-send +// ores-lint-disable-line require-send +// ores-lint-disable-file require-send +``` + +Language-native equivalents, also honoured: + +| language | one-line override | +|---|---| +| TypeScript | `// eslint-disable-next-line ores/require-send` | +| Rust (`#[must_use]`) | `#[allow(unused_must_use)]` on the next statement | +| Dart analyzer | `// ignore: name_of_lint` (analyzer rules only; require-send uses the ores-lint form) | +| Gleam | the ores-lint form above | + +Do not disable the rule for a whole file unless the file is a generated +fixture. Prefer the next-line form. + ## The two things worth knowing + **1. Implicit-return findings are capped.** `clippy::implicit_return` fires once per implicit return, which on a real crate is hundreds of identical lines. The lint stays fully enabled so nothing is missed, but `rust.sh` collapses it into a @@ -47,6 +105,44 @@ house style asks for. Enabling `implicit_return` without allowing `needless_return` makes the two lints contradict each other on every function in the crate. `selftest.sh` asserts this stays true. +## Scope: sub-projects and repo boundaries + +The linter does **not** assume the repo root is the only project. + +- **Rust** — `rust.sh` finds every crate in the repo, including ones under + `apps/` or `clients/`. Crates that are workspace members of an already-linted + root are skipped (via `cargo metadata --no-deps`) so nothing is linted twice, + and findings from every crate are aggregated into **one** capped report. +- **JS/TS** — a flat config at the repo root makes `eslint .` reach nested + packages, so the config goes in even when the JS lives in a subdirectory. + +**Nested git repositories are a hard boundary.** A repo checked out inside +another repo gets its own ores-lint install; the parent must not lint it, or the +same findings get reported twice under the wrong repo name and the same +`package.json` gets wired with two conflicting relative paths. `rollout.mjs` +records those boundaries in `.ores-lint/nested-repos.json`, and both halves of +the linter read it. + +To exclude a repo entirely — vendored upstream forks, for instance — drop an +empty `.ores-lint-ignore` file at its root. + +## Legacy config migration + +ESLint 9+ reads flat config **only**. Three older mechanisms are silently +ignored, which means any repo still relying on them has not been linted at all: + +| legacy mechanism | status | +|---|---| +| `.eslintrc*` | ignored entirely; rules are dead | +| `eslintConfig` key in `package.json` | ignored entirely | +| `.eslintignore` | ignored, with a warning | + +`audit.mjs` reports every repo in each category. `.eslintignore` is ported +automatically into flat-config `ignores` by `base.mjs` (gitignore semantics +preserved), so its intent keeps applying. The other two need a human decision +and are migrated per repo — porting the rules that still make sense, and saying +in a comment which ones were dropped and why. + ## Warn-only, by design `lint.sh` exits 0 no matter what it finds. It is wired into build and publish @@ -68,16 +164,20 @@ this directory is managed and will be replaced on the next rollout. |---|---|---| | `ORES_LINT_MAX_EXAMPLES` | `5` | example locations shown per rule | | `ORES_LINT_STRICT` | `0` | `1` makes any finding exit non-zero | -| `ORES_LINT_SKIP_JS` / `ORES_LINT_SKIP_RUST` | `0` | skip one half | +| `ORES_LINT_SKIP_JS` / `ORES_LINT_SKIP_RUST` / `ORES_LINT_SKIP_DART` / `ORES_LINT_SKIP_GLEAM` | `0` | skip one language host | +| `ORES_LINT_SKIP_REQUIRE_SEND` | `0` | skip the cross-language send() scanner | +| `ORES_LINT_REQUIRE_SEND_INCLUDE_TESTS` | `0` | `1` also scans `test/` / `*_test.dart` / etc. | | `ORES_LINT_RUST_ALL_TARGETS` | `0` | `1` also lints tests/benches/examples | | `ORES_LINT_RUST_EXTRA` | — | extra flags appended to the clippy invocation | ## Graceful degradation Nothing here is allowed to fail loudly for an environmental reason. ESLint not -installed, too old, clippy not installed, no TypeScript parser available, crate -deps not fetchable — each is reported as an actionable skip, not an error. +installed, too old, clippy not installed, dart/flutter not installed, gleam not +installed, no TypeScript parser available, crate deps not fetchable — each is +reported as an actionable skip, not an error. Repo-specific ESLint config that already existed is never overwritten. +The same is true of `analysis_options.yaml`. CI follows the same model: the workflow runs `npm i -g eslint typescript-eslint` and never runs `npm install` for the repo itself, so linting a PR does not @@ -102,11 +202,17 @@ Once you edit that file the rollout script leaves it alone. ## Fleet operations (from the `codes` directory) ```sh +node .ores-lint-toolkit/audit.mjs # report the fleet's lint posture +node .ores-lint-toolkit/audit.mjs --json out.json # ...as machine-readable data node .ores-lint-toolkit/rollout.mjs --dry-run # preview node .ores-lint-toolkit/rollout.mjs # install / re-install everywhere node .ores-lint-toolkit/rollout.mjs --only ores-otel +node .ores-lint-toolkit/rollout.mjs --shard 0/8 # one slice of a fleet-wide run node .ores-lint-toolkit/verify.mjs # assert every repo is correctly installed ``` +A full rollout over ~900 repos takes a few minutes. `--shard k/n` splits it into +bounded chunks, which matters when the runner has a per-command time limit. + Re-run the rollout after editing anything in `.ores-lint-toolkit/` — it is idempotent and propagates the change to every repo. diff --git a/.ores-lint/VERSION b/.ores-lint/VERSION index 3eefcb9..f0bb29e 100644 --- a/.ores-lint/VERSION +++ b/.ores-lint/VERSION @@ -1 +1 @@ -1.0.0 +1.3.0 diff --git a/.ores-lint/config.sh b/.ores-lint/config.sh old mode 100755 new mode 100644 index 4706b35..b920572 --- a/.ores-lint/config.sh +++ b/.ores-lint/config.sh @@ -11,6 +11,14 @@ : "${ORES_LINT_SKIP_RUST:=0}" : "${ORES_LINT_SKIP_JS:=0}" +: "${ORES_LINT_SKIP_DART:=0}" +: "${ORES_LINT_SKIP_GLEAM:=0}" +: "${ORES_LINT_SKIP_REQUIRE_SEND:=0}" +: "${ORES_LINT_REQUIRE_SEND_INCLUDE_TESTS:=0}" + +# How deep to search for nested sub-projects (crates and packages). Repos here +# routinely hold crates under apps/ and clients/ that a root-only lint misses. +: "${ORES_LINT_DEPTH:=5}" # Include tests/benches/examples in the Rust pass. Off by default so the # pre-publish signal is about shipped code. @@ -27,7 +35,10 @@ : "${ORES_LINT_IMPLICIT_RETURN_MSG:=missing \`return\` statement}" export ORES_LINT_MAX_EXAMPLES ORES_LINT_STRICT ORES_LINT_SKIP_RUST ORES_LINT_SKIP_JS -export ORES_LINT_RUST_ALL_TARGETS ORES_LINT_ESLINT_MIN_MAJOR ORES_LINT_IMPLICIT_RETURN_MSG +export ORES_LINT_SKIP_DART ORES_LINT_SKIP_GLEAM ORES_LINT_SKIP_REQUIRE_SEND +export ORES_LINT_REQUIRE_SEND_INCLUDE_TESTS +export ORES_LINT_DEPTH ORES_LINT_RUST_ALL_TARGETS ORES_LINT_ESLINT_MIN_MAJOR +export ORES_LINT_IMPLICIT_RETURN_MSG # Repo-local overrides, never overwritten by the rollout script. Sourced last so # anything set here wins. diff --git a/.ores-lint/dart.sh b/.ores-lint/dart.sh new file mode 100755 index 0000000..bc3c7ec --- /dev/null +++ b/.ores-lint/dart.sh @@ -0,0 +1,114 @@ +#!/bin/sh +# ores-lint :: Dart / Flutter +# +# Uses the Dart analyzer (`dart analyze` / `flutter analyze`) - the universally +# accepted linter for the language, equivalent to ESLint for TypeScript. Custom +# house rules that the analyzer cannot express (require-send) live in +# require-send.mjs and run from lint.sh. +# +# Nothing is installed. Missing dart/flutter is an actionable skip. + +set -u +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$DIR/config.sh" +ROOT=${1:-.} +ROOT=$(CDPATH= cd -- "$ROOT" && pwd) + +[ "${ORES_LINT_SKIP_DART}" = "1" ] && { echo "ores-lint[dart]: skipped (ORES_LINT_SKIP_DART=1)"; exit 0; } + +has_dart=0 +if find "$ROOT" -maxdepth "$ORES_LINT_DEPTH" \ + \( -name node_modules -o -name target -o -name .git -o -name vendor -o -name .vendor -o -name build \) -prune -o \ + \( -type f -name pubspec.yaml -o -type f -name '*.dart' \) -print 2>/dev/null | head -1 | grep -q .; then + has_dart=1 +fi +[ "$has_dart" = "0" ] && exit 0 + +ANALYZE="" +if command -v dart >/dev/null 2>&1; then + ANALYZE="dart analyze" +elif command -v flutter >/dev/null 2>&1; then + ANALYZE="flutter analyze" +else + echo "ores-lint[dart]: dart/flutter not found on PATH - skipping" + echo " install the Dart SDK, or Flutter, then re-run" + exit 0 +fi + +# Nested git repos are someone else's analyzer run. +NESTED_FILE="$DIR/nested-repos.json" +EXCLUDE="" +if [ -f "$NESTED_FILE" ]; then + NESTED=$(grep -o '"[^"]*"' "$NESTED_FILE" 2>/dev/null | tr -d '"') + for nrepo in $NESTED; do + [ -n "$nrepo" ] && EXCLUDE="$EXCLUDE --fatal-infos=false" + done +fi + +OUT=$(mktemp) || exit 0 +RC=0 +( cd "$ROOT" && $ANALYZE --format=machine 2>/dev/null || $ANALYZE ) >"$OUT" 2>&1 || RC=$? + +if grep -q 'No issues found!' "$OUT"; then + echo "ores-lint[dart]: clean" + rm -f "$OUT" + exit 0 +fi + +if [ "$RC" -ne 0 ] && ! grep -qE 'error|warning|info|ERROR|WARNING' "$OUT"; then + echo "ores-lint[dart]: analyzer could not run in $ROOT (exit $RC). First lines:" + sed -n '1,6p' "$OUT" | sed 's/^/ | /' + rm -f "$OUT" + exit 0 +fi + +awk -v MAXEX="$ORES_LINT_MAX_EXAMPLES" ' +BEGIN { max = MAXEX + 0; if (max < 1) max = 1; n = 0; FS = "|" } +# machine format: SEVERITY|TYPE|FILE|LINE|COLUMN|LENGTH|CODE|MESSAGE +NF >= 8 && $1 ~ /^(ERROR|WARNING|INFO)$/ { + loc = $3 ":" $4 ":" $5 + msg = $7 ": " $8 + sev = tolower($1) + if (sev == "info") sev = "warning" + key = loc "|" msg + if (key in seen) next + seen[key] = 1 + if (!(msg in count)) { order[++n] = msg; sev_of[msg] = sev } + count[msg]++ + if (shown[msg] < max) { ex[msg] = ex[msg] (shown[msg]++ ? "\n" : "") " " loc } + next +} +# human format fallback: " warning - path:line:col - message - code" +{ + line = $0 + if (match(line, /(error|warning|info) • /) || match(line, /(error|warning|info) - /)) { + n++ + raw[++human] = line + } +} +END { + if (n == 0 && human == 0) { print "ores-lint[dart]: clean"; exit 0 } + if (n == 0) { + printf "ores-lint[dart]: %d finding(s)\n", human + limit = (human < max ? human : max) + for (i = 1; i <= limit; i++) print " " raw[i] + if (human > max) printf " ... and %d more\n", human - max + print "" + exit 0 + } + total = 0 + for (i = 1; i <= n; i++) total += count[order[i]] + printf "ores-lint[dart]: %d finding(s) across %d rule(s)\n", total, n + for (i = 1; i <= n; i++) { + msg = order[i] + printf "\n %s: %s\n", sev_of[msg], msg + printf " %d instance(s); showing %d:\n", count[msg], (count[msg] < max ? count[msg] : max) + print ex[msg] + if (count[msg] > max) printf " ... and %d more\n", count[msg] - max + } + print "" +} +' "$OUT" + +rm -f "$OUT" +exit 0 diff --git a/.ores-lint/eslint/base.mjs b/.ores-lint/eslint/base.mjs index 79b372e..596f2f2 100644 --- a/.ores-lint/eslint/base.mjs +++ b/.ores-lint/eslint/base.mjs @@ -7,6 +7,7 @@ * to hundreds of heterogeneous repos must never be the thing that breaks them. */ +import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import oresPlugin from './plugin.mjs'; @@ -61,6 +62,36 @@ async function loadTsSupport() { const JS_FILES = ['**/*.js', '**/*.mjs', '**/*.cjs', '**/*.jsx']; const TS_FILES = ['**/*.ts', '**/*.mts', '**/*.cts', '**/*.tsx']; +/** + * Directories that are separate git repositories nested inside this one. They + * have their own ores-lint install and must not be linted from here, or their + * findings would be reported twice under the wrong repo. + */ +/** + * ESLint 10 dropped support for `.eslintignore` and merely warns that it is + * being ignored. Rather than let a repo's stated intent silently stop applying, + * port it into flat-config `ignores`. + */ +function legacyIgnoreFile() { + try { + const raw = readFileSync(new URL('../../.eslintignore', import.meta.url), 'utf8'); + return raw.split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#') && !l.startsWith('!')) + // .eslintignore used gitignore semantics: a bare name matched anywhere. + .map((l) => (l.includes('/') ? l.replace(/^\/+/, '') : `**/${l}`)) + .map((l) => (l.endsWith('/') ? `${l}**` : l)); + } catch { return []; } +} + +function nestedRepoIgnores() { + try { + const raw = readFileSync(new URL('../nested-repos.json', import.meta.url), 'utf8'); + const dirs = JSON.parse(raw); + return Array.isArray(dirs) ? dirs.map((d) => `${d}/**`) : []; + } catch { return []; } +} + const IGNORES = [ '**/node_modules/**', '**/dist/**', '**/build/**', '**/out/**', '**/target/**', '**/coverage/**', '**/.next/**', '**/vendor/**', '**/*.min.js', '**/*.bundle.js', @@ -109,7 +140,7 @@ export default async function oresConfig(opts = {}) { }; const configs = [ - { ignores: [...IGNORES, ...(opts.ignores || [])] }, + { ignores: [...IGNORES, ...nestedRepoIgnores(), ...legacyIgnoreFile(), ...(opts.ignores || [])] }, { files: JS_FILES, plugins: { ores: oresPlugin }, @@ -118,6 +149,15 @@ export default async function oresConfig(opts = {}) { }, ]; + if (!ts) { + // No TypeScript parser anywhere. Globally ignore TS rather than leaving it + // merely unmatched: a repo-specific config block that happens to match + // `**/*.ts` would otherwise hand TS source to the JS parser and produce a + // wall of bogus "Parsing error" findings. Ignoring is honest; js.sh prints + // a note so the gap stays visible instead of looking like a clean repo. + configs.push({ ignores: TS_FILES }); + } + if (ts) { configs.push({ files: TS_FILES, diff --git a/.ores-lint/eslint/formatter.mjs b/.ores-lint/eslint/formatter.mjs index 56c1a3d..e4a9a52 100644 --- a/.ores-lint/eslint/formatter.mjs +++ b/.ores-lint/eslint/formatter.mjs @@ -36,11 +36,19 @@ export default function oresFormatter(results) { } } - if (!byRule.size && !parseErrors.length) return 'ores-lint[js]: clean\n'; + // Report how many files were actually examined. "clean" and "nothing was + // linted" are otherwise indistinguishable, which makes a silent coverage gap + // look like a passing repo - the single most misleading thing a linter can do. + const examined = results.length; + if (!byRule.size && !parseErrors.length) { + return examined === 0 + ? 'ores-lint[js]: no lintable files matched (check ignores / file extensions)\n' + : `ores-lint[js]: clean (${examined} file${examined === 1 ? '' : 's'} linted)\n`; + } const out = []; const total = errors + warnings; - out.push(`ores-lint[js]: ${total} finding(s) across ${byRule.size} rule(s) in ${files} file(s)`); + out.push(`ores-lint[js]: ${total} finding(s) across ${byRule.size} rule(s) in ${files} of ${examined} file(s) linted`); // House rules first, then the rest by frequency. const ordered = [...byRule.entries()].sort((a, b) => { diff --git a/.ores-lint/eslint/plugin.mjs b/.ores-lint/eslint/plugin.mjs index f348759..b010e4d 100644 --- a/.ores-lint/eslint/plugin.mjs +++ b/.ores-lint/eslint/plugin.mjs @@ -13,7 +13,7 @@ */ const DEFAULT_LEVEL_METHODS = ['trace', 'debug', 'info', 'log', 'warn', 'error', 'fatal']; -const DEFAULT_TERMINAL_METHODS = ['send']; +const DEFAULT_TERMINAL_METHODS = ['send', 'send_with_store']; const LOGGER_EXPORTS = new Set([ 'logger', 'browserLogger', 'edgeLogger', 'cloudflareWorkerLogger', @@ -93,10 +93,22 @@ function isTrackedModule(source, moduleNames) { return false; } +function inspectChain(node, knownLoggers, levelMethods, terminalMethods) { + const methods = []; + const root = collectCallChain(node, methods); + const levelIndex = methods.findIndex((method) => levelMethods.has(method)); + const delivered = levelIndex >= 0 && methods.slice(levelIndex + 1).some((method) => terminalMethods.has(method)); + const isEvent = Boolean(root && knownLoggers.has(root) && levelIndex >= 0); + return { methods, root, levelIndex, delivered, isEvent }; +} + export const requireSendRule = { meta: { type: 'problem', - docs: { description: 'require chainable logger events to call a terminal method such as send()' }, + docs: { + description: 'require chainable logger events to call a terminal method such as send() or send(boolean)', + url: 'https://github.com/ores-otel/ores.otel.log', + }, schema: [{ type: 'object', properties: { @@ -108,7 +120,7 @@ export const requireSendRule = { additionalProperties: false, }], messages: { - missingSend: "Logging chain never calls {{terminal}} - this log event is built but never delivered.", + missingSend: "Logging chain never calls {{terminal}} - this log event is built but never delivered. Override with // eslint-disable-next-line ores/require-send or // ores-lint-disable-next-line require-send", }, }, @@ -122,6 +134,36 @@ export const requireSendRule = { const terminalMethods = new Set(options.terminalMethods || DEFAULT_TERMINAL_METHODS); const terminalLabel = [...terminalMethods].map((m) => `.${m}()`).join(' or '); + const scopes = []; + const enterScope = () => scopes.push({ pending: new Map() }); + const exitScope = () => { + const scope = scopes.pop(); + if (!scope) return; + for (const node of scope.pending.values()) { + context.report({ node, messageId: 'missingSend', data: { terminal: terminalLabel } }); + } + }; + const markPending = (name, node) => { + if (!name || !scopes.length) return; + scopes[scopes.length - 1].pending.set(name, node); + }; + const clearPending = (name) => { + if (!name) return; + for (let i = scopes.length - 1; i >= 0; i--) { + if (scopes[i].pending.has(name)) { + scopes[i].pending.delete(name); + return; + } + } + }; + const isPending = (name) => { + if (!name) return false; + for (let i = scopes.length - 1; i >= 0; i--) { + if (scopes[i].pending.has(name)) return true; + } + return false; + }; + const isLoggerProducer = (node) => { const current = unwrap(node); if (!current) return false; @@ -144,7 +186,40 @@ export const requireSendRule = { return false; }; + const consumeTerminalUse = (node) => { + const current = unwrap(node); + if (!current) return; + const chain = inspectChain(current, knownLoggers, levelMethods, terminalMethods); + if (chain.root && chain.methods.some((method) => terminalMethods.has(method))) { + clearPending(chain.root); + } + if (hasType(current, 'CallExpression', 'OptionalCallExpression')) { + for (const arg of current.arguments || []) { + const name = getQualifiedName(unwrap(arg)); + if (isPending(name)) clearPending(name); + } + } + }; + + const functionEnter = () => enterScope(); + const functionExit = () => exitScope(); + return { + Program() { enterScope(); }, + 'Program:exit'() { exitScope(); }, + FunctionDeclaration: functionEnter, + 'FunctionDeclaration:exit': functionExit, + FunctionExpression: functionEnter, + 'FunctionExpression:exit': functionExit, + ArrowFunctionExpression(node) { + enterScope(); + if (node.body && node.body.type !== 'BlockStatement') { + const name = getQualifiedName(unwrap(node.body)); + if (isPending(name)) clearPending(name); + } + }, + 'ArrowFunctionExpression:exit': functionExit, + ImportDeclaration(node) { if (!isTrackedModule(node.source?.value, moduleNames)) return; for (const specifier of node.specifiers || []) { @@ -169,22 +244,47 @@ export const requireSendRule = { if (node.id?.type === 'Identifier' && node.id.name && isLoggerProducer(node.init)) { knownLoggers.add(node.id.name); } + if (node.id?.type !== 'Identifier' || !node.id.name) return; + const chain = inspectChain(node.init, knownLoggers, levelMethods, terminalMethods); + if (!chain.isEvent) return; + if (chain.delivered) return; + markPending(node.id.name, node); }, AssignmentExpression(node) { const assignedName = getQualifiedName(node.left); if (assignedName && isLoggerProducer(node.right)) knownLoggers.add(assignedName); + const chain = inspectChain(node.right, knownLoggers, levelMethods, terminalMethods); + if (chain.isEvent && !chain.delivered && assignedName) { + markPending(assignedName, node); + return; + } + consumeTerminalUse(node.right); + }, + + ReturnStatement(node) { + if (!node.argument) return; + const name = getQualifiedName(unwrap(node.argument)); + if (isPending(name)) { + clearPending(name); + return; + } + const chain = inspectChain(node.argument, knownLoggers, levelMethods, terminalMethods); + if (chain.isEvent && !chain.delivered) return; + consumeTerminalUse(node.argument); + }, + + CallExpression(node) { + consumeTerminalUse(node); }, ExpressionStatement(node) { - const methods = []; - const root = collectCallChain(node.expression, methods); - if (!root || !knownLoggers.has(root)) return; - const levelIndex = methods.findIndex((method) => levelMethods.has(method)); - if (levelIndex < 0) return; - const tail = methods.slice(levelIndex + 1); - if (tail.some((method) => terminalMethods.has(method))) return; - context.report({ node, messageId: 'missingSend', data: { terminal: terminalLabel } }); + const chain = inspectChain(node.expression, knownLoggers, levelMethods, terminalMethods); + if (chain.isEvent && !chain.delivered) { + context.report({ node, messageId: 'missingSend', data: { terminal: terminalLabel } }); + return; + } + consumeTerminalUse(node.expression); }, }; }, @@ -257,5 +357,5 @@ export const rules = { semi: semiRule, }; -const plugin = { meta: { name: 'ores-lint', version: '1.0.0' }, rules }; +const plugin = { meta: { name: 'ores-lint', version: '1.3.0' }, rules }; export default plugin; diff --git a/.ores-lint/gleam.sh b/.ores-lint/gleam.sh new file mode 100755 index 0000000..f3ec2df --- /dev/null +++ b/.ores-lint/gleam.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# ores-lint :: Gleam +# +# Uses the Gleam compiler toolchain - the universally accepted checker for the +# language (there is no ESLint-equivalent plugin host): +# gleam format --check formatting, analogous to rustfmt --check +# gleam check compiler warnings / unused values +# Custom house rules (require-send on logging pipes) live in require-send.mjs +# and run from lint.sh. +# +# Nothing is installed. Missing gleam is an actionable skip. + +set -u +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$DIR/config.sh" +ROOT=${1:-.} +ROOT=$(CDPATH= cd -- "$ROOT" && pwd) + +[ "${ORES_LINT_SKIP_GLEAM}" = "1" ] && { echo "ores-lint[gleam]: skipped (ORES_LINT_SKIP_GLEAM=1)"; exit 0; } + +TOMLS=$(cd "$ROOT" && find . -maxdepth "$ORES_LINT_DEPTH" \ + \( -name node_modules -o -name target -o -name .git -o -name build -o -name vendor -o -name .vendor -o -name .ores-lint \) -prune -o \ + -type f -name gleam.toml -print 2>/dev/null \ + | sed 's|^\./||; s|gleam\.toml$||; s|/$||; s|^$|.|' | sort) + +[ -z "$TOMLS" ] && exit 0 + +command -v gleam >/dev/null 2>&1 || { + echo "ores-lint[gleam]: gleam not found on PATH - skipping" + echo " install from https://gleam.run/getting-started/installing/" + exit 0 +} + +NESTED_FILE="$DIR/nested-repos.json" +if [ -f "$NESTED_FILE" ]; then + NESTED=$(grep -o '"[^"]*"' "$NESTED_FILE" 2>/dev/null | tr -d '"') + if [ -n "$NESTED" ]; then + KEPT="" + for c in $TOMLS; do + drop=0 + for nrepo in $NESTED; do + case "$c" in + "$nrepo"|"$nrepo"/*) drop=1; break ;; + esac + done + [ "$drop" = "0" ] && KEPT="$KEPT +$c" + done + TOMLS=$(printf '%s' "$KEPT" | sed '/^$/d') + fi +fi + +[ -z "$TOMLS" ] && { echo "ores-lint[gleam]: all packages belong to nested repos - nothing to do here"; exit 0; } + +RAW=$(mktemp) || exit 0 +RAN=0 +FAILED="" + +for c in $TOMLS; do + if [ "$c" = "." ]; then cdir="$ROOT"; else cdir="$ROOT/$c"; fi + OUT=$(mktemp) + RC=0 + FMT_ARGS="src" + [ -d "$cdir/test" ] && FMT_ARGS="$FMT_ARGS test" + ( cd "$cdir" && gleam format --check $FMT_ARGS 2>/dev/null; gleam check ) >"$OUT" 2>&1 || RC=$? + RAN=$((RAN + 1)) + if [ "$RC" -ne 0 ] && ! grep -qE 'error|warning|Which files to format' "$OUT"; then + FAILED="$FAILED + $c (exit $RC): $(sed -n '1,2p' "$OUT" | tr '\n' ' ' | cut -c1-140)" + rm -f "$OUT" + continue + fi + if [ "$c" = "." ]; then cat "$OUT" >> "$RAW"; else sed "s|^|$c/|" "$OUT" >> "$RAW"; fi + rm -f "$OUT" +done + +echo "ores-lint[gleam]: linted $RAN package(s)" +[ -n "$FAILED" ] && printf 'ores-lint[gleam]: gleam could not run in some packages:%s\n' "$FAILED" + +awk -v MAXEX="$ORES_LINT_MAX_EXAMPLES" ' +BEGIN { max = MAXEX + 0; if (max < 1) max = 1; n = 0 } +/error:|warning:/ { + msg = $0 + n++ + if (shown < max) { ex = ex (shown++ ? "\n" : "") " " msg; } + next +} +END { + if (n == 0) { print "ores-lint[gleam]: clean"; exit 0 } + printf "ores-lint[gleam]: %d finding(s)\n", n + print ex + if (n > max) printf " ... and %d more\n", n - max + print "" +} +' "$RAW" + +rm -f "$RAW" +exit 0 diff --git a/.ores-lint/js.sh b/.ores-lint/js.sh index bb0af4b..ecbb076 100755 --- a/.ores-lint/js.sh +++ b/.ores-lint/js.sh @@ -73,6 +73,24 @@ done [ -n "$GLOBAL_ROOT" ] && export ORES_LINT_GLOBAL_ROOT="$GLOBAL_ROOT" [ -n "$GLOBAL_ROOT" ] && export NODE_PATH="${NODE_PATH:+$NODE_PATH:}$GLOBAL_ROOT" +# Make the TypeScript gap visible. A repo with a tsconfig whose .ts files are +# being skipped looks identical to a clean repo otherwise. +if [ -f "$ROOT/tsconfig.json" ] || ls "$ROOT"/src/*.ts >/dev/null 2>&1; then + if ! node -e " + const {createRequire}=require('node:module'); + const r=createRequire('$ROOT/package.json'); + const paths=[process.env.ORES_LINT_GLOBAL_ROOT].filter(Boolean); + for (const id of ['typescript-eslint','@typescript-eslint/parser']) { + try { r.resolve(id); process.exit(0); } catch {} + if (paths.length) { try { r.resolve(id,{paths}); process.exit(0); } catch {} } + } + process.exit(1); + " 2>/dev/null; then + echo "ores-lint[js]: NOTE - this repo has TypeScript but no typescript-eslint parser;" + echo " .ts/.tsx files are being SKIPPED. Fix with: npm i -g typescript-eslint" + fi +fi + OUT=$(mktemp) || exit 0 RC=0 ( cd "$ROOT" && "$ESLINT" . \ diff --git a/.ores-lint/lint.sh b/.ores-lint/lint.sh index 35a8376..af39cca 100755 --- a/.ores-lint/lint.sh +++ b/.ores-lint/lint.sh @@ -1,9 +1,13 @@ #!/bin/sh # ores-lint :: entry point # -# Warn-only by default. This is wired into prebuild / prepublishOnly across -# many repos, so it is designed to be incapable of breaking a build unless a +# Warn-only by default. This is wired into build and publish hooks across +# hundreds of repos, so it is designed to be incapable of breaking one unless a # human explicitly sets ORES_LINT_STRICT=1. +# +# Both halves discover sub-projects rather than assuming the repo root is the +# only project: `eslint .` walks nested packages from the root config, and +# rust.sh finds every crate including ones buried under apps/ or clients/. set -u DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) @@ -15,17 +19,52 @@ echo "ores-lint v$(cat "$DIR/VERSION" 2>/dev/null || echo '?') :: $(basename "$R FOUND=0 LOG=$(mktemp) || exit 0 -if [ -f "$ROOT/package.json" ]; then - sh "$DIR/js.sh" "$ROOT" | tee -a "$LOG" +# JS: an eslint flat config at the repo root is the trigger. The rollout puts +# one there whenever the repo contains any JS/TS at all, so nested packages in +# a Rust-rooted repo still get linted. +for c in eslint.config.mjs eslint.config.js eslint.config.cjs; do + if [ -f "$ROOT/$c" ]; then + sh "$DIR/js.sh" "$ROOT" | tee -a "$LOG" + FOUND=1 + break + fi +done + +# Rust: any Cargo.toml anywhere in the repo, not just at the root. +if find "$ROOT" -maxdepth "$ORES_LINT_DEPTH" \ + \( -name node_modules -o -name target -o -name .git -o -name vendor -o -name .vendor \) -prune -o \ + -type f -name Cargo.toml -print 2>/dev/null | head -1 | grep -q .; then + sh "$DIR/rust.sh" "$ROOT" | tee -a "$LOG" FOUND=1 fi -if [ -f "$ROOT/Cargo.toml" ]; then - sh "$DIR/rust.sh" "$ROOT" | tee -a "$LOG" +# Dart / Flutter: pubspec.yaml or any .dart source. +if find "$ROOT" -maxdepth "$ORES_LINT_DEPTH" \ + \( -name node_modules -o -name target -o -name .git -o -name vendor -o -name .vendor -o -name build \) -prune -o \ + \( -type f -name pubspec.yaml -o -type f -name '*.dart' \) -print 2>/dev/null | head -1 | grep -q .; then + sh "$DIR/dart.sh" "$ROOT" | tee -a "$LOG" FOUND=1 fi -[ "$FOUND" = "0" ] && echo "ores-lint: no package.json or Cargo.toml at repo root - nothing to do" +# Gleam: gleam.toml or any .gleam source. +if find "$ROOT" -maxdepth "$ORES_LINT_DEPTH" \ + \( -name node_modules -o -name target -o -name .git -o -name vendor -o -name .vendor -o -name build \) -prune -o \ + \( -type f -name gleam.toml -o -type f -name '*.gleam' \) -print 2>/dev/null | head -1 | grep -q .; then + sh "$DIR/gleam.sh" "$ROOT" | tee -a "$LOG" + FOUND=1 +fi + +# House rule shared across Rust / Dart / Gleam (TypeScript is ores/require-send). +if command -v node >/dev/null 2>&1 && [ -f "$DIR/require-send.mjs" ]; then + if find "$ROOT" -maxdepth "$ORES_LINT_DEPTH" \ + \( -name node_modules -o -name target -o -name .git -o -name vendor -o -name .vendor -o -name build \) -prune -o \ + -type f \( -name '*.rs' -o -name '*.dart' -o -name '*.gleam' \) -print 2>/dev/null | head -1 | grep -q .; then + node "$DIR/require-send.mjs" "$ROOT" | tee -a "$LOG" + FOUND=1 + fi +fi + +[ "$FOUND" = "0" ] && echo "ores-lint: no JS, Rust, Dart or Gleam project found in this repo - nothing to do" if [ "${ORES_LINT_STRICT}" = "1" ] && grep -q 'finding(s) across' "$LOG"; then rm -f "$LOG" diff --git a/.ores-lint/nested-repos.json b/.ores-lint/nested-repos.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/.ores-lint/nested-repos.json @@ -0,0 +1 @@ +[] diff --git a/.ores-lint/require-send.mjs b/.ores-lint/require-send.mjs new file mode 100644 index 0000000..7662ddd --- /dev/null +++ b/.ores-lint/require-send.mjs @@ -0,0 +1,448 @@ +#!/usr/bin/env node +/** + * ores-lint :: require-send (Rust, Dart, Gleam) + * + * House rule, same contract as ores/require-send in ESLint: a logging chain + * that reaches a level method must be delivered with send() / send(boolean) / + * send_with_store(...). TypeScript stays on ESLint; this file covers the + * languages ESLint cannot parse. + * + * Line-level overrides (any of these, on the finding line or the previous line): + * ores-lint-disable-next-line require-send + * ores-lint-disable-line require-send + * File-level: + * ores-lint-disable-file require-send + * + * Warn-only. Prints the same capped report format as rust.sh / js.sh. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; + +const ROOT = process.argv[2] ? process.argv[2] : process.cwd(); +const DIR = dirname(new URL(import.meta.url).pathname); +const MAX = Math.max(1, Number(process.env.ORES_LINT_MAX_EXAMPLES || 5)); +const INCLUDE_TESTS = process.env.ORES_LINT_REQUIRE_SEND_INCLUDE_TESTS === '1'; +const LEVEL = new Set(['trace', 'debug', 'info', 'log', 'warn', 'error', 'fatal']); +const TERMINAL = new Set(['send', 'send_with_store']); +const SKIP_DIR = new Set([ + 'node_modules', 'target', 'dist', 'build', 'out', 'vendor', 'coverage', + '.git', '.worktrees', '_to_delete', '.next', '.ores-lint', '.vendor', + 'deps', 'third_party', 'thirdparty', 'external', 'submodules', '.r2g', +]); +const TEST_RE = /(?:^|\/)(?:test|tests|spec)\/|_test\.(?:dart|gleam|rs)$|\.test\.|\.spec\./i; + +function nestedRepos() { + try { + const raw = JSON.parse(readFileSync(join(DIR, 'nested-repos.json'), 'utf8')); + return Array.isArray(raw) ? raw : []; + } catch { + return []; + } +} + +function trackedFiles() { + let output; + try { + output = execFileSync('git', ['-C', ROOT, 'ls-files', '-z'], { + encoding: 'buffer', + maxBuffer: 64 * 1024 * 1024, + }).toString('utf8'); + } catch { + return []; + } + const nested = nestedRepos(); + return output.split('\0').filter(Boolean).filter((rel) => { + if (!/\.(?:rs|dart|gleam)$/.test(rel)) return false; + const parts = rel.split('/'); + if (parts.some((p) => SKIP_DIR.has(p))) return false; + if (nested.some((n) => rel === n || rel.startsWith(`${n}/`))) return false; + if (!INCLUDE_TESTS && TEST_RE.test(rel)) return false; + return true; + }); +} + +function looksLikeLogger(name) { + if (!name) return false; + const base = String(name).split('.').pop(); + return ( + /^(?:log|logger|ddlog|telemetry|audit|self|this)$/i.test(base) + || /logger$/i.test(base) + || /(?:^|_)log$/i.test(base) + ); +} + +function isDisabled(suppressions, line) { + if (suppressions.file) return true; + if (suppressions.lines.has(line) || suppressions.lines.has(line - 1)) return true; + if (suppressions.next.has(line - 1)) return true; + return false; +} + +function collectSuppressions(source) { + const file = /ores-lint-disable-file\s+require-send/.test(source); + const lines = new Set(); + const next = new Set(); + const raw = source.split('\n'); + for (let i = 0; i < raw.length; i++) { + const line = raw[i]; + if (/ores-lint-disable-line\s+require-send/.test(line) || /eslint-disable-line\s+ores\/require-send/.test(line)) { + lines.add(i + 1); + } + if (/ores-lint-disable-next-line\s+require-send/.test(line) || /eslint-disable-next-line\s+ores\/require-send/.test(line)) { + next.add(i + 1); + } + } + return { file, lines, next }; +} + +function tokenize(source) { + const tokens = []; + const n = source.length; + let i = 0; + let line = 1; + let col = 1; + const push = (type, value, startLine, startCol) => { + tokens.push({ type, value, line: startLine, col: startCol }); + }; + const bump = (ch) => { + if (ch === '\n') { line += 1; col = 1; } else col += 1; + }; + + while (i < n) { + const ch = source[i]; + const startLine = line; + const startCol = col; + + if (ch === '/' && source[i + 1] === '/') { + while (i < n && source[i] !== '\n') { bump(source[i]); i += 1; } + continue; + } + if (ch === '/' && source[i + 1] === '*') { + i += 2; bump('/'); bump('*'); + while (i < n && !(source[i] === '*' && source[i + 1] === '/')) { bump(source[i]); i += 1; } + if (i < n) { bump('*'); bump('/'); i += 2; } + continue; + } + if (ch === '#') { + // Gleam does not use # comments; rust raw strings / dart interpolations + // are handled as identifiers or other. Treat # as other. + } + if (ch === '"' || ch === "'" || ch === '`') { + const q = ch; + bump(ch); i += 1; + while (i < n && source[i] !== q) { + if (source[i] === '\\' && i + 1 < n) { bump(source[i]); bump(source[i + 1]); i += 2; continue; } + if (source[i] === '\n' && q !== '`') break; + bump(source[i]); i += 1; + } + if (i < n && source[i] === q) { bump(q); i += 1; } + push('string', '', startLine, startCol); + continue; + } + if (/\s/.test(ch)) { + bump(ch); i += 1; + continue; + } + if (ch === '|' && source[i + 1] === '>') { + push('pipe', '|>', startLine, startCol); + bump('|'); bump('>'); i += 2; + continue; + } + if (ch === '=' && source[i + 1] === '>') { + push('arrow', '=>', startLine, startCol); + bump('='); bump('>'); i += 2; + continue; + } + if (/[A-Za-z_]/.test(ch)) { + let value = ''; + while (i < n && /[A-Za-z0-9_]/.test(source[i])) { value += source[i]; bump(source[i]); i += 1; } + push('ident', value, startLine, startCol); + continue; + } + const singles = { + '.': 'dot', '(': 'lparen', ')': 'rparen', '[': 'lbracket', ']': 'rbracket', + '{': 'lbrace', '}': 'rbrace', ';': 'semi', ',': 'comma', '=': 'eq', + }; + if (singles[ch]) { + push(singles[ch], ch, startLine, startCol); + bump(ch); i += 1; + continue; + } + bump(ch); i += 1; + } + return tokens; +} + +function skipBalanced(tokens, start, open, close) { + let depth = 0; + for (let i = start; i < tokens.length; i++) { + if (tokens[i].type === open) depth += 1; + else if (tokens[i].type === close) { + depth -= 1; + if (depth === 0) return i; + } + } + return tokens.length - 1; +} + +function qualifiedName(tokens, index) { + // Walk left across ident.ident + let i = index; + if (!tokens[i] || tokens[i].type !== 'ident') return { name: '', start: index }; + let name = tokens[i].value; + while (i >= 2 && tokens[i - 1].type === 'dot' && tokens[i - 2].type === 'ident') { + name = `${tokens[i - 2].value}.${name}`; + i -= 2; + } + return { name, start: i }; +} + +function countTopLevelArgs(tokens, lparenIndex) { + if (tokens[lparenIndex]?.type !== 'lparen') return 0; + let depth = 0; + let args = 0; + let seen = false; + for (let i = lparenIndex; i < tokens.length; i++) { + const t = tokens[i]; + if (t.type === 'lparen' || t.type === 'lbracket' || t.type === 'lbrace') depth += 1; + else if (t.type === 'rparen' || t.type === 'rbracket' || t.type === 'rbrace') { + depth -= 1; + if (depth === 0) return seen ? args + 1 : 0; + } else if (depth === 1 && t.type === 'comma') args += 1; + else if (depth === 1 && t.type !== 'comma') seen = true; + } + return seen ? args + 1 : 0; +} + +function walkMethodChain(tokens, start) { + // start at the root ident of `root.level(args).more(args)` + const methods = []; + let i = start; + if (!tokens[i] || tokens[i].type !== 'ident') return null; + while (i + 2 < tokens.length && tokens[i + 1].type === 'dot' && tokens[i + 2].type === 'ident' && tokens[i + 3]?.type !== 'lparen') { + if (LEVEL.has(tokens[i + 2].value) || TERMINAL.has(tokens[i + 2].value)) break; + i += 2; + } + const root = qualifiedName(tokens, i).name; + let firstArgCount = 0; + while (i + 2 < tokens.length && tokens[i + 1].type === 'dot' && tokens[i + 2].type === 'ident') { + const method = tokens[i + 2].value; + methods.push(method); + i += 2; + if (tokens[i + 1]?.type === 'lparen') { + if (methods.length === 1) firstArgCount = countTopLevelArgs(tokens, i + 1); + i = skipBalanced(tokens, i + 1, 'lparen', 'rparen'); + } + } + return { root, methods, end: i, line: tokens[start].line, firstArgCount }; +} + +function walkGleamPipe(tokens, start) { + // start at ident of a call: `logging.info(...)` or `info(...)` + if (!tokens[start] || tokens[start].type !== 'ident') return null; + const head = qualifiedName(tokens, start); + let i = start; + while (i + 1 < tokens.length && tokens[i + 1].type === 'dot' && tokens[i + 2]?.type === 'ident') i += 2; + const callee = qualifiedName(tokens, i).name; + const calleeBase = callee.split('.').pop(); + if (tokens[i + 1]?.type !== 'lparen') { + // Bare `|> send` / `|> logging.send` only — not type variables named `error`. + if (TERMINAL.has(calleeBase) && tokens[head.start - 1]?.type === 'pipe') { + return { callee, methods: [calleeBase], end: i, line: tokens[head.start].line }; + } + return null; + } + const methods = [calleeBase]; + i = skipBalanced(tokens, i + 1, 'lparen', 'rparen'); + while (tokens[i + 1]?.type === 'pipe') { + i += 1; + if (tokens[i + 1]?.type !== 'ident') break; + i += 1; + while (i + 1 < tokens.length && tokens[i + 1].type === 'dot' && tokens[i + 2]?.type === 'ident') i += 2; + const step = tokens[i].value; + methods.push(step); + if (tokens[i + 1]?.type === 'lparen') i = skipBalanced(tokens, i + 1, 'lparen', 'rparen'); + } + return { callee, methods, end: i, line: tokens[head.start].line }; +} + +function precedingAssignment(tokens, start) { + // `let name =` or `final name =` or `var name =` immediately before start + let i = start - 1; + if (tokens[i]?.type !== 'eq') return null; + i -= 1; + if (tokens[i]?.type !== 'ident') return null; + const name = tokens[i].value; + const prev = tokens[i - 1]?.value; + if (prev && /^(let|var|final|const|mut)$/.test(prev)) return name; + // dart `LogEvent event =` / rust `let mut event =` already handled via let + if (tokens[i - 1]?.type === 'ident') return name; + return name; +} + +function isReturnish(tokens, start) { + const prev = tokens[start - 1]; + if (!prev) return false; + if (prev.type === 'arrow') return true; + if (prev.type === 'ident' && prev.value === 'return') return true; + return false; +} + +function nextNonChainIsSemi(tokens, end) { + const t = tokens[end + 1]; + return t?.type === 'semi'; +} + +export function analyzeSource(source, language) { + const suppressions = collectSuppressions(source); + const tokens = tokenize(source); + const findings = []; + const pending = new Map(); + const scopeStack = [pending]; + + const current = () => scopeStack[scopeStack.length - 1]; + const mark = (name, finding) => { if (name) current().set(name, finding); }; + const clear = (name) => { + if (!name) return; + for (let i = scopeStack.length - 1; i >= 0; i--) { + if (scopeStack[i].has(name)) { scopeStack[i].delete(name); return; } + } + }; + const report = (finding) => { + if (isDisabled(suppressions, finding.line)) return; + findings.push(finding); + }; + const flushScope = (map) => { + for (const finding of map.values()) report(finding); + }; + + for (let i = 0; i < tokens.length; i++) { + const tok = tokens[i]; + if (tok.type === 'lbrace') { scopeStack.push(new Map()); continue; } + if (tok.type === 'rbrace') { + if (scopeStack.length > 1) flushScope(scopeStack.pop()); + continue; + } + + // `name.send(...)` or `send(name)` / `logging.send(name)` + if (tok.type === 'ident' && TERMINAL.has(tok.value)) { + const prev = tokens[i - 1]; + if (prev?.type === 'dot' && tokens[i - 2]?.type === 'ident') { + clear(qualifiedName(tokens, i - 2).name); + } + if (tokens[i + 1]?.type === 'lparen') { + const inner = tokens[i + 2]; + if (inner?.type === 'ident') clear(inner.value); + } + } + + if (tok.type !== 'ident') continue; + + if (language === 'gleam') { + const calleeBase = tok.value; + const isLevel = LEVEL.has(calleeBase); + if (!isLevel) continue; + if (tokens[i - 1]?.type === 'ident' && tokens[i - 1].value === 'fn') continue; + const qual = qualifiedName(tokens, i); + if (tokens[i + 1]?.type !== 'lparen' && tokens[qual.start - 1]?.type !== 'pipe') continue; + const pipe = walkGleamPipe(tokens, qual.start); + if (!pipe) continue; + if (!pipe.methods.some((m) => LEVEL.has(m))) continue; + if (pipe.methods.some((m) => TERMINAL.has(m))) { + i = pipe.end; + continue; + } + const assigned = precedingAssignment(tokens, qual.start); + const finding = { line: pipe.line, col: tok.col, message: 'logging chain never calls send()' }; + const prev = tokens[qual.start - 1]?.type; + const next = tokens[pipe.end + 1]?.type; + if (assigned) mark(assigned, finding); + else if (isReturnish(tokens, qual.start) || prev === 'lparen' || prev === 'comma' || next === 'rbrace') { /* handoff / tail return */ } + else report(finding); + i = pipe.end; + continue; + } + + // Rust / Dart method chains: look for `.level(` + if (tok.type === 'ident' && LEVEL.has(tok.value) && tokens[i - 1]?.type === 'dot' && tokens[i + 1]?.type === 'lparen') { + let rootIndex = i - 2; + while (rootIndex >= 2 && tokens[rootIndex]?.type === 'ident' && tokens[rootIndex - 1]?.type === 'dot' && tokens[rootIndex - 2]?.type === 'ident') { + rootIndex -= 2; + } + if (tokens[rootIndex]?.type !== 'ident') continue; + const chain = walkMethodChain(tokens, rootIndex); + if (!chain) continue; + if (!looksLikeLogger(chain.root) && chain.root !== 'self' && chain.root !== 'this') continue; + if (!chain.methods.some((m) => LEVEL.has(m))) continue; + if (chain.methods.some((m) => TERMINAL.has(m))) { + i = chain.end; + continue; + } + // Convenience emit: logger.log(level, msg, ctx, fields) already calls send() + // internally. The chainable API is one argument (or two in Dart). + if (chain.methods.length === 1 && (chain.firstArgCount || 0) >= 3) { + i = chain.end; + continue; + } + const assignName = precedingAssignment(tokens, rootIndex); + const finding = { line: chain.line, col: tokens[rootIndex].col, message: 'logging chain never calls send()' }; + const prev = tokens[rootIndex - 1]?.type; + if (assignName) mark(assignName, finding); + else if (isReturnish(tokens, rootIndex) || prev === 'lparen' || prev === 'comma') { /* handoff */ } + else if (language === 'rust' && !nextNonChainIsSemi(tokens, chain.end)) { /* rust tail expression / return */ } + else report(finding); + i = chain.end; + } + } + + while (scopeStack.length) flushScope(scopeStack.pop()); + return findings; +} + +export function formatReport(results) { + const all = []; + for (const { file, findings } of results) { + for (const f of findings) all.push({ ...f, file }); + } + if (!all.length) { + return results.length + ? `ores-lint[require-send]: clean (${results.length} file${results.length === 1 ? '' : 's'} scanned)\n` + : 'ores-lint[require-send]: no Rust/Dart/Gleam source to scan\n'; + } + const lines = [ + `ores-lint[require-send]: ${all.length} finding(s) across 1 rule(s) in ${results.filter((r) => r.findings.length).length} file(s)`, + '', + ' warning: logging chain never delivered (ores custom rule) [require-send]', + ` ${all.length} instance(s); showing ${Math.min(all.length, MAX)}:`, + ]; + for (const f of all.slice(0, MAX)) { + lines.push(` ${f.file}:${f.line}:${f.col}`); + } + if (all.length > MAX) lines.push(` ... and ${all.length - MAX} more`); + lines.push(''); + return `${lines.join('\n')}\n`; +} + +function main() { + if (process.env.ORES_LINT_SKIP_REQUIRE_SEND === '1') { + process.stdout.write('ores-lint[require-send]: skipped (ORES_LINT_SKIP_REQUIRE_SEND=1)\n'); + return; + } + const files = trackedFiles(); + const results = []; + for (const rel of files) { + const abs = join(ROOT, rel); + if (!existsSync(abs)) continue; + let source; + try { source = readFileSync(abs, 'utf8'); } catch { continue; } + const language = rel.endsWith('.gleam') ? 'gleam' : rel.endsWith('.dart') ? 'dart' : 'rust'; + results.push({ file: rel, findings: analyzeSource(source, language) }); + } + process.stdout.write(formatReport(results)); +} + +const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop()); +if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('require-send.mjs')) { + main(); +} diff --git a/.ores-lint/required-tools.json b/.ores-lint/required-tools.json index 4a84f03..65ace40 100644 --- a/.ores-lint/required-tools.json +++ b/.ores-lint/required-tools.json @@ -16,5 +16,15 @@ "range": "any", "install": "rustup component add clippy", "why": "the Rust half is a clippy wrapper" + }, + "dart": { + "range": "any", + "install": "install the Dart SDK or Flutter", + "why": "optional; dart analyze is the Dart/Flutter linter. Without it Dart source is skipped." + }, + "gleam": { + "range": "any", + "install": "see https://gleam.run/getting-started/installing/", + "why": "optional; gleam format --check and gleam check. Without it Gleam source is skipped." } } diff --git a/.ores-lint/rust.sh b/.ores-lint/rust.sh index 6687b25..a0dec12 100755 --- a/.ores-lint/rust.sh +++ b/.ores-lint/rust.sh @@ -1,10 +1,19 @@ #!/bin/sh # ores-lint :: Rust # +# Discovers every crate in the repo - not just one at the root - runs clippy on +# each, and aggregates ALL of them into a single report so the example cap +# applies per repo rather than per crate. +# +# Workspace handling: after linting a crate root, `cargo metadata --no-deps` +# tells us exactly which manifests that invocation already covered, so workspace +# members are not linted twice while genuinely independent nested crates still +# get their own run. +# # The headline custom behaviour: `clippy::implicit_return` fires once per -# implicit return, which on a real crate means hundreds of identical warnings. -# The lint stays enabled so nothing is missed, but it is reported as ONE -# warning carrying at most ORES_LINT_MAX_EXAMPLES locations plus a total count. +# implicit return, which across a repo means hundreds of identical warnings. The +# lint stays enabled so nothing is missed, but it is reported as ONE warning +# carrying at most ORES_LINT_MAX_EXAMPLES locations plus a total count. # # Critical interaction, handled below: `clippy::needless_return` ships enabled # in clippy's default `style` group and warns on exactly the explicit returns @@ -14,7 +23,8 @@ set -u DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) . "$DIR/config.sh" -CRATE_DIR=${1:-.} +ROOT=${1:-.} +ROOT=$(CDPATH= cd -- "$ROOT" && pwd) [ "${ORES_LINT_SKIP_RUST}" = "1" ] && { echo "ores-lint[rust]: skipped (ORES_LINT_SKIP_RUST=1)"; exit 0; } command -v cargo >/dev/null 2>&1 || { echo "ores-lint[rust]: cargo not found on PATH - skipping"; exit 0; } @@ -38,24 +48,87 @@ LINTS=" -W clippy::lossy_float_literal " [ -n "${ORES_LINT_RUST_EXTRA:-}" ] && LINTS="$LINTS $ORES_LINT_RUST_EXTRA" - TARGETS="" [ "${ORES_LINT_RUST_ALL_TARGETS}" = "1" ] && TARGETS="--all-targets" -OUT=$(mktemp) || exit 0 -RC=0 -# shellcheck disable=SC2086 -( cd "$CRATE_DIR" && cargo clippy --workspace $TARGETS --message-format=short -- $LINTS ) >"$OUT" 2>&1 || RC=$? - -# A non-zero cargo exit with no parseable diagnostics means clippy never ran -# (offline registry, broken manifest, missing toolchain). Say so plainly rather -# than reporting a clean crate. -if [ "$RC" -ne 0 ] && ! grep -q ': warning: \|: error: ' "$OUT"; then - echo "ores-lint[rust]: clippy could not run in $CRATE_DIR (exit $RC). First lines:" - sed -n '1,6p' "$OUT" | sed 's/^/ | /' - rm -f "$OUT"; exit 0 +# --- discover crates -------------------------------------------------------- +CRATES=$(cd "$ROOT" && find . -maxdepth "${ORES_LINT_DEPTH}" \ + \( -name node_modules -o -name target -o -name .git -o -name dist -o -name build \ + -o -name vendor -o -name .vendor -o -name .worktrees -o -name _to_delete \ + -o -name .ores-lint \) -prune -o \ + -type f -name Cargo.toml -print 2>/dev/null \ + | sed 's|^\./||; s|Cargo\.toml$||; s|/$||; s|^$|.|' | sort) + +[ -z "$CRATES" ] && { echo "ores-lint[rust]: no Cargo.toml found - nothing to do"; exit 0; } + +# Drop crates that live inside a NESTED git repository. Those belong to a +# different repo with its own ores-lint install; linting them from here would +# report the same findings twice under the wrong repo name. +NESTED_FILE="$DIR/nested-repos.json" +if [ -f "$NESTED_FILE" ]; then + NESTED=$(grep -o '"[^"]*"' "$NESTED_FILE" 2>/dev/null | tr -d '"') + if [ -n "$NESTED" ]; then + KEPT="" + for c in $CRATES; do + drop=0 + for nrepo in $NESTED; do + case "$c" in + "$nrepo"|"$nrepo"/*) drop=1; break ;; + esac + done + [ "$drop" = "0" ] && KEPT="$KEPT +$c" + done + CRATES=$(printf '%s' "$KEPT" | sed '/^$/d') + fi fi +[ -z "$CRATES" ] && { echo "ores-lint[rust]: all crates belong to nested repos - nothing to do here"; exit 0; } + +RAW=$(mktemp) || exit 0 +COVERED=$(mktemp) || exit 0 +RAN=0 +SKIPPED_MEMBERS=0 +FAILED="" + +for c in $CRATES; do + if [ "$c" = "." ]; then cdir="$ROOT"; else cdir="$ROOT/$c"; fi + # Already covered by an earlier workspace invocation? + if [ -s "$COVERED" ] && grep -qxF "$cdir" "$COVERED"; then + SKIPPED_MEMBERS=$((SKIPPED_MEMBERS + 1)) + continue + fi + + RC=0 + OUT=$(mktemp) + # shellcheck disable=SC2086 + ( cd "$cdir" && cargo clippy --workspace $TARGETS --message-format=short -- $LINTS ) >"$OUT" 2>&1 || RC=$? + + if [ "$RC" -ne 0 ] && ! grep -q ': warning: \|: error: ' "$OUT"; then + FAILED="$FAILED + $c (exit $RC): $(sed -n '1,2p' "$OUT" | tr '\n' ' ' | cut -c1-140)" + rm -f "$OUT" + continue + fi + RAN=$((RAN + 1)) + + # Re-root diagnostic paths at the repo, so a repo-wide report stays navigable. + if [ "$c" = "." ]; then + cat "$OUT" >> "$RAW" + else + sed "s|^|$c/|" "$OUT" >> "$RAW" + fi + rm -f "$OUT" + + # Record which manifests this invocation covered (workspace members). + ( cd "$cdir" && cargo metadata --no-deps --offline --format-version 1 2>/dev/null ) \ + | grep -o '"manifest_path":"[^"]*"' \ + | sed 's/"manifest_path":"//; s/"$//; s|/Cargo\.toml$||' >> "$COVERED" 2>/dev/null || true +done + +echo "ores-lint[rust]: linted $RAN crate root(s)$([ "$SKIPPED_MEMBERS" -gt 0 ] && echo ", $SKIPPED_MEMBERS workspace member(s) already covered")" +[ -n "$FAILED" ] && printf 'ores-lint[rust]: clippy could not run in some crates:%s\n' "$FAILED" + awk -v MAXEX="$ORES_LINT_MAX_EXAMPLES" -v TARGETMSG="$ORES_LINT_IMPLICIT_RETURN_MSG" ' BEGIN { max = MAXEX + 0; if (max < 1) max = 1; n = 0 } match($0, /: (warning|error): /) { @@ -64,6 +137,9 @@ match($0, /: (warning|error): /) { ci = index(rest, ": ") sev = substr(rest, 1, ci - 1) msg = substr(rest, ci + 2) + key = loc "|" msg + if (key in seen) next # same finding reported by two crate runs + seen[key] = 1 if (!(msg in count)) { order[++n] = msg; sev_of[msg] = sev } count[msg]++ if (shown[msg] < max) { ex[msg] = ex[msg] (shown[msg]++ ? "\n" : "") " " loc } @@ -89,6 +165,7 @@ END { } print "" } -' "$OUT" -rm -f "$OUT" +' "$RAW" + +rm -f "$RAW" "$COVERED" exit 0 diff --git a/.ores-lint/selftest.sh b/.ores-lint/selftest.sh index 0418a6e..b76bc9f 100755 --- a/.ores-lint/selftest.sh +++ b/.ores-lint/selftest.sh @@ -67,9 +67,30 @@ if command -v node >/dev/null 2>&1; then else fail "vendored eslint plugin failed to load" fi + + if node "$DIR/require-send.test.mjs" >/dev/null 2>&1; then + pass "require-send scanner fixtures" + else + fail "require-send scanner fixtures failed" + node "$DIR/require-send.test.mjs" 2>&1 | sed -n '1,20p' | sed 's/^/ /' + fi else echo " skip - node unavailable" fi +# --- Dart ------------------------------------------------------------------- +if command -v dart >/dev/null 2>&1 || command -v flutter >/dev/null 2>&1; then + pass "dart/flutter available for analyzer pass" +else + echo " skip - dart/flutter unavailable (dart.sh will no-op)" +fi + +# --- Gleam ------------------------------------------------------------------ +if command -v gleam >/dev/null 2>&1; then + pass "gleam available for format/check pass" +else + echo " skip - gleam unavailable (gleam.sh will no-op)" +fi + [ "$FAIL" = "0" ] && echo "self-test passed" || echo "self-test FAILED" exit "$FAIL"