// SPDX-License-Identifier: MIT // Part of pi-steering. /** * `defineConfig` — compile-time-typed config builder. * * Two supported authoring styles per the accepted ADR ("Design → * `defineConfig` and compile-time inference"): * * 1. **`defineConfig`** — uses `const`-generics on plugins / observers * to infer the union of observer names, then constrains * {@link Rule.observer} string references to that union. Typos in * `observer: "description-read"` (when the plugin registers * `description-reads`) produce a compile error. * * 2. **`satisfies SteeringConfig`** — plain TypeScript construct users * can fall back to when they don't want the generic inference * complexity. Gets shape validation but no cross-reference name * checking. * * The function itself does minimal runtime work — it just returns the * config unchanged. All the value is in the types. * * Generics threaded through (ADR §8): * - `AllObserverNames
` — for `Rule.observer` string refs. * - `AllWrites
` — for `Rule.when.happened.event`. * - `AllRuleNames
` — for `config.disabledRules`. * - `AllPluginNames
` — for `config.disabledPlugins`.
*
* All four helpers are exported from this module but NOT re-exported
* from the package root; they're internal plumbing, not user-facing
* API. Stable enough that plugin authors who import them directly can
* rely on their shape within a single minor version, but the contract
* is "use via defineConfig".
*/
import type { DEFAULT_PLUGINS, DEFAULT_RULES } from "./defaults.ts";
import type {
BuiltInWhenLeavesOuter,
Observer,
Plugin,
Rule,
SteeringConfig,
} from "./schema.ts";
// ---------------------------------------------------------------------------
// Type-level plumbing: project `name` / `writes` literals off tuples of
// rules, observers, or plugins.
// ---------------------------------------------------------------------------
/**
* Pull a single projection off every element of an array type.
*
* - `K = "name"` — value is the element's `name` literal
* (`{ name: N }` → `N`).
* - `K = "writes"` — value is each element of the element's
* `writes` tuple (`{ writes: readonly [..., S] }`
* → `S`).
*
* Elements missing the field (optional `writes`, widened `name`)
* contribute `never`. Non-tuple `T` inputs short-circuit to `never`.
*/
type ProjectField<
T,
K extends "name" | "writes",
> = T extends readonly (infer E)[]
? E extends Record | ProjectField =
| DefaultPluginName
| ProjectField ;
// ---------------------------------------------------------------------------
// AllRuleNames — union of rule `.name` literals across plugins + user rules.
// ---------------------------------------------------------------------------
/**
* Extract the union of rule names across:
* - every {@link DEFAULT_RULES} entry (engine-shipped defaults),
* - every plugin's `rules: Rule[]` array, AND
* - the top-level inline `rules: Rule[]` array.
*
* Used to constrain {@link SteeringConfig.disabledRules} so typos
* surface as compile errors. Default rule names are always part of
* this union — disabling a default (`disabledRules: ["no-force-push"]`)
* typechecks the same as disabling a user or plugin rule.
*
* Falls back to just {@link DefaultRuleName} when no plugin or user
* rules are registered.
*/
export type AllRuleNames<
P extends readonly Plugin[],
R extends readonly Rule[],
> =
| DefaultRuleName
| FromPluginField
| ProjectField
| FromPluginField
| ProjectField ,
AllWrites
>[],
> extends SteeringConfig {
disabledRules?: readonly AllRuleNames [];
disabledPlugins?: readonly AllPluginNames [];
plugins?: P;
rules?: R;
observers?: Inline;
}
/**
* Build a {@link SteeringConfig} with cross-reference name checking.
*
* Observer references in {@link Rule.observer} are typed against the
* union of observer names gathered from `plugins[*].observers` AND the
* top-level `observers` array — a typo produces a compile error.
*
* The `disabledRules` / `disabledPlugins` arrays are typed against the unions
* of registered rule / plugin names — typos rejected.
*
* `rules[].when.happened.event` and `rules[].when.happened.since` are
* both typed against the union of all `writes` declarations across
* plugin rules, plugin observers, user rules, and user observers —
* typos rejected. (The `since` field on the `Writes` union enforces
* the same contract as `event`: the sentinel event must be known to
* the config, not a free-form string.)
*
* Runtime behavior: returns a shallow copy of the input with optional
* fields normalized from `readonly` arrays to mutable arrays (the
* {@link SteeringConfig} shape doesn't constrain mutability). The
* return value is safe to pass to the loader / buildConfig.
*
* ## Authoring pattern — preserving observer/plugin names for inference
*
* For compile-time typo detection on rule `observer` references, declare
* your observers and plugins with `as const satisfies` so TypeScript
* preserves the literal `name` values through to `AllObserverNames`:
*
* const myObs = {
* name: "description-read",
* onResult: (event, ctx) => { ... },
* } as const satisfies Observer;
*
* const myPlugin = {
* name: "my-plugin",
* observers: [{ name: "sync-done", onResult: ... }],
* } as const satisfies Plugin;
*
* Authors who prefer type annotations (`const myObs: Observer = ...`)
* get widened `name: string`, which collapses `AllObserverNames` to
* `string` and silently disables typo detection. Use `as const satisfies`
* to keep the inference.
*
* ## Behavior with no observers declared
*
* When no plugins contribute observers AND no inline `observers[]` is
* passed, `AllObserverNames` resolves to `never`, which causes ANY
* string `observer` reference on a Rule to be a compile error. This is
* deliberate — fail-closed on unknown observer names. For configs that
* deliberately reference observers by name without registering them
* inline (e.g., deferred to runtime), use `satisfies SteeringConfig`
* as a fallback; you lose typo detection but regain flexibility.
*
* ## Hover ergonomics for plugin-predicate JSDoc
*
* The `const R extends readonly Rule[]` signature narrows the
* contextual type of inline rule literals to their `const`-inferred
* shape, bypassing the homomorphic mapped-type linkage that surfaces
* source-declared JSDoc on hover (e.g. on `when.isClean:`). Factor
* rules out into `const myRule = { ... } as const satisfies Rule`
* bindings before passing them to `defineConfig` to keep the
* hover-rich shape; see the
* `examples/dynamic-reason-runtime-cwd/steering.ts` example. The
* `as const` modifier on the binding (and the `const R` modifier on
* the signature) preserves each rule's literal `name` so
* `disabledRules` typo detection fires — the alternatives `: Rule`
* and bare `satisfies Rule` restore hover but widen the inferred
* type and collapse typo detection (and `when.happened.event`
* narrowing across declared `writes`).
*
* @example
* export default defineConfig({
* plugins: [gitPlugin],
* observers: [descriptionReadObserver],
* rules: [
* { name: "must-read-docs", ..., observer: "description-read" },
* ],
* });
*/
export function defineConfig<
const P extends readonly Plugin[] = [],
const Inline extends readonly Observer[] = [],
const R extends readonly Rule<
AllObserverNames ,
AllWrites
>[] = [],
>(config: DefineConfigInput ): SteeringConfig {
// Runtime work is minimal: copy the supplied config, widening the
// `readonly` tuple slots back to plain arrays for downstream
// consumers (loader, evaluator) that don't care about the tuple
// literal types. The generic machinery's job is done at the call
// site — once we return, we return plain SteeringConfig.
const out: SteeringConfig = {};
if (config.defaultNoOverride !== undefined) {
out.defaultNoOverride = config.defaultNoOverride;
}
if (config.disabledRules !== undefined) {
out.disabledRules = [...config.disabledRules];
}
if (config.disabledPlugins !== undefined) {
out.disabledPlugins = [...config.disabledPlugins];
}
if (config.disableDefaults !== undefined) {
out.disableDefaults = config.disableDefaults;
}
if (config.failOnWarnings !== undefined) {
out.failOnWarnings = config.failOnWarnings;
}
if (config.plugins !== undefined) {
// Cast: `readonly Plugin[]` → `Plugin[]` (shape is identical;
// the loader never mutates the array, but SteeringConfig
// doesn't require readonly).
out.plugins = [...config.plugins];
}
if (config.rules !== undefined) {
out.rules = [...config.rules] as Rule[];
}
if (config.observers !== undefined) {
out.observers = [...config.observers];
}
return out;
}