diff --git a/.github/workflows/ores-lint.yml b/.github/workflows/ores-lint.yml new file mode 100644 index 0000000..722a91c --- /dev/null +++ b/.github/workflows/ores-lint.yml @@ -0,0 +1,54 @@ +# Managed by .ores-lint/ - regenerated by the rollout script. +name: ores-lint + +on: + pull_request: + workflow_dispatch: + # Add `push:` here once this repo's lint debt is paid down. + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Detect project types + id: detect + run: | + { [ -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' || steps.detect.outputs.rs == 'true' || steps.detect.outputs.dart == 'true' || steps.detect.outputs.gleam == 'true' + with: + node-version: '22' + + # ESLint is a global tool here, not a repo dependency - so this job never + # runs `npm install` and never needs the repo's node_modules. + - name: Install eslint globally + if: steps.detect.outputs.js == 'true' + run: npm i -g eslint typescript-eslint + + - name: Install clippy + 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: + # Warn-only. Set to 1 here to make lint findings fail this repo's CI. + ORES_LINT_STRICT: '0' diff --git a/.ores-lint/README.md b/.ores-lint/README.md new file mode 100644 index 0000000..6fe9cd4 --- /dev/null +++ b/.ores-lint/README.md @@ -0,0 +1,218 @@ +# ores-lint + +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 + +```sh +sh .ores-lint/lint.sh # lints whatever this repo contains +sh .ores-lint/selftest.sh # verifies the toolkit still works after a toolchain upgrade +``` + +For JS repos it is also wired into `npm run lint:ores`, and runs automatically +before `npm run build` (`prebuild`) and before `npm publish` (`prepublishOnly`). + +## What it enforces + +**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()` 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, plus rustc `#[must_use]` on ores-otel `Event`: + +| lint | why | +|---|---| +| `clippy::implicit_return` | house style: prefer an explicit `return` at tail position | +| `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 +single warning showing at most 5 concrete locations plus `... and N more`. The +same cap applies to every ESLint rule via `eslint/formatter.mjs`. Change it with +`ORES_LINT_MAX_EXAMPLES`. + +**2. `clippy::needless_return` had to be disabled.** It ships enabled in +clippy's default `style` group and warns on exactly the explicit returns this +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 +hooks across hundreds of repos, so it is built to be incapable of breaking one +unless a human opts in. + +To make findings blocking for a single repo, create `.ores-lint/local.sh`: + +```sh +ORES_LINT_STRICT=1 +``` + +`local.sh` is yours — the rollout script never overwrites it. Everything else in +this directory is managed and will be replaced on the next rollout. + +## Knobs + +| variable | default | meaning | +|---|---|---| +| `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` / `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, 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 +require the repo's dependency tree to resolve. + +## Per-repo customisation + +`eslint.config.mjs` at the repo root takes options: + +```js +export default await oresConfig({ + requireSend: { loggerNames: ['myLogger'], terminalMethods: ['send', 'flush'] }, + rules: { 'no-console': 'warn' }, + ignores: ['**/generated/**'], +}); +``` + +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 new file mode 100644 index 0000000..f0bb29e --- /dev/null +++ b/.ores-lint/VERSION @@ -0,0 +1 @@ +1.3.0 diff --git a/.ores-lint/config.sh b/.ores-lint/config.sh new file mode 100644 index 0000000..b920572 --- /dev/null +++ b/.ores-lint/config.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# ores-lint shared configuration. Sourced by lint.sh, js.sh and rust.sh. +# Every value can be overridden from the environment, or per repo in local.sh. + +# Maximum number of concrete example locations shown for any one rule. +: "${ORES_LINT_MAX_EXAMPLES:=5}" + +# Warn-only by default: lint.sh exits 0 no matter what it finds. +# Flip to 1 (per repo, or in CI) once a repo's debt is paid down. +: "${ORES_LINT_STRICT:=0}" + +: "${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. +: "${ORES_LINT_RUST_ALL_TARGETS:=0}" + +# Minimum ESLint major version. Flat config needs 9+. ESLint is expected to be +# installed GLOBALLY, once - see required-tools.json. Nothing is ever installed +# into a repo's node_modules. +: "${ORES_LINT_ESLINT_MIN_MAJOR:=9}" + +# The exact clippy diagnostic text for `clippy::implicit_return`. selftest.sh +# verifies this still matches, so a future clippy rewording surfaces as a test +# failure rather than as a silently empty report. +: "${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_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. +ORES_LINT_CFG_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +[ -f "$ORES_LINT_CFG_DIR/local.sh" ] && . "$ORES_LINT_CFG_DIR/local.sh" 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 new file mode 100644 index 0000000..596f2f2 --- /dev/null +++ b/.ores-lint/eslint/base.mjs @@ -0,0 +1,179 @@ +/** + * ores-lint :: shared flat-config factory + * + * Everything here degrades gracefully. A repo missing TypeScript tooling gets + * its JS linted rather than an error; an ESLint that has dropped core `semi` + * falls back to the vendored rule. The point is that a lint config rolled out + * 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'; + +const require_ = createRequire(import.meta.url); + +// ESLint and its optional TypeScript parser are expected to live in a single +// global install rather than in every repo's node_modules, so resolution has to +// look outside this file's own tree. js.sh passes the global root in. +const EXTRA_PATHS = [ + process.env.ORES_LINT_GLOBAL_ROOT, + ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(':') : []), +].filter(Boolean); + +function tryResolve(id) { + try { return require_.resolve(id); } catch { /* try the global root next */ } + if (EXTRA_PATHS.length) { + try { return require_.resolve(id, { paths: EXTRA_PATHS }); } catch { /* not installed */ } + } + return null; +} + +/** Is core `semi` still shipped by the installed ESLint? */ +async function coreSemiAvailable() { + for (const id of ['eslint/use-at-your-own-risk']) { + const resolved = tryResolve(id); + if (!resolved) continue; + try { + const { builtinRules } = await import(pathToFileURL(resolved).href); + return builtinRules.has('semi'); + } catch { /* fall through */ } + } + return true; // could not introspect: assume core rules are intact +} + +/** typescript-eslint, if the repo happens to have it. */ +async function loadTsSupport() { + for (const id of ['typescript-eslint', '@typescript-eslint/parser']) { + const resolved = tryResolve(id); + if (!resolved) continue; + try { + // Import by resolved path: a bare specifier would not find a global install. + const mod = await import(pathToFileURL(resolved).href); + const m = mod.default || mod; + if (id === 'typescript-eslint' && m.parser) return { parser: m.parser, source: id }; + if (m.parseForESLint || m.parse) return { parser: m, source: id }; + } catch { /* fall through to the next candidate */ } + } + return null; +} + +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', + '**/.ores-lint/**', +]; + +/** + * @param {object} [opts] + * @param {object} [opts.requireSend] options forwarded to ores/require-send + * @param {object} [opts.rules] extra rules merged last (repo overrides) + * @param {string[]} [opts.ignores] extra ignore globs + */ +export default async function oresConfig(opts = {}) { + const useCoreSemi = await coreSemiAvailable(); + const ts = await loadTsSupport(); + + const semiRules = useCoreSemi + ? { semi: ['warn', 'always'], 'no-extra-semi': 'warn', 'semi-style': ['warn', 'last'] } + : { 'ores/semi': 'warn' }; + + // Correctness rules chosen for a near-zero false-positive rate, because this + // config lands in repos nobody is going to hand-tune afterwards. + const correctness = { + 'ores/require-send': ['warn', opts.requireSend || {}], + 'no-unused-vars': ['warn', { args: 'after-used', argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }], + eqeqeq: ['warn', 'smart'], + 'no-fallthrough': 'warn', + 'no-unreachable': 'warn', + 'no-dupe-keys': 'warn', + 'no-dupe-else-if': 'warn', + 'no-duplicate-case': 'warn', + 'no-self-compare': 'warn', + 'no-unsafe-negation': 'warn', + 'no-cond-assign': ['warn', 'always'], + 'no-constant-condition': ['warn', { checkLoops: false }], + 'no-async-promise-executor': 'warn', + 'no-promise-executor-return': 'warn', + 'no-compare-neg-zero': 'warn', + 'no-irregular-whitespace': 'warn', + 'no-template-curly-in-string': 'warn', + 'valid-typeof': 'warn', + 'use-isnan': 'warn', + 'no-debugger': 'warn', + // Deliberately NOT enabled: no-undef. Without a `globals` package it fires + // on console/process/window everywhere, and TypeScript already covers it. + }; + + const configs = [ + { ignores: [...IGNORES, ...nestedRepoIgnores(), ...legacyIgnoreFile(), ...(opts.ignores || [])] }, + { + files: JS_FILES, + plugins: { ores: oresPlugin }, + languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + rules: { ...semiRules, ...correctness, ...(opts.rules || {}) }, + }, + ]; + + 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, + plugins: { ores: oresPlugin }, + languageOptions: { parser: ts.parser, ecmaVersion: 'latest', sourceType: 'module' }, + rules: { + ...semiRules, + ...correctness, + // TypeScript's own compiler reports unused symbols with better fidelity. + 'no-unused-vars': 'off', + ...(opts.rules || {}), + }, + }); + } + + return configs; +} + +export const meta = { tsSupport: null }; diff --git a/.ores-lint/eslint/formatter.mjs b/.ores-lint/eslint/formatter.mjs new file mode 100644 index 0000000..e4a9a52 --- /dev/null +++ b/.ores-lint/eslint/formatter.mjs @@ -0,0 +1,79 @@ +/** + * ores-lint :: capped ESLint formatter + * + * Same reporting contract as the Rust side: one block per rule, at most + * ORES_LINT_MAX_EXAMPLES concrete locations, then a count of the remainder. + * A rollout across hundreds of repos surfaces thousands of missing semicolons; + * printing every one of them buries the findings that actually matter. + */ + +const MAX = Math.max(1, Number(process.env.ORES_LINT_MAX_EXAMPLES || 5)); + +const LABELS = { + semi: 'missing semicolon (ores house style)', + 'ores/semi': 'missing semicolon (ores house style)', + 'ores/require-send': 'logging chain never delivered (ores custom rule)', +}; + +export default function oresFormatter(results) { + const byRule = new Map(); + let files = 0; + let errors = 0; + let warnings = 0; + const parseErrors = []; + + for (const result of results) { + if (!result.messages.length) continue; + files++; + const rel = (result.filePath || '').replace(`${process.cwd()}/`, ''); + for (const m of result.messages) { + if (m.severity === 2) errors++; else warnings++; + if (!m.ruleId) { parseErrors.push(`${rel}:${m.line || 0}: ${m.message}`); continue; } + let entry = byRule.get(m.ruleId); + if (!entry) { entry = { count: 0, examples: [], severity: m.severity, message: m.message }; byRule.set(m.ruleId, entry); } + entry.count++; + if (entry.examples.length < MAX) entry.examples.push(`${rel}:${m.line}:${m.column}`); + } + } + + // 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} of ${examined} file(s) linted`); + + // House rules first, then the rest by frequency. + const ordered = [...byRule.entries()].sort((a, b) => { + const ah = a[0] in LABELS ? 0 : 1; + const bh = b[0] in LABELS ? 0 : 1; + return ah - bh || b[1].count - a[1].count; + }); + + for (const [ruleId, entry] of ordered) { + const sev = entry.severity === 2 ? 'error' : 'warning'; + const label = LABELS[ruleId] || entry.message; + out.push(''); + out.push(` ${sev}: ${label} [${ruleId}]`); + out.push(` ${entry.count} instance(s); showing ${Math.min(entry.count, MAX)}:`); + for (const ex of entry.examples) out.push(` ${ex}`); + if (entry.count > MAX) out.push(` ... and ${entry.count - MAX} more`); + } + + if (parseErrors.length) { + out.push(''); + out.push(` note: ${parseErrors.length} file(s) could not be parsed (usually a missing parser, not a defect):`); + for (const p of parseErrors.slice(0, MAX)) out.push(` ${p}`); + if (parseErrors.length > MAX) out.push(` ... and ${parseErrors.length - MAX} more`); + } + + out.push(''); + return `${out.join('\n')}\n`; +} diff --git a/.ores-lint/eslint/plugin.mjs b/.ores-lint/eslint/plugin.mjs new file mode 100644 index 0000000..b010e4d --- /dev/null +++ b/.ores-lint/eslint/plugin.mjs @@ -0,0 +1,361 @@ +/** + * ores-lint :: vendored ESLint plugin + * + * Plain ESM. No build step, no registry dependencies. The only thing it needs + * is an `eslint` that already exists in the repo. + * + * Rules + * ores/require-send - a logging chain that reaches a level method must end + * in a terminal call (.send(), .send(true), ...). + * Generalised from the ores-otel next-loggers plugin. + * ores/semi - fallback semicolon rule, used only when core `semi` + * is unavailable. See base.mjs. + */ + +const DEFAULT_LEVEL_METHODS = ['trace', 'debug', 'info', 'log', 'warn', 'error', 'fatal']; +const DEFAULT_TERMINAL_METHODS = ['send', 'send_with_store']; + +const LOGGER_EXPORTS = new Set([ + 'logger', 'browserLogger', 'edgeLogger', 'cloudflareWorkerLogger', + 'nodeLogger', 'bunLogger', 'denoLogger', +]); +const FACTORY_EXPORTS = new Set([ + 'createLogger', 'createBrowserLogger', 'createEdgeLogger', + 'createCloudflareWorkerLogger', 'createNodeLogger', 'createBunLogger', + 'createDenoLogger', +]); +const CLASS_EXPORTS = new Set([ + 'BaseLogger', 'BrowserLogger', 'EdgeLogger', 'CloudflareWorkerLogger', + 'NodeLogger', 'BunLogger', 'DenoLogger', +]); + +function hasType(node, ...types) { + return Boolean(node && types.includes(String(node.type))); +} + +/** Strip wrappers that do not change the identity of the underlying expression. */ +function unwrap(node) { + let current = node; + while ( + current && + (hasType(current, 'ChainExpression', 'AwaitExpression', 'TSAsExpression', 'TSTypeAssertion', 'TSNonNullExpression') || + (current.type === 'UnaryExpression' && current.operator === 'void')) + ) { + current = current.expression || current.argument; + } + return current || undefined; +} + +function getPropertyName(node) { + if (!hasType(node, 'MemberExpression', 'OptionalMemberExpression')) return undefined; + const property = node.property; + if (!property) return undefined; + if (!node.computed && property.type === 'Identifier') return property.name; + if (node.computed && property.type === 'Literal' && typeof property.value === 'string') return property.value; + return undefined; +} + +function getQualifiedName(node) { + const current = unwrap(node); + if (!current) return undefined; + if (current.type === 'Identifier') return current.name; + if (current.type === 'ThisExpression') return 'this'; + if (hasType(current, 'MemberExpression', 'OptionalMemberExpression')) { + const objectName = getQualifiedName(current.object); + const propertyName = getPropertyName(current); + return objectName && propertyName ? `${objectName}.${propertyName}` : undefined; + } + return undefined; +} + +/** Walk a call chain back to its root, collecting method names left-to-right. */ +function collectCallChain(node, methods) { + const current = unwrap(node); + if (!current) return undefined; + if (hasType(current, 'CallExpression', 'OptionalCallExpression')) { + const callee = unwrap(current.callee); + if (callee && hasType(callee, 'MemberExpression', 'OptionalMemberExpression')) { + const root = collectCallChain(callee.object, methods); + const method = getPropertyName(callee); + if (method) methods.push(method); + return root; + } + return getQualifiedName(callee); + } + return getQualifiedName(current); +} + +function isTrackedModule(source, moduleNames) { + if (typeof source !== 'string') return false; + for (const moduleName of moduleNames) { + if (source === moduleName || source.startsWith(`${moduleName}/`)) return true; + } + 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() or send(boolean)', + url: 'https://github.com/ores-otel/ores.otel.log', + }, + schema: [{ + type: 'object', + properties: { + loggerNames: { type: 'array', items: { type: 'string' }, uniqueItems: true }, + moduleNames: { type: 'array', items: { type: 'string' }, uniqueItems: true }, + levelMethods: { type: 'array', items: { type: 'string' }, uniqueItems: true }, + terminalMethods: { type: 'array', items: { type: 'string' }, uniqueItems: true }, + }, + additionalProperties: false, + }], + messages: { + 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", + }, + }, + + create(context) { + const options = context.options[0] || {}; + const knownLoggers = new Set(['log', 'logger', 'ddlog', ...(options.loggerNames || [])]); + const knownFactories = new Set(); + const knownClasses = new Set(); + const moduleNames = new Set(['@oresoftware/next-loggers', ...(options.moduleNames || [])]); + const levelMethods = new Set(options.levelMethods || DEFAULT_LEVEL_METHODS); + 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; + const directName = getQualifiedName(current); + if (directName && knownLoggers.has(directName)) return true; + if (current.type === 'NewExpression') { + const className = getQualifiedName(current.callee); + return Boolean(className && knownClasses.has(className)); + } + if (hasType(current, 'CallExpression', 'OptionalCallExpression')) { + const calleeName = getQualifiedName(current.callee); + if (calleeName && knownFactories.has(calleeName)) return true; + const callee = unwrap(current.callee); + if (callee && hasType(callee, 'MemberExpression', 'OptionalMemberExpression')) { + const method = getPropertyName(callee); + const owner = getQualifiedName(callee.object); + return method === 'anew' && Boolean(owner && knownLoggers.has(owner)); + } + } + 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 || []) { + const localName = specifier.local?.name; + if (!localName) continue; + if (specifier.type === 'ImportDefaultSpecifier') { knownLoggers.add(localName); continue; } + if (specifier.type === 'ImportNamespaceSpecifier') { + for (const name of LOGGER_EXPORTS) knownLoggers.add(`${localName}.${name}`); + for (const name of FACTORY_EXPORTS) knownFactories.add(`${localName}.${name}`); + for (const name of CLASS_EXPORTS) knownClasses.add(`${localName}.${name}`); + continue; + } + const importedName = specifier.imported?.name || specifier.imported?.value; + if (typeof importedName !== 'string') continue; + if (LOGGER_EXPORTS.has(importedName)) knownLoggers.add(localName); + if (FACTORY_EXPORTS.has(importedName)) knownFactories.add(localName); + if (CLASS_EXPORTS.has(importedName)) knownClasses.add(localName); + } + }, + + VariableDeclarator(node) { + 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 chain = inspectChain(node.expression, knownLoggers, levelMethods, terminalMethods); + if (chain.isEvent && !chain.delivered) { + context.report({ node, messageId: 'missingSend', data: { terminal: terminalLabel } }); + return; + } + consumeTerminalUse(node.expression); + }, + }; + }, +}; + +/** + * Fallback semicolon rule. Only wired up when core `semi` is missing, so in + * practice this is dormant - it exists so the house style survives a future + * ESLint that drops its formatting rules. + */ +const NEEDS_SEMI = new Set([ + 'ExpressionStatement', 'ReturnStatement', 'ThrowStatement', 'BreakStatement', + 'ContinueStatement', 'DebuggerStatement', 'DoWhileStatement', 'ImportDeclaration', + 'ExportAllDeclaration', 'PropertyDefinition', 'TSTypeAliasDeclaration', + 'TSDeclareFunction', 'TSImportEqualsDeclaration', +]); + +export const semiRule = { + meta: { + type: 'layout', + fixable: 'code', + schema: [], + docs: { description: 'require semicolons at the end of statements (vendored fallback)' }, + messages: { missingSemi: 'Missing semicolon.' }, + }, + create(context) { + const sourceCode = context.sourceCode || context.getSourceCode(); + + const check = (node) => { + const lastToken = sourceCode.getLastToken(node); + if (!lastToken) return; + if (lastToken.type === 'Punctuator' && lastToken.value === ';') return; + context.report({ + node, + loc: lastToken.loc.end, + messageId: 'missingSemi', + fix: (fixer) => fixer.insertTextAfter(lastToken, ';'), + }); + }; + + const handlers = {}; + for (const type of NEEDS_SEMI) handlers[type] = check; + + // `for (let i = 0 ...)` heads and `for (const x of y)` must not get one. + handlers.VariableDeclaration = (node) => { + const parent = node.parent; + if (parent && ( + (parent.type === 'ForStatement' && parent.init === node) || + ((parent.type === 'ForInStatement' || parent.type === 'ForOfStatement') && parent.left === node) + )) return; + check(node); + }; + + // Only export forms that are expressions/re-exports need a semicolon; + // `export function f() {}` and `export class C {}` do not. + const checkExport = (node) => { + const decl = node.declaration; + if (decl && ['FunctionDeclaration', 'ClassDeclaration', 'TSInterfaceDeclaration', 'TSEnumDeclaration', 'TSModuleDeclaration'].includes(decl.type)) return; + check(node); + }; + handlers.ExportNamedDeclaration = checkExport; + handlers.ExportDefaultDeclaration = checkExport; + + return handlers; + }, +}; + +export const rules = { + 'require-send': requireSendRule, + semi: semiRule, +}; + +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/install-git-hooks.sh b/.ores-lint/install-git-hooks.sh new file mode 100755 index 0000000..c4dbce2 --- /dev/null +++ b/.ores-lint/install-git-hooks.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Optional: install a pre-push hook that runs ores-lint. +# Not installed automatically by the rollout - run this yourself per repo. +set -u +ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "not a git repo"; exit 1; } +HOOK="$ROOT/.git/hooks/pre-push" +if [ -e "$HOOK" ] && ! grep -q 'ores-lint' "$HOOK"; then + echo "refusing to clobber an existing pre-push hook: $HOOK" + exit 1 +fi +cat > "$HOOK" <<'INNER' +#!/bin/sh +# installed by .ores-lint/install-git-hooks.sh +[ -x "$(git rev-parse --show-toplevel)/.ores-lint/lint.sh" ] && \ + sh "$(git rev-parse --show-toplevel)/.ores-lint/lint.sh" +exit 0 +INNER +chmod +x "$HOOK" +echo "installed $HOOK" diff --git a/.ores-lint/js.sh b/.ores-lint/js.sh new file mode 100755 index 0000000..ecbb076 --- /dev/null +++ b/.ores-lint/js.sh @@ -0,0 +1,108 @@ +#!/bin/sh +# ores-lint :: JavaScript / TypeScript +# +# ESLint is treated as a GLOBALLY INSTALLED TOOL, not a per-repo dependency. +# Nothing here installs anything and nothing reaches the network; hundreds of +# repos sharing one global eslint is the whole point. Resolution order: +# +# 1. a local node_modules/.bin/eslint, walking up for monorepos +# (respected if a repo genuinely pins its own) +# 2. eslint on PATH (npm i -g eslint) +# 3. the global npm root +# +# If none is found this prints an actionable skip and exits 0. + +set -u +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$DIR/config.sh" +ROOT=${1:-.} + +[ "${ORES_LINT_SKIP_JS}" = "1" ] && { echo "ores-lint[js]: skipped (ORES_LINT_SKIP_JS=1)"; exit 0; } + +find_local_eslint() { + d=$(CDPATH= cd -- "$1" && pwd) + while [ -n "$d" ] && [ "$d" != "/" ]; do + if [ -x "$d/node_modules/.bin/eslint" ]; then printf '%s\n' "$d/node_modules/.bin/eslint"; return 0; fi + d=$(dirname "$d") + done + return 1 +} + +GLOBAL_ROOT="" +for probe in "npm root -g" "pnpm root -g" "yarn global dir"; do + r=$($probe 2>/dev/null | head -1) + [ -n "$r" ] && [ -d "$r" ] && { GLOBAL_ROOT="$r"; break; } +done + +ESLINT="" +ESLINT_KIND="" +if ESLINT=$(find_local_eslint "$ROOT"); then + ESLINT_KIND="local" +elif command -v eslint >/dev/null 2>&1; then + ESLINT=$(command -v eslint); ESLINT_KIND="global (PATH)" +elif [ -n "$GLOBAL_ROOT" ] && [ -x "$GLOBAL_ROOT/.bin/eslint" ]; then + ESLINT="$GLOBAL_ROOT/.bin/eslint"; ESLINT_KIND="global" +else + echo "ores-lint[js]: no eslint found - skipping" + echo " install it once, globally: npm i -g eslint" + echo " (ores-lint never adds eslint to a repo's node_modules)" + exit 0 +fi + +# Version gate. Flat config needs ESLint 9+; an older one would fail confusingly. +VER=$("$ESLINT" --version 2>/dev/null | sed 's/^v//') +MAJOR=$(printf '%s' "$VER" | cut -d. -f1) +case "$MAJOR" in + ''|*[!0-9]*) : ;; # unparseable version: proceed rather than block + *) + if [ "$MAJOR" -lt "${ORES_LINT_ESLINT_MIN_MAJOR}" ]; then + echo "ores-lint[js]: found eslint $VER ($ESLINT_KIND) but flat config needs >=${ORES_LINT_ESLINT_MIN_MAJOR} - skipping" + echo " upgrade with: npm i -g eslint@latest" + exit 0 + fi + ;; +esac + +CONFIG="" +for c in eslint.config.mjs eslint.config.js eslint.config.cjs; do + [ -f "$ROOT/$c" ] && { CONFIG="$ROOT/$c"; break; } +done +[ -z "$CONFIG" ] && { echo "ores-lint[js]: no flat eslint config found - skipping"; exit 0; } + +# Let base.mjs find globally installed optional tooling (typescript-eslint). +[ -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" . \ + --no-error-on-unmatched-pattern \ + --format "$DIR/eslint/formatter.mjs" ) >"$OUT" 2>&1 || RC=$? + +if [ "$RC" -ne 0 ] && ! grep -q 'ores-lint\[js\]' "$OUT"; then + echo "ores-lint[js]: eslint $VER ($ESLINT_KIND) could not run in $ROOT (exit $RC). First lines:" + sed -n '1,6p' "$OUT" | sed 's/^/ | /' + rm -f "$OUT"; exit 0 +fi + +cat "$OUT" +rm -f "$OUT" +exit 0 diff --git a/.ores-lint/lint.sh b/.ores-lint/lint.sh new file mode 100755 index 0000000..af39cca --- /dev/null +++ b/.ores-lint/lint.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# ores-lint :: entry point +# +# 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) +ROOT=$(dirname "$DIR") +. "$DIR/config.sh" + +echo "ores-lint v$(cat "$DIR/VERSION" 2>/dev/null || echo '?') :: $(basename "$ROOT")" + +FOUND=0 +LOG=$(mktemp) || exit 0 + +# 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 + +# 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 + +# 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" + echo "ores-lint: FAILING because ORES_LINT_STRICT=1" + exit 1 +fi + +rm -f "$LOG" +exit 0 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 new file mode 100644 index 0000000..65ace40 --- /dev/null +++ b/.ores-lint/required-tools.json @@ -0,0 +1,30 @@ +{ + "_comment": "Tool versions ores-lint expects. These are DECLARATIONS, not dependencies - nothing here is ever installed into a repo's node_modules. Install them once, globally. The rollout script mirrors this block into each package.json under the `oresLint` key so the requirement travels with the repo.", + "eslint": { + "minMajor": 9, + "range": ">=9", + "install": "npm i -g eslint", + "why": "flat config (eslint.config.mjs) requires ESLint 9 or newer" + }, + "typescript-eslint": { + "minMajor": 8, + "range": ">=8", + "install": "npm i -g typescript-eslint", + "why": "optional; without it .ts/.tsx files are skipped rather than linted" + }, + "clippy": { + "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 new file mode 100755 index 0000000..a0dec12 --- /dev/null +++ b/.ores-lint/rust.sh @@ -0,0 +1,171 @@ +#!/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 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 +# this house style asks for. Enabling implicit_return without allowing +# needless_return makes the two lints contradict each other on every function. + +set -u +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$DIR/config.sh" +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; } +cargo clippy --version >/dev/null 2>&1 || { echo "ores-lint[rust]: clippy not installed (rustup component add clippy) - skipping"; exit 0; } + +LINTS=" +-W clippy::implicit_return +-A clippy::needless_return +-A clippy::let_and_return +-W clippy::correctness +-W clippy::suspicious +-W clippy::await_holding_lock +-W clippy::unwrap_used +-W clippy::expect_used +-W clippy::panic_in_result_fn +-W clippy::todo +-W clippy::unimplemented +-W clippy::dbg_macro +-W clippy::mem_forget +-W clippy::float_cmp +-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" + +# --- 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): /) { + loc = substr($0, 1, RSTART - 1) + rest = substr($0, RSTART + 2) + 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 } + next +} +END { + if (n == 0) { print "ores-lint[rust]: clean"; exit 0 } + total = 0 + for (i = 1; i <= n; i++) total += count[order[i]] + printf "ores-lint[rust]: %d finding(s) across %d rule(s)\n", total, n + for (pass = 1; pass <= 2; pass++) { + for (i = 1; i <= n; i++) { + msg = order[i] + is_target = (msg == TARGETMSG) + if ((pass == 1) != is_target) continue + label = is_target ? "implicit return (ores house style)" : msg + printf "\n %s: %s\n", sev_of[msg], label + if (is_target) printf " prefer an explicit `return` at tail position\n" + 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 "" +} +' "$RAW" + +rm -f "$RAW" "$COVERED" +exit 0 diff --git a/.ores-lint/selftest.sh b/.ores-lint/selftest.sh new file mode 100755 index 0000000..b76bc9f --- /dev/null +++ b/.ores-lint/selftest.sh @@ -0,0 +1,96 @@ +#!/bin/sh +# ores-lint :: self-test +# +# Guards the two assumptions this toolkit rests on: +# 1. clippy still words `implicit_return` the way config.sh expects, and +# `needless_return` can still be silenced (otherwise the two lints fight). +# 2. the vendored ESLint plugin still loads and its rules still fire. +# +# Run after a toolchain upgrade. Exits non-zero if an assumption has broken - +# a silently empty lint report is far worse than a failing test. + +set -u +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$DIR/config.sh" +FAIL=0 +pass() { echo " ok - $1"; } +fail() { echo " FAIL - $1"; FAIL=1; } + +echo "ores-lint self-test" + +# --- Rust ------------------------------------------------------------------- +if command -v cargo >/dev/null 2>&1 && cargo clippy --version >/dev/null 2>&1; then + T=$(mktemp -d) + mkdir -p "$T/src" + cat > "$T/Cargo.toml" <<'EOF' +[package] +name = "ores_lint_selftest" +version = "0.0.0" +edition = "2021" +EOF + cat > "$T/src/lib.rs" <<'EOF' +pub fn implicit(x: i32) -> i32 { x } +pub fn explicit(x: i32) -> i32 { return x; } +EOF + OUT=$( cd "$T" && cargo clippy --message-format=short -- \ + -W clippy::implicit_return -A clippy::needless_return 2>&1 ) + + if printf '%s' "$OUT" | grep -qF "$ORES_LINT_IMPLICIT_RETURN_MSG"; then + pass "clippy implicit_return message matches config.sh" + else + fail "clippy implicit_return wording changed - update ORES_LINT_IMPLICIT_RETURN_MSG in config.sh" + printf '%s\n' "$OUT" | sed -n '1,4p' | sed 's/^/ /' + fi + + if printf '%s' "$OUT" | grep -q 'unneeded `return`'; then + fail "needless_return still fires despite -A; it contradicts the house style" + else + pass "needless_return correctly silenced" + fi + + N=$(printf '%s\n' "$OUT" | grep -cF "$ORES_LINT_IMPLICIT_RETURN_MSG") + [ "$N" = "1" ] && pass "exactly 1 implicit return detected in fixture" \ + || fail "expected 1 implicit return in fixture, saw $N" + rm -rf "$T" +else + echo " skip - cargo/clippy unavailable" +fi + +# --- JavaScript ------------------------------------------------------------- +if command -v node >/dev/null 2>&1; then + if node --input-type=module -e " + const p = await import('$DIR/eslint/plugin.mjs'); + const names = Object.keys(p.default.rules); + if (!names.includes('require-send') || !names.includes('semi')) process.exit(3); + " 2>/dev/null; then + pass "vendored eslint plugin loads with both rules" + 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" diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ee5719d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,11 @@ +// ores-lint house ESLint config. +// Managed by .ores-lint/ - see .ores-lint/README.md before editing. +// Repo-specific tweaks go in the options object below; the rollout script will +// not overwrite this file once you have changed it. +import oresConfig from './.ores-lint/eslint/base.mjs'; + +export default await oresConfig({ + // requireSend: { loggerNames: ['myLogger'], terminalMethods: ['send', 'flush'] }, + // rules: { 'no-console': 'warn' }, + // ignores: ['**/generated/**'], +}); diff --git a/package.json b/package.json index e68e133..b8c097a 100755 --- a/package.json +++ b/package.json @@ -31,7 +31,10 @@ "test:internal": "node --test test/node-test/internal.test.js", "test:internal:standalone": "node test/internal/linked-list-integrity.js && node test/internal/head-tail-fuzz.js && node test/internal/lookup-consistency.js", "test:legacy": "node test/src/test-fuzz.js && node test/src/linked-queue-fuzz.js && node test/src/queue-fuzz.js && node test/src/basic-queue-fuzz.js", - "test:all": "npm run test && npm run test:internal:standalone && npm run test:legacy" + "test:all": "npm run test && npm run test:internal:standalone && npm run test:legacy", + "lint:ores": "if [ -f .ores-lint/lint.sh ]; then sh .ores-lint/lint.sh; else echo 'ores-lint not installed'; fi", + "prepublishOnly": "npm run lint:ores", + "prebuild": "npm run lint:ores" }, "repository": { "type": "git", @@ -61,5 +64,13 @@ }, "r2g": { "test": "node .r2g/tests/phase-contract.cjs --phase-z" + }, + "oresLint": { + "note": "Declared, not installed. ores-lint resolves these from a global install - see .ores-lint/README.md", + "requires": { + "eslint": ">=9", + "typescript-eslint": ">=8 (optional; without it .ts files are skipped)" + }, + "install": "npm i -g eslint typescript-eslint" } }