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
+59 -1
View File
@@ -263,7 +263,7 @@ describe("CiFiles", () => {
overview: "NFR-only",
phases: [
{ number: 1, name: "test-coverage", description: "Add tests", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
{ number: 2, name: "refactor-api", description: "Refactor", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] },
{ number: 2, name: "perf-tune", description: "Tune perf", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] },
],
};
ciFiles.writeRoadmapMd(roadmap);
@@ -289,6 +289,64 @@ describe("CiFiles", () => {
});
});
describe("getMilestoneType", () => {
it("returns nfr when no roadmap exists", () => {
const ciFiles = new CiFiles(dir);
expect(ciFiles.getMilestoneType()).toBe("nfr");
});
it("returns nfr when phases are all NFR types", () => {
const ciFiles = new CiFiles(dir, "nfr-proj2");
ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "nfr-proj2", name: "NFR Project 2", default: true }],
active_project: "nfr-proj2",
}));
const roadmap: RoadmapMd = {
overview: "NFR-only",
phases: [
{ number: 1, name: "fix-bug", description: "Fix bug", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
],
};
ciFiles.writeRoadmapMd(roadmap);
expect(ciFiles.getMilestoneType()).toBe("nfr");
});
it("returns feature when phases include feat work", () => {
const ciFiles = new CiFiles(dir, "feat-proj2");
ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "feat-proj2", name: "Feature Project 2", default: true }],
active_project: "feat-proj2",
}));
const roadmap: RoadmapMd = {
overview: "feature",
phases: [
{ number: 1, name: "auth-flow", description: "Auth feature", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
],
};
ciFiles.writeRoadmapMd(roadmap);
expect(ciFiles.getMilestoneType()).toBe("feature");
});
it("returns schema-breaking when phases include refactor/rewrite/migrate", () => {
const ciFiles = new CiFiles(dir, "schema-proj");
ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "schema-proj", name: "Schema Project", default: true }],
active_project: "schema-proj",
}));
const roadmap: RoadmapMd = {
overview: "schema-breaking",
phases: [
{ number: 1, name: "refactor-core", description: "Refactor core", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
],
};
ciFiles.writeRoadmapMd(roadmap);
expect(ciFiles.getMilestoneType()).toBe("schema-breaking");
});
});
describe("multi-project file paths", () => {
it("writes PROJECT.md to project subdirectory when slug is set", () => {
const ciFiles = new CiFiles(dir, "my-app");
+19 -6
View File
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js";
import { PipelineStage } from "../types/pipeline.js";
import { MilestoneType } from "../types/config.js";
const CI_DIR = ".ci";
@@ -467,19 +468,31 @@ export class CiFiles {
this.writeRoadmapMd(roadmap);
}
isNfrMilestone(): boolean {
getMilestoneType(): MilestoneType {
const roadmap = this.readRoadmapMd();
if (!roadmap) return true;
if (!roadmap) return "nfr";
const nfrTypes: string[] = ["fix", "chore", "docs", "perf", "refactor", "test"];
const schemaBreakKeywords: string[] = ["refactor", "rewrite", "rearchitecture", "migrate", "restructure"];
let hasFeature = false;
let hasSchemaBreak = false;
for (const phase of roadmap.phases) {
if (phase.status === "in_progress" || phase.status === "not_started") {
if (phase.status === "in_progress" || phase.status === "not_started" || phase.status === "complete") {
const phaseName = phase.name.toLowerCase();
const hasFeature = !nfrTypes.some((t) => phaseName.includes(t)) && !phaseName.includes("bug") && !phaseName.includes("tune") && !phaseName.includes("refresh");
if (hasFeature) return false;
const isNfr = nfrTypes.some((t) => phaseName.includes(t)) || phaseName.includes("bug") || phaseName.includes("tune") || phaseName.includes("refresh");
if (!isNfr) hasFeature = true;
if (schemaBreakKeywords.some((k) => phaseName.includes(k))) hasSchemaBreak = true;
}
}
return true;
if (hasSchemaBreak) return "schema-breaking";
if (hasFeature) return "feature";
return "nfr";
}
isNfrMilestone(): boolean {
return this.getMilestoneType() === "nfr";
}
private parseProjectMd(content: string): ProjectMd {
+6 -5
View File
@@ -81,17 +81,18 @@ export class ErrorRecovery {
this.git(`branch -D ${phaseBranch}`);
}
const tag = `v0.5.${phase}`;
const tags = this.git("tag -l").split("\n").map((t) => t.trim());
if (tags.includes(tag)) {
this.git(`tag -d ${tag}`);
const tags = this.git("tag -l").split("\n").map((t) => t.trim()).filter(Boolean);
const phaseTagPattern = new RegExp(`^v\\d+\\.\\d+\\.${phase}$`);
const matchingTag = tags.find((t) => phaseTagPattern.test(t));
if (matchingTag) {
this.git(`tag -d ${matchingTag}`);
}
return {
recovered: true,
strategy: "rollback",
attempts: 1,
message: `Rolled back phase ${phase}: ${reason}. Branch ${branchExists ? `${phaseBranch} deleted` : "not found"}. Tag ${tags.includes(tag) ? `${tag} deleted` : "not found"}.`,
message: `Rolled back phase ${phase}: ${reason}. Branch ${branchExists ? `${phaseBranch} deleted` : "not found"}. Tag ${matchingTag ? `${matchingTag} deleted` : "not found"}.`,
};
} catch (err) {
return {
+82
View File
@@ -137,4 +137,86 @@ describe("GitBranch", () => {
expect(result.name).toMatch(/^phase\/01-/);
});
});
describe("validateMergeOrder", () => {
it("rejects phase → main when milestone branch exists", () => {
const gitBranch = new GitBranch(repoDir);
gitBranch.createMilestoneBranch("v0.5", "baseline");
gitBranch.createPhaseBranch(1, "auth");
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
const result = gitBranch.validateMergeOrder("phase/01-auth", "main");
expect(result.valid).toBe(false);
expect(result.reason).toContain("milestone");
});
it("allows phase → milestone branch", () => {
const gitBranch = new GitBranch(repoDir);
gitBranch.createMilestoneBranch("v0.5", "baseline");
gitBranch.createPhaseBranch(1, "auth");
execSync(`git checkout milestone/v0.5-baseline`, { cwd: repoDir, stdio: "pipe" });
const result = gitBranch.validateMergeOrder("phase/01-auth", "milestone/v0.5-baseline");
expect(result.valid).toBe(true);
});
it("allows phase → main when no milestone branch exists", () => {
const gitBranch = new GitBranch(repoDir);
gitBranch.createPhaseBranch(1, "auth");
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
const result = gitBranch.validateMergeOrder("phase/01-auth", "main");
expect(result.valid).toBe(true);
});
it("allows hotfix → main", () => {
const gitBranch = new GitBranch(repoDir);
gitBranch.createMilestoneBranch("v0.5", "baseline");
execSync(`git checkout -b hotfix/critical-fix main`, { cwd: repoDir, stdio: "pipe" });
fs.writeFileSync(path.join(repoDir, "fix.txt"), "fix");
execSync(`git add . && git commit -m "hotfix: critical fix"`, { cwd: repoDir, stdio: "pipe" });
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
const result = gitBranch.validateMergeOrder("hotfix/critical-fix", "main");
expect(result.valid).toBe(true);
});
});
describe("computeMilestoneTag", () => {
it("computes next minor for feature milestone", () => {
execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" });
execSync(`git tag -a v0.5.2 -m "v0.5.2"`, { cwd: repoDir, stdio: "pipe" });
const gitBranch = new GitBranch(repoDir);
const tag = gitBranch.computeMilestoneTag("feature");
expect(tag).toBe("v0.6.0");
});
it("computes next major for schema-breaking milestone", () => {
execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" });
const gitBranch = new GitBranch(repoDir);
const tag = gitBranch.computeMilestoneTag("schema-breaking");
expect(tag).toBe("v1.0.0");
});
it("starts from v0.1.0 when no tags exist", () => {
const gitBranch = new GitBranch(repoDir);
const tag = gitBranch.computeMilestoneTag("feature");
expect(tag).toBe("v0.1.0");
});
});
describe("mergeMilestoneBranch", () => {
it("rejects merge when unmerged phase branches remain", () => {
const gitBranch = new GitBranch(repoDir);
gitBranch.createMilestoneBranch("v0.5", "baseline");
gitBranch.createPhaseBranch(1, "auth");
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
const result = gitBranch.mergeMilestoneBranch("milestone/v0.5-baseline", "main", true);
expect(result.success).toBe(false);
expect(result.message).toContain("unmerged");
});
});
});
+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(
+41
View File
@@ -279,4 +279,45 @@ status: execute
expect(ctx.isNfrMilestone()).toBe(false);
});
});
describe("getMilestoneType", () => {
it("returns nfr when only NFR commits exist", () => {
commit(repoDir, `chore(P01): cleanup
---ci---
phase: 1
milestone: v0.1.1
status: execute
---/ci---`);
const ctx = new GitContext(repoDir);
expect(ctx.getMilestoneType()).toBe("nfr");
});
it("returns feature when feat commits exist", () => {
commit(repoDir, `feat(P01): add feature
---ci---
phase: 1
milestone: v1.0
status: execute
---/ci---`);
const ctx = new GitContext(repoDir);
expect(ctx.getMilestoneType()).toBe("feature");
});
it("returns schema-breaking when refactor commits exist", () => {
commit(repoDir, `refactor(P01): rewrite core
---ci---
phase: 1
milestone: v0.5
status: execute
---/ci---`);
const ctx = new GitContext(repoDir);
expect(ctx.getMilestoneType()).toBe("schema-breaking");
});
});
});
+14 -5
View File
@@ -7,6 +7,8 @@ import {
import { parseCommitMessage } from "./commit-parser.js";
import { PipelineStage } from "../types/pipeline.js";
import { MilestoneType } from "../types/config.js";
export interface ProjectState {
currentPhase: number;
currentMilestone: string;
@@ -342,13 +344,20 @@ export class GitContext {
return null;
}
isNfrMilestone(): boolean {
getMilestoneType(): MilestoneType {
const commits = this.getRecentCommits(100);
let hasAnyCiCommit = false;
for (const commit of commits) {
if (commit.type === "feat" && commit.ci) {
return false;
}
if (!commit.ci) continue;
hasAnyCiCommit = true;
if (commit.type === "feat") return "feature";
if (commit.type === "refactor" || commit.scope === "init") return "schema-breaking";
}
return true;
if (!hasAnyCiCommit) return "nfr";
return "nfr";
}
isNfrMilestone(): boolean {
return this.getMilestoneType() === "nfr";
}
}