npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 first

This 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, categorized

Every 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#AstCacheRuleEngine 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 parse

Every 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:

  1. 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.
  2. 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 like RuleProjectGate satisfies it structurally. That package's createProjectDetectionGate() 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) pair

This 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 severityPolicy were omitted.
  • A different real severity: every Issue that 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 in ruleExecutionSummary.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 invoked

This 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 — reuses classifyPluginSource for 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 — the AstCache every file is parsed through, see "AST Caching" above
  • @devmedic/parser-typescript — the AST Engine; the only thing that ever parses a file here
  • zod