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

@vwala/tooling-config

v1.4.1

Published

Shared tooling configuration for Vwala repositories

Readme

@vwala/tooling-config

Shared tooling configuration for Vwala repositories: one private repo, one npm package, one place to change a rule.

Each tool gets a folder under src/:

src/
├── _scripts/                  Convention-check runner + git helpers
│   └── check-conventions.mjs
├── agent-files/               Agent skill + brief layout policy    (consumed via npm)
│   └── _scripts/
├── biome/                     Biome config + custom GritQL rules  (consumed via npm)
│   ├── biome.config.json
│   └── plugins/
├── commitlint/                Commit-message rules + git hooks    (consumed via npm and GitHub Actions)
│   ├── action.yml
│   ├── commitlint.config.js
│   └── hooks/
├── context/                   Module domain-language policy       (consumed via npm)
│   └── _scripts/
├── gcloud/                    Local Google Cloud ADC helper       (consumed via npm)
│   └── ensure-gcp-application-default-credentials.sh
├── monorepo/                  Package-manifest conventions        (consumed via npm)
│   └── _scripts/
│       └── check-exports.mjs
├── syncpack/                  pnpm catalog policy                 (consumed via npm)
│   └── syncpack.config.json
├── typescript/                tsconfig presets                    (consumed via npm)
│   ├── base.json
│   ├── bundler.json
│   ├── node.json
│   ├── react-library.json
│   ├── nextjs.json
│   └── expo.json
└── zizmor/                    GitHub Actions security policy      (consumed via GitHub Actions)
    ├── action.yml
    └── zizmor.yml

The tools are consumed differently — npm for anything a local toolchain reads, a GitHub Action for anything only CI runs — but they version together off the same git tag.


Biome

pnpm add -D @vwala/tooling-config @biomejs/biome
{
	"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
	"extends": ["@vwala/tooling-config/biome"]
}

Anything you add alongside extends overrides the shared config for that key.

Requires Biome >= 2.5.0. The config scopes plugins with the { "path": ..., "includes": [...] } form, which Biome added in 2.5. On 2.4 and earlier the config fails to parse.

Install it next to your biome.json. Biome resolves plugin paths relative to the directory of the config doing the extending — not relative to this package. The shared config therefore refers to its own plugins as ./node_modules/@vwala/tooling-config/src/biome/plugins/*.grit, which only resolves if the package is installed at the same level as your biome.json (normally the repository root). In a monorepo that means a root devDependency, not a nested workspace one.

If the paths cannot be resolved, Biome reports Error(s) during loading of plugins: Cannot read file. — that is what a misplaced install looks like.

Rules

Biome's own rules carry the naming and export conventions. On top of its recommended preset, six GritQL plugins cover what no built-in rule expresses:

| Plugin | Enforces | | --- | --- | | no-uuid-type-assertion | No as Uuid / as unknown as Uuid — parse with uuid(), newUuid() or zUuid instead of manufacturing the brand. Production code only. | | no-vwala-class-prefix | No vwala: Tailwind prefix in apps — it only resolves inside the design system's precompiled stylesheet. Apps only. | | use-alias-imports | No relative import crossing two or more parent directories (../../) — reach it through the project's configured alias. A single ../ to a sibling folder stays fine. Warns only. | | use-given-when-then-test-names | Every test(...) title reads GIVEN … WHEN … THEN … | | use-pnpm-scripts | No npm run / npx / yarn in a script — use pnpm run, pnpm dlx. package.json only; npx in preinstall is exempt. | | use-tests-folder-location | Test files live in a __tests__/ folder, never colocated with the implementation. |

Each plugin file carries a header comment explaining the rule and linking to the governing reference in the vwala-conventions skill. The plugins have tests in src/biome/plugins/__tests__/ that drive the real Biome binary over fixture workspaces, so a rule that silently stops matching fails CI.

Naming

Two of Biome's own rules carry the naming convention, both at warn:

| Rule | Enforces | | --- | --- | | useFilenamingConvention | Filenames are kebab-case. Every dot-separated part counts, so payment.service.ts, complete-invoice.command-handler.ts, invoice.types.ts and invoice.test.ts all pass while paymentService.ts does not. | | useNamingConvention | camelCase variables and functions, PascalCase types, classes, interfaces and components, CONSTANT_CASE for module-level constants. |

They are warnings because the convention applies to new code and asks that existing files be renamed only when meaningfully touched — a repository does not have to rename its way in. useNamingConvention deliberately does not check object literal or type properties: those keys are usually dictated by an external payload rather than chosen. strictCase is off, so HTTPServer is accepted alongside HttpServer.

check-naming (see Convention checks) supplies the two halves the rules cannot express — folder names, which Biome never sees, and an error for files added since the base ref, which no static severity can express. Existing offenders are printed as warnings and leave the exit code at 0; a new file or a new folder that breaks the convention exits 1. The base ref defaults to origin/main — set NAMING_BASE_REF for a differently named default branch, and note that a shallow CI clone that cannot resolve it degrades the check to warnings only, so fetch enough history for git merge-base to work.

__tests__, __mocks__, _scripts, hidden folders, and the [slug] / (group) / @slot segments Next.js and Expo give meaning to are exempt from the folder check.

Paths under prisma/migrations/ at any depth are ignored entirely because Prisma owns each migration directory name and persists that exact identity in the database, so it cannot be renamed to kebab-case.

Exports

Entry points are declared in a package's package.json exports, not assembled by a barrel that re-exports the world. A feature's own index.ts is a different thing — an internal boundary, and one vwala-engineering-principles requires — so nothing here touches it. Three of Biome's own rules carry the syntax half:

| Rule | Level | Enforces | | --- | --- | --- | | noReExportAll | error | No export * from — it hides name collisions, defeats tree-shaking, and re-exports whatever the target adds next. Name what you re-export. | | useExportType | error | Type-only exports use export type, so a compiler can drop them without resolving them. | | noPrivateImports | error | The @package and @private JSDoc tags are boundaries, not documentation. |

All three are errors, which is a departure from the naming rules and deliberate. useExportType has a safe autofix, so biome check --write clears every existing occurrence and there is no migration to protect against. noPrivateImports cannot flag pre-existing code at all: visibility defaults to public, so a diagnostic only exists once someone writes a tag — and a tag you are allowed to ignore is a comment. noReExportAll is the one with real remediation cost, but the fix is local to the barrel and changes no importer, which is what separates it from a rename.

noBarrelFile is deliberately not enabled. It flags named re-exports too, not just export *, which would put it in direct conflict with the feature-first rule that every feature exposes an index.ts as its public boundary. The turborepo advice it comes from is about the package level — don't make consumers import one giant barrel — and that is enforced by package exports instead, where it belongs.

test-support.ts may re-export everything. A package that publishes a "./test-support" subpath needs one file to point at, so that filename is exempt from noReExportAll — the same exception check-test-layout makes when it lets that file import test code.

noPrivateImports turns on Biome's project scanner, which indexes your project and its dependencies, including paths files.includes excludes. If lint time grows, force-ignore your build output the way this config already force-ignores .agents: "!!**/dist", "!!**/.next". Note there is no exemption for test files: @package visibility already reaches a __tests__/ subfolder, and a test reaching for a @private symbol is the thing the rule is meant to catch.

Test layout boundaries

use-tests-folder-location can only check syntax — a GritQL plugin cannot see its own file path. The path-level halves of that convention — no tests/ folder, no non-test files in __tests__/, no production code importing test code — are the second of the convention checks.


Convention checks

Some conventions are about paths and manifests rather than syntax, and Biome reasons about syntax. Those live in scripts, behind one entry point:

{
	"scripts": {
		"check-conventions": "node node_modules/@vwala/tooling-config/src/_scripts/check-conventions.mjs"
	}
}

| Check | Enforces | | --- | --- | | naming | Folders are kebab-case, and a file added since the base ref is named kebab-case — the error half of the naming rules. | | test layout | The path-level halves of test layout boundaries. | | pnpm | The halves of the package manager convention Biome cannot see — lockfiles, packageManager, and the invocations in hooks, workflows and Dockerfiles. | | package exports | Every packages/** manifest declares an exports map of explicit subpaths, with no legacy main/module/types and no dangling targets — see package exports. | | agent skills | .agents/skills/ is the only copy; other agent directories mirror it with symlinks. | | agent docs | CLAUDE.md is a symlink to AGENTS.md, never a second copy. | | module context | Every module under packages/modules/ declares its domain language in CONTEXT.md. |

Wire that single script into your own preflight and new checks arrive with an upgrade, rather than as another && you have to add by hand. It runs every check even after one fails — a bad filename and a misplaced test are independent problems, and seeing both in one run beats fixing one to discover the other. Each check is still a standalone script under src/**/_scripts/ if you want to run one on its own.

Every check reads the repository through git, so run this from the repository root — and in CI, from a checkout deep enough for git merge-base to reach the default branch. With actions/checkout that means fetch-depth: 0; on a shallow clone the naming check cannot tell a new file from an old one and silently reports everything as a warning.

The three checks below read the index rather than the working tree — git ls-files --stage reports mode 120000 for a symlink on every platform, while fs.lstat reports a regular file for every symlink in a repository cloned on Windows without developer mode.

Upgrading into agent skills or agent docs will fail a repository that currently keeps a copied .claude/skills/ tree or a second CLAUDE.md. That failure is the point, but it arrives with the upgrade rather than with an edit — fix it by replacing the copies with symlinks before wiring the new version in.

Package exports

A shared package's entry points are its exports map — one explicit subpath per entry point, so an importer reaches @vwala/platform-ui/button and pulls in one file rather than a barrel that drags the whole package along.

Scope is two rules, because "must have an exports map" and "the map must be well formed" are different questions:

  • Only packages/** has to have an exports map and a scoped @org/name. An app under apps/ is bundled by Next or Expo and imported by nobody, so an entry-point map would be ceremony; the workspace root is not a package at all. Neither is ever reported for a missing one.
  • Any manifest that declares exports has its shape checked, wherever it lives — no main/module/types beside it (Node and TypeScript both resolve through exports and ignore them, so they can only go stale), keys that are real subpaths rather than conditions, no ./* wildcard, and every target resolving to a file. That is also what makes this package check its own root manifest.

Detection is by path rather than by private, because private does not separate the two — an app and an internal package are both routinely private. It is a prefix rather than packages/*/, because the layout vwala-conventions mandates nests a category: packages/<tooling|modules|platform>/<name>/.

A target is skipped when git ignores it, which is how "./dist/button.js" in a compiled package passes before turbo build has ever run. The flip side: a build output that nothing in .gitignore covers reads as a broken path.

Severity follows age, like naming: an exports map is a published contract, and migrating one means updating every importer, so it cannot be a drive-by. A manifest added or modified since the base ref is an error, everything else a warning. The "or modified" is the difference from the naming check — a filename can only be wrong the day it is created, while a manifest field can be wrong at any edit, and the commonest real breakage is a typo in a subpath added to a manifest that already existed. Set EXPORTS_BASE_REF when the default branch is not origin/main.

Three things it deliberately leaves alone. A "." root export is fine — a feature's index.ts is its documented public boundary, so a package exporting only "." is following a convention rather than breaking one, and whether a given package should have one entry point or five is a review judgement, not a pattern. "./package.json" and "./test-support" are ordinary subpaths; the latter is the export test layout boundaries explicitly sanctions. And the @vwala/platform-* / module-* / tooling-* prefix is a separate convention — only the @org/ scope is checked here.

Agent skills

.agents/skills/ is the only place a skill is stored. Every other agent directory — .claude/skills/, .cursor/skills/, and the rest — holds symlinks into it.

The failure this prevents is silent. npx skills update rewrites .agents/skills/; a materialised copy under .claude/skills/ is untouched and stays the version the agent actually loads. Nothing errors, no diff looks wrong, and the agent quietly follows guidance that was replaced months ago. A symlink cannot drift, which is why the convention is a symlink rather than a sync step.

