/*
commit.message.lint — RFC-0265: enforces commit message hygiene for agent
commits. A commit subject is a short imperative statement of WHAT changed;
analysis, greetings, and status narration belong in the body or the PR, never
in the subject. Validates a git commit range (default: commits ahead of
origin/main) against four rules (COMMIT-01..04) and reports RFC-0203
Diagnostic[].
- Do not rewrite or reword existing commits — this is a read-only lint.
- Do not enforce a full conventional-commits toolchain — message shape only (see RFC's nonGoals).
- Lint rules are declarative patterns — new rules are data, not code branches.
- A commit message is validated as text only — no git operations are performed.
- RFC-0265: initial implementation.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
- RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
*/
import type { WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
import type {
CheckResult,
Diagnostic,
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-shared/kernel";
const RECORD_SEP = "\x1e";
const FIELD_SEP = "\x1f";
const CONVENTIONAL_PREFIX_RE =
/^(feat|fix|docs|refactor|test|chore|build|ci|perf|style)(\([a-z0-9-]+\))?: [a-z]/;
const INTERJECTION_RE = /^(Good|Great|Found|Okay|Now|Let me|Perfect|Excellent)\b/;
const RFC_ID_RE = /\brfc-\d{4}\b/i;
export interface CommitRecord {
hash: string;
parents: string[];
subject: string;
body: string;
}
export interface CommitMessageFinding extends Diagnostic {
ruleId: "COMMIT-01" | "COMMIT-02" | "COMMIT-03" | "COMMIT-04";
}
/** True when a commit is a merge (>1 parent) or a git-generated revert of one. */
export function isExempt(commit: Pick): boolean {
if (commit.parents.length > 1) return true;
if (/^Revert\s+"/.test(commit.subject)) return true;
return false;
}
/**
* Pure per-commit rule evaluation — no git I/O. `changedFiles` is optional;
* when omitted, COMMIT-04 (which needs the changed-file set) is skipped.
*/
export function lintCommitSubject(
commit: CommitRecord,
changedFiles?: string[],
): CommitMessageFinding[] {
if (isExempt(commit)) return [];
const findings: CommitMessageFinding[] = [];
const { hash, subject, body } = commit;
if (subject.length > 120) {
findings.push({
ruleId: "COMMIT-01",
severity: "error",
message: `${hash.slice(0, 8)}: subject is ${subject.length} characters, exceeding the 120-character limit.`,
fixHint: "Shorten the subject to a single imperative clause; move detail into the body.",
data: { hash, subject },
});
}
if (!CONVENTIONAL_PREFIX_RE.test(subject)) {
findings.push({
ruleId: "COMMIT-02",
severity: "error",
message: `${hash.slice(0, 8)}: subject does not match "()?: ".`,
fixHint:
'Rewrite as e.g. "feat: add maintenance debt queues" (feat|fix|docs|refactor|test|chore|build|ci|perf|style).',
data: { hash, subject },
});
}
const isNarration =
subject.startsWith("**") ||
subject.startsWith("`") ||
INTERJECTION_RE.test(subject) ||
/\.\s+\S/.test(subject);
if (isNarration) {
findings.push({
ruleId: "COMMIT-03",
severity: "error",
message: `${hash.slice(0, 8)}: subject looks like pasted analysis/narration rather than a commit subject.`,
fixHint:
"Write the subject as WHAT changed; put reasoning, findings, or status narration in the body instead.",
data: { hash, subject },
});
}
if (changedFiles && changedFiles.length > 0) {
const touchesGoverned = changedFiles.some(
(f) =>
f.startsWith("packages/os/") ||
(f.startsWith("docs/rfcs/") && !f.startsWith("docs/rfcs-audit/")),
);
if (touchesGoverned && !RFC_ID_RE.test(subject) && !RFC_ID_RE.test(body)) {
findings.push({
ruleId: "COMMIT-04",
severity: "warning",
message: `${hash.slice(0, 8)}: touches packages/os/** or docs/rfcs/** (excluding docs/rfcs-audit/**) but neither subject nor body references an RFC id.`,
fixHint: "Reference the governing RFC id (e.g. rfc-0265) in the subject or body.",
data: { hash, subject },
});
}
}
return findings;
}
/** Parse `git log --format=%H%x1f%P%x1f%s%x1f%b%x1e` output into commit records. */
export function parseGitLogOutput(raw: string): CommitRecord[] {
return raw
.split(RECORD_SEP)
.map((record) => record.replace(/^\n/, "").trim())
.filter((record) => record.length > 0)
.map((record) => {
const [hash = "", parentsRaw = "", subject = "", body = ""] = record.split(FIELD_SEP);
return {
hash,
parents: parentsRaw.split(" ").filter(Boolean),
subject,
body: body.trim(),
};
});
}
/** Parse `git log --name-only --format=%H` output into hash -> changed files. */
export function parseNameOnlyOutput(raw: string, marker: string): Map {
const result = new Map();
const blocks = raw
.split(marker)
.map((b) => b.trim())
.filter(Boolean);
for (const block of blocks) {
const lines = block.split("\n").map((l) => l.trim());
const hash = lines[0] ?? "";
if (!hash) continue;
const files = lines.slice(1).filter(Boolean);
result.set(hash, files);
}
return result;
}
async function resolveDefaultRange(workspaceRoot: string, io: WorkspaceIO): Promise {
try {
const result = await io.exec("git", ["rev-parse", "--verify", "origin/main"], {
cwd: workspaceRoot,
});
return result.exitCode === 0 ? "origin/main..HEAD" : null;
} catch {
return null; // no origin/main (e.g. fresh repo, no remote) — nothing to lint.
}
}
export async function runCommitMessageLint(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot } = context;
const range =
(input.flags.range as string | undefined) ??
(await resolveDefaultRange(workspaceRoot, context.io));
if (!range) {
return {
data: {
command: "commit.message.lint",
status: "pass",
diagnostics: [],
summary: { error: 0, warning: 0, info: 0 },
},
exitCode: 0,
summary: "[commit.message.lint] no range resolvable (no origin/main) — nothing to lint",
};
}
let logRaw: string;
const logResult = await context.io.exec(
"git",
["log", range, `--format=${RECORD_SEP}%H${FIELD_SEP}%P${FIELD_SEP}%s${FIELD_SEP}%b`],
{ cwd: workspaceRoot },
);
if (logResult.exitCode === 0) {
logRaw = logResult.stdout;
} else {
return {
data: {
command: "commit.message.lint",
status: "pass",
diagnostics: [],
summary: { error: 0, warning: 0, info: 0 },
},
exitCode: 0,
summary: `[commit.message.lint] range "${range}" resolved to no commits — nothing to lint`,
};
}
const commits = parseGitLogOutput(logRaw);
if (commits.length === 0) {
return {
data: {
command: "commit.message.lint",
status: "pass",
diagnostics: [],
summary: { error: 0, warning: 0, info: 0 },
},
exitCode: 0,
summary: `[commit.message.lint] range "${range}" has no commits — nothing to lint`,
};
}
const NAME_ONLY_MARKER = "\x02COMMIT:";
const nameOnlyResult = await context.io.exec(
"git",
["log", range, "--name-only", `--format=${NAME_ONLY_MARKER}%H`],
{ cwd: workspaceRoot },
);
const nameOnlyRaw = nameOnlyResult.exitCode === 0 ? nameOnlyResult.stdout : "";
const changedFilesByHash = parseNameOnlyOutput(nameOnlyRaw, NAME_ONLY_MARKER);
const diagnostics: Diagnostic[] = [];
for (const commit of commits) {
diagnostics.push(...lintCommitSubject(commit, changedFilesByHash.get(commit.hash)));
}
const summary = {
error: diagnostics.filter((d) => d.severity === "error").length,
warning: diagnostics.filter((d) => d.severity === "warning").length,
info: 0,
};
const status: CheckResult["status"] =
summary.error > 0 ? "fail" : summary.warning > 0 ? "warn" : "pass";
return {
data: { command: "commit.message.lint", status, diagnostics, summary },
exitCode: summary.error > 0 ? 1 : 0,
summary: `[commit.message.lint] ${commits.length} commit(s) in range, ${summary.error} error${summary.error === 1 ? "" : "s"}, ${summary.warning} warning${summary.warning === 1 ? "" : "s"}`,
};
}