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

codeowners-util

v1.0.0

Published

Generate GitHub CODEOWNERS files from a typed config

Readme

codeowners-util

GitHub's CODEOWNERS leaves a lot to desire and lacks a lot of features one would want to create fine grained rules about who owns what in a monorepo.

This library lets you define ownership in TypeScript. You describe who owns what and why. The library generates a correct CODEOWNERS file with rules sorted so GitHub's last-match-wins semantics produce the right result.

The mental model

There are two layers to ownership:

own() is the source of truth. It's a direct declaration: these teams own these paths. If two teams both call own() on the same path, they share it. Nothing else in the system can remove an own() declaration.

only() and add() are for cross-cutting patterns. Things like "all locale files should be reviewed by the i18n team." They apply to file patterns anchored at the repository root. only() replaces the owners outright. add() puts reviewers on top of the owners a file already has.

The rules

  1. own() declarations merge. Two own() calls on the same path? Both teams are co-owners.

  2. add() stacks. It puts teams on top of the current owners.

  3. only() replaces. It sets the owners outright and discards whatever came before, including a direct own() declaration. Use add() when you want to keep the existing owners.

  4. always is unconditional. Teams listed here are appended to every rule. Useful for bot accounts.

  5. A pattern starting with ! excludes files from that rule, as it does in .gitignore.

  6. Declaration order decides. Entries in rules run in the order you write them, and each one builds on the result of the last. Pattern specificity does not reorder them. own() is the base layer, so its order does not matter — the narrowest declaration wins.

Install

npm install codeowners-util

Quick start

Create a codeowners.config.ts at your repo root:

import { team, own, only, add } from "codeowners-util";
import type { CodeOwnersConfig } from "codeowners-util";

const bot = team("@ci-bot");
const platform = team("@org/platform");
const search = team("@org/search");
const i18n = team("@org/i18n");

const config: CodeOwnersConfig = {
  always: [bot],
  teams: {
    "@org/platform": "Platform & Infrastructure",
    "@org/search": "Search Experience",
    "@org/i18n": "Internationalization",
  },
  own: [
    own(
      platform,
      ["*", "apps/web", "libs/config"],
      "Platform owns the foundation",
    ),
    own(
      search,
      ["libs/search", "libs/search-api"],
      "Search team owns search libs",
    ),
    own([search, platform], "apps/web/src/routes/search.ts"),
  ],
  rules: [
    only(i18n, "**/locales/**/*.json", "All locale files are reviewed by i18n"),
    add(
      i18n,
      "**/locales/en-US/**/*.json",
      "English source strings need both product team and i18n",
    ),
  ],
};

export default config;

Generate the file:

npx codeowners-util

This writes .github/CODEOWNERS with rules sorted by specificity so GitHub's last-match-wins semantics produce the right result.

How ownership works

Direct ownership with own()

own(platform, "libs/config");
own(search, ["libs/search", "libs/search-api"]);
own([search, platform], "apps/web/src/routes/search.ts");

When multiple own() calls declare the same path, their owners merge:

own(teamA, "libs/core");
own(teamB, "libs/core");
// libs/core is co-owned by both

Cross-cutting rules with only() and add()

Rules apply file patterns across the repository, in declaration order.

add adds teams on top of the current owners:

add(i18n, "**/locales/en-US/**/*.json");
// If search owns libs/search:
// libs/search/locales/en-US/**/*.json → @org/search @org/i18n

only replaces the current owners:

only(i18n, "**/locales/**/*.json");
// Every locale file → @org/i18n, whoever owned it before

only discards a direct own() declaration too:

own([checkout, platform], "apps/web/config/features/checkout");
only(checkout, "**/features/checkout/**");

// Files inside apps/web/config/features/checkout/ → @org/checkout
// @org/platform is gone, because `only` replaces.
// Use add(checkout, ...) to keep @org/platform.

Rules run in the order you declare them, and each rule builds on the one before it:

only(i18n, "**/locales/**/*.json");      // 1. locales → i18n
add(qa, "**/locales/en-US/**/*.json");   // 2. en-US → i18n + qa

Reversing those two lines gives a different result, because only() would then discard @org/qa.

How match patterns work

Patterns are anchored at the repository root.

**/locales/**/*.json    matches locales at any depth
src/**/*.test.ts        matches the root src directory only
**/src/**/*.test.ts     matches src at any depth

A pattern with no glob characters acts as a directory prefix. apps/web matches apps/web and everything below it.

Excluding files with !

A pattern starting with ! removes files from the rule it belongs to. A file must match at least one ordinary pattern, and no ! pattern.

This lets you write a broad default with a narrow carve-out:

own(commerceDev, "*"),
own(airCars, "libs/air"),

rules: [
  // Locale files belong to the shared team, except English source strings
  only(commerceDev, ["**/locales/**/*.json", "!**/locales/en-US/**"]),
  // which stay with the owning team, plus localization
  add(localization, "**/locales/en-US/**/*.json"),
];

libs/air/locales/fr/air.json goes to @org/commerce-dev. libs/air/locales/en-US/air.json keeps @org/air-cars and gains @org/localization.

Without ! you cannot express this. only() would take the English files too, and no later rule can give a file back an owner that varies per directory.

Two details differ from .gitignore, both deliberately:

  • Order does not matter. In .gitignore a later ! re-includes something an earlier line excluded. Here every ! pattern is an exclusion, wherever it sits in the array, so the two orders below mean the same thing.

    only(commerceDev, ["**/locales/**/*.json", "!**/locales/en-US/**"]);
    only(commerceDev, ["!**/locales/en-US/**", "**/locales/**/*.json"]);
  • Only a rule pattern may start with !. GitHub does not support negation in a CODEOWNERS file, so a line beginning with ! is a syntax error there. The marker is stripped before anything is written, and an own() path starting with ! is rejected.

A rule needs at least one pattern that is not an exclusion, otherwise it selects nothing. That throws, as does a bare "!".

A ! anywhere other than the first character is literal, so **/!important.json matches a file named !important.json.

The generator reads the real files in your repository, works out the exact owner set for every file, then writes the smallest set of rules that reproduces those owners. It checks the result before writing. If a directory holds no matching file, no rule is emitted for it.

Ordering

The generated file is sorted so that more specific rules appear later and win under GitHub's last-match-wins evaluation:

* @org/platform                              # catches everything
libs/search @org/search                      # more specific, wins for libs/search
libs/search/locales/**/*.json @org/i18n      # even more specific, wins for locale files

You do not need to reason about this ordering. The generator verifies the emitted file against the owners it resolved, and fails rather than write a file that disagrees.

Descriptions

Every part of the config supports optional descriptions. They're rendered as comments in the generated file.

Team descriptions

// In the config
const config: CodeOwnersConfig = {
  teams: {
    "@org/platform": "Platform & Infrastructure",
    "@org/search": "Search Experience",
  },
  // ...
};

// Or inline with team()
const platform = team("@org/platform", "Platform & Infrastructure");

Shows up in section headers:

# @org/platform (Platform & Infrastructure), @ci-bot

Ownership descriptions

own(
  platform,
  ["*", "libs/config"],
  "Platform owns the foundation and shared config",
);

Rendered above the group:

# Platform owns the foundation and shared config
# @org/platform (Platform & Infrastructure), @ci-bot
* @org/platform @ci-bot
libs/config @org/platform @ci-bot

Rule descriptions

only(
  i18n,
  "**/locales/**/*.json",
  "All locale files are reviewed by i18n, product teams opt in via en-US",
);

Rendered below the rule header:

# ── only: **/locales/**/*.json ──
# All locale files are reviewed by i18n, product teams opt in via en-US

CLI

codeowners-util [options]

  -c, --config <path>   Config file (default: codeowners.config.ts)
  -o, --output <path>   Output file (default: .github/CODEOWNERS)
      --check           Check if output is up to date (exit 1 if stale)
      --stdout          Print generated output to stdout
  -h, --help            Show help

Use --check in CI to keep the CODEOWNERS file in sync:

npx codeowners-util --check

Input validation

A CODEOWNERS line separates fields by spaces, and # starts a comment. A team handle or path that carries whitespace, a #, or a line break can therefore add or truncate rules in the generated file.

team(), own(), only() and add() reject those values, and generate() checks the whole config again. That second check matters when you build a config from data instead of literals:

// Throws, rather than writing "* @attacker" as an extra rule
team("@org/platform\n* @attacker");
own(platform, "apps/web\n* @attacker");

// Also throws, even though it never calls team()
generate({ own: [{ owners: ["@org/ok\n* @attacker"], paths: ["*"] }] });

Descriptions become comment lines, so they may contain spaces but not line breaks.

Programmatic API

import { team, own, match, generate, write } from "codeowners-util";

team(name, description?)

Creates a typed team handle. The optional description is used in generated comments.

own(owners, paths, description?)

Declares ownership. Accepts a single team or array, and a single path or array.

only(owners, patterns, description?)

Replaces the owners of every file the patterns match. Accepts a single team or an array, and a single pattern or an array. A pattern starting with ! excludes files from the rule.

add(owners, patterns, description?)

Adds owners on top of the owners a file already has. Same argument shape as only().

generate(config, options?)

Returns the generated CODEOWNERS content as a string.

  • rootDir - root directory for filesystem-aware resolution (default: process.cwd())
  • fs - custom filesystem implementation for testing (default: node:fs)

write(config, options)

Writes the CODEOWNERS file to disk.

write(config, { outputPath: ".github/CODEOWNERS" });

// Check mode, compare without writing
const result = write(config, {
  outputPath: ".github/CODEOWNERS",
  check: true,
});
console.log(result.upToDate);

Config reference

interface CodeOwnersConfig {
  always?: Team[];
  teams?: Record<string, string>;
  /** Base layer. The narrowest declaration wins, so order does not matter. */
  own: OwnershipRule[];
  /** Applied on top of own(), in declaration order. */
  rules?: PolicyRule[];
}

Upgrading to 1.0

match() is gone. It was two operations sharing one function, so it is now two functions, and both take the same arguments as own():

// before
match: [
  match("**/locales/**/*.json", { only: [i18n] }),
  match("**/*.test.ts", { add: [qa], description: "QA reviews tests" }),
];

// after
rules: [
  only(i18n, "**/locales/**/*.json"),
  add(qa, "**/*.test.ts", "QA reviews tests"),
];

The match config key is now rules. Owners come first, patterns second, and both accept a single value or an array. Resolution semantics are unchanged.

License

MIT