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

workspace-conformance

v4.0.1

Published

Conformance checks for a pnpm workspace that ESLint cannot express: type-graph, import-graph and cross-file checks driven by the shared layout section of @exadev/config

Readme

workspace-conformance

Conformance checks for a pnpm workspace that ESLint cannot express. Type-graph checks run on ts-morph, import-graph checks run on dependency-cruiser with rules generated from the shared layout section of @exadev/config, file-tree and cross-config checks compare two files or read git state, workflow checks read GitHub Actions files, and settings checks read the repository's settings through the GitHub API. Each check reports violations with a stable code, a message and what the violation is about: a file and, where it has one, a position, or for the settings checks the repository.

It complements the workspace architecture rules of @exadev/eslint-config, it does not replace them. Those rules run in the editor and check what package.json files declare (no-uphill-dependency and no-dependency-cycle), incrementally and one file at a time. This tool checks what the code does: the import statements that actually cross packages, whether written as a package name, a relative path or a path alias, and everything that needs two files, the type checker or git. Both read the same layout section, so one description of the workspace drives both. Naming (package-name-mirrors-path) and per-file lint stay in ESLint.

Getting started

pnpm add -D workspace-conformance '@exadev/config@^2.1.0' cosmiconfig 'typescript@^6'

@exadev/config provides withSections and layoutSection for the config file, and the tool loads its sections with it, so install the major this package depends on (^2.1.0): a different major installs a second copy, whose sections and errors are not the ones the tool reads. cosmiconfig (^9.0.0 || ^10.0.0) is a peer dependency because @exadev/config loads config files through it. typescript (^5.9.0 || ^6.0.0) is a peer dependency because dependency-cruiser needs it to parse TypeScript and supports versions below 7 (the range is in the install command because an unpinned typescript installs a newer major, which pnpm only warns about); the import checks fail with a configuration error when dependency-cruiser cannot load it. ts-morph bundles its own compiler and does not use yours. eslint (^9.0.0 || ^10.0.0) is an optional peer dependency, needed only by the eslint check, which uses the copy installed in the repository it checks. The package supports the Node lines dependency-cruiser and its own dependencies support: 22.13 and later 22, 24, and 26 and later.

Configure the checks in exadev.config.ts:

import { layoutSection, withSections } from '@exadev/config';
import { conformanceSection } from 'workspace-conformance';

export default withSections(layoutSection, conformanceSection)({
  layout: {
    groups: [
      { name: 'core', rank: 0 },
      { name: 'features', rank: 1, slice: { segment: 0 } },
      { name: 'product', rank: 2, slice: { segment: 0 } },
    ],
    rankSkip: { maxDistance: 1, exemptRanks: [] },
  },
  conformance: {
    checks: {
      'import-uphill': {},
      'import-rank-skip': {},
      'import-cross-slice': {},
      'import-cycles': {},
      'instruction-symlinks': {},
      'aggregate-mappers': {
        contracts: ['product/*/contract/src/aggregates.ts'],
        adapters: '{dir}/../../adapters/*',
        mapper: '{adapter}/src/{name}.mapper.ts',
      },
      'command-types': { commands: ['product/*/contract/src/commands.ts'], exclude: ['Command'] },
    },
  },
});

Then run it:

pnpm exec workspace-conformance check

Configuration

The tool reads two sections through @exadev/config, each from exadev.config.ts under its key or from a standalone exadev.<section>.config.ts in the working directory, never both. See that package for extends, presets and how a section is found.

  • layout is the shared layoutSection. The import checks read it: groups (with path, rank, slice), nameRanks, defaultRank, rankSkip, isolatedGroups, dependencyFields, and where the packages are (root, packages, or the packages list of pnpm-workspace.yaml). Checks that do not read the layout need no layout section.
  • conformance is conformanceSection. Its checks map decides what runs: a check whose name is present with an options object runs ({} for the defaults), and false switches it off, which lets a config that extends another turn one of its checks off. A key that is not a check name, or an option a check does not have, fails when the section loads.

runChecks and the command line fail with exit status 2 when no check is enabled, so an empty section never reads as a pass.

How the layout maps to packages

Packages are the directories that the packages globs select and that hold a package.json. The root package is one when a glob selects it (.), which is how a single-package repository is described: packages: ['.'] with a group whose path is ., so import-cycles judges the files of the one package. Since every other package directory is inside the root, the root package cannot be a package beside others. Each belongs to the group whose root is the longest prefix of its directory, and takes its rank from the first matching nameRanks pattern, else its group's rank, else defaultRank. A segment slice is the path segment below the group root; a namePrefix slice is the longest slice value produced by a segment group that prefixes the package's unscoped name. These are the same rules the ESLint architecture rules apply.

The tool fails loudly, with a configuration error, on what it cannot judge: globs that select no package, a package directory no group owns, package directories nested inside one another, a rank check when some package resolves no rank, and a check that needs rankSkip, isolatedGroups or a slice when the layout has none.

Checks

workspace-conformance check --list prints the names. Violation codes are <check>/<reason> and are stable; messages may change. Paths in options are relative to the working directory and use /. Every search by glob skips node_modules, git's own data and any other checkout below the working directory (a directory with its own .git, such as a linked worktree), so a copy of the repository nested in the tree is not judged as part of it.

Import graph

The import checks generate dependency-cruiser rules from the layout in a pure function, cruise the packages' files, and read summary.violations. Every enabled import check adds its rules to a single cruise, and the findings are split back to the checks by rule name; checks whose exclude, doNotFollow and tsConfig are equal (after defaults) share a cruise, and checks that differ in any of them are cruised separately, since those options decide which files the graph contains. They never use the exit code of cruise(), which is 0 whatever it found. A rule cannot compare ranks, so the ordering is expanded into path alternations at generation time: one rule per rank, slice or group pair rather than per package pair.

All import checks take these options:

| Option | Meaning | |---|---| | exclude | Regular expressions, as source text, for paths relative to the workspace root that are left out of the graph: neither a dependant nor a dependency. node_modules directories when omitted. Setting it replaces that default. A pattern that does not compile fails when the section loads. | | doNotFollow | Regular expressions for paths whose files can be imported but whose own imports are not followed, so build output is a target of imports and never a dependant. dist directories when omitted. Setting it replaces that default. A pattern that does not compile fails when the section loads. | | tsConfig | A tsconfig for path aliases and other module resolution. |

