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 a4e61315ccf8b248c1cd83f61211029b7bb988bd Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 5 Sep 2026 10:21:07 -0500 Subject: [PATCH 2/2] wip: land uncommitted working-tree changes (20260905) 1 file(s) that existed only in this working tree, committed on a side branch without touching the checked-out main or the working tree. Explicit paths; env/dec, keys, build output, files >5MB and secret-bearing files are excluded. Review before merging. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KAAGBnXTSw7zE5xnY4tiBY --- .github/workflows/ores-lint.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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: