frontend-project-context 1.2.0 → 1.3.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/CHANGELOG.md +21 -2
- package/README.md +74 -6
- package/UPGRADING.md +28 -0
- package/docs/04-PROGRAM-DESIGN.md +20 -1
- package/docs/05-ACCEPTANCE-CONTRACT.md +32 -3
- package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +16 -8
- package/docs/14-FORMAL-RELEASE-READINESS.md +36 -0
- package/docs/17-AI-EXCHANGE-BOUNDARY-DESIGN.md +4 -2
- package/docs/18-BRANCH-AWARE-STAGED-CONTEXT-DESIGN.md +408 -0
- package/docs/README.md +10 -6
- package/examples/README.md +11 -0
- package/examples/package.json +1 -1
- package/package.json +2 -2
- package/schemas/capabilities.schema.json +25 -2
- package/schemas/integration-review-bundle.schema.json +43 -0
- package/schemas/stage-context-bundle.schema.json +56 -0
- package/schemas/stage-receipt.schema.json +55 -0
- package/schemas/task-context-plan.schema.json +85 -0
- package/src/project-context/capabilities.mjs +74 -0
- package/src/project-context/cli.mjs +29 -2
- package/src/project-context/exchange-schema.mjs +3 -3
- package/src/project-context/exchange.mjs +2 -60
- package/src/project-context/task-context-schema.mjs +526 -0
- package/src/project-context/task-context.mjs +502 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { canonicalJson, canonicalValue, digestJson, validateJsonValue } from "./canonical-json.mjs";
|
|
2
|
+
import { fail } from "./errors.mjs";
|
|
3
|
+
import { normalizeRelativePath } from "./path-policy.mjs";
|
|
4
|
+
|
|
5
|
+
export const TASK_CONTEXT_PLAN_SCHEMA_VERSION = 1;
|
|
6
|
+
export const STAGE_RECEIPT_SCHEMA_VERSION = 1;
|
|
7
|
+
export const STAGE_CONTEXT_BUNDLE_SCHEMA_VERSION = 1;
|
|
8
|
+
export const INTEGRATION_REVIEW_BUNDLE_SCHEMA_VERSION = 1;
|
|
9
|
+
export const CONTEXT_BUDGET_UNIT = "canonical-utf8-bytes";
|
|
10
|
+
|
|
11
|
+
const ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/u;
|
|
12
|
+
const SHA256 = /^sha256:[a-f0-9]{64}$/u;
|
|
13
|
+
const FORBIDDEN = /^(?:write|approve|approval|by|token|secret|shell|command|provider|autoRun|authority|execute|execution)$/iu;
|
|
14
|
+
|
|
15
|
+
function invalid(kind, message, details) {
|
|
16
|
+
fail(`${kind}-schema-invalid`, message, { details });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function object(value, label, kind) {
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) invalid(kind, `${label} must be an object`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function exactKeys(value, allowed, label, kind) {
|
|
24
|
+
for (const key of Object.keys(value)) {
|
|
25
|
+
if (FORBIDDEN.test(key)) invalid(kind, `${label} contains forbidden authority or execution field: ${key}`);
|
|
26
|
+
if (!allowed.has(key)) invalid(kind, `${label} contains unknown field: ${key}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function string(value, label, kind, options = {}) {
|
|
31
|
+
if (typeof value !== "string" || (!options.empty && value.length === 0)) invalid(kind, `${label} must be a non-empty string`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stableId(value, label, kind) {
|
|
35
|
+
string(value, label, kind);
|
|
36
|
+
if (!ID.test(value)) invalid(kind, `${label} must use stable lowercase dot/kebab naming`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function digest(value, label, kind) {
|
|
40
|
+
string(value, label, kind);
|
|
41
|
+
if (!SHA256.test(value)) invalid(kind, `${label} must be sha256`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function uniqueStrings(values, label, kind, options = {}) {
|
|
45
|
+
if (!Array.isArray(values) || (!options.empty && values.length === 0)) {
|
|
46
|
+
invalid(kind, `${label} must be ${options.empty ? "an" : "a non-empty"} array`);
|
|
47
|
+
}
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const value of values) {
|
|
50
|
+
string(value, `${label} entry`, kind);
|
|
51
|
+
if (seen.has(value)) invalid(kind, `${label} contains duplicate value: ${value}`);
|
|
52
|
+
seen.add(value);
|
|
53
|
+
}
|
|
54
|
+
return [...values].sort((left, right) => left.localeCompare(right));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function paths(values, label, kind, options = {}) {
|
|
58
|
+
const original = uniqueStrings(values, label, kind, options);
|
|
59
|
+
const normalized = original.map((value) => {
|
|
60
|
+
try {
|
|
61
|
+
return normalizeRelativePath(value, { label: `${label} entry` });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
invalid(kind, `${label} must stay inside the project`, { path: value, reason: error.code ?? "invalid-path" });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
if (new Set(normalized).size !== normalized.length) invalid(kind, `${label} contains duplicate normalized paths`);
|
|
67
|
+
return normalized.sort((left, right) => left.localeCompare(right));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function snapshots(value, label, kind) {
|
|
71
|
+
object(value, label, kind);
|
|
72
|
+
exactKeys(value, new Set(["contract", "sourcesLock", "projectionsLock"]), label, kind);
|
|
73
|
+
for (const key of ["contract", "sourcesLock", "projectionsLock"]) digest(value[key], `${label}.${key}`, kind);
|
|
74
|
+
return canonicalValue(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isWithin(target, scope) {
|
|
78
|
+
return target === scope || target.startsWith(`${scope}/`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function overlaps(left, right) {
|
|
82
|
+
return isWithin(left, right) || isWithin(right, left);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function transitiveDependencies(stages) {
|
|
86
|
+
const byId = new Map(stages.map((stage) => [stage.id, stage]));
|
|
87
|
+
const memo = new Map();
|
|
88
|
+
const visiting = new Set();
|
|
89
|
+
function visit(id) {
|
|
90
|
+
if (memo.has(id)) return memo.get(id);
|
|
91
|
+
if (visiting.has(id)) invalid("task-context-plan", `task context plan contains a dependency cycle at ${id}`);
|
|
92
|
+
visiting.add(id);
|
|
93
|
+
const dependencies = new Set();
|
|
94
|
+
for (const dependency of byId.get(id).dependsOn) {
|
|
95
|
+
if (!byId.has(dependency)) invalid("task-context-plan", `stage ${id} depends on unknown stage: ${dependency}`);
|
|
96
|
+
dependencies.add(dependency);
|
|
97
|
+
for (const inherited of visit(dependency)) dependencies.add(inherited);
|
|
98
|
+
}
|
|
99
|
+
visiting.delete(id);
|
|
100
|
+
memo.set(id, dependencies);
|
|
101
|
+
return dependencies;
|
|
102
|
+
}
|
|
103
|
+
for (const stage of stages) visit(stage.id);
|
|
104
|
+
return memo;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function validateTaskContextPlan(input) {
|
|
108
|
+
const kind = "task-context-plan";
|
|
109
|
+
object(input, "task context plan", kind);
|
|
110
|
+
exactKeys(input, new Set(["schemaVersion", "kind", "projectId", "task", "workspace", "snapshots", "budget", "stages"]), "task context plan", kind);
|
|
111
|
+
if (input.schemaVersion !== TASK_CONTEXT_PLAN_SCHEMA_VERSION) invalid(kind, `task context plan schemaVersion must be ${TASK_CONTEXT_PLAN_SCHEMA_VERSION}`);
|
|
112
|
+
if (input.kind !== "task-context-plan") invalid(kind, "task context plan.kind must be task-context-plan");
|
|
113
|
+
stableId(input.projectId, "task context plan.projectId", kind);
|
|
114
|
+
|
|
115
|
+
object(input.task, "task context plan.task", kind);
|
|
116
|
+
exactKeys(input.task, new Set(["id", "title", "goal", "acceptance"]), "task context plan.task", kind);
|
|
117
|
+
stableId(input.task.id, "task context plan.task.id", kind);
|
|
118
|
+
string(input.task.title, "task context plan.task.title", kind);
|
|
119
|
+
string(input.task.goal, "task context plan.task.goal", kind);
|
|
120
|
+
if (!Array.isArray(input.task.acceptance) || input.task.acceptance.length === 0) invalid(kind, "task context plan.task.acceptance must be a non-empty array");
|
|
121
|
+
const acceptanceIds = new Set();
|
|
122
|
+
const acceptance = input.task.acceptance.map((entry, index) => {
|
|
123
|
+
const label = `task context plan.task.acceptance[${index}]`;
|
|
124
|
+
object(entry, label, kind);
|
|
125
|
+
exactKeys(entry, new Set(["id", "text"]), label, kind);
|
|
126
|
+
stableId(entry.id, `${label}.id`, kind);
|
|
127
|
+
string(entry.text, `${label}.text`, kind);
|
|
128
|
+
if (acceptanceIds.has(entry.id)) invalid(kind, `task context plan contains duplicate acceptance id: ${entry.id}`);
|
|
129
|
+
acceptanceIds.add(entry.id);
|
|
130
|
+
return { id: entry.id, text: entry.text };
|
|
131
|
+
}).sort((left, right) => left.id.localeCompare(right.id));
|
|
132
|
+
|
|
133
|
+
object(input.workspace, "task context plan.workspace", kind);
|
|
134
|
+
exactKeys(input.workspace, new Set(["branchLabel", "baseRevision"]), "task context plan.workspace", kind);
|
|
135
|
+
string(input.workspace.branchLabel, "task context plan.workspace.branchLabel", kind);
|
|
136
|
+
string(input.workspace.baseRevision, "task context plan.workspace.baseRevision", kind);
|
|
137
|
+
|
|
138
|
+
object(input.budget, "task context plan.budget", kind);
|
|
139
|
+
exactKeys(input.budget, new Set(["maxUtf8Bytes", "maxReadTargets"]), "task context plan.budget", kind);
|
|
140
|
+
for (const key of ["maxUtf8Bytes", "maxReadTargets"]) {
|
|
141
|
+
if (!Number.isSafeInteger(input.budget[key]) || input.budget[key] <= 0) invalid(kind, `task context plan.budget.${key} must be a positive integer`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!Array.isArray(input.stages) || input.stages.length === 0) invalid(kind, "task context plan.stages must be a non-empty array");
|
|
145
|
+
const stageIds = new Set();
|
|
146
|
+
const stages = input.stages.map((stage, index) => {
|
|
147
|
+
const label = `task context plan.stages[${index}]`;
|
|
148
|
+
object(stage, label, kind);
|
|
149
|
+
exactKeys(stage, new Set(["id", "title", "objective", "dependsOn", "paths", "acceptanceIds"]), label, kind);
|
|
150
|
+
stableId(stage.id, `${label}.id`, kind);
|
|
151
|
+
if (stageIds.has(stage.id)) invalid(kind, `task context plan contains duplicate stage id: ${stage.id}`);
|
|
152
|
+
stageIds.add(stage.id);
|
|
153
|
+
string(stage.title, `${label}.title`, kind);
|
|
154
|
+
string(stage.objective, `${label}.objective`, kind);
|
|
155
|
+
const dependsOn = uniqueStrings(stage.dependsOn, `${label}.dependsOn`, kind, { empty: true });
|
|
156
|
+
dependsOn.forEach((id) => stableId(id, `${label}.dependsOn entry`, kind));
|
|
157
|
+
if (dependsOn.includes(stage.id)) invalid(kind, `stage ${stage.id} cannot depend on itself`);
|
|
158
|
+
const acceptanceForStage = uniqueStrings(stage.acceptanceIds, `${label}.acceptanceIds`, kind);
|
|
159
|
+
acceptanceForStage.forEach((id) => {
|
|
160
|
+
stableId(id, `${label}.acceptanceIds entry`, kind);
|
|
161
|
+
if (!acceptanceIds.has(id)) invalid(kind, `stage ${stage.id} references unknown acceptance id: ${id}`);
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
id: stage.id,
|
|
165
|
+
title: stage.title,
|
|
166
|
+
objective: stage.objective,
|
|
167
|
+
dependsOn,
|
|
168
|
+
paths: paths(stage.paths, `${label}.paths`, kind),
|
|
169
|
+
acceptanceIds: acceptanceForStage,
|
|
170
|
+
};
|
|
171
|
+
}).sort((left, right) => left.id.localeCompare(right.id));
|
|
172
|
+
const dependencies = transitiveDependencies(stages);
|
|
173
|
+
for (let leftIndex = 0; leftIndex < stages.length; leftIndex += 1) {
|
|
174
|
+
for (let rightIndex = leftIndex + 1; rightIndex < stages.length; rightIndex += 1) {
|
|
175
|
+
const left = stages[leftIndex];
|
|
176
|
+
const right = stages[rightIndex];
|
|
177
|
+
if (dependencies.get(left.id).has(right.id) || dependencies.get(right.id).has(left.id)) continue;
|
|
178
|
+
const conflict = left.paths.find((leftPath) => right.paths.some((rightPath) => overlaps(leftPath, rightPath)));
|
|
179
|
+
if (conflict) invalid(kind, `independent stages ${left.id} and ${right.id} have overlapping paths`, { path: conflict });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return canonicalValue({
|
|
184
|
+
schemaVersion: TASK_CONTEXT_PLAN_SCHEMA_VERSION,
|
|
185
|
+
kind: "task-context-plan",
|
|
186
|
+
projectId: input.projectId,
|
|
187
|
+
task: { id: input.task.id, title: input.task.title, goal: input.task.goal, acceptance },
|
|
188
|
+
workspace: { branchLabel: input.workspace.branchLabel, baseRevision: input.workspace.baseRevision },
|
|
189
|
+
snapshots: snapshots(input.snapshots, "task context plan.snapshots", kind),
|
|
190
|
+
budget: { maxUtf8Bytes: input.budget.maxUtf8Bytes, maxReadTargets: input.budget.maxReadTargets },
|
|
191
|
+
stages,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function taskContextPlanDigest(plan) {
|
|
196
|
+
return digestJson(validateTaskContextPlan(plan));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function acceptanceResult(value, label, kind) {
|
|
200
|
+
object(value, label, kind);
|
|
201
|
+
exactKeys(value, new Set(["id", "status", "evidence"]), label, kind);
|
|
202
|
+
stableId(value.id, `${label}.id`, kind);
|
|
203
|
+
if (!["observed", "not-observed"].includes(value.status)) invalid(kind, `${label}.status must be observed or not-observed`);
|
|
204
|
+
return { id: value.id, status: value.status, evidence: paths(value.evidence, `${label}.evidence`, kind, { empty: true }) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function verificationResult(value, label, kind) {
|
|
208
|
+
object(value, label, kind);
|
|
209
|
+
exactKeys(value, new Set(["id", "status", "summary", "evidence"]), label, kind);
|
|
210
|
+
stableId(value.id, `${label}.id`, kind);
|
|
211
|
+
if (!["passed", "failed", "observed", "skipped"].includes(value.status)) invalid(kind, `${label}.status is invalid`);
|
|
212
|
+
string(value.summary, `${label}.summary`, kind);
|
|
213
|
+
return { id: value.id, status: value.status, summary: value.summary, evidence: paths(value.evidence, `${label}.evidence`, kind, { empty: true }) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function validateStageReceipt(input, planInput, options = {}) {
|
|
217
|
+
const kind = "stage-receipt";
|
|
218
|
+
object(input, "stage receipt", kind);
|
|
219
|
+
exactKeys(input, new Set([
|
|
220
|
+
"schemaVersion", "kind", "projectId", "taskId", "stageId", "planDigest", "inputBundleDigest", "status",
|
|
221
|
+
"changedPaths", "acceptanceResults", "verificationResults", "decisions", "openIssues", "nextStageId",
|
|
222
|
+
]), "stage receipt", kind);
|
|
223
|
+
if (input.schemaVersion !== STAGE_RECEIPT_SCHEMA_VERSION) invalid(kind, `stage receipt schemaVersion must be ${STAGE_RECEIPT_SCHEMA_VERSION}`);
|
|
224
|
+
if (input.kind !== "stage-receipt") invalid(kind, "stage receipt.kind must be stage-receipt");
|
|
225
|
+
for (const [field, label] of [["projectId", "projectId"], ["taskId", "taskId"], ["stageId", "stageId"]]) stableId(input[field], `stage receipt.${label}`, kind);
|
|
226
|
+
digest(input.planDigest, "stage receipt.planDigest", kind);
|
|
227
|
+
digest(input.inputBundleDigest, "stage receipt.inputBundleDigest", kind);
|
|
228
|
+
if (options.expectedInputBundleDigest !== undefined && input.inputBundleDigest !== options.expectedInputBundleDigest) {
|
|
229
|
+
invalid(kind, "stage receipt input bundle digest is stale", { expected: options.expectedInputBundleDigest, actual: input.inputBundleDigest });
|
|
230
|
+
}
|
|
231
|
+
if (!["completed", "blocked"].includes(input.status)) invalid(kind, "stage receipt.status must be completed or blocked");
|
|
232
|
+
const changedPaths = paths(input.changedPaths, "stage receipt.changedPaths", kind, { empty: true });
|
|
233
|
+
if (!Array.isArray(input.acceptanceResults)) invalid(kind, "stage receipt.acceptanceResults must be an array");
|
|
234
|
+
const resultIds = new Set();
|
|
235
|
+
const acceptanceResults = input.acceptanceResults.map((entry, index) => {
|
|
236
|
+
const normalized = acceptanceResult(entry, `stage receipt.acceptanceResults[${index}]`, kind);
|
|
237
|
+
if (resultIds.has(normalized.id)) invalid(kind, `stage receipt contains duplicate acceptance result: ${normalized.id}`);
|
|
238
|
+
resultIds.add(normalized.id);
|
|
239
|
+
return normalized;
|
|
240
|
+
}).sort((left, right) => left.id.localeCompare(right.id));
|
|
241
|
+
if (!Array.isArray(input.verificationResults)) invalid(kind, "stage receipt.verificationResults must be an array");
|
|
242
|
+
const verificationIds = new Set();
|
|
243
|
+
const verificationResults = input.verificationResults.map((entry, index) => {
|
|
244
|
+
const normalized = verificationResult(entry, `stage receipt.verificationResults[${index}]`, kind);
|
|
245
|
+
if (verificationIds.has(normalized.id)) invalid(kind, `stage receipt contains duplicate verification result: ${normalized.id}`);
|
|
246
|
+
verificationIds.add(normalized.id);
|
|
247
|
+
return normalized;
|
|
248
|
+
}).sort((left, right) => left.id.localeCompare(right.id));
|
|
249
|
+
const decisions = uniqueStrings(input.decisions, "stage receipt.decisions", kind, { empty: true });
|
|
250
|
+
const openIssues = uniqueStrings(input.openIssues, "stage receipt.openIssues", kind, { empty: true });
|
|
251
|
+
if (input.status === "blocked" && openIssues.length === 0) invalid(kind, "blocked stage receipt requires at least one open issue");
|
|
252
|
+
if (input.status === "completed" && openIssues.length > 0) invalid(kind, "completed stage receipt cannot contain open issues");
|
|
253
|
+
if (input.nextStageId !== undefined) stableId(input.nextStageId, "stage receipt.nextStageId", kind);
|
|
254
|
+
|
|
255
|
+
if (planInput !== undefined) {
|
|
256
|
+
const plan = validateTaskContextPlan(planInput);
|
|
257
|
+
if (input.projectId !== plan.projectId || input.taskId !== plan.task.id) invalid(kind, "stage receipt project or task does not match its plan");
|
|
258
|
+
if (input.planDigest !== digestJson(plan)) invalid(kind, "stage receipt plan digest is stale");
|
|
259
|
+
const stage = plan.stages.find((entry) => entry.id === input.stageId);
|
|
260
|
+
if (!stage) invalid(kind, `stage receipt references unknown stage: ${input.stageId}`);
|
|
261
|
+
for (const id of resultIds) if (!stage.acceptanceIds.includes(id)) invalid(kind, `stage receipt references acceptance outside stage: ${id}`);
|
|
262
|
+
if (input.status === "completed") {
|
|
263
|
+
const missing = stage.acceptanceIds.filter((id) => !resultIds.has(id));
|
|
264
|
+
if (missing.length > 0) invalid(kind, "completed stage receipt does not cover all stage acceptance IDs", { missing });
|
|
265
|
+
if (acceptanceResults.some((entry) => entry.status !== "observed")) invalid(kind, "completed stage receipt requires observed acceptance results");
|
|
266
|
+
}
|
|
267
|
+
if (input.nextStageId !== undefined && !plan.stages.some((entry) => entry.id === input.nextStageId)) invalid(kind, `stage receipt references unknown next stage: ${input.nextStageId}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return canonicalValue({
|
|
271
|
+
schemaVersion: STAGE_RECEIPT_SCHEMA_VERSION,
|
|
272
|
+
kind: "stage-receipt",
|
|
273
|
+
projectId: input.projectId,
|
|
274
|
+
taskId: input.taskId,
|
|
275
|
+
stageId: input.stageId,
|
|
276
|
+
planDigest: input.planDigest,
|
|
277
|
+
inputBundleDigest: input.inputBundleDigest,
|
|
278
|
+
status: input.status,
|
|
279
|
+
changedPaths,
|
|
280
|
+
acceptanceResults,
|
|
281
|
+
verificationResults,
|
|
282
|
+
decisions,
|
|
283
|
+
openIssues,
|
|
284
|
+
...(input.nextStageId !== undefined ? { nextStageId: input.nextStageId } : {}),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function stageReceiptDigest(receipt, plan) {
|
|
289
|
+
return digestJson(validateStageReceipt(receipt, plan));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function requiredKeys(value, required, label, kind) {
|
|
293
|
+
for (const key of required) {
|
|
294
|
+
if (!Object.hasOwn(value, key)) invalid(kind, `${label} is missing required field: ${key}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function canonicalPaths(value, label, kind, options = {}) {
|
|
299
|
+
const normalized = paths(value, label, kind, options);
|
|
300
|
+
if (canonicalJson(value) !== canonicalJson(normalized)) invalid(kind, `${label} must be sorted canonical project-relative paths`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function canonicalTexts(value, label, kind, options = {}) {
|
|
304
|
+
const normalized = uniqueStrings(value, label, kind, options);
|
|
305
|
+
if (canonicalJson(value) !== canonicalJson(normalized)) invalid(kind, `${label} must be sorted unique strings`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function positiveInteger(value, label, kind) {
|
|
309
|
+
if (!Number.isSafeInteger(value) || value <= 0) invalid(kind, `${label} must be a positive integer`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function nonNegativeInteger(value, label, kind) {
|
|
313
|
+
if (!Number.isSafeInteger(value) || value < 0) invalid(kind, `${label} must be a non-negative integer`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function validateIdentity(value, label, kind) {
|
|
317
|
+
object(value, label, kind);
|
|
318
|
+
requiredKeys(value, ["id", "name"], label, kind);
|
|
319
|
+
exactKeys(value, new Set(["id", "name"]), label, kind);
|
|
320
|
+
stableId(value.id, `${label}.id`, kind);
|
|
321
|
+
string(value.name, `${label}.name`, kind);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function validateBundleTask(value, label, kind) {
|
|
325
|
+
object(value, label, kind);
|
|
326
|
+
requiredKeys(value, ["id", "title", "goal"], label, kind);
|
|
327
|
+
exactKeys(value, new Set(["id", "title", "goal"]), label, kind);
|
|
328
|
+
stableId(value.id, `${label}.id`, kind);
|
|
329
|
+
string(value.title, `${label}.title`, kind);
|
|
330
|
+
string(value.goal, `${label}.goal`, kind);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function validateBundleWorkspace(value, label, kind) {
|
|
334
|
+
object(value, label, kind);
|
|
335
|
+
requiredKeys(value, ["branchLabel", "baseRevision"], label, kind);
|
|
336
|
+
exactKeys(value, new Set(["branchLabel", "baseRevision"]), label, kind);
|
|
337
|
+
string(value.branchLabel, `${label}.branchLabel`, kind);
|
|
338
|
+
string(value.baseRevision, `${label}.baseRevision`, kind);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function validateBundleStage(value, label, kind) {
|
|
342
|
+
object(value, label, kind);
|
|
343
|
+
requiredKeys(value, ["id", "title", "objective", "acceptance", "paths"], label, kind);
|
|
344
|
+
exactKeys(value, new Set(["id", "title", "objective", "acceptance", "paths"]), label, kind);
|
|
345
|
+
stableId(value.id, `${label}.id`, kind);
|
|
346
|
+
string(value.title, `${label}.title`, kind);
|
|
347
|
+
string(value.objective, `${label}.objective`, kind);
|
|
348
|
+
if (!Array.isArray(value.acceptance) || value.acceptance.length === 0) invalid(kind, `${label}.acceptance must be a non-empty array`);
|
|
349
|
+
const acceptanceIds = new Set();
|
|
350
|
+
for (const [index, entry] of value.acceptance.entries()) {
|
|
351
|
+
const entryLabel = `${label}.acceptance[${index}]`;
|
|
352
|
+
object(entry, entryLabel, kind);
|
|
353
|
+
requiredKeys(entry, ["id", "text"], entryLabel, kind);
|
|
354
|
+
exactKeys(entry, new Set(["id", "text"]), entryLabel, kind);
|
|
355
|
+
stableId(entry.id, `${entryLabel}.id`, kind);
|
|
356
|
+
string(entry.text, `${entryLabel}.text`, kind);
|
|
357
|
+
if (acceptanceIds.has(entry.id)) invalid(kind, `${label}.acceptance contains duplicate id: ${entry.id}`);
|
|
358
|
+
acceptanceIds.add(entry.id);
|
|
359
|
+
}
|
|
360
|
+
const sortedAcceptanceIds = [...acceptanceIds].sort((left, right) => left.localeCompare(right));
|
|
361
|
+
if (canonicalJson(value.acceptance.map((entry) => entry.id)) !== canonicalJson(sortedAcceptanceIds)) {
|
|
362
|
+
invalid(kind, `${label}.acceptance must be sorted by id`);
|
|
363
|
+
}
|
|
364
|
+
canonicalPaths(value.paths, `${label}.paths`, kind);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function validateReceiptSummary(value, label, kind) {
|
|
368
|
+
object(value, label, kind);
|
|
369
|
+
const keys = ["stageId", "status", "inputBundleDigest", "changedPaths", "acceptanceResults", "verificationResults", "decisions", "openIssues"];
|
|
370
|
+
requiredKeys(value, keys, label, kind);
|
|
371
|
+
exactKeys(value, new Set(keys), label, kind);
|
|
372
|
+
stableId(value.stageId, `${label}.stageId`, kind);
|
|
373
|
+
if (!["completed", "blocked"].includes(value.status)) invalid(kind, `${label}.status must be completed or blocked`);
|
|
374
|
+
digest(value.inputBundleDigest, `${label}.inputBundleDigest`, kind);
|
|
375
|
+
canonicalPaths(value.changedPaths, `${label}.changedPaths`, kind, { empty: true });
|
|
376
|
+
if (!Array.isArray(value.acceptanceResults)) invalid(kind, `${label}.acceptanceResults must be an array`);
|
|
377
|
+
const acceptanceIds = new Set();
|
|
378
|
+
for (const [index, entry] of value.acceptanceResults.entries()) {
|
|
379
|
+
acceptanceResult(entry, `${label}.acceptanceResults[${index}]`, kind);
|
|
380
|
+
if (acceptanceIds.has(entry.id)) invalid(kind, `${label}.acceptanceResults contains duplicate id: ${entry.id}`);
|
|
381
|
+
acceptanceIds.add(entry.id);
|
|
382
|
+
}
|
|
383
|
+
if (canonicalJson(value.acceptanceResults.map((entry) => entry.id)) !== canonicalJson([...acceptanceIds].sort((left, right) => left.localeCompare(right)))) {
|
|
384
|
+
invalid(kind, `${label}.acceptanceResults must be sorted by id`);
|
|
385
|
+
}
|
|
386
|
+
if (!Array.isArray(value.verificationResults)) invalid(kind, `${label}.verificationResults must be an array`);
|
|
387
|
+
const verificationIds = new Set();
|
|
388
|
+
for (const [index, entry] of value.verificationResults.entries()) {
|
|
389
|
+
verificationResult(entry, `${label}.verificationResults[${index}]`, kind);
|
|
390
|
+
if (verificationIds.has(entry.id)) invalid(kind, `${label}.verificationResults contains duplicate id: ${entry.id}`);
|
|
391
|
+
verificationIds.add(entry.id);
|
|
392
|
+
}
|
|
393
|
+
if (canonicalJson(value.verificationResults.map((entry) => entry.id)) !== canonicalJson([...verificationIds].sort((left, right) => left.localeCompare(right)))) {
|
|
394
|
+
invalid(kind, `${label}.verificationResults must be sorted by id`);
|
|
395
|
+
}
|
|
396
|
+
canonicalTexts(value.decisions, `${label}.decisions`, kind, { empty: true });
|
|
397
|
+
canonicalTexts(value.openIssues, `${label}.openIssues`, kind, { empty: true });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function validateBundleScope(value, label, kind) {
|
|
401
|
+
object(value, label, kind);
|
|
402
|
+
exactKeys(value, new Set(["kind", "path"]), label, kind);
|
|
403
|
+
if (!["project", "path-prefix", "file"].includes(value.kind)) invalid(kind, `${label}.kind is invalid`);
|
|
404
|
+
if (value.kind === "project") {
|
|
405
|
+
if (value.path !== undefined) invalid(kind, `${label}.path is not allowed for project scope`);
|
|
406
|
+
} else {
|
|
407
|
+
canonicalPaths([value.path], `${label}.path`, kind);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function validateContractItem(value, label, kind) {
|
|
412
|
+
object(value, label, kind);
|
|
413
|
+
const keys = ["id", "kind", "subject", "value", "statement", "scope", "sourceIds"];
|
|
414
|
+
requiredKeys(value, keys, label, kind);
|
|
415
|
+
exactKeys(value, new Set(keys), label, kind);
|
|
416
|
+
stableId(value.id, `${label}.id`, kind);
|
|
417
|
+
if (!["fact", "policy", "reference", "validation-description"].includes(value.kind)) invalid(kind, `${label}.kind is invalid`);
|
|
418
|
+
stableId(value.subject, `${label}.subject`, kind);
|
|
419
|
+
try {
|
|
420
|
+
validateJsonValue(value.value);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
invalid(kind, `${label}.value must be strict JSON`, { reason: error.message });
|
|
423
|
+
}
|
|
424
|
+
string(value.statement, `${label}.statement`, kind);
|
|
425
|
+
validateBundleScope(value.scope, `${label}.scope`, kind);
|
|
426
|
+
const sourceIds = uniqueStrings(value.sourceIds, `${label}.sourceIds`, kind);
|
|
427
|
+
sourceIds.forEach((id) => stableId(id, `${label}.sourceIds entry`, kind));
|
|
428
|
+
if (canonicalJson(value.sourceIds) !== canonicalJson(sourceIds)) invalid(kind, `${label}.sourceIds must be sorted`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function validateFinding(value, label, kind) {
|
|
432
|
+
object(value, label, kind);
|
|
433
|
+
requiredKeys(value, ["code", "severity"], label, kind);
|
|
434
|
+
stableId(value.code, `${label}.code`, kind);
|
|
435
|
+
if (!["attention", "blocked"].includes(value.severity)) invalid(kind, `${label}.severity is invalid`);
|
|
436
|
+
try {
|
|
437
|
+
validateJsonValue(value);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
invalid(kind, `${label} must be strict JSON`, { reason: error.message });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function validateStageContextBundle(input) {
|
|
444
|
+
const kind = "stage-context-bundle";
|
|
445
|
+
object(input, "stage context bundle", kind);
|
|
446
|
+
const keys = [
|
|
447
|
+
"schemaVersion", "kind", "project", "task", "stage", "workspace", "planDigest", "snapshots",
|
|
448
|
+
"dependencyReceipts", "changedPaths", "contractItems", "readTargets", "findings", "excluded", "budget", "status", "bundleDigest",
|
|
449
|
+
];
|
|
450
|
+
requiredKeys(input, keys, "stage context bundle", kind);
|
|
451
|
+
exactKeys(input, new Set(keys), "stage context bundle", kind);
|
|
452
|
+
if (input.schemaVersion !== STAGE_CONTEXT_BUNDLE_SCHEMA_VERSION) invalid(kind, `stage context bundle schemaVersion must be ${STAGE_CONTEXT_BUNDLE_SCHEMA_VERSION}`);
|
|
453
|
+
if (input.kind !== "stage-context-bundle") invalid(kind, "stage context bundle.kind must be stage-context-bundle");
|
|
454
|
+
validateIdentity(input.project, "stage context bundle.project", kind);
|
|
455
|
+
validateBundleTask(input.task, "stage context bundle.task", kind);
|
|
456
|
+
validateBundleStage(input.stage, "stage context bundle.stage", kind);
|
|
457
|
+
validateBundleWorkspace(input.workspace, "stage context bundle.workspace", kind);
|
|
458
|
+
digest(input.planDigest, "stage context bundle.planDigest", kind);
|
|
459
|
+
snapshots(input.snapshots, "stage context bundle.snapshots", kind);
|
|
460
|
+
if (!Array.isArray(input.dependencyReceipts)) invalid(kind, "stage context bundle.dependencyReceipts must be an array");
|
|
461
|
+
const dependencyIds = new Set();
|
|
462
|
+
for (const [index, entry] of input.dependencyReceipts.entries()) {
|
|
463
|
+
validateReceiptSummary(entry, `stage context bundle.dependencyReceipts[${index}]`, kind);
|
|
464
|
+
if (dependencyIds.has(entry.stageId)) invalid(kind, `stage context bundle contains duplicate dependency receipt: ${entry.stageId}`);
|
|
465
|
+
dependencyIds.add(entry.stageId);
|
|
466
|
+
}
|
|
467
|
+
if (canonicalJson(input.dependencyReceipts.map((entry) => entry.stageId)) !== canonicalJson([...dependencyIds].sort((left, right) => left.localeCompare(right)))) {
|
|
468
|
+
invalid(kind, "stage context bundle.dependencyReceipts must be sorted by stageId");
|
|
469
|
+
}
|
|
470
|
+
canonicalPaths(input.changedPaths, "stage context bundle.changedPaths", kind, { empty: true });
|
|
471
|
+
if (!Array.isArray(input.contractItems)) invalid(kind, "stage context bundle.contractItems must be an array");
|
|
472
|
+
const itemIds = new Set();
|
|
473
|
+
for (const [index, entry] of input.contractItems.entries()) {
|
|
474
|
+
validateContractItem(entry, `stage context bundle.contractItems[${index}]`, kind);
|
|
475
|
+
if (itemIds.has(entry.id)) invalid(kind, `stage context bundle contains duplicate contract item: ${entry.id}`);
|
|
476
|
+
itemIds.add(entry.id);
|
|
477
|
+
}
|
|
478
|
+
if (canonicalJson(input.contractItems.map((entry) => entry.id)) !== canonicalJson([...itemIds].sort((left, right) => left.localeCompare(right)))) {
|
|
479
|
+
invalid(kind, "stage context bundle.contractItems must be sorted by id");
|
|
480
|
+
}
|
|
481
|
+
if (!Array.isArray(input.readTargets)) invalid(kind, "stage context bundle.readTargets must be an array");
|
|
482
|
+
const readPaths = new Set();
|
|
483
|
+
for (const [index, entry] of input.readTargets.entries()) {
|
|
484
|
+
const label = `stage context bundle.readTargets[${index}]`;
|
|
485
|
+
object(entry, label, kind);
|
|
486
|
+
requiredKeys(entry, ["path", "reason"], label, kind);
|
|
487
|
+
exactKeys(entry, new Set(["path", "reason", "sourceId"]), label, kind);
|
|
488
|
+
canonicalPaths([entry.path], `${label}.path`, kind);
|
|
489
|
+
if (!["stage-scope", "host-changed-path-signal", "contract-source"].includes(entry.reason)) invalid(kind, `${label}.reason is invalid`);
|
|
490
|
+
if (entry.reason === "contract-source") stableId(entry.sourceId, `${label}.sourceId`, kind);
|
|
491
|
+
else if (entry.sourceId !== undefined) invalid(kind, `${label}.sourceId requires contract-source reason`);
|
|
492
|
+
if (readPaths.has(entry.path)) invalid(kind, `stage context bundle contains duplicate read target: ${entry.path}`);
|
|
493
|
+
readPaths.add(entry.path);
|
|
494
|
+
}
|
|
495
|
+
if (canonicalJson(input.readTargets.map((entry) => entry.path)) !== canonicalJson([...readPaths].sort((left, right) => left.localeCompare(right)))) {
|
|
496
|
+
invalid(kind, "stage context bundle.readTargets must be sorted by path");
|
|
497
|
+
}
|
|
498
|
+
if (!Array.isArray(input.findings)) invalid(kind, "stage context bundle.findings must be an array");
|
|
499
|
+
input.findings.forEach((entry, index) => validateFinding(entry, `stage context bundle.findings[${index}]`, kind));
|
|
500
|
+
canonicalTexts(input.excluded, "stage context bundle.excluded", kind);
|
|
501
|
+
object(input.budget, "stage context bundle.budget", kind);
|
|
502
|
+
const budgetKeys = ["unit", "maxUtf8Bytes", "maxReadTargets", "usedUtf8Bytes", "readTargetCount"];
|
|
503
|
+
requiredKeys(input.budget, budgetKeys, "stage context bundle.budget", kind);
|
|
504
|
+
exactKeys(input.budget, new Set(budgetKeys), "stage context bundle.budget", kind);
|
|
505
|
+
if (input.budget.unit !== CONTEXT_BUDGET_UNIT) invalid(kind, `stage context bundle.budget.unit must be ${CONTEXT_BUDGET_UNIT}`);
|
|
506
|
+
positiveInteger(input.budget.maxUtf8Bytes, "stage context bundle.budget.maxUtf8Bytes", kind);
|
|
507
|
+
positiveInteger(input.budget.maxReadTargets, "stage context bundle.budget.maxReadTargets", kind);
|
|
508
|
+
nonNegativeInteger(input.budget.usedUtf8Bytes, "stage context bundle.budget.usedUtf8Bytes", kind);
|
|
509
|
+
nonNegativeInteger(input.budget.readTargetCount, "stage context bundle.budget.readTargetCount", kind);
|
|
510
|
+
if (input.budget.readTargetCount !== input.readTargets.length) invalid(kind, "stage context bundle read target count is invalid");
|
|
511
|
+
if (!["ready", "blocked"].includes(input.status)) invalid(kind, "stage context bundle.status must be ready or blocked");
|
|
512
|
+
digest(input.bundleDigest, "stage context bundle.bundleDigest", kind);
|
|
513
|
+
|
|
514
|
+
const normalized = canonicalValue(input);
|
|
515
|
+
const withoutDigest = structuredClone(normalized);
|
|
516
|
+
delete withoutDigest.bundleDigest;
|
|
517
|
+
const expectedDigest = digestJson(withoutDigest);
|
|
518
|
+
if (normalized.bundleDigest !== expectedDigest) {
|
|
519
|
+
invalid(kind, "stage context bundle digest is invalid", { expected: expectedDigest, actual: normalized.bundleDigest });
|
|
520
|
+
}
|
|
521
|
+
const expectedBytes = Buffer.byteLength(canonicalJson(normalized), "utf8");
|
|
522
|
+
if (normalized.budget.usedUtf8Bytes !== expectedBytes) {
|
|
523
|
+
invalid(kind, "stage context bundle byte count is invalid", { expected: expectedBytes, actual: normalized.budget.usedUtf8Bytes });
|
|
524
|
+
}
|
|
525
|
+
return normalized;
|
|
526
|
+
}
|