ab6af144b7
---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)
323 lines
8.8 KiB
TypeScript
323 lines
8.8 KiB
TypeScript
import { execSync } from "node:child_process";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import * as fs from "node:fs";
|
|
import { GitContext } from "../core/git-context.js";
|
|
|
|
function createTempRepo(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-test-"));
|
|
execSync("git init", { cwd: dir, stdio: "pipe" });
|
|
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "pipe" });
|
|
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
|
|
return dir;
|
|
}
|
|
|
|
function commit(repoDir: string, message: string): void {
|
|
const filePath = path.join(repoDir, `file-${Date.now()}.txt`);
|
|
fs.writeFileSync(filePath, "test", "utf-8");
|
|
execSync(`git add "${filePath}"`, { cwd: repoDir, stdio: "pipe" });
|
|
execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: repoDir, stdio: "pipe" });
|
|
}
|
|
|
|
function cleanup(dir: string): void {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
describe("GitContext", () => {
|
|
let repoDir: string;
|
|
|
|
beforeEach(() => {
|
|
repoDir = createTempRepo();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(repoDir);
|
|
});
|
|
|
|
describe("isGitRepo", () => {
|
|
it("returns true for a git repo", () => {
|
|
const ctx = new GitContext(repoDir);
|
|
expect(ctx.isGitRepo()).toBe(true);
|
|
});
|
|
|
|
it("returns false for non-git directory", () => {
|
|
const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), "ci-nongit-"));
|
|
const ctx = new GitContext(nonGit);
|
|
expect(ctx.isGitRepo()).toBe(false);
|
|
cleanup(nonGit);
|
|
});
|
|
});
|
|
|
|
describe("getCurrentBranch", () => {
|
|
it("returns current branch name", () => {
|
|
const ctx = new GitContext(repoDir);
|
|
commit(repoDir, "initial");
|
|
expect(ctx.getCurrentBranch()).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("projectSlug", () => {
|
|
it("defaults to undefined", () => {
|
|
const ctx = new GitContext(repoDir);
|
|
expect(ctx.getProjectSlug()).toBeUndefined();
|
|
});
|
|
|
|
it("accepts project slug in constructor", () => {
|
|
const ctx = new GitContext(repoDir, "task-api");
|
|
expect(ctx.getProjectSlug()).toBe("task-api");
|
|
});
|
|
|
|
it("setProjectSlug updates slug", () => {
|
|
const ctx = new GitContext(repoDir);
|
|
ctx.setProjectSlug("auth-svc");
|
|
expect(ctx.getProjectSlug()).toBe("auth-svc");
|
|
});
|
|
});
|
|
|
|
describe("getRecentCommits", () => {
|
|
it("returns parsed commits with ci blocks", () => {
|
|
commit(repoDir, `docs(init): initialize project
|
|
|
|
---ci---
|
|
phase: 0
|
|
milestone: v1.0
|
|
status: specify
|
|
---/ci---
|
|
|
|
Initial commit`);
|
|
|
|
const ctx = new GitContext(repoDir);
|
|
const commits = ctx.getRecentCommits(5);
|
|
|
|
expect(commits.length).toBeGreaterThanOrEqual(1);
|
|
const ciCommit = commits.find((c) => c.ci !== null);
|
|
expect(ciCommit).toBeDefined();
|
|
expect(ciCommit!.ci!.phase).toBe(0);
|
|
expect(ciCommit!.ci!.milestone).toBe("v1.0");
|
|
});
|
|
|
|
it("returns commits without ci blocks as null ci", () => {
|
|
commit(repoDir, "feat: some feature");
|
|
|
|
const ctx = new GitContext(repoDir);
|
|
const commits = ctx.getRecentCommits(5);
|
|
|
|
expect(commits.length).toBeGreaterThanOrEqual(1);
|
|
expect(commits[0].ci).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("reconstructState", () => {
|
|
it("reconstructs state from latest ci commit", () => {
|
|
commit(repoDir, `docs(init): initialize project
|
|
|
|
---ci---
|
|
phase: 1
|
|
milestone: v1.0
|
|
status: execute
|
|
---/ci---`);
|
|
|
|
const ctx = new GitContext(repoDir);
|
|
const state = ctx.reconstructState();
|
|
|
|
expect(state.currentPhase).toBe(1);
|
|
expect(state.currentMilestone).toBe("v1.0");
|
|
expect(state.currentStage).toBe("execute");
|
|
});
|
|
|
|
it("returns default state without ci commits", () => {
|
|
commit(repoDir, "feat: some regular feature");
|
|
|
|
const ctx = new GitContext(repoDir);
|
|
const state = ctx.reconstructState();
|
|
|
|
expect(state.currentPhase).toBe(0);
|
|
expect(state.currentStage).toBe("specify");
|
|
});
|
|
});
|
|
|
|
describe("getDecisionsFromCommits", () => {
|
|
it("extracts decisions from parsed commits", () => {
|
|
commit(repoDir, `feat(P01): add auth
|
|
|
|
---ci---
|
|
phase: 1
|
|
milestone: v1.0
|
|
status: execute
|
|
decisions:
|
|
- id: D-001
|
|
decision: Use bcrypt
|
|
rationale: Industry standard
|
|
confidence: 0.90
|
|
alternatives: [argon2]
|
|
---/ci---`);
|
|
|
|
const ctx = new GitContext(repoDir);
|
|
const commits = ctx.getRecentCommits(5);
|
|
const decisions = ctx.getDecisionsFromCommits(commits);
|
|
|
|
expect(decisions).toHaveLength(1);
|
|
expect(decisions[0].id).toBe("D-001");
|
|
expect(decisions[0].decision).toBe("Use bcrypt");
|
|
});
|
|
});
|
|
|
|
describe("getLessons", () => {
|
|
it("extracts lessons from commits", () => {
|
|
commit(repoDir, `docs(P01): complete phase
|
|
|
|
---ci---
|
|
phase: 1
|
|
milestone: v1.0
|
|
status: complete
|
|
lessons:
|
|
- Never use sync bcrypt
|
|
- Always check JWT expiry first
|
|
---/ci---`);
|
|
|
|
const ctx = new GitContext(repoDir);
|
|
const commits = ctx.getRecentCommits(10);
|
|
const lessons: string[] = [];
|
|
for (const commit of commits) {
|
|
if (commit.ci?.lessons) lessons.push(...commit.ci.lessons);
|
|
}
|
|
|
|
expect(lessons).toContain("Never use sync bcrypt");
|
|
expect(lessons).toContain("Always check JWT expiry first");
|
|
});
|
|
});
|
|
|
|
describe("getBranches", () => {
|
|
it("lists branches with type info", () => {
|
|
commit(repoDir, "initial");
|
|
execSync("git checkout -b phase/01-auth", { cwd: repoDir, stdio: "pipe" });
|
|
commit(repoDir, "feat: auth work");
|
|
execSync("git checkout -b milestone/v1.0-mvp", { cwd: repoDir, stdio: "pipe" });
|
|
commit(repoDir, "feat: milestone work");
|
|
|
|
const ctx = new GitContext(repoDir);
|
|
const branches = ctx.getBranches();
|
|
|
|
const phaseBranches = branches.filter((b) => b.type === "phase");
|
|
const milestoneBranches = branches.filter((b) => b.type === "milestone");
|
|
|
|
expect(phaseBranches.length).toBeGreaterThanOrEqual(1);
|
|
expect(milestoneBranches.length).toBeGreaterThanOrEqual(1);
|
|
expect(phaseBranches[0].phaseNumber).toBe(1);
|
|
});
|
|
|
|
it("strips project prefix when projectSlug is set", () => {
|
|
commit(repoDir, "initial");
|
|
execSync("git checkout -b task-api/phase/01-auth", { cwd: repoDir, stdio: "pipe" });
|
|
commit(repoDir, "feat: auth work");
|
|
|
|
const ctx = new GitContext(repoDir, "task-api");
|
|
const branches = ctx.getBranches();
|
|
|
|
const phaseBranches = branches.filter((b) => b.type === "phase");
|
|
expect(phaseBranches.length).toBeGreaterThanOrEqual(1);
|
|
expect(phaseBranches[0].phaseNumber).toBe(1);
|
|
expect(phaseBranches[0].name).toBe("task-api/phase/01-auth");
|
|
});
|
|
});
|
|
|
|
describe("detectProjectFromCommit", () => {
|
|
it("detects project from ci block project field", () => {
|
|
commit(repoDir, `feat(P01): task work
|
|
|
|
---ci---
|
|
phase: 1
|
|
milestone: v1.0
|
|
project: task-api
|
|
status: execute
|
|
---/ci---`);
|
|
|
|
const ctx = new GitContext(repoDir);
|
|
expect(ctx.detectProjectFromCommit()).toBe("task-api");
|
|
});
|
|
|
|
it("detects project from branch prefix", () => {
|
|
commit(repoDir, "initial");
|
|
execSync("git checkout -b auth-svc/phase/01-auth", { cwd: repoDir, stdio: "pipe" });
|
|
commit(repoDir, "feat: auth work");
|
|
|
|
const ctx = new GitContext(repoDir);
|
|
expect(ctx.detectProjectFromCommit()).toBe("auth-svc");
|
|
});
|
|
|
|
it("returns null when no project detected", () => {
|
|
commit(repoDir, "feat: some work");
|
|
const ctx = new GitContext(repoDir);
|
|
expect(ctx.detectProjectFromCommit()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("isNfrMilestone", () => {
|
|
it("returns true when no feat commits exist", () => {
|
|
commit(repoDir, `chore(P01): cleanup
|
|
|
|
---ci---
|
|
phase: 1
|
|
milestone: v0.1.1
|
|
status: execute
|
|
---/ci---`);
|
|
|
|
const ctx = new GitContext(repoDir);
|
|
expect(ctx.isNfrMilestone()).toBe(true);
|
|
});
|
|
|
|
it("returns false 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.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");
|
|
});
|
|
});
|
|
}); |