reffy-cli 0.7.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -24
- package/dist/cli.js +539 -36
- package/dist/diagram.js +2 -2
- package/dist/doctor.d.ts +1 -4
- package/dist/doctor.js +8 -12
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/manifest.js +13 -0
- package/dist/plan-archive.d.ts +8 -0
- package/dist/plan-archive.js +169 -0
- package/dist/plan-runtime.d.ts +35 -0
- package/dist/plan-runtime.js +218 -0
- package/dist/plan.d.ts +25 -0
- package/dist/plan.js +298 -0
- package/dist/planning-paths.d.ts +18 -0
- package/dist/planning-paths.js +56 -0
- package/dist/planning-workspace.d.ts +8 -0
- package/dist/planning-workspace.js +76 -0
- package/dist/refs-paths.d.ts +13 -0
- package/dist/refs-paths.js +34 -9
- package/dist/spec-runtime.d.ts +14 -0
- package/dist/spec-runtime.js +82 -0
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +50 -0
- package/dist/types.d.ts +3 -0
- package/dist/workspace.d.ts +8 -0
- package/dist/workspace.js +51 -0
- package/package.json +14 -2
package/dist/diagram.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { THEMES, renderMermaid, renderMermaidAscii } from "beautiful-mermaid";
|
|
4
|
-
function
|
|
4
|
+
function isLikelySpecDocument(inputPath, content) {
|
|
5
5
|
if (inputPath && path.basename(inputPath).toLowerCase() === "spec.md")
|
|
6
6
|
return true;
|
|
7
7
|
return /^### Requirement:\s+/m.test(content) && /^#### Scenario:\s+/m.test(content);
|
|
@@ -67,7 +67,7 @@ async function loadSource(request) {
|
|
|
67
67
|
if (trimmed.length === 0) {
|
|
68
68
|
throw new Error("Diagram input is empty.");
|
|
69
69
|
}
|
|
70
|
-
if (
|
|
70
|
+
if (isLikelySpecDocument(request.inputPath, raw)) {
|
|
71
71
|
return { sourceText: convertSpecToMermaid(raw), sourceKind: "spec" };
|
|
72
72
|
}
|
|
73
73
|
return { sourceText: raw, sourceKind: "mermaid" };
|
package/dist/doctor.d.ts
CHANGED
|
@@ -14,8 +14,5 @@ export interface DoctorReport {
|
|
|
14
14
|
optional_failed: number;
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
-
export
|
|
18
|
-
checkOpenSpec?: () => boolean;
|
|
19
|
-
}
|
|
20
|
-
export declare function runDoctor(repoRoot: string, options?: DoctorOptions): Promise<DoctorReport>;
|
|
17
|
+
export declare function runDoctor(repoRoot: string): Promise<DoctorReport>;
|
|
21
18
|
export {};
|
package/dist/doctor.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { promises as fs } from "node:fs";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { validateManifest } from "./manifest.js";
|
|
5
|
-
import { resolveRefsDirName } from "./refs-paths.js";
|
|
4
|
+
import { detectWorkspaceState, resolveRefsDirName } from "./refs-paths.js";
|
|
6
5
|
async function pathExists(targetPath) {
|
|
7
6
|
try {
|
|
8
7
|
await fs.access(targetPath);
|
|
@@ -12,10 +11,6 @@ async function pathExists(targetPath) {
|
|
|
12
11
|
return false;
|
|
13
12
|
}
|
|
14
13
|
}
|
|
15
|
-
function defaultOpenSpecCheck() {
|
|
16
|
-
const result = spawnSync("openspec", ["--version"], { stdio: "ignore" });
|
|
17
|
-
return result.error === undefined;
|
|
18
|
-
}
|
|
19
14
|
function summarizeChecks(checks) {
|
|
20
15
|
const required = checks.filter((check) => check.level === "required");
|
|
21
16
|
const optional = checks.filter((check) => check.level === "optional");
|
|
@@ -26,8 +21,9 @@ function summarizeChecks(checks) {
|
|
|
26
21
|
optional_failed: optional.filter((check) => !check.ok).length,
|
|
27
22
|
};
|
|
28
23
|
}
|
|
29
|
-
export async function runDoctor(repoRoot
|
|
24
|
+
export async function runDoctor(repoRoot) {
|
|
30
25
|
const checks = [];
|
|
26
|
+
const workspace = detectWorkspaceState(repoRoot);
|
|
31
27
|
const refsDirName = resolveRefsDirName(repoRoot);
|
|
32
28
|
const refsDir = path.join(repoRoot, refsDirName);
|
|
33
29
|
const artifactsDir = path.join(refsDir, "artifacts");
|
|
@@ -84,13 +80,13 @@ export async function runDoctor(repoRoot, options) {
|
|
|
84
80
|
: `manifest invalid: ${manifestResult.errors.join("; ")}`,
|
|
85
81
|
});
|
|
86
82
|
}
|
|
87
|
-
const checkOpenSpec = options?.checkOpenSpec ?? defaultOpenSpecCheck;
|
|
88
|
-
const hasOpenSpec = checkOpenSpec();
|
|
89
83
|
checks.push({
|
|
90
|
-
id: "
|
|
84
|
+
id: "workspace_canonical",
|
|
91
85
|
level: "optional",
|
|
92
|
-
ok:
|
|
93
|
-
message:
|
|
86
|
+
ok: workspace.mode !== "legacy",
|
|
87
|
+
message: workspace.mode === "legacy"
|
|
88
|
+
? "legacy .references workspace detected; run `reffy migrate` to adopt .reffy/"
|
|
89
|
+
: ".reffy/ is the active workspace",
|
|
94
90
|
});
|
|
95
91
|
return { checks, summary: summarizeChecks(checks) };
|
|
96
92
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { ReferencesStore } from "./storage.js";
|
|
2
2
|
export { MANIFEST_VERSION, allowedKindExtensions, validateManifest } from "./manifest.js";
|
|
3
3
|
export { runDoctor } from "./doctor.js";
|
|
4
|
+
export { createPlanScaffold } from "./plan.js";
|
|
5
|
+
export { listSpecs, showSpec } from "./spec-runtime.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { ReferencesStore } from "./storage.js";
|
|
2
2
|
export { MANIFEST_VERSION, allowedKindExtensions, validateManifest } from "./manifest.js";
|
|
3
3
|
export { runDoctor } from "./doctor.js";
|
|
4
|
+
export { createPlanScaffold } from "./plan.js";
|
|
5
|
+
export { listSpecs, showSpec } from "./spec-runtime.js";
|
package/dist/manifest.js
CHANGED
|
@@ -73,6 +73,19 @@ function validateArtifactShape(value, index, errors) {
|
|
|
73
73
|
if (!Array.isArray(value.tags) || value.tags.some((tag) => typeof tag !== "string")) {
|
|
74
74
|
errors.push(`artifacts[${index}].tags must be an array of strings`);
|
|
75
75
|
}
|
|
76
|
+
if (value.status !== undefined && typeof value.status !== "string") {
|
|
77
|
+
errors.push(`artifacts[${index}].status must be a string when provided`);
|
|
78
|
+
}
|
|
79
|
+
if (value.related_changes !== undefined) {
|
|
80
|
+
if (!Array.isArray(value.related_changes) || value.related_changes.some((entry) => typeof entry !== "string")) {
|
|
81
|
+
errors.push(`artifacts[${index}].related_changes must be an array of strings when provided`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (value.derived_outputs !== undefined) {
|
|
85
|
+
if (!Array.isArray(value.derived_outputs) || value.derived_outputs.some((entry) => typeof entry !== "string")) {
|
|
86
|
+
errors.push(`artifacts[${index}].derived_outputs must be an array of strings when provided`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
76
89
|
if (!isIsoDate(value.created_at)) {
|
|
77
90
|
errors.push(`artifacts[${index}].created_at must be an ISO timestamp`);
|
|
78
91
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface PlanArchiveResult {
|
|
2
|
+
change_id: string;
|
|
3
|
+
archive_dir: string;
|
|
4
|
+
archived_files: string[];
|
|
5
|
+
updated_specs: string[];
|
|
6
|
+
linked_artifacts: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function archivePlanningChange(repoRoot: string, changeId: string): Promise<PlanArchiveResult>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DEFAULT_PLANNING_DIRNAME } from "./planning-paths.js";
|
|
4
|
+
import { validatePlanningChange } from "./plan-runtime.js";
|
|
5
|
+
import { ReferencesStore } from "./storage.js";
|
|
6
|
+
const DELTA_SECTION_PATTERN = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/;
|
|
7
|
+
const REQUIREMENT_PATTERN = /^###\s+Requirement:\s+(.+)$/;
|
|
8
|
+
const REQUIREMENTS_HEADING = "## Requirements";
|
|
9
|
+
function archiveDatePrefix() {
|
|
10
|
+
return new Date().toISOString().slice(0, 10);
|
|
11
|
+
}
|
|
12
|
+
async function pathExists(targetPath) {
|
|
13
|
+
try {
|
|
14
|
+
await fs.access(targetPath);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function listFilesRecursive(dir) {
|
|
22
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
23
|
+
const files = [];
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const fullPath = path.join(dir, entry.name);
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
files.push(...(await listFilesRecursive(fullPath)));
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (entry.isFile()) {
|
|
31
|
+
files.push(fullPath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return files.sort();
|
|
35
|
+
}
|
|
36
|
+
function parseAddedRequirements(content, relPath) {
|
|
37
|
+
const lines = content.split(/\r?\n/);
|
|
38
|
+
const sectionTypes = new Set();
|
|
39
|
+
const blocks = [];
|
|
40
|
+
let inAddedSection = false;
|
|
41
|
+
let currentTitle = null;
|
|
42
|
+
let currentLines = [];
|
|
43
|
+
const flush = () => {
|
|
44
|
+
if (!currentTitle)
|
|
45
|
+
return;
|
|
46
|
+
blocks.push({
|
|
47
|
+
title: currentTitle,
|
|
48
|
+
content: currentLines.join("\n").trimEnd(),
|
|
49
|
+
});
|
|
50
|
+
currentTitle = null;
|
|
51
|
+
currentLines = [];
|
|
52
|
+
};
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const sectionMatch = line.match(DELTA_SECTION_PATTERN);
|
|
55
|
+
if (sectionMatch) {
|
|
56
|
+
flush();
|
|
57
|
+
const sectionType = sectionMatch[1] ?? "";
|
|
58
|
+
sectionTypes.add(sectionType);
|
|
59
|
+
inAddedSection = sectionType === "ADDED";
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const requirementMatch = line.match(REQUIREMENT_PATTERN);
|
|
63
|
+
if (requirementMatch) {
|
|
64
|
+
flush();
|
|
65
|
+
currentTitle = requirementMatch[1]?.trim() ?? "unknown";
|
|
66
|
+
currentLines = [line];
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (currentTitle) {
|
|
70
|
+
currentLines.push(line);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
flush();
|
|
74
|
+
const unsupported = Array.from(sectionTypes).filter((sectionType) => sectionType !== "ADDED");
|
|
75
|
+
if (unsupported.length > 0) {
|
|
76
|
+
throw new Error(`${relPath}: unsupported delta sections for archive: ${unsupported.join(", ")}`);
|
|
77
|
+
}
|
|
78
|
+
if (!inAddedSection && sectionTypes.size === 0) {
|
|
79
|
+
throw new Error(`${relPath}: no supported archiveable requirements found`);
|
|
80
|
+
}
|
|
81
|
+
if (blocks.length === 0) {
|
|
82
|
+
throw new Error(`${relPath}: no ADDED requirements found to archive`);
|
|
83
|
+
}
|
|
84
|
+
return blocks;
|
|
85
|
+
}
|
|
86
|
+
function buildNewCurrentSpec(capability, changeId, blocks) {
|
|
87
|
+
return [
|
|
88
|
+
`# ${capability} Specification`,
|
|
89
|
+
"",
|
|
90
|
+
"## Purpose",
|
|
91
|
+
`TBD - created by archiving change ${changeId}. Update Purpose after archive.`,
|
|
92
|
+
"",
|
|
93
|
+
REQUIREMENTS_HEADING,
|
|
94
|
+
...blocks.map((block) => block.content),
|
|
95
|
+
"",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
function appendRequirementsToCurrentSpec(existing, blocks, relPath) {
|
|
99
|
+
if (!existing.includes(REQUIREMENTS_HEADING)) {
|
|
100
|
+
throw new Error(`${relPath}: current spec is missing a "## Requirements" section`);
|
|
101
|
+
}
|
|
102
|
+
for (const block of blocks) {
|
|
103
|
+
const escaped = block.title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
104
|
+
const duplicatePattern = new RegExp(`^###\\s+Requirement:\\s+${escaped}\\s*$`, "m");
|
|
105
|
+
if (duplicatePattern.test(existing)) {
|
|
106
|
+
throw new Error(`${relPath}: current spec already contains requirement "${block.title}"`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return `${existing.trimEnd()}\n\n${blocks.map((block) => block.content).join("\n\n")}\n`;
|
|
110
|
+
}
|
|
111
|
+
async function buildSpecUpdates(repoRoot, changeId, changeDir) {
|
|
112
|
+
const specsRoot = path.join(changeDir, "specs");
|
|
113
|
+
const entries = await fs.readdir(specsRoot, { withFileTypes: true }).catch(() => []);
|
|
114
|
+
const updates = new Map();
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (!entry.isDirectory())
|
|
117
|
+
continue;
|
|
118
|
+
const capability = entry.name;
|
|
119
|
+
const deltaPath = path.join(specsRoot, capability, "spec.md");
|
|
120
|
+
if (!(await pathExists(deltaPath)))
|
|
121
|
+
continue;
|
|
122
|
+
const deltaContent = await fs.readFile(deltaPath, "utf8");
|
|
123
|
+
const relDeltaPath = path.relative(repoRoot, deltaPath).split(path.sep).join("/");
|
|
124
|
+
const blocks = parseAddedRequirements(deltaContent, relDeltaPath);
|
|
125
|
+
const currentSpecPath = path.join(repoRoot, DEFAULT_PLANNING_DIRNAME, "specs", capability, "spec.md");
|
|
126
|
+
const nextContent = (await pathExists(currentSpecPath))
|
|
127
|
+
? appendRequirementsToCurrentSpec(await fs.readFile(currentSpecPath, "utf8"), blocks, path.relative(repoRoot, currentSpecPath).split(path.sep).join("/"))
|
|
128
|
+
: buildNewCurrentSpec(capability, changeId, blocks);
|
|
129
|
+
updates.set(currentSpecPath, nextContent);
|
|
130
|
+
}
|
|
131
|
+
return updates;
|
|
132
|
+
}
|
|
133
|
+
export async function archivePlanningChange(repoRoot, changeId) {
|
|
134
|
+
const validation = await validatePlanningChange(repoRoot, changeId);
|
|
135
|
+
if (!validation.ok) {
|
|
136
|
+
throw new Error(`cannot archive invalid change "${changeId}": ${validation.errors.join("; ")}`);
|
|
137
|
+
}
|
|
138
|
+
const changeDir = path.join(repoRoot, DEFAULT_PLANNING_DIRNAME, "changes", changeId);
|
|
139
|
+
if (!(await pathExists(changeDir))) {
|
|
140
|
+
throw new Error(`change not found: ${changeId}`);
|
|
141
|
+
}
|
|
142
|
+
const archiveDir = path.join(repoRoot, DEFAULT_PLANNING_DIRNAME, "changes", "archive", `${archiveDatePrefix()}-${changeId}`);
|
|
143
|
+
if (await pathExists(archiveDir)) {
|
|
144
|
+
throw new Error(`archive destination already exists: ${path.relative(repoRoot, archiveDir).split(path.sep).join("/")}`);
|
|
145
|
+
}
|
|
146
|
+
const specUpdates = await buildSpecUpdates(repoRoot, changeId, changeDir);
|
|
147
|
+
const activeFiles = await listFilesRecursive(changeDir);
|
|
148
|
+
const pathMap = Object.fromEntries(activeFiles.map((filePath) => {
|
|
149
|
+
const relCurrent = path.relative(repoRoot, filePath).split(path.sep).join("/");
|
|
150
|
+
const archivedPath = path.join(archiveDir, path.relative(changeDir, filePath));
|
|
151
|
+
const relArchived = path.relative(repoRoot, archivedPath).split(path.sep).join("/");
|
|
152
|
+
return [relCurrent, relArchived];
|
|
153
|
+
}));
|
|
154
|
+
for (const [specPath, content] of specUpdates) {
|
|
155
|
+
await fs.mkdir(path.dirname(specPath), { recursive: true });
|
|
156
|
+
await fs.writeFile(specPath, content, "utf8");
|
|
157
|
+
}
|
|
158
|
+
await fs.mkdir(path.dirname(archiveDir), { recursive: true });
|
|
159
|
+
await fs.rename(changeDir, archiveDir);
|
|
160
|
+
const store = new ReferencesStore(repoRoot);
|
|
161
|
+
const rewriteResult = await store.rewriteDerivedOutputPaths(pathMap);
|
|
162
|
+
return {
|
|
163
|
+
change_id: changeId,
|
|
164
|
+
archive_dir: archiveDir,
|
|
165
|
+
archived_files: Object.values(pathMap).sort(),
|
|
166
|
+
updated_specs: Array.from(specUpdates.keys()).sort(),
|
|
167
|
+
linked_artifacts: rewriteResult.updated,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface TaskStatus {
|
|
2
|
+
total: number;
|
|
3
|
+
completed: number;
|
|
4
|
+
}
|
|
5
|
+
export interface PlanChangeSummary {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
change_dir: string;
|
|
9
|
+
proposal_path: string;
|
|
10
|
+
tasks_path: string;
|
|
11
|
+
design_path?: string;
|
|
12
|
+
delta_count: number;
|
|
13
|
+
task_status: TaskStatus;
|
|
14
|
+
}
|
|
15
|
+
export interface PlanShowResult extends PlanChangeSummary {
|
|
16
|
+
proposal: string;
|
|
17
|
+
tasks: string;
|
|
18
|
+
design?: string;
|
|
19
|
+
specs: Array<{
|
|
20
|
+
capability: string;
|
|
21
|
+
path: string;
|
|
22
|
+
content: string;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
export interface PlanValidationResult {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
change_id: string;
|
|
28
|
+
errors: string[];
|
|
29
|
+
warnings: string[];
|
|
30
|
+
delta_count: number;
|
|
31
|
+
task_status: TaskStatus;
|
|
32
|
+
}
|
|
33
|
+
export declare function listPlanningChanges(repoRoot: string): Promise<PlanChangeSummary[]>;
|
|
34
|
+
export declare function showPlanningChange(repoRoot: string, changeId: string): Promise<PlanShowResult>;
|
|
35
|
+
export declare function validatePlanningChange(repoRoot: string, changeId: string): Promise<PlanValidationResult>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DEFAULT_PLANNING_DIRNAME } from "./planning-paths.js";
|
|
4
|
+
const CHANGE_HEADING_PREFIX = "# Change:";
|
|
5
|
+
const REQUIREMENT_SECTION_PATTERN = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/;
|
|
6
|
+
const REQUIREMENT_HEADING_PATTERN = /^###\s+Requirement:\s+(.+)$/;
|
|
7
|
+
const SCENARIO_HEADING_PATTERN = /^####\s+Scenario:\s+(.+)$/;
|
|
8
|
+
const BAD_SCENARIO_PATTERNS = [/^###\s+Scenario:/, /^- \*\*Scenario:/, /^\*\*Scenario\*\*:/];
|
|
9
|
+
async function pathExists(targetPath) {
|
|
10
|
+
try {
|
|
11
|
+
await fs.access(targetPath);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function listDirectories(dir) {
|
|
19
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
20
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
21
|
+
}
|
|
22
|
+
function getChangePaths(repoRoot, changeId) {
|
|
23
|
+
const changeDir = path.join(repoRoot, DEFAULT_PLANNING_DIRNAME, "changes", changeId);
|
|
24
|
+
return {
|
|
25
|
+
id: changeId,
|
|
26
|
+
changeDir,
|
|
27
|
+
proposalPath: path.join(changeDir, "proposal.md"),
|
|
28
|
+
tasksPath: path.join(changeDir, "tasks.md"),
|
|
29
|
+
designPath: path.join(changeDir, "design.md"),
|
|
30
|
+
specsDir: path.join(changeDir, "specs"),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function listSpecFiles(specsDir) {
|
|
34
|
+
const capabilities = await listDirectories(specsDir);
|
|
35
|
+
const files = [];
|
|
36
|
+
for (const capability of capabilities) {
|
|
37
|
+
const capabilityDir = path.join(specsDir, capability);
|
|
38
|
+
const entries = await fs.readdir(capabilityDir, { withFileTypes: true }).catch(() => []);
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (!entry.isFile() || !entry.name.endsWith(".md"))
|
|
41
|
+
continue;
|
|
42
|
+
files.push(path.join(capabilityDir, entry.name));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return files.sort();
|
|
46
|
+
}
|
|
47
|
+
function countTasks(content) {
|
|
48
|
+
const matches = content.matchAll(/^- \[( |x)\] /gm);
|
|
49
|
+
let total = 0;
|
|
50
|
+
let completed = 0;
|
|
51
|
+
for (const match of matches) {
|
|
52
|
+
total += 1;
|
|
53
|
+
if (match[1] === "x")
|
|
54
|
+
completed += 1;
|
|
55
|
+
}
|
|
56
|
+
return { total, completed };
|
|
57
|
+
}
|
|
58
|
+
function extractTitle(content, fallback) {
|
|
59
|
+
for (const line of content.split(/\r?\n/)) {
|
|
60
|
+
if (line.startsWith(CHANGE_HEADING_PREFIX)) {
|
|
61
|
+
return line.slice(CHANGE_HEADING_PREFIX.length).trim() || fallback;
|
|
62
|
+
}
|
|
63
|
+
if (line.startsWith("# ")) {
|
|
64
|
+
return line.slice(2).trim() || fallback;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
function validateSpecContent(content, relPath, errors) {
|
|
70
|
+
const lines = content.split(/\r?\n/);
|
|
71
|
+
let sectionCount = 0;
|
|
72
|
+
let requirementCount = 0;
|
|
73
|
+
let scenarioCount = 0;
|
|
74
|
+
let currentRequirement = null;
|
|
75
|
+
let currentRequirementScenarios = 0;
|
|
76
|
+
const finalizeRequirement = () => {
|
|
77
|
+
if (currentRequirement !== null && currentRequirementScenarios === 0) {
|
|
78
|
+
errors.push(`${relPath}: requirement "${currentRequirement}" must include at least one scenario`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (REQUIREMENT_SECTION_PATTERN.test(line)) {
|
|
83
|
+
sectionCount += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const requirementMatch = line.match(REQUIREMENT_HEADING_PATTERN);
|
|
87
|
+
if (requirementMatch) {
|
|
88
|
+
finalizeRequirement();
|
|
89
|
+
requirementCount += 1;
|
|
90
|
+
currentRequirement = requirementMatch[1]?.trim() ?? "unknown";
|
|
91
|
+
currentRequirementScenarios = 0;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const scenarioMatch = line.match(SCENARIO_HEADING_PATTERN);
|
|
95
|
+
if (scenarioMatch) {
|
|
96
|
+
scenarioCount += 1;
|
|
97
|
+
currentRequirementScenarios += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (BAD_SCENARIO_PATTERNS.some((pattern) => pattern.test(line))) {
|
|
101
|
+
errors.push(`${relPath}: scenarios must use "#### Scenario:" headings`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
finalizeRequirement();
|
|
105
|
+
if (sectionCount === 0) {
|
|
106
|
+
errors.push(`${relPath}: must include at least one "## ADDED|MODIFIED|REMOVED|RENAMED Requirements" section`);
|
|
107
|
+
}
|
|
108
|
+
if (requirementCount === 0) {
|
|
109
|
+
errors.push(`${relPath}: must include at least one "### Requirement:" heading`);
|
|
110
|
+
}
|
|
111
|
+
if (scenarioCount === 0) {
|
|
112
|
+
errors.push(`${relPath}: must include at least one "#### Scenario:" heading`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function buildChangeSummary(repoRoot, changeId) {
|
|
116
|
+
const paths = getChangePaths(repoRoot, changeId);
|
|
117
|
+
const proposal = await fs.readFile(paths.proposalPath, "utf8");
|
|
118
|
+
const tasks = await fs.readFile(paths.tasksPath, "utf8").catch(() => "");
|
|
119
|
+
const designExists = await pathExists(paths.designPath);
|
|
120
|
+
const specFiles = await listSpecFiles(paths.specsDir);
|
|
121
|
+
return {
|
|
122
|
+
id: changeId,
|
|
123
|
+
title: extractTitle(proposal, changeId),
|
|
124
|
+
change_dir: paths.changeDir,
|
|
125
|
+
proposal_path: paths.proposalPath,
|
|
126
|
+
tasks_path: paths.tasksPath,
|
|
127
|
+
design_path: designExists ? paths.designPath : undefined,
|
|
128
|
+
delta_count: specFiles.length,
|
|
129
|
+
task_status: countTasks(tasks),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export async function listPlanningChanges(repoRoot) {
|
|
133
|
+
const changesRoot = path.join(repoRoot, DEFAULT_PLANNING_DIRNAME, "changes");
|
|
134
|
+
const changeIds = (await listDirectories(changesRoot)).filter((id) => id !== "archive");
|
|
135
|
+
const summaries = [];
|
|
136
|
+
for (const changeId of changeIds) {
|
|
137
|
+
const proposalPath = path.join(changesRoot, changeId, "proposal.md");
|
|
138
|
+
if (!(await pathExists(proposalPath)))
|
|
139
|
+
continue;
|
|
140
|
+
summaries.push(await buildChangeSummary(repoRoot, changeId));
|
|
141
|
+
}
|
|
142
|
+
return summaries.sort((a, b) => a.id.localeCompare(b.id));
|
|
143
|
+
}
|
|
144
|
+
export async function showPlanningChange(repoRoot, changeId) {
|
|
145
|
+
const summary = await buildChangeSummary(repoRoot, changeId);
|
|
146
|
+
const paths = getChangePaths(repoRoot, changeId);
|
|
147
|
+
const proposal = await fs.readFile(paths.proposalPath, "utf8");
|
|
148
|
+
const tasks = await fs.readFile(paths.tasksPath, "utf8");
|
|
149
|
+
const design = await fs.readFile(paths.designPath, "utf8").catch(() => undefined);
|
|
150
|
+
const specFiles = await listSpecFiles(paths.specsDir);
|
|
151
|
+
const specs = await Promise.all(specFiles.map(async (filePath) => ({
|
|
152
|
+
capability: path.basename(path.dirname(filePath)),
|
|
153
|
+
path: filePath,
|
|
154
|
+
content: await fs.readFile(filePath, "utf8"),
|
|
155
|
+
})));
|
|
156
|
+
return {
|
|
157
|
+
...summary,
|
|
158
|
+
proposal,
|
|
159
|
+
tasks,
|
|
160
|
+
design,
|
|
161
|
+
specs,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export async function validatePlanningChange(repoRoot, changeId) {
|
|
165
|
+
const paths = getChangePaths(repoRoot, changeId);
|
|
166
|
+
const errors = [];
|
|
167
|
+
const warnings = [];
|
|
168
|
+
if (!(await pathExists(paths.changeDir))) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
change_id: changeId,
|
|
172
|
+
errors: [`change not found: ${changeId}`],
|
|
173
|
+
warnings,
|
|
174
|
+
delta_count: 0,
|
|
175
|
+
task_status: { total: 0, completed: 0 },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (!(await pathExists(paths.proposalPath))) {
|
|
179
|
+
errors.push("missing required file: proposal.md");
|
|
180
|
+
}
|
|
181
|
+
if (!(await pathExists(paths.tasksPath))) {
|
|
182
|
+
errors.push("missing required file: tasks.md");
|
|
183
|
+
}
|
|
184
|
+
if (!(await pathExists(paths.specsDir))) {
|
|
185
|
+
errors.push("missing required directory: specs/");
|
|
186
|
+
}
|
|
187
|
+
const proposal = await fs.readFile(paths.proposalPath, "utf8").catch(() => "");
|
|
188
|
+
if (proposal.length > 0) {
|
|
189
|
+
if (!proposal.includes("## Why"))
|
|
190
|
+
errors.push("proposal.md must include a '## Why' section");
|
|
191
|
+
if (!proposal.includes("## What Changes"))
|
|
192
|
+
errors.push("proposal.md must include a '## What Changes' section");
|
|
193
|
+
if (!proposal.includes("## Impact"))
|
|
194
|
+
errors.push("proposal.md must include a '## Impact' section");
|
|
195
|
+
}
|
|
196
|
+
const tasks = await fs.readFile(paths.tasksPath, "utf8").catch(() => "");
|
|
197
|
+
const taskStatus = countTasks(tasks);
|
|
198
|
+
if (tasks.length > 0 && taskStatus.total === 0) {
|
|
199
|
+
warnings.push("tasks.md does not contain any checkbox tasks");
|
|
200
|
+
}
|
|
201
|
+
const specFiles = await listSpecFiles(paths.specsDir).catch(() => []);
|
|
202
|
+
if (specFiles.length === 0) {
|
|
203
|
+
errors.push("specs/ must contain at least one delta spec file");
|
|
204
|
+
}
|
|
205
|
+
for (const filePath of specFiles) {
|
|
206
|
+
const relPath = path.relative(paths.changeDir, filePath).split(path.sep).join("/");
|
|
207
|
+
const content = await fs.readFile(filePath, "utf8").catch(() => "");
|
|
208
|
+
validateSpecContent(content, relPath, errors);
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
ok: errors.length === 0,
|
|
212
|
+
change_id: changeId,
|
|
213
|
+
errors,
|
|
214
|
+
warnings,
|
|
215
|
+
delta_count: specFiles.length,
|
|
216
|
+
task_status: taskStatus,
|
|
217
|
+
};
|
|
218
|
+
}
|
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Artifact } from "./types.js";
|
|
2
|
+
interface StoreLike {
|
|
3
|
+
repoRoot: string;
|
|
4
|
+
listArtifacts(): Promise<Artifact[]>;
|
|
5
|
+
getArtifactPath(artifact: Artifact): string;
|
|
6
|
+
linkPlanningOutputs(artifactFilenames: string[], outputPaths: string[], changeId: string): Promise<{
|
|
7
|
+
linked: number;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
export interface CreatePlanInput {
|
|
11
|
+
changeId: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
artifactFilters?: string[];
|
|
14
|
+
includeAllArtifacts?: boolean;
|
|
15
|
+
overwrite?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface CreatePlanResult {
|
|
18
|
+
change_id: string;
|
|
19
|
+
change_dir: string;
|
|
20
|
+
selected_artifacts: string[];
|
|
21
|
+
written_files: string[];
|
|
22
|
+
linked_artifacts: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function createPlanScaffold(store: StoreLike, input: CreatePlanInput): Promise<CreatePlanResult>;
|
|
25
|
+
export {};
|