diff --git a/.changes/codebase-health/feat-12667-shared-copies-7dd4d9f4.md b/.changes/codebase-health/feat-12667-shared-copies-7dd4d9f4.md new file mode 100644 index 00000000000..e2a757c8dfa --- /dev/null +++ b/.changes/codebase-health/feat-12667-shared-copies-7dd4d9f4.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared medium resolver modules (`view-settings.mjs` and the modules it loads) synced into `lib/` ([#12667](https://github.com/melodic-software/claude-code-plugins/issues/12667)); no other change to this plugin. diff --git a/.changes/discovery/feat-12667-shared-copies-3a1f41bc.md b/.changes/discovery/feat-12667-shared-copies-3a1f41bc.md new file mode 100644 index 00000000000..c1177c29032 --- /dev/null +++ b/.changes/discovery/feat-12667-shared-copies-3a1f41bc.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The shared medium resolver modules (`view-settings.mjs` and the modules it loads) ship under `lib/` ([#12667](https://github.com/melodic-software/claude-code-plugins/issues/12667)); no skill changes how it resolves the medium yet. diff --git a/.changes/education/feat-12667-shared-copies-24342a06.md b/.changes/education/feat-12667-shared-copies-24342a06.md new file mode 100644 index 00000000000..e2a757c8dfa --- /dev/null +++ b/.changes/education/feat-12667-shared-copies-24342a06.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared medium resolver modules (`view-settings.mjs` and the modules it loads) synced into `lib/` ([#12667](https://github.com/melodic-software/claude-code-plugins/issues/12667)); no other change to this plugin. diff --git a/.changes/overengineering/feat-12667-shared-copies-1fe61038.md b/.changes/overengineering/feat-12667-shared-copies-1fe61038.md new file mode 100644 index 00000000000..e2a757c8dfa --- /dev/null +++ b/.changes/overengineering/feat-12667-shared-copies-1fe61038.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared medium resolver modules (`view-settings.mjs` and the modules it loads) synced into `lib/` ([#12667](https://github.com/melodic-software/claude-code-plugins/issues/12667)); no other change to this plugin. diff --git a/.changes/prototype/feat-12667-shared-copies-a1937e6e.md b/.changes/prototype/feat-12667-shared-copies-a1937e6e.md new file mode 100644 index 00000000000..e2a757c8dfa --- /dev/null +++ b/.changes/prototype/feat-12667-shared-copies-a1937e6e.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared medium resolver modules (`view-settings.mjs` and the modules it loads) synced into `lib/` ([#12667](https://github.com/melodic-software/claude-code-plugins/issues/12667)); no other change to this plugin. diff --git a/lib/view-settings.test.mjs b/lib/view-settings.test.mjs index 59496117723..179822ec9cd 100644 --- a/lib/view-settings.test.mjs +++ b/lib/view-settings.test.mjs @@ -266,6 +266,31 @@ describe("CLI", () => { const out = run(["resolve", "--lane", "review:explain-change", "--option", "artifact"], { CI: "true", CLAUDE_PROJECT_DIR: EMPTY_HOME }); assert.equal(out.stdout.split("\n")[1], "view: terminal (fell back: artifact unreachable: non-interactive run)"); }); + test("every registered view lane resolves to its shipped default with no scope set", () => { + // Expected defaults come from each skill's own medium rule: unset means `file` for the map + // records, blindspot, the post-mortem, illustrate and the work-items pages; the rest leave the + // choice to the skill's content rule, which prints as auto. + const expected = { + architecture: "file", + "discovery:blindspot": "file", + "debugging:debug": "file", + "education:illustrate": "file", + "work-items:morning-brief": "file", + "work-items:triage": "file", + "adhd:clarify": "auto", + "codebase-health:audit": "auto", + "overengineering:audit": "auto", + "education:teach": "auto", + "prototype:explore-directions": "auto", + "prototype:pressure-test": "auto", + "ai-briefing:generate": "auto", + }; + for (const [lane, medium] of Object.entries(expected)) { + const out = run(["resolve", "--lane", lane, "--option", ""], { CLAUDE_PROJECT_DIR: EMPTY_HOME }); + assert.equal(out.status, 0, `${lane}: ${out.stderr}`); + assert.equal(out.stdout.split("\n")[1], `view: ${medium} (plugin default)`, lane); + } + }); test("refuses an unknown lane, a missing option, an unknown medium or flag", () => { for (const args of [ ["resolve", "--lane", "nope", "--option", ""], diff --git a/plugins/codebase-health/lib/config-cascade.mjs b/plugins/codebase-health/lib/config-cascade.mjs new file mode 100644 index 00000000000..b5f22dc12e2 --- /dev/null +++ b/plugins/codebase-health/lib/config-cascade.mjs @@ -0,0 +1,571 @@ +// GENERATED from lib/config-cascade.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// The four-layer plugin config cascade (docs/adr/0075): later layers win per key. +// +// resolve({plugin, projectRoot, home, schema, defaults, userConfig, teamOnly, floors, userHome}) +// -> {values, provenance: {"a.b": layer | [layers]}, prose: [paths], sections: [{heading, text, layer}], +// layers: [{name, path, state: "loaded" | "absent" | "invalid", errors, error_keys}], legacy: [{path, kind}]} +// +// readLayers({...}) -> each layer's own accepted values, unmerged (see its comment below) +// +// `error_keys[i]` tags `errors[i]`: the dotted key the error names (key-level), or null when it is +// about the layer as a whole (file-level: a file refused or unparsable, a value that is not a mapping). +// +// Layers, in order: "defaults" (the `defaults` object, already parsed by the caller), "userConfig" +// (the plugin's userConfig values, flattened names), "team" (/) and "local", the +// personal overlay (/.local). A file layer reads .yaml for config and .md +// for prose; it is absent when neither file exists. `home` is the convention home the caller +// resolved, relative to projectRoot unless absolute (default docs/conventions). There is no +// user-global file layer: a file under /docs/conventions is never read, and `userHome` +// (default os.homedir()) only locates the legacy files reported in `legacy`. `schema` is a JSON +// Schema object; the subset honored is type (object, string, array, null, integer, number, boolean), +// enum, properties, additionalProperties (false or a schema), items, minimum, maximum, minItems and +// maxItems. +// +// A key is a schema leaf: any node that is not an object with `properties`, or a path named in +// `teamOnly`. The setup writer's top-level `written_against` and `provenance` records are not keys, +// declared or not: every layer skips them, so they reach neither `values` nor a userConfig name. +// Each layer's leaves are checked one by one; a leaf whose value fails the schema is +// dropped from that layer with an error naming layer, file and key, and a lower layer's value is +// then never used (ADR 0075, the invalid-value rule): a valid higher layer still wins, otherwise the +// key takes its default. The personal overlay is the one exception, because it refines the team +// file: an invalid personal value leaves the team value standing. A key containing `.` is +// rejected the same way, since keys are joined with `.` into paths, and so are `__proto__`, +// `constructor` and `prototype`, at any depth inside a value too, which a caller's merge would treat +// as more than data. A refused file makes its layer invalid; the layer's other file still applies. The team and +// local layers come from the repository: each reads only regular files, never a symlink, and is +// invalid when a relative `home` resolves outside projectRoot. A team-only key is +// accepted in "defaults" and "team" and rejected elsewhere. A YAML file that does not parse, or +// whose top level is not a mapping, makes its layer invalid and contributes no values; its .md +// file still supplies prose. A leaf whose last segment is `disable` and whose value is a list is the +// union of every layer's list, and its provenance is an array of the layer names that set it, in +// layer order; every other key's provenance is a single layer-name string. +// +// Policy floors. `floors` maps a key path to a `Floor direction` value (the Implementers table of +// docs/contracts/config-cascade/README.md; the closed set is no-floor, team-only, lowest-value, +// any-layer-wins and stricter-value), or to {direction, scale} where stricter-value takes a scale +// from loosest to strictest (default: the leaf's schema `enum` order). When the team layer set a +// floor key, a personal value is accepted only if it is no looser in that direction: team-only takes +// an equal value (a list must keep every team item); lowest-value a number no higher; any-layer-wins +// a tightening value (`true` or "report") whenever the team value is one; stricter-value a value at +// or beyond the team value on the scale. A looser value is dropped with an error naming the layer, +// file and key, and the team value stands. Floors bind the personal overlay; a `*.disable` union key +// is not compared. +// +// userConfig maps each schema leaf path to its name joined with `_` (widget.limit is widget_limit); +// two leaves with one name throw. An empty string or an unrendered `${user_config.` placeholder is +// unset, names outside the schema are ignored, and a list, mapping or team-only key is rejected. +// A string value takes the leaf's integer, number or boolean type when the leaf does not accept +// strings. Legacy locations (.claude/.* under userHome and projectRoot, and a fenced +// `yaml config` block in the team .md) are reported in `legacy`, never read for values. +// +// Prose. A `.local.md` H2 section (a `## ` heading outside a code fence) replaces the team `.md` +// section with the same heading; every other team section is kept, a local section with a new +// heading follows them, and the team text before the first H2 is kept. `sections` lists the result in +// order, each with the layer it came from (`heading` is null for text before the first H2). +// This module reads files only. File contents are data; error messages quote them as JSON strings. +import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"; +import { parse } from "./yaml-subset.mjs"; + +const PLUGIN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SCALARS = ["string", "integer", "number", "boolean", "null"]; +const DIRECTIONS = ["no-floor", "team-only", "lowest-value", "any-layer-wins", "stricter-value"]; +const clip = (text) => (text.length > 60 ? `${text.slice(0, 60)}...` : text); +const quote = (text) => JSON.stringify(clip(text)); +const show = (value) => clip(JSON.stringify(value) ?? String(value)); +const put = (object, key, value) => Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true }); +const isMapping = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const RESERVED = new Set(["__proto__", "constructor", "prototype"]); +const WRITER_KEYS = new Set(["written_against", "provenance"]); +const isWriterKey = (parts, key) => !parts.length && WRITER_KEYS.has(key); +const newLayer = (name, path, state, errors = [], error_keys = errors.map(() => null)) => ({ name, path, state, errors, error_keys }); +/** Records an error on `layer`, tagged with the dotted key it names, or null for a file-level one. */ +const record = (layer, message, key = null) => { + layer.errors.push(message); + layer.error_keys.push(key); +}; + +/** {ok} for a readable layer file, {refused: why} for one that must not be read, {} when absent. A + * repository layer refuses a symlink, which could reach a file outside the checkout; every layer + * refuses anything but a regular file, since a device or FIFO never finishes reading. */ +function fileState(path, repo) { + let st; + try { + st = lstatSync(path); + } catch { + return {}; + } + if (st.isSymbolicLink()) { + if (repo) return { refused: "is a symlink; a repository layer reads only regular files" }; + try { + st = statSync(path); + } catch { + return {}; + } + } + return st.isFile() ? { ok: true } : { refused: "is not a regular file" }; +} + +/** True when `dir` exists and its real path lies outside the real path of `root`. */ +function escapes(dir, root) { + try { + const rel = relative(realpathSync(root), realpathSync(dir)); + return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel); + } catch { + return false; + } +} +const typesOf = (node) => (node.type === undefined ? null : [node.type].flat()); + +const fits = { + object: isMapping, + array: Array.isArray, + string: (v) => typeof v === "string", + integer: Number.isInteger, + number: (v) => typeof v === "number" && Number.isFinite(v), + boolean: (v) => typeof v === "boolean", + null: (v) => v === null, +}; + +/** The schema for `key` under `node`: a schema, null when anything goes, false when it is unknown. */ +function child(node, key) { + if (isMapping(node.properties) && Object.hasOwn(node.properties, key)) return node.properties[key]; + if (node.additionalProperties === false) return false; + return isMapping(node.additionalProperties) ? node.additionalProperties : null; +} + +const isBranch = (node, path, teamOnly) => + isMapping(node) && isMapping(node.properties) && !teamOnly.has(path) && (typesOf(node)?.includes("object") ?? true); + +/** Why `value` does not fit `node`, as messages relative to the key; empty when it fits. */ +function check(value, node, at = "") { + const prefix = at ? `${at}: ` : ""; + const types = typesOf(node); + if (types && !types.some((t) => fits[t]?.(value))) return [`${prefix}expected ${types.join(" or ")}, got ${show(value)}`]; + if (Array.isArray(node.enum) && !node.enum.includes(value)) return [`${prefix}${show(value)} is not one of ${node.enum.map(show).join(", ")}`]; + if (typeof value === "number" && typeof node.minimum === "number" && value < node.minimum) return [`${prefix}${show(value)} must be at least ${node.minimum}`]; + if (typeof value === "number" && typeof node.maximum === "number" && value > node.maximum) return [`${prefix}${show(value)} must be at most ${node.maximum}`]; + if (Array.isArray(value) && typeof node.minItems === "number" && value.length < node.minItems) return [`${prefix}needs at least ${node.minItems} items, got ${value.length}`]; + if (Array.isArray(value) && typeof node.maxItems === "number" && value.length > node.maxItems) return [`${prefix}allows at most ${node.maxItems} items, got ${value.length}`]; + // An open node ({}) checks nothing but reserved names, which are refused at every depth. + if (Array.isArray(value)) return value.flatMap((item, i) => check(item, isMapping(node.items) ? node.items : {}, `${at}[${i}]`)); + if (!isMapping(value)) return []; + return Object.keys(value).flatMap((key) => { + const where = `${at}.${quote(key)}`; + if (RESERVED.has(key)) return [`${where}: a reserved name, never a config key`]; + const sub = child(node, key); + if (sub === false) return [`${where}: unknown key`]; + return check(value[key], sub ?? {}, where); + }); +} + +/** The accepted leaves of one layer's mapping, as [parts, value] pairs; rejections go to `fail` + * with the dotted key, and `true` third when the value itself was the problem. */ +function leaves(data, schema, { teamOnly, allowTeamOnly, fail }) { + const out = []; + const walk = (object, node, parts) => { + for (const key of Object.keys(object)) { + if (isWriterKey(parts, key)) continue; + const path = [...parts, key]; + const dotted = path.join("."); + const sub = child(node, key); + const value = object[key]; + if (RESERVED.has(key)) fail(`${quote(dotted)}: a reserved name, never a config key`, dotted); + else if (key.includes(".")) fail(`${quote(dotted)}: a key may not contain "."; nest it instead`, dotted); + else if (sub === false) fail(`${quote(dotted)}: unknown key`, dotted); + else if (teamOnly.has(dotted) && !allowTeamOnly) fail(`${quote(dotted)}: accepted only in the team layer`, dotted); + else if (sub && isBranch(sub, dotted, teamOnly)) { + if (isMapping(value)) walk(value, sub, path); + else if (value !== null) fail(`${quote(dotted)}: expected a mapping, got ${show(value)}`, dotted, true); + } else { + const problems = check(value, sub ?? {}); + if (problems.length) fail(`${quote(dotted)}: ${problems.join("; ")}`, dotted, true); + else out.push([path, value]); + } + } + }; + walk(data, schema, []); + return out; +} + +/** Flattened userConfig name to {path, node} for every schema leaf. */ +function flatNames(schema, teamOnly) { + const names = new Map(); + const walk = (node, parts) => { + for (const key of Object.keys(isMapping(node.properties) ? node.properties : {})) { + if (isWriterKey(parts, key)) continue; + const path = [...parts, key]; + const dotted = path.join("."); + const sub = node.properties[key]; + if (isBranch(sub, dotted, teamOnly)) { + walk(sub, path); + continue; + } + const name = path.join("_"); + if (names.has(name)) throw new Error(`config-cascade: schema keys ${names.get(name).dotted} and ${dotted} both flatten to userConfig name ${name}`); + names.set(name, { path, dotted, node: sub }); + } + }; + walk(schema, []); + return names; +} + +function coerce(raw, node) { + const types = typesOf(node) ?? []; + if (typeof raw !== "string" || types.includes("string")) return raw; + if ((types.includes("integer") || types.includes("number")) && /^[-+]?\d+(\.\d+)?$/.test(raw)) return Number(raw); + if (types.includes("boolean") && (raw === "true" || raw === "false")) return raw === "true"; + return raw; +} + +function userConfigLeaves(userConfig, names, teamOnly, fail) { + const out = []; + for (const [name, raw] of Object.entries(userConfig)) { + if (raw === undefined || raw === null || raw === "" || (typeof raw === "string" && raw.startsWith("${user_config."))) continue; + const leaf = names.get(name); + if (!leaf) continue; + const types = typesOf(leaf.node); + if (teamOnly.has(leaf.dotted)) fail(`${quote(leaf.dotted)}: accepted only in the team layer`, leaf.dotted); + else if (types && !types.every((t) => SCALARS.includes(t))) fail(`${quote(leaf.dotted)}: lists and mappings are set only in files`, leaf.dotted); + else { + const value = coerce(raw, leaf.node); + const problems = check(value, leaf.node); + if (problems.length) fail(`${quote(leaf.dotted)}: ${problems.join("; ")}`, leaf.dotted, true); + else out.push([leaf.path, value]); + } + } + return out; +} + +/** One file layer: its report entry, its parsed mapping (or null), and its prose path (or null). */ +function readLayer(name, stem, repo) { + const path = `${stem}.yaml`; + const md = `${stem}.md`; + const layer = newLayer(name, path, "absent"); + const mdFile = fileState(md, repo); + if (mdFile.refused) { + layer.state = "invalid"; + record(layer, `${name} (${md}): ${mdFile.refused}`); + } + const prose = mdFile.ok ? md : null; + if (prose) layer.state = "loaded"; + const yamlFile = fileState(path, repo); + if (yamlFile.refused) { + layer.state = "invalid"; + record(layer, `${name} (${path}): ${yamlFile.refused}`); + } + if (!yamlFile.ok) return { layer, data: null, prose }; + return { layer, data: parseInto(layer, () => readFileSync(path, "utf8")), prose }; +} + +/** The mapping `text()` parses to, loading `layer`; null, with the layer invalid, when it does not parse or is not a mapping. */ +function parseInto(layer, text) { + try { + const doc = parse(text()) ?? {}; + if (!isMapping(doc)) throw new Error("the top level must be a mapping"); + if (layer.state !== "invalid") layer.state = "loaded"; + return doc; + } catch (e) { + layer.state = "invalid"; + record(layer, `${layer.name} (${layer.path}): ${e.message}`); + return null; + } +} + +/** True when the Markdown file holds a ``` config block outside any other fence. */ +function hasConfigBlock(path) { + if (!fileState(path, true).ok) return false; + let text; + try { + text = readFileSync(path, "utf8"); + } catch { + return false; + } + let fence = null; // the open fence's marker run, backticks or tildes + for (const line of text.split(/\r\n|\r|\n/)) { + const run = line.match(/^(`{3,}|~{3,})/)?.[0]; + if (fence) { + if (run?.[0] === fence[0] && run.length >= fence.length && line.trimEnd().length === run.length) fence = null; + } else if (/^```[A-Za-z0-9_-]+ config\s*$/.test(line)) return true; + else if (run) fence = run; + } + return false; +} + +/** A Markdown text as [{heading, text}], split at each H2 outside a code fence; the first entry holds + * the text before the first H2 (heading null) and is dropped when empty. */ +function splitSections(text) { + const out = [{ heading: null, lines: [] }]; + let fence = null; + for (const line of text.split(/(?<=\n)/)) { + const run = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1]; + if (fence) { + if (run?.[0] === fence[0] && run.length >= fence.length && line.trim().length === run.length) fence = null; + } else if (run) fence = run; + else { + const h = line.match(/^## +(.*?)(?: +#+)?\s*$/); + if (h) { + out.push({ heading: h[1].replace(/\s+/g, " ").trim(), lines: [line] }); + continue; + } + } + out.at(-1).lines.push(line); + } + return out.map(({ heading, lines }) => ({ heading, text: lines.join("") })).filter((s) => s.heading !== null || s.text.trim() !== ""); +} + +function readText(path) { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** The team sections with each same-heading local section in its place, then the local sections the + * team lacks. The first team section with a heading is replaced; a later team repeat of it is dropped. */ +function mergeSections(teamText, localText) { + const team = teamText === null ? [] : splitSections(teamText).map((s) => ({ ...s, layer: "team" })); + const local = localText === null ? [] : splitSections(localText).map((s) => ({ ...s, layer: "local" })); + const replacement = new Map(local.filter((s) => s.heading !== null).map((s) => [s.heading, s])); + const used = new Set(); + const out = []; + for (const s of team) { + if (s.heading === null || !replacement.has(s.heading)) out.push(s); + else if (!used.has(s.heading)) { + used.add(s.heading); + out.push(replacement.get(s.heading)); + } + } + const teamHeadings = new Set(team.map((s) => s.heading)); + for (const s of local) { + // Text before the first H2 follows the team's own only when the team has no file; a repeated heading keeps its last section. + const fresh = s.heading === null ? team.length === 0 : !teamHeadings.has(s.heading) && replacement.get(s.heading) === s; + if (fresh) out.push(s); + } + return out; +} + +function legacyFiles(dir, plugin, kind) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + return entries.filter((e) => e.isFile() && e.name.startsWith(`${plugin}.`)).map((e) => ({ path: join(dir, e.name), kind: kind(e.name) })); +} + +function setPath(object, parts, value) { + let at = object; + for (const part of parts.slice(0, -1)) { + if (!Object.hasOwn(at, part) || !isMapping(at[part])) put(at, part, {}); + at = at[part]; + } + put(at, parts.at(-1), value); +} + +/** Validated floors: dotted key to {direction, scale}. */ +function normalizeFloors(floors) { + if (!isMapping(floors)) throw new Error("config-cascade: floors must be a mapping of key path to Floor direction"); + const out = new Map(); + for (const [key, raw] of Object.entries(floors)) { + const direction = typeof raw === "string" ? raw : raw?.direction; + if (!DIRECTIONS.includes(direction)) throw new Error(`config-cascade: floor ${JSON.stringify(key)} has direction ${show(direction)}, not one of ${DIRECTIONS.join(", ")}`); + out.set(key, { direction, scale: isMapping(raw) && Array.isArray(raw.scale) ? raw.scale : null }); + } + return out; +} + +function nodeAt(schema, parts) { + let node = schema; + for (const part of parts) { + node = node && child(node, part); + if (!node) return null; + } + return node; +} + +const tightening = (v) => v === true || v === "report"; + +/** Why `personal` loosens `team` in the floor's direction, or null when it does not. */ +function looser(floor, personal, team, node) { + if (JSON.stringify(personal) === JSON.stringify(team)) return null; + switch (floor.direction) { + case "team-only": + if (Array.isArray(personal) && Array.isArray(team) && team.every((t) => personal.some((p) => JSON.stringify(p) === JSON.stringify(t)))) return null; + return `${show(personal)} differs from the team value ${show(team)}, and the team value wins`; + case "lowest-value": + if (typeof personal === "number" && typeof team === "number" && personal <= team) return null; + return `${show(personal)} is not lower than or equal to the team value ${show(team)}`; + case "any-layer-wins": + if (!tightening(team) || tightening(personal)) return null; + return `${show(personal)} drops the team value ${show(team)}, which is the tightening one`; + case "stricter-value": { + const scale = floor.scale ?? node?.enum ?? []; + const a = scale.findIndex((v) => JSON.stringify(v) === JSON.stringify(personal)); + const b = scale.findIndex((v) => JSON.stringify(v) === JSON.stringify(team)); + if (a >= 0 && b >= 0 && a >= b) return null; + return `${show(personal)} is not as strict as the team value ${show(team)} on the scale ${show(scale)}`; + } + default: + return null; + } +} + +export function resolve({ plugin, projectRoot, home = "docs/conventions", schema, defaults, userConfig, teamOnly = [], floors = {}, userHome = homedir() }) { + if (typeof plugin !== "string" || !PLUGIN.test(plugin)) throw new Error(`config-cascade: plugin name ${JSON.stringify(plugin)} is not a plain name`); + const only = new Set(teamOnly); + const floorMap = normalizeFloors(floors); + const names = flatNames(schema, only); + const homeDir = resolvePath(projectRoot, home); + // A relative home is inside the checkout by intent; a committed symlink must not carry it out. + const homeEscapes = !isAbsolute(home) && escapes(homeDir, projectRoot); + const layers = []; + const contributions = []; // [layer name, [[parts, value]], dotted keys whose value was invalid] + const prose = []; + const proseText = {}; + + // pairsOf(fail) returns the layer's accepted [parts, value] pairs; falsy when it contributes none. + const take = (layer, pairsOf) => { + const invalid = new Set(); + const fail = (message, key, valueFailed = false) => { + record(layer, `${layer.name}${layer.path ? ` (${layer.path})` : ""}: ${message}`, key); + if (valueFailed) invalid.add(key); + }; + if (pairsOf) contributions.push([layer.name, pairsOf(fail), invalid]); + layers.push(layer); + }; + const fileLeaves = (data, allowTeamOnly) => data && ((fail) => leaves(data, schema, { teamOnly: only, allowTeamOnly, fail })); + + const shaped = (name, given) => { + if (given === undefined || given === null) return { layer: newLayer(name, null, "absent"), ok: false }; + if (isMapping(given)) return { layer: newLayer(name, null, "loaded"), ok: true }; + return { layer: newLayer(name, null, "invalid", [`${name}: expected a mapping, got ${show(given)}`]), ok: false }; + }; + const d = shaped("defaults", defaults); + take(d.layer, d.ok && fileLeaves(defaults, true)); + const u = shaped("userConfig", userConfig); + take(u.layer, u.ok && ((fail) => userConfigLeaves(userConfig, names, only, fail))); + for (const [name, stem] of [ + ["team", join(homeDir, plugin)], + ["local", join(homeDir, `${plugin}.local`)], + ]) { + if (homeEscapes) { + const why = "the convention home resolves outside the project; a repository layer stays inside it"; + take(newLayer(name, `${stem}.yaml`, "invalid", [`${name} (${homeDir}): ${why}`])); + continue; + } + const read = readLayer(name, stem, true); + take(read.layer, fileLeaves(read.data, name === "team")); + if (read.prose) { + prose.push(read.prose); + proseText[name] = readText(read.prose); + } + } + + const merged = new Map(); // dotted -> {parts, value, from} + let base = new Map(); // the defaults layer's entries, where an invalid higher value falls back to + const within = (dotted, key) => dotted === key || dotted.startsWith(`${key}.`); + for (const [name, pairs, invalid] of contributions) { + // A lower layer's value is never used for an invalid one; the personal overlay refines the team file, so its keys fall to team. + if (name !== "local") { + for (const key of invalid) { + for (const dotted of [...merged.keys()]) if (within(dotted, key)) merged.delete(dotted); + for (const [dotted, entry] of base) if (within(dotted, key)) merged.set(dotted, entry); + } + } + const layer = layers.find((l) => l.name === name); + for (const [parts, value] of pairs) { + const dotted = parts.join("."); + const prior = merged.get(dotted); + const floor = floorMap.get(dotted); + const union = parts.at(-1) === "disable" && Array.isArray(value); + if (name === "local" && floor && floor.direction !== "no-floor" && !union && prior && [prior.from].flat().includes("team")) { + const why = looser(floor, value, prior.value, nodeAt(schema, parts)); + if (why) { + record(layer, `local (${layer.path}): ${quote(dotted)}: loosens the ${floor.direction} policy floor: ${why}`, dotted); + continue; + } + } + if (union) { + const items = Array.isArray(prior?.value) ? [...prior.value] : []; + const seen = new Set(items.map((i) => JSON.stringify(i))); + for (const item of value) { + const key = JSON.stringify(item); + if (!seen.has(key)) items.push(item); + seen.add(key); + } + // An empty list adds nothing, so its layer is credited only when no layer set the key before. + const from = Array.isArray(prior?.from) ? prior.from : []; + merged.set(dotted, { parts, value: items, from: value.length > 0 || !prior ? [...from, name] : from }); + } else merged.set(dotted, { parts, value, from: name }); + } + if (name === "defaults") base = new Map(merged); + } + const values = {}; + const provenance = {}; + for (const [dotted, { parts, value, from }] of merged) { + setPath(values, parts, value); + put(provenance, dotted, from); + } + + const legacy = legacyOf(plugin, userHome, projectRoot, !homeEscapes && `${join(homeDir, plugin)}.md`); + const sections = mergeSections(proseText.team ?? null, proseText.local ?? null).map(({ heading, text, layer }) => ({ heading, text, layer })); + return { values, provenance, prose, sections, layers, legacy }; +} + +/** The legacy locations that exist; `teamMd` (or false) is the team .md checked for a fenced config block. */ +function legacyOf(plugin, userHome, projectRoot, teamMd) { + const found = [ + ...legacyFiles(join(userHome, ".claude"), plugin, () => "claude-user"), + ...(projectRoot ? legacyFiles(join(projectRoot, ".claude"), plugin, (n) => (n.startsWith(`${plugin}.local.`) ? "claude-local" : "claude-project")) : []), + ...(teamMd && hasConfigBlock(teamMd) ? [{ path: teamMd, kind: "fenced-block" }] : []), + ]; + // The project root may be the user home, so a file is reported under its first kind only. + return found.filter((entry, i) => found.findIndex((e) => e.path === entry.path) === i); +} + +/** + * Each layer's own accepted values, unmerged, for a caller that weighs the layers itself (the + * rendered-views publish rule reads every layer separately: lib/view-settings.mjs). The same reader, + * refusals and schema check as resolve(), with no precedence, floors or invalid-value fallback: an + * invalid value leaves only its own layer. Layers: "userConfig", then "team" and "local" when + * projectRoot is set. `team`, as {path, text}, replaces the working-tree team file (text null: + * absent), for a caller reading it from a git ref. + * readLayers({plugin, projectRoot, home, schema, userConfig, teamOnly, team, userHome}) + * -> {layers: [{name, path, state, errors, error_keys, values: {"a.b": value}}], legacy} + */ +export function readLayers({ plugin, projectRoot = null, home = "docs/conventions", schema, userConfig, teamOnly = [], team, userHome = homedir() }) { + if (typeof plugin !== "string" || !PLUGIN.test(plugin)) throw new Error(`config-cascade: plugin name ${JSON.stringify(plugin)} is not a plain name`); + const only = new Set(teamOnly); + const layers = []; + const add = (layer, pairsOf) => { + const fail = (message, key) => record(layer, `${layer.name}${layer.path ? ` (${layer.path})` : ""}: ${message}`, key); + const values = {}; + for (const [parts, value] of pairsOf ? pairsOf(fail) : []) put(values, parts.join("."), value); + layers.push({ ...layer, values }); + }; + if (isMapping(userConfig)) add(newLayer("userConfig", null, "loaded"), (fail) => userConfigLeaves(userConfig, flatNames(schema, only), only, fail)); + if (!projectRoot) return { layers, legacy: legacyOf(plugin, userHome, null, false) }; + const homeDir = resolvePath(projectRoot, home); + const homeEscapes = !isAbsolute(home) && escapes(homeDir, projectRoot); + for (const [name, stem] of [ + ["team", join(homeDir, plugin)], + ["local", join(homeDir, `${plugin}.local`)], + ]) { + const fileLeaves = (data) => data && ((fail) => leaves(data, schema, { teamOnly: only, allowTeamOnly: name === "team", fail })); + if (name === "team" && team) { + const layer = newLayer(name, team.path, "absent"); + add(layer, typeof team.text === "string" && fileLeaves(parseInto(layer, () => team.text))); + } else if (homeEscapes) { + add(newLayer(name, `${stem}.yaml`, "invalid", [`${name} (${homeDir}): the convention home resolves outside the project; a repository layer stays inside it`])); + } else { + const read = readLayer(name, stem, true); + add(read.layer, fileLeaves(read.data)); + } + } + return { layers, legacy: legacyOf(plugin, userHome, projectRoot, !homeEscapes && `${join(homeDir, plugin)}.md`) }; +} diff --git a/plugins/codebase-health/lib/state-key.sh b/plugins/codebase-health/lib/state-key.sh new file mode 100755 index 00000000000..78a36277953 --- /dev/null +++ b/plugins/codebase-health/lib/state-key.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# GENERATED from lib/state-key.sh by scripts/sync-shared-copies.sh. Do not edit this copy: +# edit the canonical source, then rerun the script. +# Per-project state key for anything a plugin writes under ${CLAUDE_PLUGIN_DATA}. +# +# WHY. ${CLAUDE_PLUGIN_DATA} is keyed to the plugin identifier and nothing else: +# https://code.claude.com/docs/en/plugins/manifest-reference#environment-variables +# (as of 2026-10-11; recheck when that section adds a project, checkout, +# worktree or session segment to the path). So a skill that writes a fixed filename there has one file per +# MACHINE: every run from every repository overwrites the last, and a skill that +# READS it back can serve one project's findings as another's. +# +# This prints the missing segment. The scheme is canonically defined by the +# marketplace's plugin-data-report-keying convention (rule 1); this header keeps +# a named operational duplicate of that definition so the executable ships +# self-described: +# +# = / +# +# repo-identity the first configured remote URL normalized to +# host/owner/repo, lowercased, scheme/credentials/.git +# stripped. No remote -> local/. +# Not a repo at all -> nonrepo/. +# worktree-discriminator sha256 of the canonicalized worktree root, cut to 8 +# (outside a repository, of the physical cwd). Two +# worktrees of one repository legitimately hold +# different content and must not share a report. +# +# Every path is taken physical (symlinks resolved) before it is hashed. `cd` +# keeps the logical spelling a symlink was reached through, and the read-back +# consumers derive the key from wherever the operator launched, so one +# directory reached two ways must still produce one key. git canonicalizes +# the root it reports; the non-repository rung has to do it itself. +# +# SECURITY — this is why the identity is validated rather than merely lowercased. +# A remote URL is arbitrary text that becomes DIRECTORY COMPONENTS in the caller's +# path. A remote of `../../../etc` would walk a report out of the plugin's own +# namespace. Any identity that is not a plain lowercase segment path is replaced +# by a hash, so it still keys deterministically and still stays inside the +# namespace. Every rejected shape is covered by state-key.test.sh. +# +# NOT a substitute for a per-run filename. Keying stops cross-project collision; +# it does not stop a same-project rerun overwriting yesterday's report. A caller +# that needs history writes one file per run under this key and appends a line +# to a history file — see docs/contracts/plugin-data-report-keying/. +# +# Usage: +# state-key.sh [--root ] [--explain] +# +# --root derive for that directory instead of the current one +# --explain write the rung taken and its inputs to stderr +# +# Exit: 0 always on a successful derivation — every input reaches some rung, so +# there is no "cannot key" outcome. 2 on a bad argument, an unusable --root, or +# a PATH with neither sha256sum nor shasum. +# +# Shared source: lib/state-key.sh at the marketplace repo root is the canonical source; the copies in the plugins +# that carry it are generated by scripts/sync-shared-copies.sh and registered in +# scripts/shared-copies.txt. Edit this file and rerun the script. + +set -uo pipefail + +# `cd` consults CDPATH when the operand is relative and does not start with `.`, +# and on a hit it ECHOES the resolved path to stdout and lands somewhere the +# caller never named. Either one breaks this script: stdout must be exactly the +# key, and the key must describe the requested directory. Clear it once, here. +CDPATH= + +usage() { + cat <<'EOF' +state-key.sh — per-project state key for ${CLAUDE_PLUGIN_DATA} writes. + +Prints / for a directory, so a report +persisted under the plugin data directory belongs to one project rather than to +the machine. + +Usage: + state-key.sh [--root ] [--explain] [--help] + + --root derive for that directory instead of the current one + --explain write the rung taken and its inputs to stderr + +Exit: 0 on a derivation; 2 on a bad argument, an unusable --root, or no hash tool. +EOF +} + +ROOT_ARG="" +EXPLAIN=0 +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --root) + if [[ $# -lt 2 ]]; then + echo "ERROR: --root needs a path" >&2 + exit 2 + fi + ROOT_ARG="$2" + shift 2 + ;; + --explain) + EXPLAIN=1 + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -n "$ROOT_ARG" ]]; then + if [[ ! -d "$ROOT_ARG" ]]; then + echo "ERROR: --root is not a directory: $ROOT_ARG" >&2 + exit 2 + fi + cd "$ROOT_ARG" || exit 2 +fi +# Physical cwd, forklessly: `cd -P .` rewrites $PWD with symlinks resolved. +cd -P . || exit 2 + +# sha256sum is absent on stock macOS; shasum -a 256 is the portable partner. +# Probe here in the main script, not inside sha256(). hash12/hash8 run that +# helper as a non-last pipeline stage inside a command substitution, and POSIX +# XCU 2.6.3 plus Bash's pipeline rule both execute that in a subshell, so +# `exit 2` there cannot terminate this process. Under `set -uo pipefail` (no +# -e) the script would then print a malformed key at exit 0. +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256; } +else + echo "ERROR: no sha256sum or shasum on PATH — cannot derive a state key" >&2 + exit 2 +fi + +hash12() { printf '%s' "$1" | sha256 | cut -c1-12; } +hash8() { printf '%s' "$1" | sha256 | cut -c1-8; } + +# "the FIRST configured remote" — not necessarily one named `origin`. A repo +# whose only remote is `upstream` still has a remote and must not fall through +# to the local rung. +remote_name=$(git remote 2>/dev/null | tr -d '\r' | head -1) +remote="" +if [[ -n "$remote_name" ]]; then + remote=$(git config --get "remote.${remote_name}.url" 2>/dev/null | tr -d '\r') +fi +# tr -d '\r': Git on Windows can return a CRLF-terminated path. +root=$(git rev-parse --show-toplevel 2>/dev/null | tr -d '\r') + +rung="" +if [[ -n "$remote" ]]; then + identity=$(printf '%s' "$remote" | + sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' | + tr '[:upper:]' '[:lower:]') + # A remote URL is arbitrary text and becomes DIRECTORY COMPONENTS here, so + # accept it only in the shape the scheme means — segments of [a-z0-9._-] each + # starting alphanumeric. That rejects `../central` (a relative filesystem + # remote, which would otherwise write outside the caller's namespace), + # absolute local paths, and backslashes. Anything rejected still keys + # deterministically, by hash. + if grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$' <<<"$identity"; then + rung="remote" + else + identity="remote/$(hash12 "$remote")" + rung="remote-hashed" + fi +elif [[ -n "$root" ]]; then + identity="local/$(hash12 "$root")" + rung="local" +else + # Not a git repository. `audit-pass`'s ladder stops at the two git rungs + # because it refuses non-git targets; a report-only skill audits them, so the + # scheme needs a third rung rather than a failure. + identity="nonrepo/$(hash12 "$PWD")" + rung="nonrepo" +fi + +discriminator=$(hash8 "${root:-$PWD}") + +if [[ $EXPLAIN -eq 1 ]]; then + { + echo "rung: $rung" + echo "remote: ${remote:-}" + echo "repo root: ${root:-}" + echo "cwd: $PWD" + echo "identity: $identity" + echo "discriminator: $discriminator" + } >&2 +fi + +printf '%s/%s\n' "$identity" "$discriminator" diff --git a/plugins/codebase-health/lib/view-registry.mjs b/plugins/codebase-health/lib/view-registry.mjs new file mode 100644 index 00000000000..5b33fb87a25 --- /dev/null +++ b/plugins/codebase-health/lib/view-registry.mjs @@ -0,0 +1,94 @@ +// GENERATED from lib/view-registry.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// The medium registry for rendered views: one entry per medium, the one fallback chain, and each +// view lane's registration. Skills never name a medium themselves; they reach this through +// lib/view-settings.mjs. Adding a medium or a lane is an addition here, never a skill edit. +// +// MediumEntry { id, publishes, reachable(context) -> {ok, reason}, deliver(view, context) -> {location}, reportName } +// Lane { laneId, kinds, hostedCapable, default } +// +// `context.interactive` is false in a CI or other non-interactive run, where only the terminal is +// reachable (docs/contracts/rendered-views/README.md, "Reachability and preference"). A lane's +// `default` is its shipped medium, applied when no scope decides; null leaves the choice to the +// skill's own content rule. + +const interactiveOnly = (context = {}) => (context.interactive === false ? { ok: false, reason: "non-interactive run" } : { ok: true }); + +const entry = (id, publishes, reportName, reachable, deliver) => Object.freeze({ id, publishes, reportName, reachable, deliver }); + +export const MEDIUMS = Object.freeze({ + terminal: entry("terminal", false, "the terminal", () => ({ ok: true }), () => ({ location: "terminal" })), + file: entry("file", false, "a local file", interactiveOnly, (view) => ({ location: view.path })), + artifact: entry("artifact", true, "a private Artifact on claude.ai", interactiveOnly, (view) => ({ location: "artifact", publish: view.path })), + hosted: entry("hosted", true, "the shared page host", interactiveOnly, (view) => ({ location: "hosted", publish: view.path })), +}); + +/** The registry entry for `id`, own keys only, so an inherited name such as `toString` is no medium. */ +export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS, id) ? MEDIUMS[id] : undefined); + +/** The one fallback chain: a view that cannot be served on a medium steps down this list. */ +export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); + +/** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ +export const NO_PREFERENCE = "auto"; + +/** The rendered-views concern's schema, for the cascade reader: one key, `medium`. */ +export const SCHEMA = Object.freeze({ + type: "object", + additionalProperties: false, + properties: { medium: { type: "string", enum: [NO_PREFERENCE, ...Object.keys(MEDIUMS)] } }, +}); + +const lane = (laneId, kinds, hostedCapable, medium) => Object.freeze({ laneId, kinds: Object.freeze(kinds), hostedCapable, default: medium }); + +export const LANES = Object.freeze( + Object.fromEntries( + [ + lane("review:explain-change", ["digest"], true, "artifact"), + lane("visualization:present", ["deck"], false, "artifact"), + lane("visualization:visualize", [], false, null), + lane("planning", ["plan", "brainstorm"], false, "file"), + lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), + lane("discovery:blindspot", ["blindspot"], false, "file"), + lane("debugging:debug", ["post-mortem"], false, "file"), + lane("education:illustrate", [], false, "file"), + lane("work-items:morning-brief", ["morning-brief"], false, "file"), + lane("work-items:triage", ["board"], false, "file"), + lane("adhd:clarify", [], false, null), + lane("codebase-health:audit", [], false, null), + lane("overengineering:audit", [], false, null), + lane("education:teach", [], false, null), + lane("prototype:explore-directions", [], false, null), + lane("prototype:pressure-test", [], false, null), + lane("ai-briefing:generate", [], false, null), + ].map((l) => [l.laneId, l]), + ), +); + +/** The lane registration for `id`, own keys only. */ +export const laneOf = (id) => (typeof id === "string" && Object.hasOwn(LANES, id) ? LANES[id] : undefined); + +/** `medium` as `lane` reads it: `hosted` is `artifact` unless the lane sends pages to a page host. */ +export const readAs = (medium, laneEntry) => (medium === "hosted" && !laneEntry.hostedCapable ? "artifact" : medium); + +/** + * The medium actually used for `preferred` on `laneEntry`: `preferred` itself when reachable, else the + * first reachable medium after it on the fallback chain (a hosted page steps to `artifact` first). + * @returns {{medium: string, fell: null | string}} fell: why `preferred` was not used + */ +export function fallBack(preferred, laneEntry, context = {}) { + const want = readAs(preferred, laneEntry); + const start = FALLBACK_CHAIN.indexOf(want === "hosted" ? "artifact" : want); + const candidates = want === "hosted" ? ["hosted", ...FALLBACK_CHAIN.slice(start)] : FALLBACK_CHAIN.slice(start); + const wanted = mediumOf(want); + if (!wanted) throw new Error(`view-registry: unknown medium ${JSON.stringify(preferred)}`); + const first = wanted.reachable(context); + if (first.ok) return { medium: want, fell: null }; + const medium = candidates.find((id) => mediumOf(id).reachable(context).ok) ?? "terminal"; + return { medium, fell: `${want} unreachable: ${first.reason}` }; +} + +/** The report line every view lane prints, then one line per notice. */ +export function reportLines({ medium, decidedBy, notices = [] }) { + return [`view: ${medium ?? NO_PREFERENCE} (${decidedBy})`, ...notices.map((n) => `notice: ${n.text}`)]; +} diff --git a/plugins/codebase-health/lib/view-settings.mjs b/plugins/codebase-health/lib/view-settings.mjs new file mode 100755 index 00000000000..17f324dae75 --- /dev/null +++ b/plugins/codebase-health/lib/view-settings.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +// GENERATED from lib/view-settings.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// Where a rendered view goes: the one medium resolver every view lane calls. It reads each ADR 0075 +// scope's own `medium` value (concern `rendered-views`, home docs/conventions) through the cascade's +// per-layer reader and schema check, and walks them itself, because a filter on the cascade's +// merged output would lose a lower scope's local value under a higher scope's publishing one. +// +// The argument decides alone. Otherwise a terminal or file value from any scope keeps the view +// local, the highest-precedence one naming the scope (personal override, then team, then +// userConfig). Else the first publishing value from a trusted scope wins: the personal override +// (only untracked and gitignored), then userConfig. A team file never publishes. With no scope +// deciding, the lane's own default applies (it ships with the installed plugin, which a checked-out +// branch cannot write). An invalid value is ignored without dropping another scope's value; auto is +// unset. The retired files (~/.claude/rendered-views.md, .claude/rendered-views.md and +// .claude/rendered-views.local.md) give a notice, once per plugin per project while unchanged, and +// never a value. The medium then steps down the fallback chain (lib/view-registry.mjs) until +// reachable. +// +// view-settings.mjs resolve --lane --option [--argument ] [--team-ref ] +// Prints the resolution as one JSON line, then the report line and one line per notice. +// Exit 0 resolved, 2 usage. + +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readLayers } from "./config-cascade.mjs"; +import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; + +const CONCERN = "rendered-views"; +const HOME = "docs/conventions"; +const TEAM_FILE = `${HOME}/${CONCERN}.yaml`; +const PERSONAL_FILE = `${HOME}/${CONCERN}.local.yaml`; +const HERE = dirname(fileURLToPath(import.meta.url)); + +// ------------------------------------------------------------ repository checks + +/** The session's project: CLAUDE_PROJECT_DIR, else the nearest directory above the cwd holding `.git`. */ +export function findRoot() { + if (process.env.CLAUDE_PROJECT_DIR) return resolve(process.env.CLAUDE_PROJECT_DIR); + for (let at = process.cwd(); ; at = dirname(at)) { + if (existsSync(join(at, ".git"))) return at; + if (dirname(at) === at) return null; + } +} + +const real = (p) => { + try { + return realpathSync(p); + } catch { + return resolve(p); + } +}; +const within = (child, parent) => { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +}; +const isLink = (p) => { + try { + return lstatSync(p).isSymbolicLink(); + } catch { + return false; + } +}; +function gitOut(root, args) { + try { + return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + } catch { + return null; + } +} + +/** + * A personal file applies only untracked, so a pull request cannot ship one. Every directory between + * the root and the file must be a plain, untracked directory, never a submodule, a tracked file or a + * symlink. Not gitignored, it warns, or with `requireIgnored` is ignored too. + */ +export function overlayApplies(root, path, warnings, { requireIgnored = false } = {}) { + // Any tracked case variant counts: on a case-insensitive filesystem it is this file. + const rel = relative(root, path).split("\\").join("/"); + if (gitOut(root, ["ls-files", "--", `:(icase)${rel}`])) { + warnings.push(`overlay ${rel}: tracked in git, so a pull request could set it; layer ignored`); + return false; + } + const parts = rel.split("/").slice(0, -1); + const dirs = parts.map((_, i) => parts.slice(0, i + 1).join("/")); + for (const dir of dirs) { + // A submodule or tracked file at the directory itself is content a pull request controls. + const entries = (gitOut(root, ["ls-files", "-s", "-z", "--", `:(icase)${dir}`]) ?? "").split("\0"); + if (entries.some((e) => e.split("\t")[1]?.toLowerCase() === dir.toLowerCase()) || existsSync(join(root, dir, ".git"))) { + warnings.push(`overlay ${rel}: ${dir} is a submodule or tracked entry; layer ignored`); + return false; + } + if (isLink(join(root, dir))) { + warnings.push(`overlay ${rel}: ${dir} is a symlink; layer ignored`); + return false; + } + } + if (isLink(path) || !within(real(path), join(real(root), ...parts))) { + warnings.push(`overlay ${rel}: the overlay is a symlink or resolves outside ${parts.join("/") || "the root"}; layer ignored`); + return false; + } + if (gitOut(root, ["check-ignore", "-q", "--", path]) === null) { + warnings.push(`overlay ${rel}: not gitignored, so it can reach team history${requireIgnored ? "; layer ignored" : ""}`); + return !requireIgnored; + } + return true; +} + +/** Repository scopes apply only inside a working tree that is not the user's home or above it. */ +const repoScopes = (root, userHome) => Boolean(root) && !within(real(userHome), real(root)) && gitOut(root, ["rev-parse", "--is-inside-work-tree"]) !== null; + +/** A ref name or object id: never an option, never a revision expression. */ +const REF = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; + +/** The team file as `ref` holds it, for the cascade reader; a ref naming no commit reads nothing. */ +function teamAt(root, ref, notices) { + const sha = REF.test(ref) ? gitOut(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])?.trim() : null; + if (!sha) { + const named = ref ? `team ref ${JSON.stringify(ref)} names no commit here` : "no team ref was given"; + notices.push({ kind: "team-ref", text: `${named}, so ${TEAM_FILE} is not read` }); + return { path: `${ref}:${TEAM_FILE}`, text: null }; + } + return { path: `${sha.slice(0, 12)}:${TEAM_FILE}`, text: gitOut(root, ["show", `${sha}:${TEAM_FILE}`]) }; +} + +// ------------------------------------------------------------ retired files + +const mediumIn = (text) => /^[ \t]*medium:[ \t]*["']?([a-z]+)["']?[ \t]*$/m.exec(text ?? "")?.[1] ?? null; +const readBytes = (p) => { + try { + return readFileSync(p); + } catch { + return null; + } +}; + +/** This project's state key (docs/contracts/plugin-data-report-keying, rule 1), or null when it cannot be derived. */ +function stateKey(root) { + const out = spawnSync("bash", [join(HERE, "state-key.sh"), "--root", root ?? process.cwd()], { encoding: "utf8" }); + const key = out.status === 0 ? out.stdout.trim() : ""; + return /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)+$/.test(key) ? key : null; +} + +function writeAtomic(path, text) { + mkdirSync(dirname(path), { recursive: true }); + const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + writeFileSync(temp, text); + renameSync(temp, path); +} + +/** One notice per retired file, unless this plugin already showed it for this project with these bytes. */ +function retiredNotices(legacy, { plugin, root, userHome, dataDir }) { + const where = (path) => (within(path, join(userHome, ".claude")) ? `~/${relative(userHome, path).split("\\").join("/")}` : relative(root, path).split("\\").join("/")); + const destination = { "claude-user": `the ${plugin} plugin's medium option`, "claude-project": TEAM_FILE, "claude-local": PERSONAL_FILE, "fenced-block": TEAM_FILE }; + const key = dataDir ? stateKey(root) : null; + const state = key && join(dataDir, "view-settings", key, "retired-notices.json"); + let seen = {}; + try { + seen = state ? JSON.parse(readFileSync(state, "utf8")) : {}; + } catch { + seen = {}; + } + const notices = []; + let changed = false; + const order = Object.keys(destination); + for (const { path, kind } of [...legacy].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || a.path.localeCompare(b.path))) { + const bytes = readBytes(path); + if (!bytes) continue; + const shown = where(path); + const hash = createHash("sha256").update(bytes).digest("hex"); + if (seen[shown] === hash) continue; + [seen[shown], changed] = [hash, true]; + const value = mediumIn(bytes.toString("utf8")); + const to = destination[kind] ?? TEAM_FILE; + const caveat = + value && mediumOf(value)?.publishes + ? to === TEAM_FILE + ? "; a team file never publishes, so that value would be ignored there" + : to === PERSONAL_FILE + ? "; it publishes only while that file is untracked and gitignored" + : "" + : ""; + notices.push({ kind: "retired", path: shown, value, destination: to, text: `${shown} is no longer read${value ? ` (it sets medium: ${value})` : ""}; set it in ${to} instead${caveat}` }); + } + if (state && changed) { + try { + writeAtomic(state, `${JSON.stringify(seen)}\n`); + } catch { + // Unwritable state shows the notice again next time, which is the safe direction. + } + } + return notices; +} + +// ------------------------------------------------------------ resolution + +/** True when the reader chose a publishing medium (the argument, or a trusted scope), which the publish gate takes as explicit: never the plugin default. */ +export const gateExplicit = ({ preferred, decidedBy }) => Boolean(mediumOf(preferred)?.publishes) && ["argument", "userConfig", "personal override"].includes(decidedBy); + +/** + * @param {{lane: string, argument?: string|null, userConfig?: string|null, projectRoot?: string|null, teamRef?: string|null}} request + * @param {{userHome?: string, dataDir?: string|null, interactive?: boolean}} [options] + * @returns {{preferred: string|null, medium: string|null, decidedBy: string, notices: {kind: string, text: string}[]}} + */ +export function resolveMedium({ lane, argument = null, userConfig = null, projectRoot = null, teamRef = null }, { userHome = homedir(), dataDir = null, interactive = true } = {}) { + const laneEntry = laneOf(lane); + if (!laneEntry) throw new Error(`view-settings: unknown lane ${JSON.stringify(lane)}`); + const notices = []; + const root = repoScopes(projectRoot, userHome) ? projectRoot : null; + const team = root && typeof teamRef === "string" ? teamAt(root, teamRef, notices) : undefined; + const { layers, legacy } = readLayers({ plugin: CONCERN, projectRoot: root, schema: SCHEMA, userConfig: { medium: userConfig ?? "" }, team, userHome }); + notices.unshift(...retiredNotices(legacy, { plugin: lane.split(":")[0], root, userHome, dataDir })); + const finish = (preferred, decidedBy) => { + if (!preferred) return { preferred: null, medium: null, decidedBy, notices }; + const { medium, fell } = fallBack(preferred, laneEntry, { interactive }); + return { preferred, medium, decidedBy: fell ? `fell back: ${fell}` : decidedBy, notices }; + }; + if (mediumOf(argument)) return finish(argument, "argument"); + + const shown = (path) => (path && root && isAbsolute(path) ? relative(root, path).split("\\").join("/") : path); + for (const layer of layers) { + for (const error of layer.errors) { + const prefix = `${layer.name}${layer.path ? ` (${layer.path})` : ""}: `; + const name = { local: "personal override" }[layer.name] ?? layer.name; + notices.push({ kind: "invalid", text: `${name}${layer.path ? ` ${shown(layer.path)}` : ""}: ${error.startsWith(prefix) ? error.slice(prefix.length) : error}; ignored` }); + } + } + const valueOf = (name) => layers.find((l) => l.name === name)?.values.medium; + const personal = valueOf("local"); + let personalTrusted = false; + if (personal && mediumOf(personal)?.publishes) { + const warnings = []; + personalTrusted = overlayApplies(root, join(root, PERSONAL_FILE), warnings, { requireIgnored: true }); + for (const w of warnings) notices.push({ kind: "ignored", text: `personal override ${w.replace(/^overlay /, "")}` }); + } + // Highest precedence first (ADR 0075: the personal override, then team, then userConfig). + const scopes = [ + ["personal override", personal, personalTrusted], + ["team", valueOf("team"), false], + ["userConfig", valueOf("userConfig"), true], + ].filter(([, medium]) => medium && medium !== NO_PREFERENCE); + const local = scopes.find(([, medium]) => !mediumOf(medium).publishes); + if (local) return finish(local[1], local[0]); + const teamValue = scopes.find(([name]) => name === "team")?.[1]; + if (teamValue) notices.push({ kind: "ignored", text: `team ${shown(layers.find((l) => l.name === "team").path)} sets medium: ${teamValue}; a team file never publishes, so it is ignored` }); + const trusted = scopes.find(([, , ok]) => ok); + return trusted ? finish(trusted[1], trusted[0]) : finish(laneEntry.default, "plugin default"); +} + +// ------------------------------------------------------------ CLI + +const USAGE = "usage: view-settings.mjs resolve --lane --option [--argument ] [--team-ref ]\n"; + +/** CLI: the argument vector after the script path; returns the exit code. */ +export function main(argv, env = process.env) { + const [action, ...rest] = argv; + const request = { lane: null, userConfig: undefined, argument: null, teamRef: null }; + const flags = { "--lane": "lane", "--option": "userConfig", "--argument": "argument", "--team-ref": "teamRef" }; + for (let i = 0; i < rest.length; i += 2) { + const key = flags[rest[i]]; + if (!key || rest[i + 1] === undefined) return usage(); + request[key] = rest[i + 1]; + } + if (action !== "resolve" || !laneOf(request.lane) || request.userConfig === undefined) return usage(); + if (request.argument === NO_PREFERENCE) request.argument = null; + if (request.argument !== null && !mediumOf(request.argument)) return usage(); + const ci = Boolean(env.CI) && env.CI !== "false"; + const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); + process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + return 0; +} + +function usage() { + process.stderr.write(USAGE); + return 2; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/plugins/codebase-health/lib/yaml-subset.mjs b/plugins/codebase-health/lib/yaml-subset.mjs new file mode 100644 index 00000000000..b97b62c2ab0 --- /dev/null +++ b/plugins/codebase-health/lib/yaml-subset.mjs @@ -0,0 +1,314 @@ +// GENERATED from lib/yaml-subset.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// A parser for the YAML subset the team file is written in: a port of +// plugins/code-metrics/scripts/yaml_subset.py, kept to the same grammar. +// +// parse(text) -> the document (null when empty), or throws YamlSubsetError {line, message} +// parse(text, { source: true }) -> the same grammar, but every scalar is its source text as +// lib/yaml-subset.awk prints it (plain text as written, quotes removed, `\n` kept as written), +// mappings are Maps in document order, an empty value is null, and a key may not be empty or +// hold `.` or a tab; as in that reader, a plain key is `[A-Za-z0-9_-]`, a quoted key may have +// blanks before its `:`, only a comment may follow a quoted value, and a root sequence or a +// sequence nested on one line is an error +// +// The subset: block mappings (nested by indentation of two or more spaces); block sequences +// (scalar or mapping items, a mapping item may start on the dash line); flow sequences of scalars +// (`[a, "b", 3]`, `[]`); scalars: quoted and plain strings, integers, floats, true/false, +// null/~, and an empty value meaning null; `#` comments outside quotes; blank lines. +// Everything else is reported with its line rather than parsed partially: flow mappings (`{`), +// anchors and aliases, tags, block scalars, document markers, tab indentation, duplicate keys, +// nesting deeper than MAX_DEPTH. +// Narrower than the Python grammar, as lib/yaml-subset.awk is: a plain value may not hold `: ` +// and a plain key may not hold whitespace, `$` or `.`; quoting either lifts the limit. +// Keys become own data properties, so a `__proto__` key never reaches an object's prototype. +// Error messages quote file text as JSON strings cut to 60 characters: they describe data. + +export class YamlSubsetError extends Error { + constructor(line, message) { + super(`line ${line}: ${message}`); + this.name = "YamlSubsetError"; + this.line = line; + } +} + +// Every descent below the top level passes through nested(), so this bounds the recursion. +const MAX_DEPTH = 64; +const INT = /^[-+]?\d+$/; +const FLOAT = /^[-+]?(\d+\.\d*|\.\d+|\d+)([eE][-+]?\d+)?$/; +const quote = (text) => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}...` : text); +const isItem = (content) => content === "-" || content.startsWith("- "); +const put = (object, key, value) => Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true }); + +/** The text before `#` as a comment ends it. A quote opens only where a value starts (line start, + * after `: `, `- `, `[` or `,`), so an apostrophe inside a plain value is just a character. */ +function stripComment(text) { + let q = null; + let last = -1; // index of the last non-whitespace character before i, so each test is O(1) + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (q) { + if (q === '"' && ch === "\\") i++; + else if (q === "'" && ch === "'" && text[i + 1] === "'") i++; + else if (ch === q) q = null; + } else if ((ch === "'" || ch === '"') && valueStarts(text, last, i)) { + q = ch; + } else if (ch === "#" && (i === 0 || text[i - 1] === " " || text[i - 1] === "\t")) { + return text.slice(0, i).trimEnd(); + } + if (i < text.length && !/\s/.test(text[i])) last = i; + } + return text.trimEnd(); +} + +/** True when a value starts at i: only whitespace follows line start, `: `, `- `, `[` or `,`. */ +function valueStarts(text, last, i) { + if (last < 0) return true; + const c = text[last]; + const spaced = i - last > 1; + return c === "[" || c === "," || (spaced && c === ":") || (spaced && c === "-" && (last === 0 || /\s/.test(text[last - 1]))); +} + +const ESCAPES = { n: "\n", t: "\t", '"': '"', "\\": "\\", "/": "/" }; +const SOURCE_ESCAPES = { ...ESCAPES, n: "\\n" }; + +function unquote(text, line, source) { + const q = text[0]; + if (text.length < 2 || text.at(-1) !== q) throw new YamlSubsetError(line, "unterminated quoted string"); + const body = text.slice(1, -1); + if (q === "'") return body.replaceAll("''", "'"); + let out = ""; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === "\\" && i + 1 < body.length) { + const next = body[i + 1]; + if (!Object.hasOwn(ESCAPES, next)) throw new YamlSubsetError(line, `unsupported escape \\${next} in double-quoted string`); + out += (source ? SOURCE_ESCAPES : ESCAPES)[next]; + i++; + continue; + } + out += ch; + } + return out; +} + +function scalar(raw, line, source) { + const text = raw.trim(); + if (text === "") return null; + if (text[0] === "'" || text[0] === '"') { + const close = source ? closingQuote(text) : -1; + if (close !== -1 && close !== text.length - 1) throw new YamlSubsetError(line, "unexpected text after a value"); + return unquote(text, line, source); + } + if (text[0] === "{") throw new YamlSubsetError(line, "flow mapping ({...}) is outside the subset; use block style"); + if (text[0] === "&" || text[0] === "*") throw new YamlSubsetError(line, "anchors and aliases are outside the subset"); + if (text[0] === "!") throw new YamlSubsetError(line, "tags are outside the subset"); + if (["|", ">", "|-", ">-", "|+", ">+"].includes(text)) throw new YamlSubsetError(line, "block scalars (| and >) are outside the subset"); + if (text[0] === "[") return flowSequence(text, line, source); + if (/:[ \t]/.test(text)) throw new YamlSubsetError(line, "a plain value may not hold `: `; quote it"); + if (source) return text; + if (["null", "Null", "NULL", "~"].includes(text)) return null; + if (["true", "True", "TRUE"].includes(text)) return true; + if (["false", "False", "FALSE"].includes(text)) return false; + if (INT.test(text) || FLOAT.test(text)) return Number(text); + return text; +} + +function flowSequence(text, line, source) { + if (!text.endsWith("]")) throw new YamlSubsetError(line, "unterminated flow sequence"); + const body = text.slice(1, -1).trim(); + if (body === "") return []; + const items = []; + let current = ""; + let q = null; + let depth = 0; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (q) { + current += ch; + // A backslash escape in "..." and a doubled quote in '...' stay inside the item. + if ((q === '"' && ch === "\\") || (q === "'" && ch === "'" && body[i + 1] === "'")) current += body[++i] ?? ""; + else if (ch === q) q = null; + continue; + } + if (ch === "'" || ch === '"') q = ch; + else if (ch === "[") depth++; + else if (ch === "]") depth--; + else if (ch === "," && depth === 0) { + items.push(current); + current = ""; + continue; + } + current += ch; + } + items.push(current); + return items.map((raw) => { + const item = raw.trim(); + if (item.startsWith("[")) throw new YamlSubsetError(line, "nested flow sequences are outside the subset"); + return scalar(item, line, source); + }); +} + +/** The index of the quote closing the quoted text that opens at text[0], or -1. A doubled `''` in + * single quotes and a backslash escape in double quotes stay inside, as in unquote(). */ +function closingQuote(text) { + const q = text[0]; + for (let i = 1; i < text.length; i++) { + if (q === '"' && text[i] === "\\") i++; + else if (text[i] === q) { + if (q === "'" && text[i + 1] === "'") i++; + else return i; + } + } + return -1; +} + +/** [key, rest] when the content is a mapping entry, else null. */ +function splitKey(content, line, source) { + if (content[0] === "'" || content[0] === '"') { + const close = closingQuote(content); + if (close === -1) throw new YamlSubsetError(line, "unterminated quoted key"); + const key = unquote(content.slice(0, close + 1), line, source); + const rest = source ? content.slice(close + 1).replace(/^[ \t]+/, "") : content.slice(close + 1); + if (!rest.startsWith(":") || (rest.length > 1 && rest[1] !== " " && rest[1] !== "\t")) return null; + // A source-mode key is one step of a dotted record path, as in lib/yaml-subset.awk. + if (source && (key === "" || /[.\t]/.test(key))) throw new YamlSubsetError(line, "a key may not be empty or hold `.` or a tab"); + return [key, rest.slice(1).trim()]; + } + const split = plainKey(content) ?? (content.endsWith(":") ? [content.slice(0, -1).trim(), ""] : null); + // lib/yaml-subset.awk reads no other plain key, so the line is no mapping entry there: it falls + // through to a scalar, or to "expected a mapping entry" on the same line awk errors on. + if (split && source && !/^[A-Za-z0-9_-]+$/.test(split[0])) return null; + if (split && /[\s$.]/.test(split[0])) throw new YamlSubsetError(line, `a plain key may not hold a space, \`$\` or \`.\`; quote it: ${quote(split[0])}`); + return split; +} + +/** The Python grammar `^([^\s:[\]{}#][^:#]*?):(?:\s+(.*))?$` in one linear scan: the key runs to + * the first `:`, holds no `#`, and the `:` ends the line or is followed by whitespace and a rest + * with no line terminator after its leading whitespace. */ +function plainKey(content) { + if (content === "" || /[\s:[\]{}#]/.test(content[0])) return null; + const colon = content.indexOf(":"); + if (colon === -1 || content.slice(0, colon).includes("#")) return null; + const key = content.slice(0, colon).trim(); + const after = content.slice(colon + 1); + if (after === "") return [key, ""]; + if (!/^\s/.test(after)) return null; + const rest = after.trimStart(); + if (/[\n\r\u2028\u2029]/.test(rest)) return null; + return [key, rest.trim()]; +} + +class Parser { + constructor(text, source = false) { + this.source = source; + this.lines = []; // [indent, lineNumber, content] + text + .replace(/^/, "") + .split(/\r\n|\r|\n/) + .forEach((raw, i) => { + const number = i + 1; + if (raw.startsWith("---") || raw.startsWith("...")) throw new YamlSubsetError(number, "document markers are outside the subset"); + const stripped = stripComment(raw); + if (!stripped.trim()) return; + const indent = stripped.length - stripped.replace(/^ +/, "").length; + if (stripped[indent] === "\t") throw new YamlSubsetError(number, "tab indentation is outside the subset"); + this.lines.push([indent, number, stripped.slice(indent)]); + }); + this.pos = 0; + this.depth = 0; + } + + parse() { + if (!this.lines.length) return null; + if (this.source && isItem(this.lines[0][2])) throw new YamlSubsetError(this.lines[0][1], "a root sequence is not part of the subset"); + const value = this.block(this.lines[0][0]); + if (this.pos < this.lines.length) { + const [indent, number, content] = this.lines[this.pos]; + throw new YamlSubsetError(number, `unexpected content at indent ${indent}: ${quote(content)}`); + } + return value; + } + + block(indent) { + const [, number, content] = this.lines[this.pos]; + if (isItem(content)) return this.sequence(indent); + if (splitKey(content, number, this.source) === null) throw new YamlSubsetError(number, `expected a mapping entry or sequence item, got ${quote(content)}`); + return this.mapping(indent); + } + + /** The block mapping at `indent`, its entries put into `result` (a sequence item's dash-line entry + * arrives already in it, so a duplicate is reported on its own line). */ + mapping(indent, result = this.map()) { + while (this.pos < this.lines.length) { + const [lineIndent, number, content] = this.lines[this.pos]; + if (lineIndent < indent) break; + if (lineIndent > indent) throw new YamlSubsetError(number, `unexpected indent ${lineIndent} (expected ${indent})`); + const split = splitKey(content, number, this.source); + if (split === null) throw new YamlSubsetError(number, `expected a mapping entry, got ${quote(content)}`); + const [key, rest] = split; + if (result instanceof Map ? result.has(key) : Object.hasOwn(result, key)) throw new YamlSubsetError(number, `duplicate key ${quote(key)}`); + this.pos++; + this.set(result, key, rest === "" ? this.nested(indent) : scalar(rest, number, this.source)); + } + return result; + } + + nested(parentIndent) { + if (this.pos < this.lines.length) { + const [childIndent, number, content] = this.lines[this.pos]; + if (childIndent < parentIndent || (childIndent === parentIndent && !isItem(content))) return null; + if (this.depth === MAX_DEPTH) throw new YamlSubsetError(number, `nesting deeper than ${MAX_DEPTH} levels is outside the subset`); + this.depth++; + try { + // A sequence may sit at the parent's indent (`key:` then `- item`). + return childIndent > parentIndent ? this.block(childIndent) : this.sequence(childIndent); + } finally { + this.depth--; + } + } + return null; + } + + sequence(indent) { + const result = []; + while (this.pos < this.lines.length) { + const [lineIndent, number, content] = this.lines[this.pos]; + if (lineIndent < indent || !isItem(content)) break; + if (lineIndent > indent) throw new YamlSubsetError(number, `unexpected indent ${lineIndent} (expected ${indent})`); + const item = content.slice(1).trim(); + if (this.source && isItem(item)) throw new YamlSubsetError(number, "a sequence nested on one line is not part of the subset"); + this.pos++; + if (item === "") { + // Only a deeper line belongs to an empty item; a `- ` line at this indent is its sibling. + const next = this.lines[this.pos]; + result.push(next && next[0] > indent ? this.nested(indent) : null); + continue; + } + // An item opening a quote it never closes is a quoted scalar, which scalar() reads or rejects. + const split = (item[0] === "'" || item[0] === '"') && closingQuote(item) === -1 ? null : splitKey(item, number, this.source); + if (split === null) { + result.push(scalar(item, number, this.source)); + continue; + } + // A mapping whose first entry sits on the dash line; the rest of its entries are indented + // to the item column. + const itemIndent = indent + 2; + const [key, rest] = split; + const mapping = this.set(this.map(), key, rest === "" ? this.nested(itemIndent) : scalar(rest, number, this.source)); + if (this.pos < this.lines.length && this.lines[this.pos][0] === itemIndent) this.mapping(itemIndent, mapping); + result.push(mapping); + } + return result; + } + + map() { + return this.source ? new Map() : {}; + } + + set(mapping, key, value) { + return mapping instanceof Map ? mapping.set(key, value) : put(mapping, key, value); + } +} + +/** Parse a document written in the subset; throw YamlSubsetError otherwise. */ +export const parse = (text, { source = false } = {}) => new Parser(text, source).parse(); diff --git a/plugins/discovery/lib/config-cascade.mjs b/plugins/discovery/lib/config-cascade.mjs new file mode 100644 index 00000000000..b5f22dc12e2 --- /dev/null +++ b/plugins/discovery/lib/config-cascade.mjs @@ -0,0 +1,571 @@ +// GENERATED from lib/config-cascade.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// The four-layer plugin config cascade (docs/adr/0075): later layers win per key. +// +// resolve({plugin, projectRoot, home, schema, defaults, userConfig, teamOnly, floors, userHome}) +// -> {values, provenance: {"a.b": layer | [layers]}, prose: [paths], sections: [{heading, text, layer}], +// layers: [{name, path, state: "loaded" | "absent" | "invalid", errors, error_keys}], legacy: [{path, kind}]} +// +// readLayers({...}) -> each layer's own accepted values, unmerged (see its comment below) +// +// `error_keys[i]` tags `errors[i]`: the dotted key the error names (key-level), or null when it is +// about the layer as a whole (file-level: a file refused or unparsable, a value that is not a mapping). +// +// Layers, in order: "defaults" (the `defaults` object, already parsed by the caller), "userConfig" +// (the plugin's userConfig values, flattened names), "team" (/) and "local", the +// personal overlay (/.local). A file layer reads .yaml for config and .md +// for prose; it is absent when neither file exists. `home` is the convention home the caller +// resolved, relative to projectRoot unless absolute (default docs/conventions). There is no +// user-global file layer: a file under /docs/conventions is never read, and `userHome` +// (default os.homedir()) only locates the legacy files reported in `legacy`. `schema` is a JSON +// Schema object; the subset honored is type (object, string, array, null, integer, number, boolean), +// enum, properties, additionalProperties (false or a schema), items, minimum, maximum, minItems and +// maxItems. +// +// A key is a schema leaf: any node that is not an object with `properties`, or a path named in +// `teamOnly`. The setup writer's top-level `written_against` and `provenance` records are not keys, +// declared or not: every layer skips them, so they reach neither `values` nor a userConfig name. +// Each layer's leaves are checked one by one; a leaf whose value fails the schema is +// dropped from that layer with an error naming layer, file and key, and a lower layer's value is +// then never used (ADR 0075, the invalid-value rule): a valid higher layer still wins, otherwise the +// key takes its default. The personal overlay is the one exception, because it refines the team +// file: an invalid personal value leaves the team value standing. A key containing `.` is +// rejected the same way, since keys are joined with `.` into paths, and so are `__proto__`, +// `constructor` and `prototype`, at any depth inside a value too, which a caller's merge would treat +// as more than data. A refused file makes its layer invalid; the layer's other file still applies. The team and +// local layers come from the repository: each reads only regular files, never a symlink, and is +// invalid when a relative `home` resolves outside projectRoot. A team-only key is +// accepted in "defaults" and "team" and rejected elsewhere. A YAML file that does not parse, or +// whose top level is not a mapping, makes its layer invalid and contributes no values; its .md +// file still supplies prose. A leaf whose last segment is `disable` and whose value is a list is the +// union of every layer's list, and its provenance is an array of the layer names that set it, in +// layer order; every other key's provenance is a single layer-name string. +// +// Policy floors. `floors` maps a key path to a `Floor direction` value (the Implementers table of +// docs/contracts/config-cascade/README.md; the closed set is no-floor, team-only, lowest-value, +// any-layer-wins and stricter-value), or to {direction, scale} where stricter-value takes a scale +// from loosest to strictest (default: the leaf's schema `enum` order). When the team layer set a +// floor key, a personal value is accepted only if it is no looser in that direction: team-only takes +// an equal value (a list must keep every team item); lowest-value a number no higher; any-layer-wins +// a tightening value (`true` or "report") whenever the team value is one; stricter-value a value at +// or beyond the team value on the scale. A looser value is dropped with an error naming the layer, +// file and key, and the team value stands. Floors bind the personal overlay; a `*.disable` union key +// is not compared. +// +// userConfig maps each schema leaf path to its name joined with `_` (widget.limit is widget_limit); +// two leaves with one name throw. An empty string or an unrendered `${user_config.` placeholder is +// unset, names outside the schema are ignored, and a list, mapping or team-only key is rejected. +// A string value takes the leaf's integer, number or boolean type when the leaf does not accept +// strings. Legacy locations (.claude/.* under userHome and projectRoot, and a fenced +// `yaml config` block in the team .md) are reported in `legacy`, never read for values. +// +// Prose. A `.local.md` H2 section (a `## ` heading outside a code fence) replaces the team `.md` +// section with the same heading; every other team section is kept, a local section with a new +// heading follows them, and the team text before the first H2 is kept. `sections` lists the result in +// order, each with the layer it came from (`heading` is null for text before the first H2). +// This module reads files only. File contents are data; error messages quote them as JSON strings. +import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"; +import { parse } from "./yaml-subset.mjs"; + +const PLUGIN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SCALARS = ["string", "integer", "number", "boolean", "null"]; +const DIRECTIONS = ["no-floor", "team-only", "lowest-value", "any-layer-wins", "stricter-value"]; +const clip = (text) => (text.length > 60 ? `${text.slice(0, 60)}...` : text); +const quote = (text) => JSON.stringify(clip(text)); +const show = (value) => clip(JSON.stringify(value) ?? String(value)); +const put = (object, key, value) => Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true }); +const isMapping = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const RESERVED = new Set(["__proto__", "constructor", "prototype"]); +const WRITER_KEYS = new Set(["written_against", "provenance"]); +const isWriterKey = (parts, key) => !parts.length && WRITER_KEYS.has(key); +const newLayer = (name, path, state, errors = [], error_keys = errors.map(() => null)) => ({ name, path, state, errors, error_keys }); +/** Records an error on `layer`, tagged with the dotted key it names, or null for a file-level one. */ +const record = (layer, message, key = null) => { + layer.errors.push(message); + layer.error_keys.push(key); +}; + +/** {ok} for a readable layer file, {refused: why} for one that must not be read, {} when absent. A + * repository layer refuses a symlink, which could reach a file outside the checkout; every layer + * refuses anything but a regular file, since a device or FIFO never finishes reading. */ +function fileState(path, repo) { + let st; + try { + st = lstatSync(path); + } catch { + return {}; + } + if (st.isSymbolicLink()) { + if (repo) return { refused: "is a symlink; a repository layer reads only regular files" }; + try { + st = statSync(path); + } catch { + return {}; + } + } + return st.isFile() ? { ok: true } : { refused: "is not a regular file" }; +} + +/** True when `dir` exists and its real path lies outside the real path of `root`. */ +function escapes(dir, root) { + try { + const rel = relative(realpathSync(root), realpathSync(dir)); + return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel); + } catch { + return false; + } +} +const typesOf = (node) => (node.type === undefined ? null : [node.type].flat()); + +const fits = { + object: isMapping, + array: Array.isArray, + string: (v) => typeof v === "string", + integer: Number.isInteger, + number: (v) => typeof v === "number" && Number.isFinite(v), + boolean: (v) => typeof v === "boolean", + null: (v) => v === null, +}; + +/** The schema for `key` under `node`: a schema, null when anything goes, false when it is unknown. */ +function child(node, key) { + if (isMapping(node.properties) && Object.hasOwn(node.properties, key)) return node.properties[key]; + if (node.additionalProperties === false) return false; + return isMapping(node.additionalProperties) ? node.additionalProperties : null; +} + +const isBranch = (node, path, teamOnly) => + isMapping(node) && isMapping(node.properties) && !teamOnly.has(path) && (typesOf(node)?.includes("object") ?? true); + +/** Why `value` does not fit `node`, as messages relative to the key; empty when it fits. */ +function check(value, node, at = "") { + const prefix = at ? `${at}: ` : ""; + const types = typesOf(node); + if (types && !types.some((t) => fits[t]?.(value))) return [`${prefix}expected ${types.join(" or ")}, got ${show(value)}`]; + if (Array.isArray(node.enum) && !node.enum.includes(value)) return [`${prefix}${show(value)} is not one of ${node.enum.map(show).join(", ")}`]; + if (typeof value === "number" && typeof node.minimum === "number" && value < node.minimum) return [`${prefix}${show(value)} must be at least ${node.minimum}`]; + if (typeof value === "number" && typeof node.maximum === "number" && value > node.maximum) return [`${prefix}${show(value)} must be at most ${node.maximum}`]; + if (Array.isArray(value) && typeof node.minItems === "number" && value.length < node.minItems) return [`${prefix}needs at least ${node.minItems} items, got ${value.length}`]; + if (Array.isArray(value) && typeof node.maxItems === "number" && value.length > node.maxItems) return [`${prefix}allows at most ${node.maxItems} items, got ${value.length}`]; + // An open node ({}) checks nothing but reserved names, which are refused at every depth. + if (Array.isArray(value)) return value.flatMap((item, i) => check(item, isMapping(node.items) ? node.items : {}, `${at}[${i}]`)); + if (!isMapping(value)) return []; + return Object.keys(value).flatMap((key) => { + const where = `${at}.${quote(key)}`; + if (RESERVED.has(key)) return [`${where}: a reserved name, never a config key`]; + const sub = child(node, key); + if (sub === false) return [`${where}: unknown key`]; + return check(value[key], sub ?? {}, where); + }); +} + +/** The accepted leaves of one layer's mapping, as [parts, value] pairs; rejections go to `fail` + * with the dotted key, and `true` third when the value itself was the problem. */ +function leaves(data, schema, { teamOnly, allowTeamOnly, fail }) { + const out = []; + const walk = (object, node, parts) => { + for (const key of Object.keys(object)) { + if (isWriterKey(parts, key)) continue; + const path = [...parts, key]; + const dotted = path.join("."); + const sub = child(node, key); + const value = object[key]; + if (RESERVED.has(key)) fail(`${quote(dotted)}: a reserved name, never a config key`, dotted); + else if (key.includes(".")) fail(`${quote(dotted)}: a key may not contain "."; nest it instead`, dotted); + else if (sub === false) fail(`${quote(dotted)}: unknown key`, dotted); + else if (teamOnly.has(dotted) && !allowTeamOnly) fail(`${quote(dotted)}: accepted only in the team layer`, dotted); + else if (sub && isBranch(sub, dotted, teamOnly)) { + if (isMapping(value)) walk(value, sub, path); + else if (value !== null) fail(`${quote(dotted)}: expected a mapping, got ${show(value)}`, dotted, true); + } else { + const problems = check(value, sub ?? {}); + if (problems.length) fail(`${quote(dotted)}: ${problems.join("; ")}`, dotted, true); + else out.push([path, value]); + } + } + }; + walk(data, schema, []); + return out; +} + +/** Flattened userConfig name to {path, node} for every schema leaf. */ +function flatNames(schema, teamOnly) { + const names = new Map(); + const walk = (node, parts) => { + for (const key of Object.keys(isMapping(node.properties) ? node.properties : {})) { + if (isWriterKey(parts, key)) continue; + const path = [...parts, key]; + const dotted = path.join("."); + const sub = node.properties[key]; + if (isBranch(sub, dotted, teamOnly)) { + walk(sub, path); + continue; + } + const name = path.join("_"); + if (names.has(name)) throw new Error(`config-cascade: schema keys ${names.get(name).dotted} and ${dotted} both flatten to userConfig name ${name}`); + names.set(name, { path, dotted, node: sub }); + } + }; + walk(schema, []); + return names; +} + +function coerce(raw, node) { + const types = typesOf(node) ?? []; + if (typeof raw !== "string" || types.includes("string")) return raw; + if ((types.includes("integer") || types.includes("number")) && /^[-+]?\d+(\.\d+)?$/.test(raw)) return Number(raw); + if (types.includes("boolean") && (raw === "true" || raw === "false")) return raw === "true"; + return raw; +} + +function userConfigLeaves(userConfig, names, teamOnly, fail) { + const out = []; + for (const [name, raw] of Object.entries(userConfig)) { + if (raw === undefined || raw === null || raw === "" || (typeof raw === "string" && raw.startsWith("${user_config."))) continue; + const leaf = names.get(name); + if (!leaf) continue; + const types = typesOf(leaf.node); + if (teamOnly.has(leaf.dotted)) fail(`${quote(leaf.dotted)}: accepted only in the team layer`, leaf.dotted); + else if (types && !types.every((t) => SCALARS.includes(t))) fail(`${quote(leaf.dotted)}: lists and mappings are set only in files`, leaf.dotted); + else { + const value = coerce(raw, leaf.node); + const problems = check(value, leaf.node); + if (problems.length) fail(`${quote(leaf.dotted)}: ${problems.join("; ")}`, leaf.dotted, true); + else out.push([leaf.path, value]); + } + } + return out; +} + +/** One file layer: its report entry, its parsed mapping (or null), and its prose path (or null). */ +function readLayer(name, stem, repo) { + const path = `${stem}.yaml`; + const md = `${stem}.md`; + const layer = newLayer(name, path, "absent"); + const mdFile = fileState(md, repo); + if (mdFile.refused) { + layer.state = "invalid"; + record(layer, `${name} (${md}): ${mdFile.refused}`); + } + const prose = mdFile.ok ? md : null; + if (prose) layer.state = "loaded"; + const yamlFile = fileState(path, repo); + if (yamlFile.refused) { + layer.state = "invalid"; + record(layer, `${name} (${path}): ${yamlFile.refused}`); + } + if (!yamlFile.ok) return { layer, data: null, prose }; + return { layer, data: parseInto(layer, () => readFileSync(path, "utf8")), prose }; +} + +/** The mapping `text()` parses to, loading `layer`; null, with the layer invalid, when it does not parse or is not a mapping. */ +function parseInto(layer, text) { + try { + const doc = parse(text()) ?? {}; + if (!isMapping(doc)) throw new Error("the top level must be a mapping"); + if (layer.state !== "invalid") layer.state = "loaded"; + return doc; + } catch (e) { + layer.state = "invalid"; + record(layer, `${layer.name} (${layer.path}): ${e.message}`); + return null; + } +} + +/** True when the Markdown file holds a ``` config block outside any other fence. */ +function hasConfigBlock(path) { + if (!fileState(path, true).ok) return false; + let text; + try { + text = readFileSync(path, "utf8"); + } catch { + return false; + } + let fence = null; // the open fence's marker run, backticks or tildes + for (const line of text.split(/\r\n|\r|\n/)) { + const run = line.match(/^(`{3,}|~{3,})/)?.[0]; + if (fence) { + if (run?.[0] === fence[0] && run.length >= fence.length && line.trimEnd().length === run.length) fence = null; + } else if (/^```[A-Za-z0-9_-]+ config\s*$/.test(line)) return true; + else if (run) fence = run; + } + return false; +} + +/** A Markdown text as [{heading, text}], split at each H2 outside a code fence; the first entry holds + * the text before the first H2 (heading null) and is dropped when empty. */ +function splitSections(text) { + const out = [{ heading: null, lines: [] }]; + let fence = null; + for (const line of text.split(/(?<=\n)/)) { + const run = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1]; + if (fence) { + if (run?.[0] === fence[0] && run.length >= fence.length && line.trim().length === run.length) fence = null; + } else if (run) fence = run; + else { + const h = line.match(/^## +(.*?)(?: +#+)?\s*$/); + if (h) { + out.push({ heading: h[1].replace(/\s+/g, " ").trim(), lines: [line] }); + continue; + } + } + out.at(-1).lines.push(line); + } + return out.map(({ heading, lines }) => ({ heading, text: lines.join("") })).filter((s) => s.heading !== null || s.text.trim() !== ""); +} + +function readText(path) { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** The team sections with each same-heading local section in its place, then the local sections the + * team lacks. The first team section with a heading is replaced; a later team repeat of it is dropped. */ +function mergeSections(teamText, localText) { + const team = teamText === null ? [] : splitSections(teamText).map((s) => ({ ...s, layer: "team" })); + const local = localText === null ? [] : splitSections(localText).map((s) => ({ ...s, layer: "local" })); + const replacement = new Map(local.filter((s) => s.heading !== null).map((s) => [s.heading, s])); + const used = new Set(); + const out = []; + for (const s of team) { + if (s.heading === null || !replacement.has(s.heading)) out.push(s); + else if (!used.has(s.heading)) { + used.add(s.heading); + out.push(replacement.get(s.heading)); + } + } + const teamHeadings = new Set(team.map((s) => s.heading)); + for (const s of local) { + // Text before the first H2 follows the team's own only when the team has no file; a repeated heading keeps its last section. + const fresh = s.heading === null ? team.length === 0 : !teamHeadings.has(s.heading) && replacement.get(s.heading) === s; + if (fresh) out.push(s); + } + return out; +} + +function legacyFiles(dir, plugin, kind) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + return entries.filter((e) => e.isFile() && e.name.startsWith(`${plugin}.`)).map((e) => ({ path: join(dir, e.name), kind: kind(e.name) })); +} + +function setPath(object, parts, value) { + let at = object; + for (const part of parts.slice(0, -1)) { + if (!Object.hasOwn(at, part) || !isMapping(at[part])) put(at, part, {}); + at = at[part]; + } + put(at, parts.at(-1), value); +} + +/** Validated floors: dotted key to {direction, scale}. */ +function normalizeFloors(floors) { + if (!isMapping(floors)) throw new Error("config-cascade: floors must be a mapping of key path to Floor direction"); + const out = new Map(); + for (const [key, raw] of Object.entries(floors)) { + const direction = typeof raw === "string" ? raw : raw?.direction; + if (!DIRECTIONS.includes(direction)) throw new Error(`config-cascade: floor ${JSON.stringify(key)} has direction ${show(direction)}, not one of ${DIRECTIONS.join(", ")}`); + out.set(key, { direction, scale: isMapping(raw) && Array.isArray(raw.scale) ? raw.scale : null }); + } + return out; +} + +function nodeAt(schema, parts) { + let node = schema; + for (const part of parts) { + node = node && child(node, part); + if (!node) return null; + } + return node; +} + +const tightening = (v) => v === true || v === "report"; + +/** Why `personal` loosens `team` in the floor's direction, or null when it does not. */ +function looser(floor, personal, team, node) { + if (JSON.stringify(personal) === JSON.stringify(team)) return null; + switch (floor.direction) { + case "team-only": + if (Array.isArray(personal) && Array.isArray(team) && team.every((t) => personal.some((p) => JSON.stringify(p) === JSON.stringify(t)))) return null; + return `${show(personal)} differs from the team value ${show(team)}, and the team value wins`; + case "lowest-value": + if (typeof personal === "number" && typeof team === "number" && personal <= team) return null; + return `${show(personal)} is not lower than or equal to the team value ${show(team)}`; + case "any-layer-wins": + if (!tightening(team) || tightening(personal)) return null; + return `${show(personal)} drops the team value ${show(team)}, which is the tightening one`; + case "stricter-value": { + const scale = floor.scale ?? node?.enum ?? []; + const a = scale.findIndex((v) => JSON.stringify(v) === JSON.stringify(personal)); + const b = scale.findIndex((v) => JSON.stringify(v) === JSON.stringify(team)); + if (a >= 0 && b >= 0 && a >= b) return null; + return `${show(personal)} is not as strict as the team value ${show(team)} on the scale ${show(scale)}`; + } + default: + return null; + } +} + +export function resolve({ plugin, projectRoot, home = "docs/conventions", schema, defaults, userConfig, teamOnly = [], floors = {}, userHome = homedir() }) { + if (typeof plugin !== "string" || !PLUGIN.test(plugin)) throw new Error(`config-cascade: plugin name ${JSON.stringify(plugin)} is not a plain name`); + const only = new Set(teamOnly); + const floorMap = normalizeFloors(floors); + const names = flatNames(schema, only); + const homeDir = resolvePath(projectRoot, home); + // A relative home is inside the checkout by intent; a committed symlink must not carry it out. + const homeEscapes = !isAbsolute(home) && escapes(homeDir, projectRoot); + const layers = []; + const contributions = []; // [layer name, [[parts, value]], dotted keys whose value was invalid] + const prose = []; + const proseText = {}; + + // pairsOf(fail) returns the layer's accepted [parts, value] pairs; falsy when it contributes none. + const take = (layer, pairsOf) => { + const invalid = new Set(); + const fail = (message, key, valueFailed = false) => { + record(layer, `${layer.name}${layer.path ? ` (${layer.path})` : ""}: ${message}`, key); + if (valueFailed) invalid.add(key); + }; + if (pairsOf) contributions.push([layer.name, pairsOf(fail), invalid]); + layers.push(layer); + }; + const fileLeaves = (data, allowTeamOnly) => data && ((fail) => leaves(data, schema, { teamOnly: only, allowTeamOnly, fail })); + + const shaped = (name, given) => { + if (given === undefined || given === null) return { layer: newLayer(name, null, "absent"), ok: false }; + if (isMapping(given)) return { layer: newLayer(name, null, "loaded"), ok: true }; + return { layer: newLayer(name, null, "invalid", [`${name}: expected a mapping, got ${show(given)}`]), ok: false }; + }; + const d = shaped("defaults", defaults); + take(d.layer, d.ok && fileLeaves(defaults, true)); + const u = shaped("userConfig", userConfig); + take(u.layer, u.ok && ((fail) => userConfigLeaves(userConfig, names, only, fail))); + for (const [name, stem] of [ + ["team", join(homeDir, plugin)], + ["local", join(homeDir, `${plugin}.local`)], + ]) { + if (homeEscapes) { + const why = "the convention home resolves outside the project; a repository layer stays inside it"; + take(newLayer(name, `${stem}.yaml`, "invalid", [`${name} (${homeDir}): ${why}`])); + continue; + } + const read = readLayer(name, stem, true); + take(read.layer, fileLeaves(read.data, name === "team")); + if (read.prose) { + prose.push(read.prose); + proseText[name] = readText(read.prose); + } + } + + const merged = new Map(); // dotted -> {parts, value, from} + let base = new Map(); // the defaults layer's entries, where an invalid higher value falls back to + const within = (dotted, key) => dotted === key || dotted.startsWith(`${key}.`); + for (const [name, pairs, invalid] of contributions) { + // A lower layer's value is never used for an invalid one; the personal overlay refines the team file, so its keys fall to team. + if (name !== "local") { + for (const key of invalid) { + for (const dotted of [...merged.keys()]) if (within(dotted, key)) merged.delete(dotted); + for (const [dotted, entry] of base) if (within(dotted, key)) merged.set(dotted, entry); + } + } + const layer = layers.find((l) => l.name === name); + for (const [parts, value] of pairs) { + const dotted = parts.join("."); + const prior = merged.get(dotted); + const floor = floorMap.get(dotted); + const union = parts.at(-1) === "disable" && Array.isArray(value); + if (name === "local" && floor && floor.direction !== "no-floor" && !union && prior && [prior.from].flat().includes("team")) { + const why = looser(floor, value, prior.value, nodeAt(schema, parts)); + if (why) { + record(layer, `local (${layer.path}): ${quote(dotted)}: loosens the ${floor.direction} policy floor: ${why}`, dotted); + continue; + } + } + if (union) { + const items = Array.isArray(prior?.value) ? [...prior.value] : []; + const seen = new Set(items.map((i) => JSON.stringify(i))); + for (const item of value) { + const key = JSON.stringify(item); + if (!seen.has(key)) items.push(item); + seen.add(key); + } + // An empty list adds nothing, so its layer is credited only when no layer set the key before. + const from = Array.isArray(prior?.from) ? prior.from : []; + merged.set(dotted, { parts, value: items, from: value.length > 0 || !prior ? [...from, name] : from }); + } else merged.set(dotted, { parts, value, from: name }); + } + if (name === "defaults") base = new Map(merged); + } + const values = {}; + const provenance = {}; + for (const [dotted, { parts, value, from }] of merged) { + setPath(values, parts, value); + put(provenance, dotted, from); + } + + const legacy = legacyOf(plugin, userHome, projectRoot, !homeEscapes && `${join(homeDir, plugin)}.md`); + const sections = mergeSections(proseText.team ?? null, proseText.local ?? null).map(({ heading, text, layer }) => ({ heading, text, layer })); + return { values, provenance, prose, sections, layers, legacy }; +} + +/** The legacy locations that exist; `teamMd` (or false) is the team .md checked for a fenced config block. */ +function legacyOf(plugin, userHome, projectRoot, teamMd) { + const found = [ + ...legacyFiles(join(userHome, ".claude"), plugin, () => "claude-user"), + ...(projectRoot ? legacyFiles(join(projectRoot, ".claude"), plugin, (n) => (n.startsWith(`${plugin}.local.`) ? "claude-local" : "claude-project")) : []), + ...(teamMd && hasConfigBlock(teamMd) ? [{ path: teamMd, kind: "fenced-block" }] : []), + ]; + // The project root may be the user home, so a file is reported under its first kind only. + return found.filter((entry, i) => found.findIndex((e) => e.path === entry.path) === i); +} + +/** + * Each layer's own accepted values, unmerged, for a caller that weighs the layers itself (the + * rendered-views publish rule reads every layer separately: lib/view-settings.mjs). The same reader, + * refusals and schema check as resolve(), with no precedence, floors or invalid-value fallback: an + * invalid value leaves only its own layer. Layers: "userConfig", then "team" and "local" when + * projectRoot is set. `team`, as {path, text}, replaces the working-tree team file (text null: + * absent), for a caller reading it from a git ref. + * readLayers({plugin, projectRoot, home, schema, userConfig, teamOnly, team, userHome}) + * -> {layers: [{name, path, state, errors, error_keys, values: {"a.b": value}}], legacy} + */ +export function readLayers({ plugin, projectRoot = null, home = "docs/conventions", schema, userConfig, teamOnly = [], team, userHome = homedir() }) { + if (typeof plugin !== "string" || !PLUGIN.test(plugin)) throw new Error(`config-cascade: plugin name ${JSON.stringify(plugin)} is not a plain name`); + const only = new Set(teamOnly); + const layers = []; + const add = (layer, pairsOf) => { + const fail = (message, key) => record(layer, `${layer.name}${layer.path ? ` (${layer.path})` : ""}: ${message}`, key); + const values = {}; + for (const [parts, value] of pairsOf ? pairsOf(fail) : []) put(values, parts.join("."), value); + layers.push({ ...layer, values }); + }; + if (isMapping(userConfig)) add(newLayer("userConfig", null, "loaded"), (fail) => userConfigLeaves(userConfig, flatNames(schema, only), only, fail)); + if (!projectRoot) return { layers, legacy: legacyOf(plugin, userHome, null, false) }; + const homeDir = resolvePath(projectRoot, home); + const homeEscapes = !isAbsolute(home) && escapes(homeDir, projectRoot); + for (const [name, stem] of [ + ["team", join(homeDir, plugin)], + ["local", join(homeDir, `${plugin}.local`)], + ]) { + const fileLeaves = (data) => data && ((fail) => leaves(data, schema, { teamOnly: only, allowTeamOnly: name === "team", fail })); + if (name === "team" && team) { + const layer = newLayer(name, team.path, "absent"); + add(layer, typeof team.text === "string" && fileLeaves(parseInto(layer, () => team.text))); + } else if (homeEscapes) { + add(newLayer(name, `${stem}.yaml`, "invalid", [`${name} (${homeDir}): the convention home resolves outside the project; a repository layer stays inside it`])); + } else { + const read = readLayer(name, stem, true); + add(read.layer, fileLeaves(read.data)); + } + } + return { layers, legacy: legacyOf(plugin, userHome, projectRoot, !homeEscapes && `${join(homeDir, plugin)}.md`) }; +} diff --git a/plugins/discovery/lib/state-key.sh b/plugins/discovery/lib/state-key.sh new file mode 100755 index 00000000000..78a36277953 --- /dev/null +++ b/plugins/discovery/lib/state-key.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# GENERATED from lib/state-key.sh by scripts/sync-shared-copies.sh. Do not edit this copy: +# edit the canonical source, then rerun the script. +# Per-project state key for anything a plugin writes under ${CLAUDE_PLUGIN_DATA}. +# +# WHY. ${CLAUDE_PLUGIN_DATA} is keyed to the plugin identifier and nothing else: +# https://code.claude.com/docs/en/plugins/manifest-reference#environment-variables +# (as of 2026-10-11; recheck when that section adds a project, checkout, +# worktree or session segment to the path). So a skill that writes a fixed filename there has one file per +# MACHINE: every run from every repository overwrites the last, and a skill that +# READS it back can serve one project's findings as another's. +# +# This prints the missing segment. The scheme is canonically defined by the +# marketplace's plugin-data-report-keying convention (rule 1); this header keeps +# a named operational duplicate of that definition so the executable ships +# self-described: +# +# = / +# +# repo-identity the first configured remote URL normalized to +# host/owner/repo, lowercased, scheme/credentials/.git +# stripped. No remote -> local/. +# Not a repo at all -> nonrepo/. +# worktree-discriminator sha256 of the canonicalized worktree root, cut to 8 +# (outside a repository, of the physical cwd). Two +# worktrees of one repository legitimately hold +# different content and must not share a report. +# +# Every path is taken physical (symlinks resolved) before it is hashed. `cd` +# keeps the logical spelling a symlink was reached through, and the read-back +# consumers derive the key from wherever the operator launched, so one +# directory reached two ways must still produce one key. git canonicalizes +# the root it reports; the non-repository rung has to do it itself. +# +# SECURITY — this is why the identity is validated rather than merely lowercased. +# A remote URL is arbitrary text that becomes DIRECTORY COMPONENTS in the caller's +# path. A remote of `../../../etc` would walk a report out of the plugin's own +# namespace. Any identity that is not a plain lowercase segment path is replaced +# by a hash, so it still keys deterministically and still stays inside the +# namespace. Every rejected shape is covered by state-key.test.sh. +# +# NOT a substitute for a per-run filename. Keying stops cross-project collision; +# it does not stop a same-project rerun overwriting yesterday's report. A caller +# that needs history writes one file per run under this key and appends a line +# to a history file — see docs/contracts/plugin-data-report-keying/. +# +# Usage: +# state-key.sh [--root ] [--explain] +# +# --root derive for that directory instead of the current one +# --explain write the rung taken and its inputs to stderr +# +# Exit: 0 always on a successful derivation — every input reaches some rung, so +# there is no "cannot key" outcome. 2 on a bad argument, an unusable --root, or +# a PATH with neither sha256sum nor shasum. +# +# Shared source: lib/state-key.sh at the marketplace repo root is the canonical source; the copies in the plugins +# that carry it are generated by scripts/sync-shared-copies.sh and registered in +# scripts/shared-copies.txt. Edit this file and rerun the script. + +set -uo pipefail + +# `cd` consults CDPATH when the operand is relative and does not start with `.`, +# and on a hit it ECHOES the resolved path to stdout and lands somewhere the +# caller never named. Either one breaks this script: stdout must be exactly the +# key, and the key must describe the requested directory. Clear it once, here. +CDPATH= + +usage() { + cat <<'EOF' +state-key.sh — per-project state key for ${CLAUDE_PLUGIN_DATA} writes. + +Prints / for a directory, so a report +persisted under the plugin data directory belongs to one project rather than to +the machine. + +Usage: + state-key.sh [--root ] [--explain] [--help] + + --root derive for that directory instead of the current one + --explain write the rung taken and its inputs to stderr + +Exit: 0 on a derivation; 2 on a bad argument, an unusable --root, or no hash tool. +EOF +} + +ROOT_ARG="" +EXPLAIN=0 +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --root) + if [[ $# -lt 2 ]]; then + echo "ERROR: --root needs a path" >&2 + exit 2 + fi + ROOT_ARG="$2" + shift 2 + ;; + --explain) + EXPLAIN=1 + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -n "$ROOT_ARG" ]]; then + if [[ ! -d "$ROOT_ARG" ]]; then + echo "ERROR: --root is not a directory: $ROOT_ARG" >&2 + exit 2 + fi + cd "$ROOT_ARG" || exit 2 +fi +# Physical cwd, forklessly: `cd -P .` rewrites $PWD with symlinks resolved. +cd -P . || exit 2 + +# sha256sum is absent on stock macOS; shasum -a 256 is the portable partner. +# Probe here in the main script, not inside sha256(). hash12/hash8 run that +# helper as a non-last pipeline stage inside a command substitution, and POSIX +# XCU 2.6.3 plus Bash's pipeline rule both execute that in a subshell, so +# `exit 2` there cannot terminate this process. Under `set -uo pipefail` (no +# -e) the script would then print a malformed key at exit 0. +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256; } +else + echo "ERROR: no sha256sum or shasum on PATH — cannot derive a state key" >&2 + exit 2 +fi + +hash12() { printf '%s' "$1" | sha256 | cut -c1-12; } +hash8() { printf '%s' "$1" | sha256 | cut -c1-8; } + +# "the FIRST configured remote" — not necessarily one named `origin`. A repo +# whose only remote is `upstream` still has a remote and must not fall through +# to the local rung. +remote_name=$(git remote 2>/dev/null | tr -d '\r' | head -1) +remote="" +if [[ -n "$remote_name" ]]; then + remote=$(git config --get "remote.${remote_name}.url" 2>/dev/null | tr -d '\r') +fi +# tr -d '\r': Git on Windows can return a CRLF-terminated path. +root=$(git rev-parse --show-toplevel 2>/dev/null | tr -d '\r') + +rung="" +if [[ -n "$remote" ]]; then + identity=$(printf '%s' "$remote" | + sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' | + tr '[:upper:]' '[:lower:]') + # A remote URL is arbitrary text and becomes DIRECTORY COMPONENTS here, so + # accept it only in the shape the scheme means — segments of [a-z0-9._-] each + # starting alphanumeric. That rejects `../central` (a relative filesystem + # remote, which would otherwise write outside the caller's namespace), + # absolute local paths, and backslashes. Anything rejected still keys + # deterministically, by hash. + if grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$' <<<"$identity"; then + rung="remote" + else + identity="remote/$(hash12 "$remote")" + rung="remote-hashed" + fi +elif [[ -n "$root" ]]; then + identity="local/$(hash12 "$root")" + rung="local" +else + # Not a git repository. `audit-pass`'s ladder stops at the two git rungs + # because it refuses non-git targets; a report-only skill audits them, so the + # scheme needs a third rung rather than a failure. + identity="nonrepo/$(hash12 "$PWD")" + rung="nonrepo" +fi + +discriminator=$(hash8 "${root:-$PWD}") + +if [[ $EXPLAIN -eq 1 ]]; then + { + echo "rung: $rung" + echo "remote: ${remote:-}" + echo "repo root: ${root:-}" + echo "cwd: $PWD" + echo "identity: $identity" + echo "discriminator: $discriminator" + } >&2 +fi + +printf '%s/%s\n' "$identity" "$discriminator" diff --git a/plugins/discovery/lib/view-registry.mjs b/plugins/discovery/lib/view-registry.mjs new file mode 100644 index 00000000000..5b33fb87a25 --- /dev/null +++ b/plugins/discovery/lib/view-registry.mjs @@ -0,0 +1,94 @@ +// GENERATED from lib/view-registry.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// The medium registry for rendered views: one entry per medium, the one fallback chain, and each +// view lane's registration. Skills never name a medium themselves; they reach this through +// lib/view-settings.mjs. Adding a medium or a lane is an addition here, never a skill edit. +// +// MediumEntry { id, publishes, reachable(context) -> {ok, reason}, deliver(view, context) -> {location}, reportName } +// Lane { laneId, kinds, hostedCapable, default } +// +// `context.interactive` is false in a CI or other non-interactive run, where only the terminal is +// reachable (docs/contracts/rendered-views/README.md, "Reachability and preference"). A lane's +// `default` is its shipped medium, applied when no scope decides; null leaves the choice to the +// skill's own content rule. + +const interactiveOnly = (context = {}) => (context.interactive === false ? { ok: false, reason: "non-interactive run" } : { ok: true }); + +const entry = (id, publishes, reportName, reachable, deliver) => Object.freeze({ id, publishes, reportName, reachable, deliver }); + +export const MEDIUMS = Object.freeze({ + terminal: entry("terminal", false, "the terminal", () => ({ ok: true }), () => ({ location: "terminal" })), + file: entry("file", false, "a local file", interactiveOnly, (view) => ({ location: view.path })), + artifact: entry("artifact", true, "a private Artifact on claude.ai", interactiveOnly, (view) => ({ location: "artifact", publish: view.path })), + hosted: entry("hosted", true, "the shared page host", interactiveOnly, (view) => ({ location: "hosted", publish: view.path })), +}); + +/** The registry entry for `id`, own keys only, so an inherited name such as `toString` is no medium. */ +export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS, id) ? MEDIUMS[id] : undefined); + +/** The one fallback chain: a view that cannot be served on a medium steps down this list. */ +export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); + +/** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ +export const NO_PREFERENCE = "auto"; + +/** The rendered-views concern's schema, for the cascade reader: one key, `medium`. */ +export const SCHEMA = Object.freeze({ + type: "object", + additionalProperties: false, + properties: { medium: { type: "string", enum: [NO_PREFERENCE, ...Object.keys(MEDIUMS)] } }, +}); + +const lane = (laneId, kinds, hostedCapable, medium) => Object.freeze({ laneId, kinds: Object.freeze(kinds), hostedCapable, default: medium }); + +export const LANES = Object.freeze( + Object.fromEntries( + [ + lane("review:explain-change", ["digest"], true, "artifact"), + lane("visualization:present", ["deck"], false, "artifact"), + lane("visualization:visualize", [], false, null), + lane("planning", ["plan", "brainstorm"], false, "file"), + lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), + lane("discovery:blindspot", ["blindspot"], false, "file"), + lane("debugging:debug", ["post-mortem"], false, "file"), + lane("education:illustrate", [], false, "file"), + lane("work-items:morning-brief", ["morning-brief"], false, "file"), + lane("work-items:triage", ["board"], false, "file"), + lane("adhd:clarify", [], false, null), + lane("codebase-health:audit", [], false, null), + lane("overengineering:audit", [], false, null), + lane("education:teach", [], false, null), + lane("prototype:explore-directions", [], false, null), + lane("prototype:pressure-test", [], false, null), + lane("ai-briefing:generate", [], false, null), + ].map((l) => [l.laneId, l]), + ), +); + +/** The lane registration for `id`, own keys only. */ +export const laneOf = (id) => (typeof id === "string" && Object.hasOwn(LANES, id) ? LANES[id] : undefined); + +/** `medium` as `lane` reads it: `hosted` is `artifact` unless the lane sends pages to a page host. */ +export const readAs = (medium, laneEntry) => (medium === "hosted" && !laneEntry.hostedCapable ? "artifact" : medium); + +/** + * The medium actually used for `preferred` on `laneEntry`: `preferred` itself when reachable, else the + * first reachable medium after it on the fallback chain (a hosted page steps to `artifact` first). + * @returns {{medium: string, fell: null | string}} fell: why `preferred` was not used + */ +export function fallBack(preferred, laneEntry, context = {}) { + const want = readAs(preferred, laneEntry); + const start = FALLBACK_CHAIN.indexOf(want === "hosted" ? "artifact" : want); + const candidates = want === "hosted" ? ["hosted", ...FALLBACK_CHAIN.slice(start)] : FALLBACK_CHAIN.slice(start); + const wanted = mediumOf(want); + if (!wanted) throw new Error(`view-registry: unknown medium ${JSON.stringify(preferred)}`); + const first = wanted.reachable(context); + if (first.ok) return { medium: want, fell: null }; + const medium = candidates.find((id) => mediumOf(id).reachable(context).ok) ?? "terminal"; + return { medium, fell: `${want} unreachable: ${first.reason}` }; +} + +/** The report line every view lane prints, then one line per notice. */ +export function reportLines({ medium, decidedBy, notices = [] }) { + return [`view: ${medium ?? NO_PREFERENCE} (${decidedBy})`, ...notices.map((n) => `notice: ${n.text}`)]; +} diff --git a/plugins/discovery/lib/view-settings.mjs b/plugins/discovery/lib/view-settings.mjs new file mode 100755 index 00000000000..17f324dae75 --- /dev/null +++ b/plugins/discovery/lib/view-settings.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +// GENERATED from lib/view-settings.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// Where a rendered view goes: the one medium resolver every view lane calls. It reads each ADR 0075 +// scope's own `medium` value (concern `rendered-views`, home docs/conventions) through the cascade's +// per-layer reader and schema check, and walks them itself, because a filter on the cascade's +// merged output would lose a lower scope's local value under a higher scope's publishing one. +// +// The argument decides alone. Otherwise a terminal or file value from any scope keeps the view +// local, the highest-precedence one naming the scope (personal override, then team, then +// userConfig). Else the first publishing value from a trusted scope wins: the personal override +// (only untracked and gitignored), then userConfig. A team file never publishes. With no scope +// deciding, the lane's own default applies (it ships with the installed plugin, which a checked-out +// branch cannot write). An invalid value is ignored without dropping another scope's value; auto is +// unset. The retired files (~/.claude/rendered-views.md, .claude/rendered-views.md and +// .claude/rendered-views.local.md) give a notice, once per plugin per project while unchanged, and +// never a value. The medium then steps down the fallback chain (lib/view-registry.mjs) until +// reachable. +// +// view-settings.mjs resolve --lane --option [--argument ] [--team-ref ] +// Prints the resolution as one JSON line, then the report line and one line per notice. +// Exit 0 resolved, 2 usage. + +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readLayers } from "./config-cascade.mjs"; +import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; + +const CONCERN = "rendered-views"; +const HOME = "docs/conventions"; +const TEAM_FILE = `${HOME}/${CONCERN}.yaml`; +const PERSONAL_FILE = `${HOME}/${CONCERN}.local.yaml`; +const HERE = dirname(fileURLToPath(import.meta.url)); + +// ------------------------------------------------------------ repository checks + +/** The session's project: CLAUDE_PROJECT_DIR, else the nearest directory above the cwd holding `.git`. */ +export function findRoot() { + if (process.env.CLAUDE_PROJECT_DIR) return resolve(process.env.CLAUDE_PROJECT_DIR); + for (let at = process.cwd(); ; at = dirname(at)) { + if (existsSync(join(at, ".git"))) return at; + if (dirname(at) === at) return null; + } +} + +const real = (p) => { + try { + return realpathSync(p); + } catch { + return resolve(p); + } +}; +const within = (child, parent) => { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +}; +const isLink = (p) => { + try { + return lstatSync(p).isSymbolicLink(); + } catch { + return false; + } +}; +function gitOut(root, args) { + try { + return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + } catch { + return null; + } +} + +/** + * A personal file applies only untracked, so a pull request cannot ship one. Every directory between + * the root and the file must be a plain, untracked directory, never a submodule, a tracked file or a + * symlink. Not gitignored, it warns, or with `requireIgnored` is ignored too. + */ +export function overlayApplies(root, path, warnings, { requireIgnored = false } = {}) { + // Any tracked case variant counts: on a case-insensitive filesystem it is this file. + const rel = relative(root, path).split("\\").join("/"); + if (gitOut(root, ["ls-files", "--", `:(icase)${rel}`])) { + warnings.push(`overlay ${rel}: tracked in git, so a pull request could set it; layer ignored`); + return false; + } + const parts = rel.split("/").slice(0, -1); + const dirs = parts.map((_, i) => parts.slice(0, i + 1).join("/")); + for (const dir of dirs) { + // A submodule or tracked file at the directory itself is content a pull request controls. + const entries = (gitOut(root, ["ls-files", "-s", "-z", "--", `:(icase)${dir}`]) ?? "").split("\0"); + if (entries.some((e) => e.split("\t")[1]?.toLowerCase() === dir.toLowerCase()) || existsSync(join(root, dir, ".git"))) { + warnings.push(`overlay ${rel}: ${dir} is a submodule or tracked entry; layer ignored`); + return false; + } + if (isLink(join(root, dir))) { + warnings.push(`overlay ${rel}: ${dir} is a symlink; layer ignored`); + return false; + } + } + if (isLink(path) || !within(real(path), join(real(root), ...parts))) { + warnings.push(`overlay ${rel}: the overlay is a symlink or resolves outside ${parts.join("/") || "the root"}; layer ignored`); + return false; + } + if (gitOut(root, ["check-ignore", "-q", "--", path]) === null) { + warnings.push(`overlay ${rel}: not gitignored, so it can reach team history${requireIgnored ? "; layer ignored" : ""}`); + return !requireIgnored; + } + return true; +} + +/** Repository scopes apply only inside a working tree that is not the user's home or above it. */ +const repoScopes = (root, userHome) => Boolean(root) && !within(real(userHome), real(root)) && gitOut(root, ["rev-parse", "--is-inside-work-tree"]) !== null; + +/** A ref name or object id: never an option, never a revision expression. */ +const REF = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; + +/** The team file as `ref` holds it, for the cascade reader; a ref naming no commit reads nothing. */ +function teamAt(root, ref, notices) { + const sha = REF.test(ref) ? gitOut(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])?.trim() : null; + if (!sha) { + const named = ref ? `team ref ${JSON.stringify(ref)} names no commit here` : "no team ref was given"; + notices.push({ kind: "team-ref", text: `${named}, so ${TEAM_FILE} is not read` }); + return { path: `${ref}:${TEAM_FILE}`, text: null }; + } + return { path: `${sha.slice(0, 12)}:${TEAM_FILE}`, text: gitOut(root, ["show", `${sha}:${TEAM_FILE}`]) }; +} + +// ------------------------------------------------------------ retired files + +const mediumIn = (text) => /^[ \t]*medium:[ \t]*["']?([a-z]+)["']?[ \t]*$/m.exec(text ?? "")?.[1] ?? null; +const readBytes = (p) => { + try { + return readFileSync(p); + } catch { + return null; + } +}; + +/** This project's state key (docs/contracts/plugin-data-report-keying, rule 1), or null when it cannot be derived. */ +function stateKey(root) { + const out = spawnSync("bash", [join(HERE, "state-key.sh"), "--root", root ?? process.cwd()], { encoding: "utf8" }); + const key = out.status === 0 ? out.stdout.trim() : ""; + return /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)+$/.test(key) ? key : null; +} + +function writeAtomic(path, text) { + mkdirSync(dirname(path), { recursive: true }); + const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + writeFileSync(temp, text); + renameSync(temp, path); +} + +/** One notice per retired file, unless this plugin already showed it for this project with these bytes. */ +function retiredNotices(legacy, { plugin, root, userHome, dataDir }) { + const where = (path) => (within(path, join(userHome, ".claude")) ? `~/${relative(userHome, path).split("\\").join("/")}` : relative(root, path).split("\\").join("/")); + const destination = { "claude-user": `the ${plugin} plugin's medium option`, "claude-project": TEAM_FILE, "claude-local": PERSONAL_FILE, "fenced-block": TEAM_FILE }; + const key = dataDir ? stateKey(root) : null; + const state = key && join(dataDir, "view-settings", key, "retired-notices.json"); + let seen = {}; + try { + seen = state ? JSON.parse(readFileSync(state, "utf8")) : {}; + } catch { + seen = {}; + } + const notices = []; + let changed = false; + const order = Object.keys(destination); + for (const { path, kind } of [...legacy].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || a.path.localeCompare(b.path))) { + const bytes = readBytes(path); + if (!bytes) continue; + const shown = where(path); + const hash = createHash("sha256").update(bytes).digest("hex"); + if (seen[shown] === hash) continue; + [seen[shown], changed] = [hash, true]; + const value = mediumIn(bytes.toString("utf8")); + const to = destination[kind] ?? TEAM_FILE; + const caveat = + value && mediumOf(value)?.publishes + ? to === TEAM_FILE + ? "; a team file never publishes, so that value would be ignored there" + : to === PERSONAL_FILE + ? "; it publishes only while that file is untracked and gitignored" + : "" + : ""; + notices.push({ kind: "retired", path: shown, value, destination: to, text: `${shown} is no longer read${value ? ` (it sets medium: ${value})` : ""}; set it in ${to} instead${caveat}` }); + } + if (state && changed) { + try { + writeAtomic(state, `${JSON.stringify(seen)}\n`); + } catch { + // Unwritable state shows the notice again next time, which is the safe direction. + } + } + return notices; +} + +// ------------------------------------------------------------ resolution + +/** True when the reader chose a publishing medium (the argument, or a trusted scope), which the publish gate takes as explicit: never the plugin default. */ +export const gateExplicit = ({ preferred, decidedBy }) => Boolean(mediumOf(preferred)?.publishes) && ["argument", "userConfig", "personal override"].includes(decidedBy); + +/** + * @param {{lane: string, argument?: string|null, userConfig?: string|null, projectRoot?: string|null, teamRef?: string|null}} request + * @param {{userHome?: string, dataDir?: string|null, interactive?: boolean}} [options] + * @returns {{preferred: string|null, medium: string|null, decidedBy: string, notices: {kind: string, text: string}[]}} + */ +export function resolveMedium({ lane, argument = null, userConfig = null, projectRoot = null, teamRef = null }, { userHome = homedir(), dataDir = null, interactive = true } = {}) { + const laneEntry = laneOf(lane); + if (!laneEntry) throw new Error(`view-settings: unknown lane ${JSON.stringify(lane)}`); + const notices = []; + const root = repoScopes(projectRoot, userHome) ? projectRoot : null; + const team = root && typeof teamRef === "string" ? teamAt(root, teamRef, notices) : undefined; + const { layers, legacy } = readLayers({ plugin: CONCERN, projectRoot: root, schema: SCHEMA, userConfig: { medium: userConfig ?? "" }, team, userHome }); + notices.unshift(...retiredNotices(legacy, { plugin: lane.split(":")[0], root, userHome, dataDir })); + const finish = (preferred, decidedBy) => { + if (!preferred) return { preferred: null, medium: null, decidedBy, notices }; + const { medium, fell } = fallBack(preferred, laneEntry, { interactive }); + return { preferred, medium, decidedBy: fell ? `fell back: ${fell}` : decidedBy, notices }; + }; + if (mediumOf(argument)) return finish(argument, "argument"); + + const shown = (path) => (path && root && isAbsolute(path) ? relative(root, path).split("\\").join("/") : path); + for (const layer of layers) { + for (const error of layer.errors) { + const prefix = `${layer.name}${layer.path ? ` (${layer.path})` : ""}: `; + const name = { local: "personal override" }[layer.name] ?? layer.name; + notices.push({ kind: "invalid", text: `${name}${layer.path ? ` ${shown(layer.path)}` : ""}: ${error.startsWith(prefix) ? error.slice(prefix.length) : error}; ignored` }); + } + } + const valueOf = (name) => layers.find((l) => l.name === name)?.values.medium; + const personal = valueOf("local"); + let personalTrusted = false; + if (personal && mediumOf(personal)?.publishes) { + const warnings = []; + personalTrusted = overlayApplies(root, join(root, PERSONAL_FILE), warnings, { requireIgnored: true }); + for (const w of warnings) notices.push({ kind: "ignored", text: `personal override ${w.replace(/^overlay /, "")}` }); + } + // Highest precedence first (ADR 0075: the personal override, then team, then userConfig). + const scopes = [ + ["personal override", personal, personalTrusted], + ["team", valueOf("team"), false], + ["userConfig", valueOf("userConfig"), true], + ].filter(([, medium]) => medium && medium !== NO_PREFERENCE); + const local = scopes.find(([, medium]) => !mediumOf(medium).publishes); + if (local) return finish(local[1], local[0]); + const teamValue = scopes.find(([name]) => name === "team")?.[1]; + if (teamValue) notices.push({ kind: "ignored", text: `team ${shown(layers.find((l) => l.name === "team").path)} sets medium: ${teamValue}; a team file never publishes, so it is ignored` }); + const trusted = scopes.find(([, , ok]) => ok); + return trusted ? finish(trusted[1], trusted[0]) : finish(laneEntry.default, "plugin default"); +} + +// ------------------------------------------------------------ CLI + +const USAGE = "usage: view-settings.mjs resolve --lane --option [--argument ] [--team-ref ]\n"; + +/** CLI: the argument vector after the script path; returns the exit code. */ +export function main(argv, env = process.env) { + const [action, ...rest] = argv; + const request = { lane: null, userConfig: undefined, argument: null, teamRef: null }; + const flags = { "--lane": "lane", "--option": "userConfig", "--argument": "argument", "--team-ref": "teamRef" }; + for (let i = 0; i < rest.length; i += 2) { + const key = flags[rest[i]]; + if (!key || rest[i + 1] === undefined) return usage(); + request[key] = rest[i + 1]; + } + if (action !== "resolve" || !laneOf(request.lane) || request.userConfig === undefined) return usage(); + if (request.argument === NO_PREFERENCE) request.argument = null; + if (request.argument !== null && !mediumOf(request.argument)) return usage(); + const ci = Boolean(env.CI) && env.CI !== "false"; + const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); + process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + return 0; +} + +function usage() { + process.stderr.write(USAGE); + return 2; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/plugins/education/lib/config-cascade.mjs b/plugins/education/lib/config-cascade.mjs new file mode 100644 index 00000000000..b5f22dc12e2 --- /dev/null +++ b/plugins/education/lib/config-cascade.mjs @@ -0,0 +1,571 @@ +// GENERATED from lib/config-cascade.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// The four-layer plugin config cascade (docs/adr/0075): later layers win per key. +// +// resolve({plugin, projectRoot, home, schema, defaults, userConfig, teamOnly, floors, userHome}) +// -> {values, provenance: {"a.b": layer | [layers]}, prose: [paths], sections: [{heading, text, layer}], +// layers: [{name, path, state: "loaded" | "absent" | "invalid", errors, error_keys}], legacy: [{path, kind}]} +// +// readLayers({...}) -> each layer's own accepted values, unmerged (see its comment below) +// +// `error_keys[i]` tags `errors[i]`: the dotted key the error names (key-level), or null when it is +// about the layer as a whole (file-level: a file refused or unparsable, a value that is not a mapping). +// +// Layers, in order: "defaults" (the `defaults` object, already parsed by the caller), "userConfig" +// (the plugin's userConfig values, flattened names), "team" (/) and "local", the +// personal overlay (/.local). A file layer reads .yaml for config and .md +// for prose; it is absent when neither file exists. `home` is the convention home the caller +// resolved, relative to projectRoot unless absolute (default docs/conventions). There is no +// user-global file layer: a file under /docs/conventions is never read, and `userHome` +// (default os.homedir()) only locates the legacy files reported in `legacy`. `schema` is a JSON +// Schema object; the subset honored is type (object, string, array, null, integer, number, boolean), +// enum, properties, additionalProperties (false or a schema), items, minimum, maximum, minItems and +// maxItems. +// +// A key is a schema leaf: any node that is not an object with `properties`, or a path named in +// `teamOnly`. The setup writer's top-level `written_against` and `provenance` records are not keys, +// declared or not: every layer skips them, so they reach neither `values` nor a userConfig name. +// Each layer's leaves are checked one by one; a leaf whose value fails the schema is +// dropped from that layer with an error naming layer, file and key, and a lower layer's value is +// then never used (ADR 0075, the invalid-value rule): a valid higher layer still wins, otherwise the +// key takes its default. The personal overlay is the one exception, because it refines the team +// file: an invalid personal value leaves the team value standing. A key containing `.` is +// rejected the same way, since keys are joined with `.` into paths, and so are `__proto__`, +// `constructor` and `prototype`, at any depth inside a value too, which a caller's merge would treat +// as more than data. A refused file makes its layer invalid; the layer's other file still applies. The team and +// local layers come from the repository: each reads only regular files, never a symlink, and is +// invalid when a relative `home` resolves outside projectRoot. A team-only key is +// accepted in "defaults" and "team" and rejected elsewhere. A YAML file that does not parse, or +// whose top level is not a mapping, makes its layer invalid and contributes no values; its .md +// file still supplies prose. A leaf whose last segment is `disable` and whose value is a list is the +// union of every layer's list, and its provenance is an array of the layer names that set it, in +// layer order; every other key's provenance is a single layer-name string. +// +// Policy floors. `floors` maps a key path to a `Floor direction` value (the Implementers table of +// docs/contracts/config-cascade/README.md; the closed set is no-floor, team-only, lowest-value, +// any-layer-wins and stricter-value), or to {direction, scale} where stricter-value takes a scale +// from loosest to strictest (default: the leaf's schema `enum` order). When the team layer set a +// floor key, a personal value is accepted only if it is no looser in that direction: team-only takes +// an equal value (a list must keep every team item); lowest-value a number no higher; any-layer-wins +// a tightening value (`true` or "report") whenever the team value is one; stricter-value a value at +// or beyond the team value on the scale. A looser value is dropped with an error naming the layer, +// file and key, and the team value stands. Floors bind the personal overlay; a `*.disable` union key +// is not compared. +// +// userConfig maps each schema leaf path to its name joined with `_` (widget.limit is widget_limit); +// two leaves with one name throw. An empty string or an unrendered `${user_config.` placeholder is +// unset, names outside the schema are ignored, and a list, mapping or team-only key is rejected. +// A string value takes the leaf's integer, number or boolean type when the leaf does not accept +// strings. Legacy locations (.claude/.* under userHome and projectRoot, and a fenced +// `yaml config` block in the team .md) are reported in `legacy`, never read for values. +// +// Prose. A `.local.md` H2 section (a `## ` heading outside a code fence) replaces the team `.md` +// section with the same heading; every other team section is kept, a local section with a new +// heading follows them, and the team text before the first H2 is kept. `sections` lists the result in +// order, each with the layer it came from (`heading` is null for text before the first H2). +// This module reads files only. File contents are data; error messages quote them as JSON strings. +import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"; +import { parse } from "./yaml-subset.mjs"; + +const PLUGIN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SCALARS = ["string", "integer", "number", "boolean", "null"]; +const DIRECTIONS = ["no-floor", "team-only", "lowest-value", "any-layer-wins", "stricter-value"]; +const clip = (text) => (text.length > 60 ? `${text.slice(0, 60)}...` : text); +const quote = (text) => JSON.stringify(clip(text)); +const show = (value) => clip(JSON.stringify(value) ?? String(value)); +const put = (object, key, value) => Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true }); +const isMapping = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const RESERVED = new Set(["__proto__", "constructor", "prototype"]); +const WRITER_KEYS = new Set(["written_against", "provenance"]); +const isWriterKey = (parts, key) => !parts.length && WRITER_KEYS.has(key); +const newLayer = (name, path, state, errors = [], error_keys = errors.map(() => null)) => ({ name, path, state, errors, error_keys }); +/** Records an error on `layer`, tagged with the dotted key it names, or null for a file-level one. */ +const record = (layer, message, key = null) => { + layer.errors.push(message); + layer.error_keys.push(key); +}; + +/** {ok} for a readable layer file, {refused: why} for one that must not be read, {} when absent. A + * repository layer refuses a symlink, which could reach a file outside the checkout; every layer + * refuses anything but a regular file, since a device or FIFO never finishes reading. */ +function fileState(path, repo) { + let st; + try { + st = lstatSync(path); + } catch { + return {}; + } + if (st.isSymbolicLink()) { + if (repo) return { refused: "is a symlink; a repository layer reads only regular files" }; + try { + st = statSync(path); + } catch { + return {}; + } + } + return st.isFile() ? { ok: true } : { refused: "is not a regular file" }; +} + +/** True when `dir` exists and its real path lies outside the real path of `root`. */ +function escapes(dir, root) { + try { + const rel = relative(realpathSync(root), realpathSync(dir)); + return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel); + } catch { + return false; + } +} +const typesOf = (node) => (node.type === undefined ? null : [node.type].flat()); + +const fits = { + object: isMapping, + array: Array.isArray, + string: (v) => typeof v === "string", + integer: Number.isInteger, + number: (v) => typeof v === "number" && Number.isFinite(v), + boolean: (v) => typeof v === "boolean", + null: (v) => v === null, +}; + +/** The schema for `key` under `node`: a schema, null when anything goes, false when it is unknown. */ +function child(node, key) { + if (isMapping(node.properties) && Object.hasOwn(node.properties, key)) return node.properties[key]; + if (node.additionalProperties === false) return false; + return isMapping(node.additionalProperties) ? node.additionalProperties : null; +} + +const isBranch = (node, path, teamOnly) => + isMapping(node) && isMapping(node.properties) && !teamOnly.has(path) && (typesOf(node)?.includes("object") ?? true); + +/** Why `value` does not fit `node`, as messages relative to the key; empty when it fits. */ +function check(value, node, at = "") { + const prefix = at ? `${at}: ` : ""; + const types = typesOf(node); + if (types && !types.some((t) => fits[t]?.(value))) return [`${prefix}expected ${types.join(" or ")}, got ${show(value)}`]; + if (Array.isArray(node.enum) && !node.enum.includes(value)) return [`${prefix}${show(value)} is not one of ${node.enum.map(show).join(", ")}`]; + if (typeof value === "number" && typeof node.minimum === "number" && value < node.minimum) return [`${prefix}${show(value)} must be at least ${node.minimum}`]; + if (typeof value === "number" && typeof node.maximum === "number" && value > node.maximum) return [`${prefix}${show(value)} must be at most ${node.maximum}`]; + if (Array.isArray(value) && typeof node.minItems === "number" && value.length < node.minItems) return [`${prefix}needs at least ${node.minItems} items, got ${value.length}`]; + if (Array.isArray(value) && typeof node.maxItems === "number" && value.length > node.maxItems) return [`${prefix}allows at most ${node.maxItems} items, got ${value.length}`]; + // An open node ({}) checks nothing but reserved names, which are refused at every depth. + if (Array.isArray(value)) return value.flatMap((item, i) => check(item, isMapping(node.items) ? node.items : {}, `${at}[${i}]`)); + if (!isMapping(value)) return []; + return Object.keys(value).flatMap((key) => { + const where = `${at}.${quote(key)}`; + if (RESERVED.has(key)) return [`${where}: a reserved name, never a config key`]; + const sub = child(node, key); + if (sub === false) return [`${where}: unknown key`]; + return check(value[key], sub ?? {}, where); + }); +} + +/** The accepted leaves of one layer's mapping, as [parts, value] pairs; rejections go to `fail` + * with the dotted key, and `true` third when the value itself was the problem. */ +function leaves(data, schema, { teamOnly, allowTeamOnly, fail }) { + const out = []; + const walk = (object, node, parts) => { + for (const key of Object.keys(object)) { + if (isWriterKey(parts, key)) continue; + const path = [...parts, key]; + const dotted = path.join("."); + const sub = child(node, key); + const value = object[key]; + if (RESERVED.has(key)) fail(`${quote(dotted)}: a reserved name, never a config key`, dotted); + else if (key.includes(".")) fail(`${quote(dotted)}: a key may not contain "."; nest it instead`, dotted); + else if (sub === false) fail(`${quote(dotted)}: unknown key`, dotted); + else if (teamOnly.has(dotted) && !allowTeamOnly) fail(`${quote(dotted)}: accepted only in the team layer`, dotted); + else if (sub && isBranch(sub, dotted, teamOnly)) { + if (isMapping(value)) walk(value, sub, path); + else if (value !== null) fail(`${quote(dotted)}: expected a mapping, got ${show(value)}`, dotted, true); + } else { + const problems = check(value, sub ?? {}); + if (problems.length) fail(`${quote(dotted)}: ${problems.join("; ")}`, dotted, true); + else out.push([path, value]); + } + } + }; + walk(data, schema, []); + return out; +} + +/** Flattened userConfig name to {path, node} for every schema leaf. */ +function flatNames(schema, teamOnly) { + const names = new Map(); + const walk = (node, parts) => { + for (const key of Object.keys(isMapping(node.properties) ? node.properties : {})) { + if (isWriterKey(parts, key)) continue; + const path = [...parts, key]; + const dotted = path.join("."); + const sub = node.properties[key]; + if (isBranch(sub, dotted, teamOnly)) { + walk(sub, path); + continue; + } + const name = path.join("_"); + if (names.has(name)) throw new Error(`config-cascade: schema keys ${names.get(name).dotted} and ${dotted} both flatten to userConfig name ${name}`); + names.set(name, { path, dotted, node: sub }); + } + }; + walk(schema, []); + return names; +} + +function coerce(raw, node) { + const types = typesOf(node) ?? []; + if (typeof raw !== "string" || types.includes("string")) return raw; + if ((types.includes("integer") || types.includes("number")) && /^[-+]?\d+(\.\d+)?$/.test(raw)) return Number(raw); + if (types.includes("boolean") && (raw === "true" || raw === "false")) return raw === "true"; + return raw; +} + +function userConfigLeaves(userConfig, names, teamOnly, fail) { + const out = []; + for (const [name, raw] of Object.entries(userConfig)) { + if (raw === undefined || raw === null || raw === "" || (typeof raw === "string" && raw.startsWith("${user_config."))) continue; + const leaf = names.get(name); + if (!leaf) continue; + const types = typesOf(leaf.node); + if (teamOnly.has(leaf.dotted)) fail(`${quote(leaf.dotted)}: accepted only in the team layer`, leaf.dotted); + else if (types && !types.every((t) => SCALARS.includes(t))) fail(`${quote(leaf.dotted)}: lists and mappings are set only in files`, leaf.dotted); + else { + const value = coerce(raw, leaf.node); + const problems = check(value, leaf.node); + if (problems.length) fail(`${quote(leaf.dotted)}: ${problems.join("; ")}`, leaf.dotted, true); + else out.push([leaf.path, value]); + } + } + return out; +} + +/** One file layer: its report entry, its parsed mapping (or null), and its prose path (or null). */ +function readLayer(name, stem, repo) { + const path = `${stem}.yaml`; + const md = `${stem}.md`; + const layer = newLayer(name, path, "absent"); + const mdFile = fileState(md, repo); + if (mdFile.refused) { + layer.state = "invalid"; + record(layer, `${name} (${md}): ${mdFile.refused}`); + } + const prose = mdFile.ok ? md : null; + if (prose) layer.state = "loaded"; + const yamlFile = fileState(path, repo); + if (yamlFile.refused) { + layer.state = "invalid"; + record(layer, `${name} (${path}): ${yamlFile.refused}`); + } + if (!yamlFile.ok) return { layer, data: null, prose }; + return { layer, data: parseInto(layer, () => readFileSync(path, "utf8")), prose }; +} + +/** The mapping `text()` parses to, loading `layer`; null, with the layer invalid, when it does not parse or is not a mapping. */ +function parseInto(layer, text) { + try { + const doc = parse(text()) ?? {}; + if (!isMapping(doc)) throw new Error("the top level must be a mapping"); + if (layer.state !== "invalid") layer.state = "loaded"; + return doc; + } catch (e) { + layer.state = "invalid"; + record(layer, `${layer.name} (${layer.path}): ${e.message}`); + return null; + } +} + +/** True when the Markdown file holds a ``` config block outside any other fence. */ +function hasConfigBlock(path) { + if (!fileState(path, true).ok) return false; + let text; + try { + text = readFileSync(path, "utf8"); + } catch { + return false; + } + let fence = null; // the open fence's marker run, backticks or tildes + for (const line of text.split(/\r\n|\r|\n/)) { + const run = line.match(/^(`{3,}|~{3,})/)?.[0]; + if (fence) { + if (run?.[0] === fence[0] && run.length >= fence.length && line.trimEnd().length === run.length) fence = null; + } else if (/^```[A-Za-z0-9_-]+ config\s*$/.test(line)) return true; + else if (run) fence = run; + } + return false; +} + +/** A Markdown text as [{heading, text}], split at each H2 outside a code fence; the first entry holds + * the text before the first H2 (heading null) and is dropped when empty. */ +function splitSections(text) { + const out = [{ heading: null, lines: [] }]; + let fence = null; + for (const line of text.split(/(?<=\n)/)) { + const run = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1]; + if (fence) { + if (run?.[0] === fence[0] && run.length >= fence.length && line.trim().length === run.length) fence = null; + } else if (run) fence = run; + else { + const h = line.match(/^## +(.*?)(?: +#+)?\s*$/); + if (h) { + out.push({ heading: h[1].replace(/\s+/g, " ").trim(), lines: [line] }); + continue; + } + } + out.at(-1).lines.push(line); + } + return out.map(({ heading, lines }) => ({ heading, text: lines.join("") })).filter((s) => s.heading !== null || s.text.trim() !== ""); +} + +function readText(path) { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** The team sections with each same-heading local section in its place, then the local sections the + * team lacks. The first team section with a heading is replaced; a later team repeat of it is dropped. */ +function mergeSections(teamText, localText) { + const team = teamText === null ? [] : splitSections(teamText).map((s) => ({ ...s, layer: "team" })); + const local = localText === null ? [] : splitSections(localText).map((s) => ({ ...s, layer: "local" })); + const replacement = new Map(local.filter((s) => s.heading !== null).map((s) => [s.heading, s])); + const used = new Set(); + const out = []; + for (const s of team) { + if (s.heading === null || !replacement.has(s.heading)) out.push(s); + else if (!used.has(s.heading)) { + used.add(s.heading); + out.push(replacement.get(s.heading)); + } + } + const teamHeadings = new Set(team.map((s) => s.heading)); + for (const s of local) { + // Text before the first H2 follows the team's own only when the team has no file; a repeated heading keeps its last section. + const fresh = s.heading === null ? team.length === 0 : !teamHeadings.has(s.heading) && replacement.get(s.heading) === s; + if (fresh) out.push(s); + } + return out; +} + +function legacyFiles(dir, plugin, kind) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + return entries.filter((e) => e.isFile() && e.name.startsWith(`${plugin}.`)).map((e) => ({ path: join(dir, e.name), kind: kind(e.name) })); +} + +function setPath(object, parts, value) { + let at = object; + for (const part of parts.slice(0, -1)) { + if (!Object.hasOwn(at, part) || !isMapping(at[part])) put(at, part, {}); + at = at[part]; + } + put(at, parts.at(-1), value); +} + +/** Validated floors: dotted key to {direction, scale}. */ +function normalizeFloors(floors) { + if (!isMapping(floors)) throw new Error("config-cascade: floors must be a mapping of key path to Floor direction"); + const out = new Map(); + for (const [key, raw] of Object.entries(floors)) { + const direction = typeof raw === "string" ? raw : raw?.direction; + if (!DIRECTIONS.includes(direction)) throw new Error(`config-cascade: floor ${JSON.stringify(key)} has direction ${show(direction)}, not one of ${DIRECTIONS.join(", ")}`); + out.set(key, { direction, scale: isMapping(raw) && Array.isArray(raw.scale) ? raw.scale : null }); + } + return out; +} + +function nodeAt(schema, parts) { + let node = schema; + for (const part of parts) { + node = node && child(node, part); + if (!node) return null; + } + return node; +} + +const tightening = (v) => v === true || v === "report"; + +/** Why `personal` loosens `team` in the floor's direction, or null when it does not. */ +function looser(floor, personal, team, node) { + if (JSON.stringify(personal) === JSON.stringify(team)) return null; + switch (floor.direction) { + case "team-only": + if (Array.isArray(personal) && Array.isArray(team) && team.every((t) => personal.some((p) => JSON.stringify(p) === JSON.stringify(t)))) return null; + return `${show(personal)} differs from the team value ${show(team)}, and the team value wins`; + case "lowest-value": + if (typeof personal === "number" && typeof team === "number" && personal <= team) return null; + return `${show(personal)} is not lower than or equal to the team value ${show(team)}`; + case "any-layer-wins": + if (!tightening(team) || tightening(personal)) return null; + return `${show(personal)} drops the team value ${show(team)}, which is the tightening one`; + case "stricter-value": { + const scale = floor.scale ?? node?.enum ?? []; + const a = scale.findIndex((v) => JSON.stringify(v) === JSON.stringify(personal)); + const b = scale.findIndex((v) => JSON.stringify(v) === JSON.stringify(team)); + if (a >= 0 && b >= 0 && a >= b) return null; + return `${show(personal)} is not as strict as the team value ${show(team)} on the scale ${show(scale)}`; + } + default: + return null; + } +} + +export function resolve({ plugin, projectRoot, home = "docs/conventions", schema, defaults, userConfig, teamOnly = [], floors = {}, userHome = homedir() }) { + if (typeof plugin !== "string" || !PLUGIN.test(plugin)) throw new Error(`config-cascade: plugin name ${JSON.stringify(plugin)} is not a plain name`); + const only = new Set(teamOnly); + const floorMap = normalizeFloors(floors); + const names = flatNames(schema, only); + const homeDir = resolvePath(projectRoot, home); + // A relative home is inside the checkout by intent; a committed symlink must not carry it out. + const homeEscapes = !isAbsolute(home) && escapes(homeDir, projectRoot); + const layers = []; + const contributions = []; // [layer name, [[parts, value]], dotted keys whose value was invalid] + const prose = []; + const proseText = {}; + + // pairsOf(fail) returns the layer's accepted [parts, value] pairs; falsy when it contributes none. + const take = (layer, pairsOf) => { + const invalid = new Set(); + const fail = (message, key, valueFailed = false) => { + record(layer, `${layer.name}${layer.path ? ` (${layer.path})` : ""}: ${message}`, key); + if (valueFailed) invalid.add(key); + }; + if (pairsOf) contributions.push([layer.name, pairsOf(fail), invalid]); + layers.push(layer); + }; + const fileLeaves = (data, allowTeamOnly) => data && ((fail) => leaves(data, schema, { teamOnly: only, allowTeamOnly, fail })); + + const shaped = (name, given) => { + if (given === undefined || given === null) return { layer: newLayer(name, null, "absent"), ok: false }; + if (isMapping(given)) return { layer: newLayer(name, null, "loaded"), ok: true }; + return { layer: newLayer(name, null, "invalid", [`${name}: expected a mapping, got ${show(given)}`]), ok: false }; + }; + const d = shaped("defaults", defaults); + take(d.layer, d.ok && fileLeaves(defaults, true)); + const u = shaped("userConfig", userConfig); + take(u.layer, u.ok && ((fail) => userConfigLeaves(userConfig, names, only, fail))); + for (const [name, stem] of [ + ["team", join(homeDir, plugin)], + ["local", join(homeDir, `${plugin}.local`)], + ]) { + if (homeEscapes) { + const why = "the convention home resolves outside the project; a repository layer stays inside it"; + take(newLayer(name, `${stem}.yaml`, "invalid", [`${name} (${homeDir}): ${why}`])); + continue; + } + const read = readLayer(name, stem, true); + take(read.layer, fileLeaves(read.data, name === "team")); + if (read.prose) { + prose.push(read.prose); + proseText[name] = readText(read.prose); + } + } + + const merged = new Map(); // dotted -> {parts, value, from} + let base = new Map(); // the defaults layer's entries, where an invalid higher value falls back to + const within = (dotted, key) => dotted === key || dotted.startsWith(`${key}.`); + for (const [name, pairs, invalid] of contributions) { + // A lower layer's value is never used for an invalid one; the personal overlay refines the team file, so its keys fall to team. + if (name !== "local") { + for (const key of invalid) { + for (const dotted of [...merged.keys()]) if (within(dotted, key)) merged.delete(dotted); + for (const [dotted, entry] of base) if (within(dotted, key)) merged.set(dotted, entry); + } + } + const layer = layers.find((l) => l.name === name); + for (const [parts, value] of pairs) { + const dotted = parts.join("."); + const prior = merged.get(dotted); + const floor = floorMap.get(dotted); + const union = parts.at(-1) === "disable" && Array.isArray(value); + if (name === "local" && floor && floor.direction !== "no-floor" && !union && prior && [prior.from].flat().includes("team")) { + const why = looser(floor, value, prior.value, nodeAt(schema, parts)); + if (why) { + record(layer, `local (${layer.path}): ${quote(dotted)}: loosens the ${floor.direction} policy floor: ${why}`, dotted); + continue; + } + } + if (union) { + const items = Array.isArray(prior?.value) ? [...prior.value] : []; + const seen = new Set(items.map((i) => JSON.stringify(i))); + for (const item of value) { + const key = JSON.stringify(item); + if (!seen.has(key)) items.push(item); + seen.add(key); + } + // An empty list adds nothing, so its layer is credited only when no layer set the key before. + const from = Array.isArray(prior?.from) ? prior.from : []; + merged.set(dotted, { parts, value: items, from: value.length > 0 || !prior ? [...from, name] : from }); + } else merged.set(dotted, { parts, value, from: name }); + } + if (name === "defaults") base = new Map(merged); + } + const values = {}; + const provenance = {}; + for (const [dotted, { parts, value, from }] of merged) { + setPath(values, parts, value); + put(provenance, dotted, from); + } + + const legacy = legacyOf(plugin, userHome, projectRoot, !homeEscapes && `${join(homeDir, plugin)}.md`); + const sections = mergeSections(proseText.team ?? null, proseText.local ?? null).map(({ heading, text, layer }) => ({ heading, text, layer })); + return { values, provenance, prose, sections, layers, legacy }; +} + +/** The legacy locations that exist; `teamMd` (or false) is the team .md checked for a fenced config block. */ +function legacyOf(plugin, userHome, projectRoot, teamMd) { + const found = [ + ...legacyFiles(join(userHome, ".claude"), plugin, () => "claude-user"), + ...(projectRoot ? legacyFiles(join(projectRoot, ".claude"), plugin, (n) => (n.startsWith(`${plugin}.local.`) ? "claude-local" : "claude-project")) : []), + ...(teamMd && hasConfigBlock(teamMd) ? [{ path: teamMd, kind: "fenced-block" }] : []), + ]; + // The project root may be the user home, so a file is reported under its first kind only. + return found.filter((entry, i) => found.findIndex((e) => e.path === entry.path) === i); +} + +/** + * Each layer's own accepted values, unmerged, for a caller that weighs the layers itself (the + * rendered-views publish rule reads every layer separately: lib/view-settings.mjs). The same reader, + * refusals and schema check as resolve(), with no precedence, floors or invalid-value fallback: an + * invalid value leaves only its own layer. Layers: "userConfig", then "team" and "local" when + * projectRoot is set. `team`, as {path, text}, replaces the working-tree team file (text null: + * absent), for a caller reading it from a git ref. + * readLayers({plugin, projectRoot, home, schema, userConfig, teamOnly, team, userHome}) + * -> {layers: [{name, path, state, errors, error_keys, values: {"a.b": value}}], legacy} + */ +export function readLayers({ plugin, projectRoot = null, home = "docs/conventions", schema, userConfig, teamOnly = [], team, userHome = homedir() }) { + if (typeof plugin !== "string" || !PLUGIN.test(plugin)) throw new Error(`config-cascade: plugin name ${JSON.stringify(plugin)} is not a plain name`); + const only = new Set(teamOnly); + const layers = []; + const add = (layer, pairsOf) => { + const fail = (message, key) => record(layer, `${layer.name}${layer.path ? ` (${layer.path})` : ""}: ${message}`, key); + const values = {}; + for (const [parts, value] of pairsOf ? pairsOf(fail) : []) put(values, parts.join("."), value); + layers.push({ ...layer, values }); + }; + if (isMapping(userConfig)) add(newLayer("userConfig", null, "loaded"), (fail) => userConfigLeaves(userConfig, flatNames(schema, only), only, fail)); + if (!projectRoot) return { layers, legacy: legacyOf(plugin, userHome, null, false) }; + const homeDir = resolvePath(projectRoot, home); + const homeEscapes = !isAbsolute(home) && escapes(homeDir, projectRoot); + for (const [name, stem] of [ + ["team", join(homeDir, plugin)], + ["local", join(homeDir, `${plugin}.local`)], + ]) { + const fileLeaves = (data) => data && ((fail) => leaves(data, schema, { teamOnly: only, allowTeamOnly: name === "team", fail })); + if (name === "team" && team) { + const layer = newLayer(name, team.path, "absent"); + add(layer, typeof team.text === "string" && fileLeaves(parseInto(layer, () => team.text))); + } else if (homeEscapes) { + add(newLayer(name, `${stem}.yaml`, "invalid", [`${name} (${homeDir}): the convention home resolves outside the project; a repository layer stays inside it`])); + } else { + const read = readLayer(name, stem, true); + add(read.layer, fileLeaves(read.data)); + } + } + return { layers, legacy: legacyOf(plugin, userHome, projectRoot, !homeEscapes && `${join(homeDir, plugin)}.md`) }; +} diff --git a/plugins/education/lib/state-key.sh b/plugins/education/lib/state-key.sh new file mode 100755 index 00000000000..78a36277953 --- /dev/null +++ b/plugins/education/lib/state-key.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# GENERATED from lib/state-key.sh by scripts/sync-shared-copies.sh. Do not edit this copy: +# edit the canonical source, then rerun the script. +# Per-project state key for anything a plugin writes under ${CLAUDE_PLUGIN_DATA}. +# +# WHY. ${CLAUDE_PLUGIN_DATA} is keyed to the plugin identifier and nothing else: +# https://code.claude.com/docs/en/plugins/manifest-reference#environment-variables +# (as of 2026-10-11; recheck when that section adds a project, checkout, +# worktree or session segment to the path). So a skill that writes a fixed filename there has one file per +# MACHINE: every run from every repository overwrites the last, and a skill that +# READS it back can serve one project's findings as another's. +# +# This prints the missing segment. The scheme is canonically defined by the +# marketplace's plugin-data-report-keying convention (rule 1); this header keeps +# a named operational duplicate of that definition so the executable ships +# self-described: +# +# = / +# +# repo-identity the first configured remote URL normalized to +# host/owner/repo, lowercased, scheme/credentials/.git +# stripped. No remote -> local/. +# Not a repo at all -> nonrepo/. +# worktree-discriminator sha256 of the canonicalized worktree root, cut to 8 +# (outside a repository, of the physical cwd). Two +# worktrees of one repository legitimately hold +# different content and must not share a report. +# +# Every path is taken physical (symlinks resolved) before it is hashed. `cd` +# keeps the logical spelling a symlink was reached through, and the read-back +# consumers derive the key from wherever the operator launched, so one +# directory reached two ways must still produce one key. git canonicalizes +# the root it reports; the non-repository rung has to do it itself. +# +# SECURITY — this is why the identity is validated rather than merely lowercased. +# A remote URL is arbitrary text that becomes DIRECTORY COMPONENTS in the caller's +# path. A remote of `../../../etc` would walk a report out of the plugin's own +# namespace. Any identity that is not a plain lowercase segment path is replaced +# by a hash, so it still keys deterministically and still stays inside the +# namespace. Every rejected shape is covered by state-key.test.sh. +# +# NOT a substitute for a per-run filename. Keying stops cross-project collision; +# it does not stop a same-project rerun overwriting yesterday's report. A caller +# that needs history writes one file per run under this key and appends a line +# to a history file — see docs/contracts/plugin-data-report-keying/. +# +# Usage: +# state-key.sh [--root ] [--explain] +# +# --root derive for that directory instead of the current one +# --explain write the rung taken and its inputs to stderr +# +# Exit: 0 always on a successful derivation — every input reaches some rung, so +# there is no "cannot key" outcome. 2 on a bad argument, an unusable --root, or +# a PATH with neither sha256sum nor shasum. +# +# Shared source: lib/state-key.sh at the marketplace repo root is the canonical source; the copies in the plugins +# that carry it are generated by scripts/sync-shared-copies.sh and registered in +# scripts/shared-copies.txt. Edit this file and rerun the script. + +set -uo pipefail + +# `cd` consults CDPATH when the operand is relative and does not start with `.`, +# and on a hit it ECHOES the resolved path to stdout and lands somewhere the +# caller never named. Either one breaks this script: stdout must be exactly the +# key, and the key must describe the requested directory. Clear it once, here. +CDPATH= + +usage() { + cat <<'EOF' +state-key.sh — per-project state key for ${CLAUDE_PLUGIN_DATA} writes. + +Prints / for a directory, so a report +persisted under the plugin data directory belongs to one project rather than to +the machine. + +Usage: + state-key.sh [--root ] [--explain] [--help] + + --root derive for that directory instead of the current one + --explain write the rung taken and its inputs to stderr + +Exit: 0 on a derivation; 2 on a bad argument, an unusable --root, or no hash tool. +EOF +} + +ROOT_ARG="" +EXPLAIN=0 +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --root) + if [[ $# -lt 2 ]]; then + echo "ERROR: --root needs a path" >&2 + exit 2 + fi + ROOT_ARG="$2" + shift 2 + ;; + --explain) + EXPLAIN=1 + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -n "$ROOT_ARG" ]]; then + if [[ ! -d "$ROOT_ARG" ]]; then + echo "ERROR: --root is not a directory: $ROOT_ARG" >&2 + exit 2 + fi + cd "$ROOT_ARG" || exit 2 +fi +# Physical cwd, forklessly: `cd -P .` rewrites $PWD with symlinks resolved. +cd -P . || exit 2 + +# sha256sum is absent on stock macOS; shasum -a 256 is the portable partner. +# Probe here in the main script, not inside sha256(). hash12/hash8 run that +# helper as a non-last pipeline stage inside a command substitution, and POSIX +# XCU 2.6.3 plus Bash's pipeline rule both execute that in a subshell, so +# `exit 2` there cannot terminate this process. Under `set -uo pipefail` (no +# -e) the script would then print a malformed key at exit 0. +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256; } +else + echo "ERROR: no sha256sum or shasum on PATH — cannot derive a state key" >&2 + exit 2 +fi + +hash12() { printf '%s' "$1" | sha256 | cut -c1-12; } +hash8() { printf '%s' "$1" | sha256 | cut -c1-8; } + +# "the FIRST configured remote" — not necessarily one named `origin`. A repo +# whose only remote is `upstream` still has a remote and must not fall through +# to the local rung. +remote_name=$(git remote 2>/dev/null | tr -d '\r' | head -1) +remote="" +if [[ -n "$remote_name" ]]; then + remote=$(git config --get "remote.${remote_name}.url" 2>/dev/null | tr -d '\r') +fi +# tr -d '\r': Git on Windows can return a CRLF-terminated path. +root=$(git rev-parse --show-toplevel 2>/dev/null | tr -d '\r') + +rung="" +if [[ -n "$remote" ]]; then + identity=$(printf '%s' "$remote" | + sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' | + tr '[:upper:]' '[:lower:]') + # A remote URL is arbitrary text and becomes DIRECTORY COMPONENTS here, so + # accept it only in the shape the scheme means — segments of [a-z0-9._-] each + # starting alphanumeric. That rejects `../central` (a relative filesystem + # remote, which would otherwise write outside the caller's namespace), + # absolute local paths, and backslashes. Anything rejected still keys + # deterministically, by hash. + if grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$' <<<"$identity"; then + rung="remote" + else + identity="remote/$(hash12 "$remote")" + rung="remote-hashed" + fi +elif [[ -n "$root" ]]; then + identity="local/$(hash12 "$root")" + rung="local" +else + # Not a git repository. `audit-pass`'s ladder stops at the two git rungs + # because it refuses non-git targets; a report-only skill audits them, so the + # scheme needs a third rung rather than a failure. + identity="nonrepo/$(hash12 "$PWD")" + rung="nonrepo" +fi + +discriminator=$(hash8 "${root:-$PWD}") + +if [[ $EXPLAIN -eq 1 ]]; then + { + echo "rung: $rung" + echo "remote: ${remote:-}" + echo "repo root: ${root:-}" + echo "cwd: $PWD" + echo "identity: $identity" + echo "discriminator: $discriminator" + } >&2 +fi + +printf '%s/%s\n' "$identity" "$discriminator" diff --git a/plugins/education/lib/view-registry.mjs b/plugins/education/lib/view-registry.mjs new file mode 100644 index 00000000000..5b33fb87a25 --- /dev/null +++ b/plugins/education/lib/view-registry.mjs @@ -0,0 +1,94 @@ +// GENERATED from lib/view-registry.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// The medium registry for rendered views: one entry per medium, the one fallback chain, and each +// view lane's registration. Skills never name a medium themselves; they reach this through +// lib/view-settings.mjs. Adding a medium or a lane is an addition here, never a skill edit. +// +// MediumEntry { id, publishes, reachable(context) -> {ok, reason}, deliver(view, context) -> {location}, reportName } +// Lane { laneId, kinds, hostedCapable, default } +// +// `context.interactive` is false in a CI or other non-interactive run, where only the terminal is +// reachable (docs/contracts/rendered-views/README.md, "Reachability and preference"). A lane's +// `default` is its shipped medium, applied when no scope decides; null leaves the choice to the +// skill's own content rule. + +const interactiveOnly = (context = {}) => (context.interactive === false ? { ok: false, reason: "non-interactive run" } : { ok: true }); + +const entry = (id, publishes, reportName, reachable, deliver) => Object.freeze({ id, publishes, reportName, reachable, deliver }); + +export const MEDIUMS = Object.freeze({ + terminal: entry("terminal", false, "the terminal", () => ({ ok: true }), () => ({ location: "terminal" })), + file: entry("file", false, "a local file", interactiveOnly, (view) => ({ location: view.path })), + artifact: entry("artifact", true, "a private Artifact on claude.ai", interactiveOnly, (view) => ({ location: "artifact", publish: view.path })), + hosted: entry("hosted", true, "the shared page host", interactiveOnly, (view) => ({ location: "hosted", publish: view.path })), +}); + +/** The registry entry for `id`, own keys only, so an inherited name such as `toString` is no medium. */ +export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS, id) ? MEDIUMS[id] : undefined); + +/** The one fallback chain: a view that cannot be served on a medium steps down this list. */ +export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); + +/** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ +export const NO_PREFERENCE = "auto"; + +/** The rendered-views concern's schema, for the cascade reader: one key, `medium`. */ +export const SCHEMA = Object.freeze({ + type: "object", + additionalProperties: false, + properties: { medium: { type: "string", enum: [NO_PREFERENCE, ...Object.keys(MEDIUMS)] } }, +}); + +const lane = (laneId, kinds, hostedCapable, medium) => Object.freeze({ laneId, kinds: Object.freeze(kinds), hostedCapable, default: medium }); + +export const LANES = Object.freeze( + Object.fromEntries( + [ + lane("review:explain-change", ["digest"], true, "artifact"), + lane("visualization:present", ["deck"], false, "artifact"), + lane("visualization:visualize", [], false, null), + lane("planning", ["plan", "brainstorm"], false, "file"), + lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), + lane("discovery:blindspot", ["blindspot"], false, "file"), + lane("debugging:debug", ["post-mortem"], false, "file"), + lane("education:illustrate", [], false, "file"), + lane("work-items:morning-brief", ["morning-brief"], false, "file"), + lane("work-items:triage", ["board"], false, "file"), + lane("adhd:clarify", [], false, null), + lane("codebase-health:audit", [], false, null), + lane("overengineering:audit", [], false, null), + lane("education:teach", [], false, null), + lane("prototype:explore-directions", [], false, null), + lane("prototype:pressure-test", [], false, null), + lane("ai-briefing:generate", [], false, null), + ].map((l) => [l.laneId, l]), + ), +); + +/** The lane registration for `id`, own keys only. */ +export const laneOf = (id) => (typeof id === "string" && Object.hasOwn(LANES, id) ? LANES[id] : undefined); + +/** `medium` as `lane` reads it: `hosted` is `artifact` unless the lane sends pages to a page host. */ +export const readAs = (medium, laneEntry) => (medium === "hosted" && !laneEntry.hostedCapable ? "artifact" : medium); + +/** + * The medium actually used for `preferred` on `laneEntry`: `preferred` itself when reachable, else the + * first reachable medium after it on the fallback chain (a hosted page steps to `artifact` first). + * @returns {{medium: string, fell: null | string}} fell: why `preferred` was not used + */ +export function fallBack(preferred, laneEntry, context = {}) { + const want = readAs(preferred, laneEntry); + const start = FALLBACK_CHAIN.indexOf(want === "hosted" ? "artifact" : want); + const candidates = want === "hosted" ? ["hosted", ...FALLBACK_CHAIN.slice(start)] : FALLBACK_CHAIN.slice(start); + const wanted = mediumOf(want); + if (!wanted) throw new Error(`view-registry: unknown medium ${JSON.stringify(preferred)}`); + const first = wanted.reachable(context); + if (first.ok) return { medium: want, fell: null }; + const medium = candidates.find((id) => mediumOf(id).reachable(context).ok) ?? "terminal"; + return { medium, fell: `${want} unreachable: ${first.reason}` }; +} + +/** The report line every view lane prints, then one line per notice. */ +export function reportLines({ medium, decidedBy, notices = [] }) { + return [`view: ${medium ?? NO_PREFERENCE} (${decidedBy})`, ...notices.map((n) => `notice: ${n.text}`)]; +} diff --git a/plugins/education/lib/view-settings.mjs b/plugins/education/lib/view-settings.mjs new file mode 100755 index 00000000000..17f324dae75 --- /dev/null +++ b/plugins/education/lib/view-settings.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +// GENERATED from lib/view-settings.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// Where a rendered view goes: the one medium resolver every view lane calls. It reads each ADR 0075 +// scope's own `medium` value (concern `rendered-views`, home docs/conventions) through the cascade's +// per-layer reader and schema check, and walks them itself, because a filter on the cascade's +// merged output would lose a lower scope's local value under a higher scope's publishing one. +// +// The argument decides alone. Otherwise a terminal or file value from any scope keeps the view +// local, the highest-precedence one naming the scope (personal override, then team, then +// userConfig). Else the first publishing value from a trusted scope wins: the personal override +// (only untracked and gitignored), then userConfig. A team file never publishes. With no scope +// deciding, the lane's own default applies (it ships with the installed plugin, which a checked-out +// branch cannot write). An invalid value is ignored without dropping another scope's value; auto is +// unset. The retired files (~/.claude/rendered-views.md, .claude/rendered-views.md and +// .claude/rendered-views.local.md) give a notice, once per plugin per project while unchanged, and +// never a value. The medium then steps down the fallback chain (lib/view-registry.mjs) until +// reachable. +// +// view-settings.mjs resolve --lane --option [--argument ] [--team-ref ] +// Prints the resolution as one JSON line, then the report line and one line per notice. +// Exit 0 resolved, 2 usage. + +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readLayers } from "./config-cascade.mjs"; +import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; + +const CONCERN = "rendered-views"; +const HOME = "docs/conventions"; +const TEAM_FILE = `${HOME}/${CONCERN}.yaml`; +const PERSONAL_FILE = `${HOME}/${CONCERN}.local.yaml`; +const HERE = dirname(fileURLToPath(import.meta.url)); + +// ------------------------------------------------------------ repository checks + +/** The session's project: CLAUDE_PROJECT_DIR, else the nearest directory above the cwd holding `.git`. */ +export function findRoot() { + if (process.env.CLAUDE_PROJECT_DIR) return resolve(process.env.CLAUDE_PROJECT_DIR); + for (let at = process.cwd(); ; at = dirname(at)) { + if (existsSync(join(at, ".git"))) return at; + if (dirname(at) === at) return null; + } +} + +const real = (p) => { + try { + return realpathSync(p); + } catch { + return resolve(p); + } +}; +const within = (child, parent) => { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +}; +const isLink = (p) => { + try { + return lstatSync(p).isSymbolicLink(); + } catch { + return false; + } +}; +function gitOut(root, args) { + try { + return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + } catch { + return null; + } +} + +/** + * A personal file applies only untracked, so a pull request cannot ship one. Every directory between + * the root and the file must be a plain, untracked directory, never a submodule, a tracked file or a + * symlink. Not gitignored, it warns, or with `requireIgnored` is ignored too. + */ +export function overlayApplies(root, path, warnings, { requireIgnored = false } = {}) { + // Any tracked case variant counts: on a case-insensitive filesystem it is this file. + const rel = relative(root, path).split("\\").join("/"); + if (gitOut(root, ["ls-files", "--", `:(icase)${rel}`])) { + warnings.push(`overlay ${rel}: tracked in git, so a pull request could set it; layer ignored`); + return false; + } + const parts = rel.split("/").slice(0, -1); + const dirs = parts.map((_, i) => parts.slice(0, i + 1).join("/")); + for (const dir of dirs) { + // A submodule or tracked file at the directory itself is content a pull request controls. + const entries = (gitOut(root, ["ls-files", "-s", "-z", "--", `:(icase)${dir}`]) ?? "").split("\0"); + if (entries.some((e) => e.split("\t")[1]?.toLowerCase() === dir.toLowerCase()) || existsSync(join(root, dir, ".git"))) { + warnings.push(`overlay ${rel}: ${dir} is a submodule or tracked entry; layer ignored`); + return false; + } + if (isLink(join(root, dir))) { + warnings.push(`overlay ${rel}: ${dir} is a symlink; layer ignored`); + return false; + } + } + if (isLink(path) || !within(real(path), join(real(root), ...parts))) { + warnings.push(`overlay ${rel}: the overlay is a symlink or resolves outside ${parts.join("/") || "the root"}; layer ignored`); + return false; + } + if (gitOut(root, ["check-ignore", "-q", "--", path]) === null) { + warnings.push(`overlay ${rel}: not gitignored, so it can reach team history${requireIgnored ? "; layer ignored" : ""}`); + return !requireIgnored; + } + return true; +} + +/** Repository scopes apply only inside a working tree that is not the user's home or above it. */ +const repoScopes = (root, userHome) => Boolean(root) && !within(real(userHome), real(root)) && gitOut(root, ["rev-parse", "--is-inside-work-tree"]) !== null; + +/** A ref name or object id: never an option, never a revision expression. */ +const REF = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; + +/** The team file as `ref` holds it, for the cascade reader; a ref naming no commit reads nothing. */ +function teamAt(root, ref, notices) { + const sha = REF.test(ref) ? gitOut(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])?.trim() : null; + if (!sha) { + const named = ref ? `team ref ${JSON.stringify(ref)} names no commit here` : "no team ref was given"; + notices.push({ kind: "team-ref", text: `${named}, so ${TEAM_FILE} is not read` }); + return { path: `${ref}:${TEAM_FILE}`, text: null }; + } + return { path: `${sha.slice(0, 12)}:${TEAM_FILE}`, text: gitOut(root, ["show", `${sha}:${TEAM_FILE}`]) }; +} + +// ------------------------------------------------------------ retired files + +const mediumIn = (text) => /^[ \t]*medium:[ \t]*["']?([a-z]+)["']?[ \t]*$/m.exec(text ?? "")?.[1] ?? null; +const readBytes = (p) => { + try { + return readFileSync(p); + } catch { + return null; + } +}; + +/** This project's state key (docs/contracts/plugin-data-report-keying, rule 1), or null when it cannot be derived. */ +function stateKey(root) { + const out = spawnSync("bash", [join(HERE, "state-key.sh"), "--root", root ?? process.cwd()], { encoding: "utf8" }); + const key = out.status === 0 ? out.stdout.trim() : ""; + return /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)+$/.test(key) ? key : null; +} + +function writeAtomic(path, text) { + mkdirSync(dirname(path), { recursive: true }); + const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + writeFileSync(temp, text); + renameSync(temp, path); +} + +/** One notice per retired file, unless this plugin already showed it for this project with these bytes. */ +function retiredNotices(legacy, { plugin, root, userHome, dataDir }) { + const where = (path) => (within(path, join(userHome, ".claude")) ? `~/${relative(userHome, path).split("\\").join("/")}` : relative(root, path).split("\\").join("/")); + const destination = { "claude-user": `the ${plugin} plugin's medium option`, "claude-project": TEAM_FILE, "claude-local": PERSONAL_FILE, "fenced-block": TEAM_FILE }; + const key = dataDir ? stateKey(root) : null; + const state = key && join(dataDir, "view-settings", key, "retired-notices.json"); + let seen = {}; + try { + seen = state ? JSON.parse(readFileSync(state, "utf8")) : {}; + } catch { + seen = {}; + } + const notices = []; + let changed = false; + const order = Object.keys(destination); + for (const { path, kind } of [...legacy].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || a.path.localeCompare(b.path))) { + const bytes = readBytes(path); + if (!bytes) continue; + const shown = where(path); + const hash = createHash("sha256").update(bytes).digest("hex"); + if (seen[shown] === hash) continue; + [seen[shown], changed] = [hash, true]; + const value = mediumIn(bytes.toString("utf8")); + const to = destination[kind] ?? TEAM_FILE; + const caveat = + value && mediumOf(value)?.publishes + ? to === TEAM_FILE + ? "; a team file never publishes, so that value would be ignored there" + : to === PERSONAL_FILE + ? "; it publishes only while that file is untracked and gitignored" + : "" + : ""; + notices.push({ kind: "retired", path: shown, value, destination: to, text: `${shown} is no longer read${value ? ` (it sets medium: ${value})` : ""}; set it in ${to} instead${caveat}` }); + } + if (state && changed) { + try { + writeAtomic(state, `${JSON.stringify(seen)}\n`); + } catch { + // Unwritable state shows the notice again next time, which is the safe direction. + } + } + return notices; +} + +// ------------------------------------------------------------ resolution + +/** True when the reader chose a publishing medium (the argument, or a trusted scope), which the publish gate takes as explicit: never the plugin default. */ +export const gateExplicit = ({ preferred, decidedBy }) => Boolean(mediumOf(preferred)?.publishes) && ["argument", "userConfig", "personal override"].includes(decidedBy); + +/** + * @param {{lane: string, argument?: string|null, userConfig?: string|null, projectRoot?: string|null, teamRef?: string|null}} request + * @param {{userHome?: string, dataDir?: string|null, interactive?: boolean}} [options] + * @returns {{preferred: string|null, medium: string|null, decidedBy: string, notices: {kind: string, text: string}[]}} + */ +export function resolveMedium({ lane, argument = null, userConfig = null, projectRoot = null, teamRef = null }, { userHome = homedir(), dataDir = null, interactive = true } = {}) { + const laneEntry = laneOf(lane); + if (!laneEntry) throw new Error(`view-settings: unknown lane ${JSON.stringify(lane)}`); + const notices = []; + const root = repoScopes(projectRoot, userHome) ? projectRoot : null; + const team = root && typeof teamRef === "string" ? teamAt(root, teamRef, notices) : undefined; + const { layers, legacy } = readLayers({ plugin: CONCERN, projectRoot: root, schema: SCHEMA, userConfig: { medium: userConfig ?? "" }, team, userHome }); + notices.unshift(...retiredNotices(legacy, { plugin: lane.split(":")[0], root, userHome, dataDir })); + const finish = (preferred, decidedBy) => { + if (!preferred) return { preferred: null, medium: null, decidedBy, notices }; + const { medium, fell } = fallBack(preferred, laneEntry, { interactive }); + return { preferred, medium, decidedBy: fell ? `fell back: ${fell}` : decidedBy, notices }; + }; + if (mediumOf(argument)) return finish(argument, "argument"); + + const shown = (path) => (path && root && isAbsolute(path) ? relative(root, path).split("\\").join("/") : path); + for (const layer of layers) { + for (const error of layer.errors) { + const prefix = `${layer.name}${layer.path ? ` (${layer.path})` : ""}: `; + const name = { local: "personal override" }[layer.name] ?? layer.name; + notices.push({ kind: "invalid", text: `${name}${layer.path ? ` ${shown(layer.path)}` : ""}: ${error.startsWith(prefix) ? error.slice(prefix.length) : error}; ignored` }); + } + } + const valueOf = (name) => layers.find((l) => l.name === name)?.values.medium; + const personal = valueOf("local"); + let personalTrusted = false; + if (personal && mediumOf(personal)?.publishes) { + const warnings = []; + personalTrusted = overlayApplies(root, join(root, PERSONAL_FILE), warnings, { requireIgnored: true }); + for (const w of warnings) notices.push({ kind: "ignored", text: `personal override ${w.replace(/^overlay /, "")}` }); + } + // Highest precedence first (ADR 0075: the personal override, then team, then userConfig). + const scopes = [ + ["personal override", personal, personalTrusted], + ["team", valueOf("team"), false], + ["userConfig", valueOf("userConfig"), true], + ].filter(([, medium]) => medium && medium !== NO_PREFERENCE); + const local = scopes.find(([, medium]) => !mediumOf(medium).publishes); + if (local) return finish(local[1], local[0]); + const teamValue = scopes.find(([name]) => name === "team")?.[1]; + if (teamValue) notices.push({ kind: "ignored", text: `team ${shown(layers.find((l) => l.name === "team").path)} sets medium: ${teamValue}; a team file never publishes, so it is ignored` }); + const trusted = scopes.find(([, , ok]) => ok); + return trusted ? finish(trusted[1], trusted[0]) : finish(laneEntry.default, "plugin default"); +} + +// ------------------------------------------------------------ CLI + +const USAGE = "usage: view-settings.mjs resolve --lane --option [--argument ] [--team-ref ]\n"; + +/** CLI: the argument vector after the script path; returns the exit code. */ +export function main(argv, env = process.env) { + const [action, ...rest] = argv; + const request = { lane: null, userConfig: undefined, argument: null, teamRef: null }; + const flags = { "--lane": "lane", "--option": "userConfig", "--argument": "argument", "--team-ref": "teamRef" }; + for (let i = 0; i < rest.length; i += 2) { + const key = flags[rest[i]]; + if (!key || rest[i + 1] === undefined) return usage(); + request[key] = rest[i + 1]; + } + if (action !== "resolve" || !laneOf(request.lane) || request.userConfig === undefined) return usage(); + if (request.argument === NO_PREFERENCE) request.argument = null; + if (request.argument !== null && !mediumOf(request.argument)) return usage(); + const ci = Boolean(env.CI) && env.CI !== "false"; + const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); + process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + return 0; +} + +function usage() { + process.stderr.write(USAGE); + return 2; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/plugins/overengineering/lib/config-cascade.mjs b/plugins/overengineering/lib/config-cascade.mjs new file mode 100644 index 00000000000..b5f22dc12e2 --- /dev/null +++ b/plugins/overengineering/lib/config-cascade.mjs @@ -0,0 +1,571 @@ +// GENERATED from lib/config-cascade.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// The four-layer plugin config cascade (docs/adr/0075): later layers win per key. +// +// resolve({plugin, projectRoot, home, schema, defaults, userConfig, teamOnly, floors, userHome}) +// -> {values, provenance: {"a.b": layer | [layers]}, prose: [paths], sections: [{heading, text, layer}], +// layers: [{name, path, state: "loaded" | "absent" | "invalid", errors, error_keys}], legacy: [{path, kind}]} +// +// readLayers({...}) -> each layer's own accepted values, unmerged (see its comment below) +// +// `error_keys[i]` tags `errors[i]`: the dotted key the error names (key-level), or null when it is +// about the layer as a whole (file-level: a file refused or unparsable, a value that is not a mapping). +// +// Layers, in order: "defaults" (the `defaults` object, already parsed by the caller), "userConfig" +// (the plugin's userConfig values, flattened names), "team" (/) and "local", the +// personal overlay (/.local). A file layer reads .yaml for config and .md +// for prose; it is absent when neither file exists. `home` is the convention home the caller +// resolved, relative to projectRoot unless absolute (default docs/conventions). There is no +// user-global file layer: a file under /docs/conventions is never read, and `userHome` +// (default os.homedir()) only locates the legacy files reported in `legacy`. `schema` is a JSON +// Schema object; the subset honored is type (object, string, array, null, integer, number, boolean), +// enum, properties, additionalProperties (false or a schema), items, minimum, maximum, minItems and +// maxItems. +// +// A key is a schema leaf: any node that is not an object with `properties`, or a path named in +// `teamOnly`. The setup writer's top-level `written_against` and `provenance` records are not keys, +// declared or not: every layer skips them, so they reach neither `values` nor a userConfig name. +// Each layer's leaves are checked one by one; a leaf whose value fails the schema is +// dropped from that layer with an error naming layer, file and key, and a lower layer's value is +// then never used (ADR 0075, the invalid-value rule): a valid higher layer still wins, otherwise the +// key takes its default. The personal overlay is the one exception, because it refines the team +// file: an invalid personal value leaves the team value standing. A key containing `.` is +// rejected the same way, since keys are joined with `.` into paths, and so are `__proto__`, +// `constructor` and `prototype`, at any depth inside a value too, which a caller's merge would treat +// as more than data. A refused file makes its layer invalid; the layer's other file still applies. The team and +// local layers come from the repository: each reads only regular files, never a symlink, and is +// invalid when a relative `home` resolves outside projectRoot. A team-only key is +// accepted in "defaults" and "team" and rejected elsewhere. A YAML file that does not parse, or +// whose top level is not a mapping, makes its layer invalid and contributes no values; its .md +// file still supplies prose. A leaf whose last segment is `disable` and whose value is a list is the +// union of every layer's list, and its provenance is an array of the layer names that set it, in +// layer order; every other key's provenance is a single layer-name string. +// +// Policy floors. `floors` maps a key path to a `Floor direction` value (the Implementers table of +// docs/contracts/config-cascade/README.md; the closed set is no-floor, team-only, lowest-value, +// any-layer-wins and stricter-value), or to {direction, scale} where stricter-value takes a scale +// from loosest to strictest (default: the leaf's schema `enum` order). When the team layer set a +// floor key, a personal value is accepted only if it is no looser in that direction: team-only takes +// an equal value (a list must keep every team item); lowest-value a number no higher; any-layer-wins +// a tightening value (`true` or "report") whenever the team value is one; stricter-value a value at +// or beyond the team value on the scale. A looser value is dropped with an error naming the layer, +// file and key, and the team value stands. Floors bind the personal overlay; a `*.disable` union key +// is not compared. +// +// userConfig maps each schema leaf path to its name joined with `_` (widget.limit is widget_limit); +// two leaves with one name throw. An empty string or an unrendered `${user_config.` placeholder is +// unset, names outside the schema are ignored, and a list, mapping or team-only key is rejected. +// A string value takes the leaf's integer, number or boolean type when the leaf does not accept +// strings. Legacy locations (.claude/.* under userHome and projectRoot, and a fenced +// `yaml config` block in the team .md) are reported in `legacy`, never read for values. +// +// Prose. A `.local.md` H2 section (a `## ` heading outside a code fence) replaces the team `.md` +// section with the same heading; every other team section is kept, a local section with a new +// heading follows them, and the team text before the first H2 is kept. `sections` lists the result in +// order, each with the layer it came from (`heading` is null for text before the first H2). +// This module reads files only. File contents are data; error messages quote them as JSON strings. +import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"; +import { parse } from "./yaml-subset.mjs"; + +const PLUGIN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SCALARS = ["string", "integer", "number", "boolean", "null"]; +const DIRECTIONS = ["no-floor", "team-only", "lowest-value", "any-layer-wins", "stricter-value"]; +const clip = (text) => (text.length > 60 ? `${text.slice(0, 60)}...` : text); +const quote = (text) => JSON.stringify(clip(text)); +const show = (value) => clip(JSON.stringify(value) ?? String(value)); +const put = (object, key, value) => Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true }); +const isMapping = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const RESERVED = new Set(["__proto__", "constructor", "prototype"]); +const WRITER_KEYS = new Set(["written_against", "provenance"]); +const isWriterKey = (parts, key) => !parts.length && WRITER_KEYS.has(key); +const newLayer = (name, path, state, errors = [], error_keys = errors.map(() => null)) => ({ name, path, state, errors, error_keys }); +/** Records an error on `layer`, tagged with the dotted key it names, or null for a file-level one. */ +const record = (layer, message, key = null) => { + layer.errors.push(message); + layer.error_keys.push(key); +}; + +/** {ok} for a readable layer file, {refused: why} for one that must not be read, {} when absent. A + * repository layer refuses a symlink, which could reach a file outside the checkout; every layer + * refuses anything but a regular file, since a device or FIFO never finishes reading. */ +function fileState(path, repo) { + let st; + try { + st = lstatSync(path); + } catch { + return {}; + } + if (st.isSymbolicLink()) { + if (repo) return { refused: "is a symlink; a repository layer reads only regular files" }; + try { + st = statSync(path); + } catch { + return {}; + } + } + return st.isFile() ? { ok: true } : { refused: "is not a regular file" }; +} + +/** True when `dir` exists and its real path lies outside the real path of `root`. */ +function escapes(dir, root) { + try { + const rel = relative(realpathSync(root), realpathSync(dir)); + return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel); + } catch { + return false; + } +} +const typesOf = (node) => (node.type === undefined ? null : [node.type].flat()); + +const fits = { + object: isMapping, + array: Array.isArray, + string: (v) => typeof v === "string", + integer: Number.isInteger, + number: (v) => typeof v === "number" && Number.isFinite(v), + boolean: (v) => typeof v === "boolean", + null: (v) => v === null, +}; + +/** The schema for `key` under `node`: a schema, null when anything goes, false when it is unknown. */ +function child(node, key) { + if (isMapping(node.properties) && Object.hasOwn(node.properties, key)) return node.properties[key]; + if (node.additionalProperties === false) return false; + return isMapping(node.additionalProperties) ? node.additionalProperties : null; +} + +const isBranch = (node, path, teamOnly) => + isMapping(node) && isMapping(node.properties) && !teamOnly.has(path) && (typesOf(node)?.includes("object") ?? true); + +/** Why `value` does not fit `node`, as messages relative to the key; empty when it fits. */ +function check(value, node, at = "") { + const prefix = at ? `${at}: ` : ""; + const types = typesOf(node); + if (types && !types.some((t) => fits[t]?.(value))) return [`${prefix}expected ${types.join(" or ")}, got ${show(value)}`]; + if (Array.isArray(node.enum) && !node.enum.includes(value)) return [`${prefix}${show(value)} is not one of ${node.enum.map(show).join(", ")}`]; + if (typeof value === "number" && typeof node.minimum === "number" && value < node.minimum) return [`${prefix}${show(value)} must be at least ${node.minimum}`]; + if (typeof value === "number" && typeof node.maximum === "number" && value > node.maximum) return [`${prefix}${show(value)} must be at most ${node.maximum}`]; + if (Array.isArray(value) && typeof node.minItems === "number" && value.length < node.minItems) return [`${prefix}needs at least ${node.minItems} items, got ${value.length}`]; + if (Array.isArray(value) && typeof node.maxItems === "number" && value.length > node.maxItems) return [`${prefix}allows at most ${node.maxItems} items, got ${value.length}`]; + // An open node ({}) checks nothing but reserved names, which are refused at every depth. + if (Array.isArray(value)) return value.flatMap((item, i) => check(item, isMapping(node.items) ? node.items : {}, `${at}[${i}]`)); + if (!isMapping(value)) return []; + return Object.keys(value).flatMap((key) => { + const where = `${at}.${quote(key)}`; + if (RESERVED.has(key)) return [`${where}: a reserved name, never a config key`]; + const sub = child(node, key); + if (sub === false) return [`${where}: unknown key`]; + return check(value[key], sub ?? {}, where); + }); +} + +/** The accepted leaves of one layer's mapping, as [parts, value] pairs; rejections go to `fail` + * with the dotted key, and `true` third when the value itself was the problem. */ +function leaves(data, schema, { teamOnly, allowTeamOnly, fail }) { + const out = []; + const walk = (object, node, parts) => { + for (const key of Object.keys(object)) { + if (isWriterKey(parts, key)) continue; + const path = [...parts, key]; + const dotted = path.join("."); + const sub = child(node, key); + const value = object[key]; + if (RESERVED.has(key)) fail(`${quote(dotted)}: a reserved name, never a config key`, dotted); + else if (key.includes(".")) fail(`${quote(dotted)}: a key may not contain "."; nest it instead`, dotted); + else if (sub === false) fail(`${quote(dotted)}: unknown key`, dotted); + else if (teamOnly.has(dotted) && !allowTeamOnly) fail(`${quote(dotted)}: accepted only in the team layer`, dotted); + else if (sub && isBranch(sub, dotted, teamOnly)) { + if (isMapping(value)) walk(value, sub, path); + else if (value !== null) fail(`${quote(dotted)}: expected a mapping, got ${show(value)}`, dotted, true); + } else { + const problems = check(value, sub ?? {}); + if (problems.length) fail(`${quote(dotted)}: ${problems.join("; ")}`, dotted, true); + else out.push([path, value]); + } + } + }; + walk(data, schema, []); + return out; +} + +/** Flattened userConfig name to {path, node} for every schema leaf. */ +function flatNames(schema, teamOnly) { + const names = new Map(); + const walk = (node, parts) => { + for (const key of Object.keys(isMapping(node.properties) ? node.properties : {})) { + if (isWriterKey(parts, key)) continue; + const path = [...parts, key]; + const dotted = path.join("."); + const sub = node.properties[key]; + if (isBranch(sub, dotted, teamOnly)) { + walk(sub, path); + continue; + } + const name = path.join("_"); + if (names.has(name)) throw new Error(`config-cascade: schema keys ${names.get(name).dotted} and ${dotted} both flatten to userConfig name ${name}`); + names.set(name, { path, dotted, node: sub }); + } + }; + walk(schema, []); + return names; +} + +function coerce(raw, node) { + const types = typesOf(node) ?? []; + if (typeof raw !== "string" || types.includes("string")) return raw; + if ((types.includes("integer") || types.includes("number")) && /^[-+]?\d+(\.\d+)?$/.test(raw)) return Number(raw); + if (types.includes("boolean") && (raw === "true" || raw === "false")) return raw === "true"; + return raw; +} + +function userConfigLeaves(userConfig, names, teamOnly, fail) { + const out = []; + for (const [name, raw] of Object.entries(userConfig)) { + if (raw === undefined || raw === null || raw === "" || (typeof raw === "string" && raw.startsWith("${user_config."))) continue; + const leaf = names.get(name); + if (!leaf) continue; + const types = typesOf(leaf.node); + if (teamOnly.has(leaf.dotted)) fail(`${quote(leaf.dotted)}: accepted only in the team layer`, leaf.dotted); + else if (types && !types.every((t) => SCALARS.includes(t))) fail(`${quote(leaf.dotted)}: lists and mappings are set only in files`, leaf.dotted); + else { + const value = coerce(raw, leaf.node); + const problems = check(value, leaf.node); + if (problems.length) fail(`${quote(leaf.dotted)}: ${problems.join("; ")}`, leaf.dotted, true); + else out.push([leaf.path, value]); + } + } + return out; +} + +/** One file layer: its report entry, its parsed mapping (or null), and its prose path (or null). */ +function readLayer(name, stem, repo) { + const path = `${stem}.yaml`; + const md = `${stem}.md`; + const layer = newLayer(name, path, "absent"); + const mdFile = fileState(md, repo); + if (mdFile.refused) { + layer.state = "invalid"; + record(layer, `${name} (${md}): ${mdFile.refused}`); + } + const prose = mdFile.ok ? md : null; + if (prose) layer.state = "loaded"; + const yamlFile = fileState(path, repo); + if (yamlFile.refused) { + layer.state = "invalid"; + record(layer, `${name} (${path}): ${yamlFile.refused}`); + } + if (!yamlFile.ok) return { layer, data: null, prose }; + return { layer, data: parseInto(layer, () => readFileSync(path, "utf8")), prose }; +} + +/** The mapping `text()` parses to, loading `layer`; null, with the layer invalid, when it does not parse or is not a mapping. */ +function parseInto(layer, text) { + try { + const doc = parse(text()) ?? {}; + if (!isMapping(doc)) throw new Error("the top level must be a mapping"); + if (layer.state !== "invalid") layer.state = "loaded"; + return doc; + } catch (e) { + layer.state = "invalid"; + record(layer, `${layer.name} (${layer.path}): ${e.message}`); + return null; + } +} + +/** True when the Markdown file holds a ``` config block outside any other fence. */ +function hasConfigBlock(path) { + if (!fileState(path, true).ok) return false; + let text; + try { + text = readFileSync(path, "utf8"); + } catch { + return false; + } + let fence = null; // the open fence's marker run, backticks or tildes + for (const line of text.split(/\r\n|\r|\n/)) { + const run = line.match(/^(`{3,}|~{3,})/)?.[0]; + if (fence) { + if (run?.[0] === fence[0] && run.length >= fence.length && line.trimEnd().length === run.length) fence = null; + } else if (/^```[A-Za-z0-9_-]+ config\s*$/.test(line)) return true; + else if (run) fence = run; + } + return false; +} + +/** A Markdown text as [{heading, text}], split at each H2 outside a code fence; the first entry holds + * the text before the first H2 (heading null) and is dropped when empty. */ +function splitSections(text) { + const out = [{ heading: null, lines: [] }]; + let fence = null; + for (const line of text.split(/(?<=\n)/)) { + const run = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1]; + if (fence) { + if (run?.[0] === fence[0] && run.length >= fence.length && line.trim().length === run.length) fence = null; + } else if (run) fence = run; + else { + const h = line.match(/^## +(.*?)(?: +#+)?\s*$/); + if (h) { + out.push({ heading: h[1].replace(/\s+/g, " ").trim(), lines: [line] }); + continue; + } + } + out.at(-1).lines.push(line); + } + return out.map(({ heading, lines }) => ({ heading, text: lines.join("") })).filter((s) => s.heading !== null || s.text.trim() !== ""); +} + +function readText(path) { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** The team sections with each same-heading local section in its place, then the local sections the + * team lacks. The first team section with a heading is replaced; a later team repeat of it is dropped. */ +function mergeSections(teamText, localText) { + const team = teamText === null ? [] : splitSections(teamText).map((s) => ({ ...s, layer: "team" })); + const local = localText === null ? [] : splitSections(localText).map((s) => ({ ...s, layer: "local" })); + const replacement = new Map(local.filter((s) => s.heading !== null).map((s) => [s.heading, s])); + const used = new Set(); + const out = []; + for (const s of team) { + if (s.heading === null || !replacement.has(s.heading)) out.push(s); + else if (!used.has(s.heading)) { + used.add(s.heading); + out.push(replacement.get(s.heading)); + } + } + const teamHeadings = new Set(team.map((s) => s.heading)); + for (const s of local) { + // Text before the first H2 follows the team's own only when the team has no file; a repeated heading keeps its last section. + const fresh = s.heading === null ? team.length === 0 : !teamHeadings.has(s.heading) && replacement.get(s.heading) === s; + if (fresh) out.push(s); + } + return out; +} + +function legacyFiles(dir, plugin, kind) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + return entries.filter((e) => e.isFile() && e.name.startsWith(`${plugin}.`)).map((e) => ({ path: join(dir, e.name), kind: kind(e.name) })); +} + +function setPath(object, parts, value) { + let at = object; + for (const part of parts.slice(0, -1)) { + if (!Object.hasOwn(at, part) || !isMapping(at[part])) put(at, part, {}); + at = at[part]; + } + put(at, parts.at(-1), value); +} + +/** Validated floors: dotted key to {direction, scale}. */ +function normalizeFloors(floors) { + if (!isMapping(floors)) throw new Error("config-cascade: floors must be a mapping of key path to Floor direction"); + const out = new Map(); + for (const [key, raw] of Object.entries(floors)) { + const direction = typeof raw === "string" ? raw : raw?.direction; + if (!DIRECTIONS.includes(direction)) throw new Error(`config-cascade: floor ${JSON.stringify(key)} has direction ${show(direction)}, not one of ${DIRECTIONS.join(", ")}`); + out.set(key, { direction, scale: isMapping(raw) && Array.isArray(raw.scale) ? raw.scale : null }); + } + return out; +} + +function nodeAt(schema, parts) { + let node = schema; + for (const part of parts) { + node = node && child(node, part); + if (!node) return null; + } + return node; +} + +const tightening = (v) => v === true || v === "report"; + +/** Why `personal` loosens `team` in the floor's direction, or null when it does not. */ +function looser(floor, personal, team, node) { + if (JSON.stringify(personal) === JSON.stringify(team)) return null; + switch (floor.direction) { + case "team-only": + if (Array.isArray(personal) && Array.isArray(team) && team.every((t) => personal.some((p) => JSON.stringify(p) === JSON.stringify(t)))) return null; + return `${show(personal)} differs from the team value ${show(team)}, and the team value wins`; + case "lowest-value": + if (typeof personal === "number" && typeof team === "number" && personal <= team) return null; + return `${show(personal)} is not lower than or equal to the team value ${show(team)}`; + case "any-layer-wins": + if (!tightening(team) || tightening(personal)) return null; + return `${show(personal)} drops the team value ${show(team)}, which is the tightening one`; + case "stricter-value": { + const scale = floor.scale ?? node?.enum ?? []; + const a = scale.findIndex((v) => JSON.stringify(v) === JSON.stringify(personal)); + const b = scale.findIndex((v) => JSON.stringify(v) === JSON.stringify(team)); + if (a >= 0 && b >= 0 && a >= b) return null; + return `${show(personal)} is not as strict as the team value ${show(team)} on the scale ${show(scale)}`; + } + default: + return null; + } +} + +export function resolve({ plugin, projectRoot, home = "docs/conventions", schema, defaults, userConfig, teamOnly = [], floors = {}, userHome = homedir() }) { + if (typeof plugin !== "string" || !PLUGIN.test(plugin)) throw new Error(`config-cascade: plugin name ${JSON.stringify(plugin)} is not a plain name`); + const only = new Set(teamOnly); + const floorMap = normalizeFloors(floors); + const names = flatNames(schema, only); + const homeDir = resolvePath(projectRoot, home); + // A relative home is inside the checkout by intent; a committed symlink must not carry it out. + const homeEscapes = !isAbsolute(home) && escapes(homeDir, projectRoot); + const layers = []; + const contributions = []; // [layer name, [[parts, value]], dotted keys whose value was invalid] + const prose = []; + const proseText = {}; + + // pairsOf(fail) returns the layer's accepted [parts, value] pairs; falsy when it contributes none. + const take = (layer, pairsOf) => { + const invalid = new Set(); + const fail = (message, key, valueFailed = false) => { + record(layer, `${layer.name}${layer.path ? ` (${layer.path})` : ""}: ${message}`, key); + if (valueFailed) invalid.add(key); + }; + if (pairsOf) contributions.push([layer.name, pairsOf(fail), invalid]); + layers.push(layer); + }; + const fileLeaves = (data, allowTeamOnly) => data && ((fail) => leaves(data, schema, { teamOnly: only, allowTeamOnly, fail })); + + const shaped = (name, given) => { + if (given === undefined || given === null) return { layer: newLayer(name, null, "absent"), ok: false }; + if (isMapping(given)) return { layer: newLayer(name, null, "loaded"), ok: true }; + return { layer: newLayer(name, null, "invalid", [`${name}: expected a mapping, got ${show(given)}`]), ok: false }; + }; + const d = shaped("defaults", defaults); + take(d.layer, d.ok && fileLeaves(defaults, true)); + const u = shaped("userConfig", userConfig); + take(u.layer, u.ok && ((fail) => userConfigLeaves(userConfig, names, only, fail))); + for (const [name, stem] of [ + ["team", join(homeDir, plugin)], + ["local", join(homeDir, `${plugin}.local`)], + ]) { + if (homeEscapes) { + const why = "the convention home resolves outside the project; a repository layer stays inside it"; + take(newLayer(name, `${stem}.yaml`, "invalid", [`${name} (${homeDir}): ${why}`])); + continue; + } + const read = readLayer(name, stem, true); + take(read.layer, fileLeaves(read.data, name === "team")); + if (read.prose) { + prose.push(read.prose); + proseText[name] = readText(read.prose); + } + } + + const merged = new Map(); // dotted -> {parts, value, from} + let base = new Map(); // the defaults layer's entries, where an invalid higher value falls back to + const within = (dotted, key) => dotted === key || dotted.startsWith(`${key}.`); + for (const [name, pairs, invalid] of contributions) { + // A lower layer's value is never used for an invalid one; the personal overlay refines the team file, so its keys fall to team. + if (name !== "local") { + for (const key of invalid) { + for (const dotted of [...merged.keys()]) if (within(dotted, key)) merged.delete(dotted); + for (const [dotted, entry] of base) if (within(dotted, key)) merged.set(dotted, entry); + } + } + const layer = layers.find((l) => l.name === name); + for (const [parts, value] of pairs) { + const dotted = parts.join("."); + const prior = merged.get(dotted); + const floor = floorMap.get(dotted); + const union = parts.at(-1) === "disable" && Array.isArray(value); + if (name === "local" && floor && floor.direction !== "no-floor" && !union && prior && [prior.from].flat().includes("team")) { + const why = looser(floor, value, prior.value, nodeAt(schema, parts)); + if (why) { + record(layer, `local (${layer.path}): ${quote(dotted)}: loosens the ${floor.direction} policy floor: ${why}`, dotted); + continue; + } + } + if (union) { + const items = Array.isArray(prior?.value) ? [...prior.value] : []; + const seen = new Set(items.map((i) => JSON.stringify(i))); + for (const item of value) { + const key = JSON.stringify(item); + if (!seen.has(key)) items.push(item); + seen.add(key); + } + // An empty list adds nothing, so its layer is credited only when no layer set the key before. + const from = Array.isArray(prior?.from) ? prior.from : []; + merged.set(dotted, { parts, value: items, from: value.length > 0 || !prior ? [...from, name] : from }); + } else merged.set(dotted, { parts, value, from: name }); + } + if (name === "defaults") base = new Map(merged); + } + const values = {}; + const provenance = {}; + for (const [dotted, { parts, value, from }] of merged) { + setPath(values, parts, value); + put(provenance, dotted, from); + } + + const legacy = legacyOf(plugin, userHome, projectRoot, !homeEscapes && `${join(homeDir, plugin)}.md`); + const sections = mergeSections(proseText.team ?? null, proseText.local ?? null).map(({ heading, text, layer }) => ({ heading, text, layer })); + return { values, provenance, prose, sections, layers, legacy }; +} + +/** The legacy locations that exist; `teamMd` (or false) is the team .md checked for a fenced config block. */ +function legacyOf(plugin, userHome, projectRoot, teamMd) { + const found = [ + ...legacyFiles(join(userHome, ".claude"), plugin, () => "claude-user"), + ...(projectRoot ? legacyFiles(join(projectRoot, ".claude"), plugin, (n) => (n.startsWith(`${plugin}.local.`) ? "claude-local" : "claude-project")) : []), + ...(teamMd && hasConfigBlock(teamMd) ? [{ path: teamMd, kind: "fenced-block" }] : []), + ]; + // The project root may be the user home, so a file is reported under its first kind only. + return found.filter((entry, i) => found.findIndex((e) => e.path === entry.path) === i); +} + +/** + * Each layer's own accepted values, unmerged, for a caller that weighs the layers itself (the + * rendered-views publish rule reads every layer separately: lib/view-settings.mjs). The same reader, + * refusals and schema check as resolve(), with no precedence, floors or invalid-value fallback: an + * invalid value leaves only its own layer. Layers: "userConfig", then "team" and "local" when + * projectRoot is set. `team`, as {path, text}, replaces the working-tree team file (text null: + * absent), for a caller reading it from a git ref. + * readLayers({plugin, projectRoot, home, schema, userConfig, teamOnly, team, userHome}) + * -> {layers: [{name, path, state, errors, error_keys, values: {"a.b": value}}], legacy} + */ +export function readLayers({ plugin, projectRoot = null, home = "docs/conventions", schema, userConfig, teamOnly = [], team, userHome = homedir() }) { + if (typeof plugin !== "string" || !PLUGIN.test(plugin)) throw new Error(`config-cascade: plugin name ${JSON.stringify(plugin)} is not a plain name`); + const only = new Set(teamOnly); + const layers = []; + const add = (layer, pairsOf) => { + const fail = (message, key) => record(layer, `${layer.name}${layer.path ? ` (${layer.path})` : ""}: ${message}`, key); + const values = {}; + for (const [parts, value] of pairsOf ? pairsOf(fail) : []) put(values, parts.join("."), value); + layers.push({ ...layer, values }); + }; + if (isMapping(userConfig)) add(newLayer("userConfig", null, "loaded"), (fail) => userConfigLeaves(userConfig, flatNames(schema, only), only, fail)); + if (!projectRoot) return { layers, legacy: legacyOf(plugin, userHome, null, false) }; + const homeDir = resolvePath(projectRoot, home); + const homeEscapes = !isAbsolute(home) && escapes(homeDir, projectRoot); + for (const [name, stem] of [ + ["team", join(homeDir, plugin)], + ["local", join(homeDir, `${plugin}.local`)], + ]) { + const fileLeaves = (data) => data && ((fail) => leaves(data, schema, { teamOnly: only, allowTeamOnly: name === "team", fail })); + if (name === "team" && team) { + const layer = newLayer(name, team.path, "absent"); + add(layer, typeof team.text === "string" && fileLeaves(parseInto(layer, () => team.text))); + } else if (homeEscapes) { + add(newLayer(name, `${stem}.yaml`, "invalid", [`${name} (${homeDir}): the convention home resolves outside the project; a repository layer stays inside it`])); + } else { + const read = readLayer(name, stem, true); + add(read.layer, fileLeaves(read.data)); + } + } + return { layers, legacy: legacyOf(plugin, userHome, projectRoot, !homeEscapes && `${join(homeDir, plugin)}.md`) }; +} diff --git a/plugins/overengineering/lib/state-key.sh b/plugins/overengineering/lib/state-key.sh new file mode 100755 index 00000000000..78a36277953 --- /dev/null +++ b/plugins/overengineering/lib/state-key.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# GENERATED from lib/state-key.sh by scripts/sync-shared-copies.sh. Do not edit this copy: +# edit the canonical source, then rerun the script. +# Per-project state key for anything a plugin writes under ${CLAUDE_PLUGIN_DATA}. +# +# WHY. ${CLAUDE_PLUGIN_DATA} is keyed to the plugin identifier and nothing else: +# https://code.claude.com/docs/en/plugins/manifest-reference#environment-variables +# (as of 2026-10-11; recheck when that section adds a project, checkout, +# worktree or session segment to the path). So a skill that writes a fixed filename there has one file per +# MACHINE: every run from every repository overwrites the last, and a skill that +# READS it back can serve one project's findings as another's. +# +# This prints the missing segment. The scheme is canonically defined by the +# marketplace's plugin-data-report-keying convention (rule 1); this header keeps +# a named operational duplicate of that definition so the executable ships +# self-described: +# +# = / +# +# repo-identity the first configured remote URL normalized to +# host/owner/repo, lowercased, scheme/credentials/.git +# stripped. No remote -> local/. +# Not a repo at all -> nonrepo/. +# worktree-discriminator sha256 of the canonicalized worktree root, cut to 8 +# (outside a repository, of the physical cwd). Two +# worktrees of one repository legitimately hold +# different content and must not share a report. +# +# Every path is taken physical (symlinks resolved) before it is hashed. `cd` +# keeps the logical spelling a symlink was reached through, and the read-back +# consumers derive the key from wherever the operator launched, so one +# directory reached two ways must still produce one key. git canonicalizes +# the root it reports; the non-repository rung has to do it itself. +# +# SECURITY — this is why the identity is validated rather than merely lowercased. +# A remote URL is arbitrary text that becomes DIRECTORY COMPONENTS in the caller's +# path. A remote of `../../../etc` would walk a report out of the plugin's own +# namespace. Any identity that is not a plain lowercase segment path is replaced +# by a hash, so it still keys deterministically and still stays inside the +# namespace. Every rejected shape is covered by state-key.test.sh. +# +# NOT a substitute for a per-run filename. Keying stops cross-project collision; +# it does not stop a same-project rerun overwriting yesterday's report. A caller +# that needs history writes one file per run under this key and appends a line +# to a history file — see docs/contracts/plugin-data-report-keying/. +# +# Usage: +# state-key.sh [--root ] [--explain] +# +# --root derive for that directory instead of the current one +# --explain write the rung taken and its inputs to stderr +# +# Exit: 0 always on a successful derivation — every input reaches some rung, so +# there is no "cannot key" outcome. 2 on a bad argument, an unusable --root, or +# a PATH with neither sha256sum nor shasum. +# +# Shared source: lib/state-key.sh at the marketplace repo root is the canonical source; the copies in the plugins +# that carry it are generated by scripts/sync-shared-copies.sh and registered in +# scripts/shared-copies.txt. Edit this file and rerun the script. + +set -uo pipefail + +# `cd` consults CDPATH when the operand is relative and does not start with `.`, +# and on a hit it ECHOES the resolved path to stdout and lands somewhere the +# caller never named. Either one breaks this script: stdout must be exactly the +# key, and the key must describe the requested directory. Clear it once, here. +CDPATH= + +usage() { + cat <<'EOF' +state-key.sh — per-project state key for ${CLAUDE_PLUGIN_DATA} writes. + +Prints / for a directory, so a report +persisted under the plugin data directory belongs to one project rather than to +the machine. + +Usage: + state-key.sh [--root ] [--explain] [--help] + + --root derive for that directory instead of the current one + --explain write the rung taken and its inputs to stderr + +Exit: 0 on a derivation; 2 on a bad argument, an unusable --root, or no hash tool. +EOF +} + +ROOT_ARG="" +EXPLAIN=0 +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --root) + if [[ $# -lt 2 ]]; then + echo "ERROR: --root needs a path" >&2 + exit 2 + fi + ROOT_ARG="$2" + shift 2 + ;; + --explain) + EXPLAIN=1 + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -n "$ROOT_ARG" ]]; then + if [[ ! -d "$ROOT_ARG" ]]; then + echo "ERROR: --root is not a directory: $ROOT_ARG" >&2 + exit 2 + fi + cd "$ROOT_ARG" || exit 2 +fi +# Physical cwd, forklessly: `cd -P .` rewrites $PWD with symlinks resolved. +cd -P . || exit 2 + +# sha256sum is absent on stock macOS; shasum -a 256 is the portable partner. +# Probe here in the main script, not inside sha256(). hash12/hash8 run that +# helper as a non-last pipeline stage inside a command substitution, and POSIX +# XCU 2.6.3 plus Bash's pipeline rule both execute that in a subshell, so +# `exit 2` there cannot terminate this process. Under `set -uo pipefail` (no +# -e) the script would then print a malformed key at exit 0. +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256; } +else + echo "ERROR: no sha256sum or shasum on PATH — cannot derive a state key" >&2 + exit 2 +fi + +hash12() { printf '%s' "$1" | sha256 | cut -c1-12; } +hash8() { printf '%s' "$1" | sha256 | cut -c1-8; } + +# "the FIRST configured remote" — not necessarily one named `origin`. A repo +# whose only remote is `upstream` still has a remote and must not fall through +# to the local rung. +remote_name=$(git remote 2>/dev/null | tr -d '\r' | head -1) +remote="" +if [[ -n "$remote_name" ]]; then + remote=$(git config --get "remote.${remote_name}.url" 2>/dev/null | tr -d '\r') +fi +# tr -d '\r': Git on Windows can return a CRLF-terminated path. +root=$(git rev-parse --show-toplevel 2>/dev/null | tr -d '\r') + +rung="" +if [[ -n "$remote" ]]; then + identity=$(printf '%s' "$remote" | + sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' | + tr '[:upper:]' '[:lower:]') + # A remote URL is arbitrary text and becomes DIRECTORY COMPONENTS here, so + # accept it only in the shape the scheme means — segments of [a-z0-9._-] each + # starting alphanumeric. That rejects `../central` (a relative filesystem + # remote, which would otherwise write outside the caller's namespace), + # absolute local paths, and backslashes. Anything rejected still keys + # deterministically, by hash. + if grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$' <<<"$identity"; then + rung="remote" + else + identity="remote/$(hash12 "$remote")" + rung="remote-hashed" + fi +elif [[ -n "$root" ]]; then + identity="local/$(hash12 "$root")" + rung="local" +else + # Not a git repository. `audit-pass`'s ladder stops at the two git rungs + # because it refuses non-git targets; a report-only skill audits them, so the + # scheme needs a third rung rather than a failure. + identity="nonrepo/$(hash12 "$PWD")" + rung="nonrepo" +fi + +discriminator=$(hash8 "${root:-$PWD}") + +if [[ $EXPLAIN -eq 1 ]]; then + { + echo "rung: $rung" + echo "remote: ${remote:-}" + echo "repo root: ${root:-}" + echo "cwd: $PWD" + echo "identity: $identity" + echo "discriminator: $discriminator" + } >&2 +fi + +printf '%s/%s\n' "$identity" "$discriminator" diff --git a/plugins/overengineering/lib/view-registry.mjs b/plugins/overengineering/lib/view-registry.mjs new file mode 100644 index 00000000000..5b33fb87a25 --- /dev/null +++ b/plugins/overengineering/lib/view-registry.mjs @@ -0,0 +1,94 @@ +// GENERATED from lib/view-registry.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// The medium registry for rendered views: one entry per medium, the one fallback chain, and each +// view lane's registration. Skills never name a medium themselves; they reach this through +// lib/view-settings.mjs. Adding a medium or a lane is an addition here, never a skill edit. +// +// MediumEntry { id, publishes, reachable(context) -> {ok, reason}, deliver(view, context) -> {location}, reportName } +// Lane { laneId, kinds, hostedCapable, default } +// +// `context.interactive` is false in a CI or other non-interactive run, where only the terminal is +// reachable (docs/contracts/rendered-views/README.md, "Reachability and preference"). A lane's +// `default` is its shipped medium, applied when no scope decides; null leaves the choice to the +// skill's own content rule. + +const interactiveOnly = (context = {}) => (context.interactive === false ? { ok: false, reason: "non-interactive run" } : { ok: true }); + +const entry = (id, publishes, reportName, reachable, deliver) => Object.freeze({ id, publishes, reportName, reachable, deliver }); + +export const MEDIUMS = Object.freeze({ + terminal: entry("terminal", false, "the terminal", () => ({ ok: true }), () => ({ location: "terminal" })), + file: entry("file", false, "a local file", interactiveOnly, (view) => ({ location: view.path })), + artifact: entry("artifact", true, "a private Artifact on claude.ai", interactiveOnly, (view) => ({ location: "artifact", publish: view.path })), + hosted: entry("hosted", true, "the shared page host", interactiveOnly, (view) => ({ location: "hosted", publish: view.path })), +}); + +/** The registry entry for `id`, own keys only, so an inherited name such as `toString` is no medium. */ +export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS, id) ? MEDIUMS[id] : undefined); + +/** The one fallback chain: a view that cannot be served on a medium steps down this list. */ +export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); + +/** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ +export const NO_PREFERENCE = "auto"; + +/** The rendered-views concern's schema, for the cascade reader: one key, `medium`. */ +export const SCHEMA = Object.freeze({ + type: "object", + additionalProperties: false, + properties: { medium: { type: "string", enum: [NO_PREFERENCE, ...Object.keys(MEDIUMS)] } }, +}); + +const lane = (laneId, kinds, hostedCapable, medium) => Object.freeze({ laneId, kinds: Object.freeze(kinds), hostedCapable, default: medium }); + +export const LANES = Object.freeze( + Object.fromEntries( + [ + lane("review:explain-change", ["digest"], true, "artifact"), + lane("visualization:present", ["deck"], false, "artifact"), + lane("visualization:visualize", [], false, null), + lane("planning", ["plan", "brainstorm"], false, "file"), + lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), + lane("discovery:blindspot", ["blindspot"], false, "file"), + lane("debugging:debug", ["post-mortem"], false, "file"), + lane("education:illustrate", [], false, "file"), + lane("work-items:morning-brief", ["morning-brief"], false, "file"), + lane("work-items:triage", ["board"], false, "file"), + lane("adhd:clarify", [], false, null), + lane("codebase-health:audit", [], false, null), + lane("overengineering:audit", [], false, null), + lane("education:teach", [], false, null), + lane("prototype:explore-directions", [], false, null), + lane("prototype:pressure-test", [], false, null), + lane("ai-briefing:generate", [], false, null), + ].map((l) => [l.laneId, l]), + ), +); + +/** The lane registration for `id`, own keys only. */ +export const laneOf = (id) => (typeof id === "string" && Object.hasOwn(LANES, id) ? LANES[id] : undefined); + +/** `medium` as `lane` reads it: `hosted` is `artifact` unless the lane sends pages to a page host. */ +export const readAs = (medium, laneEntry) => (medium === "hosted" && !laneEntry.hostedCapable ? "artifact" : medium); + +/** + * The medium actually used for `preferred` on `laneEntry`: `preferred` itself when reachable, else the + * first reachable medium after it on the fallback chain (a hosted page steps to `artifact` first). + * @returns {{medium: string, fell: null | string}} fell: why `preferred` was not used + */ +export function fallBack(preferred, laneEntry, context = {}) { + const want = readAs(preferred, laneEntry); + const start = FALLBACK_CHAIN.indexOf(want === "hosted" ? "artifact" : want); + const candidates = want === "hosted" ? ["hosted", ...FALLBACK_CHAIN.slice(start)] : FALLBACK_CHAIN.slice(start); + const wanted = mediumOf(want); + if (!wanted) throw new Error(`view-registry: unknown medium ${JSON.stringify(preferred)}`); + const first = wanted.reachable(context); + if (first.ok) return { medium: want, fell: null }; + const medium = candidates.find((id) => mediumOf(id).reachable(context).ok) ?? "terminal"; + return { medium, fell: `${want} unreachable: ${first.reason}` }; +} + +/** The report line every view lane prints, then one line per notice. */ +export function reportLines({ medium, decidedBy, notices = [] }) { + return [`view: ${medium ?? NO_PREFERENCE} (${decidedBy})`, ...notices.map((n) => `notice: ${n.text}`)]; +} diff --git a/plugins/overengineering/lib/view-settings.mjs b/plugins/overengineering/lib/view-settings.mjs new file mode 100755 index 00000000000..17f324dae75 --- /dev/null +++ b/plugins/overengineering/lib/view-settings.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +// GENERATED from lib/view-settings.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// Where a rendered view goes: the one medium resolver every view lane calls. It reads each ADR 0075 +// scope's own `medium` value (concern `rendered-views`, home docs/conventions) through the cascade's +// per-layer reader and schema check, and walks them itself, because a filter on the cascade's +// merged output would lose a lower scope's local value under a higher scope's publishing one. +// +// The argument decides alone. Otherwise a terminal or file value from any scope keeps the view +// local, the highest-precedence one naming the scope (personal override, then team, then +// userConfig). Else the first publishing value from a trusted scope wins: the personal override +// (only untracked and gitignored), then userConfig. A team file never publishes. With no scope +// deciding, the lane's own default applies (it ships with the installed plugin, which a checked-out +// branch cannot write). An invalid value is ignored without dropping another scope's value; auto is +// unset. The retired files (~/.claude/rendered-views.md, .claude/rendered-views.md and +// .claude/rendered-views.local.md) give a notice, once per plugin per project while unchanged, and +// never a value. The medium then steps down the fallback chain (lib/view-registry.mjs) until +// reachable. +// +// view-settings.mjs resolve --lane --option [--argument ] [--team-ref ] +// Prints the resolution as one JSON line, then the report line and one line per notice. +// Exit 0 resolved, 2 usage. + +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readLayers } from "./config-cascade.mjs"; +import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; + +const CONCERN = "rendered-views"; +const HOME = "docs/conventions"; +const TEAM_FILE = `${HOME}/${CONCERN}.yaml`; +const PERSONAL_FILE = `${HOME}/${CONCERN}.local.yaml`; +const HERE = dirname(fileURLToPath(import.meta.url)); + +// ------------------------------------------------------------ repository checks + +/** The session's project: CLAUDE_PROJECT_DIR, else the nearest directory above the cwd holding `.git`. */ +export function findRoot() { + if (process.env.CLAUDE_PROJECT_DIR) return resolve(process.env.CLAUDE_PROJECT_DIR); + for (let at = process.cwd(); ; at = dirname(at)) { + if (existsSync(join(at, ".git"))) return at; + if (dirname(at) === at) return null; + } +} + +const real = (p) => { + try { + return realpathSync(p); + } catch { + return resolve(p); + } +}; +const within = (child, parent) => { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +}; +const isLink = (p) => { + try { + return lstatSync(p).isSymbolicLink(); + } catch { + return false; + } +}; +function gitOut(root, args) { + try { + return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + } catch { + return null; + } +} + +/** + * A personal file applies only untracked, so a pull request cannot ship one. Every directory between + * the root and the file must be a plain, untracked directory, never a submodule, a tracked file or a + * symlink. Not gitignored, it warns, or with `requireIgnored` is ignored too. + */ +export function overlayApplies(root, path, warnings, { requireIgnored = false } = {}) { + // Any tracked case variant counts: on a case-insensitive filesystem it is this file. + const rel = relative(root, path).split("\\").join("/"); + if (gitOut(root, ["ls-files", "--", `:(icase)${rel}`])) { + warnings.push(`overlay ${rel}: tracked in git, so a pull request could set it; layer ignored`); + return false; + } + const parts = rel.split("/").slice(0, -1); + const dirs = parts.map((_, i) => parts.slice(0, i + 1).join("/")); + for (const dir of dirs) { + // A submodule or tracked file at the directory itself is content a pull request controls. + const entries = (gitOut(root, ["ls-files", "-s", "-z", "--", `:(icase)${dir}`]) ?? "").split("\0"); + if (entries.some((e) => e.split("\t")[1]?.toLowerCase() === dir.toLowerCase()) || existsSync(join(root, dir, ".git"))) { + warnings.push(`overlay ${rel}: ${dir} is a submodule or tracked entry; layer ignored`); + return false; + } + if (isLink(join(root, dir))) { + warnings.push(`overlay ${rel}: ${dir} is a symlink; layer ignored`); + return false; + } + } + if (isLink(path) || !within(real(path), join(real(root), ...parts))) { + warnings.push(`overlay ${rel}: the overlay is a symlink or resolves outside ${parts.join("/") || "the root"}; layer ignored`); + return false; + } + if (gitOut(root, ["check-ignore", "-q", "--", path]) === null) { + warnings.push(`overlay ${rel}: not gitignored, so it can reach team history${requireIgnored ? "; layer ignored" : ""}`); + return !requireIgnored; + } + return true; +} + +/** Repository scopes apply only inside a working tree that is not the user's home or above it. */ +const repoScopes = (root, userHome) => Boolean(root) && !within(real(userHome), real(root)) && gitOut(root, ["rev-parse", "--is-inside-work-tree"]) !== null; + +/** A ref name or object id: never an option, never a revision expression. */ +const REF = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; + +/** The team file as `ref` holds it, for the cascade reader; a ref naming no commit reads nothing. */ +function teamAt(root, ref, notices) { + const sha = REF.test(ref) ? gitOut(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])?.trim() : null; + if (!sha) { + const named = ref ? `team ref ${JSON.stringify(ref)} names no commit here` : "no team ref was given"; + notices.push({ kind: "team-ref", text: `${named}, so ${TEAM_FILE} is not read` }); + return { path: `${ref}:${TEAM_FILE}`, text: null }; + } + return { path: `${sha.slice(0, 12)}:${TEAM_FILE}`, text: gitOut(root, ["show", `${sha}:${TEAM_FILE}`]) }; +} + +// ------------------------------------------------------------ retired files + +const mediumIn = (text) => /^[ \t]*medium:[ \t]*["']?([a-z]+)["']?[ \t]*$/m.exec(text ?? "")?.[1] ?? null; +const readBytes = (p) => { + try { + return readFileSync(p); + } catch { + return null; + } +}; + +/** This project's state key (docs/contracts/plugin-data-report-keying, rule 1), or null when it cannot be derived. */ +function stateKey(root) { + const out = spawnSync("bash", [join(HERE, "state-key.sh"), "--root", root ?? process.cwd()], { encoding: "utf8" }); + const key = out.status === 0 ? out.stdout.trim() : ""; + return /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)+$/.test(key) ? key : null; +} + +function writeAtomic(path, text) { + mkdirSync(dirname(path), { recursive: true }); + const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + writeFileSync(temp, text); + renameSync(temp, path); +} + +/** One notice per retired file, unless this plugin already showed it for this project with these bytes. */ +function retiredNotices(legacy, { plugin, root, userHome, dataDir }) { + const where = (path) => (within(path, join(userHome, ".claude")) ? `~/${relative(userHome, path).split("\\").join("/")}` : relative(root, path).split("\\").join("/")); + const destination = { "claude-user": `the ${plugin} plugin's medium option`, "claude-project": TEAM_FILE, "claude-local": PERSONAL_FILE, "fenced-block": TEAM_FILE }; + const key = dataDir ? stateKey(root) : null; + const state = key && join(dataDir, "view-settings", key, "retired-notices.json"); + let seen = {}; + try { + seen = state ? JSON.parse(readFileSync(state, "utf8")) : {}; + } catch { + seen = {}; + } + const notices = []; + let changed = false; + const order = Object.keys(destination); + for (const { path, kind } of [...legacy].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || a.path.localeCompare(b.path))) { + const bytes = readBytes(path); + if (!bytes) continue; + const shown = where(path); + const hash = createHash("sha256").update(bytes).digest("hex"); + if (seen[shown] === hash) continue; + [seen[shown], changed] = [hash, true]; + const value = mediumIn(bytes.toString("utf8")); + const to = destination[kind] ?? TEAM_FILE; + const caveat = + value && mediumOf(value)?.publishes + ? to === TEAM_FILE + ? "; a team file never publishes, so that value would be ignored there" + : to === PERSONAL_FILE + ? "; it publishes only while that file is untracked and gitignored" + : "" + : ""; + notices.push({ kind: "retired", path: shown, value, destination: to, text: `${shown} is no longer read${value ? ` (it sets medium: ${value})` : ""}; set it in ${to} instead${caveat}` }); + } + if (state && changed) { + try { + writeAtomic(state, `${JSON.stringify(seen)}\n`); + } catch { + // Unwritable state shows the notice again next time, which is the safe direction. + } + } + return notices; +} + +// ------------------------------------------------------------ resolution + +/** True when the reader chose a publishing medium (the argument, or a trusted scope), which the publish gate takes as explicit: never the plugin default. */ +export const gateExplicit = ({ preferred, decidedBy }) => Boolean(mediumOf(preferred)?.publishes) && ["argument", "userConfig", "personal override"].includes(decidedBy); + +/** + * @param {{lane: string, argument?: string|null, userConfig?: string|null, projectRoot?: string|null, teamRef?: string|null}} request + * @param {{userHome?: string, dataDir?: string|null, interactive?: boolean}} [options] + * @returns {{preferred: string|null, medium: string|null, decidedBy: string, notices: {kind: string, text: string}[]}} + */ +export function resolveMedium({ lane, argument = null, userConfig = null, projectRoot = null, teamRef = null }, { userHome = homedir(), dataDir = null, interactive = true } = {}) { + const laneEntry = laneOf(lane); + if (!laneEntry) throw new Error(`view-settings: unknown lane ${JSON.stringify(lane)}`); + const notices = []; + const root = repoScopes(projectRoot, userHome) ? projectRoot : null; + const team = root && typeof teamRef === "string" ? teamAt(root, teamRef, notices) : undefined; + const { layers, legacy } = readLayers({ plugin: CONCERN, projectRoot: root, schema: SCHEMA, userConfig: { medium: userConfig ?? "" }, team, userHome }); + notices.unshift(...retiredNotices(legacy, { plugin: lane.split(":")[0], root, userHome, dataDir })); + const finish = (preferred, decidedBy) => { + if (!preferred) return { preferred: null, medium: null, decidedBy, notices }; + const { medium, fell } = fallBack(preferred, laneEntry, { interactive }); + return { preferred, medium, decidedBy: fell ? `fell back: ${fell}` : decidedBy, notices }; + }; + if (mediumOf(argument)) return finish(argument, "argument"); + + const shown = (path) => (path && root && isAbsolute(path) ? relative(root, path).split("\\").join("/") : path); + for (const layer of layers) { + for (const error of layer.errors) { + const prefix = `${layer.name}${layer.path ? ` (${layer.path})` : ""}: `; + const name = { local: "personal override" }[layer.name] ?? layer.name; + notices.push({ kind: "invalid", text: `${name}${layer.path ? ` ${shown(layer.path)}` : ""}: ${error.startsWith(prefix) ? error.slice(prefix.length) : error}; ignored` }); + } + } + const valueOf = (name) => layers.find((l) => l.name === name)?.values.medium; + const personal = valueOf("local"); + let personalTrusted = false; + if (personal && mediumOf(personal)?.publishes) { + const warnings = []; + personalTrusted = overlayApplies(root, join(root, PERSONAL_FILE), warnings, { requireIgnored: true }); + for (const w of warnings) notices.push({ kind: "ignored", text: `personal override ${w.replace(/^overlay /, "")}` }); + } + // Highest precedence first (ADR 0075: the personal override, then team, then userConfig). + const scopes = [ + ["personal override", personal, personalTrusted], + ["team", valueOf("team"), false], + ["userConfig", valueOf("userConfig"), true], + ].filter(([, medium]) => medium && medium !== NO_PREFERENCE); + const local = scopes.find(([, medium]) => !mediumOf(medium).publishes); + if (local) return finish(local[1], local[0]); + const teamValue = scopes.find(([name]) => name === "team")?.[1]; + if (teamValue) notices.push({ kind: "ignored", text: `team ${shown(layers.find((l) => l.name === "team").path)} sets medium: ${teamValue}; a team file never publishes, so it is ignored` }); + const trusted = scopes.find(([, , ok]) => ok); + return trusted ? finish(trusted[1], trusted[0]) : finish(laneEntry.default, "plugin default"); +} + +// ------------------------------------------------------------ CLI + +const USAGE = "usage: view-settings.mjs resolve --lane --option [--argument ] [--team-ref ]\n"; + +/** CLI: the argument vector after the script path; returns the exit code. */ +export function main(argv, env = process.env) { + const [action, ...rest] = argv; + const request = { lane: null, userConfig: undefined, argument: null, teamRef: null }; + const flags = { "--lane": "lane", "--option": "userConfig", "--argument": "argument", "--team-ref": "teamRef" }; + for (let i = 0; i < rest.length; i += 2) { + const key = flags[rest[i]]; + if (!key || rest[i + 1] === undefined) return usage(); + request[key] = rest[i + 1]; + } + if (action !== "resolve" || !laneOf(request.lane) || request.userConfig === undefined) return usage(); + if (request.argument === NO_PREFERENCE) request.argument = null; + if (request.argument !== null && !mediumOf(request.argument)) return usage(); + const ci = Boolean(env.CI) && env.CI !== "false"; + const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); + process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + return 0; +} + +function usage() { + process.stderr.write(USAGE); + return 2; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/plugins/overengineering/lib/yaml-subset.mjs b/plugins/overengineering/lib/yaml-subset.mjs new file mode 100644 index 00000000000..b97b62c2ab0 --- /dev/null +++ b/plugins/overengineering/lib/yaml-subset.mjs @@ -0,0 +1,314 @@ +// GENERATED from lib/yaml-subset.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// A parser for the YAML subset the team file is written in: a port of +// plugins/code-metrics/scripts/yaml_subset.py, kept to the same grammar. +// +// parse(text) -> the document (null when empty), or throws YamlSubsetError {line, message} +// parse(text, { source: true }) -> the same grammar, but every scalar is its source text as +// lib/yaml-subset.awk prints it (plain text as written, quotes removed, `\n` kept as written), +// mappings are Maps in document order, an empty value is null, and a key may not be empty or +// hold `.` or a tab; as in that reader, a plain key is `[A-Za-z0-9_-]`, a quoted key may have +// blanks before its `:`, only a comment may follow a quoted value, and a root sequence or a +// sequence nested on one line is an error +// +// The subset: block mappings (nested by indentation of two or more spaces); block sequences +// (scalar or mapping items, a mapping item may start on the dash line); flow sequences of scalars +// (`[a, "b", 3]`, `[]`); scalars: quoted and plain strings, integers, floats, true/false, +// null/~, and an empty value meaning null; `#` comments outside quotes; blank lines. +// Everything else is reported with its line rather than parsed partially: flow mappings (`{`), +// anchors and aliases, tags, block scalars, document markers, tab indentation, duplicate keys, +// nesting deeper than MAX_DEPTH. +// Narrower than the Python grammar, as lib/yaml-subset.awk is: a plain value may not hold `: ` +// and a plain key may not hold whitespace, `$` or `.`; quoting either lifts the limit. +// Keys become own data properties, so a `__proto__` key never reaches an object's prototype. +// Error messages quote file text as JSON strings cut to 60 characters: they describe data. + +export class YamlSubsetError extends Error { + constructor(line, message) { + super(`line ${line}: ${message}`); + this.name = "YamlSubsetError"; + this.line = line; + } +} + +// Every descent below the top level passes through nested(), so this bounds the recursion. +const MAX_DEPTH = 64; +const INT = /^[-+]?\d+$/; +const FLOAT = /^[-+]?(\d+\.\d*|\.\d+|\d+)([eE][-+]?\d+)?$/; +const quote = (text) => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}...` : text); +const isItem = (content) => content === "-" || content.startsWith("- "); +const put = (object, key, value) => Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true }); + +/** The text before `#` as a comment ends it. A quote opens only where a value starts (line start, + * after `: `, `- `, `[` or `,`), so an apostrophe inside a plain value is just a character. */ +function stripComment(text) { + let q = null; + let last = -1; // index of the last non-whitespace character before i, so each test is O(1) + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (q) { + if (q === '"' && ch === "\\") i++; + else if (q === "'" && ch === "'" && text[i + 1] === "'") i++; + else if (ch === q) q = null; + } else if ((ch === "'" || ch === '"') && valueStarts(text, last, i)) { + q = ch; + } else if (ch === "#" && (i === 0 || text[i - 1] === " " || text[i - 1] === "\t")) { + return text.slice(0, i).trimEnd(); + } + if (i < text.length && !/\s/.test(text[i])) last = i; + } + return text.trimEnd(); +} + +/** True when a value starts at i: only whitespace follows line start, `: `, `- `, `[` or `,`. */ +function valueStarts(text, last, i) { + if (last < 0) return true; + const c = text[last]; + const spaced = i - last > 1; + return c === "[" || c === "," || (spaced && c === ":") || (spaced && c === "-" && (last === 0 || /\s/.test(text[last - 1]))); +} + +const ESCAPES = { n: "\n", t: "\t", '"': '"', "\\": "\\", "/": "/" }; +const SOURCE_ESCAPES = { ...ESCAPES, n: "\\n" }; + +function unquote(text, line, source) { + const q = text[0]; + if (text.length < 2 || text.at(-1) !== q) throw new YamlSubsetError(line, "unterminated quoted string"); + const body = text.slice(1, -1); + if (q === "'") return body.replaceAll("''", "'"); + let out = ""; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === "\\" && i + 1 < body.length) { + const next = body[i + 1]; + if (!Object.hasOwn(ESCAPES, next)) throw new YamlSubsetError(line, `unsupported escape \\${next} in double-quoted string`); + out += (source ? SOURCE_ESCAPES : ESCAPES)[next]; + i++; + continue; + } + out += ch; + } + return out; +} + +function scalar(raw, line, source) { + const text = raw.trim(); + if (text === "") return null; + if (text[0] === "'" || text[0] === '"') { + const close = source ? closingQuote(text) : -1; + if (close !== -1 && close !== text.length - 1) throw new YamlSubsetError(line, "unexpected text after a value"); + return unquote(text, line, source); + } + if (text[0] === "{") throw new YamlSubsetError(line, "flow mapping ({...}) is outside the subset; use block style"); + if (text[0] === "&" || text[0] === "*") throw new YamlSubsetError(line, "anchors and aliases are outside the subset"); + if (text[0] === "!") throw new YamlSubsetError(line, "tags are outside the subset"); + if (["|", ">", "|-", ">-", "|+", ">+"].includes(text)) throw new YamlSubsetError(line, "block scalars (| and >) are outside the subset"); + if (text[0] === "[") return flowSequence(text, line, source); + if (/:[ \t]/.test(text)) throw new YamlSubsetError(line, "a plain value may not hold `: `; quote it"); + if (source) return text; + if (["null", "Null", "NULL", "~"].includes(text)) return null; + if (["true", "True", "TRUE"].includes(text)) return true; + if (["false", "False", "FALSE"].includes(text)) return false; + if (INT.test(text) || FLOAT.test(text)) return Number(text); + return text; +} + +function flowSequence(text, line, source) { + if (!text.endsWith("]")) throw new YamlSubsetError(line, "unterminated flow sequence"); + const body = text.slice(1, -1).trim(); + if (body === "") return []; + const items = []; + let current = ""; + let q = null; + let depth = 0; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (q) { + current += ch; + // A backslash escape in "..." and a doubled quote in '...' stay inside the item. + if ((q === '"' && ch === "\\") || (q === "'" && ch === "'" && body[i + 1] === "'")) current += body[++i] ?? ""; + else if (ch === q) q = null; + continue; + } + if (ch === "'" || ch === '"') q = ch; + else if (ch === "[") depth++; + else if (ch === "]") depth--; + else if (ch === "," && depth === 0) { + items.push(current); + current = ""; + continue; + } + current += ch; + } + items.push(current); + return items.map((raw) => { + const item = raw.trim(); + if (item.startsWith("[")) throw new YamlSubsetError(line, "nested flow sequences are outside the subset"); + return scalar(item, line, source); + }); +} + +/** The index of the quote closing the quoted text that opens at text[0], or -1. A doubled `''` in + * single quotes and a backslash escape in double quotes stay inside, as in unquote(). */ +function closingQuote(text) { + const q = text[0]; + for (let i = 1; i < text.length; i++) { + if (q === '"' && text[i] === "\\") i++; + else if (text[i] === q) { + if (q === "'" && text[i + 1] === "'") i++; + else return i; + } + } + return -1; +} + +/** [key, rest] when the content is a mapping entry, else null. */ +function splitKey(content, line, source) { + if (content[0] === "'" || content[0] === '"') { + const close = closingQuote(content); + if (close === -1) throw new YamlSubsetError(line, "unterminated quoted key"); + const key = unquote(content.slice(0, close + 1), line, source); + const rest = source ? content.slice(close + 1).replace(/^[ \t]+/, "") : content.slice(close + 1); + if (!rest.startsWith(":") || (rest.length > 1 && rest[1] !== " " && rest[1] !== "\t")) return null; + // A source-mode key is one step of a dotted record path, as in lib/yaml-subset.awk. + if (source && (key === "" || /[.\t]/.test(key))) throw new YamlSubsetError(line, "a key may not be empty or hold `.` or a tab"); + return [key, rest.slice(1).trim()]; + } + const split = plainKey(content) ?? (content.endsWith(":") ? [content.slice(0, -1).trim(), ""] : null); + // lib/yaml-subset.awk reads no other plain key, so the line is no mapping entry there: it falls + // through to a scalar, or to "expected a mapping entry" on the same line awk errors on. + if (split && source && !/^[A-Za-z0-9_-]+$/.test(split[0])) return null; + if (split && /[\s$.]/.test(split[0])) throw new YamlSubsetError(line, `a plain key may not hold a space, \`$\` or \`.\`; quote it: ${quote(split[0])}`); + return split; +} + +/** The Python grammar `^([^\s:[\]{}#][^:#]*?):(?:\s+(.*))?$` in one linear scan: the key runs to + * the first `:`, holds no `#`, and the `:` ends the line or is followed by whitespace and a rest + * with no line terminator after its leading whitespace. */ +function plainKey(content) { + if (content === "" || /[\s:[\]{}#]/.test(content[0])) return null; + const colon = content.indexOf(":"); + if (colon === -1 || content.slice(0, colon).includes("#")) return null; + const key = content.slice(0, colon).trim(); + const after = content.slice(colon + 1); + if (after === "") return [key, ""]; + if (!/^\s/.test(after)) return null; + const rest = after.trimStart(); + if (/[\n\r\u2028\u2029]/.test(rest)) return null; + return [key, rest.trim()]; +} + +class Parser { + constructor(text, source = false) { + this.source = source; + this.lines = []; // [indent, lineNumber, content] + text + .replace(/^/, "") + .split(/\r\n|\r|\n/) + .forEach((raw, i) => { + const number = i + 1; + if (raw.startsWith("---") || raw.startsWith("...")) throw new YamlSubsetError(number, "document markers are outside the subset"); + const stripped = stripComment(raw); + if (!stripped.trim()) return; + const indent = stripped.length - stripped.replace(/^ +/, "").length; + if (stripped[indent] === "\t") throw new YamlSubsetError(number, "tab indentation is outside the subset"); + this.lines.push([indent, number, stripped.slice(indent)]); + }); + this.pos = 0; + this.depth = 0; + } + + parse() { + if (!this.lines.length) return null; + if (this.source && isItem(this.lines[0][2])) throw new YamlSubsetError(this.lines[0][1], "a root sequence is not part of the subset"); + const value = this.block(this.lines[0][0]); + if (this.pos < this.lines.length) { + const [indent, number, content] = this.lines[this.pos]; + throw new YamlSubsetError(number, `unexpected content at indent ${indent}: ${quote(content)}`); + } + return value; + } + + block(indent) { + const [, number, content] = this.lines[this.pos]; + if (isItem(content)) return this.sequence(indent); + if (splitKey(content, number, this.source) === null) throw new YamlSubsetError(number, `expected a mapping entry or sequence item, got ${quote(content)}`); + return this.mapping(indent); + } + + /** The block mapping at `indent`, its entries put into `result` (a sequence item's dash-line entry + * arrives already in it, so a duplicate is reported on its own line). */ + mapping(indent, result = this.map()) { + while (this.pos < this.lines.length) { + const [lineIndent, number, content] = this.lines[this.pos]; + if (lineIndent < indent) break; + if (lineIndent > indent) throw new YamlSubsetError(number, `unexpected indent ${lineIndent} (expected ${indent})`); + const split = splitKey(content, number, this.source); + if (split === null) throw new YamlSubsetError(number, `expected a mapping entry, got ${quote(content)}`); + const [key, rest] = split; + if (result instanceof Map ? result.has(key) : Object.hasOwn(result, key)) throw new YamlSubsetError(number, `duplicate key ${quote(key)}`); + this.pos++; + this.set(result, key, rest === "" ? this.nested(indent) : scalar(rest, number, this.source)); + } + return result; + } + + nested(parentIndent) { + if (this.pos < this.lines.length) { + const [childIndent, number, content] = this.lines[this.pos]; + if (childIndent < parentIndent || (childIndent === parentIndent && !isItem(content))) return null; + if (this.depth === MAX_DEPTH) throw new YamlSubsetError(number, `nesting deeper than ${MAX_DEPTH} levels is outside the subset`); + this.depth++; + try { + // A sequence may sit at the parent's indent (`key:` then `- item`). + return childIndent > parentIndent ? this.block(childIndent) : this.sequence(childIndent); + } finally { + this.depth--; + } + } + return null; + } + + sequence(indent) { + const result = []; + while (this.pos < this.lines.length) { + const [lineIndent, number, content] = this.lines[this.pos]; + if (lineIndent < indent || !isItem(content)) break; + if (lineIndent > indent) throw new YamlSubsetError(number, `unexpected indent ${lineIndent} (expected ${indent})`); + const item = content.slice(1).trim(); + if (this.source && isItem(item)) throw new YamlSubsetError(number, "a sequence nested on one line is not part of the subset"); + this.pos++; + if (item === "") { + // Only a deeper line belongs to an empty item; a `- ` line at this indent is its sibling. + const next = this.lines[this.pos]; + result.push(next && next[0] > indent ? this.nested(indent) : null); + continue; + } + // An item opening a quote it never closes is a quoted scalar, which scalar() reads or rejects. + const split = (item[0] === "'" || item[0] === '"') && closingQuote(item) === -1 ? null : splitKey(item, number, this.source); + if (split === null) { + result.push(scalar(item, number, this.source)); + continue; + } + // A mapping whose first entry sits on the dash line; the rest of its entries are indented + // to the item column. + const itemIndent = indent + 2; + const [key, rest] = split; + const mapping = this.set(this.map(), key, rest === "" ? this.nested(itemIndent) : scalar(rest, number, this.source)); + if (this.pos < this.lines.length && this.lines[this.pos][0] === itemIndent) this.mapping(itemIndent, mapping); + result.push(mapping); + } + return result; + } + + map() { + return this.source ? new Map() : {}; + } + + set(mapping, key, value) { + return mapping instanceof Map ? mapping.set(key, value) : put(mapping, key, value); + } +} + +/** Parse a document written in the subset; throw YamlSubsetError otherwise. */ +export const parse = (text, { source = false } = {}) => new Parser(text, source).parse(); diff --git a/plugins/prototype/lib/config-cascade.mjs b/plugins/prototype/lib/config-cascade.mjs new file mode 100644 index 00000000000..b5f22dc12e2 --- /dev/null +++ b/plugins/prototype/lib/config-cascade.mjs @@ -0,0 +1,571 @@ +// GENERATED from lib/config-cascade.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// The four-layer plugin config cascade (docs/adr/0075): later layers win per key. +// +// resolve({plugin, projectRoot, home, schema, defaults, userConfig, teamOnly, floors, userHome}) +// -> {values, provenance: {"a.b": layer | [layers]}, prose: [paths], sections: [{heading, text, layer}], +// layers: [{name, path, state: "loaded" | "absent" | "invalid", errors, error_keys}], legacy: [{path, kind}]} +// +// readLayers({...}) -> each layer's own accepted values, unmerged (see its comment below) +// +// `error_keys[i]` tags `errors[i]`: the dotted key the error names (key-level), or null when it is +// about the layer as a whole (file-level: a file refused or unparsable, a value that is not a mapping). +// +// Layers, in order: "defaults" (the `defaults` object, already parsed by the caller), "userConfig" +// (the plugin's userConfig values, flattened names), "team" (/) and "local", the +// personal overlay (/.local). A file layer reads .yaml for config and .md +// for prose; it is absent when neither file exists. `home` is the convention home the caller +// resolved, relative to projectRoot unless absolute (default docs/conventions). There is no +// user-global file layer: a file under /docs/conventions is never read, and `userHome` +// (default os.homedir()) only locates the legacy files reported in `legacy`. `schema` is a JSON +// Schema object; the subset honored is type (object, string, array, null, integer, number, boolean), +// enum, properties, additionalProperties (false or a schema), items, minimum, maximum, minItems and +// maxItems. +// +// A key is a schema leaf: any node that is not an object with `properties`, or a path named in +// `teamOnly`. The setup writer's top-level `written_against` and `provenance` records are not keys, +// declared or not: every layer skips them, so they reach neither `values` nor a userConfig name. +// Each layer's leaves are checked one by one; a leaf whose value fails the schema is +// dropped from that layer with an error naming layer, file and key, and a lower layer's value is +// then never used (ADR 0075, the invalid-value rule): a valid higher layer still wins, otherwise the +// key takes its default. The personal overlay is the one exception, because it refines the team +// file: an invalid personal value leaves the team value standing. A key containing `.` is +// rejected the same way, since keys are joined with `.` into paths, and so are `__proto__`, +// `constructor` and `prototype`, at any depth inside a value too, which a caller's merge would treat +// as more than data. A refused file makes its layer invalid; the layer's other file still applies. The team and +// local layers come from the repository: each reads only regular files, never a symlink, and is +// invalid when a relative `home` resolves outside projectRoot. A team-only key is +// accepted in "defaults" and "team" and rejected elsewhere. A YAML file that does not parse, or +// whose top level is not a mapping, makes its layer invalid and contributes no values; its .md +// file still supplies prose. A leaf whose last segment is `disable` and whose value is a list is the +// union of every layer's list, and its provenance is an array of the layer names that set it, in +// layer order; every other key's provenance is a single layer-name string. +// +// Policy floors. `floors` maps a key path to a `Floor direction` value (the Implementers table of +// docs/contracts/config-cascade/README.md; the closed set is no-floor, team-only, lowest-value, +// any-layer-wins and stricter-value), or to {direction, scale} where stricter-value takes a scale +// from loosest to strictest (default: the leaf's schema `enum` order). When the team layer set a +// floor key, a personal value is accepted only if it is no looser in that direction: team-only takes +// an equal value (a list must keep every team item); lowest-value a number no higher; any-layer-wins +// a tightening value (`true` or "report") whenever the team value is one; stricter-value a value at +// or beyond the team value on the scale. A looser value is dropped with an error naming the layer, +// file and key, and the team value stands. Floors bind the personal overlay; a `*.disable` union key +// is not compared. +// +// userConfig maps each schema leaf path to its name joined with `_` (widget.limit is widget_limit); +// two leaves with one name throw. An empty string or an unrendered `${user_config.` placeholder is +// unset, names outside the schema are ignored, and a list, mapping or team-only key is rejected. +// A string value takes the leaf's integer, number or boolean type when the leaf does not accept +// strings. Legacy locations (.claude/.* under userHome and projectRoot, and a fenced +// `yaml config` block in the team .md) are reported in `legacy`, never read for values. +// +// Prose. A `.local.md` H2 section (a `## ` heading outside a code fence) replaces the team `.md` +// section with the same heading; every other team section is kept, a local section with a new +// heading follows them, and the team text before the first H2 is kept. `sections` lists the result in +// order, each with the layer it came from (`heading` is null for text before the first H2). +// This module reads files only. File contents are data; error messages quote them as JSON strings. +import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"; +import { parse } from "./yaml-subset.mjs"; + +const PLUGIN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SCALARS = ["string", "integer", "number", "boolean", "null"]; +const DIRECTIONS = ["no-floor", "team-only", "lowest-value", "any-layer-wins", "stricter-value"]; +const clip = (text) => (text.length > 60 ? `${text.slice(0, 60)}...` : text); +const quote = (text) => JSON.stringify(clip(text)); +const show = (value) => clip(JSON.stringify(value) ?? String(value)); +const put = (object, key, value) => Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true }); +const isMapping = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const RESERVED = new Set(["__proto__", "constructor", "prototype"]); +const WRITER_KEYS = new Set(["written_against", "provenance"]); +const isWriterKey = (parts, key) => !parts.length && WRITER_KEYS.has(key); +const newLayer = (name, path, state, errors = [], error_keys = errors.map(() => null)) => ({ name, path, state, errors, error_keys }); +/** Records an error on `layer`, tagged with the dotted key it names, or null for a file-level one. */ +const record = (layer, message, key = null) => { + layer.errors.push(message); + layer.error_keys.push(key); +}; + +/** {ok} for a readable layer file, {refused: why} for one that must not be read, {} when absent. A + * repository layer refuses a symlink, which could reach a file outside the checkout; every layer + * refuses anything but a regular file, since a device or FIFO never finishes reading. */ +function fileState(path, repo) { + let st; + try { + st = lstatSync(path); + } catch { + return {}; + } + if (st.isSymbolicLink()) { + if (repo) return { refused: "is a symlink; a repository layer reads only regular files" }; + try { + st = statSync(path); + } catch { + return {}; + } + } + return st.isFile() ? { ok: true } : { refused: "is not a regular file" }; +} + +/** True when `dir` exists and its real path lies outside the real path of `root`. */ +function escapes(dir, root) { + try { + const rel = relative(realpathSync(root), realpathSync(dir)); + return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel); + } catch { + return false; + } +} +const typesOf = (node) => (node.type === undefined ? null : [node.type].flat()); + +const fits = { + object: isMapping, + array: Array.isArray, + string: (v) => typeof v === "string", + integer: Number.isInteger, + number: (v) => typeof v === "number" && Number.isFinite(v), + boolean: (v) => typeof v === "boolean", + null: (v) => v === null, +}; + +/** The schema for `key` under `node`: a schema, null when anything goes, false when it is unknown. */ +function child(node, key) { + if (isMapping(node.properties) && Object.hasOwn(node.properties, key)) return node.properties[key]; + if (node.additionalProperties === false) return false; + return isMapping(node.additionalProperties) ? node.additionalProperties : null; +} + +const isBranch = (node, path, teamOnly) => + isMapping(node) && isMapping(node.properties) && !teamOnly.has(path) && (typesOf(node)?.includes("object") ?? true); + +/** Why `value` does not fit `node`, as messages relative to the key; empty when it fits. */ +function check(value, node, at = "") { + const prefix = at ? `${at}: ` : ""; + const types = typesOf(node); + if (types && !types.some((t) => fits[t]?.(value))) return [`${prefix}expected ${types.join(" or ")}, got ${show(value)}`]; + if (Array.isArray(node.enum) && !node.enum.includes(value)) return [`${prefix}${show(value)} is not one of ${node.enum.map(show).join(", ")}`]; + if (typeof value === "number" && typeof node.minimum === "number" && value < node.minimum) return [`${prefix}${show(value)} must be at least ${node.minimum}`]; + if (typeof value === "number" && typeof node.maximum === "number" && value > node.maximum) return [`${prefix}${show(value)} must be at most ${node.maximum}`]; + if (Array.isArray(value) && typeof node.minItems === "number" && value.length < node.minItems) return [`${prefix}needs at least ${node.minItems} items, got ${value.length}`]; + if (Array.isArray(value) && typeof node.maxItems === "number" && value.length > node.maxItems) return [`${prefix}allows at most ${node.maxItems} items, got ${value.length}`]; + // An open node ({}) checks nothing but reserved names, which are refused at every depth. + if (Array.isArray(value)) return value.flatMap((item, i) => check(item, isMapping(node.items) ? node.items : {}, `${at}[${i}]`)); + if (!isMapping(value)) return []; + return Object.keys(value).flatMap((key) => { + const where = `${at}.${quote(key)}`; + if (RESERVED.has(key)) return [`${where}: a reserved name, never a config key`]; + const sub = child(node, key); + if (sub === false) return [`${where}: unknown key`]; + return check(value[key], sub ?? {}, where); + }); +} + +/** The accepted leaves of one layer's mapping, as [parts, value] pairs; rejections go to `fail` + * with the dotted key, and `true` third when the value itself was the problem. */ +function leaves(data, schema, { teamOnly, allowTeamOnly, fail }) { + const out = []; + const walk = (object, node, parts) => { + for (const key of Object.keys(object)) { + if (isWriterKey(parts, key)) continue; + const path = [...parts, key]; + const dotted = path.join("."); + const sub = child(node, key); + const value = object[key]; + if (RESERVED.has(key)) fail(`${quote(dotted)}: a reserved name, never a config key`, dotted); + else if (key.includes(".")) fail(`${quote(dotted)}: a key may not contain "."; nest it instead`, dotted); + else if (sub === false) fail(`${quote(dotted)}: unknown key`, dotted); + else if (teamOnly.has(dotted) && !allowTeamOnly) fail(`${quote(dotted)}: accepted only in the team layer`, dotted); + else if (sub && isBranch(sub, dotted, teamOnly)) { + if (isMapping(value)) walk(value, sub, path); + else if (value !== null) fail(`${quote(dotted)}: expected a mapping, got ${show(value)}`, dotted, true); + } else { + const problems = check(value, sub ?? {}); + if (problems.length) fail(`${quote(dotted)}: ${problems.join("; ")}`, dotted, true); + else out.push([path, value]); + } + } + }; + walk(data, schema, []); + return out; +} + +/** Flattened userConfig name to {path, node} for every schema leaf. */ +function flatNames(schema, teamOnly) { + const names = new Map(); + const walk = (node, parts) => { + for (const key of Object.keys(isMapping(node.properties) ? node.properties : {})) { + if (isWriterKey(parts, key)) continue; + const path = [...parts, key]; + const dotted = path.join("."); + const sub = node.properties[key]; + if (isBranch(sub, dotted, teamOnly)) { + walk(sub, path); + continue; + } + const name = path.join("_"); + if (names.has(name)) throw new Error(`config-cascade: schema keys ${names.get(name).dotted} and ${dotted} both flatten to userConfig name ${name}`); + names.set(name, { path, dotted, node: sub }); + } + }; + walk(schema, []); + return names; +} + +function coerce(raw, node) { + const types = typesOf(node) ?? []; + if (typeof raw !== "string" || types.includes("string")) return raw; + if ((types.includes("integer") || types.includes("number")) && /^[-+]?\d+(\.\d+)?$/.test(raw)) return Number(raw); + if (types.includes("boolean") && (raw === "true" || raw === "false")) return raw === "true"; + return raw; +} + +function userConfigLeaves(userConfig, names, teamOnly, fail) { + const out = []; + for (const [name, raw] of Object.entries(userConfig)) { + if (raw === undefined || raw === null || raw === "" || (typeof raw === "string" && raw.startsWith("${user_config."))) continue; + const leaf = names.get(name); + if (!leaf) continue; + const types = typesOf(leaf.node); + if (teamOnly.has(leaf.dotted)) fail(`${quote(leaf.dotted)}: accepted only in the team layer`, leaf.dotted); + else if (types && !types.every((t) => SCALARS.includes(t))) fail(`${quote(leaf.dotted)}: lists and mappings are set only in files`, leaf.dotted); + else { + const value = coerce(raw, leaf.node); + const problems = check(value, leaf.node); + if (problems.length) fail(`${quote(leaf.dotted)}: ${problems.join("; ")}`, leaf.dotted, true); + else out.push([leaf.path, value]); + } + } + return out; +} + +/** One file layer: its report entry, its parsed mapping (or null), and its prose path (or null). */ +function readLayer(name, stem, repo) { + const path = `${stem}.yaml`; + const md = `${stem}.md`; + const layer = newLayer(name, path, "absent"); + const mdFile = fileState(md, repo); + if (mdFile.refused) { + layer.state = "invalid"; + record(layer, `${name} (${md}): ${mdFile.refused}`); + } + const prose = mdFile.ok ? md : null; + if (prose) layer.state = "loaded"; + const yamlFile = fileState(path, repo); + if (yamlFile.refused) { + layer.state = "invalid"; + record(layer, `${name} (${path}): ${yamlFile.refused}`); + } + if (!yamlFile.ok) return { layer, data: null, prose }; + return { layer, data: parseInto(layer, () => readFileSync(path, "utf8")), prose }; +} + +/** The mapping `text()` parses to, loading `layer`; null, with the layer invalid, when it does not parse or is not a mapping. */ +function parseInto(layer, text) { + try { + const doc = parse(text()) ?? {}; + if (!isMapping(doc)) throw new Error("the top level must be a mapping"); + if (layer.state !== "invalid") layer.state = "loaded"; + return doc; + } catch (e) { + layer.state = "invalid"; + record(layer, `${layer.name} (${layer.path}): ${e.message}`); + return null; + } +} + +/** True when the Markdown file holds a ``` config block outside any other fence. */ +function hasConfigBlock(path) { + if (!fileState(path, true).ok) return false; + let text; + try { + text = readFileSync(path, "utf8"); + } catch { + return false; + } + let fence = null; // the open fence's marker run, backticks or tildes + for (const line of text.split(/\r\n|\r|\n/)) { + const run = line.match(/^(`{3,}|~{3,})/)?.[0]; + if (fence) { + if (run?.[0] === fence[0] && run.length >= fence.length && line.trimEnd().length === run.length) fence = null; + } else if (/^```[A-Za-z0-9_-]+ config\s*$/.test(line)) return true; + else if (run) fence = run; + } + return false; +} + +/** A Markdown text as [{heading, text}], split at each H2 outside a code fence; the first entry holds + * the text before the first H2 (heading null) and is dropped when empty. */ +function splitSections(text) { + const out = [{ heading: null, lines: [] }]; + let fence = null; + for (const line of text.split(/(?<=\n)/)) { + const run = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1]; + if (fence) { + if (run?.[0] === fence[0] && run.length >= fence.length && line.trim().length === run.length) fence = null; + } else if (run) fence = run; + else { + const h = line.match(/^## +(.*?)(?: +#+)?\s*$/); + if (h) { + out.push({ heading: h[1].replace(/\s+/g, " ").trim(), lines: [line] }); + continue; + } + } + out.at(-1).lines.push(line); + } + return out.map(({ heading, lines }) => ({ heading, text: lines.join("") })).filter((s) => s.heading !== null || s.text.trim() !== ""); +} + +function readText(path) { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** The team sections with each same-heading local section in its place, then the local sections the + * team lacks. The first team section with a heading is replaced; a later team repeat of it is dropped. */ +function mergeSections(teamText, localText) { + const team = teamText === null ? [] : splitSections(teamText).map((s) => ({ ...s, layer: "team" })); + const local = localText === null ? [] : splitSections(localText).map((s) => ({ ...s, layer: "local" })); + const replacement = new Map(local.filter((s) => s.heading !== null).map((s) => [s.heading, s])); + const used = new Set(); + const out = []; + for (const s of team) { + if (s.heading === null || !replacement.has(s.heading)) out.push(s); + else if (!used.has(s.heading)) { + used.add(s.heading); + out.push(replacement.get(s.heading)); + } + } + const teamHeadings = new Set(team.map((s) => s.heading)); + for (const s of local) { + // Text before the first H2 follows the team's own only when the team has no file; a repeated heading keeps its last section. + const fresh = s.heading === null ? team.length === 0 : !teamHeadings.has(s.heading) && replacement.get(s.heading) === s; + if (fresh) out.push(s); + } + return out; +} + +function legacyFiles(dir, plugin, kind) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + return entries.filter((e) => e.isFile() && e.name.startsWith(`${plugin}.`)).map((e) => ({ path: join(dir, e.name), kind: kind(e.name) })); +} + +function setPath(object, parts, value) { + let at = object; + for (const part of parts.slice(0, -1)) { + if (!Object.hasOwn(at, part) || !isMapping(at[part])) put(at, part, {}); + at = at[part]; + } + put(at, parts.at(-1), value); +} + +/** Validated floors: dotted key to {direction, scale}. */ +function normalizeFloors(floors) { + if (!isMapping(floors)) throw new Error("config-cascade: floors must be a mapping of key path to Floor direction"); + const out = new Map(); + for (const [key, raw] of Object.entries(floors)) { + const direction = typeof raw === "string" ? raw : raw?.direction; + if (!DIRECTIONS.includes(direction)) throw new Error(`config-cascade: floor ${JSON.stringify(key)} has direction ${show(direction)}, not one of ${DIRECTIONS.join(", ")}`); + out.set(key, { direction, scale: isMapping(raw) && Array.isArray(raw.scale) ? raw.scale : null }); + } + return out; +} + +function nodeAt(schema, parts) { + let node = schema; + for (const part of parts) { + node = node && child(node, part); + if (!node) return null; + } + return node; +} + +const tightening = (v) => v === true || v === "report"; + +/** Why `personal` loosens `team` in the floor's direction, or null when it does not. */ +function looser(floor, personal, team, node) { + if (JSON.stringify(personal) === JSON.stringify(team)) return null; + switch (floor.direction) { + case "team-only": + if (Array.isArray(personal) && Array.isArray(team) && team.every((t) => personal.some((p) => JSON.stringify(p) === JSON.stringify(t)))) return null; + return `${show(personal)} differs from the team value ${show(team)}, and the team value wins`; + case "lowest-value": + if (typeof personal === "number" && typeof team === "number" && personal <= team) return null; + return `${show(personal)} is not lower than or equal to the team value ${show(team)}`; + case "any-layer-wins": + if (!tightening(team) || tightening(personal)) return null; + return `${show(personal)} drops the team value ${show(team)}, which is the tightening one`; + case "stricter-value": { + const scale = floor.scale ?? node?.enum ?? []; + const a = scale.findIndex((v) => JSON.stringify(v) === JSON.stringify(personal)); + const b = scale.findIndex((v) => JSON.stringify(v) === JSON.stringify(team)); + if (a >= 0 && b >= 0 && a >= b) return null; + return `${show(personal)} is not as strict as the team value ${show(team)} on the scale ${show(scale)}`; + } + default: + return null; + } +} + +export function resolve({ plugin, projectRoot, home = "docs/conventions", schema, defaults, userConfig, teamOnly = [], floors = {}, userHome = homedir() }) { + if (typeof plugin !== "string" || !PLUGIN.test(plugin)) throw new Error(`config-cascade: plugin name ${JSON.stringify(plugin)} is not a plain name`); + const only = new Set(teamOnly); + const floorMap = normalizeFloors(floors); + const names = flatNames(schema, only); + const homeDir = resolvePath(projectRoot, home); + // A relative home is inside the checkout by intent; a committed symlink must not carry it out. + const homeEscapes = !isAbsolute(home) && escapes(homeDir, projectRoot); + const layers = []; + const contributions = []; // [layer name, [[parts, value]], dotted keys whose value was invalid] + const prose = []; + const proseText = {}; + + // pairsOf(fail) returns the layer's accepted [parts, value] pairs; falsy when it contributes none. + const take = (layer, pairsOf) => { + const invalid = new Set(); + const fail = (message, key, valueFailed = false) => { + record(layer, `${layer.name}${layer.path ? ` (${layer.path})` : ""}: ${message}`, key); + if (valueFailed) invalid.add(key); + }; + if (pairsOf) contributions.push([layer.name, pairsOf(fail), invalid]); + layers.push(layer); + }; + const fileLeaves = (data, allowTeamOnly) => data && ((fail) => leaves(data, schema, { teamOnly: only, allowTeamOnly, fail })); + + const shaped = (name, given) => { + if (given === undefined || given === null) return { layer: newLayer(name, null, "absent"), ok: false }; + if (isMapping(given)) return { layer: newLayer(name, null, "loaded"), ok: true }; + return { layer: newLayer(name, null, "invalid", [`${name}: expected a mapping, got ${show(given)}`]), ok: false }; + }; + const d = shaped("defaults", defaults); + take(d.layer, d.ok && fileLeaves(defaults, true)); + const u = shaped("userConfig", userConfig); + take(u.layer, u.ok && ((fail) => userConfigLeaves(userConfig, names, only, fail))); + for (const [name, stem] of [ + ["team", join(homeDir, plugin)], + ["local", join(homeDir, `${plugin}.local`)], + ]) { + if (homeEscapes) { + const why = "the convention home resolves outside the project; a repository layer stays inside it"; + take(newLayer(name, `${stem}.yaml`, "invalid", [`${name} (${homeDir}): ${why}`])); + continue; + } + const read = readLayer(name, stem, true); + take(read.layer, fileLeaves(read.data, name === "team")); + if (read.prose) { + prose.push(read.prose); + proseText[name] = readText(read.prose); + } + } + + const merged = new Map(); // dotted -> {parts, value, from} + let base = new Map(); // the defaults layer's entries, where an invalid higher value falls back to + const within = (dotted, key) => dotted === key || dotted.startsWith(`${key}.`); + for (const [name, pairs, invalid] of contributions) { + // A lower layer's value is never used for an invalid one; the personal overlay refines the team file, so its keys fall to team. + if (name !== "local") { + for (const key of invalid) { + for (const dotted of [...merged.keys()]) if (within(dotted, key)) merged.delete(dotted); + for (const [dotted, entry] of base) if (within(dotted, key)) merged.set(dotted, entry); + } + } + const layer = layers.find((l) => l.name === name); + for (const [parts, value] of pairs) { + const dotted = parts.join("."); + const prior = merged.get(dotted); + const floor = floorMap.get(dotted); + const union = parts.at(-1) === "disable" && Array.isArray(value); + if (name === "local" && floor && floor.direction !== "no-floor" && !union && prior && [prior.from].flat().includes("team")) { + const why = looser(floor, value, prior.value, nodeAt(schema, parts)); + if (why) { + record(layer, `local (${layer.path}): ${quote(dotted)}: loosens the ${floor.direction} policy floor: ${why}`, dotted); + continue; + } + } + if (union) { + const items = Array.isArray(prior?.value) ? [...prior.value] : []; + const seen = new Set(items.map((i) => JSON.stringify(i))); + for (const item of value) { + const key = JSON.stringify(item); + if (!seen.has(key)) items.push(item); + seen.add(key); + } + // An empty list adds nothing, so its layer is credited only when no layer set the key before. + const from = Array.isArray(prior?.from) ? prior.from : []; + merged.set(dotted, { parts, value: items, from: value.length > 0 || !prior ? [...from, name] : from }); + } else merged.set(dotted, { parts, value, from: name }); + } + if (name === "defaults") base = new Map(merged); + } + const values = {}; + const provenance = {}; + for (const [dotted, { parts, value, from }] of merged) { + setPath(values, parts, value); + put(provenance, dotted, from); + } + + const legacy = legacyOf(plugin, userHome, projectRoot, !homeEscapes && `${join(homeDir, plugin)}.md`); + const sections = mergeSections(proseText.team ?? null, proseText.local ?? null).map(({ heading, text, layer }) => ({ heading, text, layer })); + return { values, provenance, prose, sections, layers, legacy }; +} + +/** The legacy locations that exist; `teamMd` (or false) is the team .md checked for a fenced config block. */ +function legacyOf(plugin, userHome, projectRoot, teamMd) { + const found = [ + ...legacyFiles(join(userHome, ".claude"), plugin, () => "claude-user"), + ...(projectRoot ? legacyFiles(join(projectRoot, ".claude"), plugin, (n) => (n.startsWith(`${plugin}.local.`) ? "claude-local" : "claude-project")) : []), + ...(teamMd && hasConfigBlock(teamMd) ? [{ path: teamMd, kind: "fenced-block" }] : []), + ]; + // The project root may be the user home, so a file is reported under its first kind only. + return found.filter((entry, i) => found.findIndex((e) => e.path === entry.path) === i); +} + +/** + * Each layer's own accepted values, unmerged, for a caller that weighs the layers itself (the + * rendered-views publish rule reads every layer separately: lib/view-settings.mjs). The same reader, + * refusals and schema check as resolve(), with no precedence, floors or invalid-value fallback: an + * invalid value leaves only its own layer. Layers: "userConfig", then "team" and "local" when + * projectRoot is set. `team`, as {path, text}, replaces the working-tree team file (text null: + * absent), for a caller reading it from a git ref. + * readLayers({plugin, projectRoot, home, schema, userConfig, teamOnly, team, userHome}) + * -> {layers: [{name, path, state, errors, error_keys, values: {"a.b": value}}], legacy} + */ +export function readLayers({ plugin, projectRoot = null, home = "docs/conventions", schema, userConfig, teamOnly = [], team, userHome = homedir() }) { + if (typeof plugin !== "string" || !PLUGIN.test(plugin)) throw new Error(`config-cascade: plugin name ${JSON.stringify(plugin)} is not a plain name`); + const only = new Set(teamOnly); + const layers = []; + const add = (layer, pairsOf) => { + const fail = (message, key) => record(layer, `${layer.name}${layer.path ? ` (${layer.path})` : ""}: ${message}`, key); + const values = {}; + for (const [parts, value] of pairsOf ? pairsOf(fail) : []) put(values, parts.join("."), value); + layers.push({ ...layer, values }); + }; + if (isMapping(userConfig)) add(newLayer("userConfig", null, "loaded"), (fail) => userConfigLeaves(userConfig, flatNames(schema, only), only, fail)); + if (!projectRoot) return { layers, legacy: legacyOf(plugin, userHome, null, false) }; + const homeDir = resolvePath(projectRoot, home); + const homeEscapes = !isAbsolute(home) && escapes(homeDir, projectRoot); + for (const [name, stem] of [ + ["team", join(homeDir, plugin)], + ["local", join(homeDir, `${plugin}.local`)], + ]) { + const fileLeaves = (data) => data && ((fail) => leaves(data, schema, { teamOnly: only, allowTeamOnly: name === "team", fail })); + if (name === "team" && team) { + const layer = newLayer(name, team.path, "absent"); + add(layer, typeof team.text === "string" && fileLeaves(parseInto(layer, () => team.text))); + } else if (homeEscapes) { + add(newLayer(name, `${stem}.yaml`, "invalid", [`${name} (${homeDir}): the convention home resolves outside the project; a repository layer stays inside it`])); + } else { + const read = readLayer(name, stem, true); + add(read.layer, fileLeaves(read.data)); + } + } + return { layers, legacy: legacyOf(plugin, userHome, projectRoot, !homeEscapes && `${join(homeDir, plugin)}.md`) }; +} diff --git a/plugins/prototype/lib/state-key.sh b/plugins/prototype/lib/state-key.sh new file mode 100755 index 00000000000..78a36277953 --- /dev/null +++ b/plugins/prototype/lib/state-key.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# GENERATED from lib/state-key.sh by scripts/sync-shared-copies.sh. Do not edit this copy: +# edit the canonical source, then rerun the script. +# Per-project state key for anything a plugin writes under ${CLAUDE_PLUGIN_DATA}. +# +# WHY. ${CLAUDE_PLUGIN_DATA} is keyed to the plugin identifier and nothing else: +# https://code.claude.com/docs/en/plugins/manifest-reference#environment-variables +# (as of 2026-10-11; recheck when that section adds a project, checkout, +# worktree or session segment to the path). So a skill that writes a fixed filename there has one file per +# MACHINE: every run from every repository overwrites the last, and a skill that +# READS it back can serve one project's findings as another's. +# +# This prints the missing segment. The scheme is canonically defined by the +# marketplace's plugin-data-report-keying convention (rule 1); this header keeps +# a named operational duplicate of that definition so the executable ships +# self-described: +# +# = / +# +# repo-identity the first configured remote URL normalized to +# host/owner/repo, lowercased, scheme/credentials/.git +# stripped. No remote -> local/. +# Not a repo at all -> nonrepo/. +# worktree-discriminator sha256 of the canonicalized worktree root, cut to 8 +# (outside a repository, of the physical cwd). Two +# worktrees of one repository legitimately hold +# different content and must not share a report. +# +# Every path is taken physical (symlinks resolved) before it is hashed. `cd` +# keeps the logical spelling a symlink was reached through, and the read-back +# consumers derive the key from wherever the operator launched, so one +# directory reached two ways must still produce one key. git canonicalizes +# the root it reports; the non-repository rung has to do it itself. +# +# SECURITY — this is why the identity is validated rather than merely lowercased. +# A remote URL is arbitrary text that becomes DIRECTORY COMPONENTS in the caller's +# path. A remote of `../../../etc` would walk a report out of the plugin's own +# namespace. Any identity that is not a plain lowercase segment path is replaced +# by a hash, so it still keys deterministically and still stays inside the +# namespace. Every rejected shape is covered by state-key.test.sh. +# +# NOT a substitute for a per-run filename. Keying stops cross-project collision; +# it does not stop a same-project rerun overwriting yesterday's report. A caller +# that needs history writes one file per run under this key and appends a line +# to a history file — see docs/contracts/plugin-data-report-keying/. +# +# Usage: +# state-key.sh [--root ] [--explain] +# +# --root derive for that directory instead of the current one +# --explain write the rung taken and its inputs to stderr +# +# Exit: 0 always on a successful derivation — every input reaches some rung, so +# there is no "cannot key" outcome. 2 on a bad argument, an unusable --root, or +# a PATH with neither sha256sum nor shasum. +# +# Shared source: lib/state-key.sh at the marketplace repo root is the canonical source; the copies in the plugins +# that carry it are generated by scripts/sync-shared-copies.sh and registered in +# scripts/shared-copies.txt. Edit this file and rerun the script. + +set -uo pipefail + +# `cd` consults CDPATH when the operand is relative and does not start with `.`, +# and on a hit it ECHOES the resolved path to stdout and lands somewhere the +# caller never named. Either one breaks this script: stdout must be exactly the +# key, and the key must describe the requested directory. Clear it once, here. +CDPATH= + +usage() { + cat <<'EOF' +state-key.sh — per-project state key for ${CLAUDE_PLUGIN_DATA} writes. + +Prints / for a directory, so a report +persisted under the plugin data directory belongs to one project rather than to +the machine. + +Usage: + state-key.sh [--root ] [--explain] [--help] + + --root derive for that directory instead of the current one + --explain write the rung taken and its inputs to stderr + +Exit: 0 on a derivation; 2 on a bad argument, an unusable --root, or no hash tool. +EOF +} + +ROOT_ARG="" +EXPLAIN=0 +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --root) + if [[ $# -lt 2 ]]; then + echo "ERROR: --root needs a path" >&2 + exit 2 + fi + ROOT_ARG="$2" + shift 2 + ;; + --explain) + EXPLAIN=1 + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -n "$ROOT_ARG" ]]; then + if [[ ! -d "$ROOT_ARG" ]]; then + echo "ERROR: --root is not a directory: $ROOT_ARG" >&2 + exit 2 + fi + cd "$ROOT_ARG" || exit 2 +fi +# Physical cwd, forklessly: `cd -P .` rewrites $PWD with symlinks resolved. +cd -P . || exit 2 + +# sha256sum is absent on stock macOS; shasum -a 256 is the portable partner. +# Probe here in the main script, not inside sha256(). hash12/hash8 run that +# helper as a non-last pipeline stage inside a command substitution, and POSIX +# XCU 2.6.3 plus Bash's pipeline rule both execute that in a subshell, so +# `exit 2` there cannot terminate this process. Under `set -uo pipefail` (no +# -e) the script would then print a malformed key at exit 0. +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256; } +else + echo "ERROR: no sha256sum or shasum on PATH — cannot derive a state key" >&2 + exit 2 +fi + +hash12() { printf '%s' "$1" | sha256 | cut -c1-12; } +hash8() { printf '%s' "$1" | sha256 | cut -c1-8; } + +# "the FIRST configured remote" — not necessarily one named `origin`. A repo +# whose only remote is `upstream` still has a remote and must not fall through +# to the local rung. +remote_name=$(git remote 2>/dev/null | tr -d '\r' | head -1) +remote="" +if [[ -n "$remote_name" ]]; then + remote=$(git config --get "remote.${remote_name}.url" 2>/dev/null | tr -d '\r') +fi +# tr -d '\r': Git on Windows can return a CRLF-terminated path. +root=$(git rev-parse --show-toplevel 2>/dev/null | tr -d '\r') + +rung="" +if [[ -n "$remote" ]]; then + identity=$(printf '%s' "$remote" | + sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' | + tr '[:upper:]' '[:lower:]') + # A remote URL is arbitrary text and becomes DIRECTORY COMPONENTS here, so + # accept it only in the shape the scheme means — segments of [a-z0-9._-] each + # starting alphanumeric. That rejects `../central` (a relative filesystem + # remote, which would otherwise write outside the caller's namespace), + # absolute local paths, and backslashes. Anything rejected still keys + # deterministically, by hash. + if grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$' <<<"$identity"; then + rung="remote" + else + identity="remote/$(hash12 "$remote")" + rung="remote-hashed" + fi +elif [[ -n "$root" ]]; then + identity="local/$(hash12 "$root")" + rung="local" +else + # Not a git repository. `audit-pass`'s ladder stops at the two git rungs + # because it refuses non-git targets; a report-only skill audits them, so the + # scheme needs a third rung rather than a failure. + identity="nonrepo/$(hash12 "$PWD")" + rung="nonrepo" +fi + +discriminator=$(hash8 "${root:-$PWD}") + +if [[ $EXPLAIN -eq 1 ]]; then + { + echo "rung: $rung" + echo "remote: ${remote:-}" + echo "repo root: ${root:-}" + echo "cwd: $PWD" + echo "identity: $identity" + echo "discriminator: $discriminator" + } >&2 +fi + +printf '%s/%s\n' "$identity" "$discriminator" diff --git a/plugins/prototype/lib/view-registry.mjs b/plugins/prototype/lib/view-registry.mjs new file mode 100644 index 00000000000..5b33fb87a25 --- /dev/null +++ b/plugins/prototype/lib/view-registry.mjs @@ -0,0 +1,94 @@ +// GENERATED from lib/view-registry.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// The medium registry for rendered views: one entry per medium, the one fallback chain, and each +// view lane's registration. Skills never name a medium themselves; they reach this through +// lib/view-settings.mjs. Adding a medium or a lane is an addition here, never a skill edit. +// +// MediumEntry { id, publishes, reachable(context) -> {ok, reason}, deliver(view, context) -> {location}, reportName } +// Lane { laneId, kinds, hostedCapable, default } +// +// `context.interactive` is false in a CI or other non-interactive run, where only the terminal is +// reachable (docs/contracts/rendered-views/README.md, "Reachability and preference"). A lane's +// `default` is its shipped medium, applied when no scope decides; null leaves the choice to the +// skill's own content rule. + +const interactiveOnly = (context = {}) => (context.interactive === false ? { ok: false, reason: "non-interactive run" } : { ok: true }); + +const entry = (id, publishes, reportName, reachable, deliver) => Object.freeze({ id, publishes, reportName, reachable, deliver }); + +export const MEDIUMS = Object.freeze({ + terminal: entry("terminal", false, "the terminal", () => ({ ok: true }), () => ({ location: "terminal" })), + file: entry("file", false, "a local file", interactiveOnly, (view) => ({ location: view.path })), + artifact: entry("artifact", true, "a private Artifact on claude.ai", interactiveOnly, (view) => ({ location: "artifact", publish: view.path })), + hosted: entry("hosted", true, "the shared page host", interactiveOnly, (view) => ({ location: "hosted", publish: view.path })), +}); + +/** The registry entry for `id`, own keys only, so an inherited name such as `toString` is no medium. */ +export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS, id) ? MEDIUMS[id] : undefined); + +/** The one fallback chain: a view that cannot be served on a medium steps down this list. */ +export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); + +/** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ +export const NO_PREFERENCE = "auto"; + +/** The rendered-views concern's schema, for the cascade reader: one key, `medium`. */ +export const SCHEMA = Object.freeze({ + type: "object", + additionalProperties: false, + properties: { medium: { type: "string", enum: [NO_PREFERENCE, ...Object.keys(MEDIUMS)] } }, +}); + +const lane = (laneId, kinds, hostedCapable, medium) => Object.freeze({ laneId, kinds: Object.freeze(kinds), hostedCapable, default: medium }); + +export const LANES = Object.freeze( + Object.fromEntries( + [ + lane("review:explain-change", ["digest"], true, "artifact"), + lane("visualization:present", ["deck"], false, "artifact"), + lane("visualization:visualize", [], false, null), + lane("planning", ["plan", "brainstorm"], false, "file"), + lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), + lane("discovery:blindspot", ["blindspot"], false, "file"), + lane("debugging:debug", ["post-mortem"], false, "file"), + lane("education:illustrate", [], false, "file"), + lane("work-items:morning-brief", ["morning-brief"], false, "file"), + lane("work-items:triage", ["board"], false, "file"), + lane("adhd:clarify", [], false, null), + lane("codebase-health:audit", [], false, null), + lane("overengineering:audit", [], false, null), + lane("education:teach", [], false, null), + lane("prototype:explore-directions", [], false, null), + lane("prototype:pressure-test", [], false, null), + lane("ai-briefing:generate", [], false, null), + ].map((l) => [l.laneId, l]), + ), +); + +/** The lane registration for `id`, own keys only. */ +export const laneOf = (id) => (typeof id === "string" && Object.hasOwn(LANES, id) ? LANES[id] : undefined); + +/** `medium` as `lane` reads it: `hosted` is `artifact` unless the lane sends pages to a page host. */ +export const readAs = (medium, laneEntry) => (medium === "hosted" && !laneEntry.hostedCapable ? "artifact" : medium); + +/** + * The medium actually used for `preferred` on `laneEntry`: `preferred` itself when reachable, else the + * first reachable medium after it on the fallback chain (a hosted page steps to `artifact` first). + * @returns {{medium: string, fell: null | string}} fell: why `preferred` was not used + */ +export function fallBack(preferred, laneEntry, context = {}) { + const want = readAs(preferred, laneEntry); + const start = FALLBACK_CHAIN.indexOf(want === "hosted" ? "artifact" : want); + const candidates = want === "hosted" ? ["hosted", ...FALLBACK_CHAIN.slice(start)] : FALLBACK_CHAIN.slice(start); + const wanted = mediumOf(want); + if (!wanted) throw new Error(`view-registry: unknown medium ${JSON.stringify(preferred)}`); + const first = wanted.reachable(context); + if (first.ok) return { medium: want, fell: null }; + const medium = candidates.find((id) => mediumOf(id).reachable(context).ok) ?? "terminal"; + return { medium, fell: `${want} unreachable: ${first.reason}` }; +} + +/** The report line every view lane prints, then one line per notice. */ +export function reportLines({ medium, decidedBy, notices = [] }) { + return [`view: ${medium ?? NO_PREFERENCE} (${decidedBy})`, ...notices.map((n) => `notice: ${n.text}`)]; +} diff --git a/plugins/prototype/lib/view-settings.mjs b/plugins/prototype/lib/view-settings.mjs new file mode 100755 index 00000000000..17f324dae75 --- /dev/null +++ b/plugins/prototype/lib/view-settings.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +// GENERATED from lib/view-settings.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// Where a rendered view goes: the one medium resolver every view lane calls. It reads each ADR 0075 +// scope's own `medium` value (concern `rendered-views`, home docs/conventions) through the cascade's +// per-layer reader and schema check, and walks them itself, because a filter on the cascade's +// merged output would lose a lower scope's local value under a higher scope's publishing one. +// +// The argument decides alone. Otherwise a terminal or file value from any scope keeps the view +// local, the highest-precedence one naming the scope (personal override, then team, then +// userConfig). Else the first publishing value from a trusted scope wins: the personal override +// (only untracked and gitignored), then userConfig. A team file never publishes. With no scope +// deciding, the lane's own default applies (it ships with the installed plugin, which a checked-out +// branch cannot write). An invalid value is ignored without dropping another scope's value; auto is +// unset. The retired files (~/.claude/rendered-views.md, .claude/rendered-views.md and +// .claude/rendered-views.local.md) give a notice, once per plugin per project while unchanged, and +// never a value. The medium then steps down the fallback chain (lib/view-registry.mjs) until +// reachable. +// +// view-settings.mjs resolve --lane --option [--argument ] [--team-ref ] +// Prints the resolution as one JSON line, then the report line and one line per notice. +// Exit 0 resolved, 2 usage. + +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readLayers } from "./config-cascade.mjs"; +import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; + +const CONCERN = "rendered-views"; +const HOME = "docs/conventions"; +const TEAM_FILE = `${HOME}/${CONCERN}.yaml`; +const PERSONAL_FILE = `${HOME}/${CONCERN}.local.yaml`; +const HERE = dirname(fileURLToPath(import.meta.url)); + +// ------------------------------------------------------------ repository checks + +/** The session's project: CLAUDE_PROJECT_DIR, else the nearest directory above the cwd holding `.git`. */ +export function findRoot() { + if (process.env.CLAUDE_PROJECT_DIR) return resolve(process.env.CLAUDE_PROJECT_DIR); + for (let at = process.cwd(); ; at = dirname(at)) { + if (existsSync(join(at, ".git"))) return at; + if (dirname(at) === at) return null; + } +} + +const real = (p) => { + try { + return realpathSync(p); + } catch { + return resolve(p); + } +}; +const within = (child, parent) => { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +}; +const isLink = (p) => { + try { + return lstatSync(p).isSymbolicLink(); + } catch { + return false; + } +}; +function gitOut(root, args) { + try { + return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + } catch { + return null; + } +} + +/** + * A personal file applies only untracked, so a pull request cannot ship one. Every directory between + * the root and the file must be a plain, untracked directory, never a submodule, a tracked file or a + * symlink. Not gitignored, it warns, or with `requireIgnored` is ignored too. + */ +export function overlayApplies(root, path, warnings, { requireIgnored = false } = {}) { + // Any tracked case variant counts: on a case-insensitive filesystem it is this file. + const rel = relative(root, path).split("\\").join("/"); + if (gitOut(root, ["ls-files", "--", `:(icase)${rel}`])) { + warnings.push(`overlay ${rel}: tracked in git, so a pull request could set it; layer ignored`); + return false; + } + const parts = rel.split("/").slice(0, -1); + const dirs = parts.map((_, i) => parts.slice(0, i + 1).join("/")); + for (const dir of dirs) { + // A submodule or tracked file at the directory itself is content a pull request controls. + const entries = (gitOut(root, ["ls-files", "-s", "-z", "--", `:(icase)${dir}`]) ?? "").split("\0"); + if (entries.some((e) => e.split("\t")[1]?.toLowerCase() === dir.toLowerCase()) || existsSync(join(root, dir, ".git"))) { + warnings.push(`overlay ${rel}: ${dir} is a submodule or tracked entry; layer ignored`); + return false; + } + if (isLink(join(root, dir))) { + warnings.push(`overlay ${rel}: ${dir} is a symlink; layer ignored`); + return false; + } + } + if (isLink(path) || !within(real(path), join(real(root), ...parts))) { + warnings.push(`overlay ${rel}: the overlay is a symlink or resolves outside ${parts.join("/") || "the root"}; layer ignored`); + return false; + } + if (gitOut(root, ["check-ignore", "-q", "--", path]) === null) { + warnings.push(`overlay ${rel}: not gitignored, so it can reach team history${requireIgnored ? "; layer ignored" : ""}`); + return !requireIgnored; + } + return true; +} + +/** Repository scopes apply only inside a working tree that is not the user's home or above it. */ +const repoScopes = (root, userHome) => Boolean(root) && !within(real(userHome), real(root)) && gitOut(root, ["rev-parse", "--is-inside-work-tree"]) !== null; + +/** A ref name or object id: never an option, never a revision expression. */ +const REF = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; + +/** The team file as `ref` holds it, for the cascade reader; a ref naming no commit reads nothing. */ +function teamAt(root, ref, notices) { + const sha = REF.test(ref) ? gitOut(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])?.trim() : null; + if (!sha) { + const named = ref ? `team ref ${JSON.stringify(ref)} names no commit here` : "no team ref was given"; + notices.push({ kind: "team-ref", text: `${named}, so ${TEAM_FILE} is not read` }); + return { path: `${ref}:${TEAM_FILE}`, text: null }; + } + return { path: `${sha.slice(0, 12)}:${TEAM_FILE}`, text: gitOut(root, ["show", `${sha}:${TEAM_FILE}`]) }; +} + +// ------------------------------------------------------------ retired files + +const mediumIn = (text) => /^[ \t]*medium:[ \t]*["']?([a-z]+)["']?[ \t]*$/m.exec(text ?? "")?.[1] ?? null; +const readBytes = (p) => { + try { + return readFileSync(p); + } catch { + return null; + } +}; + +/** This project's state key (docs/contracts/plugin-data-report-keying, rule 1), or null when it cannot be derived. */ +function stateKey(root) { + const out = spawnSync("bash", [join(HERE, "state-key.sh"), "--root", root ?? process.cwd()], { encoding: "utf8" }); + const key = out.status === 0 ? out.stdout.trim() : ""; + return /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)+$/.test(key) ? key : null; +} + +function writeAtomic(path, text) { + mkdirSync(dirname(path), { recursive: true }); + const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + writeFileSync(temp, text); + renameSync(temp, path); +} + +/** One notice per retired file, unless this plugin already showed it for this project with these bytes. */ +function retiredNotices(legacy, { plugin, root, userHome, dataDir }) { + const where = (path) => (within(path, join(userHome, ".claude")) ? `~/${relative(userHome, path).split("\\").join("/")}` : relative(root, path).split("\\").join("/")); + const destination = { "claude-user": `the ${plugin} plugin's medium option`, "claude-project": TEAM_FILE, "claude-local": PERSONAL_FILE, "fenced-block": TEAM_FILE }; + const key = dataDir ? stateKey(root) : null; + const state = key && join(dataDir, "view-settings", key, "retired-notices.json"); + let seen = {}; + try { + seen = state ? JSON.parse(readFileSync(state, "utf8")) : {}; + } catch { + seen = {}; + } + const notices = []; + let changed = false; + const order = Object.keys(destination); + for (const { path, kind } of [...legacy].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || a.path.localeCompare(b.path))) { + const bytes = readBytes(path); + if (!bytes) continue; + const shown = where(path); + const hash = createHash("sha256").update(bytes).digest("hex"); + if (seen[shown] === hash) continue; + [seen[shown], changed] = [hash, true]; + const value = mediumIn(bytes.toString("utf8")); + const to = destination[kind] ?? TEAM_FILE; + const caveat = + value && mediumOf(value)?.publishes + ? to === TEAM_FILE + ? "; a team file never publishes, so that value would be ignored there" + : to === PERSONAL_FILE + ? "; it publishes only while that file is untracked and gitignored" + : "" + : ""; + notices.push({ kind: "retired", path: shown, value, destination: to, text: `${shown} is no longer read${value ? ` (it sets medium: ${value})` : ""}; set it in ${to} instead${caveat}` }); + } + if (state && changed) { + try { + writeAtomic(state, `${JSON.stringify(seen)}\n`); + } catch { + // Unwritable state shows the notice again next time, which is the safe direction. + } + } + return notices; +} + +// ------------------------------------------------------------ resolution + +/** True when the reader chose a publishing medium (the argument, or a trusted scope), which the publish gate takes as explicit: never the plugin default. */ +export const gateExplicit = ({ preferred, decidedBy }) => Boolean(mediumOf(preferred)?.publishes) && ["argument", "userConfig", "personal override"].includes(decidedBy); + +/** + * @param {{lane: string, argument?: string|null, userConfig?: string|null, projectRoot?: string|null, teamRef?: string|null}} request + * @param {{userHome?: string, dataDir?: string|null, interactive?: boolean}} [options] + * @returns {{preferred: string|null, medium: string|null, decidedBy: string, notices: {kind: string, text: string}[]}} + */ +export function resolveMedium({ lane, argument = null, userConfig = null, projectRoot = null, teamRef = null }, { userHome = homedir(), dataDir = null, interactive = true } = {}) { + const laneEntry = laneOf(lane); + if (!laneEntry) throw new Error(`view-settings: unknown lane ${JSON.stringify(lane)}`); + const notices = []; + const root = repoScopes(projectRoot, userHome) ? projectRoot : null; + const team = root && typeof teamRef === "string" ? teamAt(root, teamRef, notices) : undefined; + const { layers, legacy } = readLayers({ plugin: CONCERN, projectRoot: root, schema: SCHEMA, userConfig: { medium: userConfig ?? "" }, team, userHome }); + notices.unshift(...retiredNotices(legacy, { plugin: lane.split(":")[0], root, userHome, dataDir })); + const finish = (preferred, decidedBy) => { + if (!preferred) return { preferred: null, medium: null, decidedBy, notices }; + const { medium, fell } = fallBack(preferred, laneEntry, { interactive }); + return { preferred, medium, decidedBy: fell ? `fell back: ${fell}` : decidedBy, notices }; + }; + if (mediumOf(argument)) return finish(argument, "argument"); + + const shown = (path) => (path && root && isAbsolute(path) ? relative(root, path).split("\\").join("/") : path); + for (const layer of layers) { + for (const error of layer.errors) { + const prefix = `${layer.name}${layer.path ? ` (${layer.path})` : ""}: `; + const name = { local: "personal override" }[layer.name] ?? layer.name; + notices.push({ kind: "invalid", text: `${name}${layer.path ? ` ${shown(layer.path)}` : ""}: ${error.startsWith(prefix) ? error.slice(prefix.length) : error}; ignored` }); + } + } + const valueOf = (name) => layers.find((l) => l.name === name)?.values.medium; + const personal = valueOf("local"); + let personalTrusted = false; + if (personal && mediumOf(personal)?.publishes) { + const warnings = []; + personalTrusted = overlayApplies(root, join(root, PERSONAL_FILE), warnings, { requireIgnored: true }); + for (const w of warnings) notices.push({ kind: "ignored", text: `personal override ${w.replace(/^overlay /, "")}` }); + } + // Highest precedence first (ADR 0075: the personal override, then team, then userConfig). + const scopes = [ + ["personal override", personal, personalTrusted], + ["team", valueOf("team"), false], + ["userConfig", valueOf("userConfig"), true], + ].filter(([, medium]) => medium && medium !== NO_PREFERENCE); + const local = scopes.find(([, medium]) => !mediumOf(medium).publishes); + if (local) return finish(local[1], local[0]); + const teamValue = scopes.find(([name]) => name === "team")?.[1]; + if (teamValue) notices.push({ kind: "ignored", text: `team ${shown(layers.find((l) => l.name === "team").path)} sets medium: ${teamValue}; a team file never publishes, so it is ignored` }); + const trusted = scopes.find(([, , ok]) => ok); + return trusted ? finish(trusted[1], trusted[0]) : finish(laneEntry.default, "plugin default"); +} + +// ------------------------------------------------------------ CLI + +const USAGE = "usage: view-settings.mjs resolve --lane --option [--argument ] [--team-ref ]\n"; + +/** CLI: the argument vector after the script path; returns the exit code. */ +export function main(argv, env = process.env) { + const [action, ...rest] = argv; + const request = { lane: null, userConfig: undefined, argument: null, teamRef: null }; + const flags = { "--lane": "lane", "--option": "userConfig", "--argument": "argument", "--team-ref": "teamRef" }; + for (let i = 0; i < rest.length; i += 2) { + const key = flags[rest[i]]; + if (!key || rest[i + 1] === undefined) return usage(); + request[key] = rest[i + 1]; + } + if (action !== "resolve" || !laneOf(request.lane) || request.userConfig === undefined) return usage(); + if (request.argument === NO_PREFERENCE) request.argument = null; + if (request.argument !== null && !mediumOf(request.argument)) return usage(); + const ci = Boolean(env.CI) && env.CI !== "false"; + const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); + process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + return 0; +} + +function usage() { + process.stderr.write(USAGE); + return 2; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/plugins/prototype/lib/yaml-subset.mjs b/plugins/prototype/lib/yaml-subset.mjs new file mode 100644 index 00000000000..b97b62c2ab0 --- /dev/null +++ b/plugins/prototype/lib/yaml-subset.mjs @@ -0,0 +1,314 @@ +// GENERATED from lib/yaml-subset.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// A parser for the YAML subset the team file is written in: a port of +// plugins/code-metrics/scripts/yaml_subset.py, kept to the same grammar. +// +// parse(text) -> the document (null when empty), or throws YamlSubsetError {line, message} +// parse(text, { source: true }) -> the same grammar, but every scalar is its source text as +// lib/yaml-subset.awk prints it (plain text as written, quotes removed, `\n` kept as written), +// mappings are Maps in document order, an empty value is null, and a key may not be empty or +// hold `.` or a tab; as in that reader, a plain key is `[A-Za-z0-9_-]`, a quoted key may have +// blanks before its `:`, only a comment may follow a quoted value, and a root sequence or a +// sequence nested on one line is an error +// +// The subset: block mappings (nested by indentation of two or more spaces); block sequences +// (scalar or mapping items, a mapping item may start on the dash line); flow sequences of scalars +// (`[a, "b", 3]`, `[]`); scalars: quoted and plain strings, integers, floats, true/false, +// null/~, and an empty value meaning null; `#` comments outside quotes; blank lines. +// Everything else is reported with its line rather than parsed partially: flow mappings (`{`), +// anchors and aliases, tags, block scalars, document markers, tab indentation, duplicate keys, +// nesting deeper than MAX_DEPTH. +// Narrower than the Python grammar, as lib/yaml-subset.awk is: a plain value may not hold `: ` +// and a plain key may not hold whitespace, `$` or `.`; quoting either lifts the limit. +// Keys become own data properties, so a `__proto__` key never reaches an object's prototype. +// Error messages quote file text as JSON strings cut to 60 characters: they describe data. + +export class YamlSubsetError extends Error { + constructor(line, message) { + super(`line ${line}: ${message}`); + this.name = "YamlSubsetError"; + this.line = line; + } +} + +// Every descent below the top level passes through nested(), so this bounds the recursion. +const MAX_DEPTH = 64; +const INT = /^[-+]?\d+$/; +const FLOAT = /^[-+]?(\d+\.\d*|\.\d+|\d+)([eE][-+]?\d+)?$/; +const quote = (text) => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}...` : text); +const isItem = (content) => content === "-" || content.startsWith("- "); +const put = (object, key, value) => Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true }); + +/** The text before `#` as a comment ends it. A quote opens only where a value starts (line start, + * after `: `, `- `, `[` or `,`), so an apostrophe inside a plain value is just a character. */ +function stripComment(text) { + let q = null; + let last = -1; // index of the last non-whitespace character before i, so each test is O(1) + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (q) { + if (q === '"' && ch === "\\") i++; + else if (q === "'" && ch === "'" && text[i + 1] === "'") i++; + else if (ch === q) q = null; + } else if ((ch === "'" || ch === '"') && valueStarts(text, last, i)) { + q = ch; + } else if (ch === "#" && (i === 0 || text[i - 1] === " " || text[i - 1] === "\t")) { + return text.slice(0, i).trimEnd(); + } + if (i < text.length && !/\s/.test(text[i])) last = i; + } + return text.trimEnd(); +} + +/** True when a value starts at i: only whitespace follows line start, `: `, `- `, `[` or `,`. */ +function valueStarts(text, last, i) { + if (last < 0) return true; + const c = text[last]; + const spaced = i - last > 1; + return c === "[" || c === "," || (spaced && c === ":") || (spaced && c === "-" && (last === 0 || /\s/.test(text[last - 1]))); +} + +const ESCAPES = { n: "\n", t: "\t", '"': '"', "\\": "\\", "/": "/" }; +const SOURCE_ESCAPES = { ...ESCAPES, n: "\\n" }; + +function unquote(text, line, source) { + const q = text[0]; + if (text.length < 2 || text.at(-1) !== q) throw new YamlSubsetError(line, "unterminated quoted string"); + const body = text.slice(1, -1); + if (q === "'") return body.replaceAll("''", "'"); + let out = ""; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === "\\" && i + 1 < body.length) { + const next = body[i + 1]; + if (!Object.hasOwn(ESCAPES, next)) throw new YamlSubsetError(line, `unsupported escape \\${next} in double-quoted string`); + out += (source ? SOURCE_ESCAPES : ESCAPES)[next]; + i++; + continue; + } + out += ch; + } + return out; +} + +function scalar(raw, line, source) { + const text = raw.trim(); + if (text === "") return null; + if (text[0] === "'" || text[0] === '"') { + const close = source ? closingQuote(text) : -1; + if (close !== -1 && close !== text.length - 1) throw new YamlSubsetError(line, "unexpected text after a value"); + return unquote(text, line, source); + } + if (text[0] === "{") throw new YamlSubsetError(line, "flow mapping ({...}) is outside the subset; use block style"); + if (text[0] === "&" || text[0] === "*") throw new YamlSubsetError(line, "anchors and aliases are outside the subset"); + if (text[0] === "!") throw new YamlSubsetError(line, "tags are outside the subset"); + if (["|", ">", "|-", ">-", "|+", ">+"].includes(text)) throw new YamlSubsetError(line, "block scalars (| and >) are outside the subset"); + if (text[0] === "[") return flowSequence(text, line, source); + if (/:[ \t]/.test(text)) throw new YamlSubsetError(line, "a plain value may not hold `: `; quote it"); + if (source) return text; + if (["null", "Null", "NULL", "~"].includes(text)) return null; + if (["true", "True", "TRUE"].includes(text)) return true; + if (["false", "False", "FALSE"].includes(text)) return false; + if (INT.test(text) || FLOAT.test(text)) return Number(text); + return text; +} + +function flowSequence(text, line, source) { + if (!text.endsWith("]")) throw new YamlSubsetError(line, "unterminated flow sequence"); + const body = text.slice(1, -1).trim(); + if (body === "") return []; + const items = []; + let current = ""; + let q = null; + let depth = 0; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (q) { + current += ch; + // A backslash escape in "..." and a doubled quote in '...' stay inside the item. + if ((q === '"' && ch === "\\") || (q === "'" && ch === "'" && body[i + 1] === "'")) current += body[++i] ?? ""; + else if (ch === q) q = null; + continue; + } + if (ch === "'" || ch === '"') q = ch; + else if (ch === "[") depth++; + else if (ch === "]") depth--; + else if (ch === "," && depth === 0) { + items.push(current); + current = ""; + continue; + } + current += ch; + } + items.push(current); + return items.map((raw) => { + const item = raw.trim(); + if (item.startsWith("[")) throw new YamlSubsetError(line, "nested flow sequences are outside the subset"); + return scalar(item, line, source); + }); +} + +/** The index of the quote closing the quoted text that opens at text[0], or -1. A doubled `''` in + * single quotes and a backslash escape in double quotes stay inside, as in unquote(). */ +function closingQuote(text) { + const q = text[0]; + for (let i = 1; i < text.length; i++) { + if (q === '"' && text[i] === "\\") i++; + else if (text[i] === q) { + if (q === "'" && text[i + 1] === "'") i++; + else return i; + } + } + return -1; +} + +/** [key, rest] when the content is a mapping entry, else null. */ +function splitKey(content, line, source) { + if (content[0] === "'" || content[0] === '"') { + const close = closingQuote(content); + if (close === -1) throw new YamlSubsetError(line, "unterminated quoted key"); + const key = unquote(content.slice(0, close + 1), line, source); + const rest = source ? content.slice(close + 1).replace(/^[ \t]+/, "") : content.slice(close + 1); + if (!rest.startsWith(":") || (rest.length > 1 && rest[1] !== " " && rest[1] !== "\t")) return null; + // A source-mode key is one step of a dotted record path, as in lib/yaml-subset.awk. + if (source && (key === "" || /[.\t]/.test(key))) throw new YamlSubsetError(line, "a key may not be empty or hold `.` or a tab"); + return [key, rest.slice(1).trim()]; + } + const split = plainKey(content) ?? (content.endsWith(":") ? [content.slice(0, -1).trim(), ""] : null); + // lib/yaml-subset.awk reads no other plain key, so the line is no mapping entry there: it falls + // through to a scalar, or to "expected a mapping entry" on the same line awk errors on. + if (split && source && !/^[A-Za-z0-9_-]+$/.test(split[0])) return null; + if (split && /[\s$.]/.test(split[0])) throw new YamlSubsetError(line, `a plain key may not hold a space, \`$\` or \`.\`; quote it: ${quote(split[0])}`); + return split; +} + +/** The Python grammar `^([^\s:[\]{}#][^:#]*?):(?:\s+(.*))?$` in one linear scan: the key runs to + * the first `:`, holds no `#`, and the `:` ends the line or is followed by whitespace and a rest + * with no line terminator after its leading whitespace. */ +function plainKey(content) { + if (content === "" || /[\s:[\]{}#]/.test(content[0])) return null; + const colon = content.indexOf(":"); + if (colon === -1 || content.slice(0, colon).includes("#")) return null; + const key = content.slice(0, colon).trim(); + const after = content.slice(colon + 1); + if (after === "") return [key, ""]; + if (!/^\s/.test(after)) return null; + const rest = after.trimStart(); + if (/[\n\r\u2028\u2029]/.test(rest)) return null; + return [key, rest.trim()]; +} + +class Parser { + constructor(text, source = false) { + this.source = source; + this.lines = []; // [indent, lineNumber, content] + text + .replace(/^/, "") + .split(/\r\n|\r|\n/) + .forEach((raw, i) => { + const number = i + 1; + if (raw.startsWith("---") || raw.startsWith("...")) throw new YamlSubsetError(number, "document markers are outside the subset"); + const stripped = stripComment(raw); + if (!stripped.trim()) return; + const indent = stripped.length - stripped.replace(/^ +/, "").length; + if (stripped[indent] === "\t") throw new YamlSubsetError(number, "tab indentation is outside the subset"); + this.lines.push([indent, number, stripped.slice(indent)]); + }); + this.pos = 0; + this.depth = 0; + } + + parse() { + if (!this.lines.length) return null; + if (this.source && isItem(this.lines[0][2])) throw new YamlSubsetError(this.lines[0][1], "a root sequence is not part of the subset"); + const value = this.block(this.lines[0][0]); + if (this.pos < this.lines.length) { + const [indent, number, content] = this.lines[this.pos]; + throw new YamlSubsetError(number, `unexpected content at indent ${indent}: ${quote(content)}`); + } + return value; + } + + block(indent) { + const [, number, content] = this.lines[this.pos]; + if (isItem(content)) return this.sequence(indent); + if (splitKey(content, number, this.source) === null) throw new YamlSubsetError(number, `expected a mapping entry or sequence item, got ${quote(content)}`); + return this.mapping(indent); + } + + /** The block mapping at `indent`, its entries put into `result` (a sequence item's dash-line entry + * arrives already in it, so a duplicate is reported on its own line). */ + mapping(indent, result = this.map()) { + while (this.pos < this.lines.length) { + const [lineIndent, number, content] = this.lines[this.pos]; + if (lineIndent < indent) break; + if (lineIndent > indent) throw new YamlSubsetError(number, `unexpected indent ${lineIndent} (expected ${indent})`); + const split = splitKey(content, number, this.source); + if (split === null) throw new YamlSubsetError(number, `expected a mapping entry, got ${quote(content)}`); + const [key, rest] = split; + if (result instanceof Map ? result.has(key) : Object.hasOwn(result, key)) throw new YamlSubsetError(number, `duplicate key ${quote(key)}`); + this.pos++; + this.set(result, key, rest === "" ? this.nested(indent) : scalar(rest, number, this.source)); + } + return result; + } + + nested(parentIndent) { + if (this.pos < this.lines.length) { + const [childIndent, number, content] = this.lines[this.pos]; + if (childIndent < parentIndent || (childIndent === parentIndent && !isItem(content))) return null; + if (this.depth === MAX_DEPTH) throw new YamlSubsetError(number, `nesting deeper than ${MAX_DEPTH} levels is outside the subset`); + this.depth++; + try { + // A sequence may sit at the parent's indent (`key:` then `- item`). + return childIndent > parentIndent ? this.block(childIndent) : this.sequence(childIndent); + } finally { + this.depth--; + } + } + return null; + } + + sequence(indent) { + const result = []; + while (this.pos < this.lines.length) { + const [lineIndent, number, content] = this.lines[this.pos]; + if (lineIndent < indent || !isItem(content)) break; + if (lineIndent > indent) throw new YamlSubsetError(number, `unexpected indent ${lineIndent} (expected ${indent})`); + const item = content.slice(1).trim(); + if (this.source && isItem(item)) throw new YamlSubsetError(number, "a sequence nested on one line is not part of the subset"); + this.pos++; + if (item === "") { + // Only a deeper line belongs to an empty item; a `- ` line at this indent is its sibling. + const next = this.lines[this.pos]; + result.push(next && next[0] > indent ? this.nested(indent) : null); + continue; + } + // An item opening a quote it never closes is a quoted scalar, which scalar() reads or rejects. + const split = (item[0] === "'" || item[0] === '"') && closingQuote(item) === -1 ? null : splitKey(item, number, this.source); + if (split === null) { + result.push(scalar(item, number, this.source)); + continue; + } + // A mapping whose first entry sits on the dash line; the rest of its entries are indented + // to the item column. + const itemIndent = indent + 2; + const [key, rest] = split; + const mapping = this.set(this.map(), key, rest === "" ? this.nested(itemIndent) : scalar(rest, number, this.source)); + if (this.pos < this.lines.length && this.lines[this.pos][0] === itemIndent) this.mapping(itemIndent, mapping); + result.push(mapping); + } + return result; + } + + map() { + return this.source ? new Map() : {}; + } + + set(mapping, key, value) { + return mapping instanceof Map ? mapping.set(key, value) : put(mapping, key, value); + } +} + +/** Parse a document written in the subset; throw YamlSubsetError otherwise. */ +export const parse = (text, { source = false } = {}) => new Parser(text, source).parse(); diff --git a/scripts/shared-copies.txt b/scripts/shared-copies.txt index 767d5448341..ea59e4245a7 100644 --- a/scripts/shared-copies.txt +++ b/scripts/shared-copies.txt @@ -509,14 +509,29 @@ lib/state-key.sh plugins/adhd/lib/state-key.sh lib/state-key.sh plugins/ai-briefing/lib/state-key.sh lib/view-settings.mjs plugins/architecture/lib/view-settings.mjs lib/view-settings.mjs plugins/debugging/lib/view-settings.mjs +lib/view-settings.mjs plugins/discovery/lib/view-settings.mjs +lib/view-settings.mjs plugins/education/lib/view-settings.mjs +lib/view-settings.mjs plugins/prototype/lib/view-settings.mjs +lib/view-settings.mjs plugins/codebase-health/lib/view-settings.mjs +lib/view-settings.mjs plugins/overengineering/lib/view-settings.mjs lib/view-registry.mjs plugins/architecture/lib/view-registry.mjs lib/view-registry.mjs plugins/debugging/lib/view-registry.mjs +lib/view-registry.mjs plugins/discovery/lib/view-registry.mjs +lib/view-registry.mjs plugins/education/lib/view-registry.mjs +lib/view-registry.mjs plugins/prototype/lib/view-registry.mjs +lib/view-registry.mjs plugins/codebase-health/lib/view-registry.mjs +lib/view-registry.mjs plugins/overengineering/lib/view-registry.mjs lib/config-cascade.mjs plugins/architecture/lib/config-cascade.mjs lib/config-cascade.mjs plugins/debugging/lib/config-cascade.mjs lib/yaml-subset.mjs plugins/architecture/lib/yaml-subset.mjs lib/yaml-subset.mjs plugins/debugging/lib/yaml-subset.mjs lib/state-key.sh plugins/architecture/lib/state-key.sh lib/state-key.sh plugins/debugging/lib/state-key.sh +lib/state-key.sh plugins/discovery/lib/state-key.sh +lib/state-key.sh plugins/education/lib/state-key.sh +lib/state-key.sh plugins/prototype/lib/state-key.sh +lib/state-key.sh plugins/codebase-health/lib/state-key.sh +lib/state-key.sh plugins/overengineering/lib/state-key.sh lib/rendered-view.md plugins/planning/reference/rendered-view.md lib/rendered-view.md plugins/architecture/reference/rendered-view.md lib/rendered-view.md plugins/discovery/reference/rendered-view.md @@ -560,7 +575,15 @@ lib/config-cascade.mjs plugins/domain-driven-design/scripts/lib/config-cascade.m lib/yaml-subset.mjs plugins/domain-driven-design/scripts/lib/yaml-subset.mjs lib/resolve-convention-home.sh plugins/domain-driven-design/lib/resolve-convention-home.sh lib/config-cascade.mjs plugins/corrections/scripts/lib/config-cascade.mjs +lib/config-cascade.mjs plugins/discovery/lib/config-cascade.mjs +lib/config-cascade.mjs plugins/education/lib/config-cascade.mjs +lib/config-cascade.mjs plugins/prototype/lib/config-cascade.mjs +lib/config-cascade.mjs plugins/codebase-health/lib/config-cascade.mjs +lib/config-cascade.mjs plugins/overengineering/lib/config-cascade.mjs lib/yaml-subset.mjs plugins/corrections/scripts/lib/yaml-subset.mjs +lib/yaml-subset.mjs plugins/prototype/lib/yaml-subset.mjs +lib/yaml-subset.mjs plugins/codebase-health/lib/yaml-subset.mjs +lib/yaml-subset.mjs plugins/overengineering/lib/yaml-subset.mjs lib/resolve-convention-home.sh plugins/corrections/lib/resolve-convention-home.sh lib/redact.py plugins/session-flow/skills/audit-sessions/scripts/redact.py lib/redact.py plugins/corrections/tools/findings-buffer/lib/redact.py