ln -s ../../.agents/skills/vwala-conventions .claude/skills/vwala-conventions

A symlink must resolve to exactly .agents/skills/<its own name>. Requiring the exact path rather than a prefix rejects a link into another mirror, a link reaching inside a skill, and a link whose name disagrees with the skill it points at. Symlinks are also banned inside .agents/skills/ — if the source of truth is itself a link, every mirror points at nothing.

Scope is only the skills/ subtree of each agent directory. .claude/settings.json, .claude/commands/ and .claude/agents/ are genuinely Claude's own and have no .agents/ counterpart, so they are never flagged. Dot-prefixed entries such as .gitkeep are skipped too.

Mirror completeness is deliberately not checked. Agent directories are opt-in per tool, and a repository that only uses Claude should not be made to create a .cursor/ it will never read.

Agent briefs

AGENTS.md is the brief; CLAUDE.md is a symlink to it.

Two real files means two briefs. They start identical, one gets edited, and from then on the instructions a reader reviews are not the instructions the agent loaded — with no error and no diff to notice, because both files are individually valid.

Both files are optional. A repository with neither passes, and so does one with only AGENTS.md; the rule only has something to say once a CLAUDE.md exists, and then it says the two must be the same file. The pairing is checked per directory, because a monorepo puts a brief next to each app or package. Vendored briefs under .agents/ and .claude/ are out of scope — they belong to the upstream skill.

Module glossaries

Every package under packages/modules/ owns a bounded area of the business, so every one of them declares its domain language in CONTEXT.md.

Without it each module invents its own words for the same thing — one calls it an order, the next a purchase, the third a transaction — and the cost only shows up later, when the three have to talk to each other and nobody can say whether they mean the same thing. packages/platform/ and packages/tooling/ are excluded on purpose: they hold code with no domain knowledge, so they have no domain language to declare.

A module is identified by its package.json, at any depth. Modules nest (packages/modules/act/enrich/) and may be scoped (packages/modules/@acme/invoicing/), so every manifest counts — including one at a grouping level that has modules beneath it, because a directory carrying a manifest is a package and a package here is a module. Manifests below node_modules/, src/, dist/, build/, __tests__/, test-support/ or fixtures/ are skipped; a workspace package never has one of those as an ancestor, so excluding by folder rather than by depth is what keeps a fixture's manifest from registering as a module of its own.

Existence and non-emptiness are all that is checked. The convention asks for a glossary written as terms crystallise, and a checker that demanded headings and a minimum term count would only teach people to satisfy it with a heading and a fake term.


Package manager

Vwala repositories install with pnpm. use-pnpm-scripts covers the package.json scripts and the pnpm convention check covers everything Biome cannot parse — a package-lock.json, npm-shrinkwrap.json, yarn.lock or bun.lock tracked beside pnpm-lock.yaml, a missing pnpm-lock.yaml, a root package.json that does not pin "packageManager": "pnpm@<version>", and npm/npx/yarn invocations inside git hooks, workflows, shell scripts and Dockerfiles. Markdown is out of scope: documentation quotes a third party's install line as a citation, and a stale README is a review problem rather than a build-breaking one.

Four exemptions are deliberate, and all of them are load-bearing rather than courtesies:

  • npm publish, npm view, npm pkg, npm --version. npm is matched on its install and run verbs, not on the bare word, because OIDC trusted publishing needs the npm CLI — this package's own release workflow runs all four.
  • npx --no -- <bin>. It resolves the locally installed binary and refuses to fetch one, which is both the behaviour you want in a hook and what keeps zizmor's adhoc-packages rule quiet. Bare npx is still flagged, because it silently pulls from the registry.
  • npx in a preinstall script. preinstall runs before node_modules exists, so --no has no local binary to resolve and adding the tool as a devDependency does not help — the hook still runs first. That makes the canonical "preinstall": "npx only-allow pnpm" guard the one npx call the rule cannot ask you to rewrite. Only the npx half relaxes: npm and yarn in a preinstall are still flagged, and every other lifecycle hook (prepare included) runs after install and stays strict.
  • Global installs — npm i -g, npm install --global, --location=global. A runner installing a CLI writes no lockfile and resolves nothing from the project's tree, which is the entire rationale for the rule. This one is the convention check only; a package.json script that installs into the machine's global bin is still flagged, because that is a different smell from a CI step. The scan stops at the first shell separator, so npm ci && npm i -g vercel still reports the npm ci.

