Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 0 additions & 27 deletions .claude/source-control.md

This file was deleted.

30 changes: 18 additions & 12 deletions .github/scripts/pr-section-drift.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
Expand Down Expand Up @@ -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 `- <name>` 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;
}
Expand Down
44 changes: 24 additions & 20 deletions .github/scripts/pr-section-drift.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down Expand Up @@ -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/);
});
});
Expand Down Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-require-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions docs/conventions/source-control.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading