// SPDX-License-Identifier: MIT // Part of pi-steering. /** * Two-layer discovery + merge tests for {@link loadConfigs}, * {@link buildConfig}, and {@link loadSteeringConfig}. * * Uses the same scratch-HOME + `mkdtempSync` pattern as the v1 JSON * loader tests (`../loader.test.ts`) to keep global config leakage * out of the test run — the isolated `$HOME` also points the global * layer at a scratch `/.pi/agent/steering/`. Fixtures are * written fresh per test so runs are reproducible without * repo-committed scratch files. */ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, it } from "node:test"; import { useIsolatedHome } from "./__test-helpers__.ts"; import { buildConfig, configCandidates, findConfigFile, loadConfigs, loadSteeringConfig, resolveAgentDir, } from "./loader.ts"; import type { Plugin, SteeringConfig } from "./schema.ts"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Emit a minimal `.ts` config module at `file`. */ function writeConfig(file: string, body: string): void { mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, body, "utf8"); } /** * Body template: default-exports an object literal with the given * identifiers / patterns. Kept as raw source so the real dynamic-import * codepath runs (we're testing the native-TS-loading behavior, not a * synthetic hook). */ function configModule(body: string): string { return `// Generated by loader.test.ts\nexport default ${body};\n`; } // --------------------------------------------------------------------------- // Unit bits // --------------------------------------------------------------------------- describe("loader: configCandidates", () => { it("returns index.ts before steering.ts", () => { const [a, b] = configCandidates("/tmp/x"); assert.equal(a, "/tmp/x/.pi/steering/index.ts"); assert.equal(b, "/tmp/x/.pi/steering.ts"); }); it("honors a custom slot", () => { const [a, b] = configCandidates("/tmp/x", "steering"); assert.equal(a, "/tmp/x/steering/index.ts"); assert.equal(b, "/tmp/x/steering.ts"); }); }); describe("loader: resolveAgentDir", () => { useIsolatedHome("pi-steering-v2-agentdir-"); it("falls back to ~/.pi/agent when PI_CODING_AGENT_DIR is unset", () => { const prior = process.env["PI_CODING_AGENT_DIR"]; delete process.env["PI_CODING_AGENT_DIR"]; try { assert.equal(resolveAgentDir(), join(homedir(), ".pi", "agent")); } finally { if (prior !== undefined) process.env["PI_CODING_AGENT_DIR"] = prior; } }); it("treats an empty-string PI_CODING_AGENT_DIR as unset", () => { const prior = process.env["PI_CODING_AGENT_DIR"]; process.env["PI_CODING_AGENT_DIR"] = ""; try { assert.equal(resolveAgentDir(), join(homedir(), ".pi", "agent")); } finally { if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"]; else process.env["PI_CODING_AGENT_DIR"] = prior; } }); it("expands a bare ~ PI_CODING_AGENT_DIR to homedir", () => { const prior = process.env["PI_CODING_AGENT_DIR"]; process.env["PI_CODING_AGENT_DIR"] = "~"; try { assert.equal(resolveAgentDir(), homedir()); } finally { if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"]; else process.env["PI_CODING_AGENT_DIR"] = prior; } }); }); // --------------------------------------------------------------------------- // findConfigFile // --------------------------------------------------------------------------- describe("loader: findConfigFile", () => { let tmp: string; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), "pi-steering-v2-loader-")); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); it("prefers .pi/steering/index.ts over .pi/steering.ts", () => { writeConfig(join(tmp, ".pi", "steering.ts"), configModule("{}")); writeConfig(join(tmp, ".pi", "steering", "index.ts"), configModule("{}")); const { file } = findConfigFile(tmp); assert.equal(file, join(tmp, ".pi", "steering", "index.ts")); }); it("falls back to .pi/steering.ts when index.ts is absent", () => { writeConfig(join(tmp, ".pi", "steering.ts"), configModule("{}")); const { file, diagnostic } = findConfigFile(tmp); assert.equal(file, join(tmp, ".pi", "steering.ts")); assert.equal(diagnostic, null); }); it("returns null when neither candidate exists", () => { assert.deepEqual(findConfigFile(tmp), { file: null, diagnostic: null }); }); it("reports a coexistence diagnostic when both forms exist", () => { writeConfig(join(tmp, ".pi", "steering.ts"), configModule("{}")); writeConfig(join(tmp, ".pi", "steering", "index.ts"), configModule("{}")); const { file, diagnostic } = findConfigFile(tmp); assert.equal(file, join(tmp, ".pi", "steering", "index.ts")); assert.ok(diagnostic, "expected a diagnostic when both forms coexist"); assert.equal(diagnostic.kind, "layer-form-coexistence"); assert.equal(diagnostic.type, "warning"); // `path` points at the parent directory — the renderer's `${path}:` // prefix surfaces the dir once and the message names the conflict. assert.equal(diagnostic.path, tmp); assert.match( diagnostic.message, /both .pi\/steering.ts and .pi\/steering\/index.ts/, ); }); it("finds the global slot (steering) under the agent dir", () => { writeConfig(join(tmp, "steering.ts"), configModule("{}")); writeConfig(join(tmp, "steering", "index.ts"), configModule("{}")); const { file, diagnostic } = findConfigFile(tmp, "steering"); assert.equal(file, join(tmp, "steering", "index.ts")); assert.ok( diagnostic, "expected a coexistence diagnostic for the global slot", ); assert.match(diagnostic.message, /steering\.ts and steering\/index\.ts/); }); }); // --------------------------------------------------------------------------- // loadConfigs — two-layer discovery, stray-file diagnostic, bad layer // handling // --------------------------------------------------------------------------- describe("loader: loadConfigs", () => { let tmp: string; useIsolatedHome("pi-steering-v2-loadcfgs-", (t) => { tmp = t; }); it("returns empty when no layer has a config", async () => { const cwd = join(tmp, "a", "b"); mkdirSync(cwd, { recursive: true }); const { layers, diagnostics } = await loadConfigs(cwd); assert.deepEqual(layers, []); assert.deepEqual(diagnostics, []); }); it("loads the project layer first, then the global layer", async () => { const proj = join(tmp, "proj"); mkdirSync(proj, { recursive: true }); writeConfig( join(proj, ".pi", "steering.ts"), configModule("{ disabledRules: ['project'] }"), ); writeConfig( join(tmp, ".pi", "agent", "steering", "index.ts"), configModule("{ disabledRules: ['global'] }"), ); const { layers } = await loadConfigs(proj); // Project (inner) → global (outer) order. assert.deepEqual( layers.map((l) => l.disabledRules?.[0]), ["project", "global"], ); }); it("does not collect intermediate ancestor layers (walk-up removed)", async () => { // Pins the breaking change: a config in an ancestor directory // between cwd and HOME is no longer discovered — only the cwd // project layer and the agent-dir global layer load. const cwd = join(tmp, "a", "b"); mkdirSync(cwd, { recursive: true }); writeConfig( join(tmp, "a", ".pi", "steering.ts"), configModule("{ disabledRules: ['ancestor'] }"), ); const { layers, diagnostics } = await loadConfigs(cwd); assert.deepEqual(layers, []); assert.deepEqual(diagnostics, []); }); it("loads legacy ~/.pi/steering/ only when cwd is home", async () => { // The old global location still works in exactly one situation: // launching from $HOME itself, where the project layer // `/.pi/steering/` IS the legacy `~/.pi/steering/` path. const sub = join(tmp, "sub"); mkdirSync(sub, { recursive: true }); writeConfig( join(tmp, ".pi", "steering.ts"), configModule("{ disabledRules: ['legacy'] }"), ); writeConfig( join(tmp, ".pi", "agent", "steering", "index.ts"), configModule("{ disabledRules: ['global'] }"), ); const fromSub = await loadConfigs(sub); assert.deepEqual( fromSub.layers.map((l) => l.disabledRules?.[0]), ["global"], "legacy ~/.pi/steering/ must NOT load below home", ); const fromHome = await loadConfigs(tmp); assert.deepEqual( fromHome.layers.map((l) => l.disabledRules?.[0]), ["legacy", "global"], "at cwd === home the project layer is the legacy path", ); }); it("honors PI_CODING_AGENT_DIR for the global layer", async () => { const prior = process.env["PI_CODING_AGENT_DIR"]; process.env["PI_CODING_AGENT_DIR"] = join(tmp, "custom-agent"); try { const proj = join(tmp, "proj"); mkdirSync(proj, { recursive: true }); writeConfig( join(tmp, "custom-agent", "steering", "index.ts"), configModule("{ disabledRules: ['custom'] }"), ); const { layers } = await loadConfigs(proj); assert.deepEqual( layers.map((l) => l.disabledRules?.[0]), ["custom"], ); } finally { if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"]; else process.env["PI_CODING_AGENT_DIR"] = prior; } }); it("tilde-expands a ~/… PI_CODING_AGENT_DIR under HOME", async () => { const prior = process.env["PI_CODING_AGENT_DIR"]; process.env["PI_CODING_AGENT_DIR"] = "~/my-agent"; try { const proj = join(tmp, "proj"); mkdirSync(proj, { recursive: true }); writeConfig( join(tmp, "my-agent", "steering", "index.ts"), configModule("{ disabledRules: ['tilde'] }"), ); const { layers } = await loadConfigs(proj); assert.deepEqual( layers.map((l) => l.disabledRules?.[0]), ["tilde"], ); } finally { if (prior === undefined) delete process.env["PI_CODING_AGENT_DIR"]; else process.env["PI_CODING_AGENT_DIR"] = prior; } }); it("records a layer-import-failed diagnostic for the GLOBAL layer under the agent dir", async () => { // Mirrors the project-layer import-failed pin: the shared // loadLayer path must surface the same diagnostic shape when the // failing module lives at `/steering.ts` (default agent // dir under the isolated HOME). const proj = join(tmp, "proj"); mkdirSync(proj, { recursive: true }); writeConfig( join(tmp, ".pi", "agent", "steering.ts"), "export default { rules: {{ not valid ts }} };", ); const { layers, diagnostics } = await loadConfigs(proj); assert.deepEqual(layers, []); const hit = diagnostics.find((d) => d.kind === "layer-import-failed"); assert.ok( hit, `expected a layer-import-failed diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.equal(hit.type, "warning"); assert.equal(hit.path, join(tmp, ".pi", "agent", "steering.ts")); assert.match(hit.message, /failed to import/); }); it("prefers index.ts over steering.ts at the same layer", async () => { const cwd = join(tmp, "project"); mkdirSync(cwd, { recursive: true }); writeConfig( join(cwd, ".pi", "steering.ts"), configModule("{ disabledRules: ['flat-file'] }"), ); writeConfig( join(cwd, ".pi", "steering", "index.ts"), configModule("{ disabledRules: ['directory'] }"), ); const { layers } = await loadConfigs(cwd); assert.equal(layers.length, 1); assert.deepEqual(layers[0]?.disabledRules, ["directory"]); }); it("reports a layer-form-coexistence diagnostic when both forms exist, uses directory form", async () => { const cwd = join(tmp, "project"); mkdirSync(cwd, { recursive: true }); writeConfig( join(cwd, ".pi", "steering.ts"), configModule("{ disabledRules: ['flat-form'] }"), ); writeConfig( join(cwd, ".pi", "steering", "index.ts"), configModule("{ disabledRules: ['dir-form'] }"), ); const { layers, diagnostics } = await loadConfigs(cwd); assert.equal(layers.length, 1); assert.deepEqual( layers[0]?.disabledRules, ["dir-form"], "directory form should win on ambiguous coexistence", ); const hit = diagnostics.find((d) => d.kind === "layer-form-coexistence"); assert.ok( hit, `expected a layer-form-coexistence diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.equal(hit.type, "warning"); assert.match( hit.message, /both .pi\/steering.ts and .pi\/steering\/index.ts/, ); }); it("emits a layer-stray-file diagnostic per non-.ts file under .pi/steering/", async () => { const cwd = join(tmp, "project"); mkdirSync(cwd, { recursive: true }); // Create the steering/ dir with stray non-.ts files AND no // index.ts, so the loader has a reason to walk the dir. Cover // all four file extensions users are likely to fat-finger into // the directory (.mjs / .json per the original test, plus .mts // and .js to catch the TS-lookalike + bare-JS cases). mkdirSync(join(cwd, ".pi", "steering"), { recursive: true }); writeFileSync( join(cwd, ".pi", "steering", "rules.mjs"), "// not ts", "utf8", ); writeFileSync(join(cwd, ".pi", "steering", "rules.json"), "{}", "utf8"); writeFileSync( join(cwd, ".pi", "steering", "rules.mts"), "// looks like ts but isn't .ts", "utf8", ); writeFileSync( join(cwd, ".pi", "steering", "rules.js"), "// plain js", "utf8", ); const { diagnostics } = await loadConfigs(cwd); const stray = diagnostics.filter((d) => d.kind === "layer-stray-file"); assert.equal(stray.length, 4); assert.ok(stray.every((d) => d.type === "warning")); const paths = stray.map((d) => d.path ?? ""); assert.ok(paths.some((p) => p.endsWith("rules.mjs"))); assert.ok(paths.some((p) => p.endsWith("rules.json"))); assert.ok(paths.some((p) => p.endsWith("rules.mts"))); assert.ok(paths.some((p) => p.endsWith("rules.js"))); }); it("does NOT report stray-file diagnostics for .ts helpers under .pi/steering/", async () => { const cwd = join(tmp, "project"); mkdirSync(cwd, { recursive: true }); writeConfig(join(cwd, ".pi", "steering", "index.ts"), configModule("{}")); writeConfig( join(cwd, ".pi", "steering", "helpers.ts"), "export const x = 1;", ); const { diagnostics } = await loadConfigs(cwd); assert.deepEqual( diagnostics.filter((d) => d.kind === "layer-stray-file"), [], ); }); it("records a layer-import-failed diagnostic and skips a layer whose module fails to import", async () => { const cwd = join(tmp, "project"); mkdirSync(cwd, { recursive: true }); writeConfig( join(cwd, ".pi", "steering.ts"), "export default { rules: {{ not valid ts }} };", ); const { layers, diagnostics } = await loadConfigs(cwd); assert.deepEqual(layers, []); const hit = diagnostics.find((d) => d.kind === "layer-import-failed"); assert.ok( hit, `expected a layer-import-failed diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.equal(hit.type, "warning"); assert.equal(hit.path, join(cwd, ".pi", "steering.ts")); assert.match(hit.message, /failed to import/); }); it("records a layer-import-failed diagnostic when the default export is not an object", async () => { const cwd = join(tmp, "project"); mkdirSync(cwd, { recursive: true }); writeConfig(join(cwd, ".pi", "steering.ts"), "export default 42;"); const { layers, diagnostics } = await loadConfigs(cwd); assert.deepEqual(layers, []); assert.ok( diagnostics.some( (d) => d.kind === "layer-import-failed" && d.message.includes("must be a SteeringConfig object"), ), ); }); it("records a layer-import-failed diagnostic when the module has no default export", async () => { const cwd = join(tmp, "project"); mkdirSync(cwd, { recursive: true }); // Named exports only — no `export default`. This used to silently // fall back to treating the module namespace itself as the // config (Fix 2 removed that fallback). writeConfig( join(cwd, ".pi", "steering.ts"), "export const rules = [];\nexport const plugins = [];\n", ); const { layers, diagnostics } = await loadConfigs(cwd); assert.deepEqual(layers, []); assert.ok( diagnostics.some( (d) => d.kind === "layer-import-failed" && d.message.includes("must have a default export"), ), `expected 'must have a default export' diagnostic; got: ${JSON.stringify( diagnostics, )}`, ); }); it("records a layer-import-failed diagnostic when the default export is an array", async () => { const cwd = join(tmp, "project"); mkdirSync(cwd, { recursive: true }); // Arrays pass `typeof === 'object'`; the hardened guard (Fix 3) // rejects them explicitly with an "array" tag in the message. writeConfig(join(cwd, ".pi", "steering.ts"), "export default [];"); const { layers, diagnostics } = await loadConfigs(cwd); assert.deepEqual(layers, []); assert.ok( diagnostics.some( (d) => d.kind === "layer-import-failed" && d.message.includes("must be a SteeringConfig object") && d.message.includes("got array"), ), `expected array-rejection diagnostic; got: ${JSON.stringify(diagnostics)}`, ); }); it("layer-import-failed message does not duplicate the path that the diagnostic's `path` field already carries", async () => { const cwd = join(tmp, "project"); mkdirSync(cwd, { recursive: true }); const configPath = join(cwd, ".pi", "steering.ts"); writeConfig(configPath, "export const rules = [];\n"); const { diagnostics } = await loadConfigs(cwd); const hit = diagnostics.find((d) => d.kind === "layer-import-failed"); assert.ok(hit, "expected a layer-import-failed diagnostic"); assert.equal(hit.path, configPath); // The path is on the diagnostic's `path` field; the message body // must not embed the same absolute path. Locked here so a future // edit that reintroduces the prefix fails the test. assert.ok( !hit.message.includes(configPath), `message should not embed the path; got: ${hit.message}`, ); assert.equal( hit.message, "failed to import: config file must have a default export. " + "Use `export default { ... } satisfies SteeringConfig` or " + "`export default defineConfig({ ... })`.", ); }); it("handles heterogeneous forms across the two layers (project flat + global dir)", async () => { // Project (session cwd) uses the single-file form // .pi/steering.ts; global layer uses the directory form // /steering/index.ts. Both layers should be collected // project-first without the loader tripping on the form mismatch. const proj = join(tmp, "proj"); mkdirSync(proj, { recursive: true }); writeConfig( join(proj, ".pi", "steering.ts"), configModule("{ disabledRules: ['project-flat'] }"), ); writeConfig( join(tmp, ".pi", "agent", "steering", "index.ts"), configModule("{ disabledRules: ['global-dir'] }"), ); const { layers } = await loadConfigs(proj); assert.deepEqual( layers.map((l) => l.disabledRules?.[0]), ["project-flat", "global-dir"], "expected project-first ordering regardless of per-layer form", ); }); it("emits global-layer stray-file diagnostics with paths under the agent dir", async () => { const cwd = join(tmp, "proj"); mkdirSync(cwd, { recursive: true }); // Stray non-.ts file in the GLOBAL layer's steering/ dir, with no // index.ts — the stray-file scan must run for both layers, not // just the project one. mkdirSync(join(tmp, ".pi", "agent", "steering"), { recursive: true }); writeFileSync( join(tmp, ".pi", "agent", "steering", "rules.mjs"), "// not ts", "utf8", ); const { diagnostics } = await loadConfigs(cwd); const stray = diagnostics.filter((d) => d.kind === "layer-stray-file"); assert.equal(stray.length, 1); assert.equal( stray[0]?.path, join(tmp, ".pi", "agent", "steering", "rules.mjs"), ); assert.equal(stray[0]?.type, "warning"); assert.match(stray[0]?.message ?? "", /under steering\//); }); it("re-imports config when file content changes between calls", async () => { // Regression: Node's ESM module map is keyed on URL and caches // indefinitely within a process. Without cache-busting, an edit // to `.pi/steering/index.ts` between two `loadConfigs` calls is // invisible — `/reload` looks like it does nothing. The loader // appends a `?t=` query string to defeat the cache. const dir = join(tmp, "reimport"); mkdirSync(dir, { recursive: true }); const configFile = join(dir, ".pi", "steering", "index.ts"); writeConfig(configFile, configModule("{ disabledRules: ['v1'] }")); const first = await loadConfigs(dir); assert.equal(first.layers[0]?.disabledRules?.[0], "v1"); writeConfig(configFile, configModule("{ disabledRules: ['v2'] }")); const second = await loadConfigs(dir); assert.equal( second.layers[0]?.disabledRules?.[0], "v2", "expected fresh-fetch on second call after file edit; got cached " + "version, which means the cache-bust in importConfigFile is broken", ); }); it("recovers from initial-load failure on next call", async () => { // Node's ESM cache also caches FAILED imports — once a URL has // thrown during evaluation, every subsequent `import(url)` of // that URL throws the same error, even after the file is fixed. // Cache-busting fixes this for free: a unique URL each call gets // a fresh evaluation attempt. // // We use a runtime throw (`throw new Error(...)`) rather than a // syntax error — syntax errors are surfaced by the TS stripper // before the module reaches Node's ESM cache, so they don't // poison subsequent loads. The interesting case for cache-bust // is the runtime-throw path. const dir = join(tmp, "recover"); mkdirSync(dir, { recursive: true }); const configFile = join(dir, ".pi", "steering", "index.ts"); // First load: file evaluates but throws. writeConfig(configFile, "throw new Error('first-load-boom');\n"); const first = await loadConfigs(dir); assert.equal(first.layers.length, 0); assert.equal(first.diagnostics[0]?.kind, "layer-import-failed"); // Second load: file fixed. writeConfig(configFile, configModule("{ disabledRules: ['fixed'] }")); const second = await loadConfigs(dir); assert.equal( second.layers[0]?.disabledRules?.[0], "fixed", "expected fixed file to load on second call; got cached failure, " + "which means the cache-bust in importConfigFile is broken", ); assert.deepEqual(second.diagnostics, []); }); }); // --------------------------------------------------------------------------- // buildConfig — merge semantics // --------------------------------------------------------------------------- describe("loader: buildConfig", () => { it("concatenates rules from all layers (inner-first)", () => { const inner: SteeringConfig = { rules: [ { name: "inner", tool: "bash", field: "command", pattern: /^x/, reason: "r", }, ], }; const outer: SteeringConfig = { rules: [ { name: "outer", tool: "bash", field: "command", pattern: /^y/, reason: "r", }, ], }; const { config: merged, diagnostics } = buildConfig([inner, outer]); assert.deepEqual( merged.rules?.map((r) => r.name), ["inner", "outer"], ); assert.deepEqual(diagnostics, []); }); it("inner rule by same name overrides outer (and stays silent)", () => { const inner: SteeringConfig = { rules: [ { name: "dup", tool: "bash", field: "command", pattern: /^INNER/, reason: "inner reason", }, ], }; const outer: SteeringConfig = { rules: [ { name: "dup", tool: "bash", field: "command", pattern: /^OUTER/, reason: "outer reason", }, ], }; const { config: merged, diagnostics } = buildConfig([inner, outer]); assert.equal(merged.rules?.length, 1); assert.equal(merged.rules?.[0]?.reason, "inner reason"); // Cross-layer rule overrides are intentional — no diagnostic. assert.deepEqual(diagnostics, []); }); it("records a rule-name-collision diagnostic for within-layer duplicate rules (keeps first)", () => { const { config: merged, diagnostics } = buildConfig([ { rules: [ { name: "dup", tool: "bash", field: "command", pattern: /^FIRST/, reason: "first-wins", }, { name: "dup", tool: "bash", field: "command", pattern: /^SECOND/, reason: "dropped", }, ], }, ]); assert.equal(merged.rules?.length, 1); assert.equal( merged.rules?.[0]?.reason, "first-wins", "first-registered rule should survive within a layer", ); const hit = diagnostics.find((d) => d.kind === "rule-name-collision"); assert.ok( hit, `expected a rule-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.equal(hit.type, "warning"); assert.match(hit.message, /duplicate rule "dup"/); assert.match(hit.message, /within single config layer/); }); it("unions disabledRules / disabledPlugins across layers", () => { const inner: SteeringConfig = { disabledRules: ["a"], disabledPlugins: ["pA"], }; const outer: SteeringConfig = { disabledRules: ["b", "a"], // dup with inner — should coalesce disabledPlugins: ["pB"], }; const { config: merged } = buildConfig([inner, outer]); assert.deepEqual( merged.disabledRules ? [...merged.disabledRules].sort() : undefined, ["a", "b"], ); assert.deepEqual( merged.disabledPlugins ? [...merged.disabledPlugins].sort() : undefined, ["pA", "pB"], ); }); it("inner `defaultNoOverride` wins; missing layer leaves outer in place", () => { assert.equal( buildConfig([{}, { defaultNoOverride: true }]).config.defaultNoOverride, true, "outer sets it, inner doesn't — outer wins", ); assert.equal( buildConfig([{ defaultNoOverride: false }, { defaultNoOverride: true }]) .config.defaultNoOverride, false, "inner explicitly false beats outer true", ); assert.equal(buildConfig([]).config.defaultNoOverride, undefined); }); it("inner `disableDefaults` wins", () => { assert.equal( buildConfig([{ disableDefaults: true }, { disableDefaults: false }]) .config.disableDefaults, true, ); assert.equal(buildConfig([]).config.disableDefaults, undefined); }); it("inner `failOnWarnings` wins; default left undefined when no layer specifies", () => { // Inner-wins precedence is identical to `disableDefaults` / // `defaultNoOverride` since all three flow through `mergeBool`. assert.equal( buildConfig([{ failOnWarnings: false }, { failOnWarnings: true }]).config .failOnWarnings, false, "inner explicitly false beats outer true", ); assert.equal( buildConfig([{}, { failOnWarnings: true }]).config.failOnWarnings, true, "missing inner layer leaves outer's explicit true in place", ); assert.equal( buildConfig([]).config.failOnWarnings, undefined, "buildConfig leaves the field undefined when no layer specifies it; runtime applies the !== false default", ); }); it("records a plugin-name-collision diagnostic for cross-layer duplicate plugin names; first-wins", () => { const pInner: Plugin = { name: "p", rules: [] }; const pOuter: Plugin = { name: "p", rules: [] }; const { config: merged, diagnostics } = buildConfig([ { plugins: [pInner] }, { plugins: [pOuter] }, ]); assert.equal(merged.plugins?.length, 1); const hit = diagnostics.find((d) => d.kind === "plugin-name-collision"); assert.ok( hit, `expected a plugin-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.equal(hit.type, "warning"); assert.match(hit.message, /duplicate plugin "p"/); }); it("records an observer-name-collision diagnostic for within-layer duplicates", () => { const { config: merged, diagnostics } = buildConfig([ { observers: [ { name: "o", onResult: () => {} }, { name: "o", onResult: () => {} }, ], }, ]); assert.equal(merged.observers?.length, 1); const hit = diagnostics.find((d) => d.kind === "observer-name-collision"); assert.ok( hit, `expected an observer-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.equal(hit.type, "warning"); assert.match(hit.message, /duplicate observer "o"/); }); it("inner observer by same name overrides outer (and stays silent)", () => { const innerFn = () => {}; const outerFn = () => {}; const { config: merged, diagnostics } = buildConfig([ { observers: [{ name: "shared", onResult: innerFn }] }, { observers: [{ name: "shared", onResult: outerFn }] }, ]); assert.equal(merged.observers?.length, 1); assert.strictEqual( merged.observers?.[0]?.onResult, innerFn, "inner-layer observer should win on cross-layer name collision", ); // Cross-layer observer overrides are the intended customization // path — mirror the cross-layer rule-override test and assert no // diagnostic fires. Only within-layer duplicates record one. assert.deepEqual(diagnostics, []); }); it("records an error-class tracker-name-collision diagnostic when two plugins claim the same tracker", () => { const t = { initial: 0, unknown: -1, modifiers: {} } as const; const a: Plugin = { name: "pa", trackers: { branch: t as never } }; const b: Plugin = { name: "pb", trackers: { branch: t as never } }; const { diagnostics } = buildConfig([{ plugins: [a] }, { plugins: [b] }]); const hit = diagnostics.find((d) => d.kind === "tracker-name-collision"); assert.ok( hit, `expected a tracker-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.equal(hit.type, "error"); assert.match(hit.message, /tracker name collision/); assert.match(hit.message, /branch/); }); it("suppresses the tracker-name-collision diagnostic when disabledPlugins covers one of the participants", () => { // The diagnostic message itself directs the user to "rename one // tracker or disable one plugin". Following that remedy must // resolve the diagnostic in the same edit — same disable-then- // detect ordering as plugin-name-collision and rule-name-collision. const t = { initial: 0, unknown: -1, modifiers: {} } as const; const a: Plugin = { name: "pa", trackers: { branch: t as never } }; const b: Plugin = { name: "pb", trackers: { branch: t as never } }; const { diagnostics } = buildConfig([ { plugins: [a, b], disabledPlugins: ["pa"] }, ]); assert.equal( diagnostics.filter((d) => d.kind === "tracker-name-collision").length, 0, `disabling one participant should suppress the diagnostic; got: ${JSON.stringify(diagnostics)}`, ); }); it("still emits a tracker-name-collision diagnostic without the disable", () => { // Inverse of the previous test — same colliding plugins, no // disabledPlugins, the diagnostic still fires. const t = { initial: 0, unknown: -1, modifiers: {} } as const; const a: Plugin = { name: "pa", trackers: { branch: t as never } }; const b: Plugin = { name: "pb", trackers: { branch: t as never } }; const { diagnostics } = buildConfig([{ plugins: [a, b] }]); assert.equal( diagnostics.filter((d) => d.kind === "tracker-name-collision").length, 1, ); }); it("drops a colliding plugin from collision detection when disabledPlugins covers it", () => { // Disabling 'git' in any layer should suppress the cross-layer // duplicate-plugin diagnostic for 'git'. The user's natural // workflow on seeing the warning is to add the plugin to // disabledPlugins; that edit alone should resolve the warning. // The plugin still appears in the merged output (downstream // surfaces tag it as disabled); collision detection is the only // thing that gets suppressed. const gitInner: Plugin = { name: "git", rules: [] }; const gitOuter: Plugin = { name: "git", rules: [] }; const { config: merged, diagnostics } = buildConfig([ { plugins: [gitInner], disabledPlugins: ["git"] }, { plugins: [gitOuter] }, ]); assert.equal( merged.plugins?.length, 1, "first-seen plugin should still survive into the merged plugin list", ); assert.equal(merged.plugins?.[0]?.name, "git"); assert.deepEqual(merged.disabledPlugins, ["git"]); assert.equal( diagnostics.filter((d) => d.kind === "plugin-name-collision").length, 0, `expected no plugin-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`, ); }); it("still emits a plugin-name-collision diagnostic without the disable", () => { // Inverse of the previous test — same colliding plugins, no // disabledPlugins, the cross-layer diagnostic still fires. const gitInner: Plugin = { name: "git", rules: [] }; const gitOuter: Plugin = { name: "git", rules: [] }; const { diagnostics } = buildConfig([ { plugins: [gitInner] }, { plugins: [gitOuter] }, ]); assert.equal( diagnostics.filter((d) => d.kind === "plugin-name-collision").length, 1, ); }); it("drops a within-layer duplicate rule from collision detection when disabledRules covers it", () => { const { config: merged, diagnostics } = buildConfig([ { disabledRules: ["dup"], rules: [ { name: "dup", tool: "bash", field: "command", pattern: /^FIRST/, reason: "first", }, { name: "dup", tool: "bash", field: "command", pattern: /^SECOND/, reason: "second", }, ], }, ]); assert.equal( merged.rules?.length, 1, "first-seen rule should still survive into the merged rule list", ); assert.equal(merged.rules?.[0]?.name, "dup"); assert.equal( diagnostics.filter((d) => d.kind === "rule-name-collision").length, 0, `expected no rule-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`, ); }); it("applies `defaults` as the outermost layer", () => { const { config: merged } = buildConfig( [ { rules: [ { name: "user", tool: "bash", field: "command", pattern: /u/, reason: "u", }, ], }, ], { rules: [ { name: "built-in", tool: "bash", field: "command", pattern: /b/, reason: "b", }, ], defaultNoOverride: true, }, ); assert.deepEqual(merged.rules?.map((r) => r.name).sort(), [ "built-in", "user", ]); assert.equal(merged.defaultNoOverride, true); }); it("user rule shadows a defaults rule of the same name", () => { const { config: merged } = buildConfig( [ { rules: [ { name: "shared", tool: "bash", field: "command", pattern: /USER/, reason: "user", }, ], }, ], { rules: [ { name: "shared", tool: "bash", field: "command", pattern: /DEFAULT/, reason: "default", }, ], }, ); assert.equal(merged.rules?.length, 1); assert.equal(merged.rules?.[0]?.reason, "user"); }); }); // --------------------------------------------------------------------------- // loadSteeringConfig — end-to-end // --------------------------------------------------------------------------- describe("loader: loadSteeringConfig", () => { let tmp: string; let origWarn: typeof console.warn; useIsolatedHome("pi-steering-v2-end2end-", (t) => { tmp = t; }); beforeEach(() => { origWarn = console.warn; console.warn = () => {}; }); afterEach(() => { console.warn = origWarn; }); it("loads + merges a single-layer project", async () => { const cwd = join(tmp, "p"); mkdirSync(cwd, { recursive: true }); writeConfig( join(cwd, ".pi", "steering.ts"), configModule( `{ rules: [{ name: "r", tool: "bash", field: "command", pattern: "^git", reason: "r" }] }`, ), ); const { config: merged, diagnostics } = await loadSteeringConfig(cwd); assert.equal(merged.rules?.length, 1); assert.equal(merged.rules?.[0]?.name, "r"); assert.deepEqual(diagnostics, []); }); it("applies caller-supplied defaults when no layer sets a field", async () => { const cwd = join(tmp, "p"); mkdirSync(cwd, { recursive: true }); const { config: merged, diagnostics } = await loadSteeringConfig(cwd, { defaultNoOverride: true, rules: [ { name: "built-in", tool: "bash", field: "command", pattern: /b/, reason: "b", }, ], }); assert.equal(merged.defaultNoOverride, true); assert.equal(merged.rules?.[0]?.name, "built-in"); assert.deepEqual(diagnostics, []); }); it("project rule overrides global rule by name", async () => { // End-to-end: the project layer's rule shadows the global layer's // same-named rule (project wins on name-keyed merge), with no // collision diagnostic — cross-layer override is the documented // customization path. const cwd = join(tmp, "proj"); mkdirSync(cwd, { recursive: true }); writeConfig( join(cwd, ".pi", "steering.ts"), configModule( `{ rules: [{ name: "dup", tool: "bash", field: "command", pattern: /^PROJECT/, reason: "project" }] }`, ), ); writeConfig( join(tmp, ".pi", "agent", "steering", "index.ts"), configModule( `{ rules: [{ name: "dup", tool: "bash", field: "command", pattern: /^GLOBAL/, reason: "global" }] }`, ), ); const { config: merged, diagnostics } = await loadSteeringConfig(cwd); assert.equal(merged.rules?.length, 1); assert.equal(merged.rules?.[0]?.reason, "project"); assert.deepEqual(diagnostics, []); }); it("surfaces both loader-side and merge-side diagnostics in a single array", async () => { // Stage a dual-form coexistence (loader-side warning) plus a // within-layer rule-name collision (merge-side warning) so we // can confirm both streams flow through the wrapper. const cwd = join(tmp, "p"); mkdirSync(cwd, { recursive: true }); writeConfig(join(cwd, ".pi", "steering.ts"), configModule("{}")); writeConfig( join(cwd, ".pi", "steering", "index.ts"), configModule( `{ rules: [ { name: "dup", tool: "bash", field: "command", pattern: /^A/, reason: "first" }, { name: "dup", tool: "bash", field: "command", pattern: /^B/, reason: "second" }, ] }`, ), ); const { diagnostics } = await loadSteeringConfig(cwd); assert.ok( diagnostics.some((d) => d.kind === "layer-form-coexistence"), `expected a layer-form-coexistence diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.ok( diagnostics.some((d) => d.kind === "rule-name-collision"), `expected a rule-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`, ); }); it("surfaces plugin-merger-side warnings (predicate-collision)", async () => { // External embedders calling `loadSteeringConfig` for their own // pre-flight check or bridge wiring need to see merger-side // diagnostics, not just loader-side ones — otherwise their lint // pass false-greens on configs that production refuses to start. const cwd = join(tmp, "p"); mkdirSync(cwd, { recursive: true }); writeConfig( join(cwd, ".pi", "steering.ts"), configModule( `{ plugins: [ { name: "p1", predicates: { branch: () => true } }, { name: "p2", predicates: { branch: () => false } }, ] }`, ), ); const { diagnostics } = await loadSteeringConfig(cwd); const hit = diagnostics.find((d) => d.kind === "predicate-collision"); assert.ok( hit, `expected a predicate-collision diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.equal(hit.type, "warning"); }); it("surfaces plugin-merger-side errors (reserved-tracker-name)", async () => { // reserved-tracker-name is an error-class diagnostic produced // inside `resolvePlugins`. Without the plugin merger wired in, an // embedder using `loadSteeringConfig` as a pre-flight would never // see it — production's `buildSessionRuntime` would refuse to // start on the same config. const cwd = join(tmp, "p"); mkdirSync(cwd, { recursive: true }); writeConfig( join(cwd, ".pi", "steering.ts"), configModule( `{ plugins: [ { name: "reserved-name-plugin", trackers: { events: { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" } }, }, ] }`, ), ); const { diagnostics } = await loadSteeringConfig(cwd); const hit = diagnostics.find((d) => d.kind === "reserved-tracker-name"); assert.ok( hit, `expected a reserved-tracker-name diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.equal(hit.type, "error"); }); it("surfaces malformed user-config rule names as invalid-name diagnostics (does NOT throw)", async () => { // User-config name validation runs inside the shared merge-pipeline // helper between `buildConfig` and `resolvePlugins`, so external // embedders calling `loadSteeringConfig` get the same `invalid-name` // diagnostic stream as the runtime / harness / CLI surfaces. const cwd = join(tmp, "p"); mkdirSync(cwd, { recursive: true }); writeConfig( join(cwd, ".pi", "steering.ts"), configModule( `{ rules: [ { name: "phony] BAD", tool: "bash", field: "command", pattern: /^never$/, reason: "r" }, ] }`, ), ); const { diagnostics } = await loadSteeringConfig(cwd); const hit = diagnostics.find((d) => d.kind === "invalid-name"); assert.ok( hit, `expected an invalid-name diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.equal(hit.type, "error"); assert.match(hit.message, /\(user config\)/); }); it("surfaces BOTH a tracker-name-collision AND a malformed user-config rule name in one load", async () => { // Combined error: tracker-name-collision (merge-side) plus a // malformed user-config rule name. Pins that user-config name // validation runs unconditionally so embedders see both in one // `loadSteeringConfig` call. const cwd = join(tmp, "p"); mkdirSync(cwd, { recursive: true }); writeConfig( join(cwd, ".pi", "steering.ts"), configModule( `{ plugins: [ { name: "pa", trackers: { branch: { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" } } }, { name: "pb", trackers: { branch: { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" } } }, ], rules: [ { name: "phony] BAD", tool: "bash", field: "command", pattern: /^never$/, reason: "r" }, ], }`, ), ); const { diagnostics } = await loadSteeringConfig(cwd); assert.ok( diagnostics.some((d) => d.kind === "tracker-name-collision"), `expected a tracker-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`, ); assert.ok( diagnostics.some( (d) => d.kind === "invalid-name" && /phony\] BAD/.test(d.message) && /\(user config\)/.test(d.message), ), `expected an invalid-name diagnostic for the malformed user-config rule; got: ${JSON.stringify(diagnostics)}`, ); }); });