eslint-plugin-agent-code-guard
v0.0.14
Published
ESLint plugin that guards AI-agent-written TypeScript against common sloppy patterns: async/Promise/.then chains, bare catches, unsafe casts, raw SQL, manual enum casts, mocks in integration tests, and hardcoded secrets.
Maintainers
Readme
agent-code-guard
ESLint plugin that catches the patterns your coding agent must not ship.
Install
npm install --save-dev eslint-plugin-agent-code-guard
pnpm add -D eslint-plugin-agent-code-guardHello world
Drop this into eslint.config.js:
import guard from "eslint-plugin-agent-code-guard";
import tsParser from "@typescript-eslint/parser";
export default [
{
files: ["src/**/*.ts"],
languageOptions: { parser: tsParser },
...guard.configs.recommended,
},
];Then run eslint . against any TypeScript file and the agent-code-guard syntax floor lights up. @typescript-eslint/parser is the only peer the syntax floor needs.
What it catches
Your coding agent is miscalibrated. It was trained on human-written TypeScript — decades of it — written under one constraint that does not apply to it: typing was expensive for humans. That is why its training corpus is saturated with throw new Error("bad"), as Record<string, unknown>, try { ... } catch {}, Promise<T> return types, process.env.FOO!, raw SQL strings, and vi.mock inside integration tests. Those were the compromises humans made when keyboard time was scarce. An agent does not pay the scarcity; it inherits the patterns anyway.
This plugin is the floor. The recommended preset bundles the agent-code-guard rules with the full SonarJS recommended set so the standard floor catches both AI miscalibration patterns and the broader bug-and-security floor SonarJS already covers (hardcoded secrets, redundant conditions, unused collections, ReDoS-prone regex, eval, etc.). The strict preset adds tight complexity budgets on top. An integrationTests preset forbids mocks in files that are supposed to be integration tests.
Async flow
| Rule | Catches |
|---|---|
| async-keyword | async functions outside Effect/Kysely patterns |
| promise-type | Promise<T> return types that erase the error channel |
| then-chain | .then(...) chains that hide error propagation |
| bare-catch | try { ... } catch {} that swallows the error silently |
| no-conditional-chaining | Optional/nullish parameters accepted outside explicit parser/normalizer boundaries (warn) |
| no-unbounded-concurrency | Effect.*(..., { concurrency: "unbounded" }) fan-out with no visible bound |
Effect
| Rule | Catches |
|---|---|
| effect-promise | Effect.promise(...) calls that turn rejections into defects |
| effect-error-erasure | Effect.fail(new Error(...)) and similar generic error wrapping inside the Effect channel |
| either-discriminant | Either.isLeft(...), Either.isRight(...), and _tag === "Left" / "Right" |
| tag-discriminant | Manual _tag checks on Effect-flavored tagged unions (Effect, Either, Option, Cause, Exit, Data.TaggedError, …); type-aware, needs parserOptions.project |
| no-effect-error-coalescing | Effect.mapError / catchAll wrappers that collapse typed error variants into one broad error (warn) |
Manual algebra
| Rule | Catches |
|---|---|
| manual-result | Reusable hand-rolled Result / Either algebras instead of Either / Effect |
| manual-option | Reusable hand-rolled Option / Maybe algebras instead of Option |
| manual-tagged-error | Hand-rolled tagged error classes and error unions that should use Data.TaggedError(...) |
| manual-brand | Hand-rolled nominal brands that should use Brand.nominal(...) or Schema.brand(...) (warn) |
| no-manual-brand-constructor | Cast helpers such as asUserId / makeUserId that manually construct branded values (warn) |
| no-exported-brand-constructor | Exported brand or schema constructors instead of local constructors plus exported boundary functions/types (warn) |
| no-manual-enum-cast | as "a" \| "b" string-union casts that should be generated unions |
Safety
| Rule | Catches |
|---|---|
| as-unknown-as | as unknown as cast chains that bypass type checking |
| record-cast | as Record<string, unknown> and similar unsafe casts |
| no-process-env-at-runtime | Runtime process.env access instead of reading config once at the boundary |
| no-raw-sql | Raw SQL strings that bypass the typed query builder |
| no-raw-throw-new-error | throw new Error(...) outside tests — return a tagged error instead |
| max-non-trivial-classes-per-file | More than one logic-bearing class per file; classes that extend a configured tag-class factory (default: Data.TaggedError, Context.Tag, Effect.Service, …) are exempt regardless of body |
Testing
| Rule | Catches |
|---|---|
| no-test-skip-only | .skip / .only / xit / xdescribe in committed test files |
| no-example-only-tests | Test scopes with multiple examples but no property/generative invariant test (warn) |
| no-coverage-threshold-gate | coverageThreshold gates in jest/vitest/vite configs (warn) |
| no-hardcoded-assertion-literals | Hardcoded string/number literals in test assertions (warn) |
| no-vitest-mocks | vi.mock(...) inside files that match the integration-tests glob |
Tooling
| Rule | Catches |
|---|---|
| require-knip-in-lint | package.json default quality scripts that omit Knip |
Documentation
JSDoc lint comes from bundled eslint-plugin-jsdoc; consumers do not install it separately. recommended and strict turn on the logical and contents rule sets — these validate JSDoc content (check-types, valid-types, no-types, informative-docs, etc.) and only fire if JSDoc is present and broken. strict additionally enables the stylistic rules. jsdoc/no-undefined-types is dropped because TypeScript resolves type names; pairing it with no-types: error would emit duplicate diagnostics on every @param {T} line.
A separate documentation preset enforces that JSDoc must exist on every exported declaration — every interface, type alias, enum, function, class, and exported const needs a doc comment, with @param, @property, and @returns all filled in. Because forcing JSDoc on every internal helper is noise, this preset is meant to be scoped to your folder barrels:
{
files: ["src/**/index.ts", "src/index.ts"],
plugins: guard.configs.documentation.plugins,
rules: guard.configs.documentation.rules,
}That keeps the public boundary fully documented while leaving file-internal code free to skip JSDoc.
Rule IDs in your config are namespaced as agent-code-guard/<rule>. Each rule ships a Before/After doc at the link above and locally at node_modules/eslint-plugin-agent-code-guard/docs/rules/<family>/<rule-name>.md.
Configure
This plugin uses ESLint flat config (required; ESLint ≥ 9). If you have a legacy .eslintrc, migrate to flat config first; see ESLint migration guide.
Flat config:
// eslint.config.js
import guard from "eslint-plugin-agent-code-guard";
import tsParser from "@typescript-eslint/parser";
export default [
// Application source - prod rules, SonarJS, and strict complexity budgets
{
files: ["src/**/*.ts"],
ignores: ["**/*.test.ts", "**/*.spec.ts"],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: 2022, sourceType: "module" },
},
plugins: guard.configs.strict.plugins,
settings: guard.configs.strict.settings,
rules: guard.configs.strict.rules,
},
// Test files - same complexity bar, with test-specific guard rules
{
files: ["**/*.test.ts", "**/*.spec.ts", "**/test/**/*.ts", "**/tests/**/*.ts", "**/test-support/**/*.ts"],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: 2022, sourceType: "module" },
},
plugins: guard.configs.strict.plugins,
settings: guard.configs.strict.settings,
rules: {
...guard.configs.strict.rules,
"agent-code-guard/no-test-skip-only": "error",
"agent-code-guard/no-hardcoded-assertion-literals": "warn",
},
},
// Config files — coverage-gate lint (warn)
{
files: ["**/jest.config.*", "**/vitest.config.*", "**/vite.config.*"],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: 2022, sourceType: "module" },
},
plugins: { "agent-code-guard": guard },
rules: {
"agent-code-guard/no-coverage-threshold-gate": "warn",
},
},
// Integration tests: no mocks allowed
{
files: ["**/*.integration.test.ts"],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: 2022, sourceType: "module" },
},
plugins: { "agent-code-guard": guard },
rules: guard.configs.integrationTests.rules,
},
];Peer dependencies: eslint ≥ 9, typescript ≥ 5. SonarJS and eslint-plugin-jsdoc are runtime dependencies of this package, so users of guard.configs.recommended / strict / documentation do not install them separately. Knip is also bundled and exposed as the agent-code-guard-knip bin, so downstream repos can put agent-code-guard-knip (or plain knip if they have it installed directly) in their lint scripts and the require-knip-in-lint rule will accept either.
Presets
The import alias (e.g., guard in the example above) is your choice; adjust the <import>.configs.* path accordingly. Access presets via your import identifier:
<import>.configs.recommended— application source. Flat-config fragment withplugins,settings, andrules. Bundles the agent-code-guard rules with the full SonarJS recommended set (~270 SonarJS rules covering bug catches, security, regex correctness). Excludesno-vitest-mocks(lives in the integration-tests preset).<import>.configs.strict— flat-config fragment withplugins,settings, andrules.recommendedplus strict complexity budgets (complexity,max-depth,max-lines,max-lines-per-function,max-statements, cognitive complexity, nested control flow, and related limits).<import>.configs.integrationTests.rules— integration-test glob only. Enforcesno-vitest-mocksso integration tests actually hit real dependencies.<import>.configs.documentation— barrel files only. Flat-config fragment withpluginsandrules. Enforcesjsdoc/require-jsdocplus the fullrequire-*family (param descriptions, property descriptions, returns) on every exported declaration. Apply to**/index.ts.
Disabling a rule
If a rule is wrong for your codebase, disable it in flat config:
rules: {
...guard.configs.recommended.rules,
"agent-code-guard/async-keyword": "off",
}Every disable in source should carry a written reason via @eslint-community/eslint-plugin-eslint-comments and the require-description rule. The companion Claude Code skill (see below) wires that pairing automatically.
Name notes
- npm package:
eslint-plugin-agent-code-guard. - Rule namespace:
agent-code-guard/<rule>— used by both this package and the LSP servers safer-by-default declares (agent-code-guard-syntax,agent-code-guard-architecture). One namespace across the floor (lint), the editor (LSP), and the agent loop keeps the mental model consistent.
Companion
floor — this ESLint plugin (lint-time checks). Catches per-file patterns your agent must not ship: throw new Error(...), as Record<string, unknown>, bare catch {}, etc. Every rule's meta.docs.url points at the corresponding heading in safer-by-default/PRINCIPLES.md, so any ESLint LSP renders a codeDescription.href link straight from each diagnostic to the underlying doctrine.
ceiling — safer-by-default, a Claude Code skill plugin. It calibrates the coding agent at write-time before code is committed, and its .claude-plugin/plugin.json declares two lspServers that auto-start when an LSP-aware editor (or the Claude Code agent loop) opens a TypeScript file:
agent-code-guard-syntax— wraps upstreamvscode-eslint-language-serverto surface every rule from this plugin with its rationale + PRINCIPLES.md link.agent-code-guard-architecture— runs a custom architecture analyzer (folder graph, public surface, vendor type leaks, cycle detection). Architecture rules used to live in this repo; they moved to safer-by-default to keep the npm package pure-syntax. See safer-by-default'sARCHITECTURE.md→ LSP integration.
Install both for the full calibration loop:
# The floor (this repo) — lint checks via npm:
pnpm add -D eslint-plugin-agent-code-guard@latest
# The ceiling (Claude Code skill plugin + LSPs) — via Claude Code:
# In a Claude Code session:
/plugin marketplace add chughtapan/safer-by-default
/plugin install safer@safer-by-defaultAlternatively, invoke /safer:setup in any TypeScript repo to automate both steps (wires this floor into eslint.config.js, flips tsconfig strict flags, installs the integration-tests preset, and the two LSPs auto-register from the Claude plugin).
Development
pnpm install
pnpm build
pnpm testTests live next to the code they verify. Rule-family tests are under
src/rules/<family>/*.test.ts, and shared fixtures live under local
test-support/ folders. tsconfig.json excludes *.test.ts and
test-support/ from the production build, while Vitest still discovers them.
Mutation testing
Scope: src/**/*.ts (every rule, utility, and the plugin entry). Run:
pnpm mutationStryker (with the vitest runner and typescript checker) mutates every source file and replays the vitest suite against each mutant. The default thresholds apply: high 80, low 60, break 50. A run that drops the overall score below 50 exits non-zero.
Mutation testing is a required CI gate. Every PR runs pnpm mutation; dropping below the break threshold fails the check. If you weaken a test, Stryker catches it before the lint rule ships.
Runs are incremental on PR and a full sweep runs nightly. Stryker persists state to .stryker-tmp/incremental.json, cached in CI across runs and refreshed in this repo when a release-quality mutation pass lands.
Changelog
See CHANGELOG.md for release notes.
License
MIT.
