frontend-project-context 1.0.0 → 1.2.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.
@@ -0,0 +1,528 @@
1
+ import { canonicalValue, digestJson, validateJsonValue } from "./canonical-json.mjs";
2
+ import { fail } from "./errors.mjs";
3
+ import { normalizeRelativePath } from "./path-policy.mjs";
4
+
5
+ export const PACKAGE_VERSION = "1.2.0";
6
+ export const EXCHANGE_PROTOCOL_VERSION = 1;
7
+ export const ACTION_PLAN_SCHEMA_VERSION = 1;
8
+ export const REVIEW_BUNDLE_SCHEMA_VERSION = 1;
9
+ export const CAPABILITIES_SCHEMA_VERSION = 1;
10
+
11
+ export const ACTION_KINDS = Object.freeze([
12
+ "accept-source-change",
13
+ "deprecate-item",
14
+ "deprecate-source",
15
+ "propose-item",
16
+ "publish-projection",
17
+ "register-source",
18
+ "request-item-approval",
19
+ "revise-item",
20
+ ]);
21
+
22
+ export const COMMANDS = Object.freeze([
23
+ "accept-source-change", "approve", "capabilities", "check", "context", "dashboard", "deprecate",
24
+ "deprecate-source", "discover", "init", "preflight", "propose", "publish", "register", "review-source",
25
+ "revise", "setup", "sync",
26
+ ]);
27
+
28
+ const ACTION_KIND_SET = new Set(ACTION_KINDS);
29
+ const ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/u;
30
+ const SHA256 = /^sha256:[a-f0-9]{64}$/u;
31
+ const ITEM_KINDS = new Set(["fact", "policy", "reference", "validation-description"]);
32
+ const SOURCE_KINDS = new Set(["file", "path", "json-pointer", "human-decision", "external-reference"]);
33
+ const SCOPE_KINDS = new Set(["project", "path-prefix", "file"]);
34
+ const VERIFICATION_KINDS = new Set(["none", "file-exists", "json-value", "path-digest"]);
35
+ const AUTHORITY_FIELDS = new Set(["write", "by", "approval", "approve", "provider", "command", "shell"]);
36
+
37
+ function invalid(message, details) {
38
+ fail("action-plan-schema-invalid", message, { details });
39
+ }
40
+
41
+ function reviewInvalid(message, details) {
42
+ fail("review-bundle-schema-invalid", message, { details });
43
+ }
44
+
45
+ function object(value, label) {
46
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalid(`${label} must be an object`);
47
+ }
48
+
49
+ function exactKeys(value, allowed, label) {
50
+ for (const key of Object.keys(value)) {
51
+ if (AUTHORITY_FIELDS.has(key)) invalid(`${label} contains forbidden authority or execution field: ${key}`);
52
+ if (!allowed.has(key)) invalid(`${label} contains unknown field: ${key}`);
53
+ }
54
+ }
55
+
56
+ function string(value, label, options = {}) {
57
+ if (typeof value !== "string" || (!options.empty && value.length === 0)) invalid(`${label} must be a non-empty string`);
58
+ }
59
+
60
+ function stableId(value, label) {
61
+ string(value, label);
62
+ if (!ID.test(value)) invalid(`${label} must use stable lowercase dot/kebab naming`);
63
+ }
64
+
65
+ function digest(value, label, options = {}) {
66
+ if (value === null && options.nullable) return;
67
+ string(value, label);
68
+ if (!SHA256.test(value)) invalid(`${label} must be sha256`);
69
+ }
70
+
71
+ function uniqueStrings(values, label, options = {}) {
72
+ if (!Array.isArray(values) || (!options.empty && values.length === 0)) {
73
+ invalid(`${label} must be ${options.empty ? "an" : "a non-empty"} array`);
74
+ }
75
+ const seen = new Set();
76
+ for (const value of values) {
77
+ string(value, `${label} entry`);
78
+ if (seen.has(value)) invalid(`${label} contains duplicate value: ${value}`);
79
+ seen.add(value);
80
+ }
81
+ return [...values].sort((left, right) => left.localeCompare(right));
82
+ }
83
+
84
+ function projectPath(value, label, options = {}) {
85
+ try {
86
+ return normalizeRelativePath(value, { allowRoot: options.allowRoot, label });
87
+ } catch (error) {
88
+ invalid(`${label} must stay inside the project`, { path: value, reason: error.code ?? "invalid-path" });
89
+ }
90
+ }
91
+
92
+ export function normalizeProposalPath(value, label = "proposal path") {
93
+ const normalized = projectPath(value, label);
94
+ const reserved = new Set([
95
+ ".project-context/contract.json",
96
+ ".project-context/sources.lock.json",
97
+ ".project-context/projections.lock.json",
98
+ ]);
99
+ if (!normalized.startsWith(".project-context/") || !normalized.endsWith(".json") || reserved.has(normalized)) {
100
+ invalid(`${label} must be a non-store JSON file inside .project-context/`);
101
+ }
102
+ return normalized;
103
+ }
104
+
105
+ function normalizeVerification(value, label) {
106
+ if (value === undefined) return undefined;
107
+ object(value, label);
108
+ exactKeys(value, new Set(["kind", "source", "expected"]), label);
109
+ if (!VERIFICATION_KINDS.has(value.kind)) invalid(`${label}.kind is invalid`);
110
+ if (value.kind === "none") {
111
+ if (value.source !== undefined || Object.hasOwn(value, "expected")) invalid(`${label} none verification cannot contain source or expected`);
112
+ return { kind: "none" };
113
+ }
114
+ stableId(value.source, `${label}.source`);
115
+ if (value.kind === "file-exists" && Object.hasOwn(value, "expected")) invalid(`${label} file-exists cannot contain expected`);
116
+ if (value.kind === "json-value" && !Object.hasOwn(value, "expected")) invalid(`${label} json-value requires expected`);
117
+ if (value.kind === "path-digest" && Object.hasOwn(value, "expected")) digest(value.expected, `${label}.expected`);
118
+ if (Object.hasOwn(value, "expected")) {
119
+ try {
120
+ validateJsonValue(value.expected);
121
+ } catch (error) {
122
+ invalid(`${label}.expected must be JSON-compatible`, { reason: error.message });
123
+ }
124
+ }
125
+ return canonicalValue(value);
126
+ }
127
+
128
+ function normalizeScope(value, label) {
129
+ object(value, label);
130
+ exactKeys(value, new Set(["kind", "path"]), label);
131
+ if (!SCOPE_KINDS.has(value.kind)) invalid(`${label}.kind is invalid`);
132
+ if (value.kind === "project") {
133
+ if (value.path !== undefined && value.path !== ".") invalid(`${label}.path must be omitted or '.' for project scope`);
134
+ return { kind: "project" };
135
+ }
136
+ return { kind: value.kind, path: projectPath(value.path, `${label}.path`) };
137
+ }
138
+
139
+ function normalizeItemInput(input, label, extra = []) {
140
+ object(input, label);
141
+ exactKeys(input, new Set([
142
+ "id", "kind", "subject", "value", "statement", "sources", "scope", "overrides", "verification", ...extra,
143
+ ]), label);
144
+ stableId(input.id, `${label}.id`);
145
+ if (!ITEM_KINDS.has(input.kind)) invalid(`${label}.kind is invalid`);
146
+ stableId(input.subject, `${label}.subject`);
147
+ if (!Object.hasOwn(input, "value")) invalid(`${label}.value is required`);
148
+ try {
149
+ validateJsonValue(input.value);
150
+ } catch (error) {
151
+ invalid(`${label}.value must be JSON-compatible`, { reason: error.message });
152
+ }
153
+ string(input.statement, `${label}.statement`);
154
+ const sources = uniqueStrings(input.sources, `${label}.sources`);
155
+ sources.forEach((entry) => stableId(entry, `${label}.sources entry`));
156
+ const overrides = uniqueStrings(input.overrides ?? [], `${label}.overrides`, { empty: true });
157
+ overrides.forEach((entry) => stableId(entry, `${label}.overrides entry`));
158
+ return {
159
+ id: input.id,
160
+ kind: input.kind,
161
+ subject: input.subject,
162
+ value: canonicalValue(input.value),
163
+ statement: input.statement,
164
+ sources,
165
+ scope: normalizeScope(input.scope, `${label}.scope`),
166
+ overrides,
167
+ ...(input.verification !== undefined ? { verification: normalizeVerification(input.verification, `${label}.verification`) } : {}),
168
+ };
169
+ }
170
+
171
+ function normalizeSourceInput(input, label) {
172
+ object(input, label);
173
+ exactKeys(input, new Set(["id", "kind", "path", "pointer", "reference"]), label);
174
+ stableId(input.id, `${label}.id`);
175
+ if (!SOURCE_KINDS.has(input.kind)) invalid(`${label}.kind is invalid`);
176
+ if (["file", "path", "json-pointer"].includes(input.kind)) {
177
+ if (input.reference !== undefined) invalid(`${label}.reference is not allowed for local sources`);
178
+ const normalized = { id: input.id, kind: input.kind, path: projectPath(input.path, `${label}.path`) };
179
+ if (input.kind === "json-pointer") {
180
+ string(input.pointer, `${label}.pointer`, { empty: true });
181
+ if (input.pointer !== "" && !input.pointer.startsWith("/")) invalid(`${label}.pointer must use RFC 6901 syntax`);
182
+ normalized.pointer = input.pointer;
183
+ } else if (input.pointer !== undefined) invalid(`${label}.pointer is only allowed for json-pointer`);
184
+ return normalized;
185
+ }
186
+ if (input.path !== undefined || input.pointer !== undefined) invalid(`${label} cannot contain a local path`);
187
+ string(input.reference, `${label}.reference`);
188
+ return { id: input.id, kind: input.kind, reference: input.reference };
189
+ }
190
+
191
+ function normalizeActionInput(kind, input, label) {
192
+ if (kind === "register-source") return normalizeSourceInput(input, label);
193
+ if (kind === "propose-item") {
194
+ const normalized = normalizeItemInput(input, label, ["output"]);
195
+ return { ...normalized, output: normalizeProposalPath(input.output, `${label}.output`) };
196
+ }
197
+ if (kind === "revise-item") {
198
+ const normalized = normalizeItemInput(input, label, ["expectedItemDigest"]);
199
+ digest(input.expectedItemDigest, `${label}.expectedItemDigest`);
200
+ return { ...normalized, expectedItemDigest: input.expectedItemDigest };
201
+ }
202
+ if (kind === "accept-source-change") {
203
+ object(input, label);
204
+ exactKeys(input, new Set(["id", "sourceObjectDigest", "lockedDigest", "currentDigest", "affectedItems"]), label);
205
+ stableId(input.id, `${label}.id`);
206
+ digest(input.sourceObjectDigest, `${label}.sourceObjectDigest`);
207
+ digest(input.lockedDigest, `${label}.lockedDigest`);
208
+ digest(input.currentDigest, `${label}.currentDigest`);
209
+ const affectedItems = uniqueStrings(input.affectedItems, `${label}.affectedItems`, { empty: true });
210
+ affectedItems.forEach((entry) => stableId(entry, `${label}.affectedItems entry`));
211
+ return { id: input.id, sourceObjectDigest: input.sourceObjectDigest, lockedDigest: input.lockedDigest, currentDigest: input.currentDigest, affectedItems };
212
+ }
213
+ if (kind === "deprecate-item") {
214
+ object(input, label);
215
+ exactKeys(input, new Set(["id", "expectedItemDigest", "rationale"]), label);
216
+ stableId(input.id, `${label}.id`);
217
+ digest(input.expectedItemDigest, `${label}.expectedItemDigest`);
218
+ string(input.rationale, `${label}.rationale`);
219
+ return { id: input.id, expectedItemDigest: input.expectedItemDigest, rationale: input.rationale };
220
+ }
221
+ if (kind === "deprecate-source") {
222
+ object(input, label);
223
+ exactKeys(input, new Set(["id", "expectedSourceDigest", "affectedItems", "rationale"]), label);
224
+ stableId(input.id, `${label}.id`);
225
+ digest(input.expectedSourceDigest, `${label}.expectedSourceDigest`);
226
+ const affectedItems = uniqueStrings(input.affectedItems, `${label}.affectedItems`, { empty: true });
227
+ affectedItems.forEach((entry) => stableId(entry, `${label}.affectedItems entry`));
228
+ string(input.rationale, `${label}.rationale`);
229
+ return { id: input.id, expectedSourceDigest: input.expectedSourceDigest, affectedItems, rationale: input.rationale };
230
+ }
231
+ if (kind === "request-item-approval") {
232
+ object(input, label);
233
+ exactKeys(input, new Set(["mode", "ids", "proposal", "proposalDigest", "rationale"]), label);
234
+ if (!["pending", "proposal"].includes(input.mode)) invalid(`${label}.mode must be pending or proposal`);
235
+ const ids = uniqueStrings(input.ids, `${label}.ids`);
236
+ ids.forEach((entry) => stableId(entry, `${label}.ids entry`));
237
+ if (input.mode === "proposal") {
238
+ const proposal = normalizeProposalPath(input.proposal, `${label}.proposal`);
239
+ digest(input.proposalDigest, `${label}.proposalDigest`);
240
+ if (input.rationale !== undefined) string(input.rationale, `${label}.rationale`, { empty: true });
241
+ return { mode: input.mode, ids, proposal, proposalDigest: input.proposalDigest, ...(input.rationale !== undefined ? { rationale: input.rationale } : {}) };
242
+ }
243
+ if (input.proposal !== undefined || input.proposalDigest !== undefined) invalid(`${label} pending mode cannot contain proposal fields`);
244
+ if (input.rationale !== undefined) string(input.rationale, `${label}.rationale`, { empty: true });
245
+ return { mode: input.mode, ids, ...(input.rationale !== undefined ? { rationale: input.rationale } : {}) };
246
+ }
247
+ if (kind === "publish-projection") {
248
+ object(input, label);
249
+ exactKeys(input, new Set(["target", "output", "paths", "expectedContentDigest"]), label);
250
+ if (!["agents", "ruler"].includes(input.target)) invalid(`${label}.target must be agents or ruler`);
251
+ const output = projectPath(input.output, `${label}.output`);
252
+ const paths = uniqueStrings(input.paths, `${label}.paths`).map((entry) => projectPath(entry, `${label}.paths entry`, { allowRoot: true }));
253
+ digest(input.expectedContentDigest, `${label}.expectedContentDigest`, { nullable: true });
254
+ return { target: input.target, output, paths: [...paths].sort(), expectedContentDigest: input.expectedContentDigest };
255
+ }
256
+ invalid(`${label} kind is unsupported: ${kind}`);
257
+ }
258
+
259
+ export function validateActionPlan(input) {
260
+ object(input, "action plan");
261
+ exactKeys(input, new Set(["schemaVersion", "projectId", "baselines", "actions"]), "action plan");
262
+ if (input.schemaVersion !== ACTION_PLAN_SCHEMA_VERSION) invalid(`action plan schemaVersion must be ${ACTION_PLAN_SCHEMA_VERSION}`);
263
+ stableId(input.projectId, "action plan.projectId");
264
+ object(input.baselines, "action plan.baselines");
265
+ exactKeys(input.baselines, new Set(["contract", "sourcesLock", "projectionsLock"]), "action plan.baselines");
266
+ for (const key of ["contract", "sourcesLock", "projectionsLock"]) digest(input.baselines[key], `action plan.baselines.${key}`);
267
+ if (!Array.isArray(input.actions) || input.actions.length === 0) invalid("action plan.actions must be a non-empty array");
268
+ const ids = new Set();
269
+ const actions = input.actions.map((action, index) => {
270
+ const label = `action plan.actions[${index}]`;
271
+ object(action, label);
272
+ exactKeys(action, new Set(["id", "kind", "input"]), label);
273
+ stableId(action.id, `${label}.id`);
274
+ if (ids.has(action.id)) invalid(`action plan contains duplicate action id: ${action.id}`);
275
+ ids.add(action.id);
276
+ if (!ACTION_KIND_SET.has(action.kind)) invalid(`${label}.kind is invalid`);
277
+ return { id: action.id, kind: action.kind, input: normalizeActionInput(action.kind, action.input, `${label}.input`) };
278
+ }).sort((left, right) => left.id.localeCompare(right.id));
279
+ return canonicalValue({ schemaVersion: ACTION_PLAN_SCHEMA_VERSION, projectId: input.projectId, baselines: canonicalValue(input.baselines), actions });
280
+ }
281
+
282
+ export function actionPlanDigest(plan) {
283
+ return digestJson(validateActionPlan(plan));
284
+ }
285
+
286
+ function reviewObject(value, label) {
287
+ if (!value || typeof value !== "object" || Array.isArray(value)) reviewInvalid(`${label} must be an object`);
288
+ }
289
+
290
+ function reviewExactKeys(value, allowed, label) {
291
+ for (const key of Object.keys(value)) {
292
+ if (!allowed.has(key)) reviewInvalid(`${label} contains unknown field: ${key}`);
293
+ }
294
+ }
295
+
296
+ function reviewString(value, label) {
297
+ if (typeof value !== "string" || value.length === 0) reviewInvalid(`${label} must be a non-empty string`);
298
+ }
299
+
300
+ function reviewStableId(value, label) {
301
+ reviewString(value, label);
302
+ if (!ID.test(value)) reviewInvalid(`${label} must use stable lowercase dot/kebab naming`);
303
+ }
304
+
305
+ function reviewDigest(value, label) {
306
+ reviewString(value, label);
307
+ if (!SHA256.test(value)) reviewInvalid(`${label} must be sha256`);
308
+ }
309
+
310
+ function reviewStrings(values, label, options = {}) {
311
+ if (!Array.isArray(values) || (!options.empty && values.length === 0)) {
312
+ reviewInvalid(`${label} must be ${options.empty ? "an" : "a non-empty"} array`);
313
+ }
314
+ const seen = new Set();
315
+ for (const value of values) {
316
+ reviewString(value, `${label} entry`);
317
+ if (seen.has(value)) reviewInvalid(`${label} contains duplicate value: ${value}`);
318
+ seen.add(value);
319
+ }
320
+ return [...values].sort((left, right) => left.localeCompare(right));
321
+ }
322
+
323
+ function normalizeReviewInvocation(value, label, actionId) {
324
+ reviewObject(value, label);
325
+ reviewExactKeys(value, new Set(actionId ? ["actionId", "command", "args"] : ["command", "args"]), label);
326
+ if (actionId) {
327
+ reviewStableId(value.actionId, `${label}.actionId`);
328
+ if (value.actionId !== actionId) reviewInvalid(`${label}.actionId does not match its action`);
329
+ }
330
+ reviewString(value.command, `${label}.command`);
331
+ if (!COMMANDS.includes(value.command)) reviewInvalid(`${label}.command is not a supported command`);
332
+ if (!Array.isArray(value.args)) reviewInvalid(`${label}.args must be an array`);
333
+ const args = value.args.map((entry, index) => {
334
+ if (typeof entry !== "string") reviewInvalid(`${label}.args[${index}] must be a string`);
335
+ if (["--write", "--by"].includes(entry)) reviewInvalid(`${label} contains an authority-bearing argument: ${entry}`);
336
+ return entry;
337
+ });
338
+ return { ...(actionId ? { actionId } : {}), command: value.command, args };
339
+ }
340
+
341
+ function normalizeReviewFinding(value, label) {
342
+ reviewObject(value, label);
343
+ reviewString(value.code, `${label}.code`);
344
+ reviewString(value.message, `${label}.message`);
345
+ if (value.actionId !== undefined) reviewStableId(value.actionId, `${label}.actionId`);
346
+ try {
347
+ validateJsonValue(value);
348
+ } catch (error) {
349
+ reviewInvalid(`${label} must be JSON-compatible`, { reason: error.message });
350
+ }
351
+ return canonicalValue(value);
352
+ }
353
+
354
+ function normalizeImpact(value, label) {
355
+ reviewObject(value, label);
356
+ reviewExactKeys(value, new Set(["itemIds", "sourceIds", "paths", "projectionPaths", "directProjectionPaths"]), label);
357
+ const itemIds = reviewStrings(value.itemIds, `${label}.itemIds`, { empty: true });
358
+ itemIds.forEach((entry) => reviewStableId(entry, `${label}.itemIds entry`));
359
+ const sourceIds = reviewStrings(value.sourceIds, `${label}.sourceIds`, { empty: true });
360
+ sourceIds.forEach((entry) => reviewStableId(entry, `${label}.sourceIds entry`));
361
+ return {
362
+ itemIds,
363
+ sourceIds,
364
+ paths: reviewStrings(value.paths, `${label}.paths`, { empty: true }),
365
+ projectionPaths: reviewStrings(value.projectionPaths, `${label}.projectionPaths`, { empty: true }),
366
+ ...(value.directProjectionPaths !== undefined
367
+ ? { directProjectionPaths: reviewStrings(value.directProjectionPaths, `${label}.directProjectionPaths`, { empty: true }) }
368
+ : {}),
369
+ };
370
+ }
371
+
372
+ export function validateReviewBundle(input) {
373
+ reviewObject(input, "review bundle");
374
+ reviewExactKeys(input, new Set([
375
+ "schemaVersion", "projectId", "planDigest", "baselines", "status", "summary", "actions", "groups", "findings", "invocations",
376
+ ]), "review bundle");
377
+ if (input.schemaVersion !== REVIEW_BUNDLE_SCHEMA_VERSION) reviewInvalid(`review bundle schemaVersion must be ${REVIEW_BUNDLE_SCHEMA_VERSION}`);
378
+ reviewStableId(input.projectId, "review bundle.projectId");
379
+ reviewDigest(input.planDigest, "review bundle.planDigest");
380
+ reviewObject(input.baselines, "review bundle.baselines");
381
+ reviewExactKeys(input.baselines, new Set(["contract", "sourcesLock", "projectionsLock"]), "review bundle.baselines");
382
+ for (const key of ["contract", "sourcesLock", "projectionsLock"]) reviewDigest(input.baselines[key], `review bundle.baselines.${key}`);
383
+ if (!Array.isArray(input.actions) || input.actions.length === 0) reviewInvalid("review bundle.actions must be a non-empty array");
384
+ const ids = new Set();
385
+ const actions = input.actions.map((action, index) => {
386
+ const label = `review bundle.actions[${index}]`;
387
+ reviewObject(action, label);
388
+ reviewExactKeys(action, new Set([
389
+ "id", "kind", "status", "current", "proposed", "baselines", "impact", "blockers", "humanApprovalRequired", "invocation",
390
+ ]), label);
391
+ reviewStableId(action.id, `${label}.id`);
392
+ if (ids.has(action.id)) reviewInvalid(`review bundle contains duplicate action id: ${action.id}`);
393
+ ids.add(action.id);
394
+ if (!ACTION_KIND_SET.has(action.kind)) reviewInvalid(`${label}.kind is invalid`);
395
+ if (!["reviewable", "blocked"].includes(action.status)) reviewInvalid(`${label}.status is invalid`);
396
+ if (action.humanApprovalRequired !== true) reviewInvalid(`${label}.humanApprovalRequired must be true`);
397
+ reviewObject(action.baselines, `${label}.baselines`);
398
+ for (const key of ["contract", "sourcesLock", "projectionsLock"]) reviewDigest(action.baselines[key], `${label}.baselines.${key}`);
399
+ if (!Array.isArray(action.blockers)) reviewInvalid(`${label}.blockers must be an array`);
400
+ const blockers = action.blockers.map((entry, blockerIndex) => normalizeReviewFinding(entry, `${label}.blockers[${blockerIndex}]`));
401
+ if (action.status === "reviewable" && (blockers.length > 0 || action.invocation === undefined)) {
402
+ reviewInvalid(`${label} reviewable action must have an invocation and no blockers`);
403
+ }
404
+ if (action.status === "blocked" && (blockers.length === 0 || action.invocation !== undefined)) {
405
+ reviewInvalid(`${label} blocked action must have blockers and no invocation`);
406
+ }
407
+ for (const field of ["current", "proposed", "baselines"]) {
408
+ try {
409
+ validateJsonValue(action[field]);
410
+ } catch (error) {
411
+ reviewInvalid(`${label}.${field} must be JSON-compatible`, { reason: error.message });
412
+ }
413
+ }
414
+ return {
415
+ id: action.id,
416
+ kind: action.kind,
417
+ status: action.status,
418
+ current: canonicalValue(action.current),
419
+ proposed: canonicalValue(action.proposed),
420
+ baselines: canonicalValue(action.baselines),
421
+ impact: normalizeImpact(action.impact, `${label}.impact`),
422
+ blockers,
423
+ humanApprovalRequired: true,
424
+ ...(action.invocation !== undefined ? { invocation: normalizeReviewInvocation(action.invocation, `${label}.invocation`) } : {}),
425
+ };
426
+ }).sort((left, right) => left.id.localeCompare(right.id));
427
+
428
+ if (!Array.isArray(input.groups) || input.groups.length === 0) reviewInvalid("review bundle.groups must be a non-empty array");
429
+ const groups = input.groups.map((group, index) => {
430
+ const label = `review bundle.groups[${index}]`;
431
+ reviewObject(group, label);
432
+ reviewExactKeys(group, new Set(["kind", "actionIds"]), label);
433
+ if (!["source-change", "new-or-revised-knowledge", "deprecation", "approval-request", "projection", "blocker"].includes(group.kind)) {
434
+ reviewInvalid(`${label}.kind is invalid`);
435
+ }
436
+ const actionIds = reviewStrings(group.actionIds, `${label}.actionIds`);
437
+ for (const id of actionIds) {
438
+ reviewStableId(id, `${label}.actionIds entry`);
439
+ if (!ids.has(id)) reviewInvalid(`${label} references an unknown action: ${id}`);
440
+ }
441
+ return { kind: group.kind, actionIds };
442
+ }).sort((left, right) => left.kind.localeCompare(right.kind));
443
+
444
+ if (!Array.isArray(input.findings)) reviewInvalid("review bundle.findings must be an array");
445
+ const findings = input.findings.map((entry, index) => normalizeReviewFinding(entry, `review bundle.findings[${index}]`));
446
+ for (const finding of findings) {
447
+ if (!finding.actionId || !ids.has(finding.actionId)) reviewInvalid("review bundle finding must reference a known action");
448
+ }
449
+ if (!Array.isArray(input.invocations)) reviewInvalid("review bundle.invocations must be an array");
450
+ const actionById = new Map(actions.map((action) => [action.id, action]));
451
+ const invocations = input.invocations.map((entry, index) => {
452
+ const normalized = normalizeReviewInvocation(entry, `review bundle.invocations[${index}]`, entry?.actionId);
453
+ const action = actionById.get(normalized.actionId);
454
+ if (!action || action.status !== "reviewable") reviewInvalid(`review bundle invocation references a non-reviewable action: ${normalized.actionId}`);
455
+ if (JSON.stringify(normalized, ["command", "args"]) !== JSON.stringify(action.invocation, ["command", "args"])) {
456
+ reviewInvalid(`review bundle invocation does not match action: ${normalized.actionId}`);
457
+ }
458
+ return normalized;
459
+ }).sort((left, right) => left.actionId.localeCompare(right.actionId));
460
+
461
+ reviewObject(input.summary, "review bundle.summary");
462
+ reviewExactKeys(input.summary, new Set(["actions", "reviewable", "blocked", "groups"]), "review bundle.summary");
463
+ const reviewable = actions.filter((action) => action.status === "reviewable").length;
464
+ const blocked = actions.length - reviewable;
465
+ const expectedSummary = { actions: actions.length, reviewable, blocked, groups: groups.length };
466
+ for (const key of Object.keys(expectedSummary)) {
467
+ if (input.summary[key] !== expectedSummary[key]) reviewInvalid(`review bundle.summary.${key} is inconsistent`);
468
+ }
469
+ const expectedStatus = blocked === 0 ? "reviewable" : "blocked";
470
+ if (input.status !== expectedStatus) reviewInvalid(`review bundle.status must be ${expectedStatus}`);
471
+ if (invocations.length !== reviewable) reviewInvalid("review bundle must contain exactly one invocation per reviewable action");
472
+ if (findings.length !== actions.reduce((count, action) => count + action.blockers.length, 0)) {
473
+ reviewInvalid("review bundle findings must match action blockers");
474
+ }
475
+ if (JSON.stringify(input).includes("exchange-preflight-not-authority")) reviewInvalid("review bundle contains internal preview authority data");
476
+
477
+ return canonicalValue({
478
+ schemaVersion: REVIEW_BUNDLE_SCHEMA_VERSION,
479
+ projectId: input.projectId,
480
+ planDigest: input.planDigest,
481
+ baselines: canonicalValue(input.baselines),
482
+ status: expectedStatus,
483
+ summary: expectedSummary,
484
+ actions,
485
+ groups,
486
+ findings,
487
+ invocations,
488
+ });
489
+ }
490
+
491
+ export function assertActionPlanConflictFree(plan) {
492
+ const owners = new Map();
493
+ const conflicts = [];
494
+ for (const action of plan.actions) {
495
+ const targets = [];
496
+ if (["register-source", "accept-source-change", "deprecate-source"].includes(action.kind)) targets.push(`source:${action.input.id}`);
497
+ if (["propose-item", "revise-item", "deprecate-item"].includes(action.kind)) targets.push(`item:${action.input.id}`);
498
+ if (action.kind === "request-item-approval") targets.push(...action.input.ids.map((id) => `item:${id}`));
499
+ if (action.kind === "publish-projection") targets.push(`projection:${action.input.output}`);
500
+ for (const target of targets) {
501
+ const existing = owners.get(target);
502
+ if (existing) conflicts.push({ target, actions: [existing, action.id].sort() });
503
+ else owners.set(target, action.id);
504
+ }
505
+ }
506
+ if (conflicts.length > 0) {
507
+ fail("action-plan-conflict", "action plan contains actions that mutate the same governed target", { exitCode: 1, details: { conflicts } });
508
+ }
509
+ return plan;
510
+ }
511
+
512
+ export function itemPrimitiveInput(input) {
513
+ return {
514
+ id: input.id,
515
+ kind: input.kind,
516
+ subject: input.subject,
517
+ value: structuredClone(input.value),
518
+ statement: input.statement,
519
+ sources: [...input.sources],
520
+ scope: input.scope.kind,
521
+ scopePath: input.scope.path,
522
+ overrides: [...input.overrides],
523
+ verification: input.verification?.kind,
524
+ verificationSource: input.verification?.source,
525
+ verificationExpectedPresent: Boolean(input.verification && Object.hasOwn(input.verification, "expected")),
526
+ verificationExpected: input.verification?.expected,
527
+ };
528
+ }