Do not reach for pnpm dlx only-allow pnpm. It looks like the obvious rewrite and it silently disables the guard: only-allow decides by reading npm_config_user_agent, and pnpm dlx spawns the child with pnpm's own user agent, so the check passes no matter which package manager started the install. npm install then completes and writes a package-lock.json — the exact failure the guard exists to prevent. The same trap applies to any tool that inspects npm_config_user_agent. Note also that pnpm leaves that variable unset when it runs a lifecycle script, so a hand-rolled replacement must treat absent as "fine" and fail only on a present, non-pnpm agent.

pnpm add --global is not a drop-in for npm install --global on GitHub runners. pnpm uses $PNPM_HOME/bin as its global bin directory and refuses to install unless that directory is on PATH, while pnpm/action-setup puts $PNPM_HOME itself on PATH. A workflow that wants pnpm here has to add echo "$PNPM_HOME/bin" >> "$GITHUB_PATH" first. Installing npm itself (npm install --global npm@x, for a CLI new enough for OIDC trusted publishing) should stay on npm regardless — it is the bootstrap for the first exemption above.

A repository that deliberately uses another package manager keeps it — switching is a dedicated change, not a drive-by. The check honours that on its own: it is a no-op unless the repository opts in by naming pnpm in packageManager or tracking a pnpm-lock.yaml. Biome cannot see a lockfile, though, so the plugin has no such escape hatch — drop its entry from your own plugins array.


pnpm catalog

Every external dependency in a pnpm workspace must be declared once in the pnpm-workspace.yaml catalog and referenced everywhere as catalog:.

The rule exists because of a specific failure. next-intl was declared as ^4.13.2 in three apps; an install re-resolved it to 4.13.4, which brought [email protected] with it, while platform-i18n and app-mobile still pinned [email protected]. Two copies of use-intl in the tree, and type errors that pointed everywhere except the actual cause. Nothing looked inconsistent beforehand — every declaration read ^4.13.2. The drift arrived with a resolution, not with an edit, which is why the rule is unconditional: a dependency used by only one package is still cataloged, because the second use is exactly what nobody notices adding.

pnpm add -Dw syncpack
{
	"scripts": {
		"check-catalog": "syncpack lint --config ./node_modules/@vwala/tooling-config/src/syncpack/syncpack.config.json"
	}
}

Run it from the repository root, and add it to the repo's isEverythingOk.

Syncpack has no extends, so this cannot be wired the way biome.json is — unknown top-level keys are a hard error, not a warning. If a repo needs to override something, spread the config from a .syncpackrc.mjs instead:

import base from "@vwala/tooling-config/syncpack" with { type: "json" };

export default { ...base };

That spread is shallow: replacing versionGroups replaces all of them, including the exemptions the config depends on. Read the comments in syncpack.config.json before overriding it.

What is in scope

dependencies, devDependencies and optionalDependencies, in every workspace package and in the root manifest — root dependencies install a real copy too, so a root typescript@^5.7 alongside a package's typescript@^5.9 is the same bug in the tooling layer.

peerDependencies are deliberately excluded. A peer range is a compatibility statement, intentionally wide; collapsing >=18 to catalog: would pin a published package to one version of its own peer. The devDependency that actually installs the copy is in scope, so nothing is lost.

workspace:, link: and file: specifiers are exempt — all three resolve relative to the declaring package, so there is nothing for a central catalog to hold.

Migrating

syncpack fix rewrites specifiers to catalog: and writes the entries into pnpm-workspace.yaml, preserving comments and key order.

