@deksden-com/dd-flow-cli 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/README.md +274 -0
- package/dist/cli/help.js +308 -0
- package/dist/cli/run-cli.js +945 -0
- package/dist/cli.js +4 -0
- package/dist/domain/contracts.js +57 -0
- package/dist/domain/entity-ids.js +47 -0
- package/dist/domain/flow-contract.js +233 -0
- package/dist/domain/validation.js +91 -0
- package/dist/protocol/local-files.js +141 -0
- package/dist/runtime/context.js +11 -0
- package/dist/schemas/code-stage-report.schema.json +181 -0
- package/dist/schemas/flow-run-index.schema.json +129 -0
- package/dist/schemas/mb-upgrade-review-data.schema.json +813 -0
- package/dist/schemas/memorybank-permissions-preflight.schema.json +154 -0
- package/dist/schemas/merge-stage-report.schema.json +135 -0
- package/dist/services/audit.js +19 -0
- package/dist/services/cleanup.js +310 -0
- package/dist/services/config.js +143 -0
- package/dist/services/dashboard.js +436 -0
- package/dist/services/hooks.js +929 -0
- package/dist/services/lanes.js +327 -0
- package/dist/services/memory-permissions.js +344 -0
- package/dist/services/merge-queue.js +333 -0
- package/dist/services/plans.js +149 -0
- package/dist/services/projects.js +286 -0
- package/dist/services/protocols.js +606 -0
- package/dist/services/runs.js +359 -0
- package/dist/services/schema-validation.js +185 -0
- package/dist/services/sessions.js +365 -0
- package/dist/services/worktrees.js +204 -0
- package/dist/shared/errors.js +14 -0
- package/dist/shared/json.js +17 -0
- package/dist/storage/database.js +325 -0
- package/dist/storage/paths.js +56 -0
- package/package.json +44 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { ensureDir, runtimeRunJsonPath, userFacingRunDir, userFacingRunIndexPath, resolveProjectRoot } from "../storage/paths.js";
|
|
6
|
+
import { appendAudit } from "./audit.js";
|
|
7
|
+
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
8
|
+
const runSchemaId = "dd-flow/flow-run-index@1";
|
|
9
|
+
const runIdType = "RUN";
|
|
10
|
+
const allowedRunFlowKinds = [
|
|
11
|
+
"coding",
|
|
12
|
+
"experiment",
|
|
13
|
+
"mb-init",
|
|
14
|
+
"mb-upgrade",
|
|
15
|
+
"mb-audit",
|
|
16
|
+
"mb-distill",
|
|
17
|
+
"mb-upgrade-review",
|
|
18
|
+
"custom"
|
|
19
|
+
];
|
|
20
|
+
export function startFlowRun(context, input) {
|
|
21
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
22
|
+
registerProject(context, { root: projectRoot });
|
|
23
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
24
|
+
const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
|
|
25
|
+
const flowKind = parseRunFlowKind(input.flowKind);
|
|
26
|
+
const slug = normalizeSlug(input.slug);
|
|
27
|
+
const runId = nextRunId(context, slug);
|
|
28
|
+
const { shortId } = parseFullEntityId(runId);
|
|
29
|
+
const now = context.now();
|
|
30
|
+
const runDirAbsolute = userFacingRunDir(workspaceRoot, runId);
|
|
31
|
+
const runIndexAbsolute = userFacingRunIndexPath(workspaceRoot, runId);
|
|
32
|
+
const runDirRelative = path.relative(workspaceRoot, runDirAbsolute);
|
|
33
|
+
const runtimePath = runtimeRunJsonPath(context.ddFlowHome, project.id, runId);
|
|
34
|
+
const index = {
|
|
35
|
+
schema_id: runSchemaId,
|
|
36
|
+
run_id: runId,
|
|
37
|
+
short_id: shortId,
|
|
38
|
+
flow_kind: flowKind,
|
|
39
|
+
subject: {
|
|
40
|
+
type: requiredPlain(input.subjectType, "subject-type"),
|
|
41
|
+
id: requiredPlain(input.subjectId, "subject-id")
|
|
42
|
+
},
|
|
43
|
+
project: {
|
|
44
|
+
id: project.id,
|
|
45
|
+
root: project.root
|
|
46
|
+
},
|
|
47
|
+
workspace: {
|
|
48
|
+
root: workspaceRoot,
|
|
49
|
+
run_dir: runDirRelative
|
|
50
|
+
},
|
|
51
|
+
stage_runs: [],
|
|
52
|
+
sessions: [],
|
|
53
|
+
artifacts: [],
|
|
54
|
+
status: "running",
|
|
55
|
+
verdict: "pending",
|
|
56
|
+
next_action: input.nextAction ?? null,
|
|
57
|
+
created_at: now,
|
|
58
|
+
updated_at: now
|
|
59
|
+
};
|
|
60
|
+
ensureDir(path.dirname(runtimePath));
|
|
61
|
+
ensureDir(runDirAbsolute);
|
|
62
|
+
writeJsonFile(runtimePath, index);
|
|
63
|
+
writeJsonFile(runIndexAbsolute, index);
|
|
64
|
+
context.db.run(`INSERT INTO flow_runs
|
|
65
|
+
(id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
|
|
66
|
+
status, verdict, next_action, runtime_path, run_dir, run_index_path, index_json, created_at, updated_at, completed_at)
|
|
67
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
|
|
68
|
+
runId,
|
|
69
|
+
shortId,
|
|
70
|
+
slug,
|
|
71
|
+
project.id,
|
|
72
|
+
project.root,
|
|
73
|
+
workspaceRoot,
|
|
74
|
+
flowKind,
|
|
75
|
+
index.subject.type,
|
|
76
|
+
index.subject.id,
|
|
77
|
+
index.status,
|
|
78
|
+
index.verdict,
|
|
79
|
+
index.next_action,
|
|
80
|
+
runtimePath,
|
|
81
|
+
runDirRelative,
|
|
82
|
+
runIndexAbsolute,
|
|
83
|
+
JSON.stringify(index),
|
|
84
|
+
now,
|
|
85
|
+
now
|
|
86
|
+
]);
|
|
87
|
+
appendAudit(context, {
|
|
88
|
+
projectId: project.id,
|
|
89
|
+
eventType: "flow_run.started",
|
|
90
|
+
payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot }
|
|
91
|
+
});
|
|
92
|
+
return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
|
|
93
|
+
}
|
|
94
|
+
export function getFlowRunStatus(context, input) {
|
|
95
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
96
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
97
|
+
return { ok: true, run: flowRunSummary(run), index: parseRunIndex(run.index_json) };
|
|
98
|
+
}
|
|
99
|
+
export function listFlowRuns(context, input) {
|
|
100
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
101
|
+
const runs = context.db
|
|
102
|
+
.all(`SELECT * FROM flow_runs
|
|
103
|
+
WHERE project_id = ?
|
|
104
|
+
ORDER BY updated_at DESC, id DESC`, [project.id])
|
|
105
|
+
.map(flowRunSummary);
|
|
106
|
+
return { ok: true, project, runs };
|
|
107
|
+
}
|
|
108
|
+
export function attachFlowRunStage(context, input) {
|
|
109
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
110
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
111
|
+
const index = parseRunIndex(run.index_json);
|
|
112
|
+
const now = context.now();
|
|
113
|
+
const stage = requiredPlain(input.stage, "stage");
|
|
114
|
+
const dir = requiredStageDir(input.dir);
|
|
115
|
+
const status = parseStageStatus(input.status);
|
|
116
|
+
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
117
|
+
const stageRun = {
|
|
118
|
+
...(existing ?? { order: index.stage_runs.length + 1 }),
|
|
119
|
+
stage,
|
|
120
|
+
dir,
|
|
121
|
+
status,
|
|
122
|
+
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
|
|
123
|
+
updated_at: now
|
|
124
|
+
};
|
|
125
|
+
upsertStage(index, stageRun);
|
|
126
|
+
index.updated_at = now;
|
|
127
|
+
persistRunIndex(context, project, run, index);
|
|
128
|
+
appendAudit(context, {
|
|
129
|
+
projectId: project.id,
|
|
130
|
+
eventType: "flow_run.stage_attached",
|
|
131
|
+
payload: { run_id: run.id, stage, dir, status }
|
|
132
|
+
});
|
|
133
|
+
return { ok: true, run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
134
|
+
}
|
|
135
|
+
export function completeFlowRunStage(context, input) {
|
|
136
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
137
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
138
|
+
const index = parseRunIndex(run.index_json);
|
|
139
|
+
const now = context.now();
|
|
140
|
+
const stage = requiredPlain(input.stage, "stage");
|
|
141
|
+
const status = parseStageStatus(input.status);
|
|
142
|
+
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
143
|
+
if (!existing) {
|
|
144
|
+
throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
|
|
145
|
+
}
|
|
146
|
+
const stageRun = {
|
|
147
|
+
...existing,
|
|
148
|
+
status,
|
|
149
|
+
...(input.stageReport ? { stage_report: input.stageReport } : {}),
|
|
150
|
+
...(input.data ? { data: input.data } : {}),
|
|
151
|
+
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
|
|
152
|
+
...(input.report ? { report: input.report } : {}),
|
|
153
|
+
...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
|
|
154
|
+
updated_at: now
|
|
155
|
+
};
|
|
156
|
+
upsertStage(index, stageRun);
|
|
157
|
+
index.updated_at = now;
|
|
158
|
+
persistRunIndex(context, project, run, index);
|
|
159
|
+
appendAudit(context, {
|
|
160
|
+
projectId: project.id,
|
|
161
|
+
eventType: "flow_run.stage_completed",
|
|
162
|
+
payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
|
|
163
|
+
});
|
|
164
|
+
return { ok: true, run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
165
|
+
}
|
|
166
|
+
export function completeFlowRun(context, input) {
|
|
167
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
168
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
169
|
+
const index = parseRunIndex(run.index_json);
|
|
170
|
+
const now = context.now();
|
|
171
|
+
const status = parseRunStatus(input.status);
|
|
172
|
+
index.status = status;
|
|
173
|
+
index.verdict = input.verdict ?? (status === "done" ? "accepted" : status);
|
|
174
|
+
index.next_action = input.nextAction ?? null;
|
|
175
|
+
index.updated_at = now;
|
|
176
|
+
if (["done", "blocked", "cancelled", "failed"].includes(status)) {
|
|
177
|
+
index.completed_at = now;
|
|
178
|
+
}
|
|
179
|
+
persistRunIndex(context, project, run, index);
|
|
180
|
+
appendAudit(context, {
|
|
181
|
+
projectId: project.id,
|
|
182
|
+
eventType: "flow_run.completed",
|
|
183
|
+
payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action }
|
|
184
|
+
});
|
|
185
|
+
return { ok: true, run: flowRunSummary(requireRunById(context, project.id, run.id)), index };
|
|
186
|
+
}
|
|
187
|
+
function persistRunIndex(context, project, run, index) {
|
|
188
|
+
const runtimePath = runtimeRunJsonPath(context.ddFlowHome, project.id, run.id);
|
|
189
|
+
const runIndexPath = userFacingRunIndexPath(run.workspace_root, run.id);
|
|
190
|
+
ensureDir(path.dirname(runtimePath));
|
|
191
|
+
ensureDir(path.dirname(runIndexPath));
|
|
192
|
+
writeJsonFile(runtimePath, index);
|
|
193
|
+
writeJsonFile(runIndexPath, index);
|
|
194
|
+
context.db.run(`UPDATE flow_runs
|
|
195
|
+
SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
|
|
196
|
+
updated_at = ?, completed_at = ?
|
|
197
|
+
WHERE project_id = ? AND id = ?`, [
|
|
198
|
+
index.status,
|
|
199
|
+
index.verdict,
|
|
200
|
+
index.next_action,
|
|
201
|
+
JSON.stringify(index),
|
|
202
|
+
runtimePath,
|
|
203
|
+
runIndexPath,
|
|
204
|
+
index.updated_at,
|
|
205
|
+
index.completed_at ?? null,
|
|
206
|
+
project.id,
|
|
207
|
+
run.id
|
|
208
|
+
]);
|
|
209
|
+
}
|
|
210
|
+
function upsertStage(index, stageRun) {
|
|
211
|
+
const position = index.stage_runs.findIndex((item) => item.stage === stageRun.stage);
|
|
212
|
+
if (position === -1) {
|
|
213
|
+
index.stage_runs.push(stageRun);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
index.stage_runs[position] = stageRun;
|
|
217
|
+
}
|
|
218
|
+
index.stage_runs.sort((a, b) => a.order - b.order);
|
|
219
|
+
}
|
|
220
|
+
function resolveRun(context, projectId, idOrAlias) {
|
|
221
|
+
if (isFullEntityId(idOrAlias)) {
|
|
222
|
+
return requireRunById(context, projectId, idOrAlias);
|
|
223
|
+
}
|
|
224
|
+
if (isShortEntityId(idOrAlias)) {
|
|
225
|
+
const matches = context.db.all("SELECT * FROM flow_runs WHERE project_id = ? AND short_id = ? ORDER BY updated_at DESC", [
|
|
226
|
+
projectId,
|
|
227
|
+
idOrAlias
|
|
228
|
+
]);
|
|
229
|
+
if (matches.length === 1)
|
|
230
|
+
return matches[0];
|
|
231
|
+
if (matches.length > 1) {
|
|
232
|
+
throw new AppError("ambiguous_alias", `Run alias is ambiguous: ${idOrAlias}`, 1, {
|
|
233
|
+
candidates: matches.map(flowRunSummary)
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
throw new AppError("not_found", `Run is not registered: ${idOrAlias}`, 1, { run_id: idOrAlias });
|
|
238
|
+
}
|
|
239
|
+
function requireRunById(context, projectId, runId) {
|
|
240
|
+
const run = context.db.get("SELECT * FROM flow_runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
241
|
+
if (!run) {
|
|
242
|
+
throw new AppError("not_found", `Run is not registered: ${runId}`, 1, { run_id: runId });
|
|
243
|
+
}
|
|
244
|
+
return run;
|
|
245
|
+
}
|
|
246
|
+
function nextRunId(context, slug) {
|
|
247
|
+
const rows = context.db.all("SELECT id FROM flow_runs WHERE id LIKE 'RUN-%'");
|
|
248
|
+
const max = rows.reduce((value, row) => {
|
|
249
|
+
try {
|
|
250
|
+
const parsed = parseFullEntityId(row.id);
|
|
251
|
+
return parsed.type === runIdType ? Math.max(value, Number(parsed.shortId.slice(4))) : value;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
}, 0);
|
|
257
|
+
for (let sequence = max + 1; sequence <= 999; sequence += 1) {
|
|
258
|
+
const id = formatFullId(runIdType, sequence, slug);
|
|
259
|
+
if (!context.db.get("SELECT id FROM flow_runs WHERE id = ?", [id])) {
|
|
260
|
+
return id;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
throw new AppError("validation", "RUN id sequence exhausted", 2);
|
|
264
|
+
}
|
|
265
|
+
function flowRunSummary(run) {
|
|
266
|
+
return {
|
|
267
|
+
id: run.id,
|
|
268
|
+
short_id: run.short_id,
|
|
269
|
+
slug: run.slug,
|
|
270
|
+
project_id: run.project_id,
|
|
271
|
+
project_root: run.project_root,
|
|
272
|
+
workspace_root: run.workspace_root,
|
|
273
|
+
flow_kind: run.flow_kind,
|
|
274
|
+
subject: {
|
|
275
|
+
type: run.subject_type,
|
|
276
|
+
id: run.subject_id
|
|
277
|
+
},
|
|
278
|
+
status: run.status,
|
|
279
|
+
verdict: run.verdict,
|
|
280
|
+
next_action: run.next_action,
|
|
281
|
+
runtime_path: run.runtime_path,
|
|
282
|
+
run_dir: run.run_dir,
|
|
283
|
+
run_index_path: run.run_index_path,
|
|
284
|
+
created_at: run.created_at,
|
|
285
|
+
updated_at: run.updated_at,
|
|
286
|
+
completed_at: run.completed_at
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function parseRunIndex(text) {
|
|
290
|
+
const index = JSON.parse(text);
|
|
291
|
+
if (index.schema_id !== runSchemaId) {
|
|
292
|
+
throw new AppError("validation", `Invalid run index schema: ${String(index.schema_id)}`, 2);
|
|
293
|
+
}
|
|
294
|
+
return index;
|
|
295
|
+
}
|
|
296
|
+
function parseRunFlowKind(value) {
|
|
297
|
+
if (!allowedRunFlowKinds.includes(value)) {
|
|
298
|
+
throw new AppError("validation", "flow-kind is not supported for run start", 2, {
|
|
299
|
+
flow_kind: value,
|
|
300
|
+
allowed: allowedRunFlowKinds
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
function parseRunStatus(value) {
|
|
306
|
+
if (!["running", "done", "blocked", "cancelled", "failed"].includes(value)) {
|
|
307
|
+
throw new AppError("validation", "run status is not supported", 2, {
|
|
308
|
+
status: value,
|
|
309
|
+
allowed: ["running", "done", "blocked", "cancelled", "failed"]
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
function parseStageStatus(value) {
|
|
315
|
+
if (!["pending", "running", "done", "blocked", "skipped", "failed"].includes(value)) {
|
|
316
|
+
throw new AppError("validation", "stage status is not supported", 2, {
|
|
317
|
+
status: value,
|
|
318
|
+
allowed: ["pending", "running", "done", "blocked", "skipped", "failed"]
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
function requiredStageDir(value) {
|
|
324
|
+
const dir = requiredPlain(value, "dir");
|
|
325
|
+
if (!/^\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(dir)) {
|
|
326
|
+
throw new AppError("validation", "--dir must use NN-stage-slug format", 2, { dir });
|
|
327
|
+
}
|
|
328
|
+
return dir;
|
|
329
|
+
}
|
|
330
|
+
function requiredPlain(value, label) {
|
|
331
|
+
const trimmed = value.trim();
|
|
332
|
+
if (!trimmed) {
|
|
333
|
+
throw new AppError("validation", `${label} must not be empty`, 2);
|
|
334
|
+
}
|
|
335
|
+
return trimmed;
|
|
336
|
+
}
|
|
337
|
+
function normalizeSlug(value) {
|
|
338
|
+
const slug = value
|
|
339
|
+
.normalize("NFKD")
|
|
340
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
341
|
+
.toLowerCase()
|
|
342
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
343
|
+
.replace(/^-+|-+$/g, "")
|
|
344
|
+
.replace(/-{2,}/g, "-");
|
|
345
|
+
if (!slug) {
|
|
346
|
+
throw new AppError("validation", "--slug must contain at least one ASCII letter or digit", 2);
|
|
347
|
+
}
|
|
348
|
+
return slug;
|
|
349
|
+
}
|
|
350
|
+
function resolveWorkspaceRoot(workspaceRoot) {
|
|
351
|
+
const absolute = path.resolve(workspaceRoot);
|
|
352
|
+
if (!fs.existsSync(absolute)) {
|
|
353
|
+
throw new AppError("not_found", `Workspace root does not exist: ${absolute}`, 1);
|
|
354
|
+
}
|
|
355
|
+
return fs.realpathSync(absolute);
|
|
356
|
+
}
|
|
357
|
+
function writeJsonFile(file, value) {
|
|
358
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
359
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Ajv } from "ajv/dist/ajv.js";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
const schemaNamePattern = /^[a-z0-9][a-z0-9-]*$/;
|
|
7
|
+
const mandatoryMbUpgradeReviewAspectIds = [
|
|
8
|
+
"01-goal-and-delivery",
|
|
9
|
+
"02-knowledge-preservation",
|
|
10
|
+
"03-canonical-structure",
|
|
11
|
+
"04-target-coverage",
|
|
12
|
+
"05-path-traceability",
|
|
13
|
+
"06-def-settlement",
|
|
14
|
+
"07-verification-quality",
|
|
15
|
+
"08-git-merge-delivery",
|
|
16
|
+
"09-report-quality",
|
|
17
|
+
"10-agent-process-quality"
|
|
18
|
+
];
|
|
19
|
+
export function validateSchema(options) {
|
|
20
|
+
if (!schemaNamePattern.test(options.schemaName)) {
|
|
21
|
+
throw new AppError("usage", "--schema must be a schema name such as mb-upgrade-review-data", 2);
|
|
22
|
+
}
|
|
23
|
+
const schemaResolution = resolveSchema(options);
|
|
24
|
+
const schema = readJson(schemaResolution.path, "schema");
|
|
25
|
+
const filePath = path.resolve(options.file);
|
|
26
|
+
const data = readJson(filePath, "input file");
|
|
27
|
+
const ajv = new Ajv({ allErrors: true, strict: false, validateFormats: false });
|
|
28
|
+
const validate = ajv.compile(schema);
|
|
29
|
+
const valid = validate(data);
|
|
30
|
+
const errors = valid ? [] : (validate.errors ?? []).map(formatAjvError);
|
|
31
|
+
const semanticErrors = validateSemanticSchema(options.schemaName, data);
|
|
32
|
+
const allErrors = [...errors, ...semanticErrors];
|
|
33
|
+
if (allErrors.length > 0) {
|
|
34
|
+
throw new AppError("schema_validation", `${path.basename(filePath)} does not match schema`, 2, {
|
|
35
|
+
schema: schemaResolution,
|
|
36
|
+
file: filePath,
|
|
37
|
+
errors: allErrors
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
ok: true,
|
|
42
|
+
schema: schemaResolution,
|
|
43
|
+
file: filePath,
|
|
44
|
+
errors: []
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function resolveSchema(options) {
|
|
48
|
+
const fileName = `${options.schemaName}.schema.json`;
|
|
49
|
+
const candidates = [];
|
|
50
|
+
if (options.schemaDir) {
|
|
51
|
+
candidates.push({ path: path.join(path.resolve(options.schemaDir), fileName), source: "schema_dir" });
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
|
|
55
|
+
candidates.push({ path: path.join(projectRoot, ".memory-bank", "dd-flow", "schemas", fileName), source: "project" });
|
|
56
|
+
candidates.push({ path: path.join(bundledSchemaDir(), fileName), source: "bundled" });
|
|
57
|
+
}
|
|
58
|
+
const found = candidates.find((candidate) => fs.existsSync(candidate.path));
|
|
59
|
+
if (!found) {
|
|
60
|
+
throw new AppError("schema_not_found", `Schema not found: ${options.schemaName}`, 2, {
|
|
61
|
+
schema: options.schemaName,
|
|
62
|
+
searched: candidates.map((candidate) => candidate.path)
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const schema = readJson(found.path, "schema");
|
|
66
|
+
const schemaObject = asRecord(schema);
|
|
67
|
+
const id = schemaObject ? objectValue(schemaObject, "$id") : undefined;
|
|
68
|
+
return {
|
|
69
|
+
name: options.schemaName,
|
|
70
|
+
id: typeof id === "string" ? id : options.schemaName,
|
|
71
|
+
path: found.path,
|
|
72
|
+
source: found.source
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function bundledSchemaDir() {
|
|
76
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "schemas");
|
|
77
|
+
}
|
|
78
|
+
function readJson(file, label) {
|
|
79
|
+
let text;
|
|
80
|
+
try {
|
|
81
|
+
text = fs.readFileSync(file, "utf8");
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw new AppError("validation", `Cannot read ${label}: ${file}`, 2, { cause: String(error) });
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
throw new AppError("invalid_json", `Invalid JSON in ${label}: ${file}`, 2, { cause: String(error) });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function formatAjvError(error) {
|
|
94
|
+
return {
|
|
95
|
+
path: error.instancePath || "/",
|
|
96
|
+
message: error.message ?? "schema validation failed",
|
|
97
|
+
keyword: error.keyword
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function validateSemanticSchema(schemaName, data) {
|
|
101
|
+
if (schemaName !== "mb-upgrade-review-data") {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
const root = asRecord(data);
|
|
105
|
+
if (!root) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
return validateMbUpgradeReviewData(root);
|
|
109
|
+
}
|
|
110
|
+
function validateMbUpgradeReviewData(root) {
|
|
111
|
+
const errors = [];
|
|
112
|
+
const aspects = arrayValue(root, "aspects").filter(isRecord);
|
|
113
|
+
const findings = arrayValue(root, "findings").filter(isRecord);
|
|
114
|
+
const overall = recordValue(root, "overall");
|
|
115
|
+
const verdict = overall ? stringValue(overall, "verdict") : undefined;
|
|
116
|
+
const aspectIds = aspects.map((aspect) => stringValue(aspect, "id")).filter(isString);
|
|
117
|
+
for (const id of mandatoryMbUpgradeReviewAspectIds) {
|
|
118
|
+
const count = aspectIds.filter((value) => value === id).length;
|
|
119
|
+
if (count !== 1) {
|
|
120
|
+
errors.push({
|
|
121
|
+
path: "/aspects",
|
|
122
|
+
message: `mandatory aspect ${id} must be present exactly once`,
|
|
123
|
+
keyword: "dd-flow/aspect-id"
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const aspectIdSet = new Set(aspectIds);
|
|
128
|
+
const findingIds = new Set(findings.map((finding) => stringValue(finding, "id")).filter(isString));
|
|
129
|
+
aspects.forEach((aspect, aspectIndex) => {
|
|
130
|
+
const refs = arrayValue(aspect, "findings").filter(isString);
|
|
131
|
+
refs.forEach((ref, refIndex) => {
|
|
132
|
+
if (!findingIds.has(ref)) {
|
|
133
|
+
errors.push({
|
|
134
|
+
path: `/aspects/${aspectIndex}/findings/${refIndex}`,
|
|
135
|
+
message: `finding reference does not exist: ${ref}`,
|
|
136
|
+
keyword: "dd-flow/finding-ref"
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
findings.forEach((finding, findingIndex) => {
|
|
142
|
+
const aspectId = stringValue(finding, "aspect_id");
|
|
143
|
+
if (aspectId && !aspectIdSet.has(aspectId)) {
|
|
144
|
+
errors.push({
|
|
145
|
+
path: `/findings/${findingIndex}/aspect_id`,
|
|
146
|
+
message: `finding aspect_id does not exist in aspects: ${aspectId}`,
|
|
147
|
+
keyword: "dd-flow/aspect-ref"
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const severity = stringValue(finding, "severity");
|
|
151
|
+
const tracking = recordValue(finding, "tracking");
|
|
152
|
+
const trackingStatus = tracking ? stringValue(tracking, "status") : undefined;
|
|
153
|
+
if ((verdict === "accepted" || verdict === "accepted_with_deferrals") && severity === "blocking" && trackingStatus !== "fixed_before_report") {
|
|
154
|
+
errors.push({
|
|
155
|
+
path: "/overall/verdict",
|
|
156
|
+
message: "accepted verdict cannot coexist with unresolved blocking findings",
|
|
157
|
+
keyword: "dd-flow/accepted-blocking"
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
return errors;
|
|
162
|
+
}
|
|
163
|
+
function asRecord(value) {
|
|
164
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
165
|
+
}
|
|
166
|
+
function isRecord(value) {
|
|
167
|
+
return Boolean(asRecord(value));
|
|
168
|
+
}
|
|
169
|
+
function isString(value) {
|
|
170
|
+
return typeof value === "string";
|
|
171
|
+
}
|
|
172
|
+
function objectValue(record, key) {
|
|
173
|
+
return record[key];
|
|
174
|
+
}
|
|
175
|
+
function recordValue(record, key) {
|
|
176
|
+
return asRecord(record[key]);
|
|
177
|
+
}
|
|
178
|
+
function stringValue(record, key) {
|
|
179
|
+
const value = record[key];
|
|
180
|
+
return typeof value === "string" ? value : undefined;
|
|
181
|
+
}
|
|
182
|
+
function arrayValue(record, key) {
|
|
183
|
+
const value = record[key];
|
|
184
|
+
return Array.isArray(value) ? value : [];
|
|
185
|
+
}
|