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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user