frontend-project-context 1.3.0 → 1.6.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +39 -2
  2. package/README.md +94 -16
  3. package/UPGRADING.md +47 -2
  4. package/docs/04-PROGRAM-DESIGN.md +40 -4
  5. package/docs/05-ACCEPTANCE-CONTRACT.md +33 -3
  6. package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +36 -6
  7. package/docs/14-FORMAL-RELEASE-READINESS.md +46 -0
  8. package/docs/18-BRANCH-AWARE-STAGED-CONTEXT-DESIGN.md +62 -2
  9. package/docs/19-POST-1.3.1-AI-TAKEOVER-EVIDENCE-AND-UPGRADE-PLAN.md +579 -0
  10. package/docs/20-PHASE-A-AI-TAKEOVER-AND-HEALTH-CLOSURE-DESIGN.md +535 -0
  11. package/docs/21-PHASE-B-EVIDENCE-FEEDBACK-PROTOCOL-DESIGN.md +347 -0
  12. package/docs/22-PHASE-C-TARGET-UPGRADE-PROTOCOL-DESIGN.md +398 -0
  13. package/docs/README.md +21 -5
  14. package/docs/USER-AND-AI-OPERATION-MANUAL.md +797 -0
  15. package/examples/README.md +38 -0
  16. package/examples/package.json +6 -2
  17. package/migration-manifest.json +88 -0
  18. package/package.json +3 -2
  19. package/schemas/action-plan.schema.json +31 -3
  20. package/schemas/capabilities.schema.json +50 -18
  21. package/schemas/evidence-bundle.schema.json +64 -0
  22. package/schemas/evidence-input.schema.json +82 -0
  23. package/schemas/migration-manifest.schema.json +29 -0
  24. package/schemas/migration-plan.schema.json +32 -0
  25. package/schemas/project-status.schema.json +75 -0
  26. package/schemas/projection-lock.schema.json +48 -0
  27. package/schemas/review-bundle.schema.json +3 -3
  28. package/schemas/upgrade-assessment.schema.json +48 -0
  29. package/schemas/upgrade-result-bundle.schema.json +35 -0
  30. package/src/project-context/ai-entry.mjs +320 -0
  31. package/src/project-context/capabilities.mjs +44 -17
  32. package/src/project-context/checker.mjs +20 -3
  33. package/src/project-context/cli.mjs +84 -7
  34. package/src/project-context/contract-schema.mjs +30 -16
  35. package/src/project-context/dashboard-model.mjs +4 -4
  36. package/src/project-context/dashboard-renderer.mjs +3 -3
  37. package/src/project-context/discovery.mjs +6 -1
  38. package/src/project-context/evidence-schema.mjs +209 -0
  39. package/src/project-context/evidence.mjs +99 -0
  40. package/src/project-context/exchange-schema.mjs +21 -11
  41. package/src/project-context/exchange.mjs +26 -4
  42. package/src/project-context/maintenance.mjs +2 -2
  43. package/src/project-context/migration-manifest.mjs +166 -0
  44. package/src/project-context/project-status.mjs +157 -0
  45. package/src/project-context/projection-store.mjs +8 -1
  46. package/src/project-context/task-context-schema.mjs +237 -1
  47. package/src/project-context/task-context.mjs +154 -13
  48. package/src/project-context/upgrade-schema.mjs +215 -0
  49. package/src/project-context/upgrade.mjs +494 -0