How an import is attributed to a package, and the limits of that:

  • An import that dependency-cruiser resolves to a file counts against the package whose directory holds the file. A relative import resolves without an install, a path alias needs tsConfig (paths without baseUrl resolves relative to that tsconfig, wherever the command runs), and a package name resolves through the node_modules link pnpm makes for a workspace package and the package's exports or main.
  • An import of a workspace package by its name (or a subpath of it) that resolves to no file, because the workspace is not installed or the package's entry point is not built, is attributed to the package by name. An import that neither resolves nor names a workspace package, such as an alias without tsConfig that is not a package name, is not seen, so a check can look cleaner than the workspace is; give tsConfig when aliases are used.
  • import-cycles follows files, so it cannot follow an import that resolves to no file. It treats packages that import each other by such names as a cycle between the packages, and finds it only when every import in the ring is of that kind; a ring that mixes such imports with imports that resolve to files is not seen until the workspace is installed and built.
  • Type-only imports and dynamic import() count, because a type dependency is still a dependency.
  • An import of a package that the importer declares only in package.json fields the layout's dependencyFields does not read (dependencies alone by default, so a devDependencies entry by default) is not judged by import-uphill, import-rank-skip, import-cross-slice or import-isolated-groups, whether it is written as the package name, a relative path or an alias. The ESLint workspace rules read the same dependencyFields and never see such a declaration, so a test that imports a package declared for tests is exempt from both, and widening dependencyFields to ['dependencies', 'devDependencies'] brings it into both. A package declared in a field that is read, in any field alongside, or not declared at all is judged, since an undeclared import is what this tool exists to catch. import-cycles does not apply this: a ring of imports between files is a cycle whatever the manifests declare, and a file-level cycle that runs through a test file is one only when another file imports that test file.
  • Test and tooling files inside a package are otherwise part of the package. Use exclude for paths that may cross layers.
  • dependency-cruiser supports TypeScript below 7. With a TypeScript it cannot load, or none, every import check fails with a configuration error and exit status 2 instead of reporting a clean workspace.

| Check | Verifies | |---|---| | import-uphill | No package imports a package of a strictly higher rank. | | import-rank-skip | No package imports one more than rankSkip.maxDistance ranks below it, except an exempt rank. Needs rankSkip in the layout. | | import-cross-slice | No package imports a package with a different slice. A package without a slice is in none and is never restricted. Needs a slice in the layout. | | import-isolated-groups | No package imports a package in a group that isolatedGroups pairs with its own, in either direction. Needs isolatedGroups. | | import-cycles | No files of the workspace packages import each other in a cycle, and no packages import each other by names that resolve to no file. Each cycle is reported once, starting from its smallest path, and two different rings over the same files are two cycles. It includes type-only cycles. |

The ESLint rules and this tool overlap deliberately on rank, rank skip, slice and isolation; ESLint gives instant feedback on declared dependencies and this tool catches the imports that bypass a declaration.

Type graph

The type-graph checks build a ts-morph program from the files their options select and what those import, with compiler options from tsConfig (default tsconfig.json; its include is not used). ts-morph bundles its own TypeScript, so it does not follow your compiler version. Imports must resolve, which means dependencies need to be installed: a schema imported from an uninstalled package has no type.

aggregate-mappers verifies that every aggregate type a contract file exports has a mapper file where a template says.

| Option | Meaning | |---|---| | contracts | Globs of contract files. Every exported interface and type alias in them is an aggregate. | | mapper | Path template for one aggregate's mapper. Placeholders: {name}, {dir} (the contract's directory), {file} and, with adapters, {adapter}. .. segments are resolved. | | adapters | Optional glob template (with {dir} and {file}) for adapter directories. Each aggregate then needs a mapper in every matched adapter, and a glob that matches nothing is itself a violation. | | exclude | Names of exported types that are not aggregates. | | tsConfig | See above. |

