mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-21 15:45:22 +00:00
* 🪝 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 <merge-sha>` 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 <bad-ref> --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.
886 lines
40 KiB
YAML
886 lines
40 KiB
YAML
name: Static Checks
|
|
|
|
on:
|
|
pull_request:
|
|
paths:
|
|
- 'api/**'
|
|
- 'client/**'
|
|
- 'config/**'
|
|
- 'packages/**'
|
|
- 'scripts/**'
|
|
- 'package.json'
|
|
- 'package-lock.json'
|
|
- 'eslint.config.mjs'
|
|
- '.github/workflows/static-checks.yml'
|
|
- '!**.md'
|
|
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
NODE_ENV: CI
|
|
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
|
|
|
jobs:
|
|
static-checks:
|
|
name: Static checks
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v5
|
|
with:
|
|
# Full history is load-bearing: changed-file steps diff against the
|
|
# PR base and the ESLint regression gate reads the base ref's
|
|
# 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
|
|
with:
|
|
predicate-quantifier: 'some-with-excludes'
|
|
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'
|
|
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'
|
|
- '!**.md'
|
|
|
|
- name: Set up Node.js 24.16.0
|
|
uses: actions/setup-node@v5
|
|
with:
|
|
node-version: '24.16.0'
|
|
cache: npm
|
|
|
|
- name: Install dependencies
|
|
id: install_dependencies
|
|
continue-on-error: true
|
|
run: npm ci
|
|
|
|
# Run ESLint on changed files within the api/, client/, and packages/ directories.
|
|
- name: Run ESLint on changed files
|
|
id: eslint
|
|
if: always() && steps.paths.outputs.eslint == 'true'
|
|
continue-on-error: true
|
|
run: |
|
|
# Extract the base commit SHA from the pull_request event payload.
|
|
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
|
|
echo "Base commit SHA: $BASE_SHA"
|
|
|
|
# Get changed files (only JS/TS files in api/, client/, or packages/)
|
|
mapfile -d '' -t CHANGED_FILES < <(
|
|
git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
|
|
grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
|
|
)
|
|
|
|
# Debug output
|
|
echo "Changed files:"
|
|
printf '%s\n' "${CHANGED_FILES[@]}"
|
|
|
|
# Ensure there are files to lint before running ESLint
|
|
if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
|
|
echo "No matching files changed. Skipping ESLint."
|
|
exit 0
|
|
fi
|
|
|
|
# Run ESLint
|
|
# --no-warn-ignored: changed files under config-ignored paths
|
|
# (e.g. packages/data-schemas/misc/**) must not fail --max-warnings=0
|
|
npx eslint --no-error-on-unmatched-pattern \
|
|
--config eslint.config.mjs \
|
|
--no-warn-ignored \
|
|
--max-warnings=0 \
|
|
-- "${CHANGED_FILES[@]}"
|
|
|
|
# Run Prettier --check on the same set of changed files to catch
|
|
# formatting drift in PRs that bypassed the local pre-commit hook
|
|
# (e.g. GitHub UI edit-and-merge, `git commit --no-verify`).
|
|
- name: Run Prettier --check on changed files
|
|
id: prettier
|
|
if: always() && steps.paths.outputs.eslint == 'true'
|
|
continue-on-error: true
|
|
run: |
|
|
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
|
|
mapfile -d '' -t CHANGED_FILES < <(
|
|
git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
|
|
grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
|
|
)
|
|
|
|
if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
|
|
echo "No matching files changed. Skipping Prettier."
|
|
exit 0
|
|
fi
|
|
|
|
echo "Files to check:"
|
|
printf '%s\n' "${CHANGED_FILES[@]}"
|
|
|
|
# `prettier --check` exits non-zero if any file would be reformatted.
|
|
# Suggest the local fix in the failure message so contributors aren't
|
|
# left guessing how to resolve.
|
|
if ! npx prettier --check --no-error-on-unmatched-pattern -- "${CHANGED_FILES[@]}"; then
|
|
echo ""
|
|
echo "::error::Prettier formatting drift detected. Fix locally with:"
|
|
echo "::error:: npx prettier --write <files>"
|
|
echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)."
|
|
exit 1
|
|
fi
|
|
|
|
# Verify import ordering on the same set of changed files. The script
|
|
# only sorts files under known source roots, so unrelated changed files
|
|
# (configs, etc.) are ignored. Matches the lint-staged pre-commit hook.
|
|
- name: Check import sorting on changed files
|
|
id: import_sort
|
|
if: always() && steps.paths.outputs.eslint == 'true'
|
|
continue-on-error: true
|
|
run: |
|
|
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
|
|
mapfile -d '' -t CHANGED_FILES < <(
|
|
git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
|
|
grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
|
|
)
|
|
|
|
if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
|
|
echo "No matching files changed. Skipping import-sort check."
|
|
exit 0
|
|
fi
|
|
|
|
echo "Files to check:"
|
|
printf '%s\n' "${CHANGED_FILES[@]}"
|
|
|
|
# `--check` lists offending files and exits non-zero without writing.
|
|
if ! node scripts/sort-imports.mts --check "${CHANGED_FILES[@]}"; then
|
|
echo ""
|
|
echo "::error::Import order drift detected. Fix locally with:"
|
|
echo "::error:: npm run sort-imports"
|
|
echo "::error::For specific files:"
|
|
echo "::error:: npm run sort-imports -- packages/api/src/app/metrics.ts packages/api/src/rum/proxy.ts"
|
|
echo "::error::To check without writing files:"
|
|
echo "::error:: npm run sort-imports:check"
|
|
echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)."
|
|
exit 1
|
|
fi
|
|
|
|
# The changed-file lint above never loads a changed root config: a
|
|
# config-only PR matches no lintable files, so even a malformed
|
|
# eslint.config.mjs would pass. When the config changes, gate on it
|
|
# loading and applying cleanly to representative sources, then run the
|
|
# full-tree regression gate below.
|
|
# 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'
|
|
continue-on-error: true
|
|
run: |
|
|
npx eslint --config eslint.config.mjs \
|
|
api/server/index.js client/src/main.jsx packages/api/src/index.ts
|
|
|
|
- name: Restore data-provider build cache
|
|
if: always() && steps.paths.outputs.config == 'true'
|
|
id: cache-data-provider
|
|
continue-on-error: true
|
|
uses: actions/cache@v5
|
|
with:
|
|
path: packages/data-provider/dist
|
|
key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
|
|
|
|
- name: Build data-provider
|
|
id: config_data_provider
|
|
if: always() && steps.paths.outputs.config == 'true' && steps.cache-data-provider.outputs.cache-hit != 'true'
|
|
continue-on-error: true
|
|
run: npm run build:data-provider
|
|
|
|
- name: Restore data-schemas build cache
|
|
if: always() && steps.paths.outputs.config == 'true'
|
|
id: cache-data-schemas
|
|
continue-on-error: true
|
|
uses: actions/cache@v5
|
|
with:
|
|
path: packages/data-schemas/dist
|
|
key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
|
|
|
|
- name: Build data-schemas
|
|
id: config_data_schemas
|
|
if: always() && steps.paths.outputs.config == 'true' && steps.cache-data-schemas.outputs.cache-hit != 'true'
|
|
continue-on-error: true
|
|
run: npm run build:data-schemas
|
|
|
|
- name: Restore api build cache
|
|
if: always() && steps.paths.outputs.config == 'true'
|
|
id: cache-api
|
|
continue-on-error: true
|
|
uses: actions/cache@v5
|
|
with:
|
|
path: packages/api/dist
|
|
key: build-api-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
|
|
|
|
- name: Build api
|
|
id: config_api
|
|
if: always() && steps.paths.outputs.config == 'true' && steps.cache-api.outputs.cache-hit != 'true'
|
|
continue-on-error: true
|
|
run: npm run build:api
|
|
|
|
- name: Create empty auth.json file
|
|
id: config_auth
|
|
if: always() && steps.paths.outputs.config == 'true'
|
|
continue-on-error: true
|
|
run: |
|
|
mkdir -p api/data
|
|
echo '{}' > api/data/auth.json
|
|
|
|
- name: Prepare .env.test file
|
|
id: config_env
|
|
if: always() && steps.paths.outputs.config == 'true'
|
|
continue-on-error: true
|
|
run: cp api/test/.env.test.example api/test/.env.test
|
|
|
|
- name: Run config migration tests
|
|
id: config_tests
|
|
if: always() && steps.paths.outputs.config == 'true'
|
|
continue-on-error: true
|
|
run: npm run test:config
|
|
|
|
- name: Find unused i18next keys
|
|
id: find_unused_i18n
|
|
if: always() && steps.paths.outputs.i18n == 'true'
|
|
continue-on-error: true
|
|
run: |
|
|
echo "🔍 Scanning for unused i18next keys..."
|
|
|
|
# Define paths
|
|
I18N_FILE="client/src/locales/en/translation.json"
|
|
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client" "packages/data-schemas/src")
|
|
|
|
# Check if translation file exists
|
|
if [[ ! -f "$I18N_FILE" ]]; then
|
|
echo "::error title=Missing i18n File::Translation file not found: $I18N_FILE"
|
|
exit 1
|
|
fi
|
|
|
|
# Extract all keys from the JSON file
|
|
KEYS=$(jq -r 'keys[]' "$I18N_FILE")
|
|
|
|
# Track unused keys
|
|
UNUSED_KEYS=()
|
|
|
|
# Check if each key is used in the source code
|
|
for KEY in $KEYS; do
|
|
FOUND=false
|
|
|
|
# Special case for dynamically constructed special variable keys
|
|
if [[ "$KEY" == com_ui_special_var_* ]]; then
|
|
# Check if TSpecialVarLabel is used in the codebase
|
|
for DIR in "${SOURCE_DIRS[@]}"; do
|
|
if grep -r --include=\*.{js,jsx,ts,tsx} -q "TSpecialVarLabel" "$DIR"; then
|
|
FOUND=true
|
|
break
|
|
fi
|
|
done
|
|
|
|
# Also check if the key is directly used somewhere
|
|
if [[ "$FOUND" == false ]]; then
|
|
for DIR in "${SOURCE_DIRS[@]}"; do
|
|
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
|
|
FOUND=true
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
# Special case for agent category keys that are dynamically used from database
|
|
elif [[ "$KEY" == com_agents_category_* ]]; then
|
|
# Check if agent category localization is being used
|
|
for DIR in "${SOURCE_DIRS[@]}"; do
|
|
# Check for dynamic category label/description usage
|
|
if grep -r --include=\*.{js,jsx,ts,tsx} -E "category\.(label|description).*startsWith.*['\"]com_" "$DIR" > /dev/null 2>&1 || \
|
|
# Check for the method that defines these keys
|
|
grep -r --include=\*.{js,jsx,ts,tsx} "ensureDefaultCategories" "$DIR" > /dev/null 2>&1 || \
|
|
# Check for direct usage in agentCategory.ts
|
|
grep -r --include=\*.ts -E "label:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1 || \
|
|
grep -r --include=\*.ts -E "description:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1; then
|
|
FOUND=true
|
|
break
|
|
fi
|
|
done
|
|
|
|
# Also check if the key is directly used somewhere
|
|
if [[ "$FOUND" == false ]]; then
|
|
for DIR in "${SOURCE_DIRS[@]}"; do
|
|
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
|
|
FOUND=true
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
else
|
|
# Regular check for other keys
|
|
for DIR in "${SOURCE_DIRS[@]}"; do
|
|
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
|
|
FOUND=true
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
if [[ "$FOUND" == false ]]; then
|
|
UNUSED_KEYS+=("$KEY")
|
|
fi
|
|
done
|
|
|
|
# Output results
|
|
if [[ ${#UNUSED_KEYS[@]} -gt 0 ]]; then
|
|
echo "🛑 Found ${#UNUSED_KEYS[@]} unused i18n keys:"
|
|
echo "unused_keys=$(echo "${UNUSED_KEYS[@]}" | jq -R -s -c 'split(" ")')" >> $GITHUB_ENV
|
|
for KEY in "${UNUSED_KEYS[@]}"; do
|
|
echo "::warning title=Unused i18n Key::'$KEY' is defined but not used in the codebase."
|
|
done
|
|
else
|
|
echo "✅ No unused i18n keys detected!"
|
|
echo "unused_keys=[]" >> $GITHUB_ENV
|
|
fi
|
|
|
|
- name: Fail workflow if unused keys found
|
|
id: i18n
|
|
if: >
|
|
always() &&
|
|
steps.paths.outputs.i18n == 'true' &&
|
|
(steps.find_unused_i18n.outcome == 'failure' || env.unused_keys != '[]')
|
|
continue-on-error: true
|
|
run: exit 1
|
|
|
|
- name: Install depcheck
|
|
id: install_depcheck
|
|
if: always() && steps.paths.outputs.unused_packages == 'true'
|
|
continue-on-error: true
|
|
run: npm install -g depcheck
|
|
|
|
- name: Validate JSON files
|
|
id: validate_package_json
|
|
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/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
|
|
done
|
|
|
|
- name: Extract Dependencies Used in Scripts
|
|
if: always() && steps.paths.outputs.unused_packages == 'true'
|
|
id: extract-used-scripts
|
|
continue-on-error: true
|
|
run: |
|
|
extract_deps_from_scripts() {
|
|
local package_file=$1
|
|
if [[ -f "$package_file" ]]; then
|
|
jq -r '.scripts | to_entries[].value' "$package_file" | \
|
|
grep -oE '([a-zA-Z0-9_-]+)' | sort -u > used_scripts.txt
|
|
else
|
|
touch used_scripts.txt
|
|
fi
|
|
}
|
|
|
|
extract_deps_from_scripts "package.json"
|
|
mv used_scripts.txt root_used_deps.txt
|
|
|
|
extract_deps_from_scripts "client/package.json"
|
|
mv used_scripts.txt client_used_deps.txt
|
|
|
|
extract_deps_from_scripts "api/package.json"
|
|
mv used_scripts.txt api_used_deps.txt
|
|
|
|
- name: Extract Dependencies Used in Source Code
|
|
if: always() && steps.paths.outputs.unused_packages == 'true'
|
|
id: extract-used-code
|
|
continue-on-error: true
|
|
run: |
|
|
extract_deps_from_code() {
|
|
local folder=$1
|
|
local output_file=$2
|
|
|
|
# Initialize empty output file
|
|
> "$output_file"
|
|
|
|
if [[ -d "$folder" ]]; then
|
|
# Extract require() statements (use explicit includes for portability)
|
|
grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" \
|
|
--include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
|
|
sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" >> "$output_file" || true
|
|
|
|
# Extract ES6 imports - import x from 'module'
|
|
grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
|
|
--include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
|
|
sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
|
|
|
|
# import 'module' (side-effect imports)
|
|
grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
|
|
--include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
|
|
sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
|
|
|
|
# export { x } from 'module' or export * from 'module'
|
|
grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
|
|
--include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
|
|
sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
|
|
|
|
# import type { x } from 'module' (TypeScript)
|
|
grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
|
|
--include='*.ts' --include='*.tsx' 2>/dev/null | \
|
|
sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
|
|
|
|
# Remove subpath imports but keep the base package
|
|
# For scoped packages: '@scope/pkg/subpath' -> '@scope/pkg'
|
|
# For regular packages: 'pkg/subpath' -> 'pkg'
|
|
# Scoped packages (must keep @scope/package, strip anything after)
|
|
sed -i -E 's|^(@[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
|
|
# Non-scoped packages (keep package name, strip subpath)
|
|
sed -i -E 's|^([a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
|
|
|
|
sort -u "$output_file" -o "$output_file"
|
|
fi
|
|
}
|
|
|
|
extract_deps_from_code "." root_used_code.txt
|
|
extract_deps_from_code "client" client_used_code.txt
|
|
extract_deps_from_code "api" api_used_code.txt
|
|
|
|
# Extract dependencies used by workspace packages
|
|
# These packages are used in the workspace but dependencies are provided by parent package.json
|
|
extract_deps_from_code "packages/client" packages_client_used_code.txt
|
|
extract_deps_from_code "packages/api" packages_api_used_code.txt
|
|
|
|
- name: Get @librechat/client dependencies
|
|
if: always() && steps.paths.outputs.unused_packages == 'true'
|
|
id: get-librechat-client-deps
|
|
continue-on-error: true
|
|
run: |
|
|
if [[ -f "packages/client/package.json" ]]; then
|
|
# Get all dependencies from @librechat/client (dependencies, devDependencies, and peerDependencies)
|
|
DEPS=$(jq -r '.dependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
|
|
DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
|
|
PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
|
|
|
|
# Combine all dependencies
|
|
echo "$DEPS" > librechat_client_deps.txt
|
|
echo "$DEV_DEPS" >> librechat_client_deps.txt
|
|
echo "$PEER_DEPS" >> librechat_client_deps.txt
|
|
|
|
# Also include dependencies that are imported in packages/client
|
|
cat packages_client_used_code.txt >> librechat_client_deps.txt
|
|
|
|
# Remove empty lines and sort
|
|
grep -v '^$' librechat_client_deps.txt | sort -u > temp_deps.txt
|
|
mv temp_deps.txt librechat_client_deps.txt
|
|
else
|
|
touch librechat_client_deps.txt
|
|
fi
|
|
|
|
- name: Get @librechat/api dependencies
|
|
if: always() && steps.paths.outputs.unused_packages == 'true'
|
|
id: get-librechat-api-deps
|
|
continue-on-error: true
|
|
run: |
|
|
if [[ -f "packages/api/package.json" ]]; then
|
|
# Get all dependencies from @librechat/api (dependencies, devDependencies, and peerDependencies)
|
|
DEPS=$(jq -r '.dependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
|
|
DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
|
|
PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
|
|
|
|
# Combine all dependencies
|
|
echo "$DEPS" > librechat_api_deps.txt
|
|
echo "$DEV_DEPS" >> librechat_api_deps.txt
|
|
echo "$PEER_DEPS" >> librechat_api_deps.txt
|
|
|
|
# Also include dependencies that are imported in packages/api
|
|
cat packages_api_used_code.txt >> librechat_api_deps.txt
|
|
|
|
# Remove empty lines and sort
|
|
grep -v '^$' librechat_api_deps.txt | sort -u > temp_deps.txt
|
|
mv temp_deps.txt librechat_api_deps.txt
|
|
else
|
|
touch librechat_api_deps.txt
|
|
fi
|
|
|
|
- name: Extract Workspace Dependencies
|
|
if: always() && steps.paths.outputs.unused_packages == 'true'
|
|
id: extract-workspace-deps
|
|
continue-on-error: true
|
|
run: |
|
|
# Function to get dependencies from a workspace package that are used by another package
|
|
get_workspace_package_deps() {
|
|
local package_json=$1
|
|
local output_file=$2
|
|
|
|
# Get all workspace dependencies (starting with @librechat/)
|
|
if [[ -f "$package_json" ]]; then
|
|
local workspace_deps=$(jq -r '.dependencies // {} | to_entries[] | select(.key | startswith("@librechat/")) | .key' "$package_json" 2>/dev/null || echo "")
|
|
|
|
# For each workspace dependency, get its dependencies
|
|
for dep in $workspace_deps; do
|
|
# Convert @librechat/api to packages/api
|
|
local workspace_path=$(echo "$dep" | sed 's/@librechat\//packages\//')
|
|
local workspace_package_json="${workspace_path}/package.json"
|
|
|
|
if [[ -f "$workspace_package_json" ]]; then
|
|
# Extract all dependencies from the workspace package
|
|
jq -r '.dependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
|
|
# Also extract peerDependencies
|
|
jq -r '.peerDependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
if [[ -f "$output_file" ]]; then
|
|
sort -u "$output_file" -o "$output_file"
|
|
else
|
|
touch "$output_file"
|
|
fi
|
|
}
|
|
|
|
# Get workspace dependencies for each package
|
|
get_workspace_package_deps "package.json" root_workspace_deps.txt
|
|
get_workspace_package_deps "client/package.json" client_workspace_deps.txt
|
|
get_workspace_package_deps "api/package.json" api_workspace_deps.txt
|
|
|
|
- name: Run depcheck for root package.json
|
|
if: always() && steps.paths.outputs.unused_packages == 'true'
|
|
id: check-root
|
|
continue-on-error: true
|
|
run: |
|
|
if [[ -f "package.json" ]]; then
|
|
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
|
# Exclude dependencies used in scripts, code, and workspace packages
|
|
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat root_used_deps.txt root_used_code.txt root_workspace_deps.txt | sort) || echo "")
|
|
echo "ROOT_UNUSED<<EOF" >> $GITHUB_ENV
|
|
echo "$UNUSED" >> $GITHUB_ENV
|
|
echo "EOF" >> $GITHUB_ENV
|
|
fi
|
|
|
|
- name: Run depcheck for client/package.json
|
|
if: always() && steps.paths.outputs.unused_packages == 'true'
|
|
id: check-client
|
|
continue-on-error: true
|
|
run: |
|
|
if [[ -f "client/package.json" ]]; then
|
|
chmod -R 755 client
|
|
cd client
|
|
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
|
# Exclude dependencies used in scripts, code, workspace packages, and @librechat/client imports
|
|
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt ../packages_client_used_code.txt ../librechat_client_deps.txt 2>/dev/null | sort -u) || echo "")
|
|
# Filter out false positives
|
|
UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
|
|
echo "CLIENT_UNUSED<<EOF" >> $GITHUB_ENV
|
|
echo "$UNUSED" >> $GITHUB_ENV
|
|
echo "EOF" >> $GITHUB_ENV
|
|
cd ..
|
|
fi
|
|
|
|
- name: Run depcheck for api/package.json
|
|
if: always() && steps.paths.outputs.unused_packages == 'true'
|
|
id: check-api
|
|
continue-on-error: true
|
|
run: |
|
|
if [[ -f "api/package.json" ]]; then
|
|
chmod -R 755 api
|
|
cd api
|
|
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
|
# Exclude dependencies used in scripts, code, workspace packages, and @librechat/api imports
|
|
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt ../packages_api_used_code.txt ../librechat_api_deps.txt 2>/dev/null | sort -u) || echo "")
|
|
echo "API_UNUSED<<EOF" >> $GITHUB_ENV
|
|
echo "$UNUSED" >> $GITHUB_ENV
|
|
echo "EOF" >> $GITHUB_ENV
|
|
cd ..
|
|
fi
|
|
|
|
- name: Fail workflow if unused dependencies found
|
|
id: unused_packages
|
|
if: >
|
|
always() &&
|
|
steps.paths.outputs.unused_packages == 'true' &&
|
|
(env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != '')
|
|
continue-on-error: true
|
|
run: exit 1
|
|
|
|
- name: Summarize static check failures
|
|
if: always()
|
|
env:
|
|
INSTALL_DEPENDENCIES_OUTCOME: ${{ steps.install_dependencies.outcome }}
|
|
ESLINT_OUTCOME: ${{ steps.eslint.outcome }}
|
|
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 }}
|
|
CONFIG_DATA_SCHEMAS_OUTCOME: ${{ steps.config_data_schemas.outcome }}
|
|
CACHE_API_OUTCOME: ${{ steps.cache-api.outcome }}
|
|
CONFIG_API_OUTCOME: ${{ steps.config_api.outcome }}
|
|
CONFIG_AUTH_OUTCOME: ${{ steps.config_auth.outcome }}
|
|
CONFIG_ENV_OUTCOME: ${{ steps.config_env.outcome }}
|
|
CONFIG_TESTS_OUTCOME: ${{ steps.config_tests.outcome }}
|
|
FIND_I18N_OUTCOME: ${{ steps.find_unused_i18n.outcome }}
|
|
I18N_OUTCOME: ${{ steps.i18n.outcome }}
|
|
INSTALL_DEPCHECK_OUTCOME: ${{ steps.install_depcheck.outcome }}
|
|
VALIDATE_PACKAGE_JSON_OUTCOME: ${{ steps.validate_package_json.outcome }}
|
|
EXTRACT_USED_SCRIPTS_OUTCOME: ${{ steps.extract-used-scripts.outcome }}
|
|
EXTRACT_USED_CODE_OUTCOME: ${{ steps.extract-used-code.outcome }}
|
|
GET_CLIENT_DEPS_OUTCOME: ${{ steps.get-librechat-client-deps.outcome }}
|
|
GET_API_DEPS_OUTCOME: ${{ steps.get-librechat-api-deps.outcome }}
|
|
EXTRACT_WORKSPACE_DEPS_OUTCOME: ${{ steps.extract-workspace-deps.outcome }}
|
|
CHECK_ROOT_OUTCOME: ${{ steps.check-root.outcome }}
|
|
CHECK_CLIENT_OUTCOME: ${{ steps.check-client.outcome }}
|
|
CHECK_API_OUTCOME: ${{ steps.check-api.outcome }}
|
|
UNUSED_PACKAGES_OUTCOME: ${{ steps.unused_packages.outcome }}
|
|
run: |
|
|
failures=()
|
|
|
|
record_failure() {
|
|
if [[ "$2" == "failure" ]]; then
|
|
failures+=("$1")
|
|
fi
|
|
}
|
|
|
|
record_failure "Dependency installation" "$INSTALL_DEPENDENCIES_OUTCOME"
|
|
record_failure "ESLint" "$ESLINT_OUTCOME"
|
|
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"
|
|
record_failure "Config data-schemas build" "$CONFIG_DATA_SCHEMAS_OUTCOME"
|
|
record_failure "Config API cache" "$CACHE_API_OUTCOME"
|
|
record_failure "Config API build" "$CONFIG_API_OUTCOME"
|
|
record_failure "Config auth preparation" "$CONFIG_AUTH_OUTCOME"
|
|
record_failure "Config environment preparation" "$CONFIG_ENV_OUTCOME"
|
|
record_failure "Config migration tests" "$CONFIG_TESTS_OUTCOME"
|
|
record_failure "Unused i18n scan" "$FIND_I18N_OUTCOME"
|
|
record_failure "Unused i18n keys" "$I18N_OUTCOME"
|
|
record_failure "depcheck installation" "$INSTALL_DEPCHECK_OUTCOME"
|
|
record_failure "Package JSON validation" "$VALIDATE_PACKAGE_JSON_OUTCOME"
|
|
record_failure "Package script dependency extraction" "$EXTRACT_USED_SCRIPTS_OUTCOME"
|
|
record_failure "Source dependency extraction" "$EXTRACT_USED_CODE_OUTCOME"
|
|
record_failure "Client dependency collection" "$GET_CLIENT_DEPS_OUTCOME"
|
|
record_failure "API dependency collection" "$GET_API_DEPS_OUTCOME"
|
|
record_failure "Workspace dependency extraction" "$EXTRACT_WORKSPACE_DEPS_OUTCOME"
|
|
record_failure "Root depcheck" "$CHECK_ROOT_OUTCOME"
|
|
record_failure "Client depcheck" "$CHECK_CLIENT_OUTCOME"
|
|
record_failure "API depcheck" "$CHECK_API_OUTCOME"
|
|
record_failure "Unused NPM packages" "$UNUSED_PACKAGES_OUTCOME"
|
|
|
|
if [[ "$UNUSED_PACKAGES_OUTCOME" == "failure" ]]; then
|
|
[[ -n "$ROOT_UNUSED" ]] && printf 'Root unused dependencies:\n%s\n' "$ROOT_UNUSED"
|
|
[[ -n "$CLIENT_UNUSED" ]] && printf 'Client unused dependencies:\n%s\n' "$CLIENT_UNUSED"
|
|
[[ -n "$API_UNUSED" ]] && printf 'API unused dependencies:\n%s\n' "$API_UNUSED"
|
|
fi
|
|
|
|
if [[ ${#failures[@]} -eq 0 ]]; then
|
|
echo "All affected static checks passed."
|
|
exit 0
|
|
fi
|
|
|
|
echo "::error::Static checks failed:"
|
|
printf ' - %s\n' "${failures[@]}"
|
|
exit 1
|
|
|
|
# Runs as its own job rather than a step inside `static-checks`. Two full
|
|
# type-aware sweeps of api+client+packages cost more than the rest of that
|
|
# job combined, and sharing one 30-minute budget with ~20 later steps meant a
|
|
# slow sweep starved config-migration, i18n and depcheck — the job then
|
|
# reported nothing at all, which is strictly worse than not running the gate.
|
|
eslint-sweep:
|
|
name: ESLint config regression sweep
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 45
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v5
|
|
with:
|
|
# fetch-depth: 0 is load-bearing — the gate reads the base ref's
|
|
# config via `git show`, which a shallow checkout cannot resolve.
|
|
fetch-depth: 0
|
|
|
|
- name: Detect config changes
|
|
id: paths
|
|
uses: dorny/paths-filter@v4
|
|
with:
|
|
predicate-quantifier: 'some-with-excludes'
|
|
filters: |
|
|
eslint_config:
|
|
- 'eslint.config.mjs'
|
|
- '.github/workflows/static-checks.yml'
|
|
|
|
- name: Set up Node.js 24.16.0
|
|
if: steps.paths.outputs.eslint_config == 'true'
|
|
uses: actions/setup-node@v5
|
|
with:
|
|
node-version: '24.16.0'
|
|
cache: npm
|
|
|
|
- name: Install dependencies
|
|
if: steps.paths.outputs.eslint_config == 'true'
|
|
run: npm ci
|
|
|
|
# Full-tree sweep that gates on regression, not cleanliness: the tree
|
|
# carries a pre-existing lint backlog (70 errors at time of wiring), so
|
|
# requiring a clean sweep would fail config PRs on unrelated debt.
|
|
# Instead, lint the same tree under the PR's config and under the base
|
|
# ref's config and fail when the PR's config (a) stops linting files
|
|
# the base config covered — the signature of a mis-scoped ignores — or
|
|
# (b) produces more diagnostics for some (file, rule, severity) triple.
|
|
# On an identical tree, any delta is attributable to the config change
|
|
# alone. Severity is part of the key so a warn->error escalation must
|
|
# land with the tree clean for that rule; downgrades and fixes are
|
|
# never penalized.
|
|
- name: ESLint full-sweep regression gate on config changes
|
|
id: eslint_sweep
|
|
if: steps.paths.outputs.eslint_config == 'true'
|
|
env:
|
|
# A sweep that outruns this budget yields a notice, not a failure:
|
|
# the gate is advisory about config scope, and an unfinished sweep is
|
|
# no evidence of a regression. Bounding it also keeps a pathological
|
|
# config from burning the whole job timeout with nothing to show.
|
|
ESLINT_SWEEP_BUDGET_SECONDS: '900'
|
|
run: |
|
|
run_sweep() {
|
|
set +e
|
|
timeout -k 15 "$ESLINT_SWEEP_BUDGET_SECONDS" \
|
|
npx eslint --config "$1" api client packages -f json -o "$2"
|
|
local status=$?
|
|
set -e
|
|
# 124 = timeout sent TERM; 137 = it escalated to KILL.
|
|
if [ "$status" -eq 124 ] || [ "$status" -eq 137 ]; then
|
|
return 124
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
if ! run_sweep eslint.config.mjs "$RUNNER_TEMP/eslint-head.json"; then
|
|
echo "::notice title=ESLint sweep::Head sweep exceeded ${ESLINT_SWEEP_BUDGET_SECONDS}s; skipping the regression gate for this run."
|
|
exit 0
|
|
fi
|
|
if [ ! -s "$RUNNER_TEMP/eslint-head.json" ]; then
|
|
echo "::error title=ESLint sweep::Head-config sweep produced no report — ESLint likely crashed under the new config."
|
|
exit 1
|
|
fi
|
|
|
|
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
|
|
if ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
|
|
echo "::error title=ESLint sweep::Base commit is unavailable — this gate requires the checkout above to keep fetch-depth: 0."
|
|
exit 1
|
|
fi
|
|
# The base config is written to the repo root, not $RUNNER_TEMP:
|
|
# flat-config files/ignores patterns and plugin imports resolve
|
|
# relative to the config's own directory, so a temp-dir copy would
|
|
# scope to nothing and the comparison would pass vacuously.
|
|
trap 'rm -f eslint.config.base.mjs' EXIT
|
|
if ! git show "$BASE_SHA:eslint.config.mjs" > eslint.config.base.mjs 2>/dev/null; then
|
|
echo "::notice title=ESLint sweep::No eslint.config.mjs at base ref; skipping regression comparison."
|
|
exit 0
|
|
fi
|
|
if ! run_sweep eslint.config.base.mjs "$RUNNER_TEMP/eslint-base.json"; then
|
|
echo "::notice title=ESLint sweep::Base sweep exceeded ${ESLINT_SWEEP_BUDGET_SECONDS}s; skipping the regression comparison."
|
|
exit 0
|
|
fi
|
|
if [ ! -s "$RUNNER_TEMP/eslint-base.json" ]; then
|
|
echo "::notice title=ESLint sweep::Base config cannot run against this tree; skipping regression comparison."
|
|
exit 0
|
|
fi
|
|
|
|
jq -r '.[].filePath' "$RUNNER_TEMP/eslint-head.json" | sort > "$RUNNER_TEMP/head.files"
|
|
jq -r '.[].filePath' "$RUNNER_TEMP/eslint-base.json" | sort > "$RUNNER_TEMP/base.files"
|
|
LOST=$(comm -23 "$RUNNER_TEMP/base.files" "$RUNNER_TEMP/head.files")
|
|
if [ -n "$LOST" ]; then
|
|
LOST_COUNT=$(printf '%s\n' "$LOST" | wc -l)
|
|
echo "::error title=ESLint coverage regression::The config change stops linting $LOST_COUNT file(s) the base config covered (showing up to 20):"
|
|
printf '%s\n' "$LOST" | head -20
|
|
exit 1
|
|
fi
|
|
|
|
fingerprints() {
|
|
jq -r '.[] | .filePath as $f | .messages[] | "\($f)\t\(.ruleId // "parse-error")\t\(.severity)"' "$1" |
|
|
sort | uniq -c | sed -E 's/^ *([0-9]+) /\1\t/'
|
|
}
|
|
fingerprints "$RUNNER_TEMP/eslint-head.json" > "$RUNNER_TEMP/head.fp"
|
|
fingerprints "$RUNNER_TEMP/eslint-base.json" > "$RUNNER_TEMP/base.fp"
|
|
|
|
REGRESSIONS=$(awk -F'\t' '
|
|
NR==FNR { base[$2 FS $3 FS $4] = $1; next }
|
|
{
|
|
if ($1 > base[$2 FS $3 FS $4] + 0) {
|
|
sev = ($4 == 2) ? "error" : "warn"
|
|
printf "%s %s (%s): %d -> %d\n", $2, $3, sev, base[$2 FS $3 FS $4] + 0, $1
|
|
}
|
|
}
|
|
' "$RUNNER_TEMP/base.fp" "$RUNNER_TEMP/head.fp")
|
|
|
|
if [ -n "$REGRESSIONS" ]; then
|
|
echo "::error title=ESLint config regression::The config change introduces new diagnostics (file rule (severity): base -> head):"
|
|
echo "$REGRESSIONS"
|
|
exit 1
|
|
fi
|
|
echo "No coverage loss and no new diagnostics versus the base config."
|