It picks the winner for you. Where two packages disagree, fix takes the highest range and says so only in passing. That is usually right and occasionally an unannounced major bump, so resolve the genuine disagreements by hand first, then let fix do the mechanical rest. Re-run pnpm install and typecheck afterwards.

When two packages genuinely need different versions, use a named catalog (catalog:react18) rather than a literal range. Note that once more than one catalog exists, every dependency must live in exactly one of them — syncpack reports anything ambiguous as an unfixable error.

Stopping drift at the source

Set catalogMode: strict in pnpm-workspace.yaml (pnpm >= 10.12) so pnpm add writes catalog entries instead of literal ranges.

That is not a substitute for the CI check. catalogMode applies only when pnpm add runs. It does not re-validate existing manifests on install, so a hand-edited package.json or a bad merge passes straight through. The two are complementary: catalogMode prevents most drift, syncpack lint catches the rest.


TypeScript

pnpm add -D @vwala/tooling-config typescript
{
	"extends": "@vwala/tooling-config/typescript/nextjs"
}

Anything you add alongside extends overrides the preset for that key. In a monorepo each workspace package needs its own devDependency on @vwala/tooling-config, because pnpm only lets a package resolve what its own package.json declares — and extends is resolved from the directory of the tsconfig.json doing the extending.

Presets

base holds everything that is not negotiable — strict, noUncheckedIndexedAccess, isolatedModules, declaration output, ES2024 target — and the other five layer onto it.

| Preset | Extends | For | | --- | --- | --- | | typescript/base | — | Node-resolved libraries. module/moduleResolution are NodeNext, so relative imports need file extensions. | | typescript/bundler | base | Anything a bundler resolves — ESNext + Bundler, so extensionless relative imports work. | | typescript/node | base | Node services and CLIs. Narrows lib to es2022 and pulls in @types/node. | | typescript/react-library | base | React packages published for Node resolution. Adds the automatic JSX runtime. | | typescript/nextjs | bundler | Next.js apps. Adds the next language-service plugin, jsx: preserve and noEmit. | | typescript/expo | bundler | Expo apps. Adds the automatic JSX runtime and noEmit. |

Reach for bundler rather than base whenever the code is compiled by Vite, Next, Metro, esbuild or Vitest. Writing "extends": ".../base" and then overriding module and moduleResolution by hand is the same thing spelled longer.

The presets have tests in src/typescript/__tests__/ that resolve each one through the real tsc --showConfig and assert the merged result, plus a check that every preset file is reachable through the package's exports map — a broken entry otherwise only surfaces in a consumer.

Requires TypeScript >= 5.7. base targets ES2024, and 5.6 and earlier reject es2024 for --target and --lib outright (TS6046). The presets are otherwise valid on 5.7, 6 and 7 alike.