Codes: aggregate-mappers/missing-mapper (reported at the aggregate's declaration), aggregate-mappers/no-adapters. Limits: it checks that a file exists, not what is in it; a type re-exported by the contract counts as an aggregate, and a type re-exported under another name (export type { Created as CreatedAlias }) counts once for each name it is exported as, each needing a mapper, so helper types and aliases need exclude; a type in an exported namespace, or in a module re-exported as a namespace (export * as ns), is an aggregate under its qualified name (ns.Type, which is also what {name} and exclude use); a placeholder with no value, a glob that matches no contract and a missing tsconfig are configuration errors, which name the option (checks.aggregate-mappers.contracts) and not the value written in it.

codec-pairs verifies that every encoder a codec file exports has the decoder its name implies, exported by the same file, and every decoder the encoder. A name is an encoder or a decoder when it fits that option's template: with encoder: 'encode{name}' and decoder: 'decode{name}', encodeInvoice needs decodeInvoice and decodeReceipt needs encodeReceipt. It checks only that both halves exist; that each pair round-trips is a property test (decode after encode gives back the value) and stays the repository's own, since a type graph cannot run code.

| Option | Meaning | |---|---| | codecs | Globs of codec files. Each file is judged on its own exports, re-exports included. | | encoder | Name template of an encoder, such as encode{name} or {name}Encoder. {name} appears exactly once, beside other text, and is the only placeholder, or the section fails to load. | | decoder | Name template of a decoder, in the same form and different from encoder. | | exclude | Names of exported encoders and decoders that need no counterpart, such as an encoder for a format that is only ever written. | | tsConfig | See above. |

Codes: codec-pairs/missing-decoder and codec-pairs/missing-encoder, each reported at the declaration of the half that exists. An export whose name fits neither template is not a codec and is ignored, not reported, so a codec file may also export its helpers, schemas and types without listing them in exclude; exclude is for a half that deliberately has no counterpart. Limits: only values count (functions, constants, destructured constants, classes and enums), so an interface or type alias whose name fits a template is ignored; the counterpart is looked up by name among the same file's exports, not by signature, and a value re-exported under another name counts under each name it is exported as; a value in an exported namespace, or in a module re-exported as a namespace (export * as ns), is judged under its qualified name (ns.encodeInvoice, which is also what exclude uses) and pairs only with a counterpart in the same namespace; a glob that matches no codec file and a missing tsconfig are configuration errors.

command-types verifies that every command type is derived from a schema and not written by hand. An exported alias must apply a generic named in inferences (infer, input, output, TypeOf, InferInput and InferOutput by default, the last name of the reference, so z.infer and Valibot's v.InferOutput both count) to typeof schema, where the type of schema has the ~standard member every Standard Schema has. The schema is recognised by type, so it may be imported or renamed and need not come from Zod. The generic may be imported under another name (import type { infer as Infer }), and an alias may stand between the export and the inference (type Base = z.infer<typeof schema>; export type Created = Base;): a local alias without type parameters is judged by the type it is written as. An alias with type parameters is not followed and is hand-written.

| Option | Meaning | |---|---| | commands | Globs of files that declare commands. Every exported interface and type alias in them is a command. | | exclude | Names of exported types that are not commands, such as a union of them. | | inferences | Replaces the default list of generics. | | tsConfig | See above. |

Codes: command-types/hand-written-interface, command-types/hand-written-alias (an alias that applies none of the generics, including a union or a plain object type) and command-types/not-a-schema (an inference applied to something whose type has no ~standard member). Limits: a union of commands is a hand-written alias unless excluded; a type exported from several listed files is judged once; only interfaces and type aliases are judged, including those inside an exported namespace or a module re-exported as a namespace (under their qualified names), so a class, enum or value is not a command here; when the schema library is not installed the schema's type does not resolve and the alias reports not-a-schema.

derived-types verifies that the types a file exports are derived from the schemas they belong with, for each configured pair of schema files and type files. It covers what command-types does not: a type written by hand next to the schema it should be inferred from, a table row type that should come from the table definition, an error type that should come from its error schema. An exported type is derived when somewhere in its definition a generic named in inferences is applied to typeof schema (z.infer<typeof order>, InferSelectModel<typeof users>), or a member named there is read from it (typeof users.$inferSelect), where schema is declared in one of the pair's schema files, through any imports and re-exports. The definition is followed through aliases and interfaces without type parameters, in any file, so an alias chain across files counts when it ends in an inference; through the type arguments of other generics (Partial<Order>); through an intersection with at least one derived member, a union whose members are all derived (null and undefined aside), arrays, type operators, indexed access and the extends clauses of an interface. A type literal is hand-written whatever its members are, and so is a schema passed to a generic that is not an inference (Wrapper<typeof order>) or an inference of a schema declared outside the pair's schema files.

| Option | Meaning | |---|---| | pairs | A list of { schemas, types }, each a list of globs: every exported interface and type alias in the types files must be derived from a schema declared in the schemas files. A type file listed by several pairs is judged against each. | | exclude | Names of exported types that are deliberately written by hand. | | inferences | Replaces the default list of last names that count as inference: those of command-types plus Drizzle's InferSelectModel, InferInsertModel, $inferSelect and $inferInsert (DEFAULT_DERIVED_TYPE_INFERENCES). | | tsConfig | See above. |

Code: derived-types/not-derived, reported at the type's declaration. Limits: a schema is recognised by where it is declared, not by its type, so anything in a schema file passed to an inference counts; a tuple type is not followed and is hand-written; a type exported from several type files of one pair is judged once; types inside an exported namespace or a module re-exported as a namespace are judged under their qualified names, which exclude also matches; a glob that matches no file and a missing tsconfig are configuration errors naming the option (checks.derived-types.pairs.0.schemas).

File tree and cross-config

instruction-symlinks verifies that agent instruction files are symbolic links to the README beside them (written in any form that resolves there, such as ./README.md, or through other tracked links, as CLAUDE.md to AGENTS.md to README.md), as git records them: mode 120000 in the index, not the working tree, so a checkout with core.symlinks false, which writes links out as plain files, is judged by what is committed. Options: files (AGENTS.md and CLAUDE.md), directories (globs, .), target (README.md). Codes: instruction-symlinks/missing (not tracked, in a directory whose README is tracked; a directory without one has nothing for a link to point at and is not asked for one), not-a-symlink, wrong-target (the link does not resolve to the README, including a loop or an absolute target), dangling (the target is not tracked). It needs a git working tree and reads the index, so a link that is staged counts before it is committed.

single-storybook verifies that at most one Storybook exists, in one permitted directory (the workspace root by default, or location), and none inside a package such as a UI package. A Storybook is a .storybook directory; one anywhere else is single-storybook/misplaced. exclude (globs) leaves paths out of the search, such as test fixtures; a directory name (test/fixtures) leaves out everything under it, as test/fixtures/** does. A Storybook that is started only from a package.json script or a dependency, without a .storybook directory, is not seen, and a fixture or template directory that holds one is reported unless exclude names it.

commit-types verifies that the commit types commitlint accepts are accounted for in the semantic-release config. Options: commitlint (commitlint.config.ts) and release (release.config.ts). Both files are evaluated through the shared jiti loader, so a list derived from one shared constant compares as its values. The alias and fsCache of runChecks' configFiles apply to them as to the sections; trust and the per-shape unified and standalone layer options (merge and presetSchema) are for extends and do not. A commit type that no conventional preset knows (PRESET_COMMIT_TYPES lists the ones that do) must have a releaseRules entry in the @semantic-release/commit-analyzer options and, when presetConfig.types of @semantic-release/release-notes-generator is set, a changelog section. Every releaseRules type and every changelog type must be a commit type, or it can never apply. A preset type may be left out of either list, which is how a release rule list normally omits the types that do not release. Codes: commit-types/no-release-rule, release-rule-not-a-commit-type, no-changelog-section, changelog-section-not-a-commit-type, no-type-enum (a type no preset knows is listed but commitlint sets no type-enum, so the commit types cannot be read) and missing-config. Without presetConfig.types (or without a release notes generator) no changelog sections are listed, so none is required. Limits: type-enum is read as [level, 'always', [types]], the only accepted types; 'never' forbids the types it lists (a release rule or section for one is reported) and enumerates none, so nothing is required of any other type, and level 0 accepts every type; any other shape of the rule fails the check; releaseRules given as a module path (a string, which semantic-release supports) is not read and fails the check; types that come only from an extended preset are not read; rules that name no type ({ breaking: true }) are ignored; a changelog list that omits a preset type is not reported; the configs are executed, so they must be trusted.

engines-floor verifies that the engines.node range of each package is a subset of the engines.node range of each dependency and optional dependency it lists, and of each peer dependency whose range admits a single version, as installed, so the package admits no Node version that something it needs at run time rejects. A floor comparison is not enough: a package declaring >=20 over a dependency requiring ^20.19.0 || ^22.13.0 || >=24 admits 20.0 to 20.18, 21 and 23, which the dependency rejects, so the test is semver.subset(package, dependency). Option: packages, globs of the package directories in the form of the packages list of pnpm-workspace.yaml (a ! pattern excludes, . is the root package); the root package and the packages of pnpm-workspace.yaml, when it exists, when omitted. Codes: engines-floor/wider-than-dependency (the range admits a version the dependency rejects) and no-engines (the package declares no range, so admits every version, while a dependency declares one), each reported at the package's package.json and naming the dependency and both ranges. A dependency that declares no range constrains nothing and is ignored. devDependencies are not judged: a consumer never installs them, and the Node the development toolchain needs is the repository's own concern, not a claim the published package makes. A dependency is found the way Node finds a package directory, in node_modules of the package's directory and then of each directory above it up to the working directory, following the symbolic links pnpm installs; it reads the dependency's package.json directly rather than resolving an entry point, since an exports map need not expose package.json. A peer dependency is judged only when its range is a single version (1.2.3 or =1.2.3), by the installed copy, which must be that version or the run fails. The consumer, not the package, chooses which version of a peer to install, so the package's range holds when each Node version in it is accepted by some version the peer range admits. The copy installed in the workspace is one of those versions: if it rejects a Node version the package admits, another admitted version may accept it (^9.0.0 || ^10.0.0 with cosmiconfig 10 requiring ^22.18 || >= 24 while cosmiconfig 9 accepts 22.13, and equally an earlier minor of a single major), and the versions that are not installed cannot be read without the registry, so judging the range by that copy reports packages that are correct. A range of one version leaves the consumer no choice, so its installed copy decides; a peer that is not judged need not be installed. A dependency that is not installed fails the run with a configuration error, because its range cannot be read; an optional dependency, or a peer dependency marked optional in peerDependenciesMeta, may be absent and is then skipped. A range semver cannot parse, in the package or a dependency, and options that select no package also fail the run. Limits: semver compares a range of several comparator sets set by set, so each set of the package's range must lie within one set of the dependency's, and a set that two adjacent dependency sets only cover together (>=22 against ^22.0.0 || >=23) is reported until it is split the same way; a peer dependency whose range admits more than one version is not judged at all, so a package can claim a Node version that no admitted version of a peer accepts; prerelease Node versions are not considered.

dockerfile-package-manager verifies that a package manager version pinned in a Dockerfile is the one packageManager names, ignoring a +sha suffix on either side. Options: dockerfiles (Dockerfile, Dockerfile.* and *.Dockerfile at any depth), exclude (globs left out of the search) and packageJson (package.json). Only pins of the package manager packageManager names are compared: [email protected] on a RUN line is compared, npm@latest is not. Codes: dockerfile-package-manager/version-mismatch (with line and column) and no-package-manager. A version that refers to a variable (pnpm@${PNPM_VERSION} or pnpm@$PNPM_VERSION) is compared with the value an ARG or ENV earlier in the same file gives it. Limits: it reads <manager>@<version> as written on a line that is not a comment; a variable the file gives no value (an ARG without a default, whose value the build supplies) or whose value refers to another variable is not compared; the value in effect is the latest one set before the pin, whichever build stage set it; and an unpinned pnpm@latest is a mismatch.

migrations-directory verifies that the directory a schema generator writes migrations to is the directory the deploy tool applies them from, and that no package.json script applies them with the generator in place of the deploy tool. It is tool specific, so the generator and the deploy tool are named in the options, each has an adapter that knows its config file names and where in the config the directory is, and the supported pair is drizzle-kit (out of drizzle.config.ts, .js or .json, drizzle when unset) with wrangler (migrations_dir of a d1_databases entry of wrangler.json, .jsonc or .toml, migrations when unset). Both directories are resolved against the directory of the file that sets them, and .. segments and trailing separators are normalised. Adapters are two small interfaces (GeneratorAdapter and DeployToolAdapter, with generators and deployTools as the registries), so another tool is one object and an entry in generatorNames or deployToolNames.

| Option | Meaning | |---|---| | generator | The generator, drizzle-kit. | | deployTool | The deploy tool, wrangler. | | generatorConfig | The generator's config file. The first of the adapter's default names that exists when omitted. | | deployConfig | The deploy tool's config file; the defaults are wrangler.json, wrangler.jsonc and wrangler.toml, in the order wrangler prefers them. | | database | The binding of the D1 database the generator writes for. Required when the deploy config declares more than one. | | environment | A named wrangler environment to read (env.<name>) instead of the top-level settings. | | packageJsons | Globs of the package.json files whose scripts are searched. Every package.json at any depth when omitted. |

Codes: migrations-directory/mismatch (reported at the generator's config), migrations-directory/no-database (the deploy config declares no database, or none with the binding named), migrations-directory/missing-config, and migrations-directory/generator-apply-command (reported at the script, with its position; a script running drizzle-kit migrate, which drizzle-kit push and generate are not). The generator's TypeScript config is evaluated, so it must be trusted; the deploy config is parsed as data. A config that cannot be read, or a deploy config with several databases and no database, fails the run with a configuration error and exit status 2 instead of guessing.

False positives and blind spots:

  • One generator and deploy config pair is checked per run. A workspace with several pairs (a package per database) needs the library call once for each, with the pair's generatorConfig, deployConfig and database.
  • Several databases: the generator's output is compared with the one database named by database, so the other databases are not checked at all, and the check cannot tell which database a generator config is for without being told.
  • Environments: wrangler does not inherit d1_databases into an environment, so each environment has its own migrations_dir. Only the top level, or the one environment, is compared; a divergence in another environment is not seen.
  • Directory resolution: wrangler resolves migrations_dir against the directory of its config file, but drizzle-kit resolves out against the directory it is run from. The check assumes drizzle-kit runs from the directory of its config, so a script that runs it from elsewhere (--config pointing into another directory) can differ from the answer here.
  • Config variants: only the directory searched is looked at, whereas wrangler also finds a config in a parent directory and follows a redirected config; a repository with both wrangler.json and wrangler.toml is judged by the first, and a migrations_pattern that narrows which files are applied is not read. A generator config that computes out from the environment is judged by its value in the process running the check.
  • Scripts: a script is matched by the text of the command, so drizzle-kit migrate run against a local database for tests is reported, and a command assembled in a shell variable or run from a file the script calls is not seen. Narrow packageJsons to exempt a package.

ESLint

eslint proves that the repository's own ESLint is applied. Every other lint rule runs inside ESLint, so none of them can notice a config that dropped a shared preset, switched its rules off or narrowed ignores until nothing is linted; the repository then looks configured and enforces nothing. This check asks ESLint itself, through its Node API, and carries no ESLint config of its own: a flat config holds plugin objects, a parser and functions, and editors and CI expect eslint.config.* where it already is.

ESLint (^9.0.0 || ^10.0.0) is an optional peer dependency, needed only by this check. It is resolved from the directory the checks run in, the way a module there would find it, and not from this package, so the answer is the one the repository's own lint script gets. When eslint does not resolve from there the run fails with a configuration error. When ESLint finds no config file for a sample the check reports eslint/no-config and does not substitute one.

| Option | Meaning | |---|---| | samples | Required. One file per kind of source the repository lints (src/index.ts, package.json, eslint.config.ts), as a path or { path, rules }. A path need not exist: ESLint resolves a file's configuration from its path alone. | | rules | Rules required for every sample, each with the severity it must have at least: warn is met by warn or error, error only by error. Rule entries in the form [severity, ...options] and numeric severities are read as their severity. A sample's own rules are added to these and win. | | configFile | A config file to judge instead of the one ESLint finds from the working directory. | | lint | Also lint the workspace and report every message it produces. Off when omitted. | | lintPatterns | What lint lints, as file and directory patterns. . when omitted. |

The default level lints nothing. For each sample it calls calculateConfigForFile, the same call the CLI's --print-config makes, which applies files and ignores. A file that is ignored, or that no configuration block matches, resolves to no configuration (isPathIgnored is that call returning nothing), and that is eslint/not-linted. Otherwise each required rule must be configured at the required severity: eslint/rule-missing, eslint/rule-off or eslint/rule-too-weak, each naming the rule and the file.

With lint the check also lints the workspace through lintFiles and maps every message to a violation at its position, or at the file alone when ESLint gives none, as for a parsing error that typescript-eslint's project service raises for a file outside every tsconfig. The code names the rule, eslint/lint/<rule id>, so eslint/lint/no-console and eslint/lint/@scope/plugin/rule are stable; a parsing error is eslint/fatal and a message with no rule, such as an unused disable directive, is eslint/lint-message. A pattern that matches no file, or only ignored files, is eslint/nothing-linted. Warnings are reported as well as errors, since a repository that fails its lint script on warnings (--max-warnings 0) is judged by what that script reports.

A clean lint says little without how much was linted, so with lint the check adds a note to its result (notes of its entry in runChecks' results, a line on standard output on the command line): how many files it linted and, when lintPatterns leave some out, how many other files below the working directory ESLint has a configuration for (not ignored, and matched by a configuration block) that the patterns did not reach. That second number is ESLint's own answer for each file, not a threshold. It does not see files that the config itself leaves out: a root config that ignores packages/** because each package lints itself with its own config lints only the root's files, and the note then counts only those.

Type-aware rules read the types of imports, so lint a workspace that is installed and built, as its own lint script does: an import of a workspace package whose declarations are not built resolves to nothing, its types are any, and every use of it is a no-unsafe-* finding that the built workspace does not have.

Limits:

  • An external config is a black box. The check sees which rules are active on a file, not why, so it cannot tell a rule enabled by a shared preset from one written locally. Require the rules that matter and let the repository decide how it gets them.
  • It reads the config ESLint resolves for each sample, and a sample stands for the files like it. A rule can be off for one directory the samples do not cover, so choose a sample for each kind of source and each directory with its own override.
  • An ignored file and a file that no configuration block matches both resolve to nothing and are reported the same way.
  • Only rule severities are compared, not rule options.
  • Flat config only: the ESLint 9 and later ESLint class. Legacy .eslintrc configuration is not read.
  • A config that is a TypeScript file needs whatever ESLint itself needs to load it (jiti), installed where ESLint is.

Workflows

The workflow-* checks read GitHub Actions workflow files and run offline. Each file under .github/workflows is parsed into a model of its events, jobs, the transitive needs graph, if conditions, effective permissions (a job's own declaration replaces the workflow's, it is not merged with it) and effective environment (workflow, then job, then step), and the checks read that model. A file that is not YAML, or is not shaped like a workflow, fails the run with a configuration error instead of being skipped. Expressions (${{ ... }}) are never evaluated: a value that is an expression is treated as unknown, and a check that needs a literal ignores it.

Every workflow check takes workflows (globs of the files to read, .github/workflows/*.yml and .github/workflows/*.yaml by default) and exclude (globs to leave out), except workflow-update-bot-cooldown, which reads the update bots' own configs. Jobs are named by id in the options. These checks live here and not in @exadev/eslint-config because they need the cross-job graph that a single-file lint rule cannot see (see ExaDev/eslint-config#78). Security audits of workflow files (dangerous triggers, token scopes, expression injection) stay with an external auditor such as zizmor or actionlint and are not reimplemented.

| Check | Verifies | Codes | |---|---|---| | workflow-job-ordering | A release job needs every deploy job; a documentation deploy job needs every release job and checks out the default branch afresh; a junction job has if: always(), lists every other job in needs and fails only on failure and cancelled. | release-before-deploy, docs-deploy-before-release, docs-deploy-stale-checkout, junction-not-always, junction-missing-need, junction-fails-on-skipped, junction-no-failure-condition | | workflow-skippable-jobs | A path filter does not leave a required check pending or skip a job that no junction job reports for. | trigger-path-filter, job-gated-by-path-filter | | workflow-runner-resolution | A custom or self-hosted runner label is not named literally in several jobs; a resolver job has a timeout-minutes and its output a literal hosted fallback. | repeated-label, resolver-no-timeout, resolver-no-fallback, resolver-fallback-not-hosted | | workflow-version-single-source | A setup step does not hold a literal runtime version when .nvmrc or .tool-versions already does. | literal-version | | workflow-credentials | Attestation steps have id-token: write and attestations: write; a publish that passes no token blanks the token variables it reads, or at least requests provenance so that a fallback publish is traceable. | attestation-permissions, tokenless-publish-unprotected | | workflow-release-job | The release job is the only job granted id-token: write, runs in a deployment environment and checks out with persist-credentials: false; no job holding id-token: write passes an npm token from a secret. | id-token-workflow-level, id-token-outside-release, missing-environment, persisted-credentials, token-beside-oidc | | workflow-repository-dispatch | A repository_dispatch handler does not push to the default branch, auto-merge without a required-checks rule, push or open pull requests as GITHUB_TOKEN, or dispatch to another repository with it. | pushes-default-branch, auto-merge-without-required-checks, default-token-triggers-nothing, dispatch-other-repository-with-default-token | | workflow-update-bot-cooldown | Dependabot has a cooldown in every update entry, and Renovate sets minimumReleaseAge. | dependabot-no-cooldown, renovate-no-minimum-release-age | | workflow-merge-group | A workflow that runs for pull_request also has a merge_group trigger, when the branch has a merge queue (offline: always). | missing-trigger | | workflow-action-pinning | Every uses follows the pinning policy. | not-pinned, docker-not-pinned, missing-ref |

Codes are <check>/<code>, so workflow-job-ordering/release-before-deploy. The options and the limits of each check:

workflow-job-ordering takes releaseJobs (release), deployJobs (deploy), docsDeployJobs (docs-deploy), junctionJobs (required-checks), junctionExempt (jobs the junction job need not wait for) and defaultBranch (main). A role with no job in a workflow is not judged there. Ordering is judged through the transitive needs graph, but the junction job must list each job directly, because needs.*.result covers only direct needs; a job that itself needs the junction job is never required of it, nor is a job that never runs for a pull request (an if that compares github.ref, github.ref_name or github.event_name, mentions neither pull_request nor merge_group; in an && one such operand is enough, in an || every operand must be one, since the merge queue is where the required check decides the merge and a disjunction can hold for a pull request through another operand), a job that needs such a job, or one named in junctionExempt. A documentation job's checkout is correct when its ref is ${{ github.event.repository.default_branch }} or the default branch written literally; no ref means the triggering commit, which predates the release commit. The junction job's pass condition is read as text, with each environment variable its scripts ($NAME, ${NAME}) and conditions (env.NAME) read replaced by the expression that sets it, so RESULT: ${{ needs.test.result }} tested as "$RESULT" != "success" reads as the test of needs.test.result it is. It must read the results of its needs (needs.*.result, needs.<job>.result or toJSON(needs), including a join(needs.*.result, ' ') looped over in a script) and either name both 'failure' and 'cancelled' or test a result for != 'success', which fails on both, or use the re-actors/alls-green action. A test that names 'skipped' is reported as failing on a skip, because a skipped job is how a path filter or an if keeps a job out, and so is a != 'success' test of a job that an if other than always(), its own or that of a job it needs, can skip unseen: unseen unless that if reads job outputs and the junction reads every one of them too, as a junction that tests a has-packages output before the result of the job skipped on it does. A job no such if can skip is skipped only when a job it needs fails, which the junction rightly fails on. A decision the text does not show is reported as incomplete.

workflow-skippable-jobs takes junctionJobs and pathFilterActions (dorny/paths-filter, tj-actions/changed-files, step-security/changed-files). A paths or paths-ignore filter on pull_request or pull_request_target skips the whole workflow, so a required check from it stays pending; it is reported when the workflow has a junction job, the sign that it produces a required check. A job whose own if reads the outputs of a job that runs a path filter action is skipped; it is reported unless a junction job lists it in needs, and the fix is to put the condition on its steps, so the job still runs and reports. Limits: a workflow without a junction job is not judged for its trigger filter; a script that computes changed paths itself is not recognised as a path filter; only the filters of the pull_request and pull_request_target triggers are read, so a paths filter on push is not reported; only needs.<job>.outputs in a job's own if is read.

workflow-runner-resolution takes hostedLabels, regular expressions for the labels of hosted runners: by default GitHub's standard ubuntu, windows and macos images and their -latest, versioned, -arm, -intel, -large, -xlarge and -slim forms, exported as DEFAULT_HOSTED_LABELS. Setting it replaces the default, as every list option here does, which also lets a repository drop an image it never uses; to add a third-party hosted runner such as Blacksmith to GitHub's own, extend the default: hostedLabels: [...DEFAULT_HOSTED_LABELS, '^blacksmith-']. A label that matches none, or a runner group, named literally in more than one job of a workflow is reported at each such job: resolve it once in a resolver job and read it with runs-on: ${{ fromJson(needs.<resolver>.outputs.<name>) }}. Each resolver (the job whose output a fromJson in a runs-on reads) must have timeout-minutes, so a runner that never starts does not hold the jobs waiting for it for the default six hours, and its output must carry a literal fallback (|| '["<label>"]') whose labels all match hostedLabels, since a fallback to another self-hosted label does not help when the fleet is down; the violation names each fallback label that matches no pattern. A resolver that is a call of a local reusable workflow is followed through on.workflow_call.outputs to the job that sets the output, and the findings are reported there; one in another repository cannot be read and is not judged. Limits: labels are compared within one file, not across files; a larger runner is named by a custom label, so it counts as one; an output's fallback is the first operand after the first of its expression's top-level || chain whose value is known before the run and not empty: a string literal, or format() of literals and of inputs of the reusable workflow the resolver is in, each read from the literal its caller passes in with or else from the input's default (so format('["{0}"]', inputs.fallback-label) with a literal default counts); an input passed as an expression, or with no default, is not known, and an output with no such operand has no fallback. The fallback is read as JSON; one that is not valid JSON counts as not hosted.

workflow-version-single-source recognises the setup actions for Node, Python, Go, Java, .NET, Ruby, Bun, Deno, pnpm and Terraform. A step is reported when it gives the version input a literal while .nvmrc (for Node) or .tool-versions (for any tool it lists, by its asdf name) exists in the working directory; the message names the input that reads the file. A version containing an expression, such as a matrix value, is not a literal. An alias such as lts/* is. Limits: only the actions listed are recognised, only the root files are looked for, and composite actions are not read.

workflow-credentials: an attestation step (actions/attest, actions/attest-build-provenance, actions/attest-sbom) needs id-token: write and attestations: write in the job's effective permissions. Permissions declared nowhere are the repository's default, which the file cannot show and which does not grant id-token, so they are reported. A job with id-token: write that runs a publish command (npm, pnpm or yarn npm publish, with options such as -r or --filter <package> before publish, semantic-release, changeset publish, lerna publish, bare or behind pnpm, pnpm exec, yarn or npx) and passes no token must set every token variable the publish reads to the empty string (at step, job or workflow level) or request provenance (--provenance, NPM_CONFIG_PROVENANCE: true, or publishConfig.provenance: true in the package.json of the working directory); provenance does not stop an inherited token being used, it makes that publish traceable. npm reads no token variable by name: a variable reaches it only through ${NAME} in the value of an .npmrc setting, and otherwise only variables named npm_config_* are configuration (npmrc, archived; config, archived). So a variable is required to be blanked only where the check sees something read it, and the violation names each such variable and its reader: NODE_AUTH_TOKEN after an actions/setup-node step with registry-url, whose .npmrc refers to it; NPM_TOKEN for semantic-release, whose npm plugin (lib/set-npmrc-auth.js of @semantic-release/npm) reads it when its own exchange fails; and every variable the auth settings (_authToken, _auth, _password) of the .npmrc in the working directory refer to. A tokenless publish that nothing visible reads a token for, such as pnpm publish with no registry-url and no .npmrc reference, is not reported. Limits: an .npmrc the workflow does not show (one in the runner's home directory, or written by a script), a Yarn .yarnrc.yml and a token set through an npm_config_ variable are not read; commands are read as text where a command starts, so a script file or composite action is not seen; provenance set in another package.json is not read; a job is tokenless only if no token variable has a non-empty value anywhere in it; a job that calls a reusable workflow is not read. registry-url is deliberately not reported, see below.

workflow-release-job takes releaseJobs (release, the same default as workflow-job-ordering). npm binds a trusted publisher to the repository and the workflow file, not to a branch (trusted publishers, archived), so anyone who can push a branch can edit that branch's copy of the workflow, remove the if that keeps publishing to the default branch, and publish. The protection has to live outside the file: a deployment environment whose deployment branch policy allows only the default branch, named as the environment of the trusted publisher on npm, so an OIDC token minted anywhere else is refused. In a workflow with a release job, id-token-workflow-level reports workflow-level permissions that grant id-token: write (directly or through write-all), since every job that declares none inherits it, and id-token-outside-release reports any other job that declares it, a job that calls a reusable workflow included, because the OIDC identity is the publishing credential and only the release job should be able to mint it. missing-environment reports a release job with no environment (a name, or a mapping with a name). persisted-credentials reports a release job's actions/checkout that does not set persist-credentials: false literally, since the job token, a token input or an ssh-key would otherwise stay in git config for every later step, the dependency install and third-party actions included. There is no exception for a release that pushes: semantic-release pushes to its repositoryUrl as written and, only when that fails, to the same URL with GITHUB_TOKEN or GH_TOKEN from the environment embedded (lib/get-git-auth-url.js of semantic-release), so it never needs the checkout's credentials. A deploy-key release job, the shape ExaDev's own release jobs use, checks out with persist-credentials: false, writes the key to a file in the step before the release, and hands it to git only through the GIT_SSH_COMMAND in the env of the steps that push; it is accepted as written, and the clean fixture holds one. In every workflow, token-beside-oidc reports a job whose effective permissions grant id-token: write and whose workflow, job or step env sets NODE_AUTH_TOKEN or NPM_TOKEN from the secrets context: a long-lived token beside the OIDC identity is a second way to publish that no environment protection or trusted publisher setting covers. Limits: the environment's protection rules and the npm trusted publisher settings cannot be seen in the file and are not read (missing-environment checks only that an environment is named); a release job that calls a reusable workflow is judged only for id-token, since environment and the steps belong to the called workflow; a token passed through with, written to an .npmrc by a script, or set inside a composite action is not seen.

What zizmor already covers, and what this check adds, as observed with zizmor 1.30.1 on this check's fixtures (audit rules, archived): excessive-permissions reports a workflow-level id-token: write, but not id-token: write declared by a second job, which only undocumented-permissions mentions when the grant has no comment. artipacked reports every actions/checkout without persist-credentials: false, in every job and every persona, at help severity for actions/checkout v6 and later, which keeps the credentials under $RUNNER_TEMP rather than in .git/config; this check reports it as a violation in the release job, where a step that reads git config has the publishing job's push credential. secrets-outside-env (auditor persona only) reports a secrets reference in a job with no environment, so it catches a missing environment only when the release job reads a secret; a tokenless OIDC release job with no environment produces no zizmor finding at all. use-trusted-publishing reports an npm publish that uses a token, but is silent once the job also has id-token: write, so an NPM_TOKEN from a secret beside id-token: write in a job with an environment produces no zizmor finding. No zizmor audit relates the grants of one job to the others in a workflow, or requires an environment for the job that holds id-token: write.

workflow-repository-dispatch takes defaultBranches (main and master; the github.event.repository.default_branch expression always counts) and assumeRequiredChecks. It reads workflows with a repository_dispatch trigger, which anyone with write access can start through the API. pushes-default-branch reports a git push whose destination is the default branch (a leading + is ignored, a bare HEAD is the checked-out branch, quotes around a word are removed, git -C <directory> and git -c <setting> before push are read, and options that take a value such as -o are skipped), a push with no refspec while the default branch is checked out (no git checkout -b or git switch -c earlier in the job, no non-default ref on the checkout) and stefanzweifel/git-auto-commit-action on the default branch. auto-merge-without-required-checks reports gh pr merge --auto, which merges at once when the base branch requires no checks; the file cannot show the rule, so assumeRequiredChecks: true states it and settings-required-checks verifies it. default-token-triggers-nothing reports a push whose checkout kept the default token (no token input; a push to a URL that carries its own credentials does not use it), gh pr create with GITHUB_TOKEN and peter-evans/create-pull-request with the default token: events created with GITHUB_TOKEN start no workflows, so the result is never checked. dispatch-other-repository-with-default-token reports a dispatch (gh api repos/<o>/<r>/dispatches, gh workflow run -R, peter-evans/repository-dispatch) to a repository other than github.repository with GITHUB_TOKEN, whose access is limited to its own repository. Dispatching in the same repository with GITHUB_TOKEN is not reported, because GitHub documents workflow_dispatch and repository_dispatch as the events that do create runs when made with it (events that trigger workflows, archived). Limits: commands are read as text, so a script file is not seen; a dispatch target written literally as this repository's own name is reported, since the file does not know the name.

workflow-update-bot-cooldown takes dependabot (.github/dependabot.yml, else .yaml) and renovate (renovate.json, .renovaterc.json, .renovaterc and .github/renovate.json, the first that exists). Every Dependabot updates entry needs a cooldown with default-days or one of the semver-*-days above zero. Renovate needs minimumReleaseAge at the top level or in a packageRules entry, as a duration above zero (0 days and null wait for nothing). Limits: Dependabot's cooldown does not apply to security updates; a Renovate preset is not resolved (an extends entry counts only when its name contains minimumReleaseAge); a Renovate config is read as JSON with comments and trailing commas, so other JSON5 syntax and a renovate key in package.json are not read. A repository with neither bot has nothing to report; a file named in the options that does not exist fails the run.

workflow-merge-group reports a workflow with a pull_request trigger and no merge_group trigger: GitHub starts no run of it for a queued pull request, so its required checks never report. Whether the branch has a merge queue at all is a repository setting. When the run is given a GitHub client (runChecks({ github }), --settings), the check reads the rules of the branch, as the settings-* checks do and with the same repository and branch options, once it has found a workflow without the trigger, and reports only when a merge_queue rule applies; without a queue no merge_group event is sent, so the trigger is not needed. Offline it cannot tell, and it reports every such workflow, as it always has. The file cannot show which checks are required, so a pull_request workflow whose checks are not required is reported too; narrow it with workflows and exclude. pull_request_target is left out, since it is not a place for required checks, and a workflow with no pull_request trigger is not judged. Limits: a merge_group trigger takes only branches filters (paths is a filter of push, pull_request and pull_request_target), so a workflow path-filtered for pull requests still runs in the queue once it has the trigger, and the branches filter of a merge_group trigger is not read (workflow syntax); with a client only rulesets are read, so a merge queue configured through classic branch protection is not seen and nothing is reported. When GitHub refuses the rules because the plan of the repository has no rulesets (the refusal the settings-* checks report as rules-unavailable, and RulesetsUnavailableError from a client of your own), nothing is reported either, since the repository cannot have a merge queue: GitHub offers merge queues only in public repositories owned by an organisation and in private ones of organisations on GitHub Enterprise Cloud, which have rulesets (managing a merge queue, archived); any other refusal fails the run.

workflow-action-pinning encodes a pinning policy, and does not choose a side silently: pinning to a commit SHA protects against a moved tag or branch, and a ref lets the owner of a shared workflow roll out a fix without every caller changing. The default follows the reasoning that you do not control a third party but do control your own organisation: an action or reusable workflow from outside organisations must be pinned by full 40-character commit SHA (thirdParty: 'sha'), a Docker image by digest, and one owned by a listed organisation may use any ref (sameOrganisation: 'ref'). With no organisations configured, nothing is relaxed. thirdParty and sameOrganisation each take sha or ref, so either side of the policy can be flipped, organisations lists the owners that count as the same organisation, and allow exempts owner/repository or owner/* (for example actions/*). Local actions and workflows (./...) are not judged. Limits: it checks the form of the ref, not that the SHA belongs to the repository or to a release; composite actions are not read.

Two conflicts the checks settle

registry-url does not disable tokenless publishing, so it is not banned. actions/setup-node writes an .npmrc with //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} when registry-url is set, and since v7 exports NODE_AUTH_TOKEN only if the workflow supplied one (src/authutil.ts, archived; the README says trusted publishing is unaffected because it does not use the variable). Its own trusted-publishing example sets registry-url (docs/advanced-usage.md, archived), as does npm's (trusted publishers, archived). In the npm CLI the exchange runs in npm publish before credentials are read, and on success it overrides the registry's _authToken for the request (lib/utils/oidc.js, archived). The hazard is the failure path: the same code returns without throwing when the exchange fails, and the publish then proceeds with whatever token the .npmrc and environment give it (older setup-node majors exported the placeholder XXXXX-XXXXX-XXXXX-XXXXX, and the runner's own environment, or a GITHUB_ENV write by an earlier step, can supply NODE_AUTH_TOKEN; a secret reaches a step only when the workflow maps it into env or with, which workflow-credentials already treats as a publish that is not tokenless (using secrets)). Provenance does not close that path: npm signs it through Sigstore with the CI's own OIDC identity, separately from the registry token (libnpmpublish, lib/publish.js and lib/provenance.js), so a publish that fell back to an inherited token still goes ahead and carries an attestation. So workflow-credentials asks for the token variables to be blanked, which is the only protection, or provenance to be requested, which makes a fallback publish traceable, and says nothing about registry-url. The semantic-release npm plugin behaves the same way: it tries its own exchange first (lib/verify-auth.js and `lib/tr