LibreChat/scripts/static-checks.mts
Danny Avila d369c649ed
🪥 chore: Run CI's Static Checks on Each Commit's Diff (#15303)
* 🪝 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.
2026-08-28 10:08:37 -04:00

1218 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <ids>, --skip <ids>, --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<FilterName, boolean>;
}
interface Check {
id: string;
title: string;
tier: Tier;
group: FilterName;
run: (context: CheckContext) => CheckOutcome | Promise<CheckOutcome>;
}
/** A resolved tool invocation: `node <bin.js>` 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 <entry>`, 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<string, string>;
};
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<string, RegExp>();
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 <ref> 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<string> {
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<string[]> {
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 <files>'],
};
}
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 <files>'],
};
}
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 -- <files>',
],
};
}
/**
* 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<CheckOutcome> {
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<CheckOutcome> {
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<string>();
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<Set<string>> {
const packages = new Set<string>();
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<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
}
async function readManifest(file: string): Promise<Manifest | null> {
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<string> {
const words = new Set<string>();
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<string> {
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<Set<string>> {
const inherited = new Set<string>();
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<CheckOutcome> {
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<string>(),
},
{
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<string>(),
},
];
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<void> {
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<FilterName, boolean>;
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();