# seniority

> Which source outranks the others. One resolution for flags, environment, project and home config files and defaults — with provenance, so every value can say where it came from. Drop-in paths for cosmiconfig, dotenv and rc. Zero dependencies.

Source: https://seniority.interlace.tools/docs

**Which source outranks the others.**

One resolution for flags, environment variables, config files, a `package.json` field and
declared defaults — in a fixed order, with **provenance**, so every value can say where it
came from.

It replaces **cosmiconfig**, **dotenv** and **rc**, each through a drop-in path graded by that
incumbent's own suite. The provenance is data too: `explanationJson()` is the `--json` record
and `explanationEvent()` the agent event, so an agent asking "why is this set?" gets the
answer a person does.

```
flag  >  env  >  config file  >  package.json field  >  default
```

Zero dependencies. Node builtins only. Works with any option type you already have.

```bash
npm i seniority
```

## The problem

Every CLI grows the same tangle. A flag overrides an env var, which overrides a config
file, which overrides a default — except in the one place somebody wrote the checks in a
different order, and now `--verbose` loses to `VERBOSE=0` on Tuesdays. Then a user asks
"why is this set to `us-east-1`?" and nobody can answer without reading the source.

`seniority` makes the order a property of the library rather than of each call site, and
makes the answer to "where did this come from" a return value.

## Use

```js
import { resolve, explain } from "seniority";

const specs = {
  region: { type: "string", default: "us-1" },
  dryRun: { type: "boolean" },
  token: { type: "string", env: "MY_TOKEN" },
};

const result = resolve(specs, {
  flags: { dryRun: true }, // only what the user actually typed
  env: process.env,
  envPrefix: "APP", // so `region` also reads APP_REGION
  config: { path: "./app.config.json", data: { region: "eu-2" } },
});

result.values;
// { region: 'eu-2', dryRun: true }

result.provenance["region"];
// { source: 'config', location: './app.config.json' }
```

And the part that makes it worth a package:

```js
process.stdout.write(explain("region", result));
```

```
region = "eu-2"   from config file ./app.config.json
         candidates: flag --region (unset), env APP_REGION (unset), default "us-1"
```

Every candidate, including the ones that were unset and the ones that lost. That output is
generated by the same code that picked the value, so it cannot drift from the truth.

## The order is not configurable

Deliberately. A precedence a program can rearrange is a precedence nobody can reason about
from the outside — including the person reading your `--help`, and the agent reading your
`--schema`. One order, documented once, true everywhere.

It is one exported array, `ORDER`, and `RANK` gives each entry a number spaced by ten:

```js
import { ORDER, RANK } from 'seniority';
//  ['flag', 'env', 'config', 'package', 'default']
//     0       10       20        30         40
```

## Plugins

A plugin can **add** a source — a vault, a CI variable set, a remote config — under the
family's one plugin key, `sources`. It cannot reorder the five above it.

```js
import { register, sources } from 'seniority/plugin';
import { explain, RANK, resolve } from 'seniority';

register({
  name: 'acme-vault',
  sources: {
    vault: {
      rank: RANK.env + 1, // between the environment and the config file
      read: (rt) => ({ location: 'acme://vault/ci', values: { region: rt.env.CI_REGION } }),
    },
  },
});

const r = resolve(specs, { flags, env, sources: sources({ env, cwd }) });
explain('region', r); // region = "eu-1"   from vault acme://vault/ci
```

- **`rank`** must be an integer strictly between `RANK.flag` and `RANK.default`. A plugin
  may never beat the flag the user typed, nor sink below the declared default.
- **A source is data or a reader, and exactly one.** `values: { … }` for a constant source,
  which a tool can read without running it; `read(runtime)` for one that has to go and look.
  `read` gets the `{ env, cwd }` you pass it — this package still touches no globals.
- **Every other key is ignored**, so one plugin object works across the whole family.

## `resolve` is pure

Layers in, values and provenance out. No filesystem, no `process.env`, no globals — pass
the environment in and it is testable without a process.

The half that _does_ touch the disk is a separate import, for exactly that reason:

```js
import { discover } from "seniority";

const loaded = await discover({
  name: "mytool",
  cwd: process.cwd(),
  env: process.env,
});
// searches, in order:
//   MYTOOL_CONFIG
//   ./mytool.config.{json,mjs,js,cjs}
//   $XDG_CONFIG_HOME/mytool/config.json
```

