Files
ci/src/core/ci-files.ts
T
Jon Chery ab6af144b7 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)
2026-05-29 17:18:10 +00:00

750 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
export interface ProjectMd {
name: string;
coreValue: string;
requirements: {
validated: string[];
active: string[];
outOfScope: string[];
};
constraints: string[];
context: string;
keyDecisions: Array<{
decision: string;
rationale: string;
outcome: string;
}>;
}
export interface RoadmapMd {
overview: string;
phases: Array<{
number: number;
name: string;
description: string;
status: "not_started" | "in_progress" | "complete" | "deferred";
dependsOn: number[];
requirements: string[];
successCriteria: string[];
}>;
}
export interface RequirementsMd {
v1: Array<{
category: string;
items: Array<{ id: string; description: string }>;
}>;
v2: Array<{
category: string;
items: Array<{ id: string; description: string }>;
}>;
outOfScope: Array<{ feature: string; reason: string }>;
traceability: Array<{
requirement: string;
phase: number;
status: "pending" | "in_progress" | "complete" | "blocked";
}>;
}
export interface ArchitectureMd {
overview: string;
components: Array<{
name: string;
description: string;
boundaries: string;
dependsOn: string[];
}>;
dataFlow: string;
buildOrder: string[];
}
export interface ProjectEntry {
slug: string;
name: string;
default?: boolean;
}
export class CiFiles {
private projectPath: string;
private projectSlug: string;
constructor(projectPath: string, projectSlug?: string) {
this.projectPath = projectPath;
this.projectSlug = projectSlug || "";
}
private get ciDir(): string {
return path.join(this.projectPath, CI_DIR);
}
private get projectDir(): string {
if (this.projectSlug) {
return path.join(this.ciDir, this.projectSlug);
}
return this.ciDir;
}
setProjectSlug(slug: string): void {
this.projectSlug = slug;
}
getProjectSlug(): string {
return this.projectSlug;
}
ensureCIDir(): void {
ensureDir(this.ciDir);
}
ensureProjectDir(): void {
this.ensureCIDir();
if (this.projectSlug) {
ensureDir(this.projectDir);
}
}
isInitialized(): boolean {
return fileExists(path.join(this.ciDir, "config.json"));
}
isMultiProject(): boolean {
if (!this.isInitialized()) return false;
const config = this.readConfigJson();
const projects = config?.projects;
return Array.isArray(projects) && (projects as unknown[]).length > 0;
}
listProjects(): ProjectEntry[] {
if (!this.isInitialized()) return [];
const config = this.readConfigJson();
if (Array.isArray(config?.projects) && config.projects.length > 0) {
return config.projects;
}
const subdirs = this.getProjectSubdirectories();
if (subdirs.length > 0) {
return subdirs.map((slug) => {
const projMd = this.readProjectMdForSlug(slug);
return {
slug,
name: projMd?.name || slug,
default: subdirs.length === 1,
};
});
}
return [{ slug: "default", name: "Default Project", default: true }];
}
getActiveProject(): string {
if (!this.isInitialized()) return "";
const config = this.readConfigJson();
if (config && typeof config.active_project === "string") return config.active_project;
const projects = this.listProjects();
const defaultProject = projects.find((p) => p.default);
if (defaultProject) return defaultProject.slug;
return projects.length > 0 ? projects[0].slug : "";
}
setActiveProject(slug: string): void {
this.ensureCIDir();
const config = this.readConfigJson() || {};
config.active_project = slug;
this.writeConfigJson(config);
}
addProject(slug: string, name: string, isDefault: boolean = false): void {
this.ensureCIDir();
const config = this.readConfigJson() || {};
if (!Array.isArray(config.projects)) {
config.projects = [];
}
if ((config.projects as unknown[]).some((p: unknown) => (p as ProjectEntry).slug === slug)) return;
(config.projects as ProjectEntry[]).push({ slug, name, default: isDefault });
if (isDefault || (config.projects as unknown[]).length === 1) {
config.active_project = slug;
}
this.writeConfigJson(config);
ensureDir(path.join(this.ciDir, slug));
}
needsMigration(): boolean {
if (!this.isInitialized()) return false;
if (this.isMultiProject()) return false;
const hasFlatFiles = fileExists(path.join(this.ciDir, "PROJECT.md"));
const hasSubdirs = this.getProjectSubdirectories().length > 0;
return hasFlatFiles && !hasSubdirs;
}
migrateFlatToProject(slug: string): void {
if (!this.needsMigration()) return;
this.ensureCIDir();
const projectDir = path.join(this.ciDir, slug);
ensureDir(projectDir);
const filesToMove = ["PROJECT.md", "ARCHITECTURE.md", "ROADMAP.md", "REQUIREMENTS.md"];
for (const file of filesToMove) {
const src = path.join(this.ciDir, file);
const dest = path.join(projectDir, file);
if (fileExists(src) && !fileExists(dest)) {
const content = readFile(src);
if (content) {
writeFile(dest, content);
}
}
}
const config = this.readConfigJson() || {};
config.projects = [{ slug, name: slug, default: true }];
config.active_project = slug;
this.writeConfigJson(config);
}
private getProjectSubdirectories(): string[] {
if (!fs.existsSync(this.ciDir)) return [];
try {
return fs.readdirSync(this.ciDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.filter((d) => {
const projectFile = path.join(this.ciDir, d.name, "PROJECT.md");
return fileExists(projectFile);
})
.map((d) => d.name);
} catch {
return [];
}
}
private readConfigJson(): Record<string, unknown> | null {
const content = readFile(path.join(this.ciDir, "config.json"));
if (!content) return null;
try {
return JSON.parse(content) as Record<string, unknown>;
} catch {
return null;
}
}
private writeConfigJson(config: Record<string, unknown>): void {
writeFile(path.join(this.ciDir, "config.json"), JSON.stringify(config, null, 2));
}
private readProjectMdForSlug(slug: string): ProjectMd | null {
const content = readFile(path.join(this.ciDir, slug, "PROJECT.md"));
if (!content) return null;
return this.parseProjectMd(content);
}
readProjectMd(): ProjectMd | null {
const content = readFile(path.join(this.projectDir, "PROJECT.md"));
if (!content) return null;
return this.parseProjectMd(content);
}
writeProjectMd(project: ProjectMd, reason: string): void {
this.ensureProjectDir();
const lines: string[] = [
`# ${project.name}`,
"",
"## What This Is",
"",
project.coreValue,
"",
"## Requirements",
"",
"### Validated",
"",
...project.requirements.validated.map((r) => `- ✓ ${r}`),
"",
"### Active",
"",
...project.requirements.active.map((r) => `- [ ] ${r}`),
"",
"### Out of Scope",
"",
...project.requirements.outOfScope.map((r) => `- ${r}`),
"",
"## Context",
"",
project.context,
"",
"## Constraints",
"",
...project.constraints.map((c) => `- ${c}`),
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
...project.keyDecisions.map(
(d) => `| ${d.decision} | ${d.rationale} | ${d.outcome} |`
),
"",
];
writeFile(path.join(this.projectDir, "PROJECT.md"), lines.join("\n"));
}
readRoadmapMd(): RoadmapMd | null {
const content = readFile(path.join(this.projectDir, "ROADMAP.md"));
if (!content) return null;
return this.parseRoadmapMd(content);
}
writeRoadmapMd(roadmap: RoadmapMd): void {
this.ensureProjectDir();
const lines: string[] = [
"# Roadmap",
"",
"## Overview",
"",
roadmap.overview,
"",
"## Phases",
"",
...roadmap.phases.map(
(p) => `- [${p.status === "complete" ? "x" : " "}] **Phase ${p.number}: ${p.name}** - ${p.description}`
),
"",
"## Phase Details",
"",
];
for (const phase of roadmap.phases) {
lines.push(`### Phase ${phase.number}: ${phase.name}`);
lines.push(`**Goal**.: ${phase.description}`);
lines.push(`**Depends on**: ${phase.dependsOn.length > 0 ? phase.dependsOn.map((d) => `Phase ${d}`).join(", ") : "Nothing"}`);
lines.push(`**Requirements**: ${phase.requirements.length > 0 ? phase.requirements.join(", ") : "None"}`);
lines.push("**Success Criteria**:");
for (const sc of phase.successCriteria) {
lines.push(`1. ${sc}`);
}
lines.push(`**Status**: ${phase.status}`);
lines.push("");
}
writeFile(path.join(this.projectDir, "ROADMAP.md"), lines.join("\n"));
}
readRequirementsMd(): RequirementsMd | null {
const content = readFile(path.join(this.projectDir, "REQUIREMENTS.md"));
if (!content) return null;
return this.parseRequirementsMd(content);
}
writeRequirementsMd(requirements: RequirementsMd): void {
this.ensureProjectDir();
const lines: string[] = [
"# Requirements",
"",
"## v1 Requirements",
"",
];
for (const cat of requirements.v1) {
lines.push(`### ${cat.category}`);
lines.push("");
for (const item of cat.items) {
lines.push(`- [ ] **${item.id}**: ${item.description}`);
}
lines.push("");
}
lines.push("## v2 Requirements");
lines.push("");
for (const cat of requirements.v2) {
lines.push(`### ${cat.category}`);
lines.push("");
for (const item of cat.items) {
lines.push(`- **${item.id}**: ${item.description}`);
}
lines.push("");
}
lines.push("## Out of Scope");
lines.push("");
lines.push("| Feature | Reason |");
lines.push("|---------|--------|");
for (const item of requirements.outOfScope) {
lines.push(`| ${item.feature} | ${item.reason} |`);
}
lines.push("");
lines.push("## Traceability");
lines.push("");
lines.push("| Requirement | Phase | Status |");
lines.push("|-------------|-------|--------|");
for (const t of requirements.traceability) {
lines.push(`| ${t.requirement} | Phase ${t.phase} | ${t.status} |`);
}
writeFile(path.join(this.projectDir, "REQUIREMENTS.md"), lines.join("\n"));
}
readArchitectureMd(): ArchitectureMd | null {
const content = readFile(path.join(this.projectDir, "ARCHITECTURE.md"));
if (!content) return null;
return this.parseArchitectureMd(content);
}
writeArchitectureMd(architecture: ArchitectureMd): void {
this.ensureProjectDir();
const lines: string[] = [
"# Architecture",
"",
"## Overview",
"",
architecture.overview,
"",
"## Components",
"",
];
for (const comp of architecture.components) {
lines.push(`### ${comp.name}`);
lines.push(`- **Description**: ${comp.description}`);
lines.push(`- **Boundaries**: ${comp.boundaries}`);
lines.push(`- **Depends on**: ${comp.dependsOn.length > 0 ? comp.dependsOn.join(", ") : "None"}`);
lines.push("");
}
lines.push("## Data Flow");
lines.push("");
lines.push(architecture.dataFlow);
lines.push("");
lines.push("## Build Order");
lines.push("");
for (const step of architecture.buildOrder) {
lines.push(`1. ${step}`);
}
writeFile(path.join(this.projectDir, "ARCHITECTURE.md"), lines.join("\n"));
}
updateRequirementStatus(reqId: string, status: "pending" | "in_progress" | "complete" | "blocked"): void {
const reqs = this.readRequirementsMd();
if (!reqs) return;
for (const t of reqs.traceability) {
if (t.requirement === reqId) {
t.status = status;
}
}
this.writeRequirementsMd(reqs);
}
updatePhaseStatus(phaseNumber: number, status: "not_started" | "in_progress" | "complete" | "deferred"): void {
const roadmap = this.readRoadmapMd();
if (!roadmap) return;
for (const phase of roadmap.phases) {
if (phase.number === phaseNumber) {
phase.status = status;
}
}
this.writeRoadmapMd(roadmap);
}
getMilestoneType(): MilestoneType {
const roadmap = this.readRoadmapMd();
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" || phase.status === "complete") {
const phaseName = phase.name.toLowerCase();
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;
}
}
if (hasSchemaBreak) return "schema-breaking";
if (hasFeature) return "feature";
return "nfr";
}
isNfrMilestone(): boolean {
return this.getMilestoneType() === "nfr";
}
private parseProjectMd(content: string): ProjectMd {
return {
name: this.extractSection(content, "# ") || "Unknown",
coreValue: this.extractSection(content, "## What This Is") || "",
requirements: {
validated: this.extractListItems(content, "### Validated"),
active: this.extractListItems(content, "### Active"),
outOfScope: this.extractListItems(content, "### Out of Scope"),
},
constraints: this.extractListItems(content, "## Constraints"),
context: this.extractSection(content, "## Context") || "",
keyDecisions: [],
};
}
private parseRoadmapMd(content: string): RoadmapMd {
const overview = this.extractSection(content, "## Overview") || "";
const phases: RoadmapMd["phases"] = [];
const phaseRegex = /### Phase (\d+): (.+)/g;
let match;
while ((match = phaseRegex.exec(content)) !== null) {
const number = parseInt(match[1], 10);
const name = match[2].trim();
const sectionStart = match.index + match[0].length;
const nextPhase = content.indexOf("\n### Phase ", sectionStart);
const nextH2 = content.indexOf("\n## ", sectionStart);
const sectionEnd = Math.min(
nextPhase >= 0 ? nextPhase : content.length,
nextH2 >= 0 ? nextH2 : content.length
);
const section = content.slice(sectionStart, sectionEnd);
const goalMatch = section.match(/\*\*Goal\.?\*\*:\s*(.+)/);
const statusMatch = section.match(/\*\*Status\*\*:\s*(.+)/);
const reqMatch = section.match(/\*\*Requirements\*\*:\s*(.+)/);
const depsMatch = section.match(/\*\*Depends on\*\*:\s*(.+)/);
const statusVal = statusMatch ? statusMatch[1].trim() : "not_started";
const validStatuses = ["not_started", "in_progress", "complete", "deferred"] as const;
phases.push({
number,
name,
description: goalMatch ? goalMatch[1].trim() : "",
status: validStatuses.includes(statusVal as typeof validStatuses[number])
? (statusVal as RoadmapMd["phases"][number]["status"])
: "not_started",
dependsOn: depsMatch && depsMatch[1].trim() !== "Nothing"
? depsMatch[1].split(",").map((d: string) => parseInt(d.trim().replace(/Phase /g, ""), 10)).filter((n: number) => !isNaN(n))
: [],
requirements: reqMatch && reqMatch[1].trim() !== "None"
? reqMatch[1].split(",").map((r: string) => r.trim()).filter(Boolean)
: [],
successCriteria: [],
});
}
return { overview, phases };
}
private parseRequirementsMd(content: string): RequirementsMd {
const v1: RequirementsMd["v1"] = [];
const v2: RequirementsMd["v2"] = [];
const v1Section = this.extractSection(content, "## v1 Requirements");
if (v1Section) {
const categoryBlocks = v1Section.split(/\n### /).filter(Boolean);
for (const block of categoryBlocks) {
const lines = block.split("\n");
const category = lines[0].trim();
const items: Array<{ id: string; description: string }> = [];
for (const line of lines.slice(1)) {
const tableMatch = line.match(/^\|\s*([A-Z]+-\d+)\s*\|\s*(.+?)\s*\|/);
if (tableMatch) {
items.push({ id: tableMatch[1], description: tableMatch[2] });
continue;
}
const listMatch = line.match(/^\s*-?\s*\*?\s*\[?\s*\*?\s*([A-Z]+-\d+)[\]:\s*]*(.+)/);
if (listMatch) {
items.push({ id: listMatch[1], description: listMatch[2].trim() });
}
}
if (items.length > 0) {
v1.push({ category, items });
}
}
}
const v2Section = this.extractSection(content, "## v2 Requirements");
if (v2Section) {
const categoryBlocks = v2Section.split(/\n### /).filter(Boolean);
for (const block of categoryBlocks) {
const lines = block.split("\n");
const category = lines[0].trim();
const items: Array<{ id: string; description: string }> = [];
for (const line of lines.slice(1)) {
const tableMatch = line.match(/^\|\s*([A-Z]+-\d+)\s*\|\s*(.+?)\s*\|/);
if (tableMatch) {
items.push({ id: tableMatch[1], description: tableMatch[2] });
continue;
}
const listMatch = line.match(/^\s*-?\s*\*?\s*\[?\s*\*?\s*([A-Z]+-\d+)[\]:\s*]*(.+)/);
if (listMatch) {
items.push({ id: listMatch[1], description: listMatch[2].trim() });
}
}
if (items.length > 0) {
v2.push({ category, items });
}
}
}
const outOfScope: RequirementsMd["outOfScope"] = [];
const outSection = this.extractSection(content, "## Out of Scope");
if (outSection) {
const tableRows = outSection.split("\n").filter((line) => /^\|/.test(line) && !line.includes("---") && !line.includes("Feature"));
for (const row of tableRows) {
const cols = row.split("|").map((c) => c.trim()).filter(Boolean);
if (cols.length >= 2) {
outOfScope.push({ feature: cols[0], reason: cols[1] });
}
}
if (outOfScope.length === 0) {
const listItems = this.extractListItems(content, "## Out of Scope");
for (const item of listItems) {
outOfScope.push({ feature: item, reason: "" });
}
}
}
const traceability: RequirementsMd["traceability"] = [];
const traceSection = this.extractSection(content, "## Traceability");
if (traceSection) {
const activeHeader = traceSection.includes("Active Milestone")
? "## v0.5 Requirements (Active Milestone)"
: content.includes("## v1 Requirements")
? "## v1 Requirements"
: undefined;
const tableRows = traceSection.split("\n").filter((line) => /^\|/.test(line) && !line.includes("---") && !line.includes("Requirement") && !line.includes("REQ-ID"));
for (const row of tableRows) {
const cols = row.split("|").map((c) => c.trim()).filter(Boolean);
if (cols.length >= 3) {
const req = cols[0];
const phaseStr = cols[1];
const phaseMatch = phaseStr.match(/(\d+)/);
const phase = phaseMatch ? parseInt(phaseMatch[1], 10) : 0;
const statusStr = cols[2].toLowerCase();
const status = ["pending", "in_progress", "complete", "blocked", "covered"].includes(statusStr)
? (statusStr === "covered" ? "complete" : statusStr as "pending" | "in_progress" | "complete" | "blocked")
: "pending";
traceability.push({ requirement: req, phase, status });
}
}
}
const allReqIds = new Set<string>();
for (const cat of [...v1, ...v2]) {
for (const item of cat.items) {
allReqIds.add(item.id);
}
}
for (const t of traceability) {
allReqIds.add(t.requirement);
}
const coveredInTrace = new Set(traceability.filter((t) => t.status === "complete").map((t) => t.requirement));
for (const reqId of allReqIds) {
if (!coveredInTrace.has(reqId)) {
traceability.push({ requirement: reqId, phase: 0, status: "pending" });
}
}
return { v1, v2, outOfScope, traceability };
}
private parseArchitectureMd(content: string): ArchitectureMd {
const overview = this.extractSection(content, "## Overview") || "";
const components: ArchitectureMd["components"] = [];
const section = content;
const componentRegex = /###\s+(.+)/g;
let compMatch;
const h3Positions: Array<{ name: string; start: number }> = [];
while ((compMatch = componentRegex.exec(section)) !== null) {
h3Positions.push({ name: compMatch[1].trim(), start: compMatch.index + compMatch[0].length });
}
for (let i = 0; i < h3Positions.length; i++) {
const name = h3Positions[i].name;
const start = h3Positions[i].start;
const end = i + 1 < h3Positions.length ? h3Positions[i + 1].start - (content.substring(h3Positions[i + 1].start - 4, h3Positions[i + 1].start) === "### " ? 4 : 0) : content.length;
const block = content.slice(start, end);
const descMatch = block.match(/[-*]\s*\*?\*?(?:Description|description)\*?\*?\s*[:]\s*(.+)/);
const boundaryMatch = block.match(/[-*]\s*\*?\*?(?:Boundaries|boundaries)\*?\*?\s*[:]\s*(.+)/);
const depsMatch = block.match(/[-*]\s*\*?\*?(?:Depends on|depends on|Dependencies)\*?\*?\s*[:]\s*(.+)/);
components.push({
name,
description: descMatch ? descMatch[1].trim() : "",
boundaries: boundaryMatch ? boundaryMatch[1].trim() : "",
dependsOn: depsMatch
? depsMatch[1].split(",").map((d: string) => d.trim().replace(/\*\*/g, "")).filter(Boolean)
: [],
});
}
const dataFlow = this.extractSection(content, "## Data Flow")
|| this.extractSection(content, "## Data flow")
|| "";
const buildOrder: string[] = [];
const buildSection = this.extractSection(content, "## Build Order");
if (buildSection) {
const listItems = buildSection
.split("\n")
.filter((line) => /^\d+\./.test(line.trim()))
.map((line) => line.trim().replace(/^\d+\.\s*/, ""));
buildOrder.push(...listItems);
}
return { overview, components, dataFlow, buildOrder };
}
private extractSection(content: string, header: string): string | null {
const headerIdx = content.indexOf(header);
if (headerIdx < 0) return null;
const startIdx = headerIdx + header.length;
const nextHeaderIdx = content.indexOf("\n## ", startIdx);
const endIdx = nextHeaderIdx >= 0 ? nextHeaderIdx : content.length;
return content.slice(startIdx, endIdx).trim();
}
private extractListItems(content: string, header: string): string[] {
const section = this.extractSection(content, header);
if (!section) return [];
return section
.split("\n")
.filter((line) => line.trim().startsWith("-"))
.map((line) => line.replace(/^-\s*(?:\[[ x]\]\s*)?(?:✓\s*)?/, "").trim())
.filter(Boolean);
}
}