eslint-plugin-no-magic
v1.9.1
Published
ESLint rule that flags magic string literals only in suspicious contexts (comparisons, switch cases, action types, analytics/storage/feature-flag/route sinks) and on duplication, instead of reporting every literal.
Maintainers
Readme
eslint-plugin-no-magic
ESLint plugin that flags magic string literals only where they matter: a literal is "magic" when it participates in logic, a protocol, or a business contract whose meaning should be named — not merely because it is a string.
Reporting every string literal turns the linter into noise (JSX props, imports, object keys, type unions, human copy are all strings). This plugin reports a string only when it is one of:
- an operand of an equality/inequality comparison (
===,!==,==,!=), - a
switchcasetest, - an argument to a known behavioral sink (analytics, storage, feature flags, routing, etc.),
- the
typeof an object passed to an action dispatcher (dispatch({ type: "..." })), - or duplicated across the file at or above a configurable threshold.
Everything else is ignored by default: JSX attribute values, visible JSX copy,
structural literals inside inline SVG markup, import/export sources, runtime
directives ("use client"), typeof comparison vocabulary, object keys,
property-name access, TypeScript literal-union types, enum member
initializers and extracted const definitions. No imports, packages, or
framework names receive special exemptions. JSX and SVG handling is syntax-based.
Install
pnpm add --save-dev eslint-plugin-no-magicRequires ESLint 10.10+ (flat config) and Node.js 26.x (^26.0.0).
Development uses pnpm 12+ (pinned to 12.3.4) and Vitest 5.
Rule reference
- no-magic-contracts: unnamed runtime contracts.
- no-duplicate-strings: repeated static strings.
- prefer-existing-constant: visible string reuse.
- no-duplicate-constants: repeated primitive definitions.
- require-constants-location: static constants outside constants modules.
Each rule includes examples, defaults, options, and known limitations. ESLint
meta.docs.url points to its page, and the package includes all five documents.
Shared configuration
import { createConfig } from "eslint-plugin-no-magic";
export default createConfig({
files: ["src/**/*.{js,ts,tsx}"],
contracts: { sinks: [{ callee: "client.send", argumentIndex: 1 }] },
duplicates: { minDuplicates: 3 },
contractSeverity: "error",
duplicateSeverity: "warn",
});createConfig synchronizes contract classification in both rules and copies
options so callers cannot mutate subsequent configurations. Contract allowlists
are shared by default; duplicate ignoreStrings can override them. Disabling
contracts automatically enables duplicate reporting on contract positions unless
duplicates.ignoreContracts explicitly overrides it. Configure the parser in
another flat config entry when using TypeScript or non-JavaScript syntax.
Usage
Quick start (recommended flat config)
// eslint.config.mjs
import noMagic from "eslint-plugin-no-magic";
export default [
...noMagic.configs.recommended,
];Manual wiring
import noMagic from "eslint-plugin-no-magic";
export default [
{
files: ["app/**/*.{ts,tsx}", "src/**/*.{ts,tsx}"],
plugins: { "no-magic": noMagic },
rules: {
"no-magic/no-magic-contracts": "error",
"no-magic/no-duplicate-strings": "warn",
},
},
];Independent rules
The recommended preset enables no-magic-contracts as an error and
no-duplicate-strings as a warning with ignoreContracts: true. Both can be configured independently:
export default [
...noMagic.configs.recommended,
{ rules: {
"no-magic/no-magic-contracts": ["error", { ignoreStrings: ["legacy"] }],
"no-magic/no-duplicate-strings": ["warn", { minDuplicates: 4 }],
} },
];no-magic-contracts accepts sinks, actionTypeCallees, actionTypeProperty,
and ignoreStrings. no-duplicate-strings accepts minDuplicates,
ignoreStrings, ignoreContracts, and contractOptions.
ignoreContracts defaults to true in all configurations. It omits duplicate diagnostics on contract positions
while retaining those occurrences in the duplicate count. This is a syntactic
classification, not coordination between enabled rules: if you disable the
contract rule and want duplicate reports everywhere, set ignoreContracts: false.
When customizing contract detection, pass the same options to contractOptions:
const contractOptions = { sinks: [{ callee: "client.send", argumentIndex: 1 }] };
// Rules in your flat configuration:
const rules = {
"no-magic/no-magic-contracts": ["error", contractOptions],
"no-magic/no-duplicate-strings": ["warn", {
ignoreContracts: true, contractOptions, minDuplicates: 3,
}],
};A value in contractOptions.ignoreStrings remains eligible for duplication;
ignoreStrings on the duplicate rule itself excludes it entirely. Disable the
duplicate rule if only contract detection is wanted.
no-magic-strings was deprecated in 0.2.2 and removed in 1.0.0. It is no longer
registered or shipped. See migration notes.
Contract and duplicate rule options
Options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| sinks | (string \| { callee: string, argumentIndex: number })[] | track, trackEvent, sendEvent, logEvent, captureEvent, getItem/setItem/removeItem (argument 0), isFeatureEnabled, isEnabled, getFlag, navigate, { callee: "router.push", argumentIndex: 0 }, { callee: "router.replace", argumentIndex: 0 } | Callee names whose string arguments are treated as contracts. Strings match method names or full static paths; descriptors match a method name or full static path and a zero-based argument index. |
| actionTypeCallees | string[] | ["dispatch"] | Callees whose object argument's action-type property is treated as a contract. |
| actionTypeProperty | string | "type" | The property name inspected inside actionTypeCallees arguments. |
| minDuplicates | integer >= 0 | 3 | Report a value repeated this many times (across non-allowlisted positions). 0 disables duplicate detection. |
| ignoreStrings | string[] | [] | Exact string values that are always allowed. |
"no-magic/no-magic-contracts": ["error", {
sinks: ["track", "getItem", "setItem", "removeItem",
{ callee: "router.push", argumentIndex: 0 },
{ callee: "router.replace", argumentIndex: 0 }],
ignoreStrings: ["latin"],
}]JSX and SVG presentation values remain allowed, but comparisons, action types,
and configured calls inside their expressions are checked. TypeScript as,
satisfies, type assertions, and non-null assertions preserve this detection.
Single-character values are checked in these behavioral contexts; only duplicate
checking ignores them. ignoreStrings still explicitly allows exact values.
Duplicate counts include occurrences already reported for a behavioral context,
without reporting the same node twice. minDuplicates: 0 disables counting.
Routing defaults now match only router.push and router.replace, argument 0.
For a differently named router, configure its static path explicitly. Legacy
sinks: ["push", "replace"] still opts into matching every method with those
names. Descriptors do not resolve aliases or infer receiver types.
Magic numbers
This plugin does not re-implement magic-number detection. Use the upstream
@typescript-eslint/no-magic-numbers
rule directly. Sensible defaults are exported for convenience:
import tseslint from "typescript-eslint";
import { recommendedMagicNumberOptions } from "eslint-plugin-no-magic";
export default [
{
files: ["app/**/*.{ts,tsx}", "src/**/*.{ts,tsx}"],
plugins: { "@typescript-eslint": tseslint.plugin },
languageOptions: { parser: tseslint.parser },
rules: {
"@typescript-eslint/no-magic-numbers": ["error", recommendedMagicNumberOptions],
},
},
];recommendedMagicNumberOptions ignores -1, 0, 1, array indexes, default
values, enums, numeric literal types, and readonly class properties, and
enforces const.
Development
pnpm install
pnpm build # TypeScript implementation -> dist/ JavaScript and declarations
pnpm test # Vitest 5 + ESLint RuleTester
pnpm lint
pnpm typecheck # Public configuration types
pnpm benchmark # Deterministic end-to-end lint benchmarkPublishing
pnpm create-version publishes automatically. It needs an npm automation token:
cp .env.example .env # then edit .env and set NPM_TOKENThe token is read from the environment or the gitignored .env and is never
printed. The repository has no .npmrc: beez-rp
writes a temporary npm user config outside the repository that only references
${NPM_TOKEN} (npm expands it from the environment, so the token never reaches
the disk or a command line), passes it with --userconfig and removes it
afterwards. Before publishing, it requires the release commit to stay unmodified,
checks the SHA-256 in the releases/<version>-<sha256>/ path, and compares the
tarball SHA-512 with the integrity of npm pack --dry-run for that commit;
npm pack is reproducible, so a match proves the archive is byte for byte what
npm would pack. Only then it publishes that exact tarball. prepublishOnly runs
beez-rp guard-publish, which stops a manual pnpm publish (or yarn/bun): publish
only through pnpm create-version, which publishes with npm. npm inherits the terminal, so its interactive
browser or one-time-password confirmation still works.
License
MIT
Typed configuration
The package includes TypeScript declarations and exports NoMagicContractsOptions
and SinkDescriptor for typed consumer configuration:
import type { NoMagicContractsOptions } from "eslint-plugin-no-magic";
const options = {
sinks: [{ callee: "router.push", argumentIndex: 0 }],
} satisfies NoMagicContractsOptions;Contract detection follows ternary value branches and logical fallbacks, including TypeScript wrappers. Computed static dispatcher/property names are equivalent to dot notation. Storage defaults inspect only argument 0 (the key), and preserve method-name matching for custom storage receivers. Real directive prologues and visible JSX copy in conditional branches remain exempt from duplicate reporting.
CI runs frozen installation, lint, type checks, and tests on Windows and Linux with Node 26. The suite packs the package and checks real ESLint behavior and declaration consumption from the extracted artifact.
TypeScript compiler compatibility
Type checking uses TypeScript 7 (tsc), installed as
@typescript/native: npm:typescript@^7.0.2. The ESLint parser still requires the
TypeScript 6 API, so typescript aliases @typescript/typescript6 for parsing.
Both are real implementations; the compatibility package exposes tsc6 and
does not replace the TypeScript 7 compiler used by pnpm typecheck or the
packaged consumer test. This follows Microsoft's
side-by-side installation guidance.
Performance baseline
pnpm benchmark compares parsing alone and the recommended
independent rules on 300, 3,000, and 15,000 literals in JavaScript, JSX,
TypeScript, and expressions with 40 nested conditional branches. Every run asserts diagnostic
counts. It records the median of five measured runs after two warmups, along
with Node, ESLint, OS, and CPU metadata. See the baseline.
The benchmark includes parsing and diagnostic construction; it is not an isolated
rule timing or a CI performance threshold.
Advisory constant rules
These optional rules implement statically verifiable parts of constant reuse:
rules: {
"no-magic/prefer-existing-constant": "warn",
"no-magic/no-duplicate-constants": ["warn", { ignoreValues: [100] }],
}prefer-existing-constant accepts the contract rule options. It suggests a
preceding, visible primitive string constant when the same value occurs in a
comparison, switch case, action type, or configured call. It respects lexical
shadowing and does not evaluate imports or expressions.
no-duplicate-constants reports repeated primitive string/number definitions in
the same lexical scope, excluding trivial values and ignoreValues. Separate
scopes are independent. Neither rule applies automatic fixes: equal values can
represent different business concepts. Neither infers cross-file ownership,
folder placement, deployment configuration, or whether a shared constant is safe
for client/server boundaries. Both are opt-in and absent from the recommended preset.
Visual formatter
Consumer CLI usage:
pnpm exec eslint . --format ./node_modules/eslint-plugin-no-magic/dist/formatter.jsThe formatter shows severity colors, Unicode frames, numbered source excerpts, underlined ranges, rule IDs, and a summary. Duplicate messages include the first eligible occurrence's line and column. It uses source supplied by ESLint and does not read other files. Long lines are clipped around the diagnostic.
For programmatic control:
import { createFormatter } from "eslint-plugin-no-magic/formatter";
const format = createFormatter({ color: false, unicode: false });
console.log(format(results));Color is detected from the terminal, honors NO_COLOR and FORCE_COLOR, and can
be overridden explicitly. Unicode decoration is enabled by default; the ASCII
mode disables it. Control characters are escaped to prevent terminal injection.
Terminal-specific ambiguous Unicode widths may differ. Editors and JSON formatters
still receive ordinary text messages with standard ESLint source locations.
Create a version
Changes are recorded under ## [Unreleased] in CHANGELOG.md, grouped by Keep a
Changelog sections (### Added, ### Changed, ### Deprecated, ### Removed,
### Fixed, ### Security), in English ASCII. Never edit the version or the
release date by hand.
pnpm create-version # alias: pnpm cv
pnpm create-version --dry-run # diagnosis and plan only
pnpm create-version --bump minorThe command comes from beez-rp,
shared by the Beez projects, and is configured in beez-rp.config.js. From a
clean, synced main it asks Codex to fill an empty [Unreleased] block in English
ASCII, lets you choose the next patch, minor or major version, releases
[Unreleased] as ## [X.Y.Z] - YYYY-MM-DD, commits package.json and
CHANGELOG.md as X.Y.Z with the annotated vX.Y.Z tag, runs
pnpm release:prepare on that commit, pushes main plus the tag atomically, and
publishes that exact verified tarball to npm with NPM_TOKEN (see Publishing).
Feature branches, uncommitted files or foreign commits on main stop it with the
next action to take. Running it again resumes a release left unpushed or a version
npm does not have yet, preparing and publishing only what is missing.
Prepare a local release
pnpm release:prepareThis checks the package version against the newest released changelog entry
(skipping [Unreleased]), enforces ASCII release notes, runs frozen
installation/tests/types/lint, packs with npm pack --ignore-scripts (npm, not
pnpm, so the archive is reproducible), then verifies the tarball contains its
declared exports and no unexpected private files. Output goes to
releases/<version>-<sha256>/ so previous artifacts are preserved. The directory
is ignored by Git. It does not bump versions, create commits or tags, or publish:
pnpm create-version runs it on the release commit and publishes that tarball.
Configurable duplicate syntax exemptions
no-duplicate-strings accepts ignoreSyntax, with all categories enabled by default:
createConfig({ duplicates: {
ignoreSyntax: { jsx: false, svg: true, constDefinitions: false },
} });jsx controls attribute values and rendered expressions outside SVG; svg controls
SVG markup independently; constDefinitions controls extracted constant values.
Setting a category to false includes it in duplicate counts and reports. Imports,
type declarations, directives, object keys, and property-name access stay exempt.
Behavioral contracts are still checked regardless of presentation exemptions.
These switches affect string duplication, not no-duplicate-constants.
Exclude constant names
Both advisory constant rules accept ignoreConstantNames: ["HTTP_TIMEOUT_MS"].
Names are exact and case-sensitive, not patterns. The duplicate-definition rule
excludes those names as both candidates and reported declarations. The reuse
rule never suggests excluded names and still respects lexical shadowing; it may
suggest another visible, non-excluded constant with the same value. These options
do not affect the independent string contract and duplicate-string rules.
Use PreferExistingConstantOptions or NoDuplicateConstantsOptions for typed
configuration. Value-level and name-level exclusions can be combined.
Profile optional constant rules
Run pnpm benchmark:constants to compare parser-only, reuse-only,
duplicate-definition-only, and both optional rules on 100, 500, and 1,000
candidates. The fixtures cover hits, misses, and 20 nested scopes. Five measured
runs follow two warmups, with exact diagnostic assertions and ESLint per-rule
statistics. Results are written to benchmarks/constants-results.json.
See the measured baseline. This command measures
existing behavior; it neither optimizes the rules nor sets a timing gate.
Reuse regression profiles
The constant benchmark also covers many constants with the same value,
matching candidates shadowed by parameters, and fully shadowed misses.
pnpm benchmark:constants --baseline records constants-before.json;
pnpm benchmark:constants --compare records current results and requires identical
complete diagnostics (including messages, locations, severity, and order) across
all recorded scenarios. Capture a new baseline only when the reference behavior
is intentionally changed. Profiling still uses five samples after two warmups.
prefer-existing-constant uses file-local scope/value indexes and cached visible
candidates. Temporal availability, declaration preference, name exclusions, and
shadowing remain unchanged. See before/after results
for measurements and complete diagnostic-equivalence checks.
constants-refactor preset
export default createConfig({
files: ["src/**/*.{js,ts,tsx}"],
preset: "constants-refactor",
});The preset applies one policy across rules, matching a "every static constant
lives in constants/" refactor workflow:
contracts.structuralDiscriminantsdefaults to["type", "kind", "operator"], so comparisons such asnode.type === "Identifier"are treated as language vocabulary by contract, duplicate, and reuse rules. This also exemptsaction.type === "ADD_TODO"; override the list wheretypecarries contracts.constantsLocationis enabled withexportedOnly: false, requiring every static module-level constant to live under**/constants/**.
Explicit settings always win: contracts.structuralDiscriminants, a
constantsLocation object (merged over the preset defaults), or
constantsLocation: false. Without preset, output is unchanged.
Advisory rules through createConfig
createConfig({
contracts: { sinks: ["send"] },
reuse: { severity: "warn", ignoreConstantNames: ["LOCAL_VALUE"] },
constantDuplicates: { severity: "warn", ignoreValues: [100] },
});constantsLocation: { severity, patterns, exportedOnly, ignoreConstantNames }
enables require-constants-location the same way.
Both advisory options default to false. true enables a rule with warning
severity. An object enables it with options and an optional severity. Reuse
inherits contract settings, including sinks and ignored strings, without duplicate
configuration. Its own options are severity and excluded names. Constant-definition
duplication has independent value and name exclusions. Input options are copied.
Terminal navigation
createFormatter({ hyperlinks: true, linkTarget: "vscode" }) emits OSC 8 links
that open an exact editor position. The default linkTarget: "file" uses file
URLs with location fragments; whether the opener honors the line depends on the
application. Labels include file:line:column. URLs escape spaces and controls.
Without an explicit option, links are enabled only for interactive terminals
identified as Windows Terminal, VS Code, iTerm, WezTerm or Ghostty, and disabled
in CI. Unknown terminals and non-file results keep plain locations. Use
hyperlinks: false for logs regardless of terminal capabilities. Color and links
are independent settings.
Memory profiling
pnpm benchmark:memory runs parser-only, reuse, and both advisory rules in separate
Node processes. It verifies diagnostics on 100 files with multiple scopes and
records heap after explicit GC, retained delta, sampled peak heap, and RSS.
The memory report explains the methodology and limits.
This is a diagnostic benchmark, not a leak assertion or CI memory threshold.
Minimum runtime compatibility
CI runs both Node 26.0.0 (the minimum allowed by engines.node) and the current
26.x version selected by .nvmrc, on Linux and Windows. Each combination installs
from the frozen lockfile and runs lint, public type checks and the full test suite,
including packaged-consumer integration. Locally, the same checks passed on
Windows with Node 26.0.0 and 26.8.1. Remote CI execution is separate from local validation.
Reuse boundary guarantees and limits
Reuse suggestions accept direct primitive string initializers (including static templates and TypeScript assertions), not destructuring, imports, concatenations, or calls. Parameters and destructured bindings still shadow outer names.
Hoisted function declarations can run before outer constants initialize, so reuse
searches stop after checking their local scope. Function expressions and arrows
created after a constant remain eligible. Searches also stop at dynamic with
environments. These conservative exclusions may omit safe suggestions; the rule
does not execute code, resolve imports, or prove arbitrary call ordering.
The benchmark's shadowing fixtures use arrows created after their declarations.
Historical diagnostic baselines predate this boundary audit; capture a new
--baseline before using --compare with the revised fixtures.
Overlapping configurations
ESLint applies matching flat-config entries in order. Each createConfig entry
supplies complete core string-rule options, so a later matching helper replaces
those earlier options with its supplied values and defaults. A manual severity-only
override retains existing rule options, following ESLint behavior.
Advisory options that are omitted inherit earlier matching entries. Explicit
reuse: false or constantDuplicates: false emits off, disabling earlier rules.
When changing shared contracts in a narrower helper, repeat reuse: true if you
want the inherited reuse rule to receive the new contracts.
export default [
...createConfig({ reuse: true, constantDuplicates: true }),
...createConfig({ files: ["tests/**/*.js"], reuse: false, constantDuplicates: false }),
];TypeScript implementation and distribution
Implementation lives in src/ and compiles with TypeScript 7 in strict mode.
pnpm build recreates dist/ with ESM JavaScript and generated declarations.
TypeScript checks unused bindings; ESLint checks the typed implementation using
the real parser. AST and scope types are development-only dependencies.
Public imports remain eslint-plugin-no-magic and
eslint-plugin-no-magic/formatter. Consumers execute JavaScript and do not need
TypeScript or a runtime loader. The formatter CLI path is now
./node_modules/eslint-plugin-no-magic/dist/formatter.js.
Tests and benchmarks import the compiled implementation; their pnpm commands build
first. typecheck also validates a consumer against the generated declarations.
The package test loads the compiled tarball and its formatter, then compiles a
consumer using the package exports. prepack builds automatically. dist/ and
releases/ are generated and ignored by Git. Tooling scripts and test fixtures
remain JavaScript; the published plugin implementation is TypeScript-authored.
One-build validation flows
pnpm check creates one fresh build, then runs the suite, consumer type checks,
and lint against that output. CI and release:prepare use this flow. Release
packing skips lifecycle scripts only after these gates succeed, so prepack
does not compile the same output again.
Standalone pnpm test, pnpm typecheck, benchmarks, and ordinary pnpm pack
remain self-contained and build first. No timestamps or persistent skip flags
are used to decide whether output is current. If build or validation fails,
check stops and release preparation does not pack an artifact.
Public type isolation checks
Consumer fixtures cover supported configurations and expected type errors for unknown rule names, invalid severities, misplaced options, malformed descriptors, syntax toggles, advisory settings, removed exports, and formatter options. Numeric ranges such as nonnegative thresholds remain runtime schema checks.
The package test copies the tarball's package into a temporary consumer outside
this repository. Only its ESLint peer is linked there. A plain Node process,
without inherited NODE_PATH or module loader options, verifies the plugin works
and that parser, utils, compiler, Vitest and Node type packages are not resolvable
from the consumer. TypeScript 7 is invoked externally as the test compiler; it
checks every consumer fixture against the physically copied generated declarations
with strict checking and no implicit ambient types. This checks dependency
isolation; it is not an npm-registry installation test.