TypeScript 7 dropped the compiler API. typescript@7 ships the native tsc binary and nothing else: no lib/typescript.js and no lib/tsserver.js. Editors cannot point "Use Workspace Version" at it, and any tool that reads the TypeScript API breaks. Pick the version your toolchain needs — @typescript/typescript6 still carries the API — and note that plugins (such as Next's) configures the language service, so tsc 7 accepts it but never acts on it.


zizmor

zizmor audits GitHub Actions workflows. The shared policy lives in src/zizmor/zizmor.yml and reaches zizmor through a composite action that bundles it, so consuming repositories get the zizmor version, the config and the standard arguments from one place.

Add it as a step in the workflow you already have:

jobs:
  ci:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      actions: read
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false

      - name: Mint a token that can read this repository
        id: policy-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ vars.APP_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
          owner: vwala-be
          repositories: tooling-config
          permission-contents: read

      - uses: vwala-be/tooling-config/src/[email protected]
        with:
          token: ${{ steps.policy-token.outputs.token }}

This needs the repository to be readable by the caller — already set (Settings → Actions → General → Access → Accessible from repositories in the organization), which is what lets a private repo share actions with other private repos in the org.

Why token is not optional in practice

Two different permissions are at play, and the org-level setting above only covers one of them.

The Actions runtime can check this action out. But zizmor then audits the very workflow that references it, and it resolves every uses: through the GitHub API using the job's token. The default GITHUB_TOKEN is scoped to the calling repository, so it cannot read vwala-be/tooling-config, and the ref-confusion audit fails hard:

fatal: no audit was performed
'ref-confusion' audit failed on file://./.github/workflows/ci.yml

Caused by:
    0: error in 'ref-confusion' audit
    1: couldn't list branches for vwala-be/tooling-config
    2: can't access vwala-be/tooling-config: missing or you have no access

That is an audit error, not a finding, so an inline # zizmor: ignore[ref-confusion] does not suppress it. Pass a token that can read this repository instead.

The GitHub App has to be installed on tooling-config with read access to contents and metadata. A repository-scoped installation token can still read public repositories, so the same token also covers the third-party actions in the audited workflows.

Our own CI never hits this: it dogfoods the action through the local ./src/zizmor path, which gives zizmor no cross-repository uses: to resolve. The failure is invisible here by construction and only surfaces in consuming repositories.

The policy is not overridable

zizmor has no extends or import mechanism, and passing --config disables local config discovery entirely. That is deliberate here: a repository cannot quietly weaken the org-wide policy with its own zizmor.yml. Exceptions go in src/zizmor/zizmor.yml via a PR against this repo.

Only operational knobs are exposed as action inputs — inputs (paths), persona, advanced-security, and token. Severity and rule selection stay central.

advanced-security is off by default. Uploading to the security tab needs GitHub Advanced Security, which private repositories only have under a licence; with it off you get inline PR annotations instead, which need no entitlement. The upstream action defaults this to true and refuses to run with both it and annotations enabled, so the wrapper derives annotations from it.


Commit messages

Commit messages follow Conventional Commits with a leading gitmoji — every message needs an emoji plus a type: subject, e.g. 🎉 feat: add the widget, 🐛 fix: correct the resolver. The convention is enforced in two places: a local git hook that blocks a bad message as you commit, and a CI gate that blocks it at the PR (so it can't be skipped with git commit --no-verify or by never installing the hook).

Local: the shared config + hooks (npm)

pnpm add -D @vwala/tooling-config @commitlint/cli commitizen cz-emoji-conventional husky lint-staged

Point your commitlint.config.js at the shared config (it layers gitmoji on top of Conventional Commits, so you no longer declare commitlint-config-gitmoji yourself — it comes transitively):

// commitlint.config.js  (use `export default { … }` if your repo is "type": "module")
module.exports = { extends: ["@vwala/tooling-config/commitlint"] };

Wire the hooks into package.json. The prepare script runs husky, then the shipped installer copies the canonical commit-msg, pre-commit and prepare-commit-msg hooks into your .husky/ — so every repo runs identical hooks from one source of truth:

{
  "scripts": {
    "commit": "cz",
    "prepare": "husky && node node_modules/@vwala/tooling-config/src/commitlint/_scripts/install-hooks.mjs"
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-emoji-conventional",
      "useGitmojis": true
    }
  },
  "lint-staged": {
    "*": ["biome check --write --no-errors-on-unmatched"]
  }
}

pnpm install runs prepare, so the hooks land on the first install. pnpm commit opens the interactive gitmoji + conventional prompt; commit-msg then lints whatever message you end up with, and pre-commit runs Biome over staged files via lint-staged.

CI: the bypass-proof gate (GitHub Action)

Add a step to the workflow you already have. Check out with fetch-depth: 0 so the whole commit range is present, install first, then run the shared action over the PR's base..head:

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false
          fetch-depth: 0

      - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile

      - name: Lint commit messages
        if: github.event_name == 'pull_request'
        uses: vwala-be/tooling-config/src/[email protected]
        with:
          from: ${{ github.event.pull_request.base.sha }}
          to: ${{ github.event.pull_request.head.sha }}

The action pins and fetches nothing — it runs your repo's own installed commitlint against the shared config, so the commitlint version stays under your lockfile. Make it a required status check (branch protection) so a non-conventional commit can't be merged.