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
45 changes: 0 additions & 45 deletions .claudinite-checks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,51 +6,6 @@
"rule": "file-placement",
"path": "packs/",
"reason": "pack check modules deliberately share the checks/ engine lib — a named cross-cutting concern per skills/file-placement/SKILL.md; packs are organized by pack, not by dependency"
},
{
"rule": "squash-merge-history",
"path": "main@221abc6",
"reason": "legacy merge (PR #41) from before the squash-only policy was enforced; left in history"
},
{
"rule": "squash-merge-history",
"path": "main@abc0645",
"reason": "legacy merge (PR #98) from before the squash-only policy was enforced; left in history"
},
{
"rule": "squash-merge-history",
"path": "main@cdf2cca",
"reason": "legacy direct branch merge (tarball-sync doc) from before the squash-only policy; left in history"
},
{
"rule": "squash-merge-history",
"path": "main@bce9e05",
"reason": "legacy merge (PR #29) from before the squash-only policy was enforced; left in history"
},
{
"rule": "squash-merge-history",
"path": "main@b7f1021",
"reason": "legacy merge (PR #26) from before the squash-only policy was enforced; left in history"
},
{
"rule": "squash-merge-history",
"path": "main@8c23e59",
"reason": "legacy merge (PR #21) from before the squash-only policy was enforced; left in history"
},
{
"rule": "squash-merge-history",
"path": "main@45f8fa0",
"reason": "legacy merge (PR #20) from before the squash-only policy was enforced; left in history"
},
{
"rule": "squash-merge-history",
"path": "main@28111e9",
"reason": "legacy merge (PR #19) from before the squash-only policy was enforced; left in history"
},
{
"rule": "squash-merge-history",
"path": "main@ba46c94",
"reason": "legacy merge (PR #15) from before the squash-only policy was enforced; left in history"
}
]
}
6 changes: 6 additions & 0 deletions .github/workflows/checks-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Check out the PR's real head, not the synthetic refs/pull/N/merge commit
# actions/checkout uses by default — otherwise HEAD is an ephemeral merge of
# the branch into main, which the work-scoped squash-merge-history check would
# (correctly, for that synthetic commit) flag. This makes CI evaluate exactly
# what the local Stop hook does: the branch tip. Falls back to github.sha on push.
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v4
with:
node-version: 20
Expand Down
11 changes: 7 additions & 4 deletions checks/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,13 @@ bypassed, and nothing notices. So every setting rung pairs with a check that ver
order of strength:

- **Effect check (preferred)** — offline and deterministic, it verifies the *outcome* the
setting guarantees rather than the setting itself: squash-only ⇒ no merge commits in the
default branch's first-parent history (`squash-merge-history` in the universal pack). It
catches both the setting being off *and* anyone bypassing it — and it found two historical
merge commits on this very repo's `main` the moment it first ran.
setting guarantees rather than the setting itself: squash-only ⇒ the change lands squashed,
so its own commits carry no merge commit (`squash-merge-history` in the universal pack). It
is scoped to the work — the merge commits the current change introduces on HEAD's first-parent
chain since the merge-base — not the repo's whole history: it catches the setting being off or
bypassed *for this change* without re-auditing (and demanding acceptances for) legacy merges
already on `main` that the work never touched. Testing the work, not the world, is what keeps
the check from firing on every unrelated session.
- **Config check** — reads the setting via the platform API and fails when it's off. Needs a
network-capable surface: rules carry a surface tag, the Stop hook runs only offline rules,
CI runs what its repo token can read (e.g. the allow-merge-commit flags), and the fleet
Expand Down
5 changes: 2 additions & 3 deletions checks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ acceptances); `--changed` exists only for adopting a repo with a backlog. **Base
delta rules (new suppression markers, commits referencing an issue) and `--changed` scoping
diff against the merge-base with `origin/main` (falling back to `origin/master`, `main`,
`master`) — a stale `origin/main` widens that delta, so fetch when findings look like they
aren't yours. **Shallow-clone note:** history rules (`squash-merge-history`) see only the
fetched depth — a shallow sandbox clone under-reports; CI checks out with `fetch-depth: 0`
and is authoritative.
aren't yours. `squash-merge-history` is one of these delta rules: it scopes to the merge
commits the current change introduces since that merge-base, not the repo's whole history.