`extends` is resolved relative to the extending file (or through `node_modules`),
deep-merged left to right, and cycles are rejected with the chain that formed them. An
explicit `--config` that is missing is an error; a _discovered_ file that is missing is
silence.

No YAML: JSON and JavaScript cover the cases, and a YAML parser would be a dependency.

## Bring your own option type

`OptionSpec` here declares the three fields resolution actually reads:

```ts
interface OptionSpec {
  type?: string; // only 'boolean' changes how env is read
  env?: string;
  default?: unknown;
}
```

burgee's own option type carries eighteen fields — choices, schemas, placeholders,
deprecations. It is accepted here with no adapter and no import, because TypeScript's
structural typing does the work. **Your richer type is already an `OptionSpec`.**

That is the point of this package sitting at the bottom of the stack: nothing above it has
to exist for it to be useful to you.

## Environment rules

- With `envPrefix: 'APP'`, an option without an explicit `env:` reads `APP_REGION` —
  `dryRun` becomes `APP_DRY_RUN`, `log-level` becomes `APP_LOG_LEVEL`, and a camel-cased
  name is never mapped back.
- Booleans accept `1`/`true`/`yes` and `0`/`false`/`no`. Anything else is an error naming
  the variable and the value, not a silently-falsy string.
- `APP_NO_THING` is refused, pointing at `APP_THING=false`. Two spellings of one fact is
  how a config becomes unreadable.
- Env applies only to options the running command declares — never to a sibling command's.

## API

|                               |                                                                              |
| :---------------------------- | :--------------------------------------------------------------------------- |
| `resolve(specs, layers)`      | every declared option, resolved, with provenance and the full candidate list |
| `explain(name, resolution)`   | the winning source and everything it beat, formatted                         |
| `discover(discovery)`         | find and load a config file, following `extends`                             |
| `loadWithExtends(path)`       | load one file and its `extends` chain                                        |
| `candidates(discovery)`       | the paths discovery _would_ try, and why                                     |
| `deepMerge(base, over)`       | objects merge recursively; anything else, later wins                         |
| `envName(name, spec, prefix)` | the variable an option reads                                                 |
| `envBoolean(raw)`             | the boolean spellings, or `undefined`                                        |
| `screaming(name)`             | `dryRun` → `DRY_RUN`                                                         |
| `ConfigError`                 | a value that cannot be used as configured; carries an optional `hint`        |
| `ORDER`, `RANK`               | the precedence as data, and each built-in's rank                             |
| `explanation(name, res)`      | `--explain` as a **record**; `explain` is its text rendering                 |
| `explanationJson(e)`          | the same record as `--json` data, with `set` spelled out                     |
| `explanationEvent(e)`         | the same record as an agent event                                            |
| `search(names, options)`      | the bounded upward walk — `stopAt`, a depth limit, symlink-cycle-safe        |
| `searchAll(names, options)`   | every match on the way up, nearest first                                     |
| `loadPath(path, options)`     | read and parse one file, with your loaders merged over the four builtins     |
| `loaderFor(path, loaders)`    | the loader for an extension, or the error naming what would supply one       |
| `LoaderError`                 | an extension with no loader: a **usage** error, not a config error           |
| `validate(shape, resolution)` | every violation, each naming the file, line and value that caused it         |
| `check(shape, resolution)`    | the values, or one `ConfigError` listing every violation                     |

From `seniority/plugin`:

|                      |                                                                   |
| :------------------- | :---------------------------------------------------------------- |
| `register(plugin)`   | keep a plugin's `sources`; refuse a bad rank or shape at the door |
| `sources(runtime)`   | every registered source, read and ranked — pass to `resolve`      |
| `reset()`            | forget every registered plugin                                    |
| `registered()`       | the plugins registered, in order                                  |
| `PluginError`        | `E_PLUGIN_SCHEMA` or `E_PLUGIN_CONTRACT`, with a `fix`            |

## Replaces

What `cosmiconfig`, `dotenv`, `rc` and `find-up` do between them — discovery, `extends`, env
loading, precedence, the upward walk — is one problem. This is one package with no
dependencies rather than four with a tree.

### `cosmiconfig`

The root export carries cosmiconfig's own surface, so a migration is the import line:

```js
- import { cosmiconfig } from "cosmiconfig";
+ import { cosmiconfig } from "seniority";
```

`cosmiconfig`, `cosmiconfigSync`, `Explorer`, `ExplorerSync`, `defaultLoaders`,
`defaultLoadersSync`, `getDefaultSearchPlaces`, `globalConfigSearchPlaces`, `metaSearchPlaces`
— all three search strategies, both caches, `$import`, and the meta-config merge.

