Skip to the content.

ruleman

A static analysis CLI for repositories. Point it at a declarative JSON(C) rule file and it checks things like “does this file exist” or “does this JSON file have the expected value at this key” — useful for enforcing repo-wide conventions in CI.

View on GitHub · View on npm

Install

npm install --save-dev ruleman
# or run once without installing
npx ruleman

Prebuilt native binaries are published for Linux (x64/arm64), macOS (x64/arm64), and Windows (x64) — no Rust toolchain required on install.

Quick start

npx ruleman init             # scaffolds ruleman.json
npx ruleman add README.md    # adds an existing file as a rule
npx ruleman                  # runs the checks
// ruleman.json
{
  "$schema": "https://ruleman.dev/schema.json",
  "rules": [
    {
      "type": "file",
      "severity": "error",
      "state": "present",
      "files": ["README.md", "LICENSE"]
    },
    {
      "type": "content",
      "severity": "warn",
      "format": "json",
      "file": "tsconfig.json",
      "key": "compilerOptions.strict",
      "expected": true
    }
  ]
}

Running ruleman with no arguments auto-discovers ruleman.json / ruleman.jsonc / .ruleman.json, searching the current directory and walking up — the same pattern used by eslint, prettier, and biome. Pass --config <path> to point at a specific file instead.

Config reference

Add "$schema": "https://ruleman.dev/schema.json" to any config file to get autocomplete and validation in editors that support the $schema convention (VS Code, JetBrains IDEs, etc.).

Field Type Description
$schema string Optional; points editors at the JSON Schema.
extends string[] Other ruleman configs to inherit rules from: a path, or an installed npm package. Cycles are detected and rejected.
rules Rule[] The checks to run, in order.

Every rule accepts a severity:

file

Named and shaped after Ansible’s file module: checks file presence via a state attribute rather than inventing a mirror rule type for the negated case. state: "present" requires the path to exist and be a regular file — a directory with the same name does not satisfy it.

