diff --git a/.claude/source-control.md b/.claude/source-control.md deleted file mode 100644 index c26f5f6..0000000 --- a/.claude/source-control.md +++ /dev/null @@ -1,27 +0,0 @@ -# source-control configuration - -Team-tracked layer of the source-control plugin's layered config. Key names, valid values, and -resolution order are defined upstream in -; -the merge-rung ladder and the ratification rule that makes this reviewed file the recorded baseline -are defined in -. - -## subject_pattern - -Conventional Commits - -## pr_title_pattern - -Same as `subject_pattern`. - -## pr_body_required_sections - -- Summary -- Fix -- Verification -- Related - -## babysit_loop_merge - -c3-autonomous diff --git a/.github/scripts/pr-section-drift.mjs b/.github/scripts/pr-section-drift.mjs index b7d5f93..2e50c49 100644 --- a/.github/scripts/pr-section-drift.mjs +++ b/.github/scripts/pr-section-drift.mjs @@ -2,7 +2,7 @@ // Local complement to standards' fleet lockstep (ADR-0008): this repository's // two PR-body declarations (`.github/PULL_REQUEST_TEMPLATE.md` headings and -// `.claude/source-control.md`'s `pr_body_required_sections`) are unchecked +// `docs/conventions/source-control.yaml`'s `pr_body_required_sections`) are unchecked // mirrors of the section list the `check-contract` composite enforces. ADR-0008 // fleet-checks the template and the pin from standards CI *after* merge; it // deliberately does not check this repository's source-control key. This lane @@ -28,7 +28,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const CALLER_PATH = ".github/workflows/pr-require-checks.yml"; const TEMPLATE_PATH = ".github/PULL_REQUEST_TEMPLATE.md"; -const SOURCE_CONTROL_PATH = ".claude/source-control.md"; +const SOURCE_CONTROL_PATH = "docs/conventions/source-control.yaml"; const UPSTREAM_REPO = "melodic-software/ci-workflows"; const UPSTREAM_GATE = ".github/actions/pr-require-checks/check-contract/run.sh"; const SOURCE_CONTROL_HEADING = "pr_body_required_sections"; @@ -87,27 +87,33 @@ export function parseTemplateHeadings(markdownText) { return [...stripHtmlComments(markdownText).matchAll(/^## (.+)$/gm)].map((match) => match[1].trim()); } -export function parseSourceControlSections(markdownText, location = SOURCE_CONTROL_PATH) { - const lines = markdownText.split(/\r?\n/); - const starts = lines.flatMap((line, index) => (line.trim() === `## ${SOURCE_CONTROL_HEADING}` ? [index] : [])); +// Reads the top-level block sequence `pr_body_required_sections:` from the YAML: +// its `- ` items, plain or quoted, up to the next top-level line. +export function parseSourceControlSections(yamlText, location = SOURCE_CONTROL_PATH) { + const lines = yamlText.split(/\r?\n/); + const keyRe = new RegExp(`^${SOURCE_CONTROL_HEADING}:\\s*(#.*)?$`); + const starts = lines.flatMap((line, index) => (keyRe.test(line) ? [index] : [])); if (starts.length === 0) { - throw new DriftError(`${location}: no ## ${SOURCE_CONTROL_HEADING} section found`); + throw new DriftError(`${location}: no top-level ${SOURCE_CONTROL_HEADING} list found`); } if (starts.length > 1) { - throw new DriftError(`${location}: ## ${SOURCE_CONTROL_HEADING} appears more than once`); + throw new DriftError(`${location}: ${SOURCE_CONTROL_HEADING} appears more than once`); } const names = []; for (const line of lines.slice(starts[0] + 1)) { - if (line.startsWith("## ")) { + if (line.trim() === "" || /^\s*#/.test(line)) { + continue; + } + if (!/^\s/.test(line) && !line.startsWith("- ")) { break; } - const bullet = line.match(/^- (.+)$/); - if (bullet) { - names.push(bullet[1].trim()); + const item = line.match(/^\s*- (.+?)\s*$/); + if (item) { + names.push(item[1].replace(/^(["'])(.*)\1$/, "$2")); } } if (names.length === 0) { - throw new DriftError(`${location}: ## ${SOURCE_CONTROL_HEADING} carries no list items`); + throw new DriftError(`${location}: ${SOURCE_CONTROL_HEADING} carries no list items`); } return names; } diff --git a/.github/scripts/pr-section-drift.test.mjs b/.github/scripts/pr-section-drift.test.mjs index 2b29510..da41c3b 100644 --- a/.github/scripts/pr-section-drift.test.mjs +++ b/.github/scripts/pr-section-drift.test.mjs @@ -44,17 +44,12 @@ function runSh(...names) { function sourceControl(...names) { return [ - "## subject_pattern", - "", - "Conventional Commits", - "", - "## pr_body_required_sections", - "", - ...names.map((name) => `- ${name}`), - "", - "## babysit_loop_merge", - "", - "c3-autonomous", + "# team layer", + "subject_pattern: Conventional Commits", + "pr_body_required_sections:", + " # closed list", + ...names.map((name) => ` - ${name}`), + "babysit_loop_merge: c3-autonomous", "", ].join("\n"); } @@ -136,23 +131,32 @@ describe("parseTemplateHeadings", () => { }); describe("parseSourceControlSections", () => { - it("reads the closed bullet list under pr_body_required_sections", () => { + it("reads the block sequence under pr_body_required_sections and stops at the next key", () => { assert.deepEqual(parseSourceControlSections(sourceControl("Alpha", "Beta")), ["Alpha", "Beta"]); }); - it("fails when the section is missing", () => { - assert.throws(() => parseSourceControlSections("## other\n\n- Alpha\n"), /no ## pr_body_required_sections/); + it("reads quoted and unindented items", () => { + const text = "pr_body_required_sections:\n- \"Alpha\"\n- 'Test plan'\nnext: x\n"; + assert.deepEqual(parseSourceControlSections(text), ["Alpha", "Test plan"]); }); - it("fails when the list is empty", () => { + it("fails when the key is missing", () => { + assert.throws(() => parseSourceControlSections("other:\n - Alpha\n"), /no top-level pr_body_required_sections/); + }); + + it("ignores a nested key of the same name", () => { assert.throws( - () => parseSourceControlSections("## pr_body_required_sections\n\n## next\n"), - /carries no list items/, + () => parseSourceControlSections("other:\n pr_body_required_sections:\n - Alpha\n"), + /no top-level pr_body_required_sections/, ); }); - it("fails when the heading appears twice", () => { - const text = `${sourceControl("Alpha")}\n## pr_body_required_sections\n\n- Beta\n`; + it("fails when the list is empty", () => { + assert.throws(() => parseSourceControlSections("pr_body_required_sections:\nnext: x\n"), /carries no list items/); + }); + + it("fails when the key appears twice", () => { + const text = `${sourceControl("Alpha")}pr_body_required_sections:\n - Beta\n`; assert.throws(() => parseSourceControlSections(text), /appears more than once/); }); }); @@ -180,7 +184,7 @@ describe("collectDrift", () => { it("reports a reordered source-control list", () => { const errors = collectDrift(contract, ["Alpha", "Beta"], ["Beta", "Alpha"], SHA); assert.equal(errors.length, 1); - assert.match(errors[0], /source-control\.md pr_body_required_sections \[Beta, Alpha\]/); + assert.match(errors[0], /source-control\.yaml pr_body_required_sections \[Beta, Alpha\]/); }); it("reports a missing source-control bullet", () => { diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index ea24413..750cc66 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -309,7 +309,7 @@ jobs: uses: melodic-software/ci-workflows/.github/actions/check-line-endings@594987abe8657a40bccc13d301f1f0d5cccb5348 # v0.39.8 # Compares `.github/PULL_REQUEST_TEMPLATE.md` headings and - # `.claude/source-control.md`'s `pr_body_required_sections` to the + # `docs/conventions/source-control.yaml`'s `pr_body_required_sections` to the # `section_report` calls inside the SHA-pinned `check-contract` composite's # `run.sh`. The comparison target is that pin's bytes, not a # third hardcoded copy of the four names. Fetches over the network; a fetch diff --git a/CLAUDE.md b/CLAUDE.md index 55200b8..3aec53a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,4 +40,4 @@ pins, and exits non-zero when any gating lane fails. [`.claude/rules/pr-body-contract.md`](.claude/rules/pr-body-contract.md) states the rule and what is gating versus advisory; [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md) carries the layout -and [`.claude/source-control.md`](.claude/source-control.md) the section list. +and [`docs/conventions/source-control.yaml`](docs/conventions/source-control.yaml) the section list. diff --git a/README.md b/README.md index c2bad63..3d6a3ea 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ link lane for the same reason. so there are no separate caller workflows for it. The `check-pr-sections` lane is a local script (`.github/scripts/pr-section-drift.mjs` and its tests) that compares `.github/PULL_REQUEST_TEMPLATE.md` and - `.claude/source-control.md` against the `check-contract` composite at the + `docs/conventions/source-control.yaml` against the `check-contract` composite at the SHA `.github/workflows/pr-require-checks.yml` pins. `.github/dependabot.yml` keeps `actions/checkout` current; the `ci-workflows` pins move by hand (see its `ignore` block). @@ -56,11 +56,11 @@ link lane for the same reason. [`standards`](https://github.com/melodic-software/standards) and extended per-repo by an optional `.claude/cloud-bootstrap.local.sh`. It also denies Read on secret files (`.env*`, `secrets/`, keys). - `.claude/source-control.md` is the tracked team layer of the source-control - convention (commit and PR-title pattern, required PR-body sections, merge - lane); `.work-item-tracker.json` binds the work-items tracker provider. - `.claude/source-control.md` and `.work-item-tracker.json` each resolve an - optional gitignored `*.local.*` overlay for per-operator deviations. + `docs/conventions/source-control.yaml` is the tracked team layer of the + source-control convention (commit and PR-title pattern, required PR-body + sections, merge lane); `.work-item-tracker.json` binds the work-items tracker + provider. The source-control settings and `.work-item-tracker.json` each + resolve an optional gitignored `*.local.*` overlay for per-operator deviations. `.claude/rules/pr-body-contract.md`, synced from `standards`, states the pull-request body contract; `.claude/ai-slop.json` configures the AI-writing audit for this repository. diff --git a/docs/conventions/source-control.yaml b/docs/conventions/source-control.yaml new file mode 100644 index 0000000..26435f2 --- /dev/null +++ b/docs/conventions/source-control.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/plugins/source-control/schemas/source-control.schema.json +# Team layer of the source-control plugin's settings. Keys, values and resolution: +# https://github.com/melodic-software/claude-code-plugins/blob/main/plugins/source-control/reference/config-resolution.md +# The merge-rung ladder and the ratification rule that makes this reviewed file the recorded +# baseline: https://github.com/melodic-software/claude-code-plugins/blob/main/docs/contracts/loop-lane/README.md +# +# pr_body_required_sections mirrors the section list the pinned `check-contract` composite enforces; +# .github/scripts/pr-section-drift.mjs fails a PR that lets it drift. +subject_pattern: Conventional Commits +pr_title_pattern: "Same as `subject_pattern`." +pr_body_required_sections: + - Summary + - Fix + - Verification + - Related +babysit_loop_merge: c3-autonomous