@howaboua/opencode-roadmap-plugin 0.1.0
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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/descriptions/index.d.ts +1 -0
- package/dist/descriptions/index.js +1 -0
- package/dist/descriptions/loader.d.ts +1 -0
- package/dist/descriptions/loader.js +17 -0
- package/dist/errors/loader.d.ts +2 -0
- package/dist/errors/loader.js +24 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/descriptions/index.d.ts +1 -0
- package/dist/src/descriptions/index.js +1 -0
- package/dist/src/descriptions/loader.d.ts +1 -0
- package/dist/src/descriptions/loader.js +17 -0
- package/dist/src/errors/loader.d.ts +2 -0
- package/dist/src/errors/loader.js +24 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +13 -0
- package/dist/src/storage.d.ts +25 -0
- package/dist/src/storage.js +214 -0
- package/dist/src/tools/createroadmap.d.ts +2 -0
- package/dist/src/tools/createroadmap.js +135 -0
- package/dist/src/tools/readroadmap.d.ts +2 -0
- package/dist/src/tools/readroadmap.js +90 -0
- package/dist/src/tools/updateroadmap.d.ts +2 -0
- package/dist/src/tools/updateroadmap.js +107 -0
- package/dist/src/types.d.ts +151 -0
- package/dist/src/types.js +18 -0
- package/dist/storage.d.ts +25 -0
- package/dist/storage.js +214 -0
- package/dist/tools/createroadmap.d.ts +2 -0
- package/dist/tools/createroadmap.js +135 -0
- package/dist/tools/readroadmap.d.ts +2 -0
- package/dist/tools/readroadmap.js +90 -0
- package/dist/tools/updateroadmap.d.ts +2 -0
- package/dist/tools/updateroadmap.js +107 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.js +18 -0
- package/package.json +50 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { FileStorage, RoadmapValidator } from "../storage.js";
|
|
3
|
+
import { loadDescription } from "../descriptions/index.js";
|
|
4
|
+
import { getErrorMessage } from "../errors/loader.js";
|
|
5
|
+
export async function createReadRoadmapTool(directory) {
|
|
6
|
+
const description = await loadDescription("readroadmap.txt");
|
|
7
|
+
return tool({
|
|
8
|
+
description,
|
|
9
|
+
args: {
|
|
10
|
+
actionNumber: tool.schema
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe('Specific action to read ("1.01", "1.02", etc.). If not provided, reads entire roadmap.'),
|
|
14
|
+
featureNumber: tool.schema
|
|
15
|
+
.string()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('Specific feature to read ("1", "2", etc.). Use only if not providing actionNumber.'),
|
|
18
|
+
},
|
|
19
|
+
async execute(args) {
|
|
20
|
+
const storage = new FileStorage(directory);
|
|
21
|
+
if (!(await storage.exists())) {
|
|
22
|
+
throw new Error(await getErrorMessage("roadmap_not_found"));
|
|
23
|
+
}
|
|
24
|
+
const roadmap = await storage.read();
|
|
25
|
+
if (!roadmap) {
|
|
26
|
+
throw new Error(await getErrorMessage("roadmap_corrupted"));
|
|
27
|
+
}
|
|
28
|
+
if (args.actionNumber && args.featureNumber) {
|
|
29
|
+
throw new Error("Cannot specify both actionNumber and featureNumber. Use one or the other, or neither for full roadmap.");
|
|
30
|
+
}
|
|
31
|
+
if (args.actionNumber) {
|
|
32
|
+
const actionNumberError = await RoadmapValidator.validateActionNumber(args.actionNumber);
|
|
33
|
+
if (actionNumberError) {
|
|
34
|
+
throw new Error(`${actionNumberError.message} Use ReadRoadmap to see valid action numbers.`);
|
|
35
|
+
}
|
|
36
|
+
for (const feature of roadmap.features) {
|
|
37
|
+
const action = feature.actions.find((a) => a.number === args.actionNumber);
|
|
38
|
+
if (action) {
|
|
39
|
+
return (`Action ${args.actionNumber} from Feature "${feature.title}":\n` +
|
|
40
|
+
`Description: ${action.description}\n` +
|
|
41
|
+
`Status: ${action.status}\n` +
|
|
42
|
+
`Feature: ${feature.number} - ${feature.title}\n` +
|
|
43
|
+
`Feature Description: ${feature.description}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`Action "${args.actionNumber}" not found. Use ReadRoadmap with no arguments to see all available actions.`);
|
|
47
|
+
}
|
|
48
|
+
if (args.featureNumber) {
|
|
49
|
+
const featureNumberError = await RoadmapValidator.validateFeatureNumber(args.featureNumber);
|
|
50
|
+
if (featureNumberError) {
|
|
51
|
+
throw new Error(`${featureNumberError.message} Use ReadRoadmap to see valid feature numbers.`);
|
|
52
|
+
}
|
|
53
|
+
const feature = roadmap.features.find((f) => f.number === args.featureNumber);
|
|
54
|
+
if (!feature) {
|
|
55
|
+
throw new Error(`Feature "${args.featureNumber}" not found. Use ReadRoadmap with no arguments to see all available features.`);
|
|
56
|
+
}
|
|
57
|
+
const actionList = feature.actions
|
|
58
|
+
.map((action) => ` ${action.number}: ${action.description} [${action.status}]`)
|
|
59
|
+
.join("\n");
|
|
60
|
+
const completedCount = feature.actions.filter((a) => a.status === "completed").length;
|
|
61
|
+
const totalCount = feature.actions.length;
|
|
62
|
+
return (`Feature ${feature.number}: ${feature.title}\n` +
|
|
63
|
+
`Description: ${feature.description}\n` +
|
|
64
|
+
`Progress: ${completedCount}/${totalCount} actions completed\n` +
|
|
65
|
+
`Actions:\n${actionList}`);
|
|
66
|
+
}
|
|
67
|
+
const totalActions = roadmap.features.reduce((sum, feature) => sum + feature.actions.length, 0);
|
|
68
|
+
const completedActions = roadmap.features.reduce((sum, feature) => sum + feature.actions.filter((a) => a.status === "completed").length, 0);
|
|
69
|
+
const inProgressActions = roadmap.features.reduce((sum, feature) => sum + feature.actions.filter((a) => a.status === "in_progress").length, 0);
|
|
70
|
+
const pendingActions = totalActions - completedActions - inProgressActions;
|
|
71
|
+
let output = `Project Roadmap Overview\n` +
|
|
72
|
+
`========================\n` +
|
|
73
|
+
`Features: ${roadmap.features.length}\n` +
|
|
74
|
+
`Total Actions: ${totalActions}\n` +
|
|
75
|
+
`Progress: ${completedActions} completed, ${inProgressActions} in progress, ${pendingActions} pending\n\n`;
|
|
76
|
+
for (const feature of roadmap.features) {
|
|
77
|
+
const featureCompleted = feature.actions.filter((a) => a.status === "completed").length;
|
|
78
|
+
const featureTotal = feature.actions.length;
|
|
79
|
+
output += `Feature ${feature.number}: ${feature.title} (${featureCompleted}/${featureTotal} complete)\n`;
|
|
80
|
+
output += ` Description: ${feature.description}\n`;
|
|
81
|
+
for (const action of feature.actions) {
|
|
82
|
+
const statusIcon = action.status === "completed" ? "✓" : action.status === "in_progress" ? "→" : "○";
|
|
83
|
+
output += ` ${action.number} ${statusIcon} ${action.description} [${action.status}]\n`;
|
|
84
|
+
}
|
|
85
|
+
output += "\n";
|
|
86
|
+
}
|
|
87
|
+
return output.trim();
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { FileStorage, RoadmapValidator } from "../storage.js";
|
|
3
|
+
import { loadDescription } from "../descriptions/index.js";
|
|
4
|
+
import { getErrorMessage } from "../errors/loader.js";
|
|
5
|
+
export async function createUpdateRoadmapTool(directory) {
|
|
6
|
+
const description = await loadDescription("updateroadmap.txt");
|
|
7
|
+
return tool({
|
|
8
|
+
description,
|
|
9
|
+
args: {
|
|
10
|
+
actionNumber: tool.schema.string().describe('Action number to update ("1.01", "1.02", "2.01", etc.) - required'),
|
|
11
|
+
description: tool.schema
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("New action description (full overwrite). If not provided, only status is updated."),
|
|
15
|
+
status: tool.schema
|
|
16
|
+
.enum(["pending", "in_progress", "completed"])
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("New action status - optional if only updating description"),
|
|
19
|
+
},
|
|
20
|
+
async execute(args) {
|
|
21
|
+
const storage = new FileStorage(directory);
|
|
22
|
+
if (!(await storage.exists())) {
|
|
23
|
+
throw new Error(await getErrorMessage("roadmap_not_found"));
|
|
24
|
+
}
|
|
25
|
+
const roadmap = await storage.read();
|
|
26
|
+
if (!roadmap) {
|
|
27
|
+
throw new Error(await getErrorMessage("roadmap_corrupted"));
|
|
28
|
+
}
|
|
29
|
+
const actionNumberError = await RoadmapValidator.validateActionNumber(args.actionNumber);
|
|
30
|
+
if (actionNumberError) {
|
|
31
|
+
throw new Error(`${actionNumberError.message} Use ReadRoadmap to see valid action numbers.`);
|
|
32
|
+
}
|
|
33
|
+
let targetAction = null;
|
|
34
|
+
let targetFeature = null;
|
|
35
|
+
let actionFound = false;
|
|
36
|
+
for (const feature of roadmap.features) {
|
|
37
|
+
const action = feature.actions.find((a) => a.number === args.actionNumber);
|
|
38
|
+
if (action) {
|
|
39
|
+
targetAction = action;
|
|
40
|
+
targetFeature = feature;
|
|
41
|
+
actionFound = true;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!actionFound) {
|
|
46
|
+
throw new Error(await getErrorMessage("action_not_found", { id: args.actionNumber }));
|
|
47
|
+
}
|
|
48
|
+
// Validate that at least one field is being updated
|
|
49
|
+
if (args.description === undefined && args.status === undefined) {
|
|
50
|
+
throw new Error(await getErrorMessage("no_changes_specified"));
|
|
51
|
+
}
|
|
52
|
+
const oldStatus = targetAction.status;
|
|
53
|
+
const oldDescription = targetAction.description;
|
|
54
|
+
// Validate description if provided
|
|
55
|
+
if (args.description !== undefined) {
|
|
56
|
+
const descError = await RoadmapValidator.validateDescription(args.description, "action");
|
|
57
|
+
if (descError) {
|
|
58
|
+
throw new Error(`${descError.message}`);
|
|
59
|
+
}
|
|
60
|
+
targetAction.description = args.description;
|
|
61
|
+
}
|
|
62
|
+
// Validate and update status if provided
|
|
63
|
+
if (args.status !== undefined) {
|
|
64
|
+
const statusTransitionError = await RoadmapValidator.validateStatusProgression(targetAction.status, args.status);
|
|
65
|
+
if (statusTransitionError) {
|
|
66
|
+
throw new Error(`${statusTransitionError.message} Current status: "${targetAction.status}", requested: "${args.status}"`);
|
|
67
|
+
}
|
|
68
|
+
targetAction.status = args.status;
|
|
69
|
+
}
|
|
70
|
+
await storage.write(roadmap);
|
|
71
|
+
const changes = [];
|
|
72
|
+
if (args.description !== undefined && oldDescription !== args.description) {
|
|
73
|
+
changes.push(`description updated`);
|
|
74
|
+
}
|
|
75
|
+
if (args.status !== undefined && oldStatus !== args.status) {
|
|
76
|
+
changes.push(`status: "${oldStatus}" → "${args.status}"`);
|
|
77
|
+
}
|
|
78
|
+
// Check if all actions are completed
|
|
79
|
+
let allCompleted = true;
|
|
80
|
+
for (const feature of roadmap.features) {
|
|
81
|
+
for (const action of feature.actions) {
|
|
82
|
+
if (action.status !== "completed") {
|
|
83
|
+
allCompleted = false;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!allCompleted)
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
let archiveMsg = "";
|
|
91
|
+
if (allCompleted) {
|
|
92
|
+
const archiveName = await storage.archive();
|
|
93
|
+
archiveMsg = `\n\n🎉 All actions completed! Roadmap archived to "${archiveName}".`;
|
|
94
|
+
}
|
|
95
|
+
// Format feature context
|
|
96
|
+
const featureCompleted = targetFeature.actions.filter((a) => a.status === "completed").length;
|
|
97
|
+
const featureTotal = targetFeature.actions.length;
|
|
98
|
+
let featureContext = `\n\nFeature ${targetFeature.number}: ${targetFeature.title} (${featureCompleted}/${featureTotal} complete)\n`;
|
|
99
|
+
featureContext += `Description: ${targetFeature.description}\n`;
|
|
100
|
+
for (const action of targetFeature.actions) {
|
|
101
|
+
const statusIcon = action.status === "completed" ? "✓" : action.status === "in_progress" ? "→" : "○";
|
|
102
|
+
featureContext += `${action.number} ${statusIcon} ${action.description} [${action.status}]\n`;
|
|
103
|
+
}
|
|
104
|
+
return `Updated action ${args.actionNumber} in feature "${targetFeature.title}": ${changes.join(", ")}${featureContext}${archiveMsg}`;
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const ActionStatus: z.ZodEnum<["pending", "in_progress", "completed"]>;
|
|
3
|
+
export type ActionStatus = z.infer<typeof ActionStatus>;
|
|
4
|
+
export declare const Action: z.ZodObject<{
|
|
5
|
+
number: z.ZodString;
|
|
6
|
+
description: z.ZodString;
|
|
7
|
+
status: z.ZodEnum<["pending", "in_progress", "completed"]>;
|
|
8
|
+
}, "strip", z.ZodTypeAny, {
|
|
9
|
+
number: string;
|
|
10
|
+
status: "pending" | "in_progress" | "completed";
|
|
11
|
+
description: string;
|
|
12
|
+
}, {
|
|
13
|
+
number: string;
|
|
14
|
+
status: "pending" | "in_progress" | "completed";
|
|
15
|
+
description: string;
|
|
16
|
+
}>;
|
|
17
|
+
export type Action = z.infer<typeof Action>;
|
|
18
|
+
export declare const Feature: z.ZodObject<{
|
|
19
|
+
number: z.ZodString;
|
|
20
|
+
title: z.ZodString;
|
|
21
|
+
description: z.ZodString;
|
|
22
|
+
actions: z.ZodArray<z.ZodObject<{
|
|
23
|
+
number: z.ZodString;
|
|
24
|
+
description: z.ZodString;
|
|
25
|
+
status: z.ZodEnum<["pending", "in_progress", "completed"]>;
|
|
26
|
+
}, "strip", z.ZodTypeAny, {
|
|
27
|
+
number: string;
|
|
28
|
+
status: "pending" | "in_progress" | "completed";
|
|
29
|
+
description: string;
|
|
30
|
+
}, {
|
|
31
|
+
number: string;
|
|
32
|
+
status: "pending" | "in_progress" | "completed";
|
|
33
|
+
description: string;
|
|
34
|
+
}>, "many">;
|
|
35
|
+
}, "strip", z.ZodTypeAny, {
|
|
36
|
+
number: string;
|
|
37
|
+
description: string;
|
|
38
|
+
title: string;
|
|
39
|
+
actions: {
|
|
40
|
+
number: string;
|
|
41
|
+
status: "pending" | "in_progress" | "completed";
|
|
42
|
+
description: string;
|
|
43
|
+
}[];
|
|
44
|
+
}, {
|
|
45
|
+
number: string;
|
|
46
|
+
description: string;
|
|
47
|
+
title: string;
|
|
48
|
+
actions: {
|
|
49
|
+
number: string;
|
|
50
|
+
status: "pending" | "in_progress" | "completed";
|
|
51
|
+
description: string;
|
|
52
|
+
}[];
|
|
53
|
+
}>;
|
|
54
|
+
export type Feature = z.infer<typeof Feature>;
|
|
55
|
+
export declare const Roadmap: z.ZodObject<{
|
|
56
|
+
features: z.ZodArray<z.ZodObject<{
|
|
57
|
+
number: z.ZodString;
|
|
58
|
+
title: z.ZodString;
|
|
59
|
+
description: z.ZodString;
|
|
60
|
+
actions: z.ZodArray<z.ZodObject<{
|
|
61
|
+
number: z.ZodString;
|
|
62
|
+
description: z.ZodString;
|
|
63
|
+
status: z.ZodEnum<["pending", "in_progress", "completed"]>;
|
|
64
|
+
}, "strip", z.ZodTypeAny, {
|
|
65
|
+
number: string;
|
|
66
|
+
status: "pending" | "in_progress" | "completed";
|
|
67
|
+
description: string;
|
|
68
|
+
}, {
|
|
69
|
+
number: string;
|
|
70
|
+
status: "pending" | "in_progress" | "completed";
|
|
71
|
+
description: string;
|
|
72
|
+
}>, "many">;
|
|
73
|
+
}, "strip", z.ZodTypeAny, {
|
|
74
|
+
number: string;
|
|
75
|
+
description: string;
|
|
76
|
+
title: string;
|
|
77
|
+
actions: {
|
|
78
|
+
number: string;
|
|
79
|
+
status: "pending" | "in_progress" | "completed";
|
|
80
|
+
description: string;
|
|
81
|
+
}[];
|
|
82
|
+
}, {
|
|
83
|
+
number: string;
|
|
84
|
+
description: string;
|
|
85
|
+
title: string;
|
|
86
|
+
actions: {
|
|
87
|
+
number: string;
|
|
88
|
+
status: "pending" | "in_progress" | "completed";
|
|
89
|
+
description: string;
|
|
90
|
+
}[];
|
|
91
|
+
}>, "many">;
|
|
92
|
+
}, "strip", z.ZodTypeAny, {
|
|
93
|
+
features: {
|
|
94
|
+
number: string;
|
|
95
|
+
description: string;
|
|
96
|
+
title: string;
|
|
97
|
+
actions: {
|
|
98
|
+
number: string;
|
|
99
|
+
status: "pending" | "in_progress" | "completed";
|
|
100
|
+
description: string;
|
|
101
|
+
}[];
|
|
102
|
+
}[];
|
|
103
|
+
}, {
|
|
104
|
+
features: {
|
|
105
|
+
number: string;
|
|
106
|
+
description: string;
|
|
107
|
+
title: string;
|
|
108
|
+
actions: {
|
|
109
|
+
number: string;
|
|
110
|
+
status: "pending" | "in_progress" | "completed";
|
|
111
|
+
description: string;
|
|
112
|
+
}[];
|
|
113
|
+
}[];
|
|
114
|
+
}>;
|
|
115
|
+
export type Roadmap = z.infer<typeof Roadmap>;
|
|
116
|
+
export interface RoadmapStorage {
|
|
117
|
+
read(): Promise<Roadmap | null>;
|
|
118
|
+
write(roadmap: Roadmap): Promise<void>;
|
|
119
|
+
exists(): Promise<boolean>;
|
|
120
|
+
archive(): Promise<string>;
|
|
121
|
+
}
|
|
122
|
+
export interface ValidationError {
|
|
123
|
+
code: string;
|
|
124
|
+
message: string;
|
|
125
|
+
tutorial?: string;
|
|
126
|
+
}
|
|
127
|
+
export interface ValidationResult {
|
|
128
|
+
isValid: boolean;
|
|
129
|
+
errors: ValidationError[];
|
|
130
|
+
}
|
|
131
|
+
export interface CreateRoadmapInput {
|
|
132
|
+
features: Array<{
|
|
133
|
+
number: string;
|
|
134
|
+
title: string;
|
|
135
|
+
description: string;
|
|
136
|
+
actions: Array<{
|
|
137
|
+
number: string;
|
|
138
|
+
description: string;
|
|
139
|
+
status: "pending";
|
|
140
|
+
}>;
|
|
141
|
+
}>;
|
|
142
|
+
}
|
|
143
|
+
export interface UpdateRoadmapInput {
|
|
144
|
+
actionNumber: string;
|
|
145
|
+
description?: string;
|
|
146
|
+
status: ActionStatus;
|
|
147
|
+
}
|
|
148
|
+
export interface ReadRoadmapInput {
|
|
149
|
+
actionNumber?: string;
|
|
150
|
+
featureNumber?: string;
|
|
151
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const ActionStatus = z.enum(["pending", "in_progress", "completed"]);
|
|
3
|
+
export const Action = z.object({
|
|
4
|
+
number: z
|
|
5
|
+
.string()
|
|
6
|
+
.describe('Action number as string with two decimals ("1.01", "1.02", etc.) - canonical ID, never changes'),
|
|
7
|
+
description: z.string().describe("Action description - MUTABLE (overwrite only)"),
|
|
8
|
+
status: ActionStatus.describe("Current status of this action - MUTABLE"),
|
|
9
|
+
});
|
|
10
|
+
export const Feature = z.object({
|
|
11
|
+
number: z.string().describe('Feature number as string ("1", "2", "3...") - canonical ID, never changes'),
|
|
12
|
+
title: z.string().describe("Feature title"),
|
|
13
|
+
description: z.string().describe("Brief description of what this feature accomplishes"),
|
|
14
|
+
actions: z.array(Action).describe("Array of actions for this feature"),
|
|
15
|
+
});
|
|
16
|
+
export const Roadmap = z.object({
|
|
17
|
+
features: z.array(Feature).describe("Array of features in the roadmap"),
|
|
18
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Roadmap, RoadmapStorage, ValidationError } from "./types";
|
|
2
|
+
export declare class FileStorage implements RoadmapStorage {
|
|
3
|
+
private directory;
|
|
4
|
+
constructor(directory: string);
|
|
5
|
+
exists(): Promise<boolean>;
|
|
6
|
+
read(): Promise<Roadmap | null>;
|
|
7
|
+
write(roadmap: Roadmap): Promise<void>;
|
|
8
|
+
archive(): Promise<string>;
|
|
9
|
+
}
|
|
10
|
+
export declare class RoadmapValidator {
|
|
11
|
+
static validateFeatureNumber(number: string): Promise<ValidationError | null>;
|
|
12
|
+
static validateActionNumber(number: string): Promise<ValidationError | null>;
|
|
13
|
+
static validateActionSequence(actions: Array<{
|
|
14
|
+
number: string;
|
|
15
|
+
}>, globalSeenNumbers?: Set<string>, featureNumber?: string): Promise<ValidationError[]>;
|
|
16
|
+
static validateFeatureSequence(features: Array<{
|
|
17
|
+
number: string;
|
|
18
|
+
actions: Array<{
|
|
19
|
+
number: string;
|
|
20
|
+
}>;
|
|
21
|
+
}>): Promise<ValidationError[]>;
|
|
22
|
+
static validateTitle(title: string, fieldType: "feature" | "action"): Promise<ValidationError | null>;
|
|
23
|
+
static validateDescription(description: string, fieldType: "feature" | "action"): Promise<ValidationError | null>;
|
|
24
|
+
static validateStatusProgression(currentStatus: string, newStatus: string): Promise<ValidationError | null>;
|
|
25
|
+
}
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { getErrorMessage } from "./errors/loader.js";
|
|
4
|
+
const ROADMAP_FILE = "roadmap.json";
|
|
5
|
+
export class FileStorage {
|
|
6
|
+
directory;
|
|
7
|
+
constructor(directory) {
|
|
8
|
+
this.directory = directory;
|
|
9
|
+
}
|
|
10
|
+
async exists() {
|
|
11
|
+
try {
|
|
12
|
+
await fs.access(join(this.directory, ROADMAP_FILE));
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async read() {
|
|
20
|
+
try {
|
|
21
|
+
const filePath = join(this.directory, ROADMAP_FILE);
|
|
22
|
+
const data = await fs.readFile(filePath, "utf-8");
|
|
23
|
+
if (!data.trim()) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const parsed = JSON.parse(data);
|
|
27
|
+
if (!parsed || typeof parsed !== "object") {
|
|
28
|
+
throw new Error("Invalid roadmap format: not an object");
|
|
29
|
+
}
|
|
30
|
+
if (!Array.isArray(parsed.features)) {
|
|
31
|
+
throw new Error("Invalid roadmap format: missing or invalid features array");
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof SyntaxError) {
|
|
37
|
+
throw new Error("Roadmap file contains invalid JSON. File may be corrupted.");
|
|
38
|
+
}
|
|
39
|
+
if (error instanceof Error && error.message.includes("ENOENT")) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async write(roadmap) {
|
|
46
|
+
const filePath = join(this.directory, ROADMAP_FILE);
|
|
47
|
+
const tempPath = join(this.directory, `${ROADMAP_FILE}.tmp.${Date.now()}`);
|
|
48
|
+
try {
|
|
49
|
+
const data = JSON.stringify(roadmap, null, 2);
|
|
50
|
+
await fs.writeFile(tempPath, data, "utf-8");
|
|
51
|
+
await fs.rename(tempPath, filePath);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
try {
|
|
55
|
+
await fs.unlink(tempPath);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Ignore cleanup errors
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async archive() {
|
|
64
|
+
const filePath = join(this.directory, ROADMAP_FILE);
|
|
65
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
66
|
+
const archiveFilename = `roadmap.archive.${timestamp}.json`;
|
|
67
|
+
const archivePath = join(this.directory, archiveFilename);
|
|
68
|
+
await fs.rename(filePath, archivePath);
|
|
69
|
+
return archiveFilename;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export class RoadmapValidator {
|
|
73
|
+
static async validateFeatureNumber(number) {
|
|
74
|
+
if (!number || typeof number !== "string") {
|
|
75
|
+
return {
|
|
76
|
+
code: "INVALID_FEATURE_NUMBER",
|
|
77
|
+
message: await getErrorMessage("invalid_feature_id", { id: "undefined" }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (!/^\d+$/.test(number)) {
|
|
81
|
+
return {
|
|
82
|
+
code: "INVALID_FEATURE_NUMBER_FORMAT",
|
|
83
|
+
message: await getErrorMessage("invalid_feature_id", { id: number }),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
static async validateActionNumber(number) {
|
|
89
|
+
if (!number || typeof number !== "string") {
|
|
90
|
+
return {
|
|
91
|
+
code: "INVALID_ACTION_NUMBER",
|
|
92
|
+
message: await getErrorMessage("invalid_action_id", { id: "undefined" }),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (!/^\d+\.\d{2}$/.test(number)) {
|
|
96
|
+
return {
|
|
97
|
+
code: "INVALID_ACTION_NUMBER_FORMAT",
|
|
98
|
+
message: await getErrorMessage("invalid_action_id", { id: number }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
static async validateActionSequence(actions, globalSeenNumbers, featureNumber) {
|
|
104
|
+
const errors = [];
|
|
105
|
+
const seenNumbers = new Set();
|
|
106
|
+
for (let i = 0; i < actions.length; i++) {
|
|
107
|
+
const action = actions[i];
|
|
108
|
+
const numberError = await this.validateActionNumber(action.number);
|
|
109
|
+
if (numberError) {
|
|
110
|
+
errors.push(numberError);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// Check action-feature mismatch
|
|
114
|
+
if (featureNumber) {
|
|
115
|
+
const actionFeaturePrefix = action.number.split('.')[0];
|
|
116
|
+
if (actionFeaturePrefix !== featureNumber) {
|
|
117
|
+
errors.push({
|
|
118
|
+
code: "ACTION_FEATURE_MISMATCH",
|
|
119
|
+
message: await getErrorMessage("action_mismatch", { action: action.number, feature: featureNumber }),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Check for duplicates within this feature
|
|
124
|
+
if (seenNumbers.has(action.number)) {
|
|
125
|
+
errors.push({
|
|
126
|
+
code: "DUPLICATE_ACTION_NUMBER",
|
|
127
|
+
message: `Duplicate action ID "${action.number}".`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Check for global duplicates
|
|
131
|
+
if (globalSeenNumbers?.has(action.number)) {
|
|
132
|
+
errors.push({
|
|
133
|
+
code: "DUPLICATE_ACTION_NUMBER_GLOBAL",
|
|
134
|
+
message: `Duplicate action ID "${action.number}" (exists in another feature).`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
seenNumbers.add(action.number);
|
|
138
|
+
globalSeenNumbers?.add(action.number);
|
|
139
|
+
}
|
|
140
|
+
return errors;
|
|
141
|
+
}
|
|
142
|
+
static async validateFeatureSequence(features) {
|
|
143
|
+
const errors = [];
|
|
144
|
+
const seenNumbers = new Set();
|
|
145
|
+
const seenActionNumbers = new Set();
|
|
146
|
+
for (let i = 0; i < features.length; i++) {
|
|
147
|
+
const feature = features[i];
|
|
148
|
+
const numberError = await this.validateFeatureNumber(feature.number);
|
|
149
|
+
if (numberError) {
|
|
150
|
+
errors.push(numberError);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (seenNumbers.has(feature.number)) {
|
|
154
|
+
errors.push({
|
|
155
|
+
code: "DUPLICATE_FEATURE_NUMBER",
|
|
156
|
+
message: `Duplicate feature ID "${feature.number}".`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
seenNumbers.add(feature.number);
|
|
160
|
+
const actionErrors = await this.validateActionSequence(feature.actions, seenActionNumbers, feature.number);
|
|
161
|
+
errors.push(...actionErrors);
|
|
162
|
+
}
|
|
163
|
+
return errors;
|
|
164
|
+
}
|
|
165
|
+
static async validateTitle(title, fieldType) {
|
|
166
|
+
if (!title || typeof title !== "string") {
|
|
167
|
+
return {
|
|
168
|
+
code: "INVALID_TITLE",
|
|
169
|
+
message: `Invalid ${fieldType} title. Must be non-empty string.`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (title.trim() === "") {
|
|
173
|
+
return {
|
|
174
|
+
code: "EMPTY_TITLE",
|
|
175
|
+
message: `${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)} title cannot be empty.`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
static async validateDescription(description, fieldType) {
|
|
181
|
+
if (!description || typeof description !== "string") {
|
|
182
|
+
return {
|
|
183
|
+
code: "INVALID_DESCRIPTION",
|
|
184
|
+
message: `Invalid ${fieldType} description. Must be non-empty string.`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (description.trim() === "") {
|
|
188
|
+
return {
|
|
189
|
+
code: "EMPTY_DESCRIPTION",
|
|
190
|
+
message: `${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)} description cannot be empty.`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
static async validateStatusProgression(currentStatus, newStatus) {
|
|
196
|
+
const statusFlow = {
|
|
197
|
+
pending: ["in_progress", "completed"],
|
|
198
|
+
in_progress: ["completed"],
|
|
199
|
+
completed: [],
|
|
200
|
+
};
|
|
201
|
+
const allowedTransitions = statusFlow[currentStatus] || [];
|
|
202
|
+
if (!allowedTransitions.includes(newStatus)) {
|
|
203
|
+
return {
|
|
204
|
+
code: "INVALID_STATUS_TRANSITION",
|
|
205
|
+
message: await getErrorMessage("invalid_transition", {
|
|
206
|
+
from: currentStatus,
|
|
207
|
+
to: newStatus,
|
|
208
|
+
allowed: allowedTransitions.length > 0 ? allowedTransitions.join(", ") : "None (terminal state)"
|
|
209
|
+
}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|