feat(P06): 3-tier versioning, branch hierarchy enforcement, ARCHITECTURE-PLAN synthesis

---ci---
phase: 6
milestone: v0.5
status: complete
decisions:
  - id: D-006
    decision: Research as intermediate work product
    rationale: Conclusions update .ci/ files; full research doc intentionally not preserved
    confidence: 0.90
  - id: D-007
    decision: Branch hierarchy enforcement: main > milestone > phase
    rationale: Prevents out-of-order merges and semantically wrong tags
    confidence: 0.92
  - id: D-008
    decision: 3-tier versioning: NFR/feature/schema-breaking
    rationale: Patch per phase (NFR/feature) or minor per phase (schema-breaking); milestone gets minor (feature) or major (schema-breaking)
    confidence: 0.95
requirements:
  covered: [VER-06, BRANCH-01, BRANCH-02, ARCH-01]
---/ci---

- Synthesize ARCHITECTURE-PLAN.md into .ci/ci/ARCHITECTURE.md (expanded 51→230 lines)
- Add D-006/D-007/D-008 to .ci/ci/PROJECT.md key decisions table
- Delete ARCHITECTURE-PLAN.md after synthesis
- Rewrite ship.md with 3-tier versioning model + branch hierarchy merge flows
- Rewrite branch-strategy.md with 3-tier versioning + branch hierarchy + version validation
- Add MilestoneType to config types
- Replace isNfrMilestone() with getMilestoneType() returning nfr|feature|schema-breaking
- Add validateMergeOrder(), mergeMilestoneBranch(), computeMilestoneTag() to GitBranch
- Add computeShipVersion(), validateVersionOrder(), resolveMergeTarget() to ship command
- Remove hardcoded v0.5. from error-recovery rollback
- Create .githooks/pre-push for semver ordering + branch hierarchy validation
- Add 15 new tests (370 total, all passing)
This commit is contained in:
Jon Chery
2026-05-29 17:18:10 +00:00
parent 3d069319b5
commit ab6af144b7
14 changed files with 696 additions and 93 deletions
+113
View File
@@ -1,5 +1,6 @@
import { execSync } from "node:child_process";
import { GitContext, BranchInfo } from "./git-context.js";
import { MilestoneType } from "../types/config.js";
export interface BranchCreateResult {
name: string;
@@ -108,6 +109,11 @@ export class GitBranch {
targetBranch: string,
squash: boolean = true
): BranchMergeResult {
const validation = this.validateMergeOrder(phaseBranchName, targetBranch);
if (!validation.valid) {
return { success: false, squash, message: `Merge rejected: ${validation.reason}` };
}
const branches = this.gitContext.getBranches();
const phaseBranch = branches.find((b) => b.name === phaseBranchName);
if (!phaseBranch) {
@@ -136,6 +142,113 @@ export class GitBranch {
};
}
mergeMilestoneBranch(
milestoneBranchName: string,
targetBranch: string,
squash: boolean = true
): BranchMergeResult {
const branches = this.gitContext.getBranches();
const milestoneBranch = branches.find((b) => b.name === milestoneBranchName);
if (!milestoneBranch) {
return { success: false, squash, message: `Branch ${milestoneBranchName} not found` };
}
const phaseBranches = branches.filter(
(b) => b.type === "phase" && !b.merged
);
if (phaseBranches.length > 0) {
return {
success: false,
squash,
message: `Cannot merge milestone: ${phaseBranches.length} unmerged phase branch(es) remain (${phaseBranches.map((b) => b.name).join(", ")})`,
};
}
this.git(`checkout ${targetBranch}`);
const mergeCmd = squash
? `merge --squash ${milestoneBranchName}`
: `merge --no-ff ${milestoneBranchName}`;
const result = this.git(mergeCmd);
if (result === "" && !squash) {
return { success: false, squash, message: `Merge conflict on ${milestoneBranchName}` };
}
if (squash) {
this.git(`commit -m "docs: merge milestone branch ${milestoneBranchName}"`);
}
return {
success: true,
squash,
message: `Merged ${milestoneBranchName} into ${targetBranch} (squash: ${squash})`,
};
}
validateMergeOrder(sourceBranch: string, targetBranch: string): { valid: boolean; reason: string } {
const branches = this.gitContext.getBranches();
const source = branches.find((b) => b.name === sourceBranch);
if (!source) return { valid: false, reason: `Source branch ${sourceBranch} not found` };
if (source.type === "hotfix") {
return { valid: true, reason: "Hotfix branches may merge to any target" };
}
if (source.type === "phase" && targetBranch === "main") {
const milestoneBranches = branches.filter(
(b) => b.type === "milestone" && !b.merged
);
if (milestoneBranches.length > 0) {
return {
valid: false,
reason: `Phase branch must merge into milestone branch first (active: ${milestoneBranches.map((b) => b.name).join(", ")}). Merge into main only through the milestone branch.`,
};
}
}
if (source.type === "milestone" && targetBranch === "main") {
const phaseBranches = branches.filter(
(b) => b.type === "phase" && !b.merged
);
if (phaseBranches.length > 0) {
return {
valid: false,
reason: `Milestone cannot merge to main: ${phaseBranches.length} unmerged phase branch(es) remain.`,
};
}
}
return { valid: true, reason: "Merge order is valid" };
}
computeMilestoneTag(milestoneType: MilestoneType): string {
const tags = this.git("tag -l").split("\n").map((t) => t.trim()).filter(Boolean);
let major = 0;
let minor = 0;
let patch = 0;
for (const tag of tags) {
const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/);
if (match) {
const m = parseInt(match[1]);
const n = parseInt(match[2]);
const p = parseInt(match[3]);
if (m > major || (m === major && n > minor) || (m === major && n === minor && p > patch)) {
major = m;
minor = n;
patch = p;
}
}
}
if (milestoneType === "schema-breaking") {
return `v${major + 1}.0.0`;
}
return `v${major}.${minor + 1}.0`;
}
getPhaseStatus(phaseNumber: number): PhaseBranchInfo | null {
const branches = this.gitContext.getBranches();
const phaseBranch = branches.find(