@devmedic/rule-engine
v0.1.0
Published
AST traversal scheduling, single-pass visitor merging, incremental caching, worker-pool parallelism.
Readme
@devmedic/rule-engine
Executes rules — contributed by plugins, first-party or third-party —
against parsed source files and produces normalized Issue objects. No
reports, no auto-fix application, and no knowledge of any specific
framework live here. Rule.supportedFrameworks is opaque metadata this
engine declares, carries, and never reads.
Parsing always goes through @devmedic/parser-typescript — the single source of truth for that, per Phase 5. This package never parses on its own; it goes through an @devmedic/ast-cache-engine#AstCache so the parser never runs twice for the same file content — see "AST Caching" below.
The Rule contract
import { defineRules } from '@devmedic/rule-engine';
export default defineRules({
rules: [
{
id: 'demo/no-var',
title: 'No var',
description: 'Disallows the var keyword.',
category: 'correctness', // 'correctness' | 'security' | 'performance' | 'architecture'
severity: 'warning', // 'hint' | 'info' | 'warning' | 'error' | 'critical'
documentationUrl: 'https://example.com/rules/no-var',
supportedFrameworks: [], // opaque strings a rule author chooses; the engine never inspects them
// Rule Metadata (see "Rule Metadata" below) — all optional, consumed by
// Report Engine / the VSCode extension / a dashboard, never by this
// engine's own execution.
fixable: false, // is this rule capable of producing fix edits at all?
sinceVersion: '0.1.0', // the plugin/rule-pack version this rule was introduced in
minimumRNVersion: '0.70.0', // blank for a rule pack that isn't React-Native-specific
estimatedFixTime: 5, // rough minutes to fix one occurrence by hand
tags: ['style'], // freeform categorization beyond `category`
references: [{ title: 'Why no var', url: 'https://example.com/why-no-var' }],
// Everything below is Rule Isolation — the engine DOES act on these, to
// build an execution plan before any file is read. All optional;
// absent/empty means unrestricted for that dimension. See "Rule
// Isolation" below.
supportedFileExtensions: [], // e.g. ['.ts', '.tsx']
supportedFiles: [], // e.g. ['package.json'] or ['*.config.js']
supportedDirectories: [], // e.g. ['src/screens']
ignoredDirectories: [], // e.g. ['__tests__', 'fixtures', 'generated']
ignoredFiles: [], // e.g. ['*.snap']
ignoredPatterns: [], // e.g. ['**/generated/**']
supportedProjectTypes: [], // e.g. ['react-native', 'expo'] — needs a projectGate (see below)
supportedPlatforms: [], // e.g. ['ios', 'android'] — needs a projectGate (see below)
canFix(finding, context) {
return true;
},
analyze(context) {
// context.ast comes from @devmedic/parser-typescript — parseFile()'s NormalizedAst
return []; // RuleFinding[]
},
fix(finding, context) {
return null; // RuleFixEdit[] | null — never called by this engine; see below
},
examples() {
return [{ description: 'var usage', code: 'var x = 1;', valid: false }];
},
},
],
});defineRules is an identity helper for editor type-checking, mirroring defineConfig (@devmedic/config) and definePlugin (@devmedic/plugin-sdk) — it does nothing at runtime.
fix() is part of the contract but this engine never calls it. Applying
fixes is the future Auto-Fix Engine's job; this phase only defines the
shape so rules are forward-compatible with it.
Rule Metadata
Every rule exposes metadata — a projection of its static fields, with none of its four methods, for anything that needs to describe a rule rather than run it (a Report, the VSCode extension, a dashboard):
import { extractRuleMetadata } from '@devmedic/rule-engine';
const metadata = extractRuleMetadata(myRule);
metadata.fixable; // boolean — always present, defaults to false if the rule didn't declare it
metadata.documentation; // from Rule.documentationUrl — the one field name that differs
metadata.sinceVersion;
metadata.minimumRNVersion;
metadata.supportedPlatforms;
metadata.estimatedFixTime;
metadata.tags;
metadata.references; // { title, url }[]documentation (not documentationUrl) is the one deliberate naming
difference from Rule itself — Rule.documentationUrl keeps its
established name (already threaded through Issue/RuleSummaryEntry/
SARIF output); a caller mapping RuleMetadata onto another package's own
input shape (e.g. Report Engine's RuleMetadataInput) adapts that one
field.
Validation
import { RuleMetadataSchema, validateRuleMetadata } from '@devmedic/rule-engine';
RuleMetadataSchema.safeParse(candidate); // the Zod schema directly
validateRuleMetadata(candidate); // throws with every problem, not just the firstThis is a different schema from the one validateRule/registerRule
use internally (RuleStaticFieldsSchema in validate.ts, which validates
a Rule as it's actually shaped — documentationUrl, fixable both
optional). RuleMetadataSchema validates the consumer-facing projection
(fixable required, documentation named as such) — the one an external
consumer that didn't produce the data itself (a dashboard receiving it
over the network, a cached catalog file) should validate against.
JSON Schema
import { generateRuleMetadataJsonSchema } from '@devmedic/rule-engine/dist/docs/generate-schema.js';pnpm --filter @devmedic/rule-engine run docs regenerates
schema/rule-metadata.schema.json from RuleMetadataSchema via
zod-to-json-schema — the same zodToJsonSchema(schema, { name,
$refStrategy: 'none' }) pattern @devmedic/config already established
for its own config schema, so the runtime-validated schema and the
published JSON Schema can never drift apart.
Loading rules from plugins
import { RuleEngine } from '@devmedic/rule-engine';
const engine = new RuleEngine();
await engine.loadRulesFrom('devmedic-plugin-security'); // dynamic import + validation + registration
// or, for a rule object you already have in hand:
engine.registerRule(myRule);loadRulesFrom dynamically imports the specifier and expects its default
export (or, failing that, its named exports) to be the result of
defineRules({ rules }). It reuses @devmedic/plugin-sdk's
classifyPluginSource to tag the result as internal / local / npm —
rule modules are loaded from the same kinds of sources plugins are, since a
plugin's contribution to DevMedic is its rules.
Every rule is validated (metadata via a Zod schema, methods via typeof
checks) before it's registered, and its examples() is called once at
registration to catch a broken implementation immediately rather than the
first time it's needed.
Running an analysis
const result = await engine.analyze(['src/a.ts', 'src/b.tsx'], {
concurrency: 4, // files in flight at once; default 4
cache: true, // default true
signal: abortController.signal, // optional
onProgress: (event) => console.log(event.type),
projectGate: myProjectGate, // optional — see "Rule Isolation" below
severityPolicy: mySeverityPolicy, // optional — see "Severity Policy" below
ruleTimeoutMs: 5000, // optional, default 5000 — a rule's own execution boundary, see "Rule Failure Isolation" below
});
result.issues; // readonly Issue[] — normalized, ready to hand to a report engine
result.errors; // readonly RuleExecutionFailure[] — isolated failures, nothing was thrown; see "Rule Failure Isolation" below
result.timings; // readonly { ruleId, filePath, durationMs }[]
result.filesAnalyzed; // files actually read and parsed — never counts a file rule isolation excluded entirely
result.cancelled;
result.skipped; // readonly { ruleId, filePath }[] — every (rule, file) pair the execution plan excluded, and why
result.ruleExecutionSummary; // { successful, failed, skipped: readonly string[] } — every registered rule, categorizedEvery registered rule runs against every file it could possibly match
by default — a rule with no declared fields runs everywhere, exactly as
before rule isolation existed. Nothing is hardcoded: every exclusion comes
from a field a rule itself declared, or a projectGate a caller supplied.
AST Caching
Every file is parsed through an @devmedic/ast-cache-engine#AstCache —
RuleEngine creates its own private instance by default, so nothing
changes for an existing caller. Pass your own via the constructor to
share it with another consumer analyzing the same files (e.g. a fix
pass re-parsing to call a rule's real fix()):
import { AstCache } from '@devmedic/ast-cache-engine';
const astCache = new AstCache();
const engine = new RuleEngine({ astCache });
// ... later, some other consumer of the same files:
astCache.getOrParseFile(filePath); // a cache hit if RuleEngine already parsed it, not a second parseEvery rule in a file still shares one parse (unchanged since before this
existed) — what the shared instance adds is across separate
analyze() calls, separate RuleEngine instances, and separate
non-engine consumers: the parser runs at most once per distinct
(filePath, content) pair, full stop, regardless of how many places ask
for it. See @devmedic/ast-cache-engine's own README for cache
invalidation, memory bounds, and a before/after benchmark.
Rule Isolation
Before analyze() reads a single file, it calls buildExecutionPlan to
decide — for every file — exactly which rules could possibly match it. A
rule that cannot possibly match a file never executes against it; a file no
registered rule can possibly match is never even read or parsed. Two
stages, cheapest first:
Static (
matchesStaticConstraints,match.ts) — pure path checks, zero I/O:supportedFileExtensions,supportedFiles,supportedDirectories,ignoredDirectories,ignoredFiles,ignoredPatterns. A rule that fails here never reaches stage 2.supportedFiles/ignoredFiles: exact basenames or glob patterns (*.config.js); a pattern containing/matches the file's trailing path segments instead of just the basename.supportedDirectories/ignoredDirectories: one or more/-joined directory segments (e.g.'src/screens') that must (or must not) appear consecutively among the file's ancestor directories, anywhere.ignoredPatterns: a general glob (**allowed) matched against the full path — the escape hatch beyond files/directories.
Project-aware (
projectGate, optional) —supportedProjectTypes/supportedPlatforms, checked only for rules that survived stage 1, so a rule already excluded by path never triggers a project-detection lookup:export interface RuleProjectGate { shouldRun(rule: Rule, filePath: string): boolean | Promise<boolean>; }This package defines only the interface — it has no dependency on
@devmedic/project-detection-engine; any object shaped likeRuleProjectGatesatisfies it structurally. That package'screateProjectDetectionGate()is the real implementation: it denies a file under a global ignored directory (node_modules,dist,fixtures, ...) outright, and otherwise checks the file's nearest project's detected type/platform.
import { createProjectDetectionGate } from '@devmedic/project-detection-engine';
const result = await engine.analyze(files, {
projectGate: createProjectDetectionGate(),
});
// Inspect the plan directly — no file read/parse, no analyze() call:
const plan = await engine.planExecution(files, { projectGate: createProjectDetectionGate() });
plan.entries; // readonly { filePath, rules }[] — only files with >=1 applicable rule
plan.skippedFiles; // readonly string[] — never read or parsed
plan.skipped; // readonly { ruleId, filePath }[] — every excluded (rule, file) pairThis is what keeps a React Native rule pack from firing against, say, a CLI
package, a parser package, or its own __tests__/fixtures/generated
folders living in the same monorepo — see
@devmedic/plugin-react-native's manifest for a real, shipped example, and
@devmedic/project-detection-engine's own README for the full detection
pipeline.
Benchmark: O(relevant matches), not O(files × rules)
src/execution-plan.bench.ts (pnpm bench, Vitest's bench()) quantifies
this against 20 rules and a realistic 10%-relevant file mix:
| Benchmark | Result |
| --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| buildExecutionPlan: 1,000 vs. 10,000 files | ~9.5x slower for 10x the files — plan-building scales linearly, not with the cross product |
| RuleEngine.analyze(): isolated vs. unrestricted rules, same 220 files, 10 rules | isolated rules 3.29x faster — the unrestricted engine reads, parses, and runs against all 220 files; the isolated one only touches the 20 actually-relevant ones |
Numbers are from one real run (pnpm --filter @devmedic/rule-engine run bench) and will vary by machine — re-run locally for your own baseline.
Severity Policy
Every rule declares its own default severity ('hint' | 'info' | 'warning'
| 'error' | 'critical'). A caller can override that per rule — including
disabling a rule outright — via an optional severityPolicy:
export interface SeverityPolicy {
resolveSeverity(rule: Rule): Severity | 'off';
}Same pattern as RuleProjectGate — this package defines only the
interface, with no dependency on @devmedic/severity-engine; any
object shaped like SeverityPolicy satisfies it structurally.
@devmedic/severity-engine#createSeverityPolicy(overrides) is the real
implementation, built from a project's config.rules map:
import { createSeverityPolicy } from '@devmedic/severity-engine';
const result = await engine.analyze(files, {
severityPolicy: createSeverityPolicy({
'console-log': 'warning',
'unused-dependencies': 'off',
'async-storage-token': 'critical',
}),
});resolveSeverity is called once per rule, before execution:
- No policy, or the policy returns the rule's own default: unchanged
behavior, exactly as if
severityPolicywere omitted. - A different real severity: every
Issuethat rule produces is reported at the overridden severity instead of the rule's declared default. 'off': the rule is excluded from the execution plan entirely — it never reads or parses a file for that run, and shows up inruleExecutionSummary.skipped, not merely hidden after running.
Rule Failure Isolation
Every analyze()/canFix() call runs inside its own execution boundary — a
crashing or hanging rule is captured, never thrown, and never stops the
rest of the run. Four independent failure phases, each isolated the same
way:
| Phase | What failed | Who catches it |
| --------- | -------------------------------------------------- | --------------------------------- |
| read | The engine's own file read | The engine (ruleId: '(engine)') |
| parse | The engine's own parseFile() call | The engine (ruleId: '(engine)') |
| analyze | A rule's analyze() threw, rejected, or timed out | The rule that owns it |
| can-fix | A rule's canFix() threw | The rule that owns it |
A rule that hangs inside analyze() past ruleTimeoutMs (default 5000ms)
is captured as a RuleTimeoutError, exactly like a thrown one —
withRuleTimeout races it the same way @devmedic/plugin-sdk's
withTimeout races a plugin hook, with the same honest caveat: this bounds
how long the run is allowed to wait, it does not preempt a synchronous,
CPU-bound analyze() (no timer can interrupt a single-threaded hang — a
real boundary against that needs worker_threads, not attempted here).
canFix() is contractually synchronous (Rule.canFix returns boolean,
never Promise<boolean>), so it isn't timeout-wrapped — there's nothing an
async race could preempt that a plain try/catch doesn't already catch.
Every failure is a full RuleExecutionFailure, never just a count:
export interface RuleExecutionFailure {
readonly ruleId: string;
readonly ruleName?: string; // Rule.title — absent only for engine-level read/parse failures
readonly filePath: string;
readonly phase: 'read' | 'parse' | 'analyze' | 'can-fix';
readonly durationMs: number; // how long it ran before failing
readonly error: SerializedError; // { name, message, stack? } — a real Error loses these to JSON.stringify otherwise
readonly parserState?: { language?: string; sourceType?: string; sourceLength?: number };
}serializeError (also exported) is why: JSON.stringify(new Error('x'))
produces {} — message/stack are real but non-enumerable properties —
so every failure is normalized through it before being stored, making
result.errors genuinely useful in a --json output instead of an empty
shell.
result.ruleExecutionSummary categorizes every registered rule — not
just the ones that failed — into exactly one bucket:
result.ruleExecutionSummary.successful; // ran at least once, never threw/timed out
result.ruleExecutionSummary.failed; // threw or timed out at least once, even if it also succeeded elsewhere
result.ruleExecutionSummary.skipped; // rule isolation excluded it for every file — never actually invokedThis is what @devmedic/report-engine's "Rule Failures" section and
Rule Execution Summary render from — see that package's README.
Execution model
| Requirement | How |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Load rules | loadRulesFrom(specifier) — dynamic import(), defensive CJS/ESM interop (the same lesson @devmedic/plugin-sdk and @devmedic/parser-typescript each hit), validated before registration. |
| Execute rules | An ExecutionPlan is built first (see "Rule Isolation"); each file the plan kept is read once and parsed once (via parseFile), then only the rules the plan matched to that file run against the same parsed AST — no re-parsing per rule, and no rule call for a file it was already excluded from. |
| Handle errors | Rule Failure Isolation (see above): a rule that throws, times out, or fails canFix(), or a file that fails to read/parse, is caught and reported in result.errors with a stack trace, duration, and parser state — every other rule and file keeps going. |
| Parallel execution | Files are processed with bounded concurrent scheduling (concurrency, default 4) via a small worker-pool (mapWithConcurrency) — genuine concurrency for the I/O (reading files, awaiting async rules), not multi-core parallelism. True CPU-bound parallelism would need worker_threads, meaningfully more complex here since Babel ASTs aren't structured-clone-able across threads; that's a documented future upgrade, not attempted here. |
| Execution timing | Per-rule-per-file durations in result.timings; per-file and whole-run durations via onProgress events and result.durationMs. |
| Caching | In-memory, content-hash-keyed per (filePath, ruleId) — an unchanged file skips re-running a rule that already ran against it. Self-contained for this phase rather than depending on the still-unimplemented @devmedic/cache package; swapping to that later is a drop-in change behind the same two-method surface (RuleResultCache.get/.set). |
| Cancellation support | A standard AbortSignal — checked between files and between rules within a file. This is cooperative cancellation: it stops starting new work, not a forced interruption of an in-flight synchronous call. |
| Progress events | onProgress receives file-start, file-complete, rule-error, and run-complete events as they happen. |
Depends on
@devmedic/core@devmedic/plugin-sdk— reusesclassifyPluginSourcefor rule-module source classification@devmedic/cache— declared per the Phase 0 architecture graph; not yet used, since that package isn't implemented yet (see Caching above)@devmedic/ast-cache-engine— theAstCacheevery file is parsed through, see "AST Caching" above@devmedic/parser-typescript— the AST Engine; the only thing that ever parses a file herezod
