diff --git a/.changes/code-tidying/feat-12475-upkeep-settings-5d2e8c17.md b/.changes/code-tidying/feat-12475-upkeep-settings-5d2e8c17.md new file mode 100644 index 00000000000..ee1380fb7f8 --- /dev/null +++ b/.changes/code-tidying/feat-12475-upkeep-settings-5d2e8c17.md @@ -0,0 +1,14 @@ +--- +bump: minor +--- + +### Added + +- Upkeep settings `upkeep_cadence`, `upkeep_backstop` and `upkeep_max_open` in + `/code-tidying.yaml` (defaults `daily`, `weekly`, 3), read by + `skills/tidy/scripts/upkeep-setting.sh`. See `reference/config.md`. + +### Changed + +- The `/code-tidying:tidy` backlog throttle takes its limit from `upkeep_max_open`, and + `open-pr-count.sh` reports the limit and the layer that supplied it. diff --git a/.github/scripts/maintenance-code-tidying-upkeep/package.json b/.github/scripts/maintenance-code-tidying-upkeep/package.json new file mode 100644 index 00000000000..3cc146bc36d --- /dev/null +++ b/.github/scripts/maintenance-code-tidying-upkeep/package.json @@ -0,0 +1,7 @@ +{ + "name": "maintenance-code-tidying-upkeep", + "private": true, + "scripts": { + "test": "node --test" + } +} diff --git a/.github/scripts/maintenance-code-tidying-upkeep/upkeep.mjs b/.github/scripts/maintenance-code-tidying-upkeep/upkeep.mjs new file mode 100644 index 00000000000..ea2feb67683 --- /dev/null +++ b/.github/scripts/maintenance-code-tidying-upkeep/upkeep.mjs @@ -0,0 +1,212 @@ +// Code-tidying upkeep, no model. Two commands: +// +// detect --before --after [--root ] +// Per merge: names the areas a pushed range changed, on stdout and in the +// job summary. Opens nothing. +// gate [--root ] +// Prints the batch as JSON: whether it may run and which areas to sweep, +// with `/code-tidying:tidy ` opening one PR per area on the +// chore/tidy-adhoc--YYYY-MM-DD branch that skill names. Exit 1 when +// a read fails: nothing is planned on a count it could not read. +// +// The gate derives churn from git log, so a push run GitHub lost drops no area +// (dependabot-fragments.yml records that those happen). +import { execFileSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const TIDY_SCRIPTS = join(HERE, "../../../plugins/code-tidying/skills/tidy/scripts"); +const DAY_MS = 86_400_000; +const WINDOW_DAYS = { daily: 1, weekly: 7, monthly: 30 }; +const PULL_LIST_LIMIT = 1000; +const NEVER_SWEPT_DAYS = 90; +const SAFE_AREA = /^[A-Za-z0-9_][A-Za-z0-9._-]*(\/[A-Za-z0-9._-]+)?$/; +const ADHOC_BRANCH = /^chore\/tidy-adhoc-(.+)-\d{4}-\d{2}-\d{2}$/; + +const dayOf = (ms) => Math.floor(ms / DAY_MS); + +// A path's area: plugins/, docs/, else the first segment. A path under +// a dot directory is left out: tidy's HARD list covers agent and CI config. A +// file directly under plugins/ or docs/, or at the root, has no subtree of its +// own, so its glob stops at that level. +export function areaOf(path) { + const parts = path.split("/"); + if (parts[0].startsWith(".")) return null; + let found; + if (parts.length === 1) { + found = { area: "root", glob: "*" }; + } else if ((parts[0] === "plugins" || parts[0] === "docs") && parts.length > 2) { + const area = `${parts[0]}/${parts[1]}`; + found = { area, glob: `${area}/**` }; + } else if (parts[0] === "plugins" || parts[0] === "docs") { + found = { area: parts[0], glob: `${parts[0]}/*` }; + } else { + found = { area: parts[0], glob: `${parts[0]}/**` }; + } + return SAFE_AREA.test(found.area) ? found : null; +} + +// The tidy's ad hoc branch carries: the glob's literal directory prefix in kebab case. +export const slugOf = (area) => area.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + +export function detectAreas(paths) { + const names = paths.map((path) => areaOf(path)?.area).filter(Boolean); + return [...new Set(names)].sort(); +} + +// pulls: { headRefName, createdAt, state, mergeCommit }. Slug to the newest sweep's time, +// the slugs with one open, and each sweep's own merge commit, which is not churn. +function sweepsOf(pulls) { + const last = new Map(); + const open = new Set(); + const merges = new Set(); + for (const pull of pulls) { + const slug = ADHOC_BRANCH.exec(pull.headRefName)?.[1]; + if (!slug) continue; + last.set(slug, Math.max(last.get(slug) ?? 0, Date.parse(pull.createdAt))); + if (pull.state === "OPEN") open.add(slug); + if (pull.mergeCommit?.oid) merges.add(pull.mergeCommit.oid); + } + return { last, open, merges }; +} + +// How far back the gate reads commits: to the oldest known sweep, so an area's +// churn since its sweep is seen however long the cap held it, and at least +// NEVER_SWEPT_DAYS for an area with no sweep on record. +export function lookbackStart(pulls, now) { + const sweeps = pulls.filter((pull) => ADHOC_BRANCH.test(pull.headRefName)).map((pull) => Date.parse(pull.createdAt)); + return Math.min(now - NEVER_SWEPT_DAYS * DAY_MS, ...sweeps); +} + +// paths: every tracked file. commits: { sha, at (ms), paths } back to lookbackStart. +// pulls: every sweep. Only an area with a commit after its +// last sweep is eligible. It is overdue once that oldest unswept commit is past +// the backstop window; overdue areas go first, then the oldest unswept. +export function plan({ paths, commits, pulls, cadence, backstop, limit, open, throttled, now }) { + const stop = (reason) => ({ proceed: false, reason, areas: [] }); + const windowDays = WINDOW_DAYS[backstop]; + + const { last, open: openSlugs, merges } = sweepsOf(pulls); + const newest = Math.max(0, ...last.values()); + if (newest > 0 && dayOf(now) - dayOf(newest) < WINDOW_DAYS[cadence]) return stop("cadence"); + const slots = Math.max(0, limit - open); + if (throttled || slots === 0) return stop("cap"); + + const rows = new Map(); + for (const path of paths) { + const found = areaOf(path); + if (!found || rows.has(found.area)) continue; + const slug = slugOf(found.area); + const swept = last.get(slug) ?? 0; + rows.set(found.area, { ...found, slug, swept, unswept: Infinity }); + } + for (const commit of commits) { + if (merges.has(commit.sha)) continue; + for (const path of commit.paths) { + const row = rows.get(areaOf(path)?.area); + if (row && commit.at > row.swept) row.unswept = Math.min(row.unswept, commit.at); + } + } + + const overdue = (row) => dayOf(now) - dayOf(row.unswept) >= windowDays; + const eligible = [...rows.values()].filter((row) => row.unswept !== Infinity && !openSlugs.has(row.slug)); + eligible.sort((a, b) => Number(overdue(b)) - Number(overdue(a)) || a.unswept - b.unswept || a.area.localeCompare(b.area)); + const areas = eligible.slice(0, slots).map((row) => ({ area: row.area, glob: row.glob, slug: row.slug, overdue: overdue(row) })); + return { proceed: areas.length > 0, reason: areas.length > 0 ? "due" : "nothing-due", areas }; +} + +export function parseCount(text) { + const open = /^Open tidy PR count: (\d+)( or more)?$/m.exec(text); + const limit = /^Throttle limit: (\d+),/m.exec(text); + const active = /^Throttle active: (yes|no)$/m.exec(text); + if (!open || !limit || !active) throw new Error("the open tidy PR count is unknown"); + return { open: Number(open[1]), limit: Number(limit[1]), throttled: active[1] === "yes" }; +} + +// `\0 ` before each commit's paths. +export function parseLog(text) { + return text + .split("\0") + .slice(1) + .map((chunk) => { + const [head, ...paths] = chunk.split("\n").filter(Boolean); + const [sha, at] = head.split(" "); + return { sha, at: Number(at) * 1000, paths }; + }); +} + +function run(root, file, args, env = {}) { + return execFileSync(file, args, { + cwd: root, + encoding: "utf8", + maxBuffer: 512 * 1024 * 1024, + env: { ...process.env, CLAUDE_PROJECT_DIR: root, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function setting(root, key) { + const [value, source] = run(root, "bash", [join(TIDY_SCRIPTS, "upkeep-setting.sh"), key]).trimEnd().split("\t"); + return { value, source }; +} + +function gate(root, now) { + const cadence = setting(root, "upkeep_cadence"); + const backstop = setting(root, "upkeep_backstop"); + const count = parseCount(run(root, "bash", [join(TIDY_SCRIPTS, "open-pr-count.sh")])); + // The sweeps, by branch prefix: listing all pull requests reaches about 3 days per 1000 here. + // A full list holds the newest; lookbackStart reads commits back to its oldest, and every one of + // those is after the last sweep of an area the list has lost, so that area is judged correctly. + const sweeps = JSON.parse(run(root, "gh", ["pr", "list", "--state", "all", "--limit", String(PULL_LIST_LIMIT), "--search", "head:chore/tidy-adhoc-", "--json", "headRefName,createdAt,state,mergeCommit"])); + const openPulls = JSON.parse(run(root, "gh", ["pr", "list", "--state", "open", "--limit", String(PULL_LIST_LIMIT), "--json", "headRefName,createdAt,state"])); + const since = new Date(lookbackStart(sweeps, now)).toISOString(); + const commits = parseLog(run(root, "git", ["log", `--since=${since}`, "--no-renames", "--name-only", "--format=%x00%H %ct"])); + const paths = run(root, "git", ["ls-files"]).split("\n").filter(Boolean); + const result = plan({ paths, commits, pulls: [...sweeps, ...openPulls], cadence: cadence.value, backstop: backstop.value, ...count, now }); + return { ...result, settings: { upkeep_cadence: cadence, upkeep_backstop: backstop, upkeep_max_open: count.limit }, open: count.open }; +} + +function detect(root, before, after) { + const sha = /^[0-9a-f]{40}$/; + if (!sha.test(before) || !sha.test(after) || /^0+$/.test(before)) { + console.log("::warning::the pushed range has no readable start; the daily gate's git log still covers it"); + return; + } + let changed; + try { + changed = run(root, "git", ["diff", "--name-only", "--no-renames", before, after]); + } catch { + console.log("::warning::the pushed range did not diff; the daily gate's git log still covers it"); + return; + } + const areas = detectAreas(changed.split("\n").filter(Boolean)); + console.log(areas.join("\n")); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Upkeep areas changed: ${areas.join(", ") || "none"}\n`); + } +} + +function option(args, name, fallback) { + const at = args.indexOf(name); + return at === -1 ? fallback : args[at + 1]; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const [command, ...args] = process.argv.slice(2); + const root = option(args, "--root", process.cwd()); + if (command === "detect") { + detect(root, option(args, "--before", ""), option(args, "--after", "")); + } else if (command === "gate") { + try { + console.log(JSON.stringify(gate(root, Date.now()), null, 2)); + } catch (error) { + console.error(`upkeep gate: ${error.message.split("\n")[0]}`); + process.exit(1); + } + } else { + console.error("usage: upkeep.mjs detect --before --after [--root ] | gate [--root ]"); + process.exit(2); + } +} diff --git a/.github/scripts/maintenance-code-tidying-upkeep/upkeep.test.mjs b/.github/scripts/maintenance-code-tidying-upkeep/upkeep.test.mjs new file mode 100644 index 00000000000..e93781e0797 --- /dev/null +++ b/.github/scripts/maintenance-code-tidying-upkeep/upkeep.test.mjs @@ -0,0 +1,270 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { after, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { areaOf, detectAreas, parseCount, lookbackStart, parseLog, plan, slugOf } from "./upkeep.mjs"; + +const NOW = Date.parse("2026-11-20T06:00:00Z"); +const daysAgo = (days) => NOW - days * 86_400_000; +const pull = (slug, days, state = "MERGED") => ({ + headRefName: `chore/tidy-adhoc-${slug}-2026-11-01`, + createdAt: new Date(daysAgo(days)).toISOString(), + state, +}); +const base = { paths: [], commits: [], pulls: [], cadence: "daily", backstop: "weekly", limit: 3, open: 0, throttled: false, now: NOW }; + +test("an area is plugins/, docs/, else the first path segment", () => { + assert.equal(areaOf("plugins/bugs/skills/scan/SKILL.md").area, "plugins/bugs"); + assert.equal(areaOf("docs/adr/0001-x.md").area, "docs/adr"); + assert.equal(areaOf("scripts/run.sh").area, "scripts"); + assert.equal(areaOf("lib/a/b.sh").area, "lib"); +}); + +test("each area's glob and tidy branch slug", () => { + assert.equal(areaOf("plugins/bugs/README.md").glob, "plugins/bugs/**"); + assert.equal(slugOf("plugins/code-tidying"), "plugins-code-tidying"); + assert.deepEqual(areaOf("docs/README.md"), { area: "docs", glob: "docs/*" }); + assert.deepEqual(areaOf("README.md"), { area: "root", glob: "*" }); +}); + +test("dot directories and unsafe names are not areas", () => { + assert.equal(areaOf(".github/workflows/a.yml"), null); + assert.equal(areaOf(".gitignore"), null); + assert.equal(areaOf("plugins/$(id)/x"), null); +}); + +test("detect names each changed area once, in order", () => { + assert.deepEqual(detectAreas(["scripts/a.sh", "plugins/bugs/a", ".github/x", "plugins/bugs/b"]), ["plugins/bugs", "scripts"]); +}); + +test("a churned area swept before the commit is due; one swept after it is not", () => { + const result = plan({ + ...base, + cadence: "daily", + paths: ["plugins/a/x", "plugins/b/x"], + pulls: [pull("plugins-a", 3), pull("plugins-b", 1)], + commits: [{ at: daysAgo(2), paths: ["plugins/a/x", "plugins/b/x"] }], + }); + assert.deepEqual(result.areas, [{ area: "plugins/a", glob: "plugins/a/**", slug: "plugins-a", overdue: false }]); + assert.equal(result.proceed, true); +}); + +test("an unchanged area stays out however old its sweep", () => { + const result = plan({ ...base, paths: ["plugins/a/x", "plugins/b/x"], pulls: [pull("plugins-a", 13)] }); + assert.deepEqual(result, { proceed: false, reason: "nothing-due", areas: [] }); +}); + +test("churn is overdue at 7 days under a weekly backstop, not at 6", () => { + const paths = ["plugins/a/x", "plugins/b/x"]; + const commits = [ + { at: daysAgo(7), paths: ["plugins/a/x"] }, + { at: daysAgo(6), paths: ["plugins/b/x"] }, + ]; + const overdue = Object.fromEntries(plan({ ...base, paths, commits }).areas.map((row) => [row.area, row.overdue])); + assert.deepEqual(overdue, { "plugins/a": true, "plugins/b": false }); +}); + +test("a monthly backstop does not call 7-day-old churn overdue", () => { + const result = plan({ ...base, backstop: "monthly", paths: ["plugins/a/x"], commits: [{ at: daysAgo(7), paths: ["plugins/a/x"] }] }); + assert.equal(result.areas[0].overdue, false); +}); + +test("overdue is judged from the oldest unswept commit, not a newer one", () => { + const commits = [ + { at: daysAgo(1), paths: ["plugins/a/x"] }, + { at: daysAgo(8), paths: ["plugins/a/x"] }, + ]; + assert.equal(plan({ ...base, paths: ["plugins/a/x"], commits }).areas[0].overdue, true); +}); + +test("a churned area past the window goes ahead of newly churned ones, within the cap", () => { + const result = plan({ + ...base, + limit: 3, + open: 1, + paths: ["plugins/a/x", "plugins/b/x", "plugins/c/x", "plugins/d/x"], + commits: [ + { at: daysAgo(1), paths: ["plugins/a/x"] }, + { at: daysAgo(2), paths: ["plugins/b/x"] }, + { at: daysAgo(9), paths: ["plugins/c/x"] }, + ], + }); + assert.deepEqual(result.areas.map((row) => [row.area, row.overdue]), [ + ["plugins/c", true], + ["plugins/b", false], + ]); +}); + +test("a batch at the cap waits", () => { + assert.deepEqual(plan({ ...base, open: 3, paths: ["scripts/x"] }), { proceed: false, reason: "cap", areas: [] }); + assert.equal(plan({ ...base, open: 5, paths: ["scripts/x"] }).reason, "cap"); + assert.equal(plan({ ...base, open: 0, throttled: true, paths: ["scripts/x"] }).reason, "cap"); +}); + +test("an area with an open sweep is skipped", () => { + const result = plan({ + ...base, + paths: ["plugins/a/x", "plugins/b/x"], + pulls: [pull("plugins-a", 9, "OPEN")], + commits: [{ at: daysAgo(1), paths: ["plugins/a/x", "plugins/b/x"] }], + }); + assert.deepEqual(result.areas.map((row) => row.area), ["plugins/b"]); +}); + +test("a weekly cadence waits until 7 calendar days after the last sweep", () => { + const paths = ["scripts/x", "plugins/a/x"]; + const commits = [{ at: daysAgo(1), paths: ["plugins/a/x"] }]; + assert.equal(plan({ ...base, cadence: "weekly", paths, commits, pulls: [pull("scripts", 6)] }).reason, "cadence"); + assert.equal(plan({ ...base, cadence: "weekly", paths, commits, pulls: [pull("scripts", 7)] }).proceed, true); +}); + +test("a daily cadence waits the same UTC day and runs the next, under 24 hours later", () => { + const paths = ["plugins/a/x"]; + const commits = [{ at: daysAgo(1), paths }]; + const sameDay = { headRefName: "chore/tidy-adhoc-scripts-2026-11-20", createdAt: "2026-11-20T00:10:00Z", state: "MERGED" }; + assert.equal(plan({ ...base, paths, commits, pulls: [sameDay] }).reason, "cadence"); + const yesterday = { headRefName: "chore/tidy-adhoc-scripts-2026-11-19", createdAt: "2026-11-19T06:30:00Z", state: "MERGED" }; + assert.equal(plan({ ...base, paths, commits, pulls: [yesterday] }).proceed, true); +}); + +test("a tidy lane's branch is not an area sweep", () => { + const lane = { headRefName: "chore/tidy-docs-prose-2026-11-20", createdAt: new Date(NOW).toISOString(), state: "OPEN" }; + assert.equal(plan({ ...base, paths: ["scripts/x"], commits: [{ at: daysAgo(1), paths: ["scripts/x"] }], pulls: [lane] }).proceed, true); +}); + +test("a full list of sweeps still plans, reading commits back to its oldest", () => { + const sweeps = Array.from({ length: 1000 }, (_, i) => pull("plugins-a", 200 + i)); + assert.equal(lookbackStart(sweeps, NOW), daysAgo(1199)); + const commits = [{ sha: "1", at: daysAgo(1), paths: ["scripts/x"] }]; + assert.deepEqual(plan({ ...base, paths: ["scripts/x"], pulls: sweeps, commits }).areas.map((row) => row.area), ["scripts"]); +}); + +test("an area whose only later commit is its sweep's merge stays out; a different later commit brings it back", () => { + const paths = ["plugins/a/x"]; + const swept = { ...pull("plugins-a", 3), mergeCommit: { oid: "m".repeat(40) } }; + const merge = { sha: "m".repeat(40), at: daysAgo(2), paths }; + assert.deepEqual(plan({ ...base, paths, pulls: [swept], commits: [merge] }), { proceed: false, reason: "nothing-due", areas: [] }); + const other = { sha: "c".repeat(40), at: daysAgo(1), paths }; + assert.deepEqual(plan({ ...base, paths, pulls: [swept], commits: [merge, other] }).areas.map((row) => row.area), ["plugins/a"]); +}); + +test("an unswept commit older than twice the backstop is planned, overdue first", () => { + const result = plan({ + ...base, + paths: ["plugins/a/x", "plugins/b/x"], + commits: [ + { sha: "1", at: daysAgo(1), paths: ["plugins/b/x"] }, + { sha: "2", at: daysAgo(20), paths: ["plugins/a/x"] }, + ], + }); + assert.deepEqual(result.areas.map((row) => [row.area, row.overdue]), [ + ["plugins/a", true], + ["plugins/b", false], + ]); +}); + +test("the lookback reaches the oldest sweep, and 90 days with none", () => { + assert.equal(lookbackStart([], NOW), daysAgo(90)); + assert.equal(lookbackStart([pull("plugins-a", 10)], NOW), daysAgo(90)); + assert.equal(lookbackStart([pull("plugins-a", 10), pull("plugins-b", 200)], NOW), daysAgo(200)); + const lane = { headRefName: "chore/tidy-docs-prose-2026-01-01", createdAt: new Date(daysAgo(300)).toISOString(), state: "MERGED" }; + assert.equal(lookbackStart([lane], NOW), daysAgo(90)); +}); + +test("parseCount reads open-pr-count.sh's lines and refuses an unknown count", () => { + const text = "Throttle limit: 3, team\nOpen tidy PR count: 2\nThrottle active: no\nDetail: backlog below 3\n"; + assert.deepEqual(parseCount(text), { open: 2, limit: 3, throttled: false }); + const floor = "Throttle limit: 3, default\nOpen tidy PR count: 0 or more\nThrottle active: yes\n"; + assert.deepEqual(parseCount(floor), { open: 0, limit: 3, throttled: true }); + assert.throws(() => parseCount("Throttle limit: 3, team\nOpen tidy PR count: unknown\nThrottle active: unknown\n")); +}); + +test("parseLog splits git log output by commit", () => { + assert.deepEqual(parseLog("\x00aa 100\n\nscripts/a\nscripts/b\n\x00bb 200\n\nlib/c\n"), [ + { sha: "aa", at: 100_000, paths: ["scripts/a", "scripts/b"] }, + { sha: "bb", at: 200_000, paths: ["lib/c"] }, + ]); +}); + +// End to end against a real repository, with gh stubbed. +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "upkeep.mjs"); +const dir = mkdtempSync(join(tmpdir(), "upkeep-")); +after(() => rmSync(dir, { recursive: true, force: true })); + +const git = (cwd, ...args) => execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], { cwd, encoding: "utf8" }).trim(); + +function fixture(name, yaml) { + const repo = join(dir, name); + mkdirSync(join(repo, "docs/conventions"), { recursive: true }); + mkdirSync(join(repo, "plugins/a"), { recursive: true }); + writeFileSync(join(repo, "docs/conventions/code-tidying.yaml"), yaml); + writeFileSync(join(repo, "plugins/a/x"), "1\n"); + git(repo, "init", "-q"); + git(repo, "add", "."); + git(repo, "commit", "-qm", "first"); + return repo; +} + +function stubGh(name, script) { + const bin = join(dir, `bin-${name}`); + mkdirSync(bin); + writeFileSync(join(bin, "gh"), `#!/usr/bin/env bash\n${script}\n`); + chmodSync(join(bin, "gh"), 0o755); + return `${bin}:${process.env.PATH}`; +} + +const runCli = (args, PATH, extraEnv = {}) => spawnSync("node", [SCRIPT, ...args], { encoding: "utf8", env: { ...process.env, PATH, ...extraEnv } }); + +test("gate: the setting sets the cap, and the plan names the area", () => { + const repo = fixture("gate-ok", "upkeep_max_open: 1\n"); + const PATH = stubGh("ok", `case "$*" in api*) echo '[]' ;; *--jq*) echo "4 0" ;; *) echo '[]' ;; esac`); + const result = runCli(["gate", "--root", repo], PATH); + assert.equal(result.status, 0, result.stderr); + const out = JSON.parse(result.stdout); + assert.equal(out.proceed, true); + assert.deepEqual(out.settings.upkeep_max_open, 1); + assert.deepEqual(out.settings.upkeep_cadence, { value: "daily", source: "default" }); + assert.deepEqual(out.areas, [{ area: "docs/conventions", glob: "docs/conventions/**", slug: "docs-conventions", overdue: false }]); +}); + +test("gate: at the cap the batch waits", () => { + const repo = fixture("gate-cap", "upkeep_max_open: 1\n"); + const PATH = stubGh("cap", `case "$*" in api*) echo '[]' ;; *--jq*) echo "4 1" ;; *) echo '[]' ;; esac`); + const out = JSON.parse(runCli(["gate", "--root", repo], PATH).stdout); + assert.deepEqual([out.proceed, out.reason, out.areas], [false, "cap", []]); +}); + +test("gate: an unknown open count fails and plans nothing", () => { + const repo = fixture("gate-fail", ""); + const PATH = stubGh("fail", "exit 4"); + const result = runCli(["gate", "--root", repo], PATH); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); +}); + +test("detect: names the range's areas and writes the summary", () => { + const repo = fixture("detect", ""); + const before = git(repo, "rev-parse", "HEAD"); + mkdirSync(join(repo, "scripts")); + writeFileSync(join(repo, "scripts/y"), "1\n"); + writeFileSync(join(repo, "plugins/a/x"), "2\n"); + git(repo, "add", "."); + git(repo, "commit", "-qm", "second"); + const head = git(repo, "rev-parse", "HEAD"); + const summary = join(dir, "summary.md"); + const result = runCli(["detect", "--root", repo, "--before", before, "--after", head], process.env.PATH, { GITHUB_STEP_SUMMARY: summary }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "plugins/a\nscripts\n"); + assert.equal(readFileSync(summary, "utf8"), "Upkeep areas changed: plugins/a, scripts\n"); +}); + +test("detect: a push with no start warns and stays green", () => { + const repo = fixture("detect-zero", ""); + const head = git(repo, "rev-parse", "HEAD"); + const result = runCli(["detect", "--root", repo, "--before", "0".repeat(40), "--after", head], process.env.PATH); + assert.equal(result.status, 0); + assert.match(result.stdout, /^::warning::/); +}); diff --git a/.github/workflows/maintenance-code-tidying-upkeep.yml b/.github/workflows/maintenance-code-tidying-upkeep.yml new file mode 100644 index 00000000000..90d0c01bc94 --- /dev/null +++ b/.github/workflows/maintenance-code-tidying-upkeep.yml @@ -0,0 +1,44 @@ +name: maintenance-code-tidying-upkeep + +# Code-tidying upkeep's per-merge detection: names the areas each merge changed +# in the job summary. No model, no write, no pull request. The daily batch's +# gate is .github/scripts/maintenance-code-tidying-upkeep/upkeep.mjs gate, which +# derives churn from git log, so a lost run here drops no area. + +on: + push: + branches: [main] + +permissions: {} + +jobs: + detect: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read # read the pushed range + steps: + - name: Check out the pushed commit's scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + filter: blob:none + sparse-checkout: | + /.github/actions/setup-pinned-node/ + /.github/scripts/maintenance-code-tidying-upkeep/ + /.node-version + sparse-checkout-cone-mode: false + + - name: Set up Node + uses: ./.github/actions/setup-pinned-node # zizmor: ignore[self-repository] + with: + node-version-file: .node-version + + # Trees only: naming the changed paths reads no file content. + - name: Name the changed areas + env: + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.event.after }} + run: | + [[ $BEFORE =~ ^[0-9a-f]{40}$ ]] && git fetch --quiet --no-tags --depth=1 --filter=blob:none origin "$BEFORE" || true + node .github/scripts/maintenance-code-tidying-upkeep/upkeep.mjs detect --before "$BEFORE" --after "$AFTER" diff --git a/docs/contracts/config-cascade/CHANGELOG.md b/docs/contracts/config-cascade/CHANGELOG.md index 2442ea3ffda..20d560de12f 100644 --- a/docs/contracts/config-cascade/CHANGELOG.md +++ b/docs/contracts/config-cascade/CHANGELOG.md @@ -11,6 +11,9 @@ adding an optional layer or relaxing a rule additively is a minor bump. No contract change. +- **`code-tidying` (`upkeep_*`) joins the Implementers and root-rule tables.** Its upkeep keys live + in `/code-tidying.yaml`, team layer over the default, with no `userConfig` option and no + overlay. - **`bugs` and `harness-config` (`audit-pass`) drop their one-release team reads.** The Implementers rows no longer name `.claude/bugs.md` or `.claude/audit-pass.md`: the team layer is the `docs/conventions/` file alone. diff --git a/docs/contracts/config-cascade/README.md b/docs/contracts/config-cascade/README.md index a2679ce3b4d..345d4705394 100644 --- a/docs/contracts/config-cascade/README.md +++ b/docs/contracts/config-cascade/README.md @@ -578,6 +578,7 @@ conformance. | `guardrails` | team | per-key | | `songwriting` | project override over the bundled default | whole-file per template, no per-key | | `ai-slop` | later layer | per-key (lists replace) | +| `code-tidying` (`upkeep_*`) | team, then the default | per-key | | `docs-naming` | later layer; team on named policy-floor keys | per-key | | `rendered-views` | later layer | per-key | | `testing` (`run-e2e`) | later layer | per-key | @@ -655,6 +656,7 @@ its conformance cell. | `guardrails` | no-floor | team: `docs/conventions/guardrails.yaml` (schema `plugins/guardrails/schemas/guardrails.schema.json`) | team only, over the plugin default | team | per-key | declared deviation: one key, `fix_request_post` (`block` default, `allow`), read by `hooks/block-fix-request-post.sh` from the session's repository (the payload's cwd) at its local `origin/HEAD`, never the working tree or index, and applied whichever repository the post targets; no overlay and no `userConfig` value of the key (the guard's `block_fix_request_post_enabled` switch stays a separate per-user escape). A value other than `allow` or `block`, or a file that does not parse, is named and keeps `block` | | `songwriting` | no-floor | `songwriting/templates/pat-pattison/.md` | team only, over a bundled default | project override over the bundled default | whole-file per template, no per-key | declared deviation ([ADR 0042](../../adr/0042-ratify-consumer-config-location-outliers-in-place.md)): the override path is under `songwriting/`, not `.claude/`. One tracked team file per template wins whole over `${CLAUDE_PLUGIN_ROOT}/context/pat-pattison/templates/.md`, the first match, and freezes that template against plugin updates; no user-global layer and no overlay. The ADR rules on location only. Written by `/songwriting:setup apply`, read by the craft skills | | `ai-slop` | no-floor | `.claude/ai-slop.json` | all three | later layer | per-key (lists replace) | conforms; per-key override, resolved by `/ai-slop:audit` (user-global, team, `.claude/ai-slop.local.json` overlay). Four list keys are additive-by-replacement rather than merged (`vocab_add` / `vocab_remove` tune the shipped word list, `phrase_add` / `phrase_remove` the shipped model-era phrase roster; the later layer's list wins per key). No policy-floor class: every key is a taste dial over prose style, and a personal overlay that silences a rule weakens nothing another surface depends on. Keys owned by `/ai-slop:setup`; `_comment` is an allowed free-text annotation, not drift | +| `code-tidying` (`upkeep_*`) | no-floor | team: `/code-tidying.yaml` (schema `plugins/code-tidying/schemas/code-tidying.schema.json`) | team only | team, then the default | per-key | declared deviation: no `userConfig` option and no personal overlay, since `upkeep_max_open` protects a shared review queue. A value outside the schema, or a file that does not parse, is named with its file and key and resolves to the default. Keys owned by [`plugins/code-tidying/reference/config.md`](../../../plugins/code-tidying/reference/config.md); read through `skills/tidy/scripts/upkeep-setting.sh` by `open-pr-count.sh` and the upkeep gate | | `docs-naming` | team-only | team: `docs/conventions/docs-naming.yaml` (ADR 0064; schema `plugins/docs-naming/schemas/docs-naming.schema.json`), else `.claude/docs-naming.json` for one release; user-global `~/.claude/docs-naming.json`; overlay `docs/conventions/docs-naming.local.yaml`, else `.claude/docs-naming.local.json` for one release | all three | later layer; team on named policy-floor keys | per-key | conforms, with the one sanctioned dual-read declared by retirement records `docs-naming-r001` and `docs-naming-r002` (the retired `docs-hygiene.json` and `docs-hygiene.local.json` under `.claude/` are read as authority, with a WARN, only while the new name is absent); per-key override on `file_names.*`, with a policy-floor class on `tiers`, `generated`, `sweep_exclude`, `sweep_exclude_sites`, and the three `exempt_*` keys: a personal layer may ADD entries and never remove them, and `generated` is team-layer only. Those keys decide what `/docs-naming:realign-file-names` does to a tree (which files are frozen, which reference forms are rewritten, and which shell command runs after a move), so narrowing one from a single machine would weaken a team decision, while adding a scope root or an exemption weakens nothing and stays open. `rule`, `regex`, and `redirect_map` are nearest-wins. Keys owned by [`plugins/docs-naming/reference/config.md`](../../../plugins/docs-naming/reference/config.md), which also partitions them from plugin `userConfig` (this plugin declares none, so a layer naming one is an inert unknown key). Written by `/docs-naming:setup apply`, resolved by `plugins/docs-naming/scripts/resolve-config.sh` | | `rendered-views` | no-floor | `.claude/rendered-views.md` | all three | later layer | per-key | conforms; per-key override on `medium`, no policy-floor class (taste dial, the `ai-slop` precedent). Keys owned by [`rendered-views`](../rendered-views/README.md), which also partitions them from plugin `userConfig` dials (never keys in this surface; a layer declaring one is reported as an inert unknown key). Resolved by `visualization:visualize` (wave-1 exemplar) | | `testing` (`run-e2e`) | no-floor | `.claude/testing/e2e.md` for `recording` / `browser_mode`; `docs/conventions/testing.yaml`, `~/.claude/testing.yaml` and `.claude/testing.local.yaml` for `e2e_driver` / `reuse_running_instance` / `browser_driver`, over the plugin's `userConfig` | all three | later layer | per-key | conforms; per-key override, keys owned by `/testing:run-e2e`. The YAML keys are resolved by `resolve-config.sh e2e`, which reports the supplying layer and resolves an unknown or refused value to the default without stopping | @@ -717,6 +719,7 @@ model-run skill with no reader script, so the rule lives in the skill text. | `autonomy` | not yet | hooks anchor at `CLAUDE_PROJECT_DIR` (`hooks/hook-utils.sh`); `binding.json` has no shared reader script | | `plugin-quality`, `architecture`, `authoring-formats` | not yet | each plugin's `lib/resolve-convention-home.sh`: `--root`, else `CLAUDE_PROJECT_DIR`, else git toplevel, else the current directory; team-only via pointer line, no user-global layer to collide with | | `ai-briefing`, `code-tidying` | prose only | team-only surfaces read by model-run skills | +| `code-tidying` (`upkeep_*`) | implements (inline) | `skills/tidy/scripts/upkeep-setting.sh`: the git toplevel of `CLAUDE_PROJECT_DIR`, else of the current directory; `lib/resolve-convention-home.sh` classifies the root, and the team layer is skipped when it is `$HOME`, an ancestor of it, or not a git working tree | | `playbooks` | prose only | `/playbooks:fable-5` reads `docs/conventions/playbooks.yaml` in its skill text only when the working directory's git root is neither `$HOME` nor an ancestor of it, and otherwise skips the team layer and says so; no reader script | | `implementation` | prose only | `/implementation:implement-dispatch` and `/implementation:implement` read `docs/conventions/implementation.yaml` only when the root (`CLAUDE_PROJECT_DIR`, else `git rev-parse --show-toplevel`) is inside a git working tree and is neither `$HOME` nor an ancestor of it, and otherwise reports the team layer skipped; `/planning:plan` reads that file's `integration_posture` key under planning's own root rule (git root neither `$HOME` nor an ancestor of it); no reader script | | `education` | prose only | `/education:explain` reads `docs/conventions/education.yaml` (from `git rev-parse --show-toplevel`) through its bundled `parse-concern-value.sh` only when the root is inside a git working tree and is neither `$HOME` nor an ancestor of it; the root classification lives in the skill text, not the reader | diff --git a/docs/conventions/code-tidying.yaml b/docs/conventions/code-tidying.yaml new file mode 100644 index 00000000000..2d81e9dc94c --- /dev/null +++ b/docs/conventions/code-tidying.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=../../plugins/code-tidying/schemas/code-tidying.schema.json +# Team layer for the code-tidying plugin's upkeep settings. +# Keys and resolution: plugins/code-tidying/reference/config.md. +upkeep_cadence: daily +upkeep_backstop: weekly +upkeep_max_open: 3 diff --git a/plugins/code-tidying/README.md b/plugins/code-tidying/README.md index 95e8380da84..c3e0484bdee 100644 --- a/plugins/code-tidying/README.md +++ b/plugins/code-tidying/README.md @@ -114,8 +114,9 @@ personal variation is limited to lane names the team does not track: an uncommit (structure-only is the skills' contract, not a path limit), the work-tracking exclusions, and the self-update lane's protection of this plugin's own contract surface. -- **Backlog throttle**: ≥3 open `chore/tidy-*` PRs stops the run instead of - piling on (bundled `open-pr-count.sh`, network access via your own `gh` +- **Backlog throttle**: open `chore/tidy-*` PRs at the `upkeep_max_open` limit + (default 3, [reference/config.md](reference/config.md)) stop the run instead + of piling on (bundled `open-pr-count.sh`, network access via your own `gh` auth). - **No auto-merge.** Tidy PRs are always merged by a human. diff --git a/plugins/code-tidying/lib/parse-concern-value.sh b/plugins/code-tidying/lib/parse-concern-value.sh new file mode 100755 index 00000000000..1af99bc1f23 --- /dev/null +++ b/plugins/code-tidying/lib/parse-concern-value.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# GENERATED from lib/parse-concern-value.sh by scripts/sync-shared-copies.sh. Do not edit this copy: +# edit the canonical source, then rerun the script. +# Resolve one value from a YAML concern file the way every consuming plugin +# must: quote-aware, comment-safe, whitespace-trimmed, trailing-slash-normalized, +# with a caller-supplied fallback for the case the key is absent. +# +# Parsing is yaml-subset.awk's, which sits beside this script in every copy: +# one YAML parser for the repository. A `#` inside a quoted value is part of the +# value, never a comment. +# +# SINGLE SOURCE OF TRUTH: lib/parse-concern-value.sh and lib/yaml-subset.awk at +# the marketplace repo root. The copies materialized into consuming plugins +# exist because installed plugins are cache-isolated and must be +# self-contained; never edit a copy. Edit the source and run +# scripts/sync-shared-copies.sh; CI rejects drifted copies. +# +# Usage: +# parse-concern-value.sh [--strict] [--list] [--ref ] [fallback] +# +# path to the concern file; `-` reads the document from stdin +# (a remote reader passes text it fetched itself) +# dotted key: `memory_dir`, `merge.rung`, +# `lanes.pr-merge.slots.0.activity` (sequence items by index) +# [fallback] value to emit when the key is absent or empty: the caller's +# already-resolved location or the schema default. This script +# never reads prose or schemas; the caller passes it in. +# --list print the scalar items of the sequence at , one per line +# --ref read as committed at : a 40-hex commit id or +# `origin/`. Validated before any git call and passed to +# git after --end-of-options. +# --strict exit 3 on a parse error, such as a key repeated in one +# mapping, instead of taking the fallback +# +# Output: the resolved value, or empty when nothing resolves (the caller +# applies its documented default). Exit 0 for a well-formed invocation, +# including a missing file. A document the parser rejects is treated as absent +# with one stderr line, so a malformed file never stops a consumer; --strict +# makes it exit 3 for a caller that must tell an error from an absent key. +# Exit 2 for a usage error, an invalid key or ref, or a ref that does not +# resolve. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PARSER="$SCRIPT_DIR/yaml-subset.awk" + +usage() { + echo "parse-concern-value: usage: parse-concern-value.sh [--strict] [--list] [--ref ] [fallback]" >&2 + exit 2 +} + +strict=0 list=0 has_ref=0 ref="" +pos=() +while (($#)); do + case "$1" in + -h | --help) + awk 'NR > 1 && /^set -uo/ { exit } NR > 1 { sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" + exit 0 + ;; + --strict) strict=1 ;; + --list) list=1 ;; + --ref) + (($# >= 2)) || usage + has_ref=1 ref="$2" + shift + ;; + --) + shift + pos+=("$@") + break + ;; + *) pos+=("$1") ;; + esac + shift +done + +concern_file="${pos[0]:-}" +key="${pos[1]:-}" +fallback="${pos[2]:-}" +[[ -n "$concern_file" && -n "$key" ]] || usage +if [[ ! "$key" =~ ^[A-Za-z0-9_.-]+$ ]]; then + echo "parse-concern-value: invalid key; expected [A-Za-z0-9_.-]+" >&2 + exit 2 +fi + +text="" present=1 +if ((has_ref)); then + # Only a full commit id or origin/ passes; nothing that git could read + # as an option, a revision range or a path. + if [[ "$concern_file" == "-" ]] || + ! [[ "$ref" =~ ^[0-9a-f]{40}$ || ("$ref" =~ ^origin/[A-Za-z0-9._/-]+$ && "$ref" != *..* && "$ref" != origin/-*) ]]; then + echo "parse-concern-value: invalid --ref; expected a 40-hex commit id or origin/, and a file path" >&2 + exit 2 + fi + if ! git rev-parse --verify --quiet --end-of-options "$ref^{commit}" >/dev/null; then + echo "parse-concern-value: --ref does not resolve to a commit: $ref" >&2 + exit 2 + fi + text="$(git show --end-of-options "$ref:$concern_file" 2>/dev/null)" || present=0 +elif [[ "$concern_file" == "-" ]]; then + text="$(cat)" +elif [[ -f "$concern_file" ]]; then + text="$(<"$concern_file")" +else + present=0 +fi + +records="" +if ((present)); then + if ! records="$(LC_ALL=C awk -v STRICT="$strict" -f "$PARSER" <<<"$text")"; then + printf 'parse-concern-value: %s: line %s\n' "$concern_file" \ + "$(awk -F '\t' '$1 == "error" { print $2 ": " $3 }' <<<"$records" | tail -n 1)" >&2 + ((strict)) && exit 3 + records="" + fi +fi + +values=() +# shellcheck disable=SC2016 # awk programs, expanded by awk, not the shell. +if ((list)); then + pattern='index($1, ENVIRON["PCV_KEY"] ".") == 1 && substr($1, length(ENVIRON["PCV_KEY"]) + 2) ~ /^[0-9]+$/' +else + pattern='$1 == ENVIRON["PCV_KEY"] && !seen++' +fi +while IFS= read -r v; do + v="${v%/}" # normalize a single trailing slash + [[ -z "$v" ]] || values+=("$v") +done < <(PCV_KEY="$key" LC_ALL=C awk -F '\t' "$pattern"' { print substr($0, length($1) + 2) }' <<<"$records") + +if ((${#values[@]} == 0)); then + values=("$fallback") +fi +printf '%s\n' "${values[@]}" diff --git a/plugins/code-tidying/lib/resolve-convention-home.sh b/plugins/code-tidying/lib/resolve-convention-home.sh new file mode 100755 index 00000000000..75c4fd0410d --- /dev/null +++ b/plugins/code-tidying/lib/resolve-convention-home.sh @@ -0,0 +1,535 @@ +#!/usr/bin/env bash +# GENERATED from lib/resolve-convention-home.sh by scripts/sync-shared-copies.sh. Do not edit this copy: +# edit the canonical source, then rerun the script. +# Resolve a consumer repository's convention home from the pointer line in its +# root instruction file. +# +# WHY. The config-cascade expression doctrine (docs/contracts/config-cascade/ +# README.md § Expression doctrine) binds a consumer's convention home with ONE +# pointer line inside a marked, machine-owned region of the root instruction +# file. The line IS the binding: there is no binding file, so every plugin that +# needs the home reads the same line. Eight plugins each parsing that line in +# their own prose would drift exactly the way retirement detection drifted, so +# the grammar lives here, once, tested, and the doctrine doc cites this helper +# as its owner. The behavior follows docs/adr/0075 (fixes 1, 2, 4, 5, 6, 13). +# +# GRAMMAR. The root instruction file carries a region delimited by two marker +# lines (surrounding whitespace, a leading UTF-8 BOM, and a trailing carriage +# return are ignored; nothing else may share the line): +# +# +# Team conventions live in `docs/conventions` — read the topic doc there +# before changing a governed surface. +# +# +# Inside the region, a POINTER LINE is any line that contains a backticked +# token; the FIRST backticked token on that line is the home. Lines in the +# region with no backticks are prose and are ignored. A backticked path +# anywhere outside the region is not a pointer. The home is a repo-relative +# directory: one or more segments of [A-Za-z0-9._-]+ joined by `/`, no segment +# equal to `.`, `..` or `.git` (any case, so the home is never git's own +# directory, trailing dots ignored as NTFS ignores them), an optional trailing +# `/` which is dropped on output. In the working tree the home is its own +# physical path: a symlink on the way to it, or a spelling whose case differs +# from the disk, is a FAIL, as --ref reads only a committed tree. +# Anything else — an absolute path, a leading `~`, a backslash, a drive letter, +# whitespace, or an empty token — is an invalid pointer, never a best-effort +# parse. A line inside the region that is exactly `declined` (surrounding +# whitespace ignored) records that the repository declined a home: the caller +# must not ask again. A `declined` line beside a pointer line is a FAIL. +# +# WHICH FILE. `AGENTS.md` is canonical; `CLAUDE.md` is consulted when AGENTS.md +# carries no region. A CLAUDE.md whose only non-blank content is `@AGENTS.md` is +# a pure import shim and is never consulted. The two files are compared +# physically (realpath): when one is a symlink to the other they are one file, +# read once, and nothing is reported. When two distinct files both carry a +# region, AGENTS.md supplies the printed value and CLAUDE.md's copy is reported +# on stderr as `duplicate:` — the doctrine calls that a finding with a +# remediation (remove the copy), not a hard stop, so the exit stays 0. +# +# WHERE A HOME CAN APPLY. The root is classified first. It is "not applicable" +# (exit 5) when it is $HOME, an ancestor of $HOME, or not inside a git work +# tree: a home directory or a loose folder is not a repository that binds a +# home. With no --root the root is the git toplevel of CLAUDE_PROJECT_DIR (else +# of the current directory), one home per git root: CLAUDE_PROJECT_DIR only says +# where to look, so a subdirectory of a repository never reads a file of its own. +# +# WHAT IS READ. By default the working tree. `--ref ` reads AGENTS.md, +# CLAUDE.md and the home directory as committed at , so a lane resolves the +# pointer at the base commit: a 40-hex commit id or `origin/`, validated +# before any git call and passed to git after --end-of-options. A file that is a +# symlink at is not read. `-` reads the region from stdin as the text of +# AGENTS.md instead (a remote reader passes text it fetched itself); it names no +# tree, so the root is not classified and the home directory is not checked. +# +# UNTRUSTED INPUT. The root file is consumer prose. Nothing read from it is +# evaluated, expanded, or interpolated into a command: a candidate token is only +# ever compared against the grammar and joined onto --root for a `-d` test. +# Every line is stripped of `\r` and truncated to MAX_LINE bytes before parsing. +# +# Usage: +# resolve-convention-home.sh [--root ] [--ref ] [--explain] [--help] [-] +# +# --root the repository root (default: the git toplevel of +# ${CLAUDE_PROJECT_DIR}, else of the current directory) +# --ref read the committed files at (40-hex id or origin/) +# - read the AGENTS.md text from stdin (not with --root or --ref) +# --explain write which file, region, and token were used to stderr +# +# Exit: +# 0 exactly one usable pointer resolved and the directory exists; the +# repo-relative home is printed on stdout (a `duplicate:` warning on +# stderr does not change this) +# 1 no region, or a region with no pointer line, in any consulted root file +# — the caller asks the operator; nothing is inferred here +# 2 usage error, an unusable --root, or an invalid or unresolvable --ref +# 3 FAIL, each with a distinct stderr message: two pointer lines in one +# region; an unterminated or nested region; an invalid pointer path; a +# pointer whose target directory is missing or reached through a symlink; +# `declined` beside a pointer +# 4 a region holds a `declined` line: the repository declined a home, so the +# caller uses the bundled defaults and does not ask again +# 5 not applicable: the root is $HOME, an ancestor of it, or not a git work +# tree; no home applies and nothing is asked +# +# A pointer with no concern file under the home is not an error here: the home +# resolves, and the cascade reports the missing file's layer as absent so the +# bundled default applies (config-cascade.mjs). +# +# Shared source: lib/resolve-convention-home.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. Bash 3.2-compatible on purpose: no associative +# arrays, no mapfile, no jq. + +set -uo pipefail +# The grammar's character class is ASCII by definition; a collating locale +# would let a bracket range admit letters outside it. +export LC_ALL=C + +BEGIN_MARKER='' +END_MARKER='' +MAX_LINE=4096 + +usage() { + cat <<'EOF' +resolve-convention-home.sh — read the convention-home pointer line. + +Prints the repo-relative convention home named by the first backticked token +inside the `` region of AGENTS.md +(canonical) or CLAUDE.md (unless it is a pure `@AGENTS.md` shim). + +Usage: + resolve-convention-home.sh [--root ] [--ref ] [--explain] [--help] [-] + + --root repository root (default: git toplevel of CLAUDE_PROJECT_DIR, else cwd) + --ref read the files as committed at (40-hex id or origin/) + - read the AGENTS.md text from stdin (not with --root or --ref) + --explain write the file, region, and token used to stderr + +Exit: 0 resolved (home on stdout); 1 no pointer anywhere (ask); 2 usage; + 3 FAIL — two pointers in one region, unterminated region, invalid path, + target directory missing or a symlink, or `declined` beside a pointer; + 4 declined (a `declined` line in the region; do not ask again); + 5 not applicable (root is $HOME, an ancestor of it, or not a git work tree). +EOF +} + +ROOT_ARG="" +HAS_ROOT=0 +REF="" +HAS_REF=0 +STDIN_MODE=0 +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" + HAS_ROOT=1 + shift 2 + ;; + --ref) + if [[ $# -lt 2 ]]; then + echo "ERROR: --ref needs a commit id or origin/" >&2 + exit 2 + fi + HAS_REF=1 + REF="$2" + shift 2 + ;; + -) + STDIN_MODE=1 + shift + ;; + --explain) + EXPLAIN=1 + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ $STDIN_MODE -eq 1 && ( $HAS_ROOT -eq 1 || $HAS_REF -eq 1 ) ]]; then + echo "ERROR: - (stdin) names no tree; it cannot be combined with --root or --ref" >&2 + exit 2 +fi +if [[ $HAS_REF -eq 1 ]]; then + # Only a full commit id or origin/ passes; nothing that git could read + # as an option, a revision range or a path. + if ! [[ "$REF" =~ ^[0-9a-f]{40}$ || ("$REF" =~ ^origin/[A-Za-z0-9._/-]+$ && "$REF" != *..* && "$REF" != origin/-*) ]]; then + echo "ERROR: invalid --ref; expected a 40-hex commit id or origin/" >&2 + exit 2 + fi +fi + +explain() { [[ $EXPLAIN -eq 1 ]] && echo "$*" >&2; return 0; } + +SCRATCH="" +cleanup() { [[ -z "$SCRATCH" ]] || rm -rf "$SCRATCH"; } +trap cleanup EXIT + +ROOT="" +if [[ $STDIN_MODE -eq 0 ]]; then + if [[ $HAS_ROOT -eq 1 ]]; then + ROOT="$ROOT_ARG" + else + START="${CLAUDE_PROJECT_DIR:-$PWD}" + # tr -d '\r': Git on Windows can return a CRLF-terminated path. + ROOT="$(git -C "$START" rev-parse --show-toplevel 2>/dev/null | tr -d '\r')" + [[ -n "$ROOT" ]] || ROOT="$START" + fi + if [[ ! -d "$ROOT" ]]; then + echo "ERROR: --root is not a directory: $ROOT" >&2 + exit 2 + fi +fi + +# phys : the physical path of an existing file or directory, symlinks +# resolved (realpath, else a readlink walk for macOS before 13). +phys() { + local p="$1" dir n=0 target + if command -v realpath >/dev/null 2>&1; then + realpath -- "$p" 2>/dev/null && return 0 + fi + while [[ -L "$p" && $n -lt 40 ]]; do + target="$(readlink "$p")" || return 1 + case "$target" in + /*) p="$target" ;; + *) p="$(dirname "$p")/$target" ;; + esac + n=$((n + 1)) + done + if [[ -d "$p" ]]; then + (CDPATH='' cd -- "$p" && pwd -P) + else + dir="$(CDPATH='' cd -- "$(dirname "$p")" && pwd -P)" || return 1 + printf '%s/%s\n' "$dir" "$(basename "$p")" + fi +} + +# classify_root: exit 5 when no home can apply to $ROOT. +classify_root() { + local root_p home_p why="" + root_p="$(CDPATH='' cd -- "$ROOT" && pwd -P)" || return 0 + if [[ -n "${HOME:-}" && -d "$HOME" ]]; then + home_p="$(CDPATH='' cd -- "$HOME" && pwd -P)" + if [[ "$root_p" == "$home_p" ]]; then + why="the root is \$HOME" + elif [[ "$root_p" == "/" || "$home_p/" == "$root_p/"* ]]; then + why="the root is an ancestor of \$HOME" + fi + fi + if [[ -z "$why" ]] && ! git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + why="the root is not inside a git work tree" + fi + if [[ -n "$why" ]]; then + echo "not applicable: $why ($root_p); no convention home applies" >&2 + exit 5 + fi +} + +trim() { + local s="$1" + # UTF-8 BOM is U+FEFF encoded EF BB BF at the start of the file, so on + # line 1 it is the first three bytes of this string. POSIX [:space:] / + # isspace(3) is space, FF, NL, CR, HT, VT, not BOM. + # https://www.unicode.org/faq/utf_bom.html + s="${s#$'\xEF\xBB\xBF'}" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +# is_pure_shim : every non-blank line, after CR strip and trim, is +# `@AGENTS.md`, and there is at least one such line. +is_pure_shim() { + local line seen=0 + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + line="$(trim "$line")" + [[ -z "$line" ]] && continue + [[ "$line" == "@AGENTS.md" ]] || return 1 + seen=1 + done <"$1" + [[ $seen -eq 1 ]] +} + +# scan_file : sets SCAN_STATE to one of +# none no region in the file +# empty a region exists but holds no pointer line +# one exactly one pointer line; SCAN_TOKEN holds its first backticked token +# many two or more pointer lines +# declined a `declined` line and no pointer line +# mixed a `declined` line beside a pointer line +# unterminated / nested a BEGIN with no END, or a BEGIN inside a region +scan_file() { + local file="$1" line in_region=0 regions=0 pointers=0 declined=0 rest + SCAN_STATE="none" + SCAN_TOKEN="" + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + line="${line:0:$MAX_LINE}" + case "$(trim "$line")" in + "$BEGIN_MARKER") + if [[ $in_region -eq 1 ]]; then + SCAN_STATE="nested" + return 0 + fi + in_region=1 + regions=$((regions + 1)) + continue + ;; + "$END_MARKER") + in_region=0 + continue + ;; + declined) + [[ $in_region -eq 1 ]] && declined=1 + continue + ;; + *) ;; + esac + [[ $in_region -eq 1 ]] || continue + case "$line" in + *\`*\`*) + pointers=$((pointers + 1)) + if [[ $pointers -eq 1 ]]; then + rest="${line#*\`}" + SCAN_TOKEN="${rest%%\`*}" + fi + ;; + *) ;; + esac + done <"$file" + if [[ $in_region -eq 1 ]]; then + SCAN_STATE="unterminated" + elif [[ $regions -eq 0 ]]; then + SCAN_STATE="none" + elif [[ $declined -eq 1 && $pointers -gt 0 ]]; then + SCAN_STATE="mixed" + elif [[ $declined -eq 1 ]]; then + SCAN_STATE="declined" + elif [[ $pointers -eq 0 ]]; then + SCAN_STATE="empty" + elif [[ $pointers -eq 1 ]]; then + SCAN_STATE="one" + else + SCAN_STATE="many" + fi +} + +# valid_home : the grammar above. Returns 1 with a reason in +# INVALID_REASON otherwise. +valid_home() { + local t="$1" seg bare rest backslash + backslash=$(printf '%b' '\134') + INVALID_REASON="" + if [[ -z "$t" ]]; then INVALID_REASON="empty"; return 1; fi + case "$t" in + /*) INVALID_REASON="absolute path" ;; + '~'*) INVALID_REASON="home-relative (~) path" ;; + *"$backslash"*) INVALID_REASON="backslash" ;; + *:*) INVALID_REASON="drive letter or colon" ;; + *) ;; + esac + [[ -z "$INVALID_REASON" ]] || return 1 + t="${t%/}" + if [[ -z "$t" ]]; then INVALID_REASON="empty"; return 1; fi + rest="$t/" + while [[ -n "$rest" ]]; do + seg="${rest%%/*}" + rest="${rest#*/}" + # NTFS drops trailing dots, so `.git.` names .git there too. + bare="$seg" + while [[ "$bare" == ?*. ]]; do bare="${bare%.}"; done + case "$bare" in + [.][Gg][Ii][Tt]) INVALID_REASON="a .git segment"; return 1 ;; + *) ;; + esac + case "$seg" in + "") INVALID_REASON="empty path segment"; return 1 ;; + . | ..) INVALID_REASON="dot segment ($seg)"; return 1 ;; + *[!A-Za-z0-9._-]*) INVALID_REASON="characters outside [A-Za-z0-9._/-]"; return 1 ;; + *) ;; + esac + done + return 0 +} + +# committed_file : copy as committed at $REF into . +# Returns 1 when it is absent or not a regular file at $REF (a symlink is not read). +committed_file() { + local entry mode + entry="$(git -C "$ROOT" ls-tree "$REF" -- "$1" 2>/dev/null)" || return 1 + mode="${entry%% *}" + # ./ keeps the path relative to $ROOT, as ls-tree reads it, when $ROOT is below the toplevel. + case "$mode" in + 100644 | 100755) git -C "$ROOT" show --end-of-options "$REF:./$1" >"$2" 2>/dev/null ;; + *) return 1 ;; + esac +} + +# TOKEN here is the parser sense: the pointer line scan_file lifts out of a +# convention-home region, never a credential. gitleaks' generic-api-key rule +# keys on the identifier and then takes the next line it can reach as the +# value, so it reports CLAUDE_SHIM=0 as the secret. Annotated rather than +# renamed, because the name is right for what the variable holds. +AGENTS_STATE="none"; AGENTS_TOKEN="" +CLAUDE_STATE="none"; CLAUDE_TOKEN="" # gitleaks:allow +CLAUDE_SHIM=0 # gitleaks:allow +CLAUDE_SAME=0 # gitleaks:allow +AGENTS_FILE="" +CLAUDE_FILE="" # gitleaks:allow + +if [[ $STDIN_MODE -eq 1 ]]; then + SCRATCH="$(mktemp -d)" || exit 2 + cat >"$SCRATCH/AGENTS.md" + AGENTS_FILE="$SCRATCH/AGENTS.md" +else + classify_root + if [[ $HAS_REF -eq 1 ]]; then + if ! git -C "$ROOT" rev-parse --verify --quiet --end-of-options "$REF^{commit}" >/dev/null; then + echo "ERROR: --ref does not resolve to a commit: $REF" >&2 + exit 2 + fi + SCRATCH="$(mktemp -d)" || exit 2 + committed_file AGENTS.md "$SCRATCH/AGENTS.md" && AGENTS_FILE="$SCRATCH/AGENTS.md" + committed_file CLAUDE.md "$SCRATCH/CLAUDE.md" && CLAUDE_FILE="$SCRATCH/CLAUDE.md" + else + [[ -f "$ROOT/AGENTS.md" ]] && AGENTS_FILE="$ROOT/AGENTS.md" + [[ -f "$ROOT/CLAUDE.md" ]] && CLAUDE_FILE="$ROOT/CLAUDE.md" + if [[ -n "$AGENTS_FILE" && -n "$CLAUDE_FILE" && "$(phys "$AGENTS_FILE")" == "$(phys "$CLAUDE_FILE")" ]]; then + CLAUDE_SAME=1 + CLAUDE_FILE="" + fi + fi +fi + +if [[ -n "$AGENTS_FILE" ]]; then + scan_file "$AGENTS_FILE" + AGENTS_STATE="$SCAN_STATE"; AGENTS_TOKEN="$SCAN_TOKEN" +fi +if [[ -n "$CLAUDE_FILE" ]]; then + if is_pure_shim "$CLAUDE_FILE"; then + CLAUDE_SHIM=1 + else + scan_file "$CLAUDE_FILE" + CLAUDE_STATE="$SCAN_STATE"; CLAUDE_TOKEN="$SCAN_TOKEN" + fi +fi + +if [[ $STDIN_MODE -eq 1 ]]; then + explain "source: stdin" +elif [[ $HAS_REF -eq 1 ]]; then + explain "root: $ROOT at $REF" +else + explain "root: $ROOT" +fi +explain "AGENTS.md: $AGENTS_STATE" +if [[ $CLAUDE_SAME -eq 1 ]]; then + explain "CLAUDE.md: the same file as AGENTS.md (not read twice)" +elif [[ $CLAUDE_SHIM -eq 1 ]]; then + explain "CLAUDE.md: pure @AGENTS.md shim (not consulted)" +else + explain "CLAUDE.md: $CLAUDE_STATE" +fi + +# AGENTS.md is canonical whenever it carries a region at all, even a broken one: +# a FAIL in the canonical file is not something CLAUDE.md gets to paper over. +if [[ "$AGENTS_STATE" != "none" ]]; then + CHOSEN="AGENTS.md"; STATE="$AGENTS_STATE"; TOKEN="$AGENTS_TOKEN" + if [[ "$CLAUDE_STATE" != "none" ]]; then + echo "duplicate: CLAUDE.md also carries a convention-home region; AGENTS.md is canonical, remove the CLAUDE.md copy" >&2 + fi +else + CHOSEN="CLAUDE.md"; STATE="$CLAUDE_STATE"; TOKEN="$CLAUDE_TOKEN" +fi +[[ $STDIN_MODE -eq 1 ]] && CHOSEN="stdin" +explain "chosen: $CHOSEN ($STATE)" + +case "$STATE" in +none) + echo "no convention-home region in AGENTS.md or CLAUDE.md under ${ROOT:-stdin}; ask the operator for the home" >&2 + exit 1 + ;; +empty) + echo "convention-home region in $CHOSEN has no pointer line; ask the operator for the home" >&2 + exit 1 + ;; +declined) + echo "declined: convention-home region in $CHOSEN records that this repository declined a home; use the bundled defaults and do not ask again" >&2 + exit 4 + ;; +mixed) + echo "FAIL: convention-home region in $CHOSEN holds both a declined line and a pointer line; keep one" >&2 + exit 3 + ;; +many) + echo "FAIL: two pointer lines in one convention-home region ($CHOSEN); keep exactly one" >&2 + exit 3 + ;; +unterminated) + echo "FAIL: convention-home region in $CHOSEN has a BEGIN marker with no END marker" >&2 + exit 3 + ;; +nested) + echo "FAIL: convention-home region in $CHOSEN opens a second BEGIN marker before its END" >&2 + exit 3 + ;; +*) ;; +esac + +if ! valid_home "$TOKEN"; then + echo "FAIL: invalid pointer path in $CHOSEN (${INVALID_REASON}); a home is a repo-relative directory of [A-Za-z0-9._-] segments" >&2 + exit 3 +fi +HOME_DIR="${TOKEN%/}" +explain "token: $TOKEN" + +if [[ $STDIN_MODE -eq 1 ]]; then + : # no tree to check the directory against +elif [[ $HAS_REF -eq 1 ]]; then + if [[ "$(git -C "$ROOT" cat-file -t "$REF:./$HOME_DIR" 2>/dev/null)" != "tree" ]]; then + echo "FAIL: pointer target directory missing: $HOME_DIR (named in $CHOSEN, not found at $REF)" >&2 + exit 3 + fi +elif [[ ! -d "$ROOT/$HOME_DIR" ]]; then + echo "FAIL: pointer target directory missing: $HOME_DIR (named in $CHOSEN, not found under $ROOT)" >&2 + exit 3 +elif [[ "$(phys "$ROOT/$HOME_DIR")" != "$(phys "$ROOT")/$HOME_DIR" ]]; then + echo "FAIL: pointer target is reached through a symlink, or named in a case other than on disk: $HOME_DIR (named in $CHOSEN); bind the real directory as spelled on disk" >&2 + exit 3 +fi + +printf '%s\n' "$HOME_DIR" diff --git a/plugins/code-tidying/lib/yaml-subset.awk b/plugins/code-tidying/lib/yaml-subset.awk new file mode 100644 index 00000000000..69254deb171 --- /dev/null +++ b/plugins/code-tidying/lib/yaml-subset.awk @@ -0,0 +1,282 @@ +# GENERATED from lib/yaml-subset.awk by scripts/sync-shared-copies.sh. Do not edit this copy: +# edit the canonical source, then rerun the script. +# Flatten a YAML subset to `dotted.keyvalue` records, one per scalar. +# +# SINGLE SOURCE OF TRUTH: lib/yaml-subset.awk at the marketplace repo root. +# Plugin copies are generated by scripts/sync-shared-copies.sh; never edit one. +# +# The subset: block mappings and block sequences by indentation, flow +# sequences closed on the same line, plain and quoted scalars, `#` comments +# (whole-line, or after whitespace). A plain scalar may not hold `: ` (a colon +# then a space or tab); quote such a value. A sequence item is keyed by its +# zero-based index (`lanes.pr-merge.slots.0.activity`); a mapping item's later +# keys sit two columns past its dash. An empty value, an empty flow sequence +# and a key with nothing under it print nothing. Keys and scalars may be +# quoted: `''` inside single quotes is one quote, and inside double quotes +# `\"`, `\\`, `\/` and `\t` are unescaped, `\n` is kept as written (a record +# is one line) and any other `\x` is a parse error. A key repeated in one +# mapping, flow mappings (`{...}`), nested flow sequences, a line opening with +# a `---` or `...` document marker, `%` directives, anchors, aliases, tags, +# block scalars and multi-line flow sequences are parse errors, as in +# lib/yaml-subset.mjs. +# +# The document may sit at a uniform base indent, the leading whitespace of its +# first content line; indentation is spaces. A tab in a line's leading +# whitespace is a parse error, except in the base indent of a -v BLOCK=1 fence, +# which may hold tabs. CRLF and a leading byte-order mark are accepted. +# +# -v BLOCK=1 reads only the lines inside the first ```yaml config fence (a +# docs convention file); line numbers still name the .md file's own lines. +# -v STRICT=1 is still accepted and changes nothing: a repeated key is a parse +# error at the second occurrence's line either way. +# On a parse error prints `error` and exits 1. +# Prints `block1` first when BLOCK=1 found a fence. + +function fail(msg) { + print "error\t" NR "\t" msg + failed = 1 + exit 1 +} + +function emit(path, val) { print path "\t" val } + +# A key seen twice at one full dotted path is a duplicate in its mapping. +# Sequence items carry their own index in the path, so the same key in two +# items never collides. +function dupcheck(path) { + if (path in keyseen) fail("duplicate key `" path "` (first at line " keyseen[path] ")") + keyseen[path] = NR +} + +function ch() { return substr(FS_S, FP, 1) } + +function skipws() { while (FP <= length(FS_S) && ch() ~ /[ \t]/) FP++ } + +# rtrim and uncomment are sub(/[ \t]+$/, "", s) and sub(/[ \t]+#.*$/, "", s) +# as one pass each: the regexes rescan a long run of blanks from every start. +function rtrim(s, n, c) { + for (n = length(s); n > 0; n--) { + c = substr(s, n, 1) + if (c != " " && c != "\t") break + } + return substr(s, 1, n) +} + +function uncomment(s, i, n, c, run) { + n = length(s); run = 0 + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == " " || c == "\t") { if (!run) run = i } + else if (c == "#" && run) return substr(s, 1, run - 1) + else run = 0 + } + return s +} + +# Scan the quoted scalar opening at s[start]. On success sets QV to its value +# and QEND to the index after the closing quote, and returns 1; returns 0 when +# the quote is unclosed. `''` inside single quotes is one quote; inside double +# quotes `\"`, `\\`, `\/` and `\t` are unescaped, `\n` is kept as written and +# any other `\x` is a parse error. +function qscan(s, start, q, v, i, n, c, e) { + q = substr(s, start, 1); v = ""; n = length(s) + for (i = start + 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == q) { + if (q == "'" && substr(s, i + 1, 1) == "'") { v = v "'"; i++; continue } + QV = v; QEND = i + 1 + return 1 + } + if (q == "\"" && c == "\\" && i < n) { + e = substr(s, i + 1, 1) + if (e == "\"" || e == "\\" || e == "/") v = v e + else if (e == "t") v = v "\t" + else if (e == "n") v = v c e + else fail("unsupported escape \\" e " in double-quoted string") + i++ + continue + } + v = v c + } + return 0 +} + +# When s opens with `key:` (plain, or a quoted key), sets KEY and returns the +# index of that colon; otherwise returns 0. +function keyat(s, c, r) { + c = substr(s, 1, 1) + if (c == "\"" || c == "'") { + if (!qscan(s, 1)) return 0 + KEY = QV + r = substr(s, QEND) + } else { + if (!match(s, /^[A-Za-z0-9_-]+/)) return 0 + KEY = substr(s, 1, RLENGTH) + r = substr(s, RLENGTH + 1) + } + if (!match(r, /^[ \t]*:/)) return 0 + if (KEY == "" || KEY ~ /[.\t]/) fail("a key may not be empty or hold `.` or a tab") + return length(s) - length(r) + RLENGTH +} + +function unsupported(c) { + if (c == "{") fail("flow mapping ({...}) is outside the subset; use block style") + if (c ~ /[&*!|>]/) fail("anchors, aliases, tags and block scalars are not part of the subset") +} + +# One value inside a flow sequence, at FP. +function flow(path, c, start, end) { + skipws() + c = ch() + if (c == "") fail("unclosed flow collection") + if (c == "[") fail("nested flow sequences are outside the subset") + if (c == "\"" || c == "'") { + if (!qscan(FS_S, FP)) fail("unclosed quote") + emit(path, QV) + FP = QEND + return + } + unsupported(c) + start = FP + while (FP <= length(FS_S) && ch() !~ /[],}]/) { + # ` #` starts a comment, which leaves the collection unclosed. + if (ch() == "#" && substr(FS_S, FP - 1, 1) ~ /[ \t]/) fail("unclosed flow collection") + FP++ + } + c = substr(FS_S, start, FP - start) + c = rtrim(c) + if (c == "") fail("an empty flow item") + emit(path, c) +} + +function fseq(path, i, c) { + FP++ + for (i = 0; ; i++) { + skipws() + if (ch() == "]") { FP++; return } + flow(path "." i) + skipws() + c = ch() + if (c == "]") { FP++; return } + if (c == "") fail("unclosed flow collection") + if (c != ",") fail("expected `,` or `]` in a flow sequence") + FP++ + } +} + +# The value after `key:` or `- `, leading blanks removed. An empty value opens +# a container the next deeper line fills; `seq` says a `- ` item opened it. +function value(path, s, ind, opener, c, end, rest) { + if (s == "" || s ~ /^#/) { + pending = 1; ppath = path; pind = ind; ptype = opener + return + } + c = substr(s, 1, 1) + if (c == "[") { + FS_S = s; FP = 1 + fseq(path) + rest = substr(FS_S, FP) + } else if (c == "\"" || c == "'") { + if (!qscan(s, 1)) fail("unclosed quote") + emit(path, QV) + rest = substr(s, QEND) + } else { + unsupported(c) + s = rtrim(uncomment(s)) + if (s ~ /:[ \t]/) fail("a plain value may not hold `: `; quote it") + emit(path, s) + return + } + sub(/^[ \t]+/, "", rest) + if (rest != "" && rest !~ /^#/) fail("unexpected text after a value") +} + +function mapkey(rest, ind, key, val, path, p) { + p = keyat(rest) + if (!p) fail("expected `key: value`") + key = KEY + val = substr(rest, p + 1) + if (val != "" && val !~ /^[ \t]/) fail("expected a space after `" key ":`") + sub(/^[ \t]+/, "", val) + path = (paths[n] == "" ? "" : paths[n] ".") key + dupcheck(path) + value(path, val, ind, "map") +} + +BEGIN { n = 0; based = 0; pending = 0; inblock = (BLOCK == 1) ? 0 : 1; seen = 0 } + +{ + sub(/\r$/, "") + if (NR == 1) sub(/^\357\273\277/, "") + line = $0 + if (BLOCK == 1) { + if (!inblock) { + # Any other fenced block is skipped whole, so a prose example of the + # config block inside a longer outer fence never counts. + if (other != "") { + if (line ~ /^`+[ \t]*$/ && match(line, /^`+/) && RLENGTH >= length(other)) other = "" + next + } + if (line ~ /^```yaml[ \t]+config[ \t]*$/) { + if (seen) fail("a second ```yaml config block (the first opens at line " first "); one file holds one") + inblock = 1; seen = 1; first = NR; print "block\t1" + } else if (match(line, /^```+/)) { + other = substr(line, 1, RLENGTH) + } + next + } + if (line ~ /^```[ \t]*$/) { inblock = 0; done = 1; next } + } + if (line ~ /^[ \t]*(#.*)?$/) next + if (line ~ /^(---|\.\.\.)/) fail("document markers are outside the subset") + if (line ~ /^%/) fail("directives (`%`) are outside the subset") + + match(line, /^[ \t]*/) + if (BLOCK != 1 && substr(line, 1, RLENGTH) ~ /\t/) fail("tab indentation") + if (!based) { base = substr(line, 1, RLENGTH); based = 1 } + if (substr(line, 1, length(base)) != base) fail("indentation below the document's base") + line = substr(line, length(base) + 1) + if (line ~ /^ *\t/) fail("tab indentation") + match(line, /^ */) + ind = RLENGTH + rest = substr(line, ind + 1) + dash = (rest ~ /^-( |$)/) + + if (n == 0) { + if (dash) fail("a root sequence is not part of the subset") + n = 1; inds[1] = 0; types[1] = "map"; paths[1] = "" + } + if (pending) { + # A mapping key's sequence may sit at the key's own indent. + if (ind > pind || (ind == pind && dash && ptype == "map")) { + n++; inds[n] = ind; types[n] = dash ? "seq" : "map"; paths[n] = ppath; cnt[n] = 0 + } + pending = 0 + } + while (n > 1 && (ind < inds[n] || (ind == inds[n] && types[n] == "seq" && !dash))) n-- + if (ind != inds[n]) fail("unexpected indentation") + + if (types[n] == "map") { mapkey(rest, ind); next } + + if (!dash) fail("expected a `- ` sequence item") + path = paths[n] "." cnt[n] + cnt[n]++ + item = substr(rest, 2) + match(item, /^ */) + item = substr(item, RLENGTH + 1) + if (item ~ /^-( |$)/) fail("a sequence nested on one line is not part of the subset") + p = keyat(item) + if (p && substr(item, p + 1, 1) ~ /^([ \t])?$/) { + # The item's later keys sit two columns past the dash, however far its + # first key sits, as in lib/yaml-subset.mjs. + n++; inds[n] = ind + 2; types[n] = "map"; paths[n] = path + mapkey(item, ind + 2) + } else { + value(path, item, ind, "seq") + } +} + +END { + if (failed) exit 1 + if (BLOCK == 1 && inblock && !done) { print "error\t" NR "\tunclosed ```yaml config fence"; exit 1 } +} diff --git a/plugins/code-tidying/reference/config.md b/plugins/code-tidying/reference/config.md new file mode 100644 index 00000000000..b3da70bcc2d --- /dev/null +++ b/plugins/code-tidying/reference/config.md @@ -0,0 +1,23 @@ +# code-tidying: consumer configuration + +The team layer is `/code-tidying.yaml`, validated by +[`schemas/code-tidying.schema.json`](../schemas/code-tidying.schema.json). `` is the convention +home the repository binds in its root instruction file, `docs/conventions` when it binds none or +declined one. Zero config works: every key has a default. + +| Key | Values | Default | Meaning | +|---|---|---|---| +| `upkeep_cadence` | `daily`, `weekly` | `daily` | How often an upkeep batch may open pull requests. | +| `upkeep_backstop` | `weekly`, `monthly` | `weekly` | An area changed since its last sweep is overdue once its oldest unswept commit is older than this window, and goes ahead of other changed areas. An unchanged area is never swept. | +| `upkeep_max_open` | integer, 1 or more | `3` | At most this many `chore/tidy-*` pull requests open at once: `/code-tidying:tidy` stops at it, and an upkeep batch opens only the difference. | + +`skills/tidy/scripts/upkeep-setting.sh ` is the one reader. It prints the value and the layer +that supplied it. A value outside the table, or a file that does not parse, is reported with its file +and key, and the key resolves to its default. At a root that is `$HOME`, an ancestor of it, or not a +git working tree, or under a broken pointer, the team layer does not apply. + +The surface has the plugin default and the team layer only: no `userConfig` option and no personal +overlay, because the limit protects a shared review queue. + +The hard-exclusion option and the lane files are separate surfaces, described in the +[README](../README.md) and `skills/tidy/SKILL.md`. diff --git a/plugins/code-tidying/schemas/code-tidying.schema.json b/plugins/code-tidying/schemas/code-tidying.schema.json new file mode 100644 index 00000000000..69350a0830b --- /dev/null +++ b/plugins/code-tidying/schemas/code-tidying.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/plugins/code-tidying/schemas/code-tidying.schema.json", + "title": "code-tidying team config", + "description": "Schema for /code-tidying.yaml, the team layer of the code-tidying plugin's upkeep settings. Keys and resolution: plugins/code-tidying/reference/config.md.", + "type": "object", + "additionalProperties": false, + "properties": { + "upkeep_cadence": { + "enum": ["daily", "weekly"], + "default": "daily", + "description": "How often an upkeep batch may open pull requests." + }, + "upkeep_backstop": { + "enum": ["weekly", "monthly"], + "default": "weekly", + "description": "An area changed since its last sweep is overdue once its oldest unswept commit is older than this window, and goes ahead of other changed areas. An unchanged area is never swept." + }, + "upkeep_max_open": { + "type": "integer", + "minimum": 1, + "default": 3, + "description": "At most this many chore/tidy-* pull requests open at once: /code-tidying:tidy stops at it, and an upkeep batch opens only the difference." + } + } +} diff --git a/plugins/code-tidying/skills/tidy/SKILL.md b/plugins/code-tidying/skills/tidy/SKILL.md index 08c12b1421a..19f603abe1a 100644 --- a/plugins/code-tidying/skills/tidy/SKILL.md +++ b/plugins/code-tidying/skills/tidy/SKILL.md @@ -108,7 +108,7 @@ Run in order. Each phase has one job, and every phase runs whatever the tidying' 1. Resolve the lane from `$ARGUMENTS` per the Action Router. 2. Load the lane per **Lane resolution** and read the full file(s), merging both layers when the project lane declares `## Merge semantics`. The resolved lane owns scope globs, watch-for list, lane-specific exclusions, verification commands, Conventional Commits type, and preferred research sources. 3. Resolve the HARD-path override channels per `reference/exclusions.md` section 4 (the `override` argument, `.claude/code-tidying/exclusion-overrides.md`, then `hard_exclusions`) and write down the lifted set with the channel that lifted each entry. Empty is the normal answer. -4. Backlog throttle (skipped under `in-place`): if ≥3 open PRs match `chore/tidy-*` (pre-computed context above), STOP, surface a one-line note, and exit cleanly. It is a stop signal, not a warning: reviews are backed up, so do not queue one more. +4. Backlog throttle (skipped under `in-place`): if the pre-computed context above reports `Throttle active: yes` (open `chore/tidy-*` PRs at the `upkeep_max_open` limit, [reference/config.md](../../reference/config.md)), STOP, surface a one-line note, and exit cleanly. It is a stop signal, not a warning: reviews are backed up, so do not queue one more. 5. Find the anchor commit: the most recent merged `chore/tidy--` PR for this lane (or `git log --grep` if none merged yet), the "what has drifted since the last sweep" baseline. ### Phase B. Branch diff --git a/plugins/code-tidying/skills/tidy/scripts/open-pr-count.sh b/plugins/code-tidying/skills/tidy/scripts/open-pr-count.sh index 0259d6fc218..5a8a8978952 100755 --- a/plugins/code-tidying/skills/tidy/scripts/open-pr-count.sh +++ b/plugins/code-tidying/skills/tidy/scripts/open-pr-count.sh @@ -5,7 +5,8 @@ # -o pipefail omitted: graceful degradation when gh fails mid-pipeline. set -u -THROTTLE_LIMIT=3 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IFS=$'\t' read -r THROTTLE_LIMIT LIMIT_SOURCE < <(bash "$SCRIPT_DIR/upkeep-setting.sh" upkeep_max_open) # Open PRs are listed without a search and filtered by head branch here: a # degraded search can answer a false empty with no signal gh passes on. A # listing that fills its limit may be missing tidy PRs, so it throttles. @@ -15,8 +16,9 @@ usage() { cat <= ${THROTTLE_LIMIT} and gh exit 0, or when the -open-PR listing reaches its ${LIST_LIMIT}-PR limit. +Throttle active when count >= the upkeep_max_open setting (now ${THROTTLE_LIMIT}, +${LIMIT_SOURCE}) and gh exit 0, or when the open-PR listing reaches its +${LIST_LIMIT}-PR limit. Usage: open-pr-count.sh [--help] @@ -39,6 +41,8 @@ case "${1:-}" in *) ;; esac +printf 'Throttle limit: %s, %s\n' "$THROTTLE_LIMIT" "$LIMIT_SOURCE" + if ! command -v gh >/dev/null 2>&1; then emit_unknown "gh not installed" exit 1 diff --git a/plugins/code-tidying/skills/tidy/scripts/open-pr-count.test.sh b/plugins/code-tidying/skills/tidy/scripts/open-pr-count.test.sh index e5243576cdf..f5a31ea82aa 100755 --- a/plugins/code-tidying/skills/tidy/scripts/open-pr-count.test.sh +++ b/plugins/code-tidying/skills/tidy/scripts/open-pr-count.test.sh @@ -2,6 +2,7 @@ # Self-contained contract tests for open-pr-count.sh (network-free: gh is # stubbed via a PATH shim so no real GitHub calls happen). set -uo pipefail +unset GIT_DIR GIT_WORK_TREE GIT_CONFIG SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$SCRIPT_DIR/open-pr-count.sh" @@ -39,6 +40,8 @@ cat >"$TEST_TMPDIR/bin/gh" <<'EOF' case "$*" in *--search*) exit 64 ;; *"--limit 1000"*) ;; *) exit 65 ;; esac case "${GH_STUB_MODE:-}" in count0) echo "5 0" ;; + count1) echo "9 1" ;; + count2) echo "9 2" ;; count3) echo "12 3" ;; full) echo "1000 0" ;; fail | *) exit 4 ;; @@ -84,6 +87,47 @@ rc=$? assert_exit "gh failure exits 1" 1 "$rc" assert_contains "gh failure reports unknown" "$out" "Open tidy PR count: unknown" +# --- 5. The limit comes from the upkeep_max_open setting ------------------------ + +# A repository whose home is ; a consumer's own root, never the plugin's. +new_repo() { + local repo="$TEST_TMPDIR/$1" + mkdir -p "$repo/$2" + git -C "$repo" init -q + printf '%s\n' "$repo" +} +run_at() { + PATH="$STUB_PATH" CLAUDE_PROJECT_DIR="$1" GH_STUB_MODE="$2" bash "$SCRIPT" 2>&1 +} + +repo="$(new_repo set2 docs/conventions)" +printf 'upkeep_max_open: 2\n' >"$repo/docs/conventions/code-tidying.yaml" +out="$(run_at "$repo" count2)" +assert_contains "setting 2, 2 open: throttled" "$out" "Throttle active: yes" +assert_contains "setting 2 names the team layer" "$out" "Throttle limit: 2, team" +out="$(run_at "$repo" count1)" +assert_contains "setting 2, 1 open: not throttled" "$out" "Throttle active: no" + +repo="$(new_repo absent docs/conventions)" +out="$(run_at "$repo" count3)" +assert_contains "no file, 3 open: throttled" "$out" "Throttle active: yes" +assert_contains "no file names the default" "$out" "Throttle limit: 3, default" +out="$(run_at "$repo" count2)" +assert_contains "no file, 2 open: not throttled" "$out" "Throttle active: no" + +repo="$(new_repo invalid docs/conventions)" +printf 'upkeep_max_open: 0\n' >"$repo/docs/conventions/code-tidying.yaml" +out="$(run_at "$repo" count2)" +assert_contains "invalid value falls to the default" "$out" "Throttle active: no" +assert_contains "invalid value is named" "$out" "docs/conventions/code-tidying.yaml upkeep_max_open: 0 is not valid" + +repo="$(new_repo bound team/rules)" +# shellcheck disable=SC2016 # literal backticks: the pointer line's grammar. +printf '\nConventions live in `team/rules`.\n\n' >"$repo/AGENTS.md" +printf 'upkeep_max_open: 1\n' >"$repo/team/rules/code-tidying.yaml" +out="$(run_at "$repo" count1)" +assert_contains "the bound home is read" "$out" "Throttle active: yes" + # --- Final report --------------------------------------------------------------- if [[ "$FAILED" -eq 0 ]]; then diff --git a/plugins/code-tidying/skills/tidy/scripts/upkeep-setting.sh b/plugins/code-tidying/skills/tidy/scripts/upkeep-setting.sh new file mode 100755 index 00000000000..005d92cdb87 --- /dev/null +++ b/plugins/code-tidying/skills/tidy/scripts/upkeep-setting.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Resolve one upkeep setting: the team layer at /code-tidying.yaml, else +# the default. Keys and layers: reference/config.md. +# +# Usage: upkeep-setting.sh upkeep_cadence|upkeep_backstop|upkeep_max_open +# +# Prints and exits 0. is `team`, `default`, or +# `default ()`. Exit 2 on a usage error. +# +# The root is the git toplevel of CLAUDE_PROJECT_DIR, else of the current +# directory. +set -uo pipefail + +LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../lib" && pwd)" + +key="${1:-}" +case "$key" in +upkeep_cadence) + default=daily + valid='^(daily|weekly)$' + ;; +upkeep_backstop) + default=weekly + valid='^(weekly|monthly)$' + ;; +upkeep_max_open) + default=3 + valid='^[1-9][0-9]{0,3}$' + ;; +*) + echo "upkeep-setting: usage: upkeep-setting.sh upkeep_cadence|upkeep_backstop|upkeep_max_open" >&2 + exit 2 + ;; +esac + +resolve() { + printf '%s\t%s\n' "$default" "default${1:+ ($1)}" + exit 0 +} + +root="$(git -C "${CLAUDE_PROJECT_DIR:-.}" rev-parse --show-toplevel 2>/dev/null)" || resolve + +home="$(bash "$LIB/resolve-convention-home.sh" --root "$root" 2>/dev/null)" +case $? in +0) ;; +1 | 4) home=docs/conventions ;; +3) resolve "the convention-home pointer is broken" ;; +*) resolve ;; +esac + +rel="$home/code-tidying.yaml" +[[ -f "$root/$rel" ]] || resolve + +value="$(bash "$LIB/parse-concern-value.sh" --strict "$root/$rel" "$key" 2>/dev/null)" +(($? == 3)) && resolve "$rel does not parse" +[[ -n "$value" ]] || resolve +[[ "$value" =~ $valid ]] || resolve "$rel $key: ${value:0:40} is not valid" +printf '%s\tteam\n' "$value" diff --git a/scripts/outside-node-packages.txt b/scripts/outside-node-packages.txt index 5cc9eeeacc9..6c68d29446e 100644 --- a/scripts/outside-node-packages.txt +++ b/scripts/outside-node-packages.txt @@ -25,6 +25,7 @@ .github/scripts/lane-run-report .github/scripts/lib .github/scripts/maintenance-audit-lane-runs +.github/scripts/maintenance-code-tidying-upkeep .github/scripts/maintenance-monitor-signals .github/scripts/maintenance-reconcile-lost-runs .github/scripts/post-merge-demotion-alert diff --git a/scripts/shared-copies.txt b/scripts/shared-copies.txt index 81016586b92..1f8518c6b2f 100644 --- a/scripts/shared-copies.txt +++ b/scripts/shared-copies.txt @@ -118,6 +118,8 @@ lib/parse-concern-value.sh plugins/testing/scripts/parse-concern-value.sh lib/parse-concern-value.sh plugins/planning/skills/setup/scripts/parse-concern-value.sh lib/parse-concern-value.sh plugins/discipline/skills/setup/scripts/parse-concern-value.sh lib/parse-concern-value.sh plugins/docs-hygiene/lib/parse-concern-value.sh +lib/parse-concern-value.sh plugins/code-tidying/lib/parse-concern-value.sh +lib/yaml-subset.awk plugins/code-tidying/lib/yaml-subset.awk lib/yaml-subset.awk plugins/harness-memory/skills/audit/scripts/yaml-subset.awk lib/yaml-subset.awk plugins/session-flow/skills/retro/scripts/yaml-subset.awk lib/yaml-subset.awk plugins/discovery/skills/setup/scripts/yaml-subset.awk @@ -493,6 +495,7 @@ lib/yaml-subset.mjs plugins/verification/lib/yaml-subset.mjs lib/resolve-convention-home.sh plugins/discipline/lib/resolve-convention-home.sh lib/resolve-convention-home.sh plugins/discovery/lib/resolve-convention-home.sh lib/resolve-convention-home.sh plugins/docs-hygiene/lib/resolve-convention-home.sh +lib/resolve-convention-home.sh plugins/code-tidying/lib/resolve-convention-home.sh lib/resolve-convention-home.sh plugins/education/lib/resolve-convention-home.sh lib/resolve-convention-home.sh plugins/review/lib/resolve-convention-home.sh lib/resolve-convention-home.sh plugins/session-flow/lib/resolve-convention-home.sh