Files
ci/src/core/ciagent-files.ts
T
Jon Chery 4a58aa1657 refactor(rebrand): rename & rebrand CI → CIAgent across all source and test files
- Type renames: CIConfig → CIAgentConfig, DEFAULT_CI_CONFIG → DEFAULT_CIAGENT_CONFIG
- Type renames: CiMetadata → CIAgentMetadata, ParsedCiCommit → ParsedCIAgentCommit
- Function renames: initCI → initCIAgent, isCIInitialized → isCIAgentInitialized
- Function renames: extractCiBlock → extractCIAgentBlock, parseCiBlock → parseCIAgentBlock
- Class renames: CiFiles → CIAgentFiles
- Import paths: ci-files.js → ciagent-files.js
- Directory paths: .ci/ → .ciagent/ across all source and test files
- Check names: ".ci directory exists" → ".ciagent directory exists"
- Check names: "CI config valid" → "CIAgent config valid"
- Temp dir names: ci-*-test- → ciagent-*-test-
- CLI examples: "ci init" → "ciagent init"
- Fix deepMerge infinite recursion bug in config.ts
- ---ci---/---/ci--- block markers preserved unchanged
- All 31 test suites, 370 tests passing

---ci---
phase: 1
milestone: v0.5
plan: 07
task: 07-01-01
status: execute
---/ci---
2026-05-29 18:01:13 +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 = ".ciagent";
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 CIAgentFiles {
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);
}
}