@@ -0,0 +1,157 @@
1
+ import { buildSyncAssistBundle } from "./assist.mjs";
2
+ import { inspectAiEntry } from "./ai-entry.mjs";
3
+ import { checkExitCode, checkProject } from "./checker.mjs";
4
+ import { ProjectContextError } from "./errors.mjs";
5
+ import { PACKAGE_VERSION } from "./exchange-schema.mjs";
6
+ import { inspectProjectInitialization, loadProject } from "./project-store.mjs";
7
+
8
+ export const PROJECT_STATUS_SCHEMA_VERSION = 1;
9
+
10
+ function uniqueSorted(values) {
11
+ return [...new Set(values.filter((value) => value !== undefined && value !== null))]
12
+ .sort((left, right) => String(left).localeCompare(String(right)));
13
+ }
14
+
15
+ function emptySummary() {
16
+ return { findings: 0, changedSources: 0, pendingItems: 0, affectedProjections: 0 };
17
+ }
18
+
19
+ function compactReadTarget(target) {
20
+ return {
21
+ sourceId: target.sourceId,
22
+ locator: structuredClone(target.locator),
23
+ reason: target.reason,
24
+ priority: target.priority,
25
+ };
26
+ }
27
+
28
+ function compactWorkUnit(unit) {
29
+ const compact = { kind: unit.kind };
30
+ for (const key of ["sourceIds", "itemIds", "fallbackItemIds", "paths", "projectionPaths", "findingCodes"]) {
31
+ if (unit[key] !== undefined) compact[key] = [...unit[key]];
32
+ }
33
+ return compact;
34
+ }
35
+
36
+ function base(initialization, health, nextActions) {
37
+ return {
38
+ schemaVersion: PROJECT_STATUS_SCHEMA_VERSION,
39
+ package: { name: "frontend-project-context", version: PACKAGE_VERSION },
40
+ initialization: {
41
+ state: initialization,
42
+ present: [],
43
+ missing: [],
44
+ },
45
+ project: null,
46
+ snapshots: null,
47
+ health,
48
+ entry: { state: "absent", path: null, rendererVersion: null },
49
+ summary: emptySummary(),
50
+ findingCodes: [],
51
+ sourceIds: [],
52
+ itemIds: [],
53
+ projectionPaths: [],
54
+ readTargets: [],
55
+ workUnits: [],
56
+ nextActions,
57
+ boundaries: {},
58
+ };
59
+ }
60
+
61
+ function statusBoundaries(boundaries) {
62
+ return structuredClone(boundaries);
63
+ }
64
+
65
+ export async function buildProjectStatus(root, boundaries) {
66
+ const initialization = await inspectProjectInitialization(root);
67
+ if (initialization.status !== "initialized") {
68
+ const result = base(initialization.status, initialization.status, initialization.status === "uninitialized" ? ["run-setup-preview"] : ["resolve-conflict"]);
69
+ result.initialization.present = [...initialization.present];
70
+ result.initialization.missing = [...initialization.missing];
71
+ if (initialization.status === "partial") result.findingCodes = ["project-state-partial"];
72
+ result.summary.findings = result.findingCodes.length;
73
+ result.boundaries = statusBoundaries(boundaries);
74
+ return { status: result, exitCode: initialization.status === "uninitialized" ? 1 : 2 };
75
+ }
76
+
77
+ let project;
78
+ try {
79
+ project = await loadProject(root);
80
+ } catch (error) {
81
+ if (!(error instanceof ProjectContextError)) throw error;
82
+ const result = base("invalid", "invalid", ["resolve-conflict"]);
83
+ result.initialization.present = [...initialization.present];
84
+ result.initialization.missing = [...initialization.missing];
85
+ result.findingCodes = ["project-state-invalid"];
86
+ result.summary.findings = 1;
87
+ result.boundaries = statusBoundaries(boundaries);
88
+ return { status: result, exitCode: 2 };
89
+ }
90
+
91
+ const regionEntries = project.projectionsLock.projections.filter((entry) => entry.ownership === "region" && entry.target === "ai-entry");
92
+ const entryPath = regionEntries[0]?.path ?? "AGENTS.md";
93
+ const [sync, entry] = await Promise.all([
94
+ buildSyncAssistBundle(root, project),
95
+ inspectAiEntry(root, project, entryPath),
96
+ ]);
97
+ const findings = [...sync.findings];
98
+ if (entry.state === "absent") findings.push({ code: "ai-entry-missing", path: null });
99
+ if (entry.state === "conflict" && !findings.some((finding) => finding.code === "ai-entry-ownership-conflict")) {
100
+ findings.push({ code: "ai-entry-ownership-conflict", path: entry.path });
101
+ }
102
+ const conflict = entry.state === "conflict" || findings.some((finding) => finding.code.includes("ownership-conflict"));
103
+ const sourceOrContractBlocker = findings.some((finding) =>
104
+ finding.code.startsWith("source-") || finding.code.startsWith("verification-") || finding.code.startsWith("scope-") || finding.code === "contract-conflict",
105
+ );
106
+ const health = conflict ? "conflict" : findings.length > 0 || entry.state !== "current" ? "attention" : "clean";
107
+ const nextActions = [];
108
+ if (conflict) nextActions.push("resolve-conflict");
109
+ else {
110
+ if (sync.summary.changedSources > 0 || sourceOrContractBlocker) nextActions.push("review-source-change");
111
+ if (sync.summary.pendingItems > 0) nextActions.push("review-pending");
112
+ if (sync.summary.affectedProjections > 0) nextActions.push("review-projection");
113
+ if (entry.state !== "current") nextActions.push("publish-ai-entry");
114
+ if (findings.length > 0 && nextActions.length === 0) nextActions.push("run-sync");
115
+ if (health === "clean") nextActions.push("ready-for-task");
116
+ }
117
+ const sourceIds = uniqueSorted([
118
+ ...findings.flatMap((finding) => finding.source ? [finding.source] : []),
119
+ ...sync.workUnits.flatMap((unit) => unit.sourceIds ?? []),
120
+ ]);
121
+ const itemIds = uniqueSorted([
122
+ ...findings.flatMap((finding) => finding.items ?? (finding.item ? [finding.item] : [])),
123
+ ...sync.workUnits.flatMap((unit) => unit.itemIds ?? []),
124
+ ]);
125
+ const projectionPaths = uniqueSorted([
126
+ ...sync.projectionPaths,
127
+ ...findings.flatMap((finding) => finding.path ? [finding.path] : []),
128
+ ]);
129
+ const status = {
130
+ schemaVersion: PROJECT_STATUS_SCHEMA_VERSION,
131
+ package: { name: "frontend-project-context", version: PACKAGE_VERSION },
132
+ initialization: { state: "initialized", present: [...initialization.present], missing: [] },
133
+ project: { id: project.contract.project.id, name: project.contract.project.name },
134
+ snapshots: {
135
+ contract: project.contractDigest,
136
+ sourcesLock: project.sourcesLockDigest,
137
+ projectionsLock: project.projectionsLockDigest,
138
+ },
139
+ health,
140
+ entry,
141
+ summary: {
142
+ findings: findings.length,
143
+ changedSources: sync.summary.changedSources,
144
+ pendingItems: sync.summary.pendingItems,
145
+ affectedProjections: projectionPaths.length,
146
+ },
147
+ findingCodes: uniqueSorted(findings.map((finding) => finding.code)),
148
+ sourceIds,
149
+ itemIds,
150
+ projectionPaths,
151
+ readTargets: sync.readTargets.map(compactReadTarget),
152
+ workUnits: sync.workUnits.map(compactWorkUnit),
153
+ nextActions: uniqueSorted(nextActions),
154
+ boundaries: statusBoundaries(boundaries),
155
+ };
156
+ return { status, exitCode: health === "clean" ? 0 : conflict ? 3 : Math.max(1, checkExitCode(findings)) };
157
+ }
@@ -28,6 +28,12 @@ export async function publishProjection(root, project, options, dependencies = {
28
28
  const rendered = renderProjection(project.contract, paths, target);
29
29
  const existing = await readContent(resolved.absolute);
30
30
  const lockEntry = project.projectionsLock.projections.find((entry) => entry.path === resolved.normalized);
31
+ if (lockEntry?.ownership === "region") {
32
+ fail("ai-entry-path-conflict", `refusing whole-file projection over managed AI Entry: ${resolved.normalized}`, {
33
+ exitCode: 3,
34
+ details: { path: resolved.normalized },
35
+ });
36
+ }
31
37
  if (existing !== null) {
32
38
  const marker = parseProjectionMarker(existing);
33
39
  if (
@@ -46,6 +52,7 @@ export async function publishProjection(root, project, options, dependencies = {
46
52
  const nextEntry = {
47
53
  path: resolved.normalized,
48
54
  target,
55
+ ...(project.projectionsLock.schemaVersion === 2 ? { ownership: "file" } : {}),
49
56
  paths: rendered.paths,
50
57
  contractDigest: rendered.contractDigest,
51
58
  bundleDigest: rendered.bundleDigest,
@@ -54,7 +61,7 @@ export async function publishProjection(root, project, options, dependencies = {
54
61
  rendererVersion: rendered.rendererVersion,
55
62
  };
56
63
  const nextLock = {
57
- schemaVersion: 1,
64
+ schemaVersion: project.projectionsLock.schemaVersion,
58
65
  projections: [
59
66
  ...project.projectionsLock.projections.filter((entry) => entry.path !== resolved.normalized),
60
67
  nextEntry,
@@ -1,4 +1,4 @@
1
- import { canonicalValue, digestJson } from "./canonical-json.mjs";
1
+ import { canonicalJson, canonicalValue, digestJson, validateJsonValue } from "./canonical-json.mjs";
2
2
  import { fail } from "./errors.mjs";
3
3
  import { normalizeRelativePath } from "./path-policy.mjs";
4
4
 
@@ -288,3 +288,239 @@ export function validateStageReceipt(input, planInput, options = {}) {
288
288
  export function stageReceiptDigest(receipt, plan) {
289
289
  return digestJson(validateStageReceipt(receipt, plan));
290
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
+ }
@@ -10,6 +10,7 @@ import {
10
10
  INTEGRATION_REVIEW_BUNDLE_SCHEMA_VERSION,
11
11
  STAGE_CONTEXT_BUNDLE_SCHEMA_VERSION,
12
12
  taskContextPlanDigest,
13
+ validateStageContextBundle,
13
14
  validateStageReceipt,
14
15
  validateTaskContextPlan,
15
16
  } from "./task-context-schema.mjs";
@@ -185,17 +186,13 @@ function acceptanceFor(plan, stage) {
185
186
  return plan.task.acceptance.filter((entry) => ids.has(entry.id)).map((entry) => structuredClone(entry));
186
187
  }
187
188
 
188
- export async function buildStageContextBundle(root, project, planInput, options) {
189
- const plan = validateTaskContextPlan(planInput);
190
- if (plan.projectId !== project.contract.project.id) fail("task-context-project-mismatch", "task context plan belongs to a different project");
191
- await validatePlanPaths(root, plan);
189
+ async function buildStageContextBundleCore(root, project, plan, options, receipts, bindingFindings = []) {
192
190
  const stage = plan.stages.find((entry) => entry.id === options.stageId);
193
191
  if (!stage) fail("task-context-stage-missing", `task context plan has no stage: ${options.stageId}`);
194
192
  const changedPaths = normalizeSignalPaths(options.changedPaths ?? [], "changed path");
195
193
  await validateProjectPaths(root, changedPaths, "changed path");
196
- const receipts = normalizedReceipts(options.receipts ?? [], plan);
197
194
  const planDigest = taskContextPlanDigest(plan);
198
- const findings = [...baselineFindings(plan, project), ...checkerFindings(await checkProject(root, project))];
195
+ const findings = [...bindingFindings, ...baselineFindings(plan, project), ...checkerFindings(await checkProject(root, project))];
199
196
  const dependencyReceipts = [];
200
197
  for (const dependencyId of stage.dependsOn) {
201
198
  const receipt = receipts.get(dependencyId);
@@ -259,6 +256,142 @@ export async function buildStageContextBundle(root, project, planInput, options)
259
256
  return bundle;
260
257
  }
261
258
 
259
+ function sameJson(left, right) {
260
+ return canonicalJson(left) === canonicalJson(right);
261
+ }
262
+
263
+ function artifactStageId(input) {
264
+ return input && typeof input === "object" && !Array.isArray(input) && input.stage
265
+ && typeof input.stage === "object" && !Array.isArray(input.stage) && typeof input.stage.id === "string"
266
+ ? input.stage.id
267
+ : undefined;
268
+ }
269
+
270
+ async function verifyReceiptBundleBindings(root, project, plan, receipts, receiptBundleInputs) {
271
+ const findings = [];
272
+ const candidatesByStage = new Map();
273
+ for (const input of receiptBundleInputs) {
274
+ const stageId = artifactStageId(input);
275
+ let bundle;
276
+ try {
277
+ bundle = validateStageContextBundle(input);
278
+ } catch (error) {
279
+ if (!(error instanceof ProjectContextError) || error.code !== "stage-context-bundle-schema-invalid") throw error;
280
+ findings.push(finding("stage-receipt-input-bundle-invalid", "blocked", {
281
+ ...(stageId ? { stageId } : {}),
282
+ reason: error.message,
283
+ }));
284
+ }
285
+ if (!stageId) continue;
286
+ const candidates = candidatesByStage.get(stageId) ?? [];
287
+ candidates.push(bundle);
288
+ candidatesByStage.set(stageId, candidates);
289
+ }
290
+
291
+ for (const [stageId, candidates] of candidatesByStage) {
292
+ if (!receipts.has(stageId)) findings.push(finding("stage-receipt-input-bundle-mismatch", "blocked", {
293
+ stageId,
294
+ reason: "receipt-missing",
295
+ }));
296
+ if (candidates.length > 1) findings.push(finding("stage-receipt-input-bundle-duplicate", "blocked", {
297
+ stageId,
298
+ count: candidates.length,
299
+ }));
300
+ }
301
+
302
+ const planDigest = taskContextPlanDigest(plan);
303
+ const currentSnapshots = snapshots(project);
304
+ const verifiedReceipts = new Map();
305
+ const verification = new Map();
306
+
307
+ async function verify(stageId) {
308
+ if (verification.has(stageId)) return verification.get(stageId);
309
+ const pending = (async () => {
310
+ const receipt = receipts.get(stageId);
311
+ if (!receipt) return false;
312
+ const candidates = candidatesByStage.get(stageId) ?? [];
313
+ if (candidates.length === 0) {
314
+ findings.push(finding("stage-receipt-input-bundle-missing", "blocked", { stageId }));
315
+ return false;
316
+ }
317
+ if (candidates.length !== 1 || !candidates[0]) return false;
318
+ const bundle = candidates[0];
319
+ let valid = true;
320
+ const mismatch = (reason, details = {}) => {
321
+ valid = false;
322
+ findings.push(finding("stage-receipt-input-bundle-mismatch", "blocked", { stageId, reason, ...details }));
323
+ };
324
+ const stale = (reason, details = {}) => {
325
+ valid = false;
326
+ findings.push(finding("stage-receipt-input-bundle-stale", "blocked", { stageId, reason, ...details }));
327
+ };
328
+ if (bundle.project.id !== receipt.projectId) mismatch("project-id", { expected: receipt.projectId, actual: bundle.project.id });
329
+ if (bundle.task.id !== receipt.taskId) mismatch("task-id", { expected: receipt.taskId, actual: bundle.task.id });
330
+ if (bundle.stage.id !== receipt.stageId) mismatch("stage-id", { expected: receipt.stageId, actual: bundle.stage.id });
331
+ if (bundle.project.id !== plan.projectId || bundle.task.id !== plan.task.id || !plan.stages.some((entry) => entry.id === bundle.stage.id)) {
332
+ mismatch("plan-identity");
333
+ }
334
+ if (receipt.planDigest !== planDigest) {
335
+ stale("receipt-plan-digest", { expected: planDigest, actual: receipt.planDigest });
336
+ findings.push(finding("stage-receipt-stale", "blocked", { stageId, reason: "plan-digest" }));
337
+ }
338
+ if (bundle.planDigest !== planDigest) stale("bundle-plan-digest", { expected: planDigest, actual: bundle.planDigest });
339
+ for (const key of Object.keys(currentSnapshots)) {
340
+ if (bundle.snapshots[key] !== plan.snapshots[key] || bundle.snapshots[key] !== currentSnapshots[key]) {
341
+ stale(`${key}-snapshot`, {
342
+ plan: plan.snapshots[key],
343
+ current: currentSnapshots[key],
344
+ actual: bundle.snapshots[key],
345
+ });
346
+ }
347
+ }
348
+
349
+ const stage = plan.stages.find((entry) => entry.id === stageId);
350
+ const dependencyReceipts = new Map();
351
+ for (const dependencyId of stage.dependsOn) {
352
+ if (receipts.has(dependencyId) && await verify(dependencyId)) dependencyReceipts.set(dependencyId, receipts.get(dependencyId));
353
+ }
354
+ const rebuilt = await buildStageContextBundleCore(root, project, plan, {
355
+ stageId,
356
+ changedPaths: bundle.changedPaths,
357
+ }, dependencyReceipts);
358
+ if (!sameJson(bundle, rebuilt)) stale("canonical-content", {
359
+ expectedBundleDigest: rebuilt.bundleDigest,
360
+ actualBundleDigest: bundle.bundleDigest,
361
+ });
362
+ if (receipt.inputBundleDigest !== bundle.bundleDigest) mismatch("receipt-input-bundle-digest", {
363
+ expected: bundle.bundleDigest,
364
+ actual: receipt.inputBundleDigest,
365
+ });
366
+ if (receipt.status === "completed" && bundle.status === "blocked") {
367
+ valid = false;
368
+ findings.push(finding("stage-receipt-input-bundle-invalid", "blocked", {
369
+ stageId,
370
+ reason: "completed-receipt-bound-to-blocked-bundle",
371
+ }));
372
+ }
373
+ if (valid) verifiedReceipts.set(stageId, receipt);
374
+ return valid;
375
+ })();
376
+ verification.set(stageId, pending);
377
+ return pending;
378
+ }
379
+
380
+ for (const stage of plan.stages) {
381
+ if (receipts.has(stage.id)) await verify(stage.id);
382
+ }
383
+ return { receipts: verifiedReceipts, findings: stableFindings(findings) };
384
+ }
385
+
386
+ export async function buildStageContextBundle(root, project, planInput, options) {
387
+ const plan = validateTaskContextPlan(planInput);
388
+ if (plan.projectId !== project.contract.project.id) fail("task-context-project-mismatch", "task context plan belongs to a different project");
389
+ await validatePlanPaths(root, plan);
390
+ const receipts = normalizedReceipts(options.receipts ?? [], plan);
391
+ const binding = await verifyReceiptBundleBindings(root, project, plan, receipts, options.receiptBundles ?? []);
392
+ return buildStageContextBundleCore(root, project, plan, options, binding.receipts, binding.findings);
393
+ }
394
+
262
395
  function receiptScopeEscapes(plan, receipt) {
263
396
  const stage = plan.stages.find((entry) => entry.id === receipt.stageId);
264
397
  if (!stage) return receipt.changedPaths;
@@ -286,9 +419,12 @@ export async function buildIntegrationReviewBundle(root, project, planInput, opt
286
419
  const mainChangedPaths = normalizeSignalPaths(options.mainChangedPaths ?? [], "main changed path");
287
420
  const branchChangedPaths = normalizeSignalPaths(options.branchChangedPaths ?? [], "branch changed path");
288
421
  await validateProjectPaths(root, [...mainChangedPaths, ...branchChangedPaths], "integration changed path");
289
- const receipts = normalizedReceipts(options.receipts ?? [], plan);
422
+ const suppliedReceipts = normalizedReceipts(options.receipts ?? [], plan);
423
+ const binding = await verifyReceiptBundleBindings(root, project, plan, suppliedReceipts, options.receiptBundles ?? []);
424
+ const receipts = binding.receipts;
290
425
  const planDigest = taskContextPlanDigest(plan);
291
426
  const findings = [
427
+ ...binding.findings,
292
428
  ...baselineFindings(plan, project),
293
429
  ...checkerFindings(await checkProject(root, project)),
294
430
  ...pathOverlapFindings(mainChangedPaths, branchChangedPaths),
@@ -337,7 +473,7 @@ export async function buildIntegrationReviewBundle(root, project, planInput, opt
337
473
  return { ...bundle, bundleDigest: digestJson(bundle) };
338
474
  }
339
475
 
340
- async function readInputs(root, planPath, receiptPaths) {
476
+ async function readInputs(root, planPath, receiptPaths, receiptBundlePaths) {
341
477
  const resolvedPlan = await resolveExistingInside(root, planPath);
342
478
  const plan = await readJsonFile(resolvedPlan.absolute, "task context plan");
343
479
  const receipts = [];
@@ -345,17 +481,22 @@ async function readInputs(root, planPath, receiptPaths) {
345
481
  const resolved = await resolveExistingInside(root, receiptPath);
346
482
  receipts.push(await readJsonFile(resolved.absolute, "stage receipt"));
347
483
  }
348
- return { plan, receipts };
484
+ const receiptBundles = [];
485
+ for (const bundlePath of receiptBundlePaths) {
486
+ const resolved = await resolveExistingInside(root, bundlePath);
487
+ receiptBundles.push(await readJsonFile(resolved.absolute, "stage context bundle"));
488
+ }
489
+ return { plan, receipts, receiptBundles };
349
490
  }
350
491
 
351
492
  export async function buildStageContextBundleFiles(root, planPath, options) {
352
- const { plan, receipts } = await readInputs(root, planPath, options.receiptPaths ?? []);
493
+ const { plan, receipts, receiptBundles } = await readInputs(root, planPath, options.receiptPaths ?? [], options.receiptBundlePaths ?? []);
353
494
  const project = await loadProject(root);
354
- return buildStageContextBundle(root, project, plan, { ...options, receipts });
495
+ return buildStageContextBundle(root, project, plan, { ...options, receipts, receiptBundles });
355
496
  }
356
497
 
357
498
  export async function buildIntegrationReviewBundleFiles(root, planPath, options) {
358
- const { plan, receipts } = await readInputs(root, planPath, options.receiptPaths ?? []);
499
+ const { plan, receipts, receiptBundles } = await readInputs(root, planPath, options.receiptPaths ?? [], options.receiptBundlePaths ?? []);
359
500
  const project = await loadProject(root);
360
- return buildIntegrationReviewBundle(root, project, plan, { ...options, receipts });
501
+ return buildIntegrationReviewBundle(root, project, plan, { ...options, receipts, receiptBundles });
361
502
  }