4a58aa1657
- 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---
750 lines
23 KiB
TypeScript
750 lines
23 KiB
TypeScript
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);
|
||
}
|
||
} |