{ "type": "file", "state": "present", "files": ["README.md"] }
{ "type": "file", "state": "absent", "files": ["yarn.lock", "**/*.log"] }
Field Type Required Description
files string[] yes Paths to check (repo-relative). An entry containing *, ?, [ or { is a glob pattern.
state "present" | "absent" no "present" (default) fails if missing or not a regular file; "absent" fails if anything exists there.

directory

The same idea as file, for directories — kept as a separate rule type rather than folded into file because file/directory are genuinely different things to check (not a superficial present/absent-style variation), and future directory-specific attributes (like empty below) shouldn’t leak into file’s schema.

{ "type": "directory", "state": "present", "directories": [".github/workflows"] }
{ "type": "directory", "directories": ["dist"], "empty": false }
{ "type": "directory", "state": "absent", "directories": ["packages/*/node_modules"] }
Field Type Required Description
directories string[] yes Paths to check (repo-relative). An entry with *, ?, [ or { is a glob pattern.
state "present" | "absent" no "present" (default) fails if missing or not a directory; "absent" fails if anything exists there.
empty boolean no If set, additionally requires zero (true) or at least one (false) entries. Only checked when state is present.

content

Checks a value inside a structured file. Rather than a json-match rule type and a yaml-match/toml-match type per format, format selects the parser and the rule type itself stays content. Named to pair with file: file checks whether a file exists, content checks what’s inside it.

format reads json (default), yaml or toml. All three are mapped onto the same JSON-shaped tree, so key, comparison and state behave identically whichever one a file is written in — see format mapping for the details that follow from that.

comparison decides how the value at key is compared with expected, and state decides whether that comparison has to hold — two independent axes, so every comparison also works negated:

// engines.node is exactly ">=18"
{ "type": "content", "file": "package.json", "key": "engines.node", "expected": ">=18" }

// engines.node mentions 18 somewhere
{ "type": "content", "comparison": "contains", "file": "package.json",
  "key": "engines.node", "expected": "18" }

// workspaces lists packages/*
{ "type": "content", "comparison": "contains", "file": "package.json",
  "key": "workspaces", "expected": "packages/*" }

// the package name is scoped
{ "type": "content", "comparison": "regex", "file": "package.json",
  "key": "name", "expected": "^@acme/" }

// ...and must not be published as UNLICENSED
{ "type": "content", "comparison": "regex", "state": "mismatch",
  "file": "package.json", "key": "license", "expected": "^UNLICENSED$" }

key is a dot-separated path, and a numeric segment indexes into an array: workspaces.0, contributors.1.name. A key that isn’t there fails every comparison.

Field Type Required Description
format "json" | "yaml" | "toml" no Parser to use. "json" is the default.
file string yes Path to the file, or a glob pattern — every matching file is checked.
key string yes Dot-separated path into the parsed document; numeric segments index arrays.
expected any yes The value key is compared against. Must be a string when comparison is "regex".
comparison "equals" | "contains" | "regex" no "equals" (default) deep equality; "contains" substring of a string or element of an array; "regex" the value must match.
state "match" | "mismatch" no "match" (default) requires the comparison to hold; "mismatch" requires it to fail.

Format mapping

YAML and TOML are converted to the JSON shape before the key is walked, which makes the following part of the contract rather than an accident:

// a workflow pinned to a modern action
{ "type": "content", "format": "yaml", "comparison": "regex",
  "file": ".github/workflows/*.yml",
  "key": "jobs.test.steps.0.uses", "expected": "^actions/checkout@v[5-9]" }

// the crate's edition
{ "type": "content", "format": "toml", "file": "Cargo.toml",
  "key": "package.edition", "expected": "2024" }

checksum

Pins a file’s exact bytes by hash — for files that are supposed to change only deliberately (vendored bundles, generated output, a CI workflow you don’t want edited casually). Separate from content because it asserts a different thing: content reads a value out of a parsed document, checksum compares the whole file’s digest and doesn’t care about its format.

state: "match" (default) fails unless the file’s digest equals expected; state: "mismatch" fails when it does (pinning a digest the file must not have, e.g. a known-bad revision). A missing or unreadable file fails too.

{
  "type": "checksum",
  "algorithm": "sha256",
  "file": "vendor/lib.js",
  "expected": "3879a5d930ae1999b278a3a498f7de3fd83ba8dae59330fcfa2db31c103ac21d"
}

Record the digest with ruleman add --checksum rather than by hand, and re-run the same command to refresh it after an intentional change.

Field Type Required Description
algorithm "sha256" no Hash algorithm. "sha256" (default); more can be added later.
file string yes Path to the file to hash.
expected string yes Hex digest, compared case-insensitively.
state "match" | "mismatch" no "match" (default) requires the digest to equal expected; "mismatch" requires it not to.

extends

Share rules across config files, or across repos:

// ruleman.json
{
  "extends": [
    "./base.ruleman.json",        // a file in this repo
    "@acme/ruleman-config",       // an installed npm package
    "@acme/ruleman-config/strict.jsonc"  // a specific file inside one
  ],
  "rules": [{ "type": "file", "files": ["CHANGELOG.md"] }]
}

Rules from extended files run first, in the order listed, followed by the file’s own rules. Circular references are rejected with an error.

An entry starting with . or / is a path, resolved relative to the file that declares it. Anything else is an npm package name, looked up in node_modules walking up from the config file — the same shape as eslint’s shareable configs and tsconfig’s extends. A package name alone uses that package’s ruleman.json / ruleman.jsonc / .ruleman.json; add a subpath to name a specific file.

Package resolution is offline: the package has to be installed, so what a run checks against is pinned by your lockfile rather than fetched over the network mid-check. If it isn’t installed, the run says so rather than reporting a confusing missing-file error.

Rules from a package check the consuming repo. A shared files: ["LICENSE"] means your LICENSE, not the one inside node_modules — so a package’s rules resolve against the config that extended it, all the way down the package’s own extends chain.

Path resolution

file’s files, directory’s directories, content’s and checksum’s file, and path-shaped extends entries are all resolved relative to the config file that declares them — not the directory ruleman is invoked from. This keeps results consistent whether you run ruleman from the repo root, from a subdirectory (via upward config discovery), or via extends pulling in rules defined elsewhere. The one exception is rules from an extended package, which resolve against the consuming repo — see extends.

Globs

An entry in files / directories containing *, ?, [ or { is a glob pattern matched against the working tree; anything else is a literal path, which is checked with a single stat and never triggers a directory walk.

A pattern asserts “there is at least one match”: state: "present" fails when nothing matches, and state: "absent" fails once per match. That makes absent the natural way to forbid a whole class of paths:

{ "type": "file", "state": "absent", "files": ["**/*.log", "**/.DS_Store"] }
{ "type": "directory", "state": "absent", "directories": ["packages/*/node_modules"] }

For content and checksum, a pattern reads as “every matching file satisfies this”, which is how a rule covers a whole set of packages:

{ "type": "content", "file": "packages/*/package.json",
  "key": "license", "expected": "MIT" }

Matching nothing is a failure there — a rule about a file’s contents has nothing to assert without a file. For file and directory, where existence is the assertion, the reading differs by state.

Note the quantifier when reading a present pattern: files: ["packages/*/README.md"] passes as soon as any package has a README — it does not require one in every package. To require it in each, list them:

{ "type": "file", "files": ["packages/a/README.md", "packages/b/README.md"] }

Comments and trailing commas

Config files are parsed as JSONC, so comments (//, /* */) and trailing commas are allowed.

Config validation

The config is validated before any check runs, and anything it can’t make sense of is an error rather than something quietly ignored:

::error::[ruleman] 設定ファイル 'ruleman.json' の解析に失敗しました: rules[2]: unknown field `stat`, expected one of `severity`, `state`, `files`

Attributes that fill the same slot are mutually exclusive rather than combined — specifying both is an error, not an AND.

CLI reference

ruleman [--config <path>] [--format <fmt>]   # run checks (default command)
ruleman init [--force]                       # scaffold a starter ruleman.json
ruleman add <path>...                        # add existing paths as rules
ruleman --version
ruleman --help

--format

How results are reported. Defaults to auto.

Value Output
auto github when GITHUB_ACTIONS=true, text otherwise.
github GitHub Actions workflow commands (::error file=...::), surfaced as annotations on the run.
text One error: / warning: line per failure, plus a count. Readable in any terminal or CI.
json A single JSON document: { "diagnostics": [...], "summary": { "errors": n, "warnings": n } }.

Each diagnostic carries severity, rule (the rule type that produced it), file (the path it’s about, or null) and message. Config-level failures are reported the same way, so --format json always emits one parseable document:

ruleman --format json | jq '.diagnostics[] | select(.severity == "error") | .file'

The exit code is 1 if any error was reported and 0 otherwise, in every format — warn never fails the run.

add

Registers paths that already exist in the repo, so you don’t have to hand-edit the config to lock in a file that’s there today:

ruleman add README.md .github/workflows   # one file rule, one directory rule
ruleman add --severity warn CHANGELOG.md
ruleman add --checksum vendor/lib.js      # pin the file's current hash

With no options it writes an existence check — state: "present" at severity: "error". Each path must exist, and what’s on disk decides the rule type: a regular file goes into a file rule’s files, a directory into a directory rule’s directories.

Paths are appended to an existing rule with the same type, state and severity when there is one, otherwise a new rule is appended to rules. Paths already covered by a matching present rule are reported and skipped.

--checksum

Hashes each file as it is right now and writes a checksum rule instead of an existence rule — the recording half of hash pinning, so the digest never has to be pasted in by hand:

ruleman add --checksum vendor/lib.js schema.graphql
ruleman add --checksum --algorithm sha256 vendor/lib.js   # sha256 is the default

Directories are rejected. When a match rule for the same file and algorithm already exists, its expected is rewritten in place rather than duplicated — so after an intentional edit, re-running the same command is how you refresh the pin. Re-running it on an unchanged file reports that and writes nothing. mismatch rules are never rewritten, since they pin a digest the file must not have.

Arguments are interpreted relative to the current directory but stored relative to the config file (with / separators), matching how rule paths resolve at check time — so ruleman add main.rs from src/ writes src/main.rs into a config at the repo root. Paths outside the config file’s directory are rejected.

Edits are applied to the config’s syntax tree rather than reserialized, so comments, indentation and trailing commas are preserved.

Building from source

cargo build --release
./target/release/ruleman --version

Changelog

CHANGELOG.md records what changed in each release, including the breaking changes to watch for when upgrading.

License

MIT © Codebase Inc.