diff --git a/plugins/concise/docs/INDEX.md b/plugins/concise/docs/INDEX.md index 798be36..104a975 100644 --- a/plugins/concise/docs/INDEX.md +++ b/plugins/concise/docs/INDEX.md @@ -5,9 +5,9 @@ Last updated: 2026-10-08 | Doc | Read it to | Created | Last updated | |---|---|---|---| -| [configuration.md](configuration.md) | Find a config key, its default, the layer order, the merge rules, `mode`, and the dictionary. | 2026-09-04 | 2026-10-06 | +| [configuration.md](configuration.md) | Find a config key, its default, the layer order, the merge rules, `mode`, and the dictionary. | 2026-09-04 | 2026-10-08 | | [tools.md](tools.md) | Change settings with the `concise-config` CLI or the MCP tools, or tune them from writing samples. | 2026-10-05 | 2026-10-05 | -| [environment.md](environment.md) | Control a session with `BEC_` variables. | 2026-09-04 | 2026-10-05 | +| [environment.md](environment.md) | Control a session with `BEC_` variables. | 2026-09-04 | 2026-10-08 | | [categories.md](categories.md) | Look up an AI writing category, its presets, and its scopes. | 2026-09-04 | 2026-10-08 | | [packs.md](packs.md) | Write or check a pattern pack. | 2026-09-04 | 2026-10-08 | | [host-features.md](host-features.md) | Check which Claude Code, Codex, and omp hook, skill, and MCP features the manifests and the omp extension use, with the host doc sources and the live test results. | 2026-10-05 | 2026-10-07 | diff --git a/plugins/concise/docs/configuration.md b/plugins/concise/docs/configuration.md index 78c68d7..c629c3d 100644 --- a/plugins/concise/docs/configuration.md +++ b/plugins/concise/docs/configuration.md @@ -62,12 +62,14 @@ The hook reads config from 5 layers, lowest first. A higher layer replaces a sca 1. The built-in defaults. 2. `BEC_CONFIG_JSON`, then the baseline variables `BEC_FEATURE_ENABLE`, `BEC_FEATURE_DISABLE`, `BEC_ENABLE_PATTERNS`, `BEC_DISABLE_PATTERNS`, and `BEC_LOAD_LIB_PATHS`. -3. User config: the first of `$XDG_CONFIG_HOME/concise/concise.json`, `~/.config/concise/concise.json`, `~/.claude/concise.json`, `~/.codex/concise.json`. -4. Project config: `BEC_CONFIG_PATH` when it is set, else `/.claude/concise.json`, else `/.codex/concise.json`. +3. The `BEC_CONFIG_PATH` file, then the user config. The user config is the first of `$XDG_CONFIG_HOME/concise/concise.json`, `~/.config/concise/concise.json`, `~/.claude/concise.json`, `~/.codex/concise.json`. +4. Project config: `/.claude/concise.json`, else `/.codex/concise.json`. 5. The override variables: the `ALWAYS` forms, `BEC_HOOK_SOFT_FAIL`, `BEC_DISABLE_STOP_HOOK`, the `BEC_LOG_*` set, the `BEC_ALLOW_*` set, and the `BEC_BYPASS_*` set. A project file overrides `BEC_FEATURE_ENABLE`. `BEC_FEATURE_ALWAYS_ENABLE` overrides the project file. The variables are listed in [environment.md](environment.md). +Of the three config files, the `BEC_CONFIG_PATH` file loads first. The user file merges over it, then the project file merges over both, so the project file wins a conflict. A relative `BEC_CONFIG_PATH` resolves against the cwd. When `BEC_CONFIG_PATH` names the user file or the project file, the hook reads that file once, in the user or project place. `BEC_CONFIG_PATH_ONLY=1` skips the user file and the project file. + ## Merge rules per key - Unioned across layers, duplicates dropped, order kept: `styleIgnoreGlobs`, `allowList.phrases`, `allowList.patterns`, `bypass.phrases`, `bypass.patterns`, `features.aiWriting.allow`, `features.aiWriting.packs`, `features.aiWriting.excludePacks`. diff --git a/plugins/concise/docs/environment.md b/plugins/concise/docs/environment.md index 84e4b02..b1ccad0 100644 --- a/plugins/concise/docs/environment.md +++ b/plugins/concise/docs/environment.md @@ -9,12 +9,13 @@ Value formats: - Path lists split on the platform path delimiter (`:` on Linux and macOS, `;` on Windows). - A `BEC_ALLOW_*` or `BEC_BYPASS_*` value that starts with `[` is parsed as a JSON array, and any other value as a comma list. -The layer column refers to the 5 config layers in [configuration.md](configuration.md). Layer 2 sits under the user file and the project file. Layer 5 sits over both. +The layer column refers to the 5 config layers in [configuration.md](configuration.md). Layer 2 sits under the `BEC_CONFIG_PATH` file, the user file, and the project file. Layer 5 sits over all three. | Variable | Value | Effect | Layer | | --- | --- | --- | --- | | `BEC_CONFIG_JSON` | JSON object | a whole config layer | 2 | -| `BEC_CONFIG_PATH` | path | the project config file to read | 4 | +| `BEC_CONFIG_PATH` | path | a config file read under the user file and the project file; a relative path resolves against the cwd | 3 | +| `BEC_CONFIG_PATH_ONLY` | boolean | skips the user file and the project file, so only the `BEC_CONFIG_PATH` file applies | 3, 4 | | `BEC_FEATURE_ENABLE` | id list | turns features and checks on | 2 | | `BEC_FEATURE_DISABLE` | id list | turns features and checks off | 2 | | `BEC_FEATURE_ALWAYS_ENABLE` | id list | turns features and checks on over the config files | 5 | diff --git a/plugins/concise/hooks/lib/config.mjs b/plugins/concise/hooks/lib/config.mjs index 69b2850..d28a799 100644 --- a/plugins/concise/hooks/lib/config.mjs +++ b/plugins/concise/hooks/lib/config.mjs @@ -1,5 +1,5 @@ -import { readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import { readFileSync, existsSync, realpathSync } from "node:fs"; +import { join, resolve } from "node:path"; import { readEnv } from "./env.mjs"; import { applyLayer, envBaselineLayer, envOverrideLayer } from "./config-layers.mjs"; @@ -116,19 +116,30 @@ export function userConfigPath(env) { return candidates.find((path) => existsSync(path)) || null; } -export function projectConfigPath(cwd, vars) { - if (vars.configPath) return vars.configPath; +export function projectConfigPath(cwd) { const base = cwd || "."; return CONFIG_DIRS.map((dir) => join(base, dir, "concise.json")).find((path) => existsSync(path)) || null; } +const realPath = (path) => (existsSync(path) ? realpathSync(path) : path); + +// The files in merge order: env (BEC_CONFIG_PATH, resolved against cwd), user, project. +// env is null when that file is also the user or project file, so it loads once. +export function configFiles(cwd, env = process.env, vars = readEnv(env)) { + const pinned = vars.configPath ? resolve(cwd || ".", vars.configPath) : null; + const user = vars.configPathOnly ? null : userConfigPath(env); + const project = vars.configPathOnly ? null : projectConfigPath(cwd); + const shared = pinned && [user, project].some((path) => path && realPath(path) === realPath(pinned)); + return { env: shared ? null : pinned, user, project }; +} + export function loadConfig(cwd, env = process.env) { const vars = readEnv(env || {}); const problems = [...vars.problems]; let config = applyLayer(defaultConfig(), vars.configJson); config = applyLayer(config, envBaselineLayer(vars)); - config = applyLayer(config, readLayer(userConfigPath(env || {}), problems)); - config = applyLayer(config, readLayer(projectConfigPath(cwd, vars), problems)); + const files = configFiles(cwd, env || {}, vars); + for (const path of [files.env, files.user, files.project]) config = applyLayer(config, readLayer(path, problems)); config = applyLayer(config, envOverrideLayer(vars)); for (const [group, key] of [["context", "enabled"], ["context", "perTurn"], ["subagentStop", "enabled"], ["testFilter", "codexPostToolUse"]]) { if (typeof config[group][key] === "boolean") continue; diff --git a/plugins/concise/hooks/lib/env.mjs b/plugins/concise/hooks/lib/env.mjs index cc95c93..18aa928 100644 --- a/plugins/concise/hooks/lib/env.mjs +++ b/plugins/concise/hooks/lib/env.mjs @@ -80,6 +80,7 @@ export function readEnv(env = process.env) { return { configJson: parseConfigJson(raw("BEC_CONFIG_JSON"), problems), configPath: raw("BEC_CONFIG_PATH")?.trim() || null, + configPathOnly: bool("BEC_CONFIG_PATH_ONLY") === true, featureEnable: ids("BEC_FEATURE_ENABLE"), featureDisable: ids("BEC_FEATURE_DISABLE"), alwaysEnableFeatures: ids("BEC_FEATURE_ALWAYS_ENABLE"), diff --git a/plugins/concise/test/config-tests.mjs b/plugins/concise/test/config-tests.mjs index cec69c1..3d66df0 100644 --- a/plugins/concise/test/config-tests.mjs +++ b/plugins/concise/test/config-tests.mjs @@ -1,10 +1,10 @@ #!/usr/bin/env node -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; import { ok, bad, summary } from "./lib.mjs"; -import { loadConfig, defaultConfig } from "../hooks/lib/config.mjs"; +import { configFiles, loadConfig, defaultConfig } from "../hooks/lib/config.mjs"; import { readEnv, parseSize } from "../hooks/lib/env.mjs"; const dirs = []; @@ -66,6 +66,7 @@ console.log("config: env parsing"); const vars = readEnv(env); eq("BEC_CONFIG_JSON parses to an object", vars.configJson, { maxRetries: 4 }); eq("BEC_CONFIG_PATH kept as a path", vars.configPath, "/tmp/x.json"); + eq("BEC_CONFIG_PATH_ONLY is off when unset", vars.configPathOnly, false); eq("BEC_FEATURE_ENABLE splits on commas", vars.featureEnable, ["aiWriting", "emDash"]); eq("BEC_FEATURE_DISABLE parses", vars.featureDisable, ["comments"]); eq("BEC_FEATURE_ALWAYS_ENABLE parses", vars.alwaysEnableFeatures, ["prBody"]); @@ -119,11 +120,33 @@ console.log("\nconfig: layers"); eq("env overrides set softFail", config.softFail, true); eq("no problems on clean layers", config.problems, []); - const pinned = writeJson(join(project, "other.json"), { maxFileLines: 55 }); - eq("BEC_CONFIG_PATH replaces the project file", loadConfig(project, { ...env, BEC_CONFIG_PATH: pinned }).maxFileLines, 55); + const pinned = writeJson(join(project, "other.json"), { maxFileLines: 55, maxCommentLines: 3, maxRetries: 6 }); + const withPath = loadConfig(project, { ...env, BEC_CONFIG_PATH: pinned }); + eq("the project file merges over the BEC_CONFIG_PATH file", withPath.maxFileLines, 120); + eq("the user file merges over the BEC_CONFIG_PATH file", withPath.maxCommentLines, 5); + eq("the BEC_CONFIG_PATH file merges over BEC_CONFIG_JSON", withPath.maxRetries, 6); + eq("a relative BEC_CONFIG_PATH resolves against the cwd", loadConfig(project, { BEC_CONFIG_PATH: "other.json" }).maxRetries, 6); + const only = loadConfig(project, { ...env, BEC_CONFIG_PATH: pinned, BEC_CONFIG_PATH_ONLY: "1" }); + eq("BEC_CONFIG_PATH_ONLY skips the user and project files", [only.maxFileLines, only.maxCommentLines], [55, 3]); eq("loadConfig with one argument still reads the project file", loadConfig(project).maxFileLines, 120); } +{ + const home = temp("concise-same-home-"); + const project = temp("concise-same-proj-"); + const user = writeJson(join(home, ".config", "concise", "concise.json"), { features: { aiWriting: { enablePatterns: ["filler"] } } }); + writeJson(join(project, ".claude", "concise.json"), { features: { aiWriting: { disablePatterns: ["filler"] } } }); + const env = { HOME: home, BEC_CONFIG_PATH: user }; + eq("a project file merges over a BEC_CONFIG_PATH that names the user file", ai(loadConfig(project, env)).enablePatterns, []); + eq("a BEC_CONFIG_PATH that names the user file loads in the user place", configFiles(project, env).env, null); + eq("a missing BEC_CONFIG_PATH is still reported as the env file", configFiles(project, { BEC_CONFIG_PATH: "gone.json" }).env, join(project, "gone.json")); + const broken = temp("concise-same-broken-"); + const local = writeJson(join(broken, ".claude", "concise.json"), "{ not json"); + symlinkSync(local, join(broken, "linked.json")); + const problems = loadConfig(broken, { BEC_CONFIG_PATH: "linked.json" }).problems.map((problem) => problem.source); + eq("a BEC_CONFIG_PATH link to the project file loads that file once", problems, [local]); +} + console.log("\nconfig: features and patterns"); { diff --git a/plugins/concise/test/coverage-tools-ops-tests.mjs b/plugins/concise/test/coverage-tools-ops-tests.mjs index 3892f7a..4d46da1 100644 --- a/plugins/concise/test/coverage-tools-ops-tests.mjs +++ b/plugins/concise/test/coverage-tools-ops-tests.mjs @@ -114,7 +114,7 @@ console.log("\ncoverage: dictionary-ops"); { const entries = [{ id: "env-term", match: "exact", value: "zorb", fix: "zap" }, { id: "broken", match: "regex", value: "(", fix: "x" }]; - const env = { BEC_CONFIG_PATH: "missing.json", BEC_CONFIG_JSON: JSON.stringify({ features: { dictionary: { entries } } }) }; + const env = { BEC_CONFIG_JSON: JSON.stringify({ features: { dictionary: { entries } } }) }; const listed = listDictionary({ cwd: project(), env }); const term = listed.entries.find((entry) => entry.id === "env-term"); const broken = listed.entries.find((entry) => entry.id === "broken"); @@ -126,6 +126,20 @@ console.log("\ncoverage: dictionary-ops"); check("a layer file with no entries list owns no entries", noEntries.entries.find((entry) => entry.id === "env-term")?.layer === "environment", noEntries.entries); } +{ + const term = (id, fix) => ({ id, match: "exact", value: id, fix }); + const dir = project({ features: { dictionary: { entries: [term("shared", "project")] } } }); + writeFileSync(join(dir, "env.json"), JSON.stringify({ features: { dictionary: { entries: [term("shared", "env"), term("pinned", "env")] } } })); + const listed = listDictionary({ cwd: dir, env: { BEC_CONFIG_PATH: "env.json" } }).entries; + const owner = (id) => listed.find((entry) => entry.id === id); + check("the project file owns an entry it shares with the BEC_CONFIG_PATH file", owner("shared")?.layer === "project-claude" && owner("shared").fix === "project", listed); + check("the BEC_CONFIG_PATH file owns its own entries", owner("pinned")?.layer === "env-config", listed); + const fresh = project(); + writeFileSync(join(fresh, "env.json"), "{}"); + const plan = planEdit({ cwd: fresh, env: { BEC_CONFIG_PATH: "env.json" }, edits: [{ op: "set", key: "maxRetries", value: 3 }] }); + check("the project layer is a new project file, not the BEC_CONFIG_PATH file", plan.layer.id === "project-claude" && !plan.layer.exists, plan.layer); +} + { const dir = project({ features: { dictionary: { enabled: false } } }); const env = {}; @@ -199,9 +213,9 @@ console.log("\ncoverage: settings"); { const home = tempDir(); - const state = { layers: [{ id: "user-xdg", active: false }, { id: "user-claude", active: false }, { id: "project-override", active: false }, { id: "project-claude", active: false }] }; + const state = { layers: [{ id: "env-config", active: true }, { id: "user-xdg", active: false }, { id: "user-claude", active: false }, { id: "project-claude", active: false }] }; check("user resolves to the first user layer when none is in effect", resolveLayer(state, "user").id === "user-xdg", state); - check("project resolves to an inactive override before the Claude file", resolveLayer(state, "project", home).id === "project-override", state); + check("project resolves to the Claude file, not the BEC_CONFIG_PATH file", resolveLayer(state, "project", home).id === "project-claude", state); } { diff --git a/plugins/concise/tools/dictionary-ops.mjs b/plugins/concise/tools/dictionary-ops.mjs index 5737fa1..896f3ee 100644 --- a/plugins/concise/tools/dictionary-ops.mjs +++ b/plugins/concise/tools/dictionary-ops.mjs @@ -8,7 +8,6 @@ const object = (value) => value !== null && typeof value === "object" && !Array. const LIST_FIELDS = { hooks: HOOKS, scopes: SCOPES }; function layerEntries(layer) { - if (!layer.exists) return []; try { const entries = readValue(JSON.parse(layer.text), "features.dictionary.entries"); return Array.isArray(entries) ? entries : []; diff --git a/plugins/concise/tools/settings.mjs b/plugins/concise/tools/settings.mjs index 9f03a3a..85bb5ad 100644 --- a/plugins/concise/tools/settings.mjs +++ b/plugins/concise/tools/settings.mjs @@ -47,8 +47,6 @@ export function resolveLayer(state, name = "project", cwd = process.cwd()) { const active = group.find((layer) => layer.active); if (active) return active; if (name === "user") return group[0]; - const override = group.find((layer) => layer.id === "project-override"); - if (override) return override; const codexOnly = existsSync(join(cwd, ".codex")) && !existsSync(join(cwd, ".claude")); return group.find((layer) => layer.id === (codexOnly ? "project-codex" : "project-claude")); } diff --git a/plugins/concise/web/configuration.mjs b/plugins/concise/web/configuration.mjs index 8d050df..94f838f 100644 --- a/plugins/concise/web/configuration.mjs +++ b/plugins/concise/web/configuration.mjs @@ -1,8 +1,8 @@ import { existsSync, readFileSync, realpathSync, statSync, mkdirSync, writeFileSync, renameSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { createHash, randomUUID } from "node:crypto"; -import { defaultConfig, loadConfig, projectConfigPath, userConfigPath } from "../hooks/lib/config.mjs"; -import { readEnv, parseSize } from "../hooks/lib/env.mjs"; +import { configFiles, defaultConfig, loadConfig } from "../hooks/lib/config.mjs"; +import { parseSize } from "../hooks/lib/env.mjs"; import { entryProblem } from "../hooks/lib/dictionary.mjs"; export const problem = (message, status = 400) => Object.assign(new Error(message), { status }); @@ -64,22 +64,24 @@ function layer(id, label, path, active = false) { export function configuration(cwd, env) { const home = env.HOME || env.USERPROFILE; - const user = userConfigPath(env); - const project = projectConfigPath(cwd, readEnv(env)); - const candidates = [ + const files = configFiles(cwd, env); + const others = [ ...(env.XDG_CONFIG_HOME ? [["user-xdg", "User (XDG)", join(env.XDG_CONFIG_HOME, "concise", "concise.json")]] : []), ...(home ? [ ["user", "User", join(home, ".config", "concise", "concise.json")], ["user-claude", "User (Claude)", join(home, ".claude", "concise.json")], ["user-codex", "User (Codex)", join(home, ".codex", "concise.json")], ] : []), - ...(readEnv(env).configPath ? [["project-override", "Project (BEC_CONFIG_PATH)", resolve(cwd, readEnv(env).configPath)]] : []), ["project-claude", "Project (Claude)", join(cwd, ".claude", "concise.json")], ["project-codex", "Project (Codex)", join(cwd, ".codex", "concise.json")], ]; + // The BEC_CONFIG_PATH file comes first, as it loads first. A missing file yields its path to a user or project id. + const ownLayer = files.env && (existsSync(files.env) || !others.some(([, , path]) => path === files.env)); + const candidates = [...(ownLayer ? [["env-config", "BEC_CONFIG_PATH", files.env]] : []), ...others]; + const loaded = [files.env, files.user, files.project]; const seen = new Set(); const layers = candidates.filter(([, , path]) => !seen.has(path) && seen.add(path)).map(([id, label, path]) => - layer(id, label, path, path === user || path === (project ? resolve(cwd, project) : null))); + layer(id, label, path, loaded.includes(path) && existsSync(path))); const filterLayers = home ? ["claude", "codex"].map((host) => layer(`filter-${host}`, `Test filter (${host})`, join(home, `.${host}`, "test-filter.conf"), true)) : []; return { defaults: defaultConfig(), effective: loadConfig(cwd, env), layers, filterLayers, diff --git a/plugins/concise/web/testing/runner.mjs b/plugins/concise/web/testing/runner.mjs index 3b19f29..64e689a 100644 --- a/plugins/concise/web/testing/runner.mjs +++ b/plugins/concise/web/testing/runner.mjs @@ -46,7 +46,7 @@ function environment(env, directory, configPath) { const home = join(directory, "home"); const state = join(directory, "state"); return { ...out, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, ".config"), - TMPDIR: state, TMP: state, TEMP: state, BEC_CONFIG_PATH: configPath, + TMPDIR: state, TMP: state, TEMP: state, BEC_CONFIG_PATH: configPath, BEC_CONFIG_PATH_ONLY: "1", BEC_MONITOR_DISABLED: "1", CLAUDE_PLUGIN_ROOT: PLUGIN }; } diff --git a/test/playground.test.mjs b/test/playground.test.mjs index 3bbcd39..6bdbd66 100644 --- a/test/playground.test.mjs +++ b/test/playground.test.mjs @@ -141,6 +141,16 @@ test("playground uses the config snapshot and isolates user environment, logging assert.equal(existsSync(shellMarker), false); }); +test("playground previews ignore the project config file", async (t) => { + const cwd = await fixture(t); + const projectLog = join(cwd, "project.log"); + await mkdir(join(cwd, ".claude")); + await writeFile(join(cwd, ".claude", "concise.json"), JSON.stringify({ features: { emDash: { enabled: false } }, log: { enabled: true, path: projectLog } })); + const result = await runTest({ cwd, config: dashConfig(), text: "This \u2014 wording." }); + assert.equal(result.hooks[0].decision, "deny"); + assert.equal(existsSync(projectLog), false); +}); + test("playground rejects unknown session IDs and replaces raw session and cwd fields", async (t) => { const cwd = await fixture(t); await assert.rejects(runTest({ cwd, session: "../../outside" }), /Unknown playground session/); diff --git a/test/web-coverage-config.test.mjs b/test/web-coverage-config.test.mjs index b24871f..222069c 100644 --- a/test/web-coverage-config.test.mjs +++ b/test/web-coverage-config.test.mjs @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { configuration, saveConfiguration, validateConfig, validateFilter } from "../plugins/concise/web/configuration.mjs"; async function fixture(t) { @@ -30,17 +30,37 @@ test("validation rejects malformed values for each typed setting", () => { assert.doesNotThrow(() => validateConfig({ log: { path: null } })); }); -test("configuration lists XDG and override layers and hides token variables", async (t) => { +test("configuration lists XDG and BEC_CONFIG_PATH layers and hides token variables", async (t) => { const { root, cwd, env } = await fixture(t); const xdg = join(root, "xdg"); const state = configuration(cwd, { ...env, XDG_CONFIG_HOME: xdg, BEC_CONFIG_PATH: "custom.json", BEC_MONITOR_TOKEN: "secret", BEC_SOFT_FAIL: "0" }); const layer = (id) => state.layers.find((item) => item.id === id); assert.equal(layer("user-xdg").path, join(xdg, "concise", "concise.json")); - assert.equal(layer("project-override").path, join(cwd, "custom.json")); - assert.equal(layer("project-override").active, true); + assert.deepEqual(state.layers.map((item) => item.id), ["env-config", "user-xdg", "user", "user-claude", "user-codex", "project-claude", "project-codex"]); + assert.equal(layer("env-config").path, join(cwd, "custom.json")); + assert.equal(layer("env-config").label, "BEC_CONFIG_PATH"); + assert.equal(layer("env-config").active, false); assert.deepEqual(state.environment, { BEC_CONFIG_PATH: "custom.json", BEC_SOFT_FAIL: "0" }); }); +test("layers follow the merge order and list a shared BEC_CONFIG_PATH file once", async (t) => { + const { root, cwd, env } = await fixture(t); + const write = async (path) => { await mkdir(dirname(path), { recursive: true }); await writeFile(path, "{}"); return path; }; + const user = await write(join(root, "home", ".config", "concise", "concise.json")); + await write(join(cwd, ".claude", "concise.json")); + await write(join(cwd, "custom.json")); + const active = (extra) => configuration(cwd, { ...env, ...extra }).layers.filter((item) => item.active).map((item) => item.id); + assert.deepEqual(active({ BEC_CONFIG_PATH: "custom.json" }), ["env-config", "user", "project-claude"]); + assert.deepEqual(active({ BEC_CONFIG_PATH: user }), ["user", "project-claude"]); + assert.deepEqual(active({ BEC_CONFIG_PATH: "custom.json", BEC_CONFIG_PATH_ONLY: "1" }), ["env-config"]); + const claude = await write(join(root, "home", ".claude", "concise.json")); + const state = configuration(cwd, { ...env, BEC_CONFIG_PATH: claude }); + assert.deepEqual(state.layers.map((item) => [item.id, item.active]).slice(0, 4), + [["env-config", true], ["user", true], ["user-codex", false], ["project-claude", true]]); + const missing = configuration(cwd, { ...env, BEC_CONFIG_PATH: ".codex/concise.json" }).layers.map((item) => item.id); + assert.deepEqual(missing, ["user", "user-claude", "user-codex", "project-claude", "project-codex"]); +}); + test("unreadable layers report their error and refuse saves", async (t) => { const { cwd, env } = await fixture(t); await mkdir(join(cwd, ".claude", "concise.json"), { recursive: true }); diff --git a/test/web-coverage-packs.test.mjs b/test/web-coverage-packs.test.mjs index 1fcaa52..37cfe3a 100644 --- a/test/web-coverage-packs.test.mjs +++ b/test/web-coverage-packs.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { existsSync } from "node:fs"; import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; @@ -111,7 +112,7 @@ test("path sources resolve against the project and must be packs", async (t) => await rejects(addPack({ cwd, env, source: "notes.txt" }), 400, /must be a \.json or \.mjs file or a directory/); }); -test("layer edits refuse invalid and shadowed project layers", async (t) => { +test("layer edits refuse invalid project layers and ignore BEC_CONFIG_PATH", async (t) => { const { cwd, env, writeJson } = await fixture(t); await mkdir(join(cwd, "local-packs")); const layerPath = join(cwd, ".claude", "concise.json"); @@ -121,7 +122,12 @@ test("layer edits refuse invalid and shadowed project layers", async (t) => { await writeJson(layerPath, "[]"); await rejects(add(), 400, /concise\.json is not a JSON object$/); await rm(layerPath); - await rejects(add({ BEC_CONFIG_PATH: ".claude/concise.json" }), 400, /^Unknown configuration layer$/); + for (const pinned of ["pinned.json", ".claude/concise.json"]) { + assert.deepEqual(await add({ BEC_CONFIG_PATH: pinned }), { path: join(cwd, "local-packs"), layer: "project-claude" }); + assert.deepEqual(JSON.parse(await readFile(layerPath, "utf8")).features.aiWriting.packs, ["local-packs"]); + await rm(layerPath); + } + assert.equal(existsSync(join(cwd, "pinned.json")), false); }); test("remove and toggle validate their inputs", async (t) => { diff --git a/test/web-coverage-server.test.mjs b/test/web-coverage-server.test.mjs index 2e5d032..a96b776 100644 --- a/test/web-coverage-server.test.mjs +++ b/test/web-coverage-server.test.mjs @@ -115,7 +115,7 @@ test("BEC_CONFIG_PATH resolves against the project directory", async (t) => { const app = await fixture(t, { BEC_CONFIG_PATH: "custom.json" }); const state = await (await app.api("/api/state")).json(); assert.equal(state.environment.BEC_CONFIG_PATH, join(app.cwd, "custom.json")); - assert.equal(state.layers.find((layer) => layer.id === "project-override").path, join(app.cwd, "custom.json")); + assert.equal(state.layers.find((layer) => layer.id === "env-config").path, join(app.cwd, "custom.json")); }); test("playground input must be an object and defaults to the saved settings", async (t) => {