From d369c649ed0c710d2772f18307a4de6135d7c8ab Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 28 Aug 2026 10:08:37 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=A5=20chore:=20Run=20CI's=20Static=20C?= =?UTF-8?q?hecks=20on=20Each=20Commit's=20Diff=20(#15303)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿช chore: Run Static Checks on Every Commit Adds `scripts/static-checks.mts`, a local port of the Static Checks CI job (.github/workflows/static-checks.yml) scoped to the files in a diff. It resolves the changed-file list, applies the same `dorny/paths-filter` groups the job uses, and runs whichever checks those paths activate โ€” ESLint, Prettier, import order, ESLint config validation, package.json validation, and (behind `--full`) config migration tests, unused i18n keys and depcheck. Like the job, every selected check runs even after one fails and the failures are summarized at the end. The pre-commit hook keeps lint-staged for the per-file layer, which verifies the exact staged content of partially staged files, then runs the script for everything lint-staged cannot cover. lint-staged now uses the job's ESLint invocation, so warnings fail locally the way they fail CI. The slow gates stay opt-in (`npm run static-checks:full`, or `STATIC_CHECKS_FULL=1`) to keep commit latency unchanged. Hooks were never installed: `config/prepare.js` existed but no `prepare` script called it, so the hook only ran where `core.hooksPath` had been set by hand. Replaces it with an inline `prepare` โ€” both Dockerfiles run `npm ci` after copying only the manifests, so a `node config/prepare.js` step would fail the image build, and husky is absent from `--omit=dev` installs. The i18n scan is a single pass over the source identifiers rather than one grep per key, verified to flag exactly the same keys as the CI loop (including the substring and dynamic-key cases) in 0.5s instead of 14s. * ๐Ÿฉน fix: Address Codex Round 1 on the Static Checks Runner Activate gates from the unfiltered changed-path list. `dorny/paths-filter` matches deleted paths too, so gating on the `--diff-filter=ACMRTUXB` list the per-file steps use let a delete-only commit โ€” the last reference to a translation key, say โ€” slip past the i18n and depcheck gates. The two lists are now derived separately, the way CI derives them. Pass `-m` to `git diff-tree` in `--commit` mode. Without it a merge commit emits no paths at all, so `--commit ` reported "Nothing to check"; a real merge in this repo's history goes from 0 to 97 files. Build the workspaces the config suite imports instead of skipping when `dist` is absent. The three `dist` directories are gitignored and `npm ci` does not produce them, so a fresh checkout reported a pass for a gate that never ran โ€” and an existing `dist` could be stale. Each is a sub-second tsdown build. Resolve a global depcheck through a shell on Windows, where npm exposes it as `depcheck.cmd` and `spawnSync` cannot see the shim. Skip dot directories when walking for imports. `.claude/worktrees/` can hold a full checkout per branch โ€” 117 on this machine โ€” and the root-wide scan behind the depcheck gate walked every one of them. Records the remaining boundary in the header: the per-file checks see exact staged content via lint-staged, while the tree-wide gates read the working tree, as running them by hand would. * ๐Ÿฉน fix: Address Codex Round 2 on the Static Checks Runner Treat an unresolvable checker as a failure. ESLint and Prettier missing meant the runner printed "All affected static checks passed" without having linted anything; only depcheck, which CI installs globally and this documents as optional, may still skip. Catch per-check exceptions. The runner promises that every selected check runs even after a failure, but a throw โ€” a malformed translation JSON, say โ€” escaped and cancelled the checks after it. Each is now recorded as that check's failure; verified that depcheck still runs after i18n throws. Restrict `--commit` to the checked-out commit. Paths came from the named commit while contents came from the working tree, so an older revision was scored against the wrong file contents: a file added then deleted vanished, and one modified since was read at its newer contents. It now fails with a pointer to `--against`. Reject unknown options. `--ful` silently ran the fast tier and `--commmit HEAD` treated `HEAD` as a file path, both exiting 0 and implying gates had run. Cover the runner in CI. `scripts/**` was absent from the workflow's trigger paths, so a PR touching only the script the pre-commit hook now depends on got no Static Checks run โ€” and ESLint has no flat-config match for `scripts/**/*.mts`, so nothing else loads it either. Adds the trigger path, a `runner` filter group and a step that runs the script against the PR's own diff. * ๐Ÿ”— feat: Add Circular Dependency and TypeScript Gates Both already run in CI as jobs of the Backend Unit Tests workflow; this brings them to the local runner so they land before a push rather than after. Circular dependencies (`node config/circular-deps.mjs`) is fast enough at 0.9s to sit in the per-commit tier, gated on the same paths that trigger the CI job. TypeScript stays opt-in behind `--full`: the five projects cost between 2.3s and 20.9s each, which is too much per commit. Each project declares the paths that can affect it โ€” its own sources plus its upstream packages โ€” so an edit to data-provider still typechecks data-schemas, api, packages/client and client, while an `api/**`-only change runs none of them, since no typechecked project includes that directory. The builds a project's imports resolve through are made first, mirroring the CI jobs' dependency on the build artifacts, through a helper the config suite now shares. Also addresses codex round 3: Reject conflicting target selectors. `--against origin/dev package.json` silently checked only the file, and `--against --commit HEAD` never resolved the bad base, so a caller could believe a range had been checked. Require a clean worktree in `--commit` mode. The HEAD-only restriction was not enough: contents still come from the working tree, so an uncommitted edit was scored against the named commit โ€” an invalid uncommitted package.json failing a valid HEAD, or an uncommitted fix masking a defect in it. The summary now names how many checks were skipped rather than reporting a bare pass, and a typecheck failure carries the stale-workspace-build hint โ€” inside a git worktree `librechat-data-provider` resolves to the main checkout, whose dist can predate the branch and shows up as missing properties. * ๐Ÿฉน fix: Address Codex Round 4 on the Static Checks Runner Diff `--against` from the merge base. A two-dot diff reports the base branch's own commits in reverse once it advances, so `--against origin/dev` scored 64 files for a branch that changed 7, activating gates for files the branch never touched. Three dots makes the documented PR-style command mean what it says. Typecheck on root manifest changes. Both review workflows trigger their TypeScript jobs on package.json and package-lock.json, because a dependency or @types bump breaks compilation on its own; the local filter ignored them, so `static-checks:full` passed where CI would fail. Validate every workspace manifest. The list mirrored the four the CI step happens to name, so a malformed packages/api, data-provider or data-schemas manifest passed validation in the revision modes, which have no lint-staged pass behind them. Both lists now cover all seven. Make the runner smoke execute a check. `--list` never runs one, and a script-only PR activates no group, so the CI coverage added for exactly that case could pass with the execution path untouched. It now runs against an explicit target. * ๐Ÿฉน fix: Address Codex Round 5 on the Static Checks Runner Activate the JSON gate for every manifest it validates. Round 4 added the four workspace manifests to the validation list but not to the filter that turns the gate on, so a malformed packages/data-provider or data-schemas manifest still passed when it was the only changed file โ€” the list grew and the trigger did not. The same two entries also feed the unused-package calculation, reached through api/package.json's @librechat/data-schemas dependency. Include the owning workflows in the imported gates' filters. Circular dependencies and TypeScript come from the review workflows, both of which list their own YAML in `on.paths` and therefore rerun those jobs when the workflow changes; locally the gates stayed inactive, so a change to how they are built or invoked could bypass the local equivalent. Added to the group filters and to the per-project predicates, since a workflow-only change would otherwise activate the group and then select no project. Bound command batches by characters rather than file count. Windows caps a command line at 32767 characters, far below POSIX ARG_MAX, and a count does not bound that: 400 of this repository's longer paths already come to 30176 characters before the executable and fixed arguments. Verified that a list spanning several batches still reports a defect in its final file. --- .github/CONTRIBUTING.md | 3 + .github/workflows/static-checks.yml | 32 +- .husky/lint-staged.config.js | 4 +- .husky/pre-commit | 9 +- config/prepare.js | 12 - package.json | 3 + scripts/static-checks.mts | 1218 +++++++++++++++++++++++++++ 7 files changed, 1266 insertions(+), 15 deletions(-) delete mode 100644 config/prepare.js create mode 100644 scripts/static-checks.mts diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6524947ba2..1574efbcd1 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -45,6 +45,9 @@ Project maintainers have the right and responsibility to remove, edit, or reject 1. Before starting work, make sure your main branch has the latest commits with `npm run update`. 2. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning. + - `npm install` sets the hooks up for you; set `HUSKY=0` to opt out. + - The pre-commit hook runs the Static Checks CI job locally, scoped to the files in the commit. Run it by hand with `npm run static-checks`, against a base ref with `npm run static-checks -- --against origin/dev`, or with the slow gates (TypeScript, config migration tests, unused i18n keys, unused npm packages) via `npm run static-checks:full`. + - Every commit gets ESLint, Prettier, import order and circular-dependency detection; the slower gates stay opt-in so commits stay fast. 3. After your changes, reinstall packages in your current branch using `npm run reinstall` and ensure everything still works. - Restart the ESLint server ("ESLint: Restart ESLint Server" in VS Code command bar) and your IDE after reinstalling or updating. 4. Clear web app localStorage and cookies before and after changes. diff --git a/.github/workflows/static-checks.yml b/.github/workflows/static-checks.yml index fc957a6d90..5782dab05f 100644 --- a/.github/workflows/static-checks.yml +++ b/.github/workflows/static-checks.yml @@ -7,6 +7,7 @@ on: - 'client/**' - 'config/**' - 'packages/**' + - 'scripts/**' - 'package.json' - 'package-lock.json' - 'eslint.config.mjs' @@ -40,6 +41,8 @@ jobs: # config via git show โ€” a shallow checkout breaks both. fetch-depth: 0 + # scripts/static-checks.mts mirrors these filters to run the same checks + # locally on a commit's diff โ€” keep the two in sync. - name: Detect affected checks id: paths uses: dorny/paths-filter@v4 @@ -70,11 +73,19 @@ jobs: - 'packages/data-schemas/src/**' - '.github/workflows/static-checks.yml' - '!**.md' + runner: + - 'scripts/static-checks.mts' + - '.github/workflows/static-checks.yml' unused_packages: - 'api/**' - 'client/**' - 'packages/api/**' - 'packages/client/**' + # Every workspace manifest the JSON validation step covers, plus + # the ones whose dependencies feed the unused-package calculation + # through api/package.json's @librechat/data-schemas entry. + - 'packages/data-provider/package.json' + - 'packages/data-schemas/package.json' - 'package.json' - 'package-lock.json' - '.github/workflows/static-checks.yml' @@ -202,6 +213,23 @@ jobs: # Directory args, not `npm run lint`: the root brace-expansion@^5 # override breaks minimatch@3's brace expansion, so that script's # braced glob crashes on a clean install; dir args never brace-expand. + # scripts/static-checks.mts runs these same checks locally from the + # pre-commit hook, and nothing else in this job loads it: ESLint has no + # flat-config match for scripts/**/*.mts. Run it against the PR's own + # diff so a syntax error or a broken filter fails here rather than in + # every contributor's next commit. + - name: Smoke the local static-checks runner + id: runner + if: always() && steps.paths.outputs.runner == 'true' + continue-on-error: true + run: | + BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") + node scripts/static-checks.mts --against "$BASE_SHA" --list + # An explicit target, because --list never executes a check and a + # script-only PR activates no group โ€” so neither would exercise the + # execution path this step exists to protect. + node scripts/static-checks.mts package.json --only json + - name: Validate ESLint config on config changes id: eslint_config if: always() && steps.paths.outputs.eslint_config == 'true' @@ -393,7 +421,7 @@ jobs: if: always() && steps.paths.outputs.unused_packages == 'true' continue-on-error: true run: | - for FILE in package.json client/package.json api/package.json packages/client/package.json; do + for FILE in package.json client/package.json api/package.json packages/api/package.json packages/client/package.json packages/data-provider/package.json packages/data-schemas/package.json; do if [[ -f "$FILE" ]]; then jq empty "$FILE" || (echo "::error title=Invalid JSON::$FILE is invalid" && exit 1) fi @@ -642,6 +670,7 @@ jobs: ESLINT_CONFIG_OUTCOME: ${{ steps.eslint_config.outcome }} PRETTIER_OUTCOME: ${{ steps.prettier.outcome }} IMPORT_SORT_OUTCOME: ${{ steps.import_sort.outcome }} + RUNNER_OUTCOME: ${{ steps.runner.outcome }} CACHE_DATA_PROVIDER_OUTCOME: ${{ steps.cache-data-provider.outcome }} CONFIG_DATA_PROVIDER_OUTCOME: ${{ steps.config_data_provider.outcome }} CACHE_DATA_SCHEMAS_OUTCOME: ${{ steps.cache-data-schemas.outcome }} @@ -678,6 +707,7 @@ jobs: record_failure "ESLint config validation" "$ESLINT_CONFIG_OUTCOME" record_failure "Prettier" "$PRETTIER_OUTCOME" record_failure "Import sorting" "$IMPORT_SORT_OUTCOME" + record_failure "Local static-checks runner" "$RUNNER_OUTCOME" record_failure "Config data-provider cache" "$CACHE_DATA_PROVIDER_OUTCOME" record_failure "Config data-provider build" "$CONFIG_DATA_PROVIDER_OUTCOME" record_failure "Config data-schemas cache" "$CACHE_DATA_SCHEMAS_OUTCOME" diff --git a/.husky/lint-staged.config.js b/.husky/lint-staged.config.js index 8aee5fba81..910f484837 100644 --- a/.husky/lint-staged.config.js +++ b/.husky/lint-staged.config.js @@ -3,7 +3,9 @@ module.exports = { 'node scripts/sort-imports.mts', 'prettier --write', 'eslint --fix', - 'eslint', + // Same invocation as the Static Checks CI job: warnings are failures there, + // and changed files under config-ignored paths must not trip it. + 'eslint --config eslint.config.mjs --no-warn-ignored --max-warnings=0', ], '*.json': ['prettier --write'], }; diff --git a/.husky/pre-commit b/.husky/pre-commit index 70fef90065..67de2b6c1f 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,10 @@ #!/bin/sh [ -n "$CI" ] && exit 0 -npx lint-staged --config ./.husky/lint-staged.config.js + +# Formats, sorts and lints the exact staged content of each changed file. +npx lint-staged --config ./.husky/lint-staged.config.js || exit 1 + +# The rest of the Static Checks CI job, gated on the paths this commit touches. +# Add STATIC_CHECKS_FULL=1 for the slow gates (TypeScript, config migration +# tests, unused i18n keys, unused npm packages). +node scripts/static-checks.mts --skip eslint,prettier,imports diff --git a/config/prepare.js b/config/prepare.js deleted file mode 100644 index c5ec4ab872..0000000000 --- a/config/prepare.js +++ /dev/null @@ -1,12 +0,0 @@ -const { exec } = require('child_process'); - -if (process.env.NODE_ENV !== 'CI') { - exec('npx husky install', (error, stdout, stderr) => { - if (error) { - console.error(`exec error: ${error}`); - return; - } - console.log(`stdout: ${stdout}`); - console.error(`stderr: ${stderr}`); - }); -} diff --git a/package.json b/package.json index 9afc2bfcc5..44ad89c799 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "packages/*" ], "scripts": { + "prepare": "node -e \"import('husky').then((h) => h.default()).catch(() => {})\"", "update": "node config/update.js", "add-balance": "node config/add-balance.js", "set-balance": "node config/set-balance.js", @@ -104,6 +105,8 @@ "lint": "eslint .", "sort-imports": "node scripts/sort-imports.mts", "sort-imports:check": "node scripts/sort-imports.mts --check", + "static-checks": "node scripts/static-checks.mts", + "static-checks:full": "node scripts/static-checks.mts --full", "format": "npx prettier --write \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"", "b:api": "NODE_ENV=production bun run api/server/index.js", "b:api-inspect": "NODE_ENV=production bun --inspect run api/server/index.js", diff --git a/scripts/static-checks.mts b/scripts/static-checks.mts new file mode 100644 index 0000000000..561d6a5c0e --- /dev/null +++ b/scripts/static-checks.mts @@ -0,0 +1,1218 @@ +#!/usr/bin/env node +/** + * Local equivalent of the "Static checks" CI job + * (.github/workflows/static-checks.yml), scoped to the files in a diff. + * + * The job has two layers and this mirrors both. Per-file checks (ESLint, + * Prettier, import order) run against the changed JS/TS files under `api/`, + * `client/` and `packages/`. Tree-wide gates (config migration tests, unused + * i18n keys, unused npm packages) run only when the diff touches the paths + * that gate them in CI. Circular-dependency detection and the TypeScript + * project checks come from the Backend Unit Tests workflow rather than the + * Static Checks job, but they gate on a commit's paths the same way. + * + * Like the CI job, every selected check runs even after one fails, and the + * failures are summarized at the end. + * + * The per-file checks see the exact staged content of a commit, because the + * pre-commit hook runs them through lint-staged. The tree-wide gates read the + * working tree, the same as running them by hand โ€” reading the index instead + * would mean materializing a second checkout with its own installs and builds. + * + * Runs on Node 24+ via native type-stripping (`.mts` keeps ESM semantics under + * the CommonJS repo root): + * + * Staged diff (what the pre-commit hook runs): + * npm run static-checks + * Add the slow gates (TypeScript, config tests, i18n, depcheck): + * npm run static-checks:full + * Against a base ref, the way CI sees a pull request: + * node scripts/static-checks.mts --against origin/dev + * A single commit: + * node scripts/static-checks.mts --commit HEAD + * Explicit files: + * node scripts/static-checks.mts packages/api/src/index.ts + * + * Flags: --staged, --full, --fast, --only , --skip , --verbose, + * --list. Check ids: eslint, prettier, imports, eslint-config, json, + * circular-deps, typecheck, config-tests, i18n, depcheck. + */ + +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { readFile, readdir } from 'node:fs/promises'; +import { join, relative, resolve, dirname } from 'node:path'; +import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs'; + +import type { Dirent } from 'node:fs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** + * Path filters, mirroring the `dorny/paths-filter` block in + * .github/workflows/static-checks.yml. A group is active when some changed + * file matches one of its patterns and no exclusion (`!`) pattern. + * Keep the two in sync โ€” the workflow additionally defines a `runner` group + * for its own smoke test of this script, which has no local counterpart. + */ +const FILTERS = { + eslint: [ + 'api/**', + 'client/**', + 'packages/**', + 'eslint.config.mjs', + '.github/workflows/static-checks.yml', + '!**.md', + ], + eslint_config: ['eslint.config.mjs', '.github/workflows/static-checks.yml'], + config: ['api/**', 'config/**', 'packages/**', '.github/workflows/static-checks.yml', '!**.md'], + i18n: [ + 'api/**', + 'client/src/**', + 'packages/client/**', + 'packages/data-provider/src/**', + 'packages/data-schemas/src/**', + '.github/workflows/static-checks.yml', + '!**.md', + ], + // Mirrors the Backend Unit Tests workflow, which owns both of these jobs. + circular_deps: [ + 'api/**', + 'packages/**', + 'package.json', + 'package-lock.json', + 'config/circular-deps.mjs', + '.github/workflows/backend-review.yml', + '!**.md', + ], + // The review workflows trigger their TypeScript jobs on the root manifests + // too, since a dependency or @types bump can break compilation on its own. + typecheck: [ + 'client/**', + 'packages/**', + 'package.json', + 'package-lock.json', + '.github/workflows/backend-review.yml', + '.github/workflows/frontend-review.yml', + '!**.md', + ], + unused_packages: [ + 'api/**', + 'client/**', + 'packages/api/**', + 'packages/client/**', + // Every workspace manifest PACKAGE_JSON_FILES validates, plus the ones + // whose dependencies feed the unused-package calculation through + // api/package.json's @librechat/data-schemas entry. + 'packages/data-provider/package.json', + 'packages/data-schemas/package.json', + 'package.json', + 'package-lock.json', + '.github/workflows/static-checks.yml', + '!**.md', + ], +} as const; + +type FilterName = keyof typeof FILTERS; + +/** The same set the CI job lints, formats and import-sorts. */ +const SOURCE_FILE_PATTERN = /^(api|client|packages)\/.*\.(js|jsx|ts|tsx)$/; + +/** Files ESLint is pointed at when the flat config itself changes. */ +const CONFIG_SMOKE_FILES = [ + 'api/server/index.js', + 'client/src/main.jsx', + 'packages/api/src/index.ts', +]; + +const PACKAGE_JSON_FILES = [ + 'package.json', + 'client/package.json', + 'api/package.json', + 'packages/api/package.json', + 'packages/client/package.json', + 'packages/data-provider/package.json', + 'packages/data-schemas/package.json', +]; + +const I18N_FILE = 'client/src/locales/en/translation.json'; +const I18N_SOURCE_DIRS = [ + 'client/src', + 'api', + 'packages/data-provider/src', + 'packages/client', + 'packages/data-schemas/src', +]; + +const SOURCE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx']; +const IMPORT_EXTENSIONS = [...SOURCE_EXTENSIONS, '.mjs', '.cjs', '.mts', '.cts']; +const SKIP_DIR_NAMES = new Set(['node_modules', 'dist', 'coverage']); + +/** + * Batch limits, so a large diff cannot overflow the command line. Windows caps + * a command line at 32767 characters โ€” far below POSIX ARG_MAX โ€” and a count + * alone does not bound that: 400 of this repo's longer paths already approach + * it, so the character budget is the real constraint and the count is a + * secondary guard. + */ +const BATCH_SIZE = 400; +const BATCH_CHARS = 24000; + +type Tier = 'fast' | 'slow'; + +interface CheckOutcome { + ok: boolean; + skipped?: string; + output?: string; + hints?: string[]; +} + +interface CheckContext { + files: string[]; + sourceFiles: string[]; + groups: Record; +} + +interface Check { + id: string; + title: string; + tier: Tier; + group: FilterName; + run: (context: CheckContext) => CheckOutcome | Promise; +} + +/** A resolved tool invocation: `node ` for a workspace package. */ +interface Executable { + command: string; + args: string[]; + /** Windows resolves `.cmd` shims only through a shell. */ + shell?: boolean; +} + +interface CommandResult { + status: number; + stdout: string; + stderr: string; + output: string; +} + +function fail(message: string): never { + console.error(`static-checks: ${message}`); + process.exit(2); +} + +function parseList(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +const argv = process.argv.slice(2); + +/** Every accepted flag, so a typo fails loudly instead of changing the run. */ +const KNOWN_FLAGS = new Set([ + '--against', + '--commit', + '--only', + '--skip', + '--staged', + '--full', + '--fast', + '--list', + '--verbose', +]); + +function readOption(name: string): string | undefined { + const index = argv.indexOf(name); + if (index === -1) return undefined; + const value = argv[index + 1]; + if (!value || value.startsWith('--')) fail(`${name} requires a value`); + return value; +} + +const VALUED_OPTIONS = { + against: readOption('--against'), + commit: readOption('--commit'), + only: readOption('--only'), + skip: readOption('--skip'), +}; + +const OPTIONS = { + against: VALUED_OPTIONS.against, + commit: VALUED_OPTIONS.commit, + only: parseList(VALUED_OPTIONS.only), + skip: parseList(VALUED_OPTIONS.skip), + list: argv.includes('--list'), + verbose: argv.includes('--verbose'), + full: + argv.includes('--full') || (process.env.STATIC_CHECKS_FULL === '1' && !argv.includes('--fast')), +}; + +const OPTION_VALUES = new Set( + Object.values(VALUED_OPTIONS).filter((value): value is string => value !== undefined), +); + +const UNKNOWN_FLAGS = argv.filter((arg) => arg.startsWith('-') && !KNOWN_FLAGS.has(arg)); +if (UNKNOWN_FLAGS.length > 0) { + fail(`unknown option(s): ${UNKNOWN_FLAGS.join(', ')}`); +} + +const FILE_ARGS = argv.filter((arg) => !arg.startsWith('-') && !OPTION_VALUES.has(arg)); + +/** Runs a command, capturing output so only failures have to be printed. */ +function runCommand(executable: Executable, args: string[], cwd = ROOT): CommandResult { + const result = spawnSync(executable.command, [...executable.args, ...args], { + cwd, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + shell: executable.shell === true, + }); + const stdout = result.stdout ?? ''; + const stderr = result.stderr ?? (result.error ? `${result.error.message}\n` : ''); + return { + status: result.error ? 1 : (result.status ?? 1), + stdout, + stderr, + output: `${stdout}${stderr}`, + }; +} + +/** Runs a command in batches so a large file list stays under the arg limit. */ +function batchFiles(files: string[]): string[][] { + const batches: string[][] = []; + let batch: string[] = []; + let length = 0; + for (const file of files) { + const cost = file.length + 1; + const full = batch.length >= BATCH_SIZE || length + cost > BATCH_CHARS; + // A single path longer than the budget still gets its own batch rather + // than an empty one. + if (batch.length > 0 && full) { + batches.push(batch); + batch = []; + length = 0; + } + batch.push(file); + length += cost; + } + if (batch.length > 0) batches.push(batch); + return batches; +} + +function runOnFiles(executable: Executable, args: string[], files: string[]): CommandResult { + let status = 0; + let stdout = ''; + let stderr = ''; + for (const batch of batchFiles(files)) { + const result = runCommand(executable, [...args, ...batch]); + if (status === 0) status = result.status; + stdout += result.stdout; + stderr += result.stderr; + } + return { status, stdout, stderr, output: `${stdout}${stderr}` }; +} + +const GIT: Executable = { command: 'git', args: [] }; + +const NPM: Executable = + process.platform === 'win32' + ? { command: 'npm.cmd', args: [], shell: true } + : { command: 'npm', args: [] }; + +function captureStdout(executable: Executable, args: string[]): string { + const result = runCommand(executable, args); + if (result.status !== 0) { + fail(`${executable.command} ${args.join(' ')} failed:\n${result.stderr}`); + } + return result.stdout; +} + +const require = createRequire(import.meta.url); + +/** + * Resolves an installed package's executable to `node `, which works + * the same on every platform โ€” unlike the `node_modules/.bin` shims, which + * Node refuses to spawn on Windows without a shell. + */ +function resolveBin(name: string, binName = name): Executable | null { + try { + const manifestPath = require.resolve(`${name}/package.json`, { paths: [ROOT] }); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + bin?: string | Record; + }; + const entry = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[binName]; + if (!entry) return null; + return { command: process.execPath, args: [resolve(dirname(manifestPath), entry)] }; + } catch { + return null; + } +} + +/** + * A checker that cannot run is a failure, not a skip: reporting "all affected + * static checks passed" without having linted anything is worse than saying + * nothing. Only depcheck, which CI installs globally and this treats as + * optional, is allowed to skip. + */ +function missingBin(name: string): CheckOutcome { + return { ok: false, output: `${name} is not installed โ€” run npm ci` }; +} + +function globToRegExp(pattern: string): RegExp { + let source = ''; + for (let index = 0; index < pattern.length; index++) { + const char = pattern[index]; + if (char === '*') { + if (pattern[index + 1] === '*') { + source += '.*'; + index++; + continue; + } + source += '[^/]*'; + continue; + } + source += /[a-zA-Z0-9/_-]/.test(char) ? char : `\\${char}`; + } + return new RegExp(`^${source}$`); +} + +const MATCHERS = new Map(); + +function matches(pattern: string, file: string): boolean { + let matcher = MATCHERS.get(pattern); + if (!matcher) { + matcher = globToRegExp(pattern); + MATCHERS.set(pattern, matcher); + } + return matcher.test(file); +} + +/** `some-with-excludes`: some file matches an include and no exclusion. */ +function groupIsActive(files: string[], patterns: readonly string[]): boolean { + const includes = patterns.filter((pattern) => !pattern.startsWith('!')); + const excludes = patterns + .filter((pattern) => pattern.startsWith('!')) + .map((pattern) => pattern.slice(1)); + return files.some( + (file) => + includes.some((pattern) => matches(pattern, file)) && + !excludes.some((pattern) => matches(pattern, file)), + ); +} + +interface Target { + label: string; + /** Every changed path, deletions included โ€” this is what activates a group. */ + files: string[]; + /** Only paths the per-file checks can open, matching the CI job's file list. */ + existing: string[]; +} + +/** + * Two lists, because CI derives two. `dorny/paths-filter` matches added, + * modified AND deleted paths when it decides which checks are affected, while + * the ESLint/Prettier/import-sort steps narrow to `--diff-filter=ACMRTUXB` + * so they never hand a deleted path to a tool. Activating gates off the + * narrowed list would let a delete-only commit โ€” the last reference to a + * translation key, say โ€” slip past the i18n and depcheck gates. + */ +function diffPaths(args: string[]): { files: string[]; existing: string[] } { + const split = (output: string): string[] => output.split('\0').filter(Boolean); + return { + files: split(captureStdout(GIT, args)), + existing: split(captureStdout(GIT, [...args, '--diff-filter=ACMRTUXB'])), + }; +} + +function resolveTarget(): Target { + // Precedence would silently drop the losers: `--against origin/dev pkg.json` + // checked only the file, and paired with --commit the base ref was never even + // resolved, so a caller could believe a range had been checked. + const selectors = [ + FILE_ARGS.length > 0 && 'file arguments', + OPTIONS.commit && '--commit', + OPTIONS.against && '--against', + argv.includes('--staged') && '--staged', + ].filter((selector): selector is string => typeof selector === 'string'); + if (selectors.length > 1) { + fail(`${selectors.join(' and ')} cannot be combined โ€” name the target once`); + } + + if (FILE_ARGS.length > 0) { + const files = FILE_ARGS.map((file) => relative(ROOT, resolve(file)).split('\\').join('/')); + return { label: 'files from the command line', files, existing: files }; + } + + if (OPTIONS.commit) { + // Paths come from the commit but contents come from the working tree, so + // any other revision would be checked against the wrong file contents โ€” + // an added-then-deleted file would vanish, a since-modified one would be + // read at its newer contents. Use --against for a range instead. + const requested = captureStdout(GIT, [ + 'rev-parse', + '--verify', + `${OPTIONS.commit}^{commit}`, + ]).trim(); + const head = captureStdout(GIT, ['rev-parse', '--verify', 'HEAD^{commit}']).trim(); + if (requested !== head) { + fail( + `--commit ${OPTIONS.commit} (${requested.slice(0, 10)}) is not the checked-out commit ` + + `(${head.slice(0, 10)}). These checks read the working tree, so check that commit out ` + + `first, or use --against to scope by a range.`, + ); + } + // Contents still come from the working tree, so uncommitted edits would + // be scored against the named commit โ€” an invalid uncommitted package.json + // failing a valid HEAD, or an uncommitted fix masking a defect in it. + const dirty = captureStdout(GIT, ['status', '--porcelain', '--untracked-files=no']).trim(); + if (dirty) { + fail( + 'these checks read the working tree, so --commit needs it to match the commit; ' + + `${dirty.split('\n').length} tracked file(s) differ. Commit or restore them, or drop ` + + '--commit to check the staged diff.', + ); + } + // -m is load-bearing: without it a merge commit yields no paths at all. + return { + label: `commit ${OPTIONS.commit}`, + ...diffPaths([ + 'diff-tree', + '--root', + '-m', + '--no-commit-id', + '-r', + '-z', + '--name-only', + OPTIONS.commit, + ]), + }; + } + + if (OPTIONS.against) { + // Three dots: diff from the merge base, not between the two tips. Once the + // base branch advances, a two-dot diff reports its commits in reverse as + // part of this target, activating gates for files the branch never touched. + return { + label: `${OPTIONS.against}...HEAD`, + ...diffPaths(['diff', '-z', '--name-only', `${OPTIONS.against}...HEAD`]), + }; + } + + return { + label: 'staged diff', + ...diffPaths(['diff', '-z', '--cached', '--name-only']), + }; +} + +/** Recursively yields repo-relative paths of source files under `dir`. */ +async function* walkSourceFiles(dir: string, extensions: string[]): AsyncGenerator { + let entries: Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + // Dot directories hold tooling, not product code, and one of them โ€” + // .claude/worktrees โ€” can hold a full checkout per branch. + if (entry.name.startsWith('.') || SKIP_DIR_NAMES.has(entry.name)) continue; + yield* walkSourceFiles(path, extensions); + } else if (entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext))) { + yield path; + } + } +} + +/** Every JS/TS-ish file under `dirs`, as absolute paths. */ +async function collectFiles(dirs: string[], extensions: string[]): Promise { + const files: string[] = []; + for (const dir of dirs) { + for await (const file of walkSourceFiles(resolve(ROOT, dir), extensions)) { + files.push(file); + } + } + return files; +} + +// --------------------------------------------------------------- per-file checks + +function lintChangedFiles(context: CheckContext): CheckOutcome { + if (context.sourceFiles.length === 0) { + return { ok: true, skipped: 'no changed JS/TS files' }; + } + const eslint = resolveBin('eslint'); + if (!eslint) return missingBin('eslint'); + + // --no-warn-ignored: changed files under config-ignored paths + // (e.g. packages/data-schemas/misc/**) must not fail --max-warnings=0. + const result = runOnFiles( + eslint, + [ + '--no-error-on-unmatched-pattern', + '--config', + 'eslint.config.mjs', + '--no-warn-ignored', + '--max-warnings=0', + '--', + ], + context.sourceFiles, + ); + return { + ok: result.status === 0, + output: result.output, + hints: ['Fix automatically where possible with: npx eslint --fix '], + }; +} + +function checkFormatting(context: CheckContext): CheckOutcome { + if (context.sourceFiles.length === 0) { + return { ok: true, skipped: 'no changed JS/TS files' }; + } + const prettier = resolveBin('prettier'); + if (!prettier) return missingBin('prettier'); + + const result = runOnFiles( + prettier, + ['--check', '--no-error-on-unmatched-pattern', '--'], + context.sourceFiles, + ); + return { + ok: result.status === 0, + output: result.output, + hints: ['Fix with: npx prettier --write '], + }; +} + +function checkImportOrder(context: CheckContext): CheckOutcome { + if (context.sourceFiles.length === 0) { + return { ok: true, skipped: 'no changed JS/TS files' }; + } + const sortImports: Executable = { + command: process.execPath, + args: [resolve(ROOT, 'scripts/sort-imports.mts'), '--check'], + }; + const result = runOnFiles(sortImports, [], context.sourceFiles); + return { + ok: result.status === 0, + output: result.output, + hints: [ + 'Fix everything with: npm run sort-imports', + 'Fix specific files with: npm run sort-imports -- ', + ], + }; +} + +/** + * The changed-file lint never loads a changed root config: a config-only diff + * matches no lintable files, so even a malformed eslint.config.mjs would pass. + * When the config changes, gate on it loading and applying to representative + * sources. CI additionally runs a full-tree regression sweep, which is too + * slow to be worth repeating locally. + */ +function validateEslintConfig(): CheckOutcome { + const eslint = resolveBin('eslint'); + if (!eslint) return missingBin('eslint'); + const result = runCommand(eslint, ['--config', 'eslint.config.mjs', ...CONFIG_SMOKE_FILES]); + return { ok: result.status === 0, output: result.output }; +} + +async function validatePackageJson(): Promise { + const invalid: string[] = []; + for (const file of PACKAGE_JSON_FILES) { + const path = resolve(ROOT, file); + if (!existsSync(path)) continue; + try { + JSON.parse(await readFile(path, 'utf8')); + } catch (error) { + invalid.push(`${file}: ${(error as Error).message}`); + } + } + return { ok: invalid.length === 0, output: invalid.join('\n') }; +} + +// --------------------------------------------------------------- config migration tests + +/** + * The config suite reaches these workspaces through their `dist` exports, so + * CI builds them before running it. Building here too โ€” rather than skipping + * when `dist` is absent โ€” keeps a fresh checkout from reporting a pass for a + * gate that never ran, and keeps a stale `dist` from being tested instead of + * the working tree. Each is a sub-second tsdown build. + */ +const CONFIG_TEST_BUILDS = ['build:data-provider', 'build:data-schemas', 'build:api']; + +/** Dependency order; callers pass a subset and it is built in this sequence. */ +const BUILD_ORDER = [ + 'build:data-provider', + 'build:data-schemas', + 'build:api', + 'build:client-package', +]; + +/** Returns the failing build's outcome, or null when every build succeeded. */ +function buildWorkspaces(scripts: string[]): CheckOutcome | null { + for (const script of BUILD_ORDER.filter((entry) => scripts.includes(entry))) { + const build = runCommand(NPM, ['run', script]); + if (build.status !== 0) { + return { ok: false, output: `npm run ${script} failed:\n${build.output}` }; + } + } + return null; +} + +function runConfigTests(): CheckOutcome { + const buildFailure = buildWorkspaces(CONFIG_TEST_BUILDS); + if (buildFailure) return buildFailure; + + mkdirSync(resolve(ROOT, 'api/data'), { recursive: true }); + const authFile = resolve(ROOT, 'api/data/auth.json'); + if (!existsSync(authFile)) writeFileSync(authFile, '{}\n'); + + const envFile = resolve(ROOT, 'api/test/.env.test'); + const envExample = resolve(ROOT, 'api/test/.env.test.example'); + if (!existsSync(envFile) && existsSync(envExample)) copyFileSync(envExample, envFile); + + const jest = resolveBin('jest'); + if (!jest) return missingBin('jest'); + + // Same invocation as `npm run test:config`. + const result = runCommand(jest, ['--config', 'config/jest.config.js']); + return { ok: result.status === 0, output: result.output }; +} + +// --------------------------------------------------------------- unused i18n keys + +const TOKEN_PATTERN = /[A-Za-z0-9_]+/g; +const CATEGORY_LOOKUP = /category\.(label|description).*startsWith.*['"]com_/; + +/** + * CI greps every key across the source dirs one key at a time. This collects + * the identifiers once and tests keys against them, which is the same + * substring question asked in a single pass: any occurrence of a key is inside + * a maximal `[A-Za-z0-9_]+` run, because keys are made only of those + * characters. + */ +async function findUnusedI18nKeys(): Promise { + const translationFile = resolve(ROOT, I18N_FILE); + if (!existsSync(translationFile)) { + return { ok: false, output: `Translation file not found: ${I18N_FILE}` }; + } + + const translations = JSON.parse(await readFile(translationFile, 'utf8')) as Record< + string, + string + >; + const keys = Object.keys(translations); + if (keys.length === 0) return { ok: true, skipped: 'no keys defined' }; + + const shortestKey = keys.reduce((shortest, key) => Math.min(shortest, key.length), Infinity); + const tokens = new Set(); + let hasCategoryLookup = false; + + for (const file of await collectFiles(I18N_SOURCE_DIRS, SOURCE_EXTENSIONS)) { + const content = await readFile(file, 'utf8'); + if (!hasCategoryLookup) hasCategoryLookup = CATEGORY_LOOKUP.test(content); + for (const token of content.match(TOKEN_PATTERN) ?? []) { + if (token.length >= shortestKey) tokens.add(token); + } + } + + let tokenList: string[] | null = null; + const isReferenced = (key: string): boolean => { + if (tokens.has(key)) return true; + tokenList ??= [...tokens]; + return tokenList.some((token) => token.includes(key)); + }; + + const unused = keys.filter((key) => { + // Special variable labels are built dynamically from TSpecialVarLabel. + if (key.startsWith('com_ui_special_var_') && tokens.has('TSpecialVarLabel')) return false; + // Agent category keys are read back from the database. + if ( + key.startsWith('com_agents_category_') && + (hasCategoryLookup || tokens.has('ensureDefaultCategories')) + ) { + return false; + } + return !isReferenced(key); + }); + + if (unused.length === 0) return { ok: true }; + return { + ok: false, + output: `Found ${unused.length} unused i18n key(s):\n${unused.map((key) => ` ${key}`).join('\n')}`, + hints: [`Remove them from ${I18N_FILE} or reference them in the source.`], + }; +} + +// --------------------------------------------------------------- circular dependencies + +function findCircularDependencies(): CheckOutcome { + const script = resolve(ROOT, 'config/circular-deps.mjs'); + if (!existsSync(script)) { + return { ok: false, output: 'config/circular-deps.mjs is missing' }; + } + const result = runCommand({ command: process.execPath, args: [script] }, []); + return { ok: result.status === 0, output: result.output }; +} + +// --------------------------------------------------------------- TypeScript + +/** + * One entry per `tsc --noEmit` the review workflows run. `paths` includes each + * project's upstream packages, so an edit to data-provider still typechecks the + * projects that consume it; `requires` lists the builds its imports resolve + * through, mirroring those jobs' dependency on the build artifacts. + */ +const ROOT_MANIFESTS = ['package.json', 'package-lock.json']; + +/** Each imported gate reruns in CI when its owning workflow changes. */ +const BACKEND_REVIEW = '.github/workflows/backend-review.yml'; +const FRONTEND_REVIEW = '.github/workflows/frontend-review.yml'; + +const TYPECHECK_PROJECTS = [ + { + project: 'packages/data-provider/tsconfig.json', + paths: ['packages/data-provider/**', ...ROOT_MANIFESTS, BACKEND_REVIEW, '!**.md'], + requires: [], + }, + { + project: 'packages/data-schemas/tsconfig.json', + paths: [ + 'packages/data-provider/**', + 'packages/data-schemas/**', + ...ROOT_MANIFESTS, + BACKEND_REVIEW, + '!**.md', + ], + requires: ['build:data-provider'], + }, + { + project: 'packages/api/tsconfig.json', + paths: [ + 'packages/data-provider/**', + 'packages/data-schemas/**', + 'packages/api/**', + ...ROOT_MANIFESTS, + BACKEND_REVIEW, + '!**.md', + ], + requires: ['build:data-provider', 'build:data-schemas'], + }, + { + project: 'packages/client/tsconfig.json', + paths: [ + 'packages/data-provider/**', + 'packages/client/**', + ...ROOT_MANIFESTS, + BACKEND_REVIEW, + '!**.md', + ], + requires: ['build:data-provider'], + }, + { + project: 'client/tsconfig.json', + paths: [ + 'client/**', + 'packages/data-provider/**', + 'packages/client/**', + ...ROOT_MANIFESTS, + FRONTEND_REVIEW, + '!**.md', + ], + requires: ['build:data-provider', 'build:client-package'], + }, +]; + +function runTypecheck(context: CheckContext): CheckOutcome { + const selected = TYPECHECK_PROJECTS.filter((entry) => groupIsActive(context.files, entry.paths)); + if (selected.length === 0) { + return { ok: true, skipped: 'no changed TypeScript project' }; + } + + const tsc = resolveBin('typescript', 'tsc'); + if (!tsc) return missingBin('typescript'); + + const buildFailure = buildWorkspaces([...new Set(selected.flatMap((entry) => entry.requires))]); + if (buildFailure) return buildFailure; + + const failures: string[] = []; + for (const entry of selected) { + const result = runCommand(tsc, ['--noEmit', '-p', entry.project]); + if (result.status !== 0) { + failures.push(`${entry.project}:\n${result.output.trim()}`); + } + } + + return { + ok: failures.length === 0, + output: failures.join('\n\n'), + hints: [ + 'Missing properties on a workspace type usually mean a stale build: run npm run build:packages.', + 'In a git worktree, librechat-data-provider resolves to the main checkout, whose dist may predate your branch.', + ], + }; +} + +// --------------------------------------------------------------- unused npm packages + +const IMPORT_PATTERNS = [ + /require\(\s*['"]([a-zA-Z0-9@/._-]+)['"]\s*\)/g, + /\bimport\b[^\n]*?\bfrom\s*['"]([a-zA-Z0-9@/._-]+)['"]/g, + /\bexport\b[^\n]*?\bfrom\s*['"]([a-zA-Z0-9@/._-]+)['"]/g, + /\bimport\s*['"]([a-zA-Z0-9@/._-]+)['"]/g, +]; + +/** `@scope/pkg/sub` -> `@scope/pkg`, `pkg/sub` -> `pkg`, relative -> null. */ +function packageName(specifier: string): string | null { + if (specifier.startsWith('.')) return null; + if (!specifier.startsWith('@')) return specifier.split('/')[0]; + const [scope, name] = specifier.split('/'); + return name ? `${scope}/${name}` : null; +} + +async function importedPackages(dir: string): Promise> { + const packages = new Set(); + for (const file of await collectFiles([dir], IMPORT_EXTENSIONS)) { + const content = await readFile(file, 'utf8'); + for (const pattern of IMPORT_PATTERNS) { + for (const match of content.matchAll(pattern)) { + const name = packageName(match[1]); + if (name) packages.add(name); + } + } + } + return packages; +} + +interface Manifest { + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +} + +async function readManifest(file: string): Promise { + const path = resolve(ROOT, file); + if (!existsSync(path)) return null; + return JSON.parse(await readFile(path, 'utf8')) as Manifest; +} + +/** Words appearing in a manifest's npm scripts, as CI extracts them. */ +function scriptWords(manifest: Manifest | null): Set { + const words = new Set(); + for (const script of Object.values(manifest?.scripts ?? {})) { + for (const word of script.match(/[a-zA-Z0-9_-]+/g) ?? []) words.add(word); + } + return words; +} + +function manifestDependencies(manifest: Manifest | null): Set { + return new Set([ + ...Object.keys(manifest?.dependencies ?? {}), + ...Object.keys(manifest?.devDependencies ?? {}), + ...Object.keys(manifest?.peerDependencies ?? {}), + ]); +} + +/** Dependencies a manifest inherits through its `@librechat/*` workspaces. */ +async function workspaceDependencies(manifest: Manifest | null): Promise> { + const inherited = new Set(); + for (const dependency of Object.keys(manifest?.dependencies ?? {})) { + if (!dependency.startsWith('@librechat/')) continue; + const workspace = await readManifest( + `${dependency.replace('@librechat/', 'packages/')}/package.json`, + ); + for (const name of Object.keys(workspace?.dependencies ?? {})) inherited.add(name); + for (const name of Object.keys(workspace?.peerDependencies ?? {})) inherited.add(name); + } + return inherited; +} + +/** Falls back to a global install, which is how CI provides depcheck. */ +function resolveDepcheck(): Executable { + return ( + resolveBin('depcheck') ?? { + command: 'depcheck', + args: [], + // A global install is `depcheck.cmd` on Windows, which needs a shell. + shell: process.platform === 'win32', + } + ); +} + +function unusedDependencies(depcheck: Executable, cwd: string): string[] | null { + const result = runCommand(depcheck, ['--json'], cwd); + // depcheck exits non-zero when it reports findings, so only parse failures matter. + try { + const report = JSON.parse(result.stdout) as { dependencies?: string[] }; + return report.dependencies ?? []; + } catch { + return null; + } +} + +async function findUnusedPackages(): Promise { + const depcheck = resolveDepcheck(); + const probe = runCommand(depcheck, ['--version']); + if (probe.status !== 0) { + return { ok: true, skipped: 'depcheck is not installed โ€” npm install -g depcheck' }; + } + + const [rootManifest, clientManifest, apiManifest, packagesClientManifest, packagesApiManifest] = + await Promise.all([ + readManifest('package.json'), + readManifest('client/package.json'), + readManifest('api/package.json'), + readManifest('packages/client/package.json'), + readManifest('packages/api/package.json'), + ]); + + const [rootCode, clientCode, apiCode, packagesClientCode, packagesApiCode] = await Promise.all([ + importedPackages('.'), + importedPackages('client'), + importedPackages('api'), + importedPackages('packages/client'), + importedPackages('packages/api'), + ]); + + const targets = [ + { + name: 'Root', + dir: ROOT, + allowed: [scriptWords(rootManifest), rootCode, await workspaceDependencies(rootManifest)], + ignored: new Set(), + }, + { + name: 'Client', + dir: resolve(ROOT, 'client'), + allowed: [ + scriptWords(clientManifest), + clientCode, + await workspaceDependencies(clientManifest), + packagesClientCode, + manifestDependencies(packagesClientManifest), + ], + // Consumed through a bundler alias, so depcheck cannot see the import. + ignored: new Set(['micromark-extension-llm-math']), + }, + { + name: 'API', + dir: resolve(ROOT, 'api'), + allowed: [ + scriptWords(apiManifest), + apiCode, + await workspaceDependencies(apiManifest), + packagesApiCode, + manifestDependencies(packagesApiManifest), + ], + ignored: new Set(), + }, + ]; + + const findings: string[] = []; + for (const target of targets) { + if (!existsSync(join(target.dir, 'package.json'))) continue; + const unused = unusedDependencies(depcheck, target.dir); + if (unused === null) { + return { ok: false, output: `depcheck produced no JSON report for ${target.name}` }; + } + const reportable = unused.filter( + (name) => !target.ignored.has(name) && !target.allowed.some((set) => set.has(name)), + ); + if (reportable.length > 0) { + findings.push( + `${target.name} unused dependencies:\n${reportable.map((n) => ` ${n}`).join('\n')}`, + ); + } + } + + return { + ok: findings.length === 0, + output: findings.join('\n'), + // CI scans node_modules too, so it treats a few more packages as used. + hints: [ + 'CI also counts imports found under node_modules โ€” confirm before removing a dependency.', + ], + }; +} + +// --------------------------------------------------------------- runner + +const CHECKS: Check[] = [ + { id: 'eslint', title: 'ESLint', tier: 'fast', group: 'eslint', run: lintChangedFiles }, + { id: 'prettier', title: 'Prettier', tier: 'fast', group: 'eslint', run: checkFormatting }, + { id: 'imports', title: 'Import sorting', tier: 'fast', group: 'eslint', run: checkImportOrder }, + { + id: 'eslint-config', + title: 'ESLint config validation', + tier: 'fast', + group: 'eslint_config', + run: validateEslintConfig, + }, + { + id: 'json', + title: 'package.json validation', + tier: 'fast', + group: 'unused_packages', + run: validatePackageJson, + }, + { + id: 'circular-deps', + title: 'Circular dependencies', + tier: 'fast', + group: 'circular_deps', + run: findCircularDependencies, + }, + { + id: 'typecheck', + title: 'TypeScript', + tier: 'slow', + group: 'typecheck', + run: runTypecheck, + }, + { + id: 'config-tests', + title: 'Config migration tests', + tier: 'slow', + group: 'config', + run: runConfigTests, + }, + { id: 'i18n', title: 'Unused i18n keys', tier: 'slow', group: 'i18n', run: findUnusedI18nKeys }, + { + id: 'depcheck', + title: 'Unused npm packages', + tier: 'slow', + group: 'unused_packages', + run: findUnusedPackages, + }, +]; + +const TITLE_WIDTH = 26; + +function report(symbol: string, title: string, detail: string): void { + console.log(` ${symbol} ${title.padEnd(TITLE_WIDTH)} ${detail}`); +} + +function printBlock(text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + console.log( + trimmed + .split('\n') + .map((line) => ` ${line}`) + .join('\n'), + ); +} + +async function main(): Promise { + const unknown = [...OPTIONS.only, ...OPTIONS.skip].filter( + (id) => !CHECKS.some((check) => check.id === id), + ); + if (unknown.length > 0) fail(`unknown check id(s): ${unknown.join(', ')}`); + + const target = resolveTarget(); + const groups = Object.fromEntries( + Object.entries(FILTERS).map(([name, patterns]) => [ + name, + groupIsActive(target.files, patterns), + ]), + ) as Record; + + const context: CheckContext = { + files: target.files, + groups, + sourceFiles: target.existing + .filter((file) => SOURCE_FILE_PATTERN.test(file)) + .filter((file) => existsSync(resolve(ROOT, file))), + }; + + const selected = CHECKS.filter( + (check) => OPTIONS.only.length === 0 || OPTIONS.only.includes(check.id), + ); + + if (OPTIONS.list) { + console.log(`Static checks ยท ${target.label} ยท ${target.files.length} file(s)`); + for (const check of CHECKS) { + const state = !selected.includes(check) + ? 'deselected' + : OPTIONS.skip.includes(check.id) + ? 'skipped (--skip)' + : !groups[check.group] + ? 'not affected' + : check.tier === 'slow' && !OPTIONS.full && !OPTIONS.only.includes(check.id) + ? 'slow tier (--full)' + : 'would run'; + report(' ', `${check.title} (${check.id})`, state); + } + return; + } + + console.log(`Static checks ยท ${target.label} ยท ${target.files.length} file(s)`); + if (target.files.length === 0) { + console.log('\nNothing to check.'); + return; + } + + const failures: string[] = []; + let skipped = 0; + + for (const check of selected) { + if (OPTIONS.skip.includes(check.id)) { + skipped++; + report('โ€“', check.title, 'skipped (--skip)'); + continue; + } + if (!groups[check.group]) { + report('โ€“', check.title, 'not affected by this diff'); + continue; + } + if (check.tier === 'slow' && !OPTIONS.full && !OPTIONS.only.includes(check.id)) { + skipped++; + report('โ€“', check.title, 'skipped (run with --full)'); + continue; + } + + const started = Date.now(); + // The CI job gives every step continue-on-error; a check that throws + // (malformed translation JSON, say) must not cancel the ones after it. + let outcome: CheckOutcome; + try { + outcome = await check.run(context); + } catch (error) { + outcome = { ok: false, output: `${check.title} threw: ${(error as Error).stack ?? error}` }; + } + const elapsed = `${((Date.now() - started) / 1000).toFixed(1)}s`; + + if (outcome.skipped) { + skipped++; + report('โ€“', check.title, `skipped: ${outcome.skipped}`); + continue; + } + if (outcome.ok) { + report('โœ“', check.title, elapsed); + if (OPTIONS.verbose) printBlock(outcome.output ?? ''); + continue; + } + + failures.push(check.title); + report('โœ—', check.title, elapsed); + printBlock(outcome.output ?? ''); + for (const hint of outcome.hints ?? []) printBlock(hint); + } + + const tail = skipped > 0 ? ` (${skipped} skipped)` : ''; + if (failures.length === 0) { + console.log(`\nAll affected static checks passed${tail}.`); + return; + } + + console.log(`\nStatic checks failed:\n${failures.map((title) => ` - ${title}`).join('\n')}`); + process.exitCode = 1; +} + +await main();