# seniority/config > Every export of seniority/config, with its signature and doc comment: deepMerge, lineOf, packageLayer, loadWithExtends, candidates, discover, plus 3 types. Source: https://seniority.interlace.tools/docs/api/config ```ts import { deepMerge, lineOf, packageLayer, … } from 'seniority/config'; ``` ## Functions ### candidates The discovery order as candidate paths, first hit wins; each entry says why it was tried. ```ts function candidates(d: Discovery): { path: string; reason: string; }[]; ``` | Parameter | Type | | :-- | :-- | | `d` | `Discovery` | **Returns** `{ path: string; reason: string; }[]` ### deepMerge Objects merge recursively; anything else, the later value wins. ```ts function deepMerge(base: Record, over: Record): Record; ``` | Parameter | Type | | :-- | :-- | | `base` | `Record` | | `over` | `Record` | **Returns** `Record` ### discover Discover and load. `package.json#` is not a file here — the engine reads the owning package.json itself and treats the field as its own layer, below config. ```ts function discover(d: Discovery): Promise; ``` | Parameter | Type | | :-- | :-- | | `d` | `Discovery` | **Returns** `Promise` ### lineOf The line a top-level key is set on, one-based, or `undefined`. See `keyLines` for what it will and will not match. ```ts function lineOf(text: string, key: string): number | undefined; ``` | Parameter | Type | | :-- | :-- | | `text` | `string` | | `key` | `string` | **Returns** `number \| undefined` ### loadWithExtends Load a file and everything it extends, outermost first, the file's own keys winning. ```ts function loadWithExtends(path: string, options?: LoadConfigOptions, seen?: string[]): Promise; ``` | Parameter | Type | | :-- | :-- | | `path` | `string` | | `options` (optional) | `LoadConfigOptions` | | `seen` (optional) | `string[]` | **Returns** `Promise` ### packageLayer The `package.json` field named after a program, as the layer below the config file, with the line each of its keys is set on (R3). Without the line, `explain` could say a value came from `package.json` and not where in it, which is the one file in a project long enough for that to matter. ```ts function packageLayer(path: string, name: string, manifest?: Record): Layer | undefined; ``` | Parameter | Type | | :-- | :-- | | `path` | `string` | | `name` | `string` | | `manifest` (optional) | `Record` | **Returns** `Layer \| undefined` ## Interfaces ### Discovery ```ts interface Discovery { name: string; cwd: string; env: Record; /** `--config `. */ explicit?: string; /** `--no-config`. */ disabled?: boolean; /** Extra loaders, by extension, merged over the four builtins (R6). */ loaders?: Readonly>; /** * The extensions tried in the current directory, in order. Defaults to the four Node can * read; a program that injected a `.yaml` loader adds `.yaml` here. The two are separate * because supplying a parser and asking discovery to look for that format are different * decisions, and a program may want either without the other. */ extensions?: readonly string[]; /** * Walk up from `cwd` looking for the same names (R5). **Off by default**: a config found * in a directory the user did not name is the kind of surprise `--explain` exists to * prevent, so it is a program's decision rather than an ambient behaviour. */ upward?: boolean; /** The highest directory an upward walk may look in, inclusive. Defaults to the filesystem root (Y10). */ stopAt?: string; } ``` ### LoadConfigOptions Options that reach a load, threaded through `extends` so every file in a chain reads the same way. ```ts interface LoadConfigOptions { loaders?: Readonly>; } ``` ### Loaded ```ts interface Loaded extends Layer { /** The files merged, outermost first — what `--explain` shows. */ chain: string[]; } ``` --- # seniority/cosmiconfig > Every export of seniority/cosmiconfig, with its signature and doc comment: cosmiconfig, cosmiconfigSync, Explorer, ExplorerSync, plus 10 types. Source: https://seniority.interlace.tools/docs/api/cosmiconfig ```ts import { cosmiconfig, cosmiconfigSync, Explorer, … } from 'seniority/cosmiconfig'; ``` ## Functions ### cosmiconfig The asynchronous entry. ```ts function cosmiconfig(moduleName: string, options?: Readonly>): PublicExplorer; ``` | Parameter | Type | | :-- | :-- | | `moduleName` | `string` | | `options` (optional) | `Readonly>` | **Returns** `PublicExplorer` ### cosmiconfigSync The synchronous entry. Constructs `ExplorerSync` twice — once for the meta config, once for the search. ```ts function cosmiconfigSync(moduleName: string, options?: Readonly>): PublicExplorerSync; ``` | Parameter | Type | | :-- | :-- | | `moduleName` | `string` | | `options` (optional) | `Readonly>` | **Returns** `PublicExplorerSync` ## Classes ### Explorer The asynchronous explorer. ```ts class Explorer extends ExplorerBase { load(filepath: string): Promise; search(from?: string): Promise; /** A `metaConfigFilePath` that loads to something non-empty short-circuits the whole search. */ private searchMetaConfig; /** One directory's places, in order; `undefined` means "nothing here, keep walking". Untransformed. */ private scan; private readConfiguration; private loadWithImports; private loadConfiguration; private directories; } ``` ### ExplorerSync The synchronous explorer. Same decisions, `readFileSync` and `statSync`, and no `.mjs` anywhere. ```ts class ExplorerSync extends ExplorerBase { load(filepath: string): CosmiconfigResult; search(from?: string): CosmiconfigResult; private searchMetaConfig; private scan; private readConfiguration; private loadWithImports; private loadConfiguration; private directories; } ``` ## Interfaces ### CommonOptions ```ts interface CommonOptions { packageProp?: string | string[]; searchPlaces: string[]; ignoreEmptySearchPlaces: boolean; stopDir?: string; cache: boolean; mergeImportArrays: boolean; mergeSearchPlaces: boolean; searchStrategy: SearchStrategy; /** * seniority's one addition: the directory a `global` search ends in. cosmiconfig always * derives it from the environment through `env-paths`; here it can be passed, and when it is * not, it is derived the same way, reading the environment through `runtime.ts` (D-135). */ globalConfigDir?: string; } ``` ### Options ```ts interface Options extends CommonOptions { loaders: Loaders; transform: Transform; } ``` ### PublicExplorer ```ts interface PublicExplorer { search: (from?: string) => Promise; load: (filepath: string) => Promise; clearLoadCache: () => void; clearSearchCache: () => void; clearCaches: () => void; } ``` ### PublicExplorerSync ```ts interface PublicExplorerSync { search: (from?: string) => CosmiconfigResult; load: (filepath: string) => CosmiconfigResult; clearLoadCache: () => void; clearSearchCache: () => void; clearCaches: () => void; } ``` ## Types ### Config ```ts type Config = unknown; ``` ### CosmiconfigResult ```ts type CosmiconfigResult = { config: Config; filepath: string; isEmpty?: boolean; } | null; ``` ### Loaders ```ts type Loaders = Record; ``` ### OptionsSync ```ts type OptionsSync = Options; ``` ### SearchStrategy ```ts type SearchStrategy = 'none' | 'project' | 'global'; ``` ### Transform ```ts type Transform = (result: CosmiconfigResult) => CosmiconfigResult | Promise; ``` --- # seniority/dotenv > Every export of seniority/dotenv, with its signature and doc comment: parse, populate, configDotenv, config, dotenv, plus 11 types. Source: https://seniority.interlace.tools/docs/api/dotenv ```ts import dotenv from 'seniority/dotenv'; import { parse, populate, configDotenv, … } from 'seniority/dotenv'; ``` ## Functions ### config dotenv's `config`: `configDotenv`, through the module object a stub can patch. ```ts function config(options?: Partial): ConfigResult; ``` | Parameter | Type | | :-- | :-- | | `options` (optional) | `Partial` | **Returns** `ConfigResult` ### configDotenv Read, parse and populate. Returns `{ parsed }`, or `{ parsed, error }` with the last error, and **never throws for a missing file** — dotenv is loaded at import time, where a throw takes the program down before it can say anything useful. ```ts function configDotenv(given?: Partial): ConfigResult; ``` | Parameter | Type | | :-- | :-- | | `given` (optional) | `Partial` | **Returns** `ConfigResult` ### parse Every `KEY=value` in a `.env`, as an object. Accepts the Buffer `readFileSync` hands back. ```ts function parse(src: string | Buffer, options?: ParseOptions): Record; ``` | Parameter | Type | | :-- | :-- | | `src` | `string \| Buffer` | | `options` (optional) | `ParseOptions` | **Returns** `Record` ### populate Copy `parsed` into `target` and return what was actually set. Without `override` a key the target already holds is left alone — the same rule seniority's own `ORDER` states as `env > config` (R1), arrived at independently by dotenv and worth noticing. Either argument that is not an object is `OBJECT_REQUIRED`, as dotenv 18 checks both before it reads either. ```ts function populate(target: DotenvPopulateInput, parsed: DotenvPopulateInput, options?: PopulateOptions): DotenvPopulateOutput; ``` | Parameter | Type | | :-- | :-- | | `target` | `DotenvPopulateInput` | | `parsed` | `DotenvPopulateInput` | | `options` (optional) | `PopulateOptions` | **Returns** `DotenvPopulateOutput` ## Constants ### default The default export, declared as `dotenv`. **The default export, and why a drop-in subpath needs one.** ```ts const dotenv: { config: typeof config; configDotenv: typeof configDotenv; parse: typeof parse; populate: typeof populate; }; ``` ### module.exports What `require()` returns: the default export, `dotenv`, under the name Node's `require(esm)` reads. ## Interfaces ### ConfigOptions ```ts interface ConfigOptions extends Omit { /** Log what it does, through `console.log`; a string is read as dotenv reads it (`'false'`, `'0'`, … are false). */ debug?: boolean | string; /** Replace keys the environment already has. */ override?: boolean | string; /** Suppress the `injected env` line. Off by default, as in dotenv. */ quiet?: boolean | string | undefined; /** Parse with the character scanner. */ fast?: boolean | string; /** One file or several, highest priority first — an earlier file's key is not overwritten by a later one. `./.env` when omitted; a leading `~` is the home directory; a `URL` is read as one. */ path: string | URL | readonly (string | URL)[]; /** The object to populate; the process's own environment when omitted, as dotenv does (D-135). */ processEnv: Record; encoding?: BufferEncoding; } ``` ### ConfigResult ```ts interface ConfigResult { parsed?: Record; error?: Error; } ``` ### ParseOptions ```ts interface ParseOptions { /** Use the character scanner (dotenv's `{ fast: true }`) instead of the regular expression; read as dotenv reads a boolean. */ fast?: boolean | string | undefined; } ``` ### PopulateOptions ```ts interface PopulateOptions { /** Replace a key the target already has. Off by default: the real environment outranks a file. */ override?: boolean; /** Report each key that was already defined, and whether it was overwritten. dotenv's own `_debug`. */ debug?: boolean; } ``` ## Types ### DotenvConfigOptions ```ts type DotenvConfigOptions = Partial; ``` ### DotenvConfigOutput ```ts type DotenvConfigOutput = ConfigResult; ``` ### DotenvParseOptions ```ts type DotenvParseOptions = ParseOptions; ``` ### DotenvParseOutput ```ts type DotenvParseOutput = Record; ``` ### DotenvPopulateInput ```ts type DotenvPopulateInput = Record; ``` ### DotenvPopulateOptions ```ts type DotenvPopulateOptions = PopulateOptions; ``` ### DotenvPopulateOutput ```ts type DotenvPopulateOutput = Record; ``` --- # seniority/dotenv/cli > Every export of seniority/dotenv/cli, with its signature and doc comment: parseRunArgs, run, HELP, plus 2 types. Source: https://seniority.interlace.tools/docs/api/dotenv/cli ```ts import { parseRunArgs, run, HELP } from 'seniority/dotenv/cli'; ``` ## Functions ### parseRunArgs dotenv's `parseRunArgs`. ```ts function parseRunArgs(args: readonly string[]): RunArgs; ``` | Parameter | Type | | :-- | :-- | | `args` | `readonly string[]` | **Returns** `RunArgs` ### run dotenv's `run`: the whole of `dotenv `. ```ts function run(argv: readonly string[], proc?: CliProcess, io?: RunIo): void; ``` | Parameter | Type | | :-- | :-- | | `argv` | `readonly string[]` | | `proc` (optional) | `CliProcess` | | `io` (optional) | `RunIo` | **Returns** `void` ## Constants ### HELP dotenv's help text, to the line. ```ts const HELP: string; ``` ## Interfaces ### RunIo How the child is started, and how Windows takes its tree down; the real ones unless a test hands in its own. ```ts interface RunIo { spawn?: (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; spawnSync?: (command: string, args: readonly string[], options: { stdio: 'ignore'; }) => unknown; } ``` ## Types ### RunArgs The command line, read: dotenv's flags up to the command, and the command with everything after it. ```ts type RunArgs = { help: true; } | { error: string; } | { paths: string[]; pathSet: boolean; quiet?: boolean; debug?: boolean; override?: boolean; fast?: boolean; command: string[]; }; ``` --- # seniority/dotenv/config > seniority/dotenv/config exports nothing: importing it is the call. Source: https://seniority.interlace.tools/docs/api/dotenv/config This entry exports nothing. Importing it is the call. ```ts import 'seniority/dotenv/config'; ``` --- # seniority/explain > Every export of seniority/explain, with its signature and doc comment: explain, explanation, renderExplanation, plus 1 type. Source: https://seniority.interlace.tools/docs/api/explain ```ts import { explain, explanation, renderExplanation } from 'seniority/explain'; ``` ## Functions ### explain The rendered form: what `--explain` prints. ```ts function explain(name: string, resolution: Resolution): string; ``` | Parameter | Type | | :-- | :-- | | `name` | `string` | | `resolution` | `Resolution` | **Returns** `string` ### explanation The record. Everything else in this file renders it. ```ts function explanation(key: string, resolution: ExplainedResolution): Explanation; ``` | Parameter | Type | | :-- | :-- | | `key` | `string` | | `resolution` | `ExplainedResolution` | **Returns** `Explanation` ### renderExplanation The human rendering — what `explain()` returns and a terminal prints. ```ts function renderExplanation(e: Explanation): string; ``` | Parameter | Type | | :-- | :-- | | `e` | `Explanation` | **Returns** `string` ## Interfaces ### Explanation ```ts interface Explanation { /** The option asked about. */ key: string; /** False when the command declares no such option — different from "declared and unset". */ declared: boolean; /** The resolved value, or `undefined` when no source set one. */ value: unknown; /** The candidate that won, or `undefined` when none did. */ winner?: ExplainedCandidate; /** Every candidate, in `ORDER`. The truth table for this one option. */ candidates: ExplainedCandidate[]; /** Candidates that set a value and were outranked. */ lost: ExplainedCandidate[]; /** * Candidates that were consulted and had nothing. Kept apart from `lost` because * "the config file says `lib` and the flag beat it" and "there is no config file" are * different answers, and `--explain` exists to tell them apart. */ unset: ExplainedCandidate[]; } ``` --- # seniority/find-up > Every export of seniority/find-up, with its signature and doc comment: findUpMultipleSync, findUpMultiple, findUpSync, findUp, findDownSync, findDown and 1 more, plus 3 types. Source: https://seniority.interlace.tools/docs/api/find-up ```ts import { findUpMultipleSync, findUpMultiple, findUpSync, … } from 'seniority/find-up'; ``` ## Functions ### findDown ```ts function findDown(name: Name, options?: FindDownOptions): Promise; ``` | Parameter | Type | | :-- | :-- | | `name` | `Name` | | `options` (optional) | `FindDownOptions` | **Returns** `Promise` ### findDownSync The first match looking down from `cwd`, `depth` levels at most, nearest level first unless `strategy` is `'depth'`. ```ts function findDownSync(name: Name, options?: FindDownOptions): string | undefined; ``` | Parameter | Type | | :-- | :-- | | `name` | `Name` | | `options` (optional) | `FindDownOptions` | **Returns** `string \| undefined` ### findUp ```ts function findUp(name: Name | Matcher>, options?: Options): Promise; ``` | Parameter | Type | | :-- | :-- | | `name` | `Name \| Matcher>` | | `options` (optional) | `Options` | **Returns** `Promise` ### findUpMultiple ```ts function findUpMultiple(name: Name | Matcher>, options?: Options): Promise; ``` | Parameter | Type | | :-- | :-- | | `name` | `Name \| Matcher>` | | `options` (optional) | `Options` | **Returns** `Promise` ### findUpMultipleSync ```ts function findUpMultipleSync(name: Name | Matcher, options?: Options): string[]; ``` | Parameter | Type | | :-- | :-- | | `name` | `Name \| Matcher` | | `options` (optional) | `Options` | **Returns** `string[]` ### findUpSync The nearest match walking up, or `undefined`. In a directory, the first name given wins. ```ts function findUpSync(name: Name | Matcher, options?: Options): string | undefined; ``` | Parameter | Type | | :-- | :-- | | `name` | `Name \| Matcher` | | `options` (optional) | `Options` | **Returns** `string \| undefined` ## Constants ### findUpStop Return this from a matcher to end the walk with no match. ```ts const findUpStop: unique symbol; ``` ## Interfaces ### FindDownOptions ```ts interface FindDownOptions { /** Where to start. Defaults to the process's working directory. */ readonly cwd?: URL | string; /** How many directories below `cwd` to look. `0` is `cwd` alone; defaults to `1`. */ readonly depth?: number; /** Nearest level first (`'breadth'`, the default) or each branch to the bottom first (`'depth'`). */ readonly strategy?: 'breadth' | 'depth'; readonly type?: 'file' | 'directory' | 'both'; readonly allowSymlinks?: boolean; } ``` ### Options ```ts interface Options { /** Where to start. Defaults to the process's working directory. */ readonly cwd?: URL | string; /** The highest directory to look in, inclusive. Defaults to the filesystem root. */ readonly stopAt?: URL | string; /** What a name must be to match. Defaults to `'file'`. */ readonly type?: 'file' | 'directory' | 'both'; /** Follow a symbolic link to what it points at. Defaults to `true`. */ readonly allowSymlinks?: boolean; /** How many matches `findUpMultiple` collects at most. Defaults to `Infinity`. */ readonly limit?: number; } ``` ## Types ### Match ```ts type Match = string | typeof findUpStop | undefined; ``` --- # seniority > Every export of seniority, with its signature and doc comment: explain, ConfigError, envBoolean, envName, ORDER, RANK and 37 more, plus 33 types. Source: https://seniority.interlace.tools/docs/api seniority — which source outranks the others. ```ts import { explain, ConfigError, envBoolean, … } from 'seniority'; ``` ## Functions ### check The throwing form: the resolved values, or one `CONFIG`-class error listing every violation. `validate` is the record; this is the rendering a program that just wants to exit reaches for (E1, E3 — a message and a hint, never a stack). ```ts function check(shape: Record, resolution: Resolution): Record; ``` | Parameter | Type | | :-- | :-- | | `shape` | `Record` | | `resolution` | `Resolution` | **Returns** `Record` ### decodeFileContent A config file saved as UTF-16 — PowerShell's `Out-File` default on Windows — is not valid UTF-8, and reading it as UTF-8 produces text no parser can use. A UTF-8 BOM is deliberately left where it is: that is what 10.0.1 does, and one of its four cases asserts it. ```ts function decodeFileContent(buffer: Buffer): string; ``` | Parameter | Type | | :-- | :-- | | `buffer` | `Buffer` | **Returns** `string` ### explanationEvent The agent rendering: the family's `{ event, data }` envelope over the same projection. ```ts function explanationEvent(e: Explanation): ExplanationEvent; ``` | Parameter | Type | | :-- | :-- | | `e` | `Explanation` | **Returns** `ExplanationEvent` ### explanationJson The `--json` rendering: the same record, with `set` spelled out rather than inferred from a missing key. ```ts function explanationJson(e: Explanation): ExplanationJson; ``` | Parameter | Type | | :-- | :-- | | `e` | `Explanation` | **Returns** `ExplanationJson` ### getDefaultSearchPlaces `.foorc`, `.foorc.json`, `foo.config.js`, … — the async explorer's twenty-one places, in order. ```ts function getDefaultSearchPlaces(moduleName: string): string[]; ``` | Parameter | Type | | :-- | :-- | | `moduleName` | `string` | **Returns** `string[]` ### getDefaultSearchPlacesSync The same list minus every `.mjs`: a synchronous load cannot import an ES module. ```ts function getDefaultSearchPlacesSync(moduleName: string): string[]; ``` | Parameter | Type | | :-- | :-- | | `moduleName` | `string` | **Returns** `string[]` ### getPropertyByPath A property name, or a period-delimited path, or an array of names. ```ts function getPropertyByPath(source: unknown, path: string | readonly string[]): unknown; ``` | Parameter | Type | | :-- | :-- | | `source` | `unknown` | | `path` | `string \| readonly string[]` | **Returns** `unknown` ### loaderFor The loader for a path, or the `USAGE` refusal that names what is missing. ```ts function loaderFor(filepath: string, loaders?: Readonly>): Loader; ``` | Parameter | Type | | :-- | :-- | | `filepath` | `string` | | `loaders` (optional) | `Readonly>` | **Returns** `Loader` ### loadPath Read one file and parse it. The object check is here rather than in each loader so a caller's loader cannot be the reason a non-object reaches the resolver. ```ts function loadPath(filepath: string, options?: LoadOptions): Promise>; ``` | Parameter | Type | | :-- | :-- | | `filepath` | `string` | | `options` (optional) | `LoadOptions` | **Returns** `Promise>` ### search The nearest match, or `undefined`. ```ts function search(names: string | readonly string[], options: SearchOptions): Found | undefined; ``` | Parameter | Type | | :-- | :-- | | `names` | `string \| readonly string[]` | | `options` | `SearchOptions` | **Returns** `Found \| undefined` ### searchAll Every match on the way up, nearest directory first and, within a directory, in the order given. ```ts function searchAll(names: string | readonly string[], options: SearchOptions): Found[]; ``` | Parameter | Type | | :-- | :-- | | `names` | `string \| readonly string[]` | | `options` | `SearchOptions` | **Returns** `Found[]` ### truthTable Every combination of `order`, grouped by winner, highest first: each source, then every subset of the sources below it in increasing binary order, and last the row with nothing set. 2ⁿ rows for n sources, each row and its `set` frozen. ```ts function truthTable(order: readonly S[]): TruthRow[]; ``` | Parameter | Type | | :-- | :-- | | `order` | `readonly S[]` | **Returns** `TruthRow[]` ### validate Every violation, in the order the shape declares its options — never the first one alone. A config file with three mistakes should be fixable in one pass, not three runs. ```ts function validate(shape: Record, resolution: Resolution): Violation[]; ``` | Parameter | Type | | :-- | :-- | | `shape` | `Record` | | `resolution` | `Resolution` | **Returns** `Violation[]` ## Classes ### LoaderError A format nobody declared a loader for. ```ts class LoaderError extends Error { readonly extension: string; readonly hint: string; readonly exitCode = 2; constructor(message: string, extension: string, hint: string); } ``` ## Constants ### builtinLoaders The four Node can read unaided. Frozen: one caller's `loaders['.json'] = …` must not reach another's. ```ts const defaultLoaders: Readonly>; ``` ### defaultLoaders The async loader table. Frozen: `index.test.ts` asserts that deleting a key throws. ```ts const defaultLoaders: Readonly>; ``` ### defaultLoadersSync The sync table: no `.mjs`, no `.mts`, and `loadJsSync` in place of `loadJs`. ```ts const defaultLoadersSync: Readonly>; ``` ### globalConfigSearchPlaces Tried in the user's global config directory, which is always the last directory a `global` search visits. ```ts const globalConfigSearchPlaces: string[]; ``` ### globalConfigSearchPlacesSync The same, minus `.mjs`. ```ts const globalConfigSearchPlacesSync: string[]; ``` ### metaSearchPlaces Where cosmiconfig looks for its *own* configuration — the `cosmiconfig` key of a `.config/config.*` or a `package.json`. Internal to 10.0.1 and exported here because the meta explorer is the one part of the option pipeline a caller can observe. ```ts const metaSearchPlaces: string[]; ``` ### NOT_BUNDLED The formats a caller may supply and this package will not bundle, listed so the refusal can name them and so a reader can see the 747 M/wk that is being declined rather than missed. ```ts const NOT_BUNDLED: readonly string[]; ``` ### TRUTH_TABLE The table for the five built-in sources: 32 rows, frozen to the last array. ```ts const TRUTH_TABLE: readonly TruthRow[]; ``` ### WALK_LIMIT How many directories one walk may visit. ```ts const WALK_LIMIT = 64; ``` ## Interfaces ### ExplanationEvent ```ts interface ExplanationEvent { event: 'config.explain'; data: ExplanationJson; } ``` ### ExplanationJson ```ts interface ExplanationJson { option: string; value?: unknown; source?: string; location?: string; line?: number; candidates: ExplanationCandidateJson[]; } ``` ### Found ```ts interface Found { /** The file, absolute. */ path: string; /** The directory it was found in, absolute. */ dir: string; /** How many steps up from `cwd` — `0` is `cwd` itself. What `--explain` prints as "found N levels up". */ depth: number; } ``` ### LoadOptions ```ts interface LoadOptions { /** Merged over `defaultLoaders`; a caller may add a format or replace a builtin. */ loaders?: Readonly>; } ``` ### SearchOptions ```ts interface SearchOptions { /** Where the walk starts. The first directory checked, not the first one above it. */ cwd: string; /** The highest directory the walk may look in, inclusive. Defaults to the filesystem root. */ stopAt?: string; /** Directories to visit at most. Defaults to `WALK_LIMIT`; a smaller number wins. */ limit?: number; /** Injected for tests and for a caller with its own filesystem. Defaults to `existsSync`. */ exists?: (path: string) => boolean; /** Injected the same way. Defaults to `realpathSync`, and a path that cannot be resolved is used as written. */ realpath?: (path: string) => string; } ``` ### Shape What validation reads from a manifest, and no more. ```ts interface Shape { type?: string; choices?: readonly unknown[]; required?: boolean; default?: unknown; } ``` ### TruthRow One combination: the sources that set a value, in the order given, and the one that wins. ```ts interface TruthRow { readonly set: readonly S[]; /** The first source of `set` in the order, or `null` when nothing set a value. */ readonly winner: S | null; } ``` ### Violation ```ts interface Violation { key: string; /** The whole sentence, ready to print: the rule, then where the value came from. */ message: string; /** What the value should have been — `'a string'`, `'one of fast, safe'`. */ expected: string; value: unknown; /** Absent only for a required option that no source set: there is no origin to name. */ provenance?: Provenance; } ``` ## Types ### Loader What a loader is handed and what it gives back. The same shape `cosmiconfig` uses, so a caller's loader ports unchanged. ```ts type Loader = (filepath: string, content: string) => unknown; ``` ## Re-exported Documented on the page of the entry point that declares them. | Export | Kind | Documented in | | :-- | :-- | :-- | | `candidates` | function | [`seniority/config`](/docs/api/config#candidates) | | `cosmiconfig` | function | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#cosmiconfig) | | `cosmiconfigSync` | function | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#cosmiconfigsync) | | `deepMerge` | function | [`seniority/config`](/docs/api/config#deepmerge) | | `discover` | function | [`seniority/config`](/docs/api/config#discover) | | `envBoolean` | function | [`seniority/precedence`](/docs/api/precedence#envboolean) | | `envName` | function | [`seniority/precedence`](/docs/api/precedence#envname) | | `explain` | function | [`seniority/explain`](/docs/api/explain#explain) | | `explanation` | function | [`seniority/explain`](/docs/api/explain#explanation) | | `lineOf` | function | [`seniority/config`](/docs/api/config#lineof) | | `loadWithExtends` | function | [`seniority/config`](/docs/api/config#loadwithextends) | | `packageLayer` | function | [`seniority/config`](/docs/api/config#packagelayer) | | `renderExplanation` | function | [`seniority/explain`](/docs/api/explain#renderexplanation) | | `resolve` | function | [`seniority/precedence`](/docs/api/precedence#resolve) | | `screaming` | function | [`seniority/precedence`](/docs/api/precedence#screaming) | | `ConfigError` | class | [`seniority/precedence`](/docs/api/precedence#configerror) | | `Explorer` | class | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#explorer) | | `ExplorerSync` | class | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#explorersync) | | `ORDER` | const | [`seniority/precedence`](/docs/api/precedence#order) | | `RANK` | const | [`seniority/precedence`](/docs/api/precedence#rank) | | `Candidate` | interface | [`seniority/precedence`](/docs/api/precedence#candidate) | | `CommonOptions` | interface | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#commonoptions) | | `Discovery` | interface | [`seniority/config`](/docs/api/config#discovery) | | `Explanation` | interface | [`seniority/explain`](/docs/api/explain#explanation) | | `KeyOrigin` | interface | [`seniority/precedence`](/docs/api/precedence#keyorigin) | | `Layer` | interface | [`seniority/precedence`](/docs/api/precedence#layer) | | `Layers` | interface | [`seniority/precedence`](/docs/api/precedence#layers) | | `LoadConfigOptions` | interface | [`seniority/config`](/docs/api/config#loadconfigoptions) | | `Loaded` | interface | [`seniority/config`](/docs/api/config#loaded) | | `Options` | interface | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#options) | | `OptionSpec` | interface | [`seniority/precedence`](/docs/api/precedence#optionspec) | | `Provenance` | interface | [`seniority/precedence`](/docs/api/precedence#provenance) | | `PublicExplorer` | interface | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#publicexplorer) | | `PublicExplorerSync` | interface | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#publicexplorersync) | | `Resolution` | interface | [`seniority/precedence`](/docs/api/precedence#resolution) | | `SourceLayer` | interface | [`seniority/precedence`](/docs/api/precedence#sourcelayer) | | `BuiltinSource` | type | [`seniority/precedence`](/docs/api/precedence#builtinsource) | | `Config` | type | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#config) | | `CosmiconfigResult` | type | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#cosmiconfigresult) | | `Loaders` | type | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#loaders) | | `OptionsSync` | type | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#optionssync) | | `SearchStrategy` | type | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#searchstrategy) | | `Source` | type | [`seniority/precedence`](/docs/api/precedence#source) | | `Transform` | type | [`seniority/cosmiconfig`](/docs/api/cosmiconfig#transform) | --- # seniority/lilconfig > Every export of seniority/lilconfig, with its signature and doc comment: lilconfig, lilconfigSync, defaultLoadersSync, defaultLoaders, plus 11 types. Source: https://seniority.interlace.tools/docs/api/lilconfig ```ts import { lilconfig, lilconfigSync, defaultLoadersSync, … } from 'seniority/lilconfig'; ``` ## Functions ### lilconfig The async explorer. Its five keys, in this order, are asserted against cosmiconfig's. ```ts function lilconfig(name: string, options?: Options): AsyncSearcher; ``` | Parameter | Type | | :-- | :-- | | `name` | `string` | | `options` (optional) | `Options` | **Returns** `AsyncSearcher` ### lilconfigSync The sync explorer. Same walk, `fs.accessSync`/`fs.readFileSync`, and `require` for scripts. ```ts function lilconfigSync(name: string, options?: OptionsSync): SyncSearcher; ``` | Parameter | Type | | :-- | :-- | | `name` | `string` | | `options` (optional) | `OptionsSync` | **Returns** `SyncSearcher` ## Constants ### defaultLoaders lilconfig's async table: `import()` for the three script extensions, `JSON.parse` for data. ```ts const defaultLoaders: Loaders; ``` ### defaultLoadersSync lilconfig's sync table. `.json` is `require`, not `JSON.parse` — that difference is graded. ```ts const defaultLoadersSync: LoadersSync; ``` ## Interfaces ### AsyncSearcher ```ts interface AsyncSearcher extends ClearCaches { search: (searchFrom?: string) => Promise; load: (filepath: string) => Promise; } ``` ### Options ```ts interface Options extends OptionsBase { loaders?: Loaders; transform?: Transform; } ``` ### OptionsSync ```ts interface OptionsSync extends OptionsBase { loaders?: LoadersSync; transform?: TransformSync; } ``` ### SyncSearcher ```ts interface SyncSearcher extends ClearCaches { search: (searchFrom?: string) => LilconfigResult; load: (filepath: string) => LilconfigResult; } ``` ## Types ### LilconfigResult ```ts type LilconfigResult = null | { filepath: string; config: unknown; isEmpty?: boolean; }; ``` ### Loader ```ts type Loader = LoaderSync | ((filepath: string, content: string) => Promise); ``` ### Loaders ```ts type Loaders = Record; ``` ### LoadersSync ```ts type LoadersSync = Record; ``` ### LoaderSync ```ts type LoaderSync = (filepath: string, content: string) => unknown; ``` ### Transform ```ts type Transform = TransformSync | ((result: LilconfigResult) => Promise); ``` ### TransformSync ```ts type TransformSync = (result: LilconfigResult) => LilconfigResult; ``` --- # seniority/plugin > Every export of seniority/plugin, with its signature and doc comment: validate, register, reset, registered, sources, CONTRACT and 1 more, plus 5 types. Source: https://seniority.interlace.tools/docs/api/plugin The plugin host for seniority's half of the contract (`plugin-contract` R1, R5a, R6, R7, R8). ```ts import { validate, register, reset, … } from 'seniority/plugin'; ``` ## Functions ### register Register a plugin. Later wins at an equal rank, like ESLint flat config: the array is ordered, a caller reads it top to bottom, and the last word on a source is the one nearest the program. ```ts function register(plugin: unknown): void; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `void` ### registered The plugins registered, in registration order — oldest first, which is the order a reader reads them in. ```ts function registered(): readonly Plugin[]; ``` **Returns** `readonly Plugin[]` ### reset Forget every registered plugin. For tests, and for a program that re-registers at runtime. ```ts function reset(): void; ``` **Returns** `void` ### sources Every registered source, read against this runtime and sorted by rank — the array `resolve` takes as `layers.sources`. ```ts function sources(runtime: SourceRuntime): SourceLayer[]; ``` | Parameter | Type | | :-- | :-- | | `runtime` | `SourceRuntime` | **Returns** `SourceLayer[]` ### validate Refuse a plugin that cannot contribute a source, at the door. ```ts function validate(plugin: unknown): asserts plugin is Plugin; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `asserts plugin is Plugin` ## Classes ### PluginError A refused plugin says what is wrong and what to do about it — the family's one vocabulary. ```ts class PluginError extends Error { readonly code: PluginErrorCode; readonly fix: string; constructor(code: PluginErrorCode, message: string, fix: string); } ``` ## Constants ### CONTRACT The plugin contract version. One number for the family — the same `1` flagstaff declares, written out rather than imported for the reason in the file comment above. ```ts const CONTRACT = 1; ``` ## Interfaces ### Plugin The keys seniority reads. Declared structurally: any object with these fields is a plugin here, whatever else it carries. ```ts interface Plugin { name: string; contract?: number; sources?: Record; } ``` ### SourceRead What a `read` returns when it has something to say. ```ts interface SourceRead { values: Record; /** Where a person would look. Falls back to the source's `location`, then to its name. */ location?: string; } ``` ### SourceRuntime What a source is handed. Declared structurally and deliberately small: `env` and `cwd` are what a vault, a CI variable set or a remote config needs, and anything a plugin could do with `process` directly it should be given instead (R11, Y9). ```ts interface SourceRuntime { env: Record; cwd: string; } ``` ### SourceSpec One source a plugin contributes, keyed in `sources` by the name `--explain` prints. ```ts interface SourceSpec { /** Against `RANK`: strictly between `RANK.flag` and `RANK.default`. */ rank: number; values?: Record; location?: string; read?: (runtime: SourceRuntime) => SourceRead | undefined; } ``` ## Types ### PluginErrorCode ```ts type PluginErrorCode = 'E_PLUGIN_SCHEMA' | 'E_PLUGIN_CONTRACT' | 'E_NO_CONTRIBUTION'; ``` --- # seniority/precedence > Every export of seniority/precedence, with its signature and doc comment: screaming, envName, envBoolean, resolve, ORDER, RANK and 1 more, plus 10 types. Source: https://seniority.interlace.tools/docs/api/precedence One precedence order, fixed and not configurable (commander-env V1–V3, V5): ```ts import { screaming, envName, envBoolean, … } from 'seniority/precedence'; ``` ## Functions ### envBoolean Booleans from env accept one spelling each way (R7). ```ts function envBoolean(raw: string): boolean | undefined; ``` | Parameter | Type | | :-- | :-- | | `raw` | `string` | **Returns** `boolean \| undefined` ### envName ```ts function envName(name: string, spec: OptionSpec, prefix: string | undefined): string | undefined; ``` | Parameter | Type | | :-- | :-- | | `name` | `string` | | `spec` | `OptionSpec` | | `prefix` | `string \| undefined` | **Returns** `string \| undefined` ### resolve Every declared option, resolved through the layers; a missing required one is left undefined for the caller to report. ```ts function resolve(specs: Record, layers: Layers): Resolution; ``` | Parameter | Type | | :-- | :-- | | `specs` | `Record` | | `layers` | `Layers` | **Returns** `Resolution` ### screaming `region` → `REGION`, `dryRun` → `DRY_RUN`, `log-level` → `LOG_LEVEL` (yargs #2005: never camel-cased back). ```ts function screaming(name: string): string; ``` | Parameter | Type | | :-- | :-- | | `name` | `string` | **Returns** `string` ## Classes ### ConfigError A value that cannot be used as configured: exit CONFIG (3), never a stack (E1, E3). ```ts class ConfigError extends Error { readonly hint?: string | undefined; constructor(message: string, hint?: string | undefined); } ``` ## Constants ### ORDER The precedence, highest first — **the one declaration** (R1, R14). ```ts const ORDER: readonly ["flag", "env", "config", "package", "default"]; ``` ### RANK ```ts const RANK: Readonly>; ``` ## Interfaces ### Candidate ```ts interface Candidate { source: Source; location: string; /** `undefined` when the layer had nothing for this option. */ value: unknown; /** The line in `location` that set it, when the layer knows (R3). */ line?: number; } ``` ### KeyOrigin Where one key of a layer was set: the file, and the line when the loader could tell. ```ts interface KeyOrigin { path: string; line?: number; } ``` ### Layer ```ts interface Layer { path: string; data: Record; /** * Key → the line in `path` that sets it, when the loader could tell (R3, R12). Optional * everywhere: a `.js` config has no line a parser can hand back without a parser, and a * missing line is reported as a missing line rather than as line zero. */ lines?: Record; /** * Key → the file, and its line, that set it, for a layer merged from several files (R3, R7). * A config that `extends` another is one layer with one `path`; without this, a value only * the parent set is cited at the child, a file that does not contain it. A key absent here is * cited at `path` and `lines`. */ origins?: Readonly>; } ``` ### Layers ```ts interface Layers { /** What the user typed: only options present on the command line. */ flags: Record; env: Record; /** With a prefix, an option without `env:` reads `PREFIX_OPTION_NAME` (R2). */ envPrefix?: string; config?: Layer; /** The `package.json` field named after the program, when present. */ pkg?: Layer; /** Plugin-contributed sources, already read — see `seniority/plugin`'s `sources()`. */ sources?: readonly SourceLayer[]; } ``` ### OptionSpec What resolution needs to know about an option, and no more. ```ts interface OptionSpec { /** Only `'boolean'` changes how the environment is read; everything else stays text. */ type?: string; /** Environment variable consulted when the flag is absent (V2). */ env?: string; default?: unknown; } ``` ### Provenance ```ts interface Provenance { source: Source; /** The env name, the config file, or `package.json` — where a person would look. */ location?: string; /** The line within `location`, when the layer recorded one (R3). A file source may; an env name cannot. */ line?: number; } ``` ### Resolution ```ts interface Resolution { values: Record; provenance: Record; candidates: Record; } ``` ### SourceLayer A layer contributed by something other than the five — the `sources` plugin host, already read, so `resolve` stays pure over what it is handed (R2, R11). ```ts interface SourceLayer { /** The kind `--explain` prints and `provenance.source` carries: the plugin's own name for it. */ source: string; /** Where a person would look — a path, a URL, a variable set. */ location: string; /** Against `RANK`; strictly between `RANK.flag` and `RANK.default`. */ rank: number; data: Record; } ``` ## Types ### BuiltinSource The five seniority resolves itself. ```ts type BuiltinSource = (typeof ORDER)[number]; ``` ### Source An **open** union (R13, PLAN D5). A plugin's source is a `Source` seniority has never heard of; `(string & {})` keeps the five as completions while admitting the rest, so the `sources` host of PLAN 1.3 is the additive change it reads as rather than a type break. ```ts type Source = BuiltinSource | (string & {}); ``` --- # seniority/rc > Every export of seniority/rc, with its signature and doc comment: parse, rc, rc, plus 3 types. Source: https://seniority.interlace.tools/docs/api/rc ```ts import rc from 'seniority/rc'; import { parse, rc } from 'seniority/rc'; ``` ## Functions ### default The default export, declared as `rc`. rc, with its world as an argument. ```ts function rc(name: string, defaults?: RcConfig | string, argv?: RcConfig, parseWith?: RcParse, options?: RcOptions): RcConfig; ``` | Parameter | Type | | :-- | :-- | | `name` | `string` | | `defaults` (optional) | `RcConfig \| string` | | `argv` (optional) | `RcConfig` | | `parseWith` (optional) | `RcParse` | | `options` (optional) | `RcOptions` | **Returns** `RcConfig` ### module.exports What `require()` returns: the default export, `rc`, under the name Node's `require(esm)` reads. ### parse rc's own `cc.parse`: a document starting with `{` is JSON — comments allowed — and anything else is INI, which this package does not bundle a parser for (constraint 3). The refusal names `parse`, rc's fourth parameter, so supplying `ini.parse` is one argument. ```ts function parse(content: string): RcConfig; ``` | Parameter | Type | | :-- | :-- | | `content` | `string` | **Returns** `RcConfig` ### rc rc, with its world as an argument. ```ts function rc(name: string, defaults?: RcConfig | string, argv?: RcConfig, parseWith?: RcParse, options?: RcOptions): RcConfig; ``` | Parameter | Type | | :-- | :-- | | `name` | `string` | | `defaults` (optional) | `RcConfig \| string` | | `argv` (optional) | `RcConfig` | | `parseWith` (optional) | `RcParse` | | `options` (optional) | `RcOptions` | **Returns** `RcConfig` ## Interfaces ### RcOptions The world, as an argument (R11). Every field has a default that touches no process: `cwd` resolves the empty path, `home` asks `os`, and `env` is **empty** — because guessing an environment is worse than not having one, which is the call `seniority/dotenv` already makes for `processEnv`. ```ts interface RcOptions { /** The environment to read `_*` from; the process's own when omitted, as rc does (D-135). */ env?: Record; /** Where the upward walk for `.rc` starts. */ cwd?: string; /** The home directory the four `~` places hang off. */ home?: string; /** Skip the `/etc` places, as rc does on Windows — and, as rc, by default only there. */ win?: boolean; } ``` ## Types ### RcConfig A parsed config document. rc places no shape on it, and neither does this. ```ts type RcConfig = Record; ``` ### RcParse rc's fourth parameter: turn a file's text into an object. ```ts type RcParse = (content: string) => RcConfig; ``` --- # seniority/yaml > Every export of seniority/yaml, with its signature and doc comment: parse, YAMLException, plus 1 type. Source: https://seniority.interlace.tools/docs/api/yaml YAML for configuration files, with no dependency (D-20260930-seniority-yaml). ```ts import { parse, YAMLException } from 'seniority/yaml'; ``` ## Functions ### parse Read one YAML document into plain values. Throws `YAMLException` for anything outside the subset above, and for input that is not YAML at all. ```ts function parse(text: string): unknown; ``` | Parameter | Type | | :-- | :-- | | `text` | `string` | **Returns** `unknown` ## Classes ### YAMLException A document this parser cannot read, with the reason and where. ```ts class YAMLException extends Error { readonly reason: string; readonly mark: Mark | undefined; constructor(reason: string, mark?: Mark, snippet?: string); } ``` ## Interfaces ### Mark Where an error is, 0-based, in the shape of js-yaml's `mark`. ```ts interface Mark { line: number; column: number; position: number; } ``` --- # Changelog > Every release of seniority, newest first, from its CHANGELOG.md — what changed and the pull request it came from. Source: https://seniority.interlace.tools/docs/changelog ## 1.2.1 ### Patch Changes - [#1483](https://github.com/ofri-peretz/burgee/pull/1483) [`3e69fb2`](https://github.com/ofri-peretz/burgee/commit/3e69fb2d8d068210347ac71d2886d6a4309859d1) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The package manifest names its author and publishes with `publishConfig` `{ "access": "public", "provenance": true }`. burgee's `homepage` is its landing page, https://burgee.interlace.tools. oxmutate gains `keywords`, so npm search finds it, and a `sideEffects` list (its CLI, its worker and its Vitest runner run on load), so a bundler can drop the rest. No code changes. ## 1.2.0 ### Minor Changes - [#1264](https://github.com/ofri-peretz/burgee/pull/1264) [`e739f18`](https://github.com/ofri-peretz/burgee/commit/e739f1822cfc196e280ee74c1a47c0d3543fc989) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Provenance now gives the line for a value set in the `package.json` field. The new `packageLayer(path, name)` in `seniority/config` (and the root) reads the field named after your program as the layer below the config file, and records the line each of its keys is set on. `explain` then prints `package.json field in /app/package.json:6`, not just the file. Pass the parsed manifest as a third argument if you already have it, and the file is read again only for the lines. `lineOf` now reads the top level of the JSON only. It used to return the first `"key":` anywhere in the file, so a nested key with the same name, written above the top-level one, was cited at the wrong line. A key set twice is cited at the last one, the one `JSON.parse` keeps. burgee: `--explain` and `meta.provenance` now give the line for a value set in the `package.json` field (`package.json:5`), as they already did for a config file. ### Patch Changes - [#1285](https://github.com/ofri-peretz/burgee/pull/1285) [`6570ff2`](https://github.com/ofri-peretz/burgee/commit/6570ff28a3aa8959548ee338dfb114cb9b7deab5) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `seniority/dotenv/cli` follows dotenv 18.0.9: `dotenv run` reads an empty `DOTENV_PATH` or `DOTENV_CONFIG_PATH` as unset and loads `./.env`, where it used to read the empty string as a path and stop with `EISDIR`. dotenv 18.0.9's own suite grades `seniority/dotenv` 197 / 197, level with dotenv itself. ## 1.1.1 ### Patch Changes - [#1258](https://github.com/ofri-peretz/burgee/pull/1258) [`e3b3a41`](https://github.com/ofri-peretz/burgee/commit/e3b3a41745ecdc853d741c457306c566541271b6) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `check` reads a `.json` plugin file as data. It handed every file to `import()`, and Node refuses a `.json` module without an import attribute (ERR_IMPORT_ATTRIBUTE_MISSING), so a plugin written as JSON got Node's message and no verdict, from the host's own `check` and through `burgee check`, which runs it. - [#1266](https://github.com/ofri-peretz/burgee/pull/1266) [`02e6f9f`](https://github.com/ofri-peretz/burgee/commit/02e6f9fcd779d892e7097659a6bd4c2f0e5b5a04) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family plugin schema (`schema.json`, identical in every plugin host) now describes `formats`, burgee's output-format key. This package reads nothing new, and a plugin carrying `formats` registers here as before. - [#1252](https://github.com/ofri-peretz/burgee/pull/1252) [`efc994e`](https://github.com/ofri-peretz/burgee/commit/efc994ea20dae1f31990a7afef9dfa3c34fee596) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The README names the Node builtin the package builds on, and says what the package adds over it: `Intl.Segmenter` and `util.stripVTControlCharacters` (linegauge), `process.loadEnvFile` and `util.parseEnv` (seniority), `node:child_process` (bellpull), `process.on(signal)` (closeout). The README's opening names the package and its incumbents only, and every benchmark figure in it links to where it is measured. ## 1.1.0 ### Minor Changes - [#1203](https://github.com/ofri-peretz/burgee/pull/1203) [`801875d`](https://github.com/ofri-peretz/burgee/commit/801875de1252846b59e5bfa024ca716818316847) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The precedence order is now exported as a truth table. `TRUTH_TABLE` has 32 rows, one for each combination of the five sources (flag, env, config, package, default). Each row lists the sources that set a value and names the one that wins. It is `truthTable(ORDER)`, so it always matches the order `resolve` uses, and the new docs page [The truth table](https://seniority.interlace.tools/docs/guides/truth-table) is generated from it. Provenance through `extends` now names the right file. When a config extends another, a value set only by the parent used to be credited to the extending file, which does not contain it. The loaded layer now has `origins`: for each key, the nearest file in the chain that set it, and that file's line. `resolve`, `explain` and `--json` provenance cite that file and line. A file that extends nothing loads the same as before. burgee: `--explain` and `meta.provenance` now give the line for a value set in a config file (`app.config.json:3`). They also name the file in an `extends` chain that set it, not the whole chain joined into one location. ## 1.0.12 ### Patch Changes - [#1186](https://github.com/ofri-peretz/burgee/pull/1186) [`af24328`](https://github.com/ofri-peretz/burgee/commit/af243288e27cbffbcffac18af7112172e46a003b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Published declarations now keep each doc comment's first paragraph, its code examples and its `@param`, `@returns`, `@throws`, `@example`, `@deprecated`, `@default` and `@see` tags, and drop the longer design notes below them, so the tarballs are smaller (burgee by 5.7% gzipped, bellpull and closeout by about a fifth). Types are unchanged, and hover text in an editor keeps its summary. `burgee`'s README also names `burgee/program-schema.json`, the JSON Schema every `--schema` document validates against. ## 1.0.11 ### Patch Changes - [#1160](https://github.com/ofri-peretz/burgee/pull/1160) [`0b381a0`](https://github.com/ofri-peretz/burgee/commit/0b381a0d394092edf2ea21a9143a10111aa72fe4) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Export `./package.json`. `require('/package.json')` threw `ERR_PACKAGE_PATH_NOT_EXPORTED`, which broke tools that read a dependency's manifest that way: bundlers, license scanners and version checks. ## 1.0.10 ### Patch Changes - [#1138](https://github.com/ofri-peretz/burgee/pull/1138) [`6b90ffe`](https://github.com/ofri-peretz/burgee/commit/6b90ffed8cd883b26f94fc3b690c1e2beb41ec83) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `seniority/dotenv` follows dotenv 18.0.8: `parse(src, { fast: true })` unquotes a segment that ends at a U+2028 or U+2029 inside an unquoted value, as the regex parser does, and `populate` takes and returns dotenv's own `DotenvPopulateInput` / `DotenvPopulateOutput`, now `string | undefined` because an `undefined` input is copied as given. dotenv 18.0.8's own suite grades it 193 / 193, level with dotenv itself. `seniority/lilconfig` follows lilconfig 3.1.4: its JSON loader drops a leading byte-order mark, so a BOM-prefixed `package.json` or rc file loads instead of throwing. lilconfig 3.1.4's suite grades it 77 / 77. ## 1.0.9 ### Patch Changes - [#1130](https://github.com/ofri-peretz/burgee/pull/1130) [`c05c86e`](https://github.com/ofri-peretz/burgee/commit/c05c86e2caa0fc3a89f97f7fe9c37bd7b876bfaf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every drop-in subpath now type-checks under `moduleResolution: node10`, and under `module: commonjs` with no resolution set, which is TypeScript's default there. `import … from 'burgee/commander'` used to fail with TS2307 in such a project, although `require()` of the same specifier worked. Each package now declares `types` and a `typesVersions` map, derived from `exports`, with one key per typed subpath pointing at the file `exports` already names. `exports` is unchanged, so `node16`, `nodenext` and `bundler` read exactly what they read before. `controlroom/ink`'s declarations no longer name the untyped `react-reconciler`, which was TS7016 for consumers with `skipLibCheck: false`. ## 1.0.8 ### Patch Changes - [#1075](https://github.com/ofri-peretz/burgee/pull/1075) [`48ccbaf`](https://github.com/ofri-peretz/burgee/commit/48ccbaf47b5363978e080acbfd5f63add7655d46) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `seniority/dotenv/cli` on Windows: `dotenv run` now refuses an argument with a line break or NUL in it, and an environment with a variable name ending in `^`, before anything starts. It reports `dotenv: …` and exits 1, because cmd.exe cannot carry those. It also escapes `:` for cmd, so `%name:…%` can no longer expand a variable into the command line. Each closes a way a crafted argument could reach cmd.exe live. Arguments that are not refused reach the program exactly as before. ## 1.0.7 ### Patch Changes - [#1045](https://github.com/ofri-peretz/burgee/pull/1045) [`c4fd9a3`](https://github.com/ofri-peretz/burgee/commit/c4fd9a3cb10bc36bef8c468365addfc6deb572cf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family plugin schema (`schema.json`, identical in every plugin host) now describes `locales`, burgee's locale-pack key. A plugin carrying `locales` validates against the shared schema as it does against `burgee/locale-pack-schema.json`, which lists every message id. This package reads nothing new, and a plugin carrying `locales` registers here as before. ## 1.0.6 ### Patch Changes - [#1022](https://github.com/ofri-peretz/burgee/pull/1022) [`ecb0a00`](https://github.com/ofri-peretz/burgee/commit/ecb0a00ca68e9a3b3584aefabb9dcf97e3ff967a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The tarball no longer carries declaration files that no `exports` entry reaches. TypeScript could never open them, because a deep import is refused by `exports`. Every entry's types are unchanged. The ten packages drop 71 files and 118,312 bytes between them, burgee 35 files and 76,504 bytes, which takes its tarball from 251,814 to 225,278 bytes gzipped. ## 1.0.5 ### Patch Changes - [#1005](https://github.com/ofri-peretz/burgee/pull/1005) [`079250e`](https://github.com/ofri-peretz/burgee/commit/079250ebc3e0cc511df1269060854711f686b5b4) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `seniority/find-up` is now find-up 8's surface, graded by find-up 8.0.0's own suite: 104 of 104, level with find-up itself. It adds a matcher function with `findUpStop`, `findDown` and `findDownSync`, and locate-path's `type` and `allowSymlinks`; `cwd` and `stopAt` take a `URL`. Three behaviours follow find-up rather than seniority's resolver walk: `cwd` is optional and defaults to the process's working directory, a name matches a file unless `type` says otherwise, and `findUpMultiple` returns the first matching name per directory. `burgee migrate` now rewrites `find-up` to `seniority/find-up`. - [#1001](https://github.com/ofri-peretz/burgee/pull/1001) [`6f74a64`](https://github.com/ofri-peretz/burgee/commit/6f74a647c107307f11e1aef7092cf4e1afc6146d) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The README no longer says every path is graded: `seniority/find-up` has no vendored `find-up` suite yet, so it now says "not graded yet" until it has a number. ## 1.0.4 ### Patch Changes - [#977](https://github.com/ofri-peretz/burgee/pull/977) [`2e2030e`](https://github.com/ofri-peretz/burgee/commit/2e2030e4ae6f7151e1dba876b1b63d49a37afa19) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `seniority/dotenv` follows dotenv 18.0.7: `config()` treats an option passed as `undefined` as not passed, so `{ path: undefined, quiet: undefined }` keeps the `DOTENV_PATH` / `DOTENV_QUIET` (and `DOTENV_CONFIG_*`) defaults instead of overriding them, and `dotenv run` (`seniority/dotenv/cli`) honours a `DOTENV_QUIET` set in the `.env` it loads when neither a flag nor the starting environment said otherwise. dotenv 18.0.7's own suite grades it 191 / 191, level with dotenv itself. ## 1.0.3 ### Patch Changes - [#938](https://github.com/ofri-peretz/burgee/pull/938) [`9babb6d`](https://github.com/ofri-peretz/burgee/commit/9babb6da1ae7783c7e8367f89e3da9dffc85ffdb) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - npm description and README: the description says what the package does, then its dependency fact in one of two wordings ("Zero dependencies." or "No dependency outside the burgee family."), then its drop-in paths. The README's family section states how the family is built — the leaves, one-way dependencies, nothing from outside the family, trusted publishing with provenance — and its table column is "Migrates from". No API, compatibility grade or benchmark changes. ## 1.0.2 ### Patch Changes - [#917](https://github.com/ofri-peretz/burgee/pull/917) [`735f0f6`](https://github.com/ofri-peretz/burgee/commit/735f0f68048f2d0f5e4effad151ed5132b480bb8) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README: the opening paragraph says what the package does first. The drop-in path follows it as one line, naming the incumbent and `npx burgee migrate`, and every compatibility row and benchmark is unchanged. ## 1.0.1 ### Patch Changes - [#897](https://github.com/ofri-peretz/burgee/pull/897) [`f29f159`](https://github.com/ofri-peretz/burgee/commit/f29f159ca0c9746f58d8ba84eec9be72b1140093) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - controlroom has its own docs site at https://controlroom.interlace.tools: its README's Docs line, its `homepage`, and every family table now link there. ## 1.0.0 ### Major Changes - [#862](https://github.com/ofri-peretz/burgee/pull/862) [`6aab9c5`](https://github.com/ofri-peretz/burgee/commit/6aab9c5f2e3b3c5cd551b723022952660d681f16) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - seniority 1.0.0. The spec is fully built, every drop-in passes its incumbent's own suite at the latest release level with the incumbent itself (dotenv 18.0.6 181 / 181, lilconfig 3.1.3 77 / 77, rc 1.2.8 1 / 1, cosmiconfig 10.0.1 242 / 243 against its own 242), and coverage is 100% on all four measures (D-20261008-seniority-1-0-evidence). The published entries, the plugin `schema.json` and the `seniority` bin become a semver contract. No API changed in this release. ### Patch Changes - [#848](https://github.com/ofri-peretz/burgee/pull/848) [`6ae533e`](https://github.com/ofri-peretz/burgee/commit/6ae533e4610c32bad557f5d74a24d0c9a0f07f7e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family `schema.json` describes what five values look like. `contract` is `1`, and burgee refuses a plugin that declares none. A caique widget's `static(spec)` gets `{ kind, message, ...sample.done }`. A seniority `rank` sits between the built-in layers at flag 0, environment 10, config file 20, `package.json` 30 and default 40. A burgee hook's `filter` is `{ command: RegExp }`, now with `command` required, so the schema and the host refuse the same filters. - [#857](https://github.com/ofri-peretz/burgee/pull/857) [`d80b2d0`](https://github.com/ofri-peretz/burgee/commit/d80b2d0f4a535400c6825b42ac2d8e1be2827f4e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family `schema.json` closes a bellpull resolver's `when` with `additionalProperties: false`, so the schema and bellpull's own validation refuse the same unknown keys. - [#848](https://github.com/ofri-peretz/burgee/pull/848) [`6ae533e`](https://github.com/ofri-peretz/burgee/commit/6ae533e4610c32bad557f5d74a24d0c9a0f07f7e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README now shows a whole plugin as a default export, the command that checks it (from an install, and from a clone where `dist/` has to be built first), and the report `check` prints when the plugin passes. caique says what a widget's `static(spec)` receives. seniority gives the `rank` for each slot between the built-in layers. bellpull says that `{VAR}` is the only substitution a path gets. - [#847](https://github.com/ofri-peretz/burgee/pull/847) [`6d3eda2`](https://github.com/ofri-peretz/burgee/commit/6d3eda268a7368acf9051ed31564813a2ac31c38) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `seniority/dotenv` follows dotenv 18.0.6: `populate` reads `override` and `debug` the way dotenv's `parseBoolean` does, so the string `'false'` (or `'0'`, `'no'`, `'off'`, `''`) turns them off instead of on. dotenv 18.0.6's own suite grades it 181 / 181, level with dotenv itself. ## 0.8.0 ### Minor Changes - [#828](https://github.com/ofri-peretz/burgee/pull/828) [`8118d5c`](https://github.com/ofri-peretz/burgee/commit/8118d5c8754a09a61c8b050169d9515121908591) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `seniority/dotenv` now follows dotenv 18, graded by dotenv 18.0.5's own suite at 179 / 179, level with dotenv itself (it was 106 / 141 against 17.4.2). `config()` takes its defaults from `DOTENV_PATH`, `DOTENV_ENCODING`, `DOTENV_QUIET`, `DOTENV_DEBUG`, `DOTENV_OVERRIDE` and `DOTENV_FAST`, or their older `DOTENV_CONFIG_*` names. It reports `◇ injected env (n) from ` on `console.error` unless quiet, and its debug lines behind `┆` on `console.log`. `parse(src, { fast: true })` is dotenv's character scanner, and the regular-expression parser now expands `\n` in a value that opens with a double quote even when the quote never closes, as dotenv does. `configDotenv` is exported. Two entries are new: `seniority/dotenv/config` (`import 'dotenv/config'`, quiet unless asked) and `seniority/dotenv/cli` (`dotenv run`, with signal forwarding). `.env.vault`, `DOTENV_KEY` and `decrypt` are not built, because dotenv 18 removed them (D-20261001-seniority-dotenv-vault). `burgee migrate` now rewrites `dotenv` to `seniority/dotenv`, and `dotenv/config` and `dotenv/config.js` to `seniority/dotenv/config`, on dotenv 18. A project on dotenv 17 is left alone and listed under `offMajor`: 17's own suite grades the drop-in 107 / 141, because the façade speaks 18. ## 0.7.2 ### Patch Changes - [#816](https://github.com/ofri-peretz/burgee/pull/816) [`5230016`](https://github.com/ofri-peretz/burgee/commit/52300169a98ece41b3544e5316833464e63a7c83) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - controlroom hosts plugins (R10): `keymaps` and `panes` register through `controlroom/plugin`'s `register()` against the family schema, a screen takes either by name, and `controlroom check ` reports what a plugin contributes. The family schema every host ships gains the `keymaps` and `panes` definitions. ## 0.7.1 ### Patch Changes - [#786](https://github.com/ofri-peretz/burgee/pull/786) [`d810ac0`](https://github.com/ofri-peretz/burgee/commit/d810ac0d29ab11f04525d3394b29e83762853bcb) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The cosmiconfig drop-in's `global` search now ends in the directory cosmiconfig uses. That directory comes from `env-paths`: `$XDG_CONFIG_HOME/` on Linux, falling back to `~/.config/` when the variable is unset or empty, `%APPDATA%\\Config` on Windows, and `~/Library/Preferences/` on macOS. Before this fix, the drop-in built the directory from the home directory alone. A Linux user with a non-default `XDG_CONFIG_HOME` who moved to `seniority` with `burgee migrate` silently lost their global config. The environment is read when the search runs, through the package's one runtime seam, and only when `globalConfigDir` is not passed. On Linux, cosmiconfig 10.0.1's own suite now grades `seniority` at 242 / 243, the same as cosmiconfig itself (D-20260930-seniority-xdg-config-home). ## 0.7.0 ### Minor Changes - [#778](https://github.com/ofri-peretz/burgee/pull/778) [`24300f4`](https://github.com/ofri-peretz/burgee/commit/24300f42455836e73ba36daaf400c4ab2f8d1893) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `seniority`'s cosmiconfig drop-in now reads YAML the way cosmiconfig does. `.yaml`, `.yml` and extensionless rc files go through `seniority/yaml`, the package's own parser, which is loaded the first time a YAML file is read and never before. The loader used to read only the JSON subset of YAML and throw a `LoaderError` (`no YAML parser for `) for the rest. It now returns what `js-yaml` 5 returns. A malformed file throws cosmiconfig's own message, `YAML Error in :` followed by the reason and `(line:column)`. cosmiconfig 10.0.1's own suite grades `seniority` at 240 / 243, up from 186 / 243, level with the control's 240 / 243. `burgee migrate` now rewrites `cosmiconfig` to `seniority`, because the row is level. A file that imports a name `seniority` does not export, such as the type `LoaderSync`, is refused as `unknown-export` and stays on cosmiconfig. On Linux, the global config directory is still resolved from the home directory rather than from `XDG_CONFIG_HOME` (D-20260930-seniority-yaml). - [#776](https://github.com/ofri-peretz/burgee/pull/776) [`8ba7c33`](https://github.com/ofri-peretz/burgee/commit/8ba7c33b8196ee9324b129a6eb25ca9daa4eeeab) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - New entry, `seniority/yaml`: a YAML parser with no dependency. `parse(text)` returns what `js-yaml` 5's `load` returns for the part of YAML a configuration file is written in — block and flow mappings and sequences, plain, quoted and block scalars, comments, document markers, anchors and aliases, and the core schema (`null`, booleans, integers, floats, strings, and the `!!str` / `!!int` / … tags). Malformed input throws a `YAMLException` with js-yaml's reason and its 1-based `(line:column)`. Every expectation in its tests is also checked against js-yaml on every run. Nothing else in the package imports it, so a program that reads no YAML carries none of it. Pass it to `discover` as a loader to read YAML config files: `loaders: { '.yaml': (_path, text) => parse(text) }` with `extensions` naming `.yaml` (D-20260930-seniority-yaml). ## 0.6.5 ### Patch Changes - [#752](https://github.com/ofri-peretz/burgee/pull/752) [`22e6dae`](https://github.com/ofri-peretz/burgee/commit/22e6dae1b71bfa478265829a1b98d20a6f9de4f7) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - A plugin that declares `contract: 0` or a negative contract is now refused with `E_PLUGIN_CONTRACT`, as `schema.json`'s minimum of 1 always said. These hosts checked only that a contract was not newer than the one they know, so 0 and below registered. - [#749](https://github.com/ofri-peretz/burgee/pull/749) [`bc59cab`](https://github.com/ofri-peretz/burgee/commit/bc59cabea92366de1a4a48d7b5153046d8546794) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - At an equal rank the plugin registered later now wins, as the docs and `register()` always said. `sources()` returned tied plugins oldest first and `resolve` takes the first candidate with a value, so the earlier registration won the tie. ## 0.6.4 ### Patch Changes - [#674](https://github.com/ofri-peretz/burgee/pull/674) [`e9f45d8`](https://github.com/ofri-peretz/burgee/commit/e9f45d85d9db5b2e3dcaa1e43f292a1281a6952a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README: family header, badges, install, migrating, the family table. Every package README now opens the same way — lockup, tagline, one badge row in one order (npm version, downloads, Quality Gate, the package's own coverage, OpenSSF Scorecard, unpacked size, dependencies, types, Node, licence, npm provenance), a row of compatibility badges read from the graded baseline — and carries the same sections in the same order: Install for npm, pnpm, yarn and bun, Quick start, Migrating as a before/after diff, Compatibility, Benchmarks, For agents, API, and a generated table of the nine packages. Links are absolute, so they work on npm as well as GitHub. - [#731](https://github.com/ofri-peretz/burgee/pull/731) [`bc68493`](https://github.com/ofri-peretz/burgee/commit/bc684935589d3f3d8d5d297f292ae4ccdbb48c32) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `seniority/cosmiconfig` now matches cosmiconfig 10.0.1 in three places where it did not. - `stopDir: ''` searches the start directory alone, as upstream's truthiness check does. Before, an empty `stopDir` switched the search to `global` and walked up to the working directory and then into the global config directory. - `packageProp` walks a path the way upstream does. A path through a string reads the string's own properties, so `'name.length'` is a number. A path through a `null`, such as `"foo": null` under `packageProp: 'foo.bar'`, throws the `TypeError` upstream throws, annotated with the file. Before, both answered "not found", and the search moved on to the next file. - A start directory that cannot be `stat`ed for any reason but absence rejects the search with the `stat` error, as upstream's `isDirectory` does. Before, every such failure read as "no config here". Which paths fail that way is the platform's call: on Linux and macOS, `search('/sub')` rejects with `ENOTDIR` and a directory the process may not enter with `EACCES`; Windows reports `\sub` as not found, so there it is still "no config here", as it is upstream. `loadJson` no longer wraps a non-`Error` in an `Error`, because `JSON.parse` of a string throws nothing else. - [#732](https://github.com/ofri-peretz/burgee/pull/732) [`ee6780b`](https://github.com/ofri-peretz/burgee/commit/ee6780b4c9558de1143ff7e5983c16a71973b529) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Code no input could reach is removed. There is no behaviour change. - `seniority check` loses a "(replaces …)" helper it never called, and a map that returned each source name unchanged. It loads one plugin into an emptied registry, so there is nothing for a source to replace. The header now says that. - `seniority/lilconfig` no longer writes `dirname(p) || sep`, because Node's `dirname` never returns `''`. - `seniority/dotenv`'s parser no longer checks that a match has a key, because the key group in its pattern is not optional. - `seniority/rc`'s comment stripper reads characters with `charAt`, so there is no fallback for an index that is always in range. - The JSON loader behind `seniority/config` quotes the parser's own message without first checking that it threw an `Error`, because `JSON.parse` of a string throws nothing else. ## 0.6.3 ### Patch Changes - [#627](https://github.com/ofri-peretz/burgee/pull/627) [`4a629a9`](https://github.com/ofri-peretz/burgee/commit/4a629a9ff1129f2ce6f0c34e7cfc1d159304d18a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Lint with every published Interlace ESLint plugin, and fix what the upgrade surfaced. - caique: the inquirer theme merge skips `__proto__`, `constructor` and `prototype` keys, so a theme object cannot swap the merged object's prototype. - burgee: last-element reads use `.at(-1)`. - burgee, closeout, flagstaff, roundel: helpers that capture nothing from their enclosing function move to module scope. - seniority: suppression comments name the `no-dynamic-require` rule that now reports the config loader's dynamic `require`. No public API or output changes. ## 0.6.2 ### Patch Changes - [#604](https://github.com/ofri-peretz/burgee/pull/604) [`0e7b1e8`](https://github.com/ofri-peretz/burgee/commit/0e7b1e88a7021350cf689f109c7728f799be1589) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README links to its migration guides under the docs link: "Migrating from: chalk", "ora · log-update · boxen · cli-table3", and so on. That puts a path from the npm page to the guide for the library you are replacing. No code changes. ## 0.6.1 ### Patch Changes - [#588](https://github.com/ofri-peretz/burgee/pull/588) [`073037a`](https://github.com/ofri-peretz/burgee/commit/073037ab38b13490ab119c84122a46c7c605be14) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - READMEs and package descriptions now match what each drop-in path is graded at. bellpull names `bellpull/node-which` as the drop-in for npm `which` (5 / 5) and no longer lists execa as a drop-in. paratext's ansi-escapes row is 4 / 4, with the CSI half implemented. seniority's rc row is 1 / 1, dotenv's `config()` defaults to `process.env`, and lilconfig is 77 / 77. linegauge documents `ambiguousIsNarrow` and `strip` as shipped. No code changes. ## 0.6.0 ### Minor Changes - [#486](https://github.com/ofri-peretz/burgee/pull/486) [`f08ff58`](https://github.com/ofri-peretz/burgee/commit/f08ff582f0a0e1a5de90d9c7ff0c9401c867380c) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The `seniority/dotenv` and `seniority/rc` drop-ins now read the process by default, like the packages they replace. `config()` with no arguments populates `process.env` from `./.env`; `rc(name)` reads the process environment. `config()` also accepts a `URL` or `~/` path, calls `fs`/`os` in a way test stubs can intercept, and returns `parsed` alongside any `error`, matching dotenv 17. Graded by each incumbent's own test suite: dotenv 80 → 106 of 141, rc 0 → 1 of 1. seniority's resolver still never reads the process itself. ### Patch Changes - [#474](https://github.com/ofri-peretz/burgee/pull/474) [`1955419`](https://github.com/ofri-peretz/burgee/commit/19554194342b55f8893161f894a8c2a4df1b0f21) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - burgee plugins can hook two more stages. `parse` runs before the command is resolved: it receives argv and may return a replacement, which is how an alias plugin maps `d` to `deploy`. `shutdown` runs once as the program exits, whether the command succeeded or failed. The family `schema.json` shipped in every package now describes both stages. - [#522](https://github.com/ofri-peretz/burgee/pull/522) [`f4be6a8`](https://github.com/ofri-peretz/burgee/commit/f4be6a8733e338bea4483992edeaa72fcddd36fd) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `require('bellpull/cross-spawn')`, `require('flagstaff/cli-table3')`, `require('burgee/yargs')`, `require('seniority/dotenv')` and `require('seniority/rc')` now return what the incumbent's `require()` does — the function, the class, the factory, the object — instead of an ES module namespace. Each exports its default as `'module.exports'`, which is what Node hands a CommonJS caller, and which yargs' own entry already does. `const spawn = require('…'); spawn(…)` threw before. ## 0.5.1 ### Patch Changes - [#508](https://github.com/ofri-peretz/burgee/pull/508) [`1aae1e2`](https://github.com/ofri-peretz/burgee/commit/1aae1e2186ce88421067df5317795773419e53d0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - ` --help` and `--version` answer instead of crashing. The bin took its first argument as the plugin file to import, so `roundel --help` failed with `Cannot find module '…/--help'` and exit 1. `-h`/`--help` now print usage and exit 0, `-V`/`--version` print the version and exit 0, and any other flag where the plugin file belongs is a usage error, exit 2. ## 0.5.0 ### Minor Changes - [#507](https://github.com/ofri-peretz/burgee/pull/507) [`b8e97dc`](https://github.com/ofri-peretz/burgee/commit/b8e97dcb64772e413f0b6f9e17e063c73314d242) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Runs on Node 20 and 22, not just 24+: `engines.node` is now `^20.19.0 || >=22.13.0`. Those are the first releases where `require(esm)` loads without a warning, so the CommonJS `require()` path keeps working. Every package's test suite runs on exactly 20.19.0 and 22.13.0, on Linux, macOS and Windows. caique's prompts no longer call `Promise.withResolvers`, which Node 20 doesn't have. ### Patch Changes - [#505](https://github.com/ofri-peretz/burgee/pull/505) [`9800b43`](https://github.com/ofri-peretz/burgee/commit/9800b43d9c74a49dfb66d04a40fd0d1c48892e20) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Docs: the Benchmarks section's weight ceiling is re-measured against a fresh install of each incumbent's latest release (cosmiconfig 10.0.1, slice-ansi 9.0.1, which 7.0.0, dotenv 18.0.3, …) instead of the copies hoisted in this workspace, and names incumbents that were measured but left out of the ceiling as exactly that. ## 0.4.3 ### Patch Changes - [#494](https://github.com/ofri-peretz/burgee/pull/494) [`f7f6d4b`](https://github.com/ofri-peretz/burgee/commit/f7f6d4b8e8f9d9c7010bd4c81fda4b4d106fc9f0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each package's `homepage` and README docs link now point at its own documentation site, `https://.interlace.tools`, instead of a page on burgee's site. The old `burgee.interlace.tools/docs/packages/` URLs answer with a 301 to the new host, so nothing already linked breaks. closeout's README override example also resolves to the current release again (`npm:closeout@^0.4`; the 0.4.0 release left it at `^0.3`). ## 0.4.2 ### Patch Changes - [#465](https://github.com/ofri-peretz/burgee/pull/465) [`acf98f3`](https://github.com/ofri-peretz/burgee/commit/acf98f3e612c6d79e6c2b78a847abcd06a063cbc) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README now opens with the incumbent it replaces and the agent surface it serves (`--json`, an agent event, or a static projection), so npm shows both above the fold. README text only; no code changed. ## 0.4.1 ### Patch Changes - [#454](https://github.com/ofri-peretz/burgee/pull/454) [`b4584e7`](https://github.com/ofri-peretz/burgee/commit/b4584e719bc0064b294aab5ea6da1c11f698f0e9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package's npm `homepage` now points at its page on the docs site, `https://burgee.interlace.tools/docs/packages/`, and each README links it under the header. The keywords add what people and models search for: `burgee` gains `cli-framework`, `argument-parser`, `subcommands`, `json-schema`, `mcp-server`, `model-context-protocol`, `ai-agent`, `llm`, `shell-completion`, `typescript`, `zero-dependency`, `commander-alternative` and `yargs-alternative`; the other eight gain `agent`, `ai-agent`, `non-tty`, `json` and `zero-dependency` where the package does that — `zero-dependency` only on the six that install nothing at all. `burgee`'s README gains a short FAQ (commander alternative, agent use, MCP, dependencies) and states the compatibility counts the oracle holds — 1,360 / 1,360 of commander's tests and 804 / 804 of yargs' — where it had said 1,215 and 1,185. `caique`'s README no longer calls a released package pre-release. - [#442](https://github.com/ofri-peretz/burgee/pull/442) [`bdaf364`](https://github.com/ofri-peretz/burgee/commit/bdaf364f81564c1700cf1adec18f927afe6c60c9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now lists `plugin`, `plugins` and `extensible` in its npm keywords, because every package takes plugins through one shared contract. A plugin is a plain object, validated against the `schema.json` that ships in every package, and checked with the package's own `check` command. Each package reads its own key and ignores the rest, so one object can extend any subset of the family. The [plugins page](https://github.com/ofri-peretz/burgee/blob/main/apps/docs/content/docs/plugins.mdx) has a nine-layer example that every package's `check` accepts in CI. - [#435](https://github.com/ofri-peretz/burgee/pull/435) [`7888524`](https://github.com/ofri-peretz/burgee/commit/78885245eb292cd4a40541fe09382a198c9c45cf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` now describes every plugin host in the family. The one schema each package ships as its plugin contract used to cover only four hosts: roundel's `tokens`, flagstaff's `glyphs`, `spinners`, `borders` and `components`, paratext's `capabilities`, and linegauge's `widths`. Five hosts validated their keys in their own code, but the file an author (or a model) writes against said nothing about them. It now describes all of them: - bellpull `resolvers`, including the absolute-path rule on `paths` - caique `widgets` - closeout `handlers`, including the phases a plugin may use - seniority `sources`, including the rank bounds - burgee `commands`, `hooks` and `enforce` Where the schema can express a rule, it gives the same verdict as the host's own validator, and a test holds the two together. Function-valued fields (`static`, `run`, `read`, `handler`) are described and required, but not typed, because JSON Schema can't say "function". **flagstaff** now validates a plugin against only its own keys, not the whole family schema. It no longer refuses a plugin over another host's key, which lets one plugin object contribute to several hosts. Its entry points are also 4.7–5.9 KB lighter for it. - [#445](https://github.com/ofri-peretz/burgee/pull/445) [`dac303e`](https://github.com/ofri-peretz/burgee/commit/dac303e944e889ac4175ac38c94e4ca0f0ca5358) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now declares `sideEffects` truthfully, so bundlers can drop what you don't import. Six packages declared nothing, so no bundler could drop any of their modules. A named import from the root now bundles to the same bytes as the same import from its subpath: | import | before | after | | :------------------------------------ | ------: | ------: | | `import { explain } from 'seniority'` | 2,939 B | 1,067 B | | `import { decide } from 'caique'` | 1,235 B | 734 B | | `import { strip } from 'linegauge'` | 1,102 B | 940 B | | `import { once } from 'closeout'` | 353 B | 235 B | flagstaff and roundel used to declare `false`, but each ships a `check` command whose file runs when loaded. Each now lists that file, which is the true statement. paratext also lists the two modules that register its built-in capabilities when they load. ## 0.4.0 ### Minor Changes - [#421](https://github.com/ofri-peretz/burgee/pull/421) [`db3c59e`](https://github.com/ofri-peretz/burgee/commit/db3c59e3dcd373c7e6e4a057715adb173766523f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every plugin host has a `check` command. ```bash npx linegauge check ./my-widths.mjs npx burgee check ./my-plugin.mjs --json ``` PRINCIPLES 7 asks three things of an extension surface: the plugin is data validated against one published schema, there is a **`check` command that shows it every way it can be seen**, and the bar is measured. The first was built in all nine hosts; the second existed in `flagstaff` alone. So an author writing a plugin for any other host found out what it did by shipping it into a program — and a surface nobody can check is a surface nobody outside this repository can write against. Each command validates, registers, and shows what the host does with the plugin, in the host's own terms: linegauge measures each code point **before and after** the override, paratext shows a capability's `encode` **and** its `fallback`, roundel each token and what it replaced, caique each widget's static projection rendered with its own sample. burgee's returns a **document** rather than printing one, so `burgee check --json` is the form an agent that just wrote a plugin reads. They share one contract with the author, held identically across all nine: - a readable report, contribution by contribution, with **`ok` as the last line**; - a refusal with a code from the family's vocabulary and a `fix`, exit 1; - **`E_NO_CONTRIBUTION`** for a plugin that contributes nothing to this host — the schema allows unknown keys so one object registers everywhere, which makes a misspelled key silent, and this is how that typo tells on itself; - exit 2 with no file. Each host also gains an eval case measuring the one-turn claim, proved to discriminate before it was committed: green against a correct plugin, red against the same plugin with one field broken. - [#418](https://github.com/ofri-peretz/burgee/pull/418) [`c0fa8a3`](https://github.com/ofri-peretz/burgee/commit/c0fa8a37913fab17a6d06615b7432116b1c0e1db) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `explain` moves from `seniority/precedence` to `seniority/explain`. A re-export is not free across a package boundary. `explain` was exported from `precedence.ts`, so every program that resolved a configuration loaded `explain.js` whether or not anything ever explained one — **1,018 bundled bytes and one more module on the startup path** for the branch taken when a user asks _why did this option get that value_. `import { explain } from 'seniority'` is unchanged: the root barrel still exports it, from its new home. Only `seniority/precedence` stops re-exporting it. `burgee/config` re-exports it the same way it always did, and `burgee`'s engine loads it behind an `await import('seniority/explain')` on the `--explain` branch, which is now the only thing that pays for it. Measured on burgee's core entry: **28,637 → 27,552 bundled bytes**, and 22 → 21 modules for `import 'burgee'`. ### Patch Changes - [#430](https://github.com/ofri-peretz/burgee/pull/430) [`4d1b2b3`](https://github.com/ofri-peretz/burgee/commit/4d1b2b399cff354864d1e2e843a19fde80ef1f30) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `check` now reports every refusal with its code and its fix, wherever it was raised. Some plugin files register themselves on import: they call `register()` at the top of the module and export the result. Until now, when such a file was refused, the error was thrown inside `check`'s `import()`, before the only `try` that turns a `PluginError` into `E_PLUGIN_SCHEMA: …` plus a `fix:` line. The author got the bare message on stderr, with no code and no fix. Now the whole of `check` runs inside that one handler, so every refusal comes out the same way on every host. ## 0.3.1 ### Patch Changes - [#386](https://github.com/ofri-peretz/burgee/pull/386) [`5a85175`](https://github.com/ofri-peretz/burgee/commit/5a85175da66df5e797446eaada1c3492cc8b8fff) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The Unicode segmenter is built on first use rather than at import, and both packages now strip comments from what they publish. `new Intl.Segmenter()` loads ICU's grapheme-break data. Two were constructed at module scope, and almost nothing paid for them: every caller takes the ASCII fast path first, so a run of printable ASCII — a help screen, a flag name, a path — never reaches `segment()`. `segmenter` is now a function; the two call sites become `segmenter()`. `linegauge` and `seniority` were also the two published packages whose build never ran `strip-comments` at all. Unpacked: linegauge 83,538 → 56,148 and seniority 193,682 → 139,793. Together these take `import 'burgee'` from 56.87 ms to 44.39 ms, medians of seven. ## 0.3.0 ### Minor Changes - [#379](https://github.com/ofri-peretz/burgee/pull/379) [`955b979`](https://github.com/ofri-peretz/burgee/commit/955b9790560ad2e478a1abbcbe42ec2bc4ba7423) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `seniority/lilconfig` and `seniority/rc` — two new drop-in subpaths, and `seniority/dotenv` grows the default export its incumbent has. `seniority/lilconfig` is lilconfig 3.1.3's surface: `lilconfig`, `lilconfigSync`, `defaultLoaders`, `defaultLoadersSync`, and **no fifth runtime export**, because lilconfig's own suite compares the module's keys against cosmiconfig's. Graded at **67 / 77** against that suite, up from 0 — the same number its control scores, so no case in it now separates the two. The zero was not a missing feature: the row had been pointed at the package _root_, which presents cosmiconfig's surface and answers `lilconfigSync is not a function` seventy-seven times. `seniority/rc` is rc 1.2.8's merge with none of its four dependencies: the file stack in rc's own order, `__` nesting for environment keys, JSON-with-comments, `deep-extend`'s merge, and `configs` / `config` reporting which files were read. INI is refused by name with the argument that would parse it, the way YAML already is. Its environment arrives as an argument rather than off the process, which is the one divergence and the reason its compat row stays at 0 / 1. `seniority/dotenv` now has a default export carrying `config`, `parse` and `populate`, so a CJS caller `require()`ing it gets the same mutable object `require('dotenv')` gives — which is what dotenv's own suite stubs. `populate` also matches 17.4.2 more closely: it validates `parsed` (not `processEnv`), returns the keys it actually set, and logs under `debug`. The row moves **74 / 141 → 80 / 141**. ## 0.2.0 ### Minor Changes - [#316](https://github.com/ofri-peretz/burgee/pull/316) [`c8acb28`](https://github.com/ofri-peretz/burgee/commit/c8acb2848714a1cbe3e26a2f9895d7498fb4f096) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - seniority to 1.0's requirement set: cosmiconfig's surface, measured at 186 / 241. The root export now carries `cosmiconfig`'s own API — `cosmiconfig`, `cosmiconfigSync`, `Explorer`, `ExplorerSync`, `defaultLoaders`, `defaultLoadersSync`, the search-place and loader tables, `decodeFileContent` and `getPropertyByPath` — with all three search strategies, both caches, `$import` with `mergeImportArrays`, and the meta-config merge. Graded by cosmiconfig 10.0.1's own suite, run unmodified, it goes from **3 / 241 (1.2%)** to **186 / 241 (77.2%)** against a control of 240 / 241. Every one of the 55 cases it does not pass is one of two named divergences: 54 are the absent YAML parser — this package bundles no format parser, so `loadYaml` reads the JSON subset of YAML and refuses the rest with an error naming the `loaders` option that supplies one — and one is a test-harness file path. None is a difference in how a config is found, merged or reported. Two new compatibility subpaths: - **`seniority/dotenv`** — dotenv 17's `parse` and `populate`, grammar included. `config` takes the environment it populates as `processEnv` rather than reaching for `process.env`, so nothing in this package touches the process. - **`seniority/find-up`** — `findUp`, `findUpSync`, `findUpMultiple`, `findUpMultipleSync` over a bounded, symlink-cycle-safe upward walk, in place of four packages. Also new on the root export, all additive: - `explanation()` — `--explain` as a record, with `explain()`'s text now literally a rendering of it, alongside `explanationJson()` and `explanationEvent()`. - `search()` / `searchAll()` — the walk, bounded by `stopAt`, a depth limit and the filesystem root. - `loadPath()`, `loaderFor()`, `LoaderError` — four builtin loaders, injected loaders for every other format, and a usage-class error naming the extension and the option. - `validate()` / `check()` — a violation reported with its provenance: `` `out` must be a string; `./mytool.config.js:3` set it to `4` ``. - `provenance.line`, recorded per key for JSON config layers, and `discover`'s `loaders`, `extensions`, `upward` and `stopAt` options. No behaviour of the existing API changes, and the package still declares zero dependencies. - [#294](https://github.com/ofri-peretz/burgee/pull/294) [`3f92a60`](https://github.com/ofri-peretz/burgee/commit/3f92a6099b1b8d5d476c64405ca963d40bf9af45) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Host `sources`, the plugin key, at `seniority/plugin` — a plugin adds a resolution source (a vault, a CI variable set, a remote config) and `--explain` names it as the provenance of the value it won. `Source` is now an open union, so this is additive rather than a type break, and the one code change it costs is `describe()`'s `default` branch: an unknown source renders itself from the `source` and `location` it declared, which is how a plugin explains itself without seniority knowing its name. `ORDER` is exported and is the single declaration the union and the new `RANK` are both generated from — the design's array and the shipped union had disagreed (`project`/`home`/`pkg` against `config`/`package`), and a plugin must not register against two spellings. The shipped five win, because `provenance.source` is a value users already read and because `project` versus `home` was one kind with two locations, which `location` already names precisely. A plugin's `rank` slots its source _between_ two built-ins and is refused outside `(flag, default)`: it can never beat the flag the user typed, nor sink below the declared default. The order stays the fixed thing it claims to be. ### Patch Changes - [#316](https://github.com/ofri-peretz/burgee/pull/316) [`c8acb28`](https://github.com/ofri-peretz/burgee/commit/c8acb2848714a1cbe3e26a2f9895d7498fb4f096) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `mergeAll` refuses `prototype` as well as `__proto__` and `constructor`, and the guard is three comparisons rather than a `Set` lookup. The guard existed and had no test — it was written, and believed. CodeQL's `js/prototype-polluting-function` could not see it through the `Set` binding and blocked a merge on it, which is a fair complaint about a security guard: one a reader has to follow a binding to find is one a reviewer will miss too. What it actually prevents, measured rather than assumed. A first attempt at the test asserted `({}).polluted === undefined` after merging a `__proto__` key and **passed with the guard deleted** — `target['__proto__'] = v` goes through the setter and swaps _that object's_ prototype; it does not write `Object.prototype`. The damage is narrower and quieter: the config object handed back to the caller silently inherits whatever the file said, so `config.isAdmin` can answer for a key no file set at the top level. `cosmiconfig-util.test.ts` now asserts that, and four of its six cases go red when the guard is removed. - [#339](https://github.com/ofri-peretz/burgee/pull/339) [`f295630`](https://github.com/ofri-peretz/burgee/commit/f2956301d5f9dcbcac0b001b00ebaf0315891fac) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` constrains token names, because it was promising something no host honours. `tokens` was described as any name to a `#rrggbb` colour. `roundel`'s `validate()` accepts ten semantic names — `error`, `warn`, `ok`, `hint`, `muted`, `command`, `flag`, `value`, `heading`, `ground` — and throws on everything else. So a plugin author doing exactly what their own `E_PLUGIN_SCHEMA` error tells them, comparing their object against `roundel/schema.json`, got a green from the schema and `"accent" is not a token` from `register()`. Measured 2026-09-16 with `{ accent: '#336699' }`. The schema now carries `propertyNames.enum`, and `scripts/plugin-contract-lock.test.ts` pins the enum and the runtime set to each other from both sides, so neither can grow a name the other does not know. Every host ships a byte-identical copy of this file (`plugin-schema-lock.test.ts` asserts it), which is why nine packages are listed. Only the key `roundel` owns is constrained: describing `widgets`, `handlers`, `sources`, `resolvers` or `commands` in a file all eight hosts share is what made _flagstaff_ start validating caique's key last time (`PluginError: plugin.widgets.later: expected object, got boolean`), and those stay in `plugin-schema-lock`'s `UNDESCRIBED` list with that reason. `linegauge` is in the list for a different change: `ceilings.json`'s R9 block now records the bar as D1's tree-inclusive ceiling — 83,538 against 170,342, a ratio of 0.4904 — and keeps the superseded `get-east-asian-width` bar beside it with the count of entries that cleared it. ## 0.1.0 ### Minor Changes - [#219](https://github.com/ofri-peretz/burgee/pull/219) [`ac7b9e5`](https://github.com/ofri-peretz/burgee/commit/ac7b9e5c30ccc960f4f948c40f8536e3751cc4d0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - seniority resolves flags, env, config and defaults — with provenance The package existed as a reserved name exporting a string constant. It now does the job its description has always claimed. ``` flag > env > config file > package.json field > default ``` `resolve(specs, layers)` returns the values, the provenance of each, and every candidate that lost — so `explain(name, resolution)` can print the winning source and the ones it beat, generated by the same code that picked the value. A `--explain` built any other way can drift from the truth; this one cannot. `resolve` is pure: layers in, values out, no filesystem and no `process.env`. `discover` is the half that touches the disk and is a separate import for that reason — searching `NAME_CONFIG`, `./name.config.{json,mjs,js,cjs}` and `$XDG_CONFIG_HOME/name/config.json`, following `extends` with deep merge and rejecting cycles with the chain that formed them. The code is burgee's, moved down a layer where it belongs: `precedence.ts` and `config.ts` with their 28 tests, which passed unchanged. Its `OptionSpec` here declares only the three fields resolution reads, so any program's richer option type satisfies it structurally — no adapter, no import, and no dependency pointing back up the stack. Zero dependencies; Node builtins only. --- # Coming from cosmiconfig > A cosmiconfig alternative with zero dependencies: import { cosmiconfig } from seniority, graded 242 / 243 by cosmiconfig's own suite, level with cosmiconfig itself — with provenance, so every resolved value can say where it came from, as text, --json data or an agent event. Source: https://seniority.interlace.tools/docs/coming-from/cosmiconfig **seniority** is a **cosmiconfig alternative** that also covers what `dotenv`, `rc` and `find-up` do beside it: one resolution for flags, environment, config files, a `package.json` field and defaults, in a fixed order, with no dependencies. ## Migrate from cosmiconfig in one import ```diff - import { cosmiconfig } from 'cosmiconfig'; + import { cosmiconfig } from 'seniority'; ``` The root export carries cosmiconfig's surface: `cosmiconfig`, `cosmiconfigSync`, `Explorer`, `ExplorerSync`, `defaultLoaders`, `defaultLoadersSync`, `getDefaultSearchPlaces`, `globalConfigSearchPlaces` and `metaSearchPlaces` — all three search strategies, both caches, `$import`, and the meta-config merge. **YAML reads as it does in cosmiconfig.** cosmiconfig takes `js-yaml` for `.yaml`, `.yml` and extensionless files; seniority reads them with `seniority/yaml`, its own parser, loaded on the first YAML file and never before. It returns what js-yaml 5 returns, and its errors carry js-yaml's reason and position. `npx burgee migrate` makes the import swap for you. ## Is seniority compatible with cosmiconfig? Level with cosmiconfig on its own suite. cosmiconfig's own suite, vendored at 10.0.1 and unmodified apart from the import specifier, runs against `seniority` beside a control that runs it against real cosmiconfig: | | passing | rate | | :-- | --: | --: | | `seniority` | 242 / 243 | 99.6% | | cosmiconfig itself (control) | 242 / 243 | 99.6% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority, and it names every case that leaves the gate. The one case not passing fails for cosmiconfig too: the harness reaching for a file path the vendored copy does not have. The XDG global-directory pair, which registers only on Linux, passes for both: a `global` search ends in `$XDG_CONFIG_HOME/` when that is set, as cosmiconfig's does. On macOS the pair does not register, and both read 240 of 241. Level with the control is the claim, and it is a grade, not the word *compatible*. The same page grades `seniority/lilconfig` (lilconfig's own suite), `seniority/dotenv` and `seniority/rc`. ## What you gain over cosmiconfig cosmiconfig finds a file. seniority resolves the value, and keeps the receipt: - **Provenance on every value.** `resolve()` returns each option's winning source and every candidate it beat. `explain('region', result)` prints `region = "eu-2" from config file ./app.config.json` with the unset flag and env candidates beneath it. - **The same record for agents.** `explanationJson()` is that record as `--json` data, and `explanationEvent()` is it as an agent event — generated by the code that picked the value, so neither can drift from the text. - **An order nobody can rearrange.** `flag > env > config file > package.json field > default`, exported as `ORDER`. A plugin can add a source between them and can never beat the flag the user typed — so the agent reading your `--schema` and the person reading your `--help` can reason about it from the outside. - **`resolve` is pure.** No filesystem, no `process.env`: the environment is passed in, so it is testable without a process. ## When to switch from cosmiconfig - "Why is this set to that?" is a question your users or agents ask, and nobody can answer it without reading the source. - You want config, env and flags in one precedence rather than three hand-written ones. - You do not need YAML, or you are content to pass the parser in. The API, the discovery rules and the other drop-in paths are on [seniority](/docs). --- # Coming from dotenv > A dotenv alternative with a drop-in path: import dotenv from seniority/dotenv, graded 197 / 197 by dotenv 18's own test suite, level with dotenv itself — then the same package resolves every value across flags, env, config files and defaults, with provenance as text, --json data or an agent event. Source: https://seniority.interlace.tools/docs/coming-from/dotenv **seniority** is a **dotenv alternative** that also covers what `cosmiconfig`, `rc` and `find-up` do beside it: one resolution for flags, environment, config files, a `package.json` field and defaults, in a fixed order, with no dependencies. ## Migrate from dotenv in one import ```diff - import dotenv from 'dotenv'; + import dotenv from 'seniority/dotenv'; - import 'dotenv/config'; + import 'seniority/dotenv/config'; ``` `npx burgee migrate` makes both edits for you. `seniority/dotenv` carries dotenv 18's `config`, `configDotenv`, `parse` and `populate`, as named exports and on a default export shaped like dotenv's `module.exports`. `parse` is dotenv's line grammar, character for character, and `parse(src, { fast: true })` is its character scanner. `config()` behaves as dotenv's does when told nothing: it reads `./.env` from the working directory into `process.env`. It takes dotenv's options — `path` (one file or several, a `URL`, a leading `~`), `processEnv`, `encoding`, `override`, `debug`, `quiet` and `fast` — and their defaults from `DOTENV_PATH`, `DOTENV_QUIET` and the rest, or the older `DOTENV_CONFIG_*` names. It prints `◇ injected env (n) from .env` on stderr unless quiet, and returns `{ parsed }` or `{ parsed, error }` rather than throwing for a missing file. - **`seniority/dotenv/config`** is `import 'dotenv/config'` and `node -r dotenv/config`: importing it loads the file, quietly unless `DOTENV_QUIET=false`. - **`seniority/dotenv/cli`** is `dotenv run`. Its `run(argv)` takes what follows the program name — `run(['run', '-f', '.env.local', '--', 'node', 'app.js'])` — loads the files, starts the command, forwards SIGINT, SIGTERM, SIGHUP and SIGQUIT to it, and exits with its code. **Not built, because dotenv 18 removed them:** `decrypt`, `DOTENV_KEY`, the `.env.vault` format and the log-line tips. ## Is seniority compatible with dotenv? At dotenv 18, yes, by the measure this page uses. dotenv's own suite, vendored at 18.0.9 and unmodified apart from the import specifier, runs against `seniority/dotenv` beside a control that runs it against real dotenv: | | passing | rate | | :-- | --: | --: | | `seniority/dotenv` | 197 / 197 | 100.0% | | dotenv itself (control) | 197 / 197 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority. The suite runs under node-tap, whose plan counts assertions rather than test cases, so 191 is assertions. They include `dotenv run` spawned for real, its signal forwarding, and programs that `import 'dotenv/config'`. dotenv 17.4.2's suite is graded too, and **not claimed**: 107 / 141 against a control of 141 / 141. The 34 are what dotenv 18 changed — the vault and `decrypt`, the tips, and the log line moving from `console.log` to `console.error` — so `burgee migrate` leaves a project on dotenv 17 alone and says so. The same page grades `seniority` (cosmiconfig's own suite), `seniority/lilconfig` and `seniority/rc`. ## What you gain over dotenv dotenv puts a file into the environment. seniority resolves the value, and keeps the receipt: - **Provenance on every value.** `resolve()` returns each option's winning source and every candidate it beat. `explain('region', result)` prints `region = "eu-2" from config file ./app.config.json` with the unset flag and env candidates beneath it. - **The same record for agents.** `explanationJson()` is that record as `--json` data, and `explanationEvent()` is it as an agent event — generated by the code that picked the value, so neither can drift from the text. - **An order nobody can rearrange.** `flag > env > config file > package.json field > default`, exported as `ORDER`. `populate` already keeps a key the environment holds unless you pass `override`; `resolve` states that rule once for every source, and a plugin can add a source between them but can never beat the flag the user typed. - **`resolve` is pure.** No filesystem, no `process.env`: the environment is passed in, so it is testable without a process. Only the drop-in façades reach for `process.env` by default, because their incumbents do. ## When to switch from dotenv - "Why is this set to that?" is a question your users or agents ask, and the answer depends on whether a flag, the environment or a config file won. - You load `.env` and then hand-write the order in which a flag, an env var and a config file override each other. - You are on dotenv 18, or can move to it: 18 already removed `.env.vault` and `DOTENV_KEY`. The API, the discovery rules and the other drop-in paths are on [seniority](/docs). --- # Coming from rc > An rc alternative with a drop-in path: import rc from seniority/rc, graded 1 / 1 by rc's own test — one exit-code bit, not a case count — then rc's file stack and merge with none of its four dependencies, and a resolver that says where every value came from. Source: https://seniority.interlace.tools/docs/coming-from/rc **seniority** is an **rc alternative** that also covers what `cosmiconfig`, `dotenv` and `find-up` do beside it: one resolution for flags, environment, config files, a `package.json` field and defaults, in a fixed order, with no dependencies. ## Migrate from rc in one import ```diff - import rc from 'rc'; + import rc from 'seniority/rc'; ``` The call is rc's — `rc(name, defaults, argv, parse)` — and so is what it does: the file stack in rc's own order (`/etc//config`, `/etc/rc`, the four places under the home directory, the nearest `.rc` walking up from the working directory, then a file named by `config` in the environment or on the command line), `_*` environment keys with `__` read as nesting, JSON with comments, `deep-extend`'s merge (arrays replaced, not concatenated), a string `defaults` read as JSON, and the `configs` / `config` report of which files were read. Like rc, it reads the process's environment when you pass none. **Two differences, both on purpose.** - **INI is refused by name, not parsed.** seniority bundles no format parser, so a file that is not JSON is refused with the fix in the error. Pass `ini.parse` in rc's own fourth position and you have rc's behaviour, with the parser as your dependency rather than everyone's. - **The command line is an argument.** When `argv` is falsy, rc 1.2.8 fills it with `minimist(process.argv.slice(2))`; `seniority/rc` treats an omitted `argv` as empty and never reads the command line. Pass the flags you already parsed as the third argument. ```js import rc from 'seniority/rc'; const config = rc('mytool', defaults, flags); ``` A fifth argument, `{ env, cwd, home, win }`, supplies the rest of the world when you would rather not have it read from the process — which is how it is tested. ## Is seniority compatible with rc? Graded, and the grade is coarse. rc's `test/test.js`, vendored from 1.2.8 and unmodified apart from the import specifier, runs against `seniority/rc` beside a control that runs it against real rc: | | passing | grade | | :-- | --: | :-- | | `seniority/rc` | 1 / 1 | by exit code | | rc itself (control) | 1 / 1 | by exit code | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority. **Read `1 / 1` as one bit, not one case:** rc's suite is scripts of bare `assert` calls with no reporter, so the only honest grade is that the script ran against this target and exited 0. That script covers an environment option, a nopt-shaped `argv` outranking it, and a commented JSON `.rc` in the working directory with its `config` and `configs` report. rc 1.2.8 was never tagged upstream, so the suite is taken from the repository's head at commit `a97f6ad`. rc's two other test files are not graded: `ini.js` tests an internal and the `ini` package, and `nested-env-vars.js` is public and not graded yet. The same page grades `seniority` (cosmiconfig's own suite), `seniority/lilconfig` and `seniority/dotenv`. ## What you gain over rc rc 1.2.8 depends on `deep-extend`, `ini`, `minimist` and `strip-json-comments`. `seniority/rc` depends on nothing. And where rc merges, seniority also resolves the value and keeps the receipt: - **Provenance on every value.** `resolve()` returns each option's winning source and every candidate it beat, where rc's `configs` only lists the files it read. `explanationJson()` is that record as `--json` data and `explanationEvent()` as an agent event, generated by the code that picked the value. - **An order nobody can rearrange.** `flag > env > config file > package.json field > default`, exported as `ORDER`. A plugin can add a source between them and can never beat the flag the user typed. - **A bounded walk.** The upward search for `.rc` stops at sixty-four levels, so no directory tree can turn it into a hang. ## When to switch from rc - Your rc files are JSON, or you are content to pass `ini.parse` in. - You already parse your own flags, and would rather rc did not parse `process.argv` a second time behind them. - "Why is this set to that?" is a question your users or agents ask. The API, the discovery rules and the other drop-in paths are on [seniority](/docs). --- # Compatibility > How seniority's four drop-ins are graded by their incumbents' own suites — lilconfig 77 / 77, rc 1 / 1 (one bit), cosmiconfig 242 / 243 (level with cosmiconfig itself), dotenv 197 / 197 (level with dotenv 18 itself) — and the differences that remain. Source: https://seniority.interlace.tools/docs/drop-ins `seniority` (cosmiconfig's API at the root), `seniority/lilconfig`, `seniority/dotenv` and `seniority/rc` are graded, not described as compatible. Each is run against its incumbent's **own test suite**, by [compat-oracle](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/README.md), in CI. ✓ yes · ◐ partial (what is missing is said) · ✗ no · — does not apply. Every cell links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades. ### Compatibility | Capability | **seniority** | cosmiconfig | lilconfig | dotenv | rc | find-up | | :-- | :-- | :-- | :-- | :-- | :-- | :-- | | **Passes lilconfig's own test suite** — `seniority/lilconfig` is graded by lilconfig 3.1.3's own tests, all 77, the same count the real lilconfig gets here. | [✓ 77 / 77 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/lilconfig.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cosmiconfig/test/successful-files.test.ts) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/lilconfig/src/spec/index.spec.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/dotenv/tests/test-parse-multiline.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/rc/test/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/find-up/test/finddown.js) | | **Passes rc's own test** — `seniority/rc` runs rc 1.2.8's test script, one script of bare assertions with no reporter, and exits 0: the grade is one bit, not 100% of anything. | [✓ 1 / 1 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/rc.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cosmiconfig/test/successful-files.test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/lilconfig/src/spec/index.spec.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/dotenv/tests/test-parse-multiline.js) | [✓ its own test, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/rc/test/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/find-up/test/finddown.js) | | **Passes find-up's own test suite** — `seniority/find-up` runs find-up 8.0.0's own suite, unmodified: 104 of 104, the same as find-up itself, with the two cases that expect a `.git` at the vendored root subtracted by name from both. | [✓ 104 / 104 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/find-up.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cosmiconfig/test/successful-files.test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/lilconfig/src/spec/index.spec.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/dotenv/tests/test-parse-multiline.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/rc/test/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/find-up/test/finddown.js) | | **Passes cosmiconfig's own test suite** — `seniority` passes 242 of the 243 cases of cosmiconfig 10.0.1's own suite on Linux, the count the published control run passes too, the XDG global-directory pair included. | [◐ 242 / 243 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/cosmiconfig.json) | [✓ its own suite, the control run; the case the harness cannot load fails for it too](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cosmiconfig/test/successful-files.test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/lilconfig/src/spec/index.spec.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/dotenv/tests/test-parse-multiline.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/rc/test/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/find-up/test/finddown.js) | | **Passes dotenv's own test suite** — `seniority/dotenv` passes all 197 assertions of dotenv 18.0.9's own suite (node-tap counts assertions), as dotenv itself does here — its `dotenv run` command line and `import 'dotenv/config'` included. | [✓ 197 / 197 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/dotenv.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cosmiconfig/test/successful-files.test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/lilconfig/src/spec/index.spec.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/dotenv/tests/test-parse-multiline.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/rc/test/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/find-up/test/finddown.js) | The family's [compatibility page](https://burgee.interlace.tools/docs/compatibility) is generated from the oracle's last run and is the authority for the current figures. ## How a suite is graded 1. The incumbent's repository is cloned at the graded version, and its test directory is copied into `packages/compat-oracle/vendor/`. The versions are cosmiconfig 10.0.1, lilconfig 3.1.4, dotenv 18.0.9, and rc 1.2.8 (rc's release was never tagged, so its commit is pinned). Each copy has a `PROVENANCE` file. 2. The only edit is the import that reaches the library, rewritten to a shim generated per run. 3. A **control run** points the shim at the real incumbent first. Its total is what every rate is measured against. 4. The **target run** points the same shim at seniority. ## What each grade covers - **lilconfig: 77 of 77**, the same count the real lilconfig gets here. Ten of the cases mock `fs` with `jest.mock` to assert which files were read, and the oracle applies that mock the way jest does. - **rc: 1 of 1, by exit code.** rc's suite is one script of bare assertions with no reporter, so the grade is one bit: the script ran against seniority and exited 0. It is not 100% of anything. - **cosmiconfig: 242 of 243**, the same count the real cosmiconfig gets here. YAML files are read with `seniority/yaml`, the package's own parser, loaded on the first YAML file. The 54 YAML cases that failed until 2026-09-30 pass, all 22 of `import.test.ts` among them. So does the XDG pair that registers only on Linux: the global config directory is read from `XDG_CONFIG_HOME` as cosmiconfig reads it. The one not passing is `index.test.ts`, which imports cosmiconfig's entry by a file path and fails for the control too. - **dotenv: 197 of 197**, the same count the real dotenv gets here. The suite runs under node-tap, which counts assertions, so 197 is assertions. It spawns `dotenv run` and programs that `import 'dotenv/config'`, and seniority answers both. dotenv 17.4.2's suite is graded and not claimed: 107 of 141, the 34 being the vault, the tips and the log line 18 moved to stderr. ## Known differences - **No YAML, and no INI.** The cosmiconfig and rc drop-ins read JSON (rc's with comments) and refuse YAML and INI by name, with a hint naming the argument that supplies a parser. - **`seniority/dotenv` writes `process.env` only when you give it nothing else.** Pass `processEnv` and it fills that object instead. - **No vault.** `.env.vault`, `DOTENV_KEY` and `decrypt` are not implemented, because dotenv 18 removed them. - **seniority's own API is stricter than the drop-ins.** `resolve` reads only declared options from the environment and refuses unclear booleans. `seniority/rc` keeps rc's behaviour, which is to take every `_` variable. ## Moving one import ```diff - import { cosmiconfig } from 'cosmiconfig'; + import { cosmiconfig } from 'seniority'; ``` ```diff - import { lilconfig } from 'lilconfig'; + import { lilconfig } from 'seniority/lilconfig'; ``` `npx burgee migrate --dry-run` lists every import it would rewrite, and rewrites only drop-ins graded level with their incumbent, which here means lilconfig and rc. [Incremental migration](/docs/recipes/incremental-migration) moves from a drop-in to `resolve`. --- # FAQ > Short answers about seniority — the order, YAML, numbers from the environment, discovery, dotenv's vault, testing, plugins and CommonJS — each with where to read more. Source: https://seniority.interlace.tools/docs/faq ## Can I change the precedence order? No. It is `flag`, `env`, `config`, `package`, `default`, and it is the same in every program, so a user or an agent can reason about it from outside. A plugin can add a source between two of them. [Precedence](/docs/guides/precedence). ## Why is my default beating the environment? Because your argument parser filled it into `flags`. Pass only what the user typed as `flags`, and put defaults in the spec's `default`, which ranks last. ## Does it read YAML? Yes, with its own parser and no dependency. The cosmiconfig drop-in reads `.yaml`, `.yml` and extensionless files as cosmiconfig does, loading `seniority/yaml` on the first one; that is how cosmiconfig's suite grades it 242 of 243, level with cosmiconfig. `discover` and `loadPath` take it as a loader: `loaders: { '.yaml': (_path, text) => parse(text) }`. [Config files](/docs/guides/config-files#loaders-bring-your-own-format). ## A number from the environment comes out as a string. The environment is text, and `resolve` converts only booleans. `validate` reports it, naming the variable. [Validation](/docs/guides/validation). ## Why wasn't my `APP_SOMETHING` variable picked up? The environment reaches only options the running command declares, under the prefix in SCREAMING_SNAKE. `logLevel` reads `APP_LOG_LEVEL`, not `APP_LOGLEVEL`. [Environment variables](/docs/guides/environment). ## Does discovery walk up to parent directories? Only with `upward: true`, and then only as far as `stopAt`, the filesystem root, or 64 directories. [Config files](/docs/guides/config-files#walking-upward-bounded) and [Config in a monorepo](/docs/recipes/monorepo). ## Does `seniority/dotenv` support `.env.vault`? No, and neither does dotenv 18, which removed the vault, `DOTENV_KEY` and `decrypt`. `seniority/dotenv` is graded against dotenv 18's own suite and passes all 179 assertions. [Compatibility](/docs/drop-ins). ## How do I test my configuration handling? Pass the environment and the config data to `resolve` as arguments; nothing is read from the process. [Testing configuration](/docs/recipes/testing-config). ## Can a plugin override what the user typed? No. A source's `rank` must be strictly between the flag's and the default's, and `npx seniority check` refuses anything else before it ships. [Source plugins](/docs/guides/plugins). ## Can I use it from CommonJS? Yes, on Node 22.13+, through `require(esm)`. --- # Getting started > Install seniority, declare three options, resolve them from flags, the environment, a config file and defaults in one fixed order, and ask where each value came from. Source: https://seniority.interlace.tools/docs/getting-started seniority decides one thing: when a flag, an environment variable, a config file, a `package.json` field and a default all have an opinion about an option, which one wins. It keeps the answer to "why is this set?" as a value you can print. ## Install ```bash npm install seniority ``` It has no dependencies. It is ESM with a `default` condition, so `require('seniority')` also works from CommonJS on Node 22.13+. ## Resolve three options You declare the options, then hand `resolve` the layers you have: only what the user actually typed as `flags`, the environment, and a config file's data with its path. ```js title="first.mjs" import { explain, resolve } 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 }, env: { APP_REGION: 'eu-3', MY_TOKEN: 's3cret' }, envPrefix: 'APP', config: { path: './app.config.json', data: { region: 'eu-2' } }, }); console.log(result.values); console.log(result.provenance.region); process.stdout.write(explain('region', result)); process.stdout.write(explain('dryRun', result)); ``` ```text title="node first.mjs" { region: 'eu-3', dryRun: true, token: 's3cret' } { source: 'env', location: 'APP_REGION' } region = "eu-3" from env APP_REGION candidates: flag --region (unset), config file ./app.config.json "eu-2", default "us-1" dryRun = true from flag --dryRun candidates: env APP_DRY_RUN (unset), config file ./app.config.json (unset), default (unset) ``` `region` came from `APP_REGION`. The explanation lists everything it beat (the config file's `"eu-2"` and the default) and the flag nobody typed. That text is rendered from the same record that picked the value, so it cannot drift from the truth. Every output block on this site is checked: `tests/examples.test.ts` writes each titled file, runs the command in the block's title, and compares. ## The order ```js import { ORDER, RANK } from 'seniority'; // ORDER: ['flag', 'env', 'config', 'package', 'default'] // RANK: { flag: 0, env: 10, config: 20, package: 30, default: 40 } ``` The first source that sets an option wins. The order is not configurable, on purpose: a precedence a program can rearrange is one nobody can reason about from the outside. A plugin can add a source *between* two of these, but never reorder them. ## Two halves - **`resolve(specs, layers)` is pure.** No filesystem, no `process.env`, no globals. The environment is an argument, so the same inputs give the same answer anywhere. - **`discover({ name, cwd, env })` touches the disk.** It finds and loads the config file, following `extends`, and hands back a layer to pass as `config`. ## Where next - [Guides](/docs/guides/precedence): precedence and `--explain`, config files, the environment, validation, plugins. - [Why seniority](/docs/why-seniority): what it does that cosmiconfig, lilconfig, dotenv and rc do not, and what it deliberately leaves out, cell by cell, with the evidence. - [Coming from cosmiconfig](/docs/coming-from/cosmiconfig), [dotenv](/docs/coming-from/dotenv) and [rc](/docs/coming-from/rc): change one import. - [API reference](/docs/api): every export of every entry point. --- # Config files > discover() finds a config file in a fixed order, follows extends, and records each key's line; search() walks upward only when asked, bounded by stopAt, a depth limit and the root; loaders bring any format. Source: https://seniority.interlace.tools/docs/guides/config-files `discover` is the half of seniority that touches the disk. It finds a config file, loads it and its `extends` chain, and returns a layer to hand to `resolve` as `config`: ```js title="discover.mjs" import { mkdirSync, writeFileSync } from 'node:fs'; import { relative } from 'node:path'; import { discover, explain, resolve } from 'seniority'; mkdirSync('shared', { recursive: true }); writeFileSync('shared/base.config.json', JSON.stringify({ region: 'us-1', retries: 3 }, null, 2)); writeFileSync('mytool.config.json', '{\n "extends": "./shared/base.config.json",\n "region": "eu-2"\n}\n'); const loaded = await discover({ name: 'mytool', cwd: '.', env: {} }); console.log(loaded.chain.map((p) => relative('.', p))); const specs = { region: { type: 'string' }, retries: { type: 'number' } }; const result = resolve(specs, { flags: {}, env: {}, config: loaded }); console.log(result.values); process.stdout.write(explain('region', result)); ``` ```text title="node discover.mjs" [ 'shared/base.config.json', 'mytool.config.json' ] { region: 'eu-2', retries: 3 } region = "eu-2" from config file mytool.config.json:3 candidates: flag --region (unset), default (unset) ``` ## Where it looks The first of these that exists wins: 1. The file named by `explicit` (your `--config`). **A missing one is an error**, not silence. 2. The file named by `_CONFIG` in the environment. 3. `.config.json`, `.mjs`, `.js` or `.cjs` in `cwd`. 4. The user's config directory: `$XDG_CONFIG_HOME//config.json`. `candidates(discovery)` lists those paths without reading them. `disabled: true` (your `--no-config`) turns discovery off, including the file the environment names. A discovered file that is missing is silence. ## extends `extends` is a string or a list. It is resolved relative to the extending file, or through `node_modules`, and deep-merged left to right: objects merge key by key, and for anything else the extending file wins. A cycle is refused, naming the chain that formed it. `chain` lists the files merged, outermost first. Provenance follows the chain. `origins` on the loaded layer records, for each top-level key, the nearest file that set it and that file's line, and `resolve` cites it. In the example above, `retries` comes from `shared/base.config.json` at line 3, not from `mytool.config.json`, which never mentions it. ## Walking upward, bounded `discover` does not walk up the tree unless you pass `upward: true`, because a surprise parent config is worse than none. `search` and `searchAll` are the walk itself: ```js title="walk.mjs" import { mkdirSync, writeFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { search, searchAll } from 'seniority'; // A repository with a root config and a package with its own, two levels down. mkdirSync('repo/packages/app/src', { recursive: true }); writeFileSync('repo/mytool.config.json', '{}'); writeFileSync('repo/packages/app/mytool.config.json', '{}'); const from = join('repo', 'packages', 'app', 'src'); const show = (found) => (found === undefined ? 'nothing' : `${relative('.', found.path)} (depth ${found.depth})`); console.log('nearest:', show(search('mytool.config.json', { cwd: from, stopAt: 'repo' }))); console.log('every:', searchAll('mytool.config.json', { cwd: from, stopAt: 'repo' }).map(show).join(', ')); console.log('stopped at packages:', show(search('mytool.config.json', { cwd: 'repo/packages', stopAt: 'repo/packages' }))); ``` ```text title="node walk.mjs" nearest: repo/packages/app/mytool.config.json (depth 1) every: repo/packages/app/mytool.config.json (depth 1), repo/mytool.config.json (depth 3) stopped at packages: nothing ``` The walk ends at whichever comes first: - `stopAt`, inclusive; - the filesystem root; - `WALK_LIMIT` (64) directories. Directories are compared by their real path, so a symlink that points back at its own ancestor ends the walk instead of looping. `depth` on the result is how many steps up the match was. ## Loaders: bring your own format Four formats load with no parser: JSON, and JavaScript as `.mjs`, `.js` or `.cjs`. In a JavaScript config, the default export may be a function that computes the config. Everything else (YAML, TOML, JSON5, INI) needs a loader you pass, so the parser is your dependency rather than everyone's. An extension with no loader is a **usage** error that names the option to fix it: ```js title="yaml.mjs" import { writeFileSync } from 'node:fs'; import { loadPath } from 'seniority'; writeFileSync('app.yaml', 'region: eu-2\nretries: 3\n'); try { await loadPath('app.yaml'); } catch (error) { console.log(`${error.name}: ${error.message}`); console.log(`hint: ${error.hint}`); } // seniority's own YAML parser, in an entry of its own: nothing loads it unless you do. const { parse } = await import('seniority/yaml'); console.log(await loadPath('app.yaml', { loaders: { '.yaml': (_filepath, content) => parse(content) } })); ``` ```text title="node yaml.mjs" LoaderError: no loader for ".yaml" hint: pass loaders: { ".yaml": (filepath, content) => … } — seniority bundles no format parser { region: 'eu-2', retries: 3 } ``` `discover` takes the same `loaders`. A loader may also replace a builtin, so a program that wants JSON5 for `.json` can have it. **YAML is a loader, not a default, and not a dependency.** `loadPath` and `discover` read the four formats Node reads; `seniority/yaml` is the fifth, passed as above. It is the same parser the cosmiconfig drop-in uses, which is how `seniority` passes 242 of cosmiconfig's 243 cases, level with cosmiconfig itself. ## What is tested - [`config.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/seniority/src/config.test.ts): - the discovery order; - a missing `--config` is an error; - `--no-config`; - `extends` is deep-merged and relative, and a cycle is refused by name. - [`search.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/seniority/src/search.test.ts): - nearest first; - bounded by `stopAt`, the root and `WALK_LIMIT`; - a symlink cycle ends the walk. - [`load.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/seniority/src/load.test.ts): - the four builtins; - a missing loader is USAGE, naming the option; - injected loaders, including one over a builtin. --- # Environment variables > How seniority reads the environment: only declared options, named PREFIX_OPTION_NAME in SCREAMING_SNAKE, one boolean spelling each way, PREFIX_NO_X refused — and the environment always an argument. Source: https://seniority.interlace.tools/docs/guides/environment The environment is a layer like any other, and it is passed in: `resolve(specs, { env })`. Nothing in `resolve` reads `process.env`, so a test hands it an object and a program hands it `process.env`. ## The rules ```js title="envrules.mjs" import { resolve } from 'seniority'; const specs = { dryRun: { type: 'boolean' }, logLevel: { type: 'string' } }; const run = (env) => { try { return JSON.stringify(resolve(specs, { flags: {}, env, envPrefix: 'APP' }).values); } catch (error) { return `${error.message} (${error.hint})`; } }; console.log(run({ APP_DRY_RUN: 'yes', APP_LOG_LEVEL: 'debug' })); console.log(run({ APP_DRY_RUN: 'false' })); console.log(run({ APP_DRY_RUN: 'maybe' })); console.log(run({ APP_NO_DRY_RUN: '1' })); console.log(run({ APP_SECRET_FLAG: 'x', APP_LOGLEVEL: 'debug' })); ``` ```text title="node envrules.mjs" {"dryRun":true,"logLevel":"debug"} {"dryRun":false} APP_DRY_RUN="maybe" is not a boolean (use APP_DRY_RUN=true or APP_DRY_RUN=false) APP_NO_DRY_RUN is not supported (set APP_DRY_RUN=false instead) {} ``` Line by line: 1. **Names.** With `envPrefix: 'APP'`, an option reads `APP_` plus its name in SCREAMING_SNAKE. `dryRun` is `APP_DRY_RUN`, and `logLevel` and `log-level` are both `APP_LOG_LEVEL`. A name is never camel-cased back from a variable. - An option with an explicit `env: 'MY_TOKEN'` reads that variable instead, prefix or not. - With no prefix and no `env:`, an option reads no variable at all. 2. **Booleans.** `1`, `true` and `yes`, or `0`, `false` and `no`, in any case. The string `"false"` is never silently truthy. 3. **Anything else is a CONFIG error** naming the variable, the value and the spelling to use. 4. **`APP_NO_X` is refused**, pointing at `APP_X=false`. Two spellings of one fact is how a configuration becomes unreadable. 5. **Only declared options.** `APP_SECRET_FLAG` sets nothing, because no option declares it, and `APP_LOGLEVEL` is not `APP_LOG_LEVEL`. The environment cannot inject a key the running command did not ask for, including a sibling command's option. ## dotenv files `seniority/dotenv` is dotenv's API (`config`, `parse`, `populate`), graded by dotenv's own suite. Give `config` the object to fill as `processEnv`, and the `.env` file joins the environment layer without touching `process.env`: ```js title="dotenv.mjs" import { writeFileSync } from 'node:fs'; import { resolve } from 'seniority'; import dotenv from 'seniority/dotenv'; writeFileSync('.env', 'APP_REGION=eu-2\nAPP_DRY_RUN=yes\n'); const env = { APP_REGION: 'us-9' }; // already set: dotenv does not override it dotenv.config({ path: '.env', processEnv: env, quiet: true }); const { values, provenance } = resolve({ region: { type: 'string' }, dryRun: { type: 'boolean' } }, { flags: {}, env, envPrefix: 'APP' }); console.log(values, provenance.region.location); ``` ```text title="node dotenv.mjs" { region: 'us-9', dryRun: true } APP_REGION ``` Called with no `processEnv`, `config()` populates `process.env`, as dotenv does. The encrypted `.env.vault` is not built, because dotenv 18 removed it: see [Compatibility](/docs/drop-ins). ## What is tested - [`precedence.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/seniority/src/precedence.test.ts): - env applies only to declared options, by their own names; - the six boolean spellings; - anything else is a CONFIG error naming the fix; - `PREFIX_NO_X` is rejected. - [`dotenv.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/seniority/src/dotenv.test.ts): - parsing as dotenv 18.0.5 does; - `config` populates the object it was given. --- # Source plugins > seniority/plugin: add a configuration source — a vault, a CI variable set, a remote config — at a rank between two built-ins, named in --explain, and checked by npx seniority check before it ships. Source: https://seniority.interlace.tools/docs/guides/plugins A plugin **adds a source**; it cannot reorder the five built-ins. It registers under the family's plugin key, `sources`, and its `rank` slots it between two of them: ```js title="vault.mjs" import { explain, RANK, resolve } from 'seniority'; import { register, sources } from 'seniority/plugin'; register({ name: 'acme-vault', sources: { vault: { rank: RANK.env + 1, // after the environment, before the config file read: (rt) => ({ location: 'acme://vault/ci', values: { region: rt.env.CI_REGION } }), }, }, }); const env = { CI_REGION: 'ap-1' }; const specs = { region: { type: 'string', default: 'us-1' } }; const result = resolve(specs, { flags: {}, env, config: { path: 'app.config.json', data: { region: 'eu-2' } }, sources: sources({ env, cwd: '.' }) }); process.stdout.write(explain('region', result)); ``` ```text title="node vault.mjs" region = "ap-1" from vault acme://vault/ci candidates: flag --region (unset), config file app.config.json "eu-2", default "us-1" ``` `--explain` names the plugin's source by the name and location it declared, even though seniority has never heard of it. | field | meaning | | :-- | :-- | | `rank` | an integer strictly between `RANK.flag` (0) and `RANK.default` (40): a source may not beat what the user typed, nor sink below the declared default | | `read(runtime)` | returns `{ location?, values }`, or nothing to contribute nothing | | `values` | the data itself, instead of `read`: a source may be pure data | | `location` | what `--explain` prints; the source's name when absent | A source has either `read` or `values`, never both, because one source gives one answer. At an equal rank, the plugin registered later wins. `sources(runtime)` reads every registered source and sorts them by rank; pass the result to `resolve` as `sources`. ## Checking a plugin before it ships `npx seniority check ` validates a plugin module's default export against the family schema. It exits 0 on success, 1 with a code and a fix, or 2 on a usage error: ```js title="badplugin.mjs" export default { name: 'acme-vault', sources: { vault: { rank: -5, values: { region: 'ap-1' } }, }, }; ``` ```text title="npx seniority check badplugin.mjs" exit="1" E_PLUGIN_SCHEMA: plugin "acme-vault": source "vault" has rank -5 fix: a rank is an integer strictly between 0 (flag) and 40 (default) — a source may not beat what the user typed, nor sink below the declared default ``` ## What is tested - [`plugin.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/seniority/src/plugin.test.ts): - a plugin's source is the provenance of the value, and names itself in `--explain`; - it appears as a losing candidate when a built-in outranks it; - a source may be data; - the rank slots it between the built-ins, and cannot outrank the flag or sink below the default; - the family's refusals. --- # Precedence and --explain > resolve() applies one fixed order — flag, env, config, package.json, default — and records, for every value, the source that set it, the file line, and every candidate it beat, as text, --json data or an agent event. Source: https://seniority.interlace.tools/docs/guides/precedence `resolve(specs, layers)` takes the options a command declares and the layers a run has. For every option it returns three things: | field | what it is | | :-- | :-- | | `values[key]` | the value that won | | `provenance[key]` | `{ source, location, line? }`: where it came from | | `candidates[key]` | every source in `ORDER`, set or not, with its value | The first source in `ORDER` that sets an option wins: `flag`, `env`, `config`, `package`, `default`. [The truth table](/docs/guides/truth-table) spells that out for all 32 combinations, generated from the exported `TRUTH_TABLE`. `flags` must hold only what the user typed. A parser's defaults belong in the spec; if they were passed as flags, they would outrank the environment. ## The record behind `--explain` `explain(name, result)` prints the answer a person reads. `explanation(name, result)` is the same answer as a record. `explanationJson` projects it for `--json`, and `explanationEvent` for an agent's event stream: ```js title="record.mjs" import { explanation, explanationEvent, explanationJson, resolve } from 'seniority'; const specs = { region: { type: 'string', default: 'us-1' } }; const result = resolve(specs, { flags: {}, env: { APP_REGION: 'eu-3' }, envPrefix: 'APP', config: { path: 'app.config.json', data: { region: 'eu-2' }, lines: { region: 2 } } }); const record = explanation('region', result); console.log(JSON.stringify(explanationJson(record))); console.log(JSON.stringify(explanationEvent(record))); ``` ```text title="node record.mjs" {"option":"region","value":"eu-3","source":"env","location":"APP_REGION","candidates":[{"source":"flag","location":"--region","set":false},{"source":"env","location":"APP_REGION","set":true,"value":"eu-3"},{"source":"config","location":"app.config.json","line":2,"set":true,"value":"eu-2"},{"source":"default","location":"default","set":true,"value":"us-1"}]} {"event":"config.explain","data":{"option":"region","value":"eu-3","source":"env","location":"APP_REGION","candidates":[{"source":"flag","location":"--region","set":false},{"source":"env","location":"APP_REGION","set":true,"value":"eu-3"},{"source":"config","location":"app.config.json","line":2,"set":true,"value":"eu-2"},{"source":"default","location":"default","set":true,"value":"us-1"}]}} ``` An agent gets the same answer a person does: the winner and every candidate. Candidates that lost and candidates that were never set are told apart by `set`. A JSON config layer loaded by `discover` carries the line each key is set on, so the record points at `app.config.json:2`, not just the file. An option the command does not declare is reported as undeclared, not as unset. ## Bring your own option type `resolve` reads three fields of a spec and ignores the rest: - `type`: only `'boolean'` changes how the environment is read; - `env`; - `default`. So the option type your CLI already has works as it is, with no adapter. In TypeScript, a richer type is already an `OptionSpec` by structure; burgee passes its own eighteen-field option type straight in. ```js title="own-type.mjs" import { resolve } from 'seniority'; // A CLI's own option shape: seniority reads `type`, `env` and `default`, and nothing else. const options = { region: { type: 'string', default: 'us-1', choices: ['us-1', 'eu-2'], describe: 'where to deploy', group: 'Deploy' }, verbose: { type: 'boolean', alias: 'v', describe: 'say more', hidden: false }, }; const { values, provenance } = resolve(options, { flags: { verbose: true }, env: {} }); console.log(values, provenance.region.source, provenance.verbose.source); ``` ```text title="node own-type.mjs" { region: 'us-1', verbose: true } default flag ``` ## What is tested - [`precedence.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/seniority/src/precedence.test.ts): - flag > env > config > package.json > default, each pair; - the default is last and has no location; - `ORDER` is the declaration, and the candidates follow it exactly. - [`truth-table.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/seniority/src/truth-table.test.ts): - `TRUTH_TABLE` equals `precedence-vectors.json`, row for row; - `resolve` gives every row's winner. burgee's `precedence-vectors.test.ts` runs the same rows through a real program, so a change to the order fails both suites. - [`explain.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/seniority/src/explain.test.ts): - the winner and every candidate, in `ORDER`; - lost and unset are told apart; - the line is carried; - the text is the record rendered, byte for byte; - `--json` and the event are the same record. --- # The truth table > Every combination of the 5 sources seniority reads, and which one wins: 32 rows generated from the exported TRUTH_TABLE, which is generated from ORDER, the array the resolver reads. Source: https://seniority.interlace.tools/docs/guides/truth-table The order is `flag` > `env` > `config` > `package.json` > `default`: the first source that sets a value wins. The table below is that sentence for every case, one row per combination of sources that set a value. `set` means the source set one; `·` means it did not. | `flag` | `env` | `config` | `package.json` | `default` | wins | | :-: | :-: | :-: | :-: | :-: | :-- | | set | · | · | · | · | **`flag`** | | set | set | · | · | · | **`flag`** | | set | · | set | · | · | **`flag`** | | set | set | set | · | · | **`flag`** | | set | · | · | set | · | **`flag`** | | set | set | · | set | · | **`flag`** | | set | · | set | set | · | **`flag`** | | set | set | set | set | · | **`flag`** | | set | · | · | · | set | **`flag`** | | set | set | · | · | set | **`flag`** | | set | · | set | · | set | **`flag`** | | set | set | set | · | set | **`flag`** | | set | · | · | set | set | **`flag`** | | set | set | · | set | set | **`flag`** | | set | · | set | set | set | **`flag`** | | set | set | set | set | set | **`flag`** | | · | set | · | · | · | **`env`** | | · | set | set | · | · | **`env`** | | · | set | · | set | · | **`env`** | | · | set | set | set | · | **`env`** | | · | set | · | · | set | **`env`** | | · | set | set | · | set | **`env`** | | · | set | · | set | set | **`env`** | | · | set | set | set | set | **`env`** | | · | · | set | · | · | **`config`** | | · | · | set | set | · | **`config`** | | · | · | set | · | set | **`config`** | | · | · | set | set | set | **`config`** | | · | · | · | set | · | **`package.json`** | | · | · | · | set | set | **`package.json`** | | · | · | · | · | set | **`default`** | | · | · | · | · | · | nothing: the option is unset | The same rows are exported as data, so a program, a test or a page can read them: ```js import { TRUTH_TABLE } from 'seniority'; TRUTH_TABLE.find((row) => row.set.join() === 'env,config').winner; // 'env' ``` `TRUTH_TABLE` is `truthTable(ORDER)`, and `ORDER` is the array `resolve` reads. The repository holds both to `precedence-vectors.json`, in seniority's suite and through a real burgee program, so a change to the order has to change the vectors in the same diff. A plugin source slots between two of these by `rank` and never reorders them; see [Plugins](/docs/guides/plugins). --- # Validation > validate() returns every violation at once, each naming the rule, the value and the source that set it — file and line, flag or variable — and check() throws them as one CONFIG error. Source: https://seniority.interlace.tools/docs/guides/validation A value can be the wrong type wherever it came from. seniority's validation knows where it came from, so the message points at the place to fix it: the file and line, the flag the user typed, or the variable. ```js title="validate.mjs" import { check, resolve, validate } from 'seniority'; const specs = { out: { type: 'string' }, mode: { type: 'string' }, retries: { type: 'number' }, token: { type: 'string' } }; const shape = { out: { type: 'string' }, mode: { choices: ['fast', 'safe'] }, retries: { type: 'number' }, token: { required: true } }; const result = resolve(specs, { flags: { mode: 'quick' }, env: { APP_RETRIES: 'three' }, envPrefix: 'APP', config: { path: 'mytool.config.json', data: { out: 4 }, lines: { out: 3 } }, }); for (const violation of validate(shape, result)) console.log(violation.message); try { check(shape, result); } catch (error) { console.log(`${error.constructor.name}, ${error.message.split('\n').length} lines`); } ``` ```text title="node validate.mjs" `out` must be a string; `mytool.config.json:3` set it to `4` `mode` must be one of fast, safe; `--mode` set it to `"quick"` `retries` must be a number; `APP_RETRIES` set it to `"three"` `token` is required, and no source set it ConfigError, 4 lines ``` - **Every violation, not just the first.** A config with four mistakes is fixed in one pass. - **`validate` never throws.** No violations is an empty array. `check` is the throwing form: one `ConfigError` listing them all, or the values when there is nothing to say. - **A shape reads `type`, `choices`, `required` and `default`.** An option with no declared type is not type-checked: a shape that says nothing asserts nothing. - **Each violation is data too:** `{ key, message, expected, value, provenance }`, so `--json` can carry it without parsing the sentence. The line comes from the layer. `discover` records the line of each top-level key in a JSON config. A JavaScript config has no line to give, so its violations name the file alone. ## What is tested - [`validate.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/seniority/src/validate.test.ts): - the sentence, line and all; - no line when the source has none; - a flag named as the user typed it; - choices listed; - a required option with no provenance; - every violation reported; - `check` throws one CONFIG error. --- # seniority > Resolves flags, environment variables, config files and defaults in one fixed order, with provenance: every value can say where it came from. Zero dependencies. Drop-in paths for cosmiconfig, lilconfig, dotenv and rc. Source: https://seniority.interlace.tools/docs **seniority** resolves flags, environment variables, config files, a `package.json` field and declared defaults in one fixed order, with **provenance**: every value can say where it came from. 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. Drop-in for **cosmiconfig**, **dotenv** and **rc**: `npx burgee migrate` moves the imports, each path graded by that incumbent's own suite. **Over the platform.** Node reads one `.env` file itself, with `process.loadEnvFile` and `util.parseEnv`. The platform's parser is not dotenv's: it reads an escaped quote differently, and on Node 20 and 22 it differs on more. seniority parses the way dotenv does, and adds what no builtin has: one precedence over flags, environment, config files and defaults, discovery, and the provenance of every value. The repository's absorption probe asks every supported Node line how far `util.parseEnv` has caught up. ``` flag > env > config file > package.json field > default ``` Zero dependencies. Node builtins only. Works with any option type you already have. ## Install ```bash npm install seniority pnpm add seniority yarn add seniority bun add seniority ``` ## Quick start ```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 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. ## 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 ``` `TRUTH_TABLE` is the same order as one row per combination of sources: 32 rows, each the sources that set a value and the one that wins. It is generated from `ORDER`, so it cannot disagree with a run. ## `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. YAML is one loader away, and still not a dependency. `seniority/yaml` is this package's own parser — the part of YAML a config file is written in, read the way `js-yaml` 5 reads it — in an entry of its own, so a program that never imports it never carries it: ```js import { discover } from "seniority"; import { parse } from "seniority/yaml"; const yaml = (_path, text) => parse(text); await discover({ name: "mytool", cwd: process.cwd(), env: process.env, loaders: { ".yaml": yaml, ".yml": yaml }, extensions: [".json", ".yaml", ".yml", ".js"], }); ``` Supplying the parser and asking discovery to look for the format are two options on purpose: a program may want either without the other. ## 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. ## 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. A lower rank wins. Where a source lands, as the number to write: | to rank… | `rank` | | :-- | :-- | | above the environment, below a flag | 1–9 | | above the config file, below the environment | 11–19 | | above `package.json`, below the config file | 21–29 | | above the default, below `package.json` | 31–39 | The same plugin as a file of its own — one module whose default export is a plain object, with no import, which is the shape `check` loads: ```js // acme-vault.mjs export default { name: 'acme-vault', contract: 1, sources: { vault: { rank: 11, // env is 10 and the config file 20: this beats the file and loses to the environment read: (runtime) => (runtime.env.CI_REGION === undefined ? undefined : { location: 'acme://vault/ci', values: { region: runtime.env.CI_REGION } }), }, }, }; ``` Check it before it ships: ```bash npx seniority check ./acme-vault.mjs # in a clone of this repository, where dist/ is not committed: npx turbo run build --filter=seniority && node packages/seniority/dist/cli.js check ./acme-vault.mjs ``` ```text acme-vault — 1 sources vault acme-vault: ok ``` A refusal exits 1 with a code and the edit to make: a rank outside 1–39, both `values` and `read` or neither, or a `sources` that is not an object keyed by name. ## Migrating Each path starts with one import change. `npx burgee migrate --dry-run` lists the imports the codemod would rewrite — only drop-ins graded level with their incumbent — and `npx burgee migrate` makes the change ([Migrate](https://burgee.interlace.tools/docs/migrate)). ### `cosmiconfig` The root export carries cosmiconfig's own surface, so a migration is the import line: ```diff - 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. ### `seniority/dotenv` `config`, `configDotenv`, `parse` and `populate` are dotenv 18's, both of its parsers included, so a migration is the import line: ```diff - import dotenv from "dotenv"; + import dotenv from "seniority/dotenv"; - import "dotenv/config"; + import "seniority/dotenv/config"; ``` `config()` behaves as dotenv's does when told nothing: it reads `./.env` from the working directory into `process.env`, takes its defaults from `DOTENV_PATH`, `DOTENV_QUIET` and the rest (or their older `DOTENV_CONFIG_*` names), and prints `◇ injected env (n) from .env` on stderr unless quiet. Pass `processEnv` to populate another object, and `path` for another file (or several, a `URL`, a leading `~`). `parse(src, { fast: true })` is dotenv's character scanner. `seniority/dotenv/config` is the side-effect entry, quiet unless asked, and `seniority/dotenv/cli` is `dotenv run` — `run(['run', '-f', '.env.local', '--', 'node', 'app.js'])` loads the files, starts the command, and forwards its signals. `decrypt`, `DOTENV_KEY` and the `.env.vault` format are not built, because dotenv 18 removed them. The process is read in one place, `src/runtime.ts`, and only by the drop-in paths whose incumbents read it by default. `resolve` never reads it, and `parse` works on a string you already have — in a test, in a browser build, anywhere. ### `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: ```diff - import { lilconfigSync } from "lilconfig"; + import { lilconfigSync } from "seniority/lilconfig"; ``` 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. ```diff - const config = require("rc")("mytool", defaults); + import rc from "seniority/rc"; + const config = rc("mytool", defaults, argv); ``` Like rc, it reads the process's environment when you pass none; a fifth argument, `{ env, cwd, home, win }`, supplies the world instead. Two differences, both on purpose. The command line is an argument: rc fills a missing `argv` with `minimist(process.argv.slice(2))`, and this treats it as empty. 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. ### `seniority/find-up` find-up 8's surface — `findUp`, `findUpSync`, `findUpMultiple`, `findUpMultipleSync`, a matcher function and `findUpStop`, `findDown` and `findDownSync`, with locate-path's `type` and `allowSymlinks` — in one file. `find-up` → `locate-path` → `p-locate` is three packages for that. ```diff - import { findUp } from "find-up"; + import { findUp } from "seniority/find-up"; ``` ## Compatibility 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. Each path below is graded by its incumbent's own suite, run unmodified through `compat-oracle`; the current grades are generated under *Benchmarks* below and published on the [compatibility page](https://burgee.interlace.tools/docs/compatibility). ### `cosmiconfig` **Graded by cosmiconfig 10.0.1's own test suite: 242 of 243 cases, the same as the real `cosmiconfig` gets here.** Not "compatible" — a number, from the incumbent's tests, run unmodified. YAML is read the way cosmiconfig reads it. cosmiconfig takes `js-yaml` for `.yaml`, `.yml` and extensionless files; this package reads them with `seniority/yaml`, its own parser, loaded the first time a YAML file is — so a program whose configs are JSON or JavaScript never loads it. It returns what `js-yaml` 5 returns, and its errors carry js-yaml's reason and position, which cosmiconfig's suite asserts word for word. The one case not passing fails for cosmiconfig too: the test harness reaching for a file path the vendored copy does not have. The global config directory is where cosmiconfig puts it, through `env-paths`: `$XDG_CONFIG_HOME/` on Linux, `~/.config/` when that is unset, `%APPDATA%\\Config` on Windows and `~/Library/Preferences/` on macOS. The suite's XDG pair, which registers only on Linux, passes. On macOS, where it does not register, both read 240 of 241. ### `seniority/dotenv` **Graded by dotenv 18.0.9's own test suite: 197 of 197, the same as the real `dotenv` gets here.** That includes its `dotenv run` command line and signal forwarding, both parsers, and `import 'dotenv/config'`. dotenv 17.4.2's suite is graded too and not claimed: 107 of 141, because 17's vault, tips and `console.log` line are what 18 changed. ### `seniority/find-up` **Graded by find-up 8.0.0's own test suite: 104 of 104 cases, the same as the real `find-up` gets here.** Two of the suite's 106 are subtracted by name from both sides, and the compatibility page says why: each expects a `.git` directory at the package's own root, which a vendored copy does not have. A name matches a file unless `type` says otherwise, and `cwd` defaults to the process's working directory, as find-up's do. ### `seniority/lilconfig` **Graded by lilconfig 3.1.4's own test suite: 77 of 77 cases, the same as the real `lilconfig` gets here.** Ten of them assert which files were read by mocking `fs` with `jest.mock`; since #549 the oracle applies that mock the way jest does, so they run and pass instead of failing for both. ### `seniority/rc` **Graded by rc 1.2.8's own test: 1 of 1 — by exit code.** That suite is one script of bare assertions with no reporter, so the grade is one bit: the script ran against this package and exited 0. It is not 100% of anything. ## Benchmarks Every number here is produced by `npm run bench` and published at [burgee.interlace.tools/docs/benchmarks](https://burgee.interlace.tools/docs/benchmarks). Graded by the incumbent's own test suite: | suite | passing | | :-- | --: | | `cosmiconfig` | 242 / 243 | | `dotenv` | 197 / 197 | | `find-up` | 104 / 104 | | `lilconfig` | 77 / 77 | | `rc` | 1 / 1 | Weight, installed and tree-inclusive: **223,753 bytes** against **1,972,507** for the incumbents it replaces — a ratio of **0.1134** ([measured](https://burgee.interlace.tools/docs/benchmarks)). ## For agents - **"Why is this set?" has a machine answer.** `explanation(name, res)` is `--explain` as a record; `explanationJson()` is the same record as `--json` data and `explanationEvent()` as an agent event, so an agent gets the answer a person does — every candidate, including the ones that were unset and the ones that lost. - **The order is data.** `ORDER` and `RANK` are exported, so a tool reading your `--schema` can state the precedence without guessing it. - **`resolve` touches nothing.** No filesystem, no `process.env`, no globals: pass the environment in and the same inputs give the same answer anywhere. - **A source plugin can be checked before it ships.** `npx seniority check ./vault.mjs` validates it against the family schema and exits 0, 1 with a code and a fix, or 2 on a usage error. The whole plugin, the rank to write and the report are under [Plugins](#plugins). - **The docs are machine-readable** at [seniority.interlace.tools/llms.txt](https://seniority.interlace.tools/llms.txt) and [llms-full.txt](https://seniority.interlace.tools/llms-full.txt). ## 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 | | `packageLayer(path, name)` | the program's `package.json` field as a layer, each key cited at its line | | `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 | | `TRUTH_TABLE`, `truthTable` | every combination of sources and the one that wins, generated from `ORDER` | | `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` | Every export, with its types, is on [seniority.interlace.tools](https://seniority.interlace.tools/docs). ## 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. ## The family Eleven packages, one repository, one release pipeline. Each installs and works on its own, and each owns one job. Six of them — `bellpull`, `closeout`, `linegauge`, `paratext`, `roundel`, `seniority` — depend on nothing; the others depend only on packages in this table, and every dependency points one way, down the [layers](https://burgee.interlace.tools/docs/concepts/family). No package declares a dependency from outside the family; `react` and `react-reconciler` are optional peers of `controlroom`, which npm does not install; `vitest` is an optional peer of `oxmutate`, which npm does not install. [`package-shape-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/package-shape-lock.test.ts) holds that for every manifest, and [`independence-install-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/independence-install-lock.test.ts) installs each package alone and finds nothing but the family packages it declares. Releases are published by one workflow through npm trusted publishing: no npm token is used, and each release carries SLSA provenance, which `npm view dist.attestations` shows. [`trusted-publishing-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/trusted-publishing-lock.test.ts) keeps both true. | Package | What it is | Migrates from | | :-- | :-- | :-- | | [burgee](https://burgee.interlace.tools/docs/packages/burgee) | The CLI framework: one declaration, every surface | commander, yargs and meow | | [roundel](https://roundel.interlace.tools/docs) | Colour: one output policy, semantic tokens, a theme | chalk, colorette, kleur, picocolors, supports-color and yoctocolors | | [flagstaff](https://flagstaff.interlace.tools/docs) | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 | | [caique](https://caique.interlace.tools/docs) | Prompts that are flags first, and never hang | @inquirer/core | | [linegauge](https://linegauge.interlace.tools/docs) | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line | | [paratext](https://paratext.interlace.tools/docs) | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img | | **seniority** (this package) | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc | | [closeout](https://closeout.interlace.tools/docs) | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime | | [bellpull](https://bellpull.interlace.tools/docs) | Subprocesses, and which executable actually ran | cross-spawn and which | | [controlroom](https://controlroom.interlace.tools/docs) | Keyboard-driven terminal screens, inline or full-screen | ink | | [oxmutate](https://oxmutate.interlace.tools/docs) | Mutation testing on the oxc engine, inside your own Vitest | Stryker | Every migration guide, and the family-wide [compatibility](https://burgee.interlace.tools/docs/compatibility) and [benchmarks](https://burgee.interlace.tools/docs/benchmarks) pages, are on [burgee.interlace.tools](https://burgee.interlace.tools/docs/packages). ## Contributing Issues and pull requests are welcome at [ofri-peretz/burgee](https://github.com/ofri-peretz/burgee/issues); read [CONTRIBUTING.md](https://github.com/ofri-peretz/burgee/blob/main/CONTRIBUTING.md) first. Report a vulnerability privately, as [SECURITY.md](https://github.com/ofri-peretz/burgee/blob/main/SECURITY.md) describes — never in a public issue. ## Licence MIT © Ofri Peretz — see [LICENSE](https://github.com/ofri-peretz/burgee/blob/main/packages/seniority/LICENSE). --- # Add --explain to a CLI > Give a CLI a --explain