## Configuration — `.claudinite-checks.json` (repo root)

Expand Down
2 changes: 1 addition & 1 deletion checks/conversion-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ engineeringPractices judgment core.
| task-lifecycle: issue before work; commits reference it | **check** `universal/task-lifecycle`: commits since merge-base carry `#N`; the issue exists |
| task-lifecycle: update issue status as work progresses | **check** *(adv)*, partial — full flow lives in the merge skill |
| merge-to-main: the recipe (~4 calls) | **skill** (trigger stays the owner's "LGTM" preference) |
| merge-to-main: squash as the method | **setting** — GitHub "allow squash merging" only — verified by **check** `universal/squash-merge-history`: no merge commits in main's first-parent history (the effect), plus a CI-surface config check later |
| merge-to-main: squash as the method | **setting** — GitHub "allow squash merging" only — verified by **check** `universal/squash-merge-history`: the change introduces no merge commits (scoped to the work — its own commits since the merge-base — not the repo's whole history), plus a CI-surface config check later |
| merge-to-main: gate on CI only if the repo has it | folds into the skill |
| merge-to-main: lessons pass on every merge | folds into the skill (deterministic step, not a remembered trigger) |
| merge-to-main: don't re-read the issue; don't fight branch deletion | folds into the skill |
Expand Down
10 changes: 7 additions & 3 deletions checks/lib/context.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,13 @@ export function buildContext({ root, mode = 'changed', baseOverride = null }) {
return added;
},

// Merge commits along a ref's first-parent chain (the squash-only effect check).
mergeCommitsOn(ref) {
const out = gitTry(root, 'log', '--merges', '--first-parent', '--format=%h %s', ref);
// Merge commits the current change introduces — those on HEAD's first-parent
// chain since the merge-base with the base branch (the squash-only effect
// check, scoped to the work). Empty when no base resolves or the branch is
// even with it; pre-existing merges already on the base are out of range.
introducedMergeCommits() {
if (!mergeBase) return [];
const out = gitTry(root, 'log', '--merges', '--first-parent', '--format=%h %s', `${mergeBase}..HEAD`);
return lines(out).map((l) => {
const i = l.indexOf(' ');
return { sha: l.slice(0, i), subject: l.slice(i + 1) };
Expand Down
45 changes: 30 additions & 15 deletions checks/test/rules.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -142,25 +142,40 @@ test('pack-declaration: silent when declaration matches reality', () => {
} finally { cleanup(root); }
});

test('squash-merge-history: flags a merge commit on main, silent on linear history', () => {
test('squash-merge-history: flags a merge the work introduces, silent on linear history and pre-existing main merges', () => {
const linear = makeRepo({ changed: { 'f.txt': 'x\n' } });
const merged = makeRepo({ changed: {} });

// A merge commit on the feature branch itself — the current change introduces it → fires.
const introduced = makeRepo({ changed: { 'f.txt': 'x\n' } });
git(introduced, 'checkout', '-q', '-b', 'side');
writeFiles(introduced, { 's.txt': 'x\n' });
git(introduced, 'add', '-A');
git(introduced, 'commit', '-q', '-m', 'side work');
git(introduced, 'checkout', '-q', 'feature');
git(introduced, 'merge', '-q', '--no-ff', '-m', 'merge side into feature', 'side');

// A merge commit already on main, before the branch's work — the repo's history, not the work → silent.
const preexisting = makeRepo({ changed: {} });
git(preexisting, 'checkout', '-q', 'main');
git(preexisting, 'checkout', '-q', '-b', 'side');
writeFiles(preexisting, { 's.txt': 'x\n' });
git(preexisting, 'add', '-A');
git(preexisting, 'commit', '-q', '-m', 'side work');
git(preexisting, 'checkout', '-q', 'main');
git(preexisting, 'merge', '-q', '--no-ff', '-m', 'merge side', 'side');
git(preexisting, 'checkout', '-q', '-B', 'feature', 'main'); // branch fresh off post-merge main
writeFiles(preexisting, { 'w.txt': 'x\n' });
git(preexisting, 'add', '-A');
git(preexisting, 'commit', '-q', '-m', 'feature work');

try {
git(merged, 'checkout', '-q', 'main');
git(merged, 'checkout', '-q', '-b', 'side');
writeFiles(merged, { 's.txt': 'x\n' });
git(merged, 'add', '-A');
git(merged, 'commit', '-q', '-m', 'side work');
git(merged, 'checkout', '-q', 'main');
git(merged, 'merge', '-q', '--no-ff', '-m', 'merge side', 'side');
git(merged, 'checkout', '-q', 'feature');

const findings = run(squashMergeHistory, merged);
const findings = run(squashMergeHistory, introduced);
assert.equal(findings.length, 1);
assert.match(findings[0].what, /merge side/);
assert.match(findings[0].file, /^main@/);
assert.match(findings[0].what, /merge side into feature/);
assert.match(findings[0].file, /^feature@/);
assert.equal(run(squashMergeHistory, linear).length, 0);
} finally { cleanup(linear); cleanup(merged); }
assert.equal(run(squashMergeHistory, preexisting).length, 0);
} finally { cleanup(linear); cleanup(introduced); cleanup(preexisting); }
});

test('changed-mode scoping: pre-existing violations elsewhere are not reported', () => {
Expand Down
2 changes: 1 addition & 1 deletion packs/universal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Always on — the baseline every session loads (`RULES.md`, injected by the pack
| `task-lifecycle` | branch commits reference an issue | blocking |
| `warning-suppression` | no new suppression markers | blocking |
| `pack-declaration` | declaration matches repo fingerprint | blocking |
| `squash-merge-history` | main has no merge commits | blocking |
| `squash-merge-history` | change introduces no merge commits | blocking |
| `file-placement` | code references stay near | advisory |
| `claude-md-length` | root CLAUDE.md under ~200 lines | advisory |
| `generated-merge-driver` | GENERATED files get merge=ours | advisory |
Expand Down
24 changes: 12 additions & 12 deletions packs/universal/squash-merge-history.mjs
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { finding } from '../../checks/lib/findings.mjs';

// The effect check behind the squash-only platform setting: the setting is never
// trusted — a merge commit in main's first-parent history proves it was off or
// bypassed. One finding per merge commit, keyed by sha, so a legacy merge can be
// accepted individually while a new one still fires.
// The effect check behind the squash-only platform setting, scoped to the work:
// a merge commit the current change introduces (on HEAD's first-parent chain
// since the merge-base) is a merge that would land non-squash and proves the
// setting was off or bypassed. It tests the work, not the repo's whole history —
// pre-existing merges already on the base are out of scope. One finding per
// introduced merge, keyed by sha.
const rule = {
id: 'squash-merge-history',
severity: 'blocking',
description: "The default branch's first-parent history must be squash-only (no merge commits)",
description: 'A change introduces no merge commits (it lands squashed, not merged)',
doc: 'skills/merge-to-main/SKILL.md',
why: 'the squash-only repo setting can be off or bypassed; the history is the effect that proves it held',
why: 'the squash-only repo setting can be off or bypassed; a merge commit in the work is the effect that proves it',

run(ctx) {
if (!ctx.baseRef) return [];
const refShort = ctx.baseRef.replace(/^origin\//, '');
return ctx.mergeCommitsOn(ctx.baseRef).map(({ sha, subject }) =>
return ctx.introducedMergeCommits().map(({ sha, subject }) =>
finding(rule, {
file: `${refShort}@${sha}`,
what: `merge commit on ${refShort}: ${subject}`,
fix: 'enable squash-only merges in the repo settings (allow squash; disallow merge commits and rebase merges); accept this sha with a reason if it is a known legacy merge, or turn the rule off if the project deliberately runs a non-squash policy named in its CLAUDE.md',
file: `${ctx.branch || 'HEAD'}@${sha}`,
what: `merge commit introduced by this change: ${subject}`,
fix: 'rebase to drop the merge commit (git pull --rebase, or git rebase onto the base) so the branch lands squashed; keep squash-only enabled in the repo settings, or turn the rule off if the project deliberately runs a non-squash policy named in its CLAUDE.md',
})
);
},
Expand Down
Loading