From 953e41824800ae02985ff1f4c7b4721189fcb145 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 17:01:51 +0000 Subject: [PATCH 1/2] chore: check in outstanding working-tree changes Commits work that was left uncommitted in this repository's working tree, so it is preserved in history rather than being lost or swept into an unrelated change later. Files were content-scanned for credentials before staging; build output was excluded. This is a checkpoint of pre-existing local work, not a reviewed change - the diff is worth reading before it is built on. Files (15): package.json .github/workflows/ores-lint.yml .ores-lint/ eslint.config.mjs --- .github/workflows/ores-lint.yml | 40 +++++ .ores-lint/README.md | 112 ++++++++++++++ .ores-lint/VERSION | 1 + .ores-lint/config.sh | 35 +++++ .ores-lint/eslint/base.mjs | 139 +++++++++++++++++ .ores-lint/eslint/formatter.mjs | 71 +++++++++ .ores-lint/eslint/plugin.mjs | 261 ++++++++++++++++++++++++++++++++ .ores-lint/install-git-hooks.sh | 19 +++ .ores-lint/js.sh | 90 +++++++++++ .ores-lint/lint.sh | 37 +++++ .ores-lint/required-tools.json | 20 +++ .ores-lint/rust.sh | 94 ++++++++++++ .ores-lint/selftest.sh | 75 +++++++++ eslint.config.mjs | 11 ++ package.json | 13 +- 15 files changed, 1017 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ores-lint.yml create mode 100644 .ores-lint/README.md create mode 100644 .ores-lint/VERSION create mode 100755 .ores-lint/config.sh create mode 100644 .ores-lint/eslint/base.mjs create mode 100644 .ores-lint/eslint/formatter.mjs create mode 100644 .ores-lint/eslint/plugin.mjs create mode 100755 .ores-lint/install-git-hooks.sh create mode 100755 .ores-lint/js.sh create mode 100755 .ores-lint/lint.sh create mode 100644 .ores-lint/required-tools.json create mode 100755 .ores-lint/rust.sh create mode 100755 .ores-lint/selftest.sh create mode 100644 eslint.config.mjs diff --git a/.github/workflows/ores-lint.yml b/.github/workflows/ores-lint.yml new file mode 100644 index 0000000..dd1d982 --- /dev/null +++ b/.github/workflows/ores-lint.yml @@ -0,0 +1,40 @@ +# 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 ] && echo "js=true" >> "$GITHUB_OUTPUT" || echo "js=false" >> "$GITHUB_OUTPUT" + [ -f Cargo.toml ] && echo "rs=true" >> "$GITHUB_OUTPUT" || echo "rs=false" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-node@v4 + if: steps.detect.outputs.js == '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 + + - 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..f631380 --- /dev/null +++ b/.ores-lint/README.md @@ -0,0 +1,112 @@ +# 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. + +## 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 the repo's own 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 | +| 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: + +| 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 | + +## 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. + +## 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` | `0` | skip one half | +| `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. +Repo-specific ESLint config that already existed is never overwritten. + +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/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/verify.mjs # assert every repo is correctly installed +``` + +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..3eefcb9 --- /dev/null +++ b/.ores-lint/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/.ores-lint/config.sh b/.ores-lint/config.sh new file mode 100755 index 0000000..4706b35 --- /dev/null +++ b/.ores-lint/config.sh @@ -0,0 +1,35 @@ +#!/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}" + +# 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_RUST_ALL_TARGETS ORES_LINT_ESLINT_MIN_MAJOR 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/eslint/base.mjs b/.ores-lint/eslint/base.mjs new file mode 100644 index 0000000..79b372e --- /dev/null +++ b/.ores-lint/eslint/base.mjs @@ -0,0 +1,139 @@ +/** + * 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 { 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']; + +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, ...(opts.ignores || [])] }, + { + files: JS_FILES, + plugins: { ores: oresPlugin }, + languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + rules: { ...semiRules, ...correctness, ...(opts.rules || {}) }, + }, + ]; + + 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..56c1a3d --- /dev/null +++ b/.ores-lint/eslint/formatter.mjs @@ -0,0 +1,71 @@ +/** + * 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}`); + } + } + + if (!byRule.size && !parseErrors.length) return 'ores-lint[js]: clean\n'; + + const out = []; + const total = errors + warnings; + out.push(`ores-lint[js]: ${total} finding(s) across ${byRule.size} rule(s) in ${files} file(s)`); + + // 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..f348759 --- /dev/null +++ b/.ores-lint/eslint/plugin.mjs @@ -0,0 +1,261 @@ +/** + * 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']; + +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; +} + +export const requireSendRule = { + meta: { + type: 'problem', + docs: { description: 'require chainable logger events to call a terminal method such as send()' }, + 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.", + }, + }, + + 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 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; + }; + + return { + 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); + } + }, + + AssignmentExpression(node) { + const assignedName = getQualifiedName(node.left); + if (assignedName && isLoggerProducer(node.right)) knownLoggers.add(assignedName); + }, + + 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 } }); + }, + }; + }, +}; + +/** + * 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.0.0' }, rules }; +export default plugin; 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..bb0af4b --- /dev/null +++ b/.ores-lint/js.sh @@ -0,0 +1,90 @@ +#!/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" + +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..35a8376 --- /dev/null +++ b/.ores-lint/lint.sh @@ -0,0 +1,37 @@ +#!/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 +# human explicitly sets ORES_LINT_STRICT=1. + +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 + +if [ -f "$ROOT/package.json" ]; then + sh "$DIR/js.sh" "$ROOT" | tee -a "$LOG" + FOUND=1 +fi + +if [ -f "$ROOT/Cargo.toml" ]; then + sh "$DIR/rust.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" + +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/required-tools.json b/.ores-lint/required-tools.json new file mode 100644 index 0000000..4a84f03 --- /dev/null +++ b/.ores-lint/required-tools.json @@ -0,0 +1,20 @@ +{ + "_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" + } +} diff --git a/.ores-lint/rust.sh b/.ores-lint/rust.sh new file mode 100755 index 0000000..6687b25 --- /dev/null +++ b/.ores-lint/rust.sh @@ -0,0 +1,94 @@ +#!/bin/sh +# ores-lint :: Rust +# +# 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. +# +# 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" +CRATE_DIR=${1:-.} + +[ "${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" + +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 +fi + +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) + 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 "" +} +' "$OUT" +rm -f "$OUT" +exit 0 diff --git a/.ores-lint/selftest.sh b/.ores-lint/selftest.sh new file mode 100755 index 0000000..0418a6e --- /dev/null +++ b/.ores-lint/selftest.sh @@ -0,0 +1,75 @@ +#!/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 +else + echo " skip - node unavailable" +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 a5a83ab..ebc1a84 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,23 @@ "build": "astro build", "preview": "astro preview", "test": "playwright test", - "test:ui": "playwright test --ui" + "test:ui": "playwright test --ui", + "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" }, "dependencies": { "astro": "^5.2.0" }, "devDependencies": { "@playwright/test": "^1.49.0" + }, + "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" } } From 05005f6e91aaebf6846c587813a299dd320e751b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 5 Sep 2026 16:20:22 -0500 Subject: [PATCH 2/2] wip: land uncommitted working-tree changes (fleet rescue 20260905) 16 file(s) that were only on the laptop. Explicit paths; env/dec, keys, build output and secret-bearing files are excluded. Review before merging. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NAkKcSnTEpB3Ezt2wMpTXe --- .github/workflows/ores-lint.yml | 20 +- .ores-lint/README.md | 124 ++++++++- .ores-lint/VERSION | 2 +- .ores-lint/config.sh | 13 +- .ores-lint/dart.sh | 114 ++++++++ .ores-lint/eslint/base.mjs | 42 ++- .ores-lint/eslint/formatter.mjs | 12 +- .ores-lint/eslint/plugin.mjs | 124 ++++++++- .ores-lint/gleam.sh | 98 +++++++ .ores-lint/js.sh | 18 ++ .ores-lint/lint.sh | 53 +++- .ores-lint/nested-repos.json | 1 + .ores-lint/require-send.mjs | 448 ++++++++++++++++++++++++++++++++ .ores-lint/required-tools.json | 10 + .ores-lint/rust.sh | 115 ++++++-- .ores-lint/selftest.sh | 21 ++ 16 files changed, 1160 insertions(+), 55 deletions(-) mode change 100755 => 100644 .ores-lint/config.sh create mode 100755 .ores-lint/dart.sh create mode 100755 .ores-lint/gleam.sh create mode 100644 .ores-lint/nested-repos.json create mode 100644 .ores-lint/require-send.mjs 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"