**Graded by cosmiconfig 10.0.1's own test suite: 186 of 243 cases.** Not "compatible" — a
number, from the incumbent's tests, run unmodified. Where it stops is one thing:

> **YAML.** cosmiconfig reads `.yaml`, `.yml` and extensionless files through `js-yaml`.
> This package bundles no format parser, so `loadYaml` here reads the subset of YAML that is
> also JSON — which is every JSON document — and **refuses the rest by name**, telling you to
> pass `loaders: { '.yaml': yaml.load }`. Do that and you have cosmiconfig's behaviour
> exactly, with the parser as *your* dependency rather than everyone's.

Every one of the 55 cases not passing is that, bar one that is the test harness reaching for a
file path the vendored copy does not have. None of them is a difference in how a config is
found, merged or reported — and that is counted rather than claimed: every failing entry in
the raw output was matched against its own diagnostic, and 54 of the 55 carry the "no YAML
parser" refusal above. The largest block is the whole of `import.test.ts`, 22 cases: `$import`
works, and every fixture it is tested with is `.yml`.

### `seniority/dotenv`

`parse` and `populate` are dotenv 17's, grammar included. `config` takes the environment to
populate as an argument:

```js
import { config } from "seniority/dotenv";

config({ path: ".env", processEnv: process.env });
```

That one word is the whole difference, and it is deliberate: nothing in this package reads or
writes `process` on its own. It is also why `parse` is usable in a test, in a browser build,
or on a string you already have.

### `seniority/lilconfig`

`lilconfig`, `lilconfigSync`, `defaultLoaders`, `defaultLoadersSync` — lilconfig's search
places, its loader tables (`.json` through `require` in the sync one, exactly as upstream),
its caches, and its disagreements with cosmiconfig kept rather than smoothed over:

```js
- import { lilconfigSync } from "lilconfig";
+ import { lilconfigSync } from "seniority/lilconfig";
```

**Graded by lilconfig 3.1.3's own test suite: 67 of 77 cases — the same score the real
`lilconfig` gets here.** The ten neither of us passes assert which files were read by mocking
`fs` with `jest.mock`, which cannot reach a CommonJS `require('fs')` under vitest. No case in
that suite separates this from the package it replaces.

Its own entry point, not the root: lilconfig's last test reads the *keys* of the module it is
given and compares them with cosmiconfig's, so one module cannot honestly be both.

### `seniority/rc`

rc's merge — the `/etc`, `$HOME` and upward-walk file stack in rc's own order, `__` nesting
for environment keys, JSON with comments, `deep-extend`'s merge, and the `configs` / `config`
report of which files were actually read — with none of rc's four dependencies.

```js
- const config = require("rc")("mytool", defaults);
+ import rc from "seniority/rc";
+ const config = rc("mytool", defaults, argv, undefined, { env: process.env });
```

Two differences, both on purpose. The environment is an argument, for the same reason
`config` takes one. And an INI-shaped file is refused by name rather than parsed — pass
`ini.parse` in rc's own fourth position and you have rc's behaviour, with the parser as
*your* dependency.

**Graded by rc 1.2.8's own test: 0 of 1.** That suite is one script of bare assertions, and
it sets `process.env` before calling `rc(name, defaults)` — a signature with no slot for an
environment. It gets past the first assertion and fails on the second. The same script with
the environment passed in is `src/rc.test.ts`, and it passes.

### `seniority/find-up`

`findUp`, `findUpSync`, `findUpMultiple`, `findUpMultipleSync` over the same bounded walk —
`stopAt`, a depth limit, and a symlink ring that ends the walk instead of spinning it.
`find-up` → `locate-path` → `p-locate` → `path-exists` is four packages for that.

## Benchmarks

Every number here is produced by `npm run bench` and published at [/docs/benchmarks](https://burgee.interlace.tools/docs/benchmarks).

Graded by the incumbent's own test suite:

| suite | passing |
| :-- | --: |
| `cosmiconfig` | 186 / 243 |
| `dotenv` | 80 / 141 |
| `lilconfig` | 67 / 77 |
| `rc` | 0 / 1 |

Weight, installed and tree-inclusive: **152,447 bytes** against **1,972,507** for the incumbents it replaces — a ratio of **0.0773**.
## Where it sits

Plugins register under the `sources` key, against the one schema the whole family shares.

`burgee` builds on it, and it builds on nothing in this family.
## Licence

MIT
