frontend-project-context 1.3.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -2
- package/README.md +156 -40
- package/UPGRADING.md +55 -1
- package/docs/04-PROGRAM-DESIGN.md +34 -4
- package/docs/05-ACCEPTANCE-CONTRACT.md +40 -3
- package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +67 -22
- package/docs/14-FORMAL-RELEASE-READINESS.md +30 -1
- package/docs/18-BRANCH-AWARE-STAGED-CONTEXT-DESIGN.md +2 -2
- package/docs/19-POST-1.3.1-AI-TAKEOVER-EVIDENCE-AND-UPGRADE-PLAN.md +579 -0
- package/docs/20-PHASE-A-AI-TAKEOVER-AND-HEALTH-CLOSURE-DESIGN.md +535 -0
- package/docs/21-PHASE-B-EVIDENCE-FEEDBACK-PROTOCOL-DESIGN.md +347 -0
- package/docs/22-PHASE-C-TARGET-UPGRADE-PROTOCOL-DESIGN.md +398 -0
- package/docs/23-ADAPTIVE-BOUNDED-TASK-CONTEXT-DESIGN.md +432 -0
- package/docs/24-A130-REAL-HOST-TARGET-PROJECT-COMPARISON.md +210 -0
- package/docs/25-REAL-PROJECT-SOURCE-OF-TRUTH-MAINTENANCE-DESIGN.md +409 -0
- package/docs/26-A130-QUALITY-CLOSURE-AND-ADAPTIVE-DELIVERY-REPAIR-DESIGN.md +609 -0
- package/docs/README.md +38 -6
- package/docs/USER-AND-AI-OPERATION-MANUAL.md +840 -0
- package/examples/README.md +29 -2
- package/examples/package.json +6 -2
- package/migration-manifest.json +110 -0
- package/package.json +3 -2
- package/schemas/action-plan.schema.json +31 -3
- package/schemas/adaptive-context-bundle.schema.json +70 -0
- package/schemas/capabilities.schema.json +64 -18
- package/schemas/context-query.schema.json +69 -0
- package/schemas/coverage-audit.schema.json +32 -0
- package/schemas/evidence-bundle.schema.json +64 -0
- package/schemas/evidence-input.schema.json +82 -0
- package/schemas/host-promotion-evidence.schema.json +33 -0
- package/schemas/migration-manifest.schema.json +29 -0
- package/schemas/migration-plan.schema.json +32 -0
- package/schemas/project-status.schema.json +75 -0
- package/schemas/projection-lock.schema.json +48 -0
- package/schemas/review-bundle.schema.json +3 -3
- package/schemas/routing-index.schema.json +58 -0
- package/schemas/truth-reconciliation-input.schema.json +60 -0
- package/schemas/truth-reconciliation-review-bundle.schema.json +155 -0
- package/schemas/upgrade-assessment.schema.json +48 -0
- package/schemas/upgrade-result-bundle.schema.json +35 -0
- package/src/project-context/a130-evaluation.mjs +91 -0
- package/src/project-context/adaptive-context-schema.mjs +392 -0
- package/src/project-context/adaptive-context.mjs +547 -0
- package/src/project-context/ai-entry.mjs +320 -0
- package/src/project-context/assist.mjs +4 -2
- package/src/project-context/capabilities.mjs +62 -17
- package/src/project-context/checker.mjs +24 -6
- package/src/project-context/cli.mjs +113 -3
- package/src/project-context/contract-schema.mjs +30 -16
- package/src/project-context/dashboard-model.mjs +4 -4
- package/src/project-context/dashboard-renderer.mjs +3 -3
- package/src/project-context/discovery.mjs +13 -8
- package/src/project-context/evidence-schema.mjs +209 -0
- package/src/project-context/evidence.mjs +99 -0
- package/src/project-context/exchange-schema.mjs +23 -12
- package/src/project-context/exchange.mjs +26 -4
- package/src/project-context/maintenance.mjs +4 -4
- package/src/project-context/migration-manifest.mjs +168 -0
- package/src/project-context/project-status.mjs +157 -0
- package/src/project-context/projection-store.mjs +8 -1
- package/src/project-context/renderer.mjs +75 -1
- package/src/project-context/source-reader.mjs +63 -30
- package/src/project-context/task-context.mjs +14 -2
- package/src/project-context/truth-reconciliation-schema.mjs +488 -0
- package/src/project-context/truth-reconciliation.mjs +543 -0
- package/src/project-context/upgrade-schema.mjs +219 -0
- package/src/project-context/upgrade.mjs +494 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { canonicalJson, digestJson } from "./canonical-json.mjs";
|
|
2
|
+
import { fail } from "./errors.mjs";
|
|
3
|
+
import { readJsonFile } from "./io.mjs";
|
|
4
|
+
import { resolveExistingInside } from "./path-policy.mjs";
|
|
5
|
+
import { loadProject } from "./project-store.mjs";
|
|
6
|
+
import {
|
|
7
|
+
finalizeTruthReviewBundle,
|
|
8
|
+
normalizeTruthReconciliationInput,
|
|
9
|
+
TRUTH_RECONCILIATION_REVIEW_BUNDLE_SCHEMA_VERSION,
|
|
10
|
+
validateTruthReviewBundle,
|
|
11
|
+
} from "./truth-reconciliation-schema.mjs";
|
|
12
|
+
|
|
13
|
+
const RESOLUTIONS = Object.freeze({
|
|
14
|
+
duplicate: ["keep-current-baseline"],
|
|
15
|
+
"compatible-compose": ["compose-compatible-changes"],
|
|
16
|
+
"scope-split": ["split-scope-or-override"],
|
|
17
|
+
supersede: ["accept-branch-change", "replace-or-deprecate-source"],
|
|
18
|
+
"semantic-conflict": ["keep-current-baseline", "accept-branch-change", "defer-scoped-conflict"],
|
|
19
|
+
"implementation-only": ["external-implementation-fix-required"],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function uniqueSorted(values) {
|
|
23
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scopeKey(scope) {
|
|
27
|
+
return scope.kind === "project" ? "project" : `${scope.kind}:${scope.path}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function scopesOverlapPath(scope, target) {
|
|
31
|
+
if (scope.kind === "project") return true;
|
|
32
|
+
if (scope.kind === "file") return target === scope.path;
|
|
33
|
+
return target === scope.path || target.startsWith(`${scope.path}/`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isTaskRelated(scopes, taskPaths) {
|
|
37
|
+
return scopes.some((scope) => taskPaths.some((target) => scopesOverlapPath(scope, target)));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function invocation(root, command, args) {
|
|
41
|
+
return { command, args: ["--project", root, ...args] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function recoveryPreviews(root, resolutionKinds, impacts, actualDigest) {
|
|
45
|
+
return resolutionKinds.map((resolutionKind) => {
|
|
46
|
+
if (resolutionKind === "accept-branch-change" && impacts.sourceIds.length === 1 && actualDigest) {
|
|
47
|
+
return {
|
|
48
|
+
resolutionKind,
|
|
49
|
+
invocation: invocation(root, "accept-source-change", [
|
|
50
|
+
"--id", impacts.sourceIds[0], "--expected-digest", actualDigest,
|
|
51
|
+
...(impacts.itemIds.length > 0 ? ["--affected-items", ...impacts.itemIds] : []),
|
|
52
|
+
]),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (resolutionKind === "fix-source-and-rerun" || resolutionKind === "refresh-promotion-evidence") {
|
|
56
|
+
return { resolutionKind, externalAction: { kind: resolutionKind === "fix-source-and-rerun" ? "refresh-project-baseline-and-contract-state" : "refresh-host-promotion-evidence", affectedSourceIds: impacts.sourceIds, affectedItemIds: impacts.itemIds } };
|
|
57
|
+
}
|
|
58
|
+
if (resolutionKind === "replace-or-deprecate-source" && impacts.sourceIds.length === 1) {
|
|
59
|
+
return { resolutionKind, invocation: invocation(root, "review-source", ["--id", impacts.sourceIds[0]]) };
|
|
60
|
+
}
|
|
61
|
+
if (resolutionKind === "external-implementation-fix-required") {
|
|
62
|
+
return { resolutionKind, externalAction: { kind: "fix-implementation-outside-project-context", affectedPaths: impacts.scopes.map((scope) => scope.path ?? ".") } };
|
|
63
|
+
}
|
|
64
|
+
return { resolutionKind, requiredHumanInputs: resolutionKind === "compose-compatible-changes" ? ["composedValue", "sources", "scope"] : resolutionKind === "split-scope-or-override" ? ["scopes", "overrideIds"] : ["decisionRationale"] };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function finding(root, code, severity, impacts, options = {}) {
|
|
69
|
+
const normalized = {
|
|
70
|
+
sourceIds: uniqueSorted(impacts.sourceIds ?? []),
|
|
71
|
+
itemIds: uniqueSorted(impacts.itemIds ?? []),
|
|
72
|
+
subjects: uniqueSorted(impacts.subjects ?? []),
|
|
73
|
+
scopes: [...new Map((impacts.scopes ?? []).map((scope) => [scopeKey(scope), scope])).values()]
|
|
74
|
+
.sort((left, right) => scopeKey(left).localeCompare(scopeKey(right))),
|
|
75
|
+
};
|
|
76
|
+
const resolutionKinds = uniqueSorted(options.resolutionKinds ?? ["keep-current-baseline"]);
|
|
77
|
+
if (resolutionKinds.length === 0) {
|
|
78
|
+
return finding(root, "recovery-contract-incomplete", "blocked", normalized, {
|
|
79
|
+
resolutionKinds: ["fix-source-and-rerun"], blocksTask: true,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const base = {
|
|
83
|
+
code,
|
|
84
|
+
severity,
|
|
85
|
+
affectedSourceIds: normalized.sourceIds,
|
|
86
|
+
affectedItemIds: normalized.itemIds,
|
|
87
|
+
affectedSubjects: normalized.subjects,
|
|
88
|
+
affectedScopes: normalized.scopes,
|
|
89
|
+
baseline: {
|
|
90
|
+
expectedDigest: options.expectedDigest ?? null,
|
|
91
|
+
actualDigest: options.actualDigest ?? null,
|
|
92
|
+
expectedLabel: options.expectedLabel ?? null,
|
|
93
|
+
actualLabel: options.actualLabel ?? null,
|
|
94
|
+
},
|
|
95
|
+
blocksTask: options.blocksTask ?? false,
|
|
96
|
+
blocksExistingDelivery: false,
|
|
97
|
+
resolutionKinds,
|
|
98
|
+
recoveryPreviews: recoveryPreviews(root, resolutionKinds, normalized, options.actualDigest),
|
|
99
|
+
humanRoles: uniqueSorted(options.humanRoles ?? ["truth-maintainer"]),
|
|
100
|
+
validateAfterResolution: uniqueSorted(options.validateAfterResolution ?? ["reconcile-truth", "status", "check"]),
|
|
101
|
+
recomputeFromCurrentBaseline: true,
|
|
102
|
+
};
|
|
103
|
+
const previews = recoveryPreviews(root, resolutionKinds, normalized, options.actualDigest);
|
|
104
|
+
const resolutionState = resolutionKinds.includes("external-implementation-fix-required")
|
|
105
|
+
? "external-fix-required"
|
|
106
|
+
: resolutionKinds.some((entry) => ["accept-branch-change", "compose-compatible-changes", "replace-or-deprecate-source", "split-scope-or-override"].includes(entry))
|
|
107
|
+
? "contract-action-required"
|
|
108
|
+
: resolutionKinds.some((entry) => ["fix-source-and-rerun", "refresh-promotion-evidence"].includes(entry))
|
|
109
|
+
? "revalidation-required"
|
|
110
|
+
: "needs-human-decision";
|
|
111
|
+
const resolutionContract = {
|
|
112
|
+
resolutionState,
|
|
113
|
+
resolutionKinds,
|
|
114
|
+
requiredHumanInputs: uniqueSorted(previews.flatMap((entry) => entry.requiredHumanInputs ?? [])),
|
|
115
|
+
nextActions: previews,
|
|
116
|
+
requiredPostResolutionEvidence: uniqueSorted(["current-project-snapshots", "current-source-and-item-baselines", "project-context-clean", ...(resolutionKinds.includes("external-implementation-fix-required") ? ["external-code-and-test-evidence"] : [])]),
|
|
117
|
+
completionCriteria: [
|
|
118
|
+
"previous-finding-absent-on-current-baseline",
|
|
119
|
+
"all-related-items-approved-and-effective",
|
|
120
|
+
"all-related-validation-current-and-passing",
|
|
121
|
+
"project-context-impact-closure-clean",
|
|
122
|
+
"no-replacement-blocked-finding-with-same-root-cause",
|
|
123
|
+
],
|
|
124
|
+
recomputeFromCurrentBaseline: true,
|
|
125
|
+
};
|
|
126
|
+
const findingDigest = digestJson({ code: base.code, severity: base.severity, affectedSourceIds: base.affectedSourceIds, affectedItemIds: base.affectedItemIds, affectedSubjects: base.affectedSubjects, affectedScopes: base.affectedScopes, baseline: base.baseline, blocksTask: base.blocksTask });
|
|
127
|
+
return { ...base, findingDigest, resolutionContract };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function classifyChanges(changes) {
|
|
131
|
+
const digests = uniqueSorted(changes.map((entry) => entry.change.candidateDigest));
|
|
132
|
+
if (digests.length === 1) return "duplicate";
|
|
133
|
+
const classes = uniqueSorted(changes.map((entry) => entry.change.classification).filter((entry) => entry !== "duplicate"));
|
|
134
|
+
return classes.length === 1 ? classes[0] : "semantic-conflict";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function groupChanges(candidates, kind, keyOf) {
|
|
138
|
+
const groups = new Map();
|
|
139
|
+
for (const candidate of candidates) {
|
|
140
|
+
const changes = kind === "source" ? candidate.sourceChanges : candidate.itemChanges;
|
|
141
|
+
for (const change of changes) {
|
|
142
|
+
const key = keyOf(change);
|
|
143
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
144
|
+
groups.get(key).push({ candidateId: candidate.id, taskId: candidate.taskId, change });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return [...groups.entries()]
|
|
148
|
+
.filter(([, changes]) => new Set(changes.map((entry) => entry.candidateId)).size > 1)
|
|
149
|
+
.map(([key, changes]) => ({ kind, key, classification: classifyChanges(changes), changes }))
|
|
150
|
+
.sort((left, right) => `${left.kind}:${left.key}`.localeCompare(`${right.kind}:${right.key}`));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function groupSubjectChanges(candidates) {
|
|
154
|
+
const groups = new Map();
|
|
155
|
+
for (const candidate of candidates) {
|
|
156
|
+
for (const change of candidate.sourceChanges) {
|
|
157
|
+
for (const subject of change.subjects) {
|
|
158
|
+
if (!groups.has(subject)) groups.set(subject, []);
|
|
159
|
+
groups.get(subject).push({ candidateId: candidate.id, taskId: candidate.taskId, change });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const change of candidate.itemChanges) {
|
|
163
|
+
if (!groups.has(change.subject)) groups.set(change.subject, []);
|
|
164
|
+
groups.get(change.subject).push({ candidateId: candidate.id, taskId: candidate.taskId, change });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return [...groups.entries()]
|
|
168
|
+
.filter(([, changes]) => new Set(changes.map((entry) => entry.candidateId)).size > 1)
|
|
169
|
+
.map(([key, changes]) => ({ kind: "subject", key, classification: classifyChanges(changes), changes }))
|
|
170
|
+
.sort((left, right) => left.key.localeCompare(right.key));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function groupImpacts(group) {
|
|
174
|
+
const sourceIds = [];
|
|
175
|
+
const itemIds = [];
|
|
176
|
+
const subjects = [];
|
|
177
|
+
const scopes = [];
|
|
178
|
+
for (const { change } of group.changes) {
|
|
179
|
+
if (change.sourceId) sourceIds.push(change.sourceId);
|
|
180
|
+
if (change.itemId) itemIds.push(change.itemId);
|
|
181
|
+
sourceIds.push(...(change.sourceIds ?? []));
|
|
182
|
+
itemIds.push(...(change.affectedItemIds ?? []));
|
|
183
|
+
subjects.push(...(change.subjects ?? []), ...(change.subject ? [change.subject] : []));
|
|
184
|
+
scopes.push(...(change.scopes ?? []), ...(change.scope ? [change.scope] : []));
|
|
185
|
+
}
|
|
186
|
+
return { sourceIds, itemIds, subjects, scopes };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function snapshotsEqual(left, right) {
|
|
190
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function successfulEvidence(evidence) {
|
|
194
|
+
return evidence.verificationResults.length > 0 && evidence.verificationResults.every((result) => ["passed", "observed"].includes(result.status));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function promotionState(candidate) {
|
|
198
|
+
const applicable = candidate.promotionEvidence.filter((entry) => entry.taskId === candidate.taskId && successfulEvidence(entry));
|
|
199
|
+
const current = applicable.filter((entry) => (
|
|
200
|
+
entry.codeSnapshotDigest === candidate.currentCodeSnapshotDigest ||
|
|
201
|
+
entry.equivalentToCodeSnapshotDigest === candidate.currentCodeSnapshotDigest
|
|
202
|
+
) && entry.sourceSnapshotDigest === candidate.currentSourceSnapshotDigest &&
|
|
203
|
+
entry.artifactDigest === candidate.currentArtifactDigest &&
|
|
204
|
+
entry.acceptanceSuiteDigest === candidate.currentAcceptanceSuiteDigest &&
|
|
205
|
+
snapshotsEqual(entry.projectSnapshots, candidate.expectedProjectSnapshots));
|
|
206
|
+
const stale = applicable.filter((entry) => !current.includes(entry));
|
|
207
|
+
const currentQa = current.filter((entry) => entry.stage === "qa");
|
|
208
|
+
const integration = current.filter((entry) => entry.integration && ["preproduction", "production"].includes(entry.stage));
|
|
209
|
+
return { applicable, current, stale, currentQa, integration };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function promotionBinding(value) {
|
|
213
|
+
return digestJson({
|
|
214
|
+
codeSnapshotDigest: value.codeSnapshotDigest ?? value.currentCodeSnapshotDigest,
|
|
215
|
+
sourceSnapshotDigest: value.sourceSnapshotDigest ?? value.currentSourceSnapshotDigest,
|
|
216
|
+
artifactDigest: value.artifactDigest ?? value.currentArtifactDigest,
|
|
217
|
+
acceptanceSuiteDigest: value.acceptanceSuiteDigest ?? value.currentAcceptanceSuiteDigest,
|
|
218
|
+
projectSnapshots: value.projectSnapshots ?? value.expectedProjectSnapshots,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function candidateImpacts(candidate) {
|
|
223
|
+
return {
|
|
224
|
+
sourceIds: candidate.sourceChanges.map((entry) => entry.sourceId),
|
|
225
|
+
itemIds: [
|
|
226
|
+
...candidate.sourceChanges.flatMap((entry) => entry.affectedItemIds),
|
|
227
|
+
...candidate.itemChanges.map((entry) => entry.itemId),
|
|
228
|
+
],
|
|
229
|
+
subjects: [
|
|
230
|
+
...candidate.sourceChanges.flatMap((entry) => entry.subjects),
|
|
231
|
+
...candidate.itemChanges.map((entry) => entry.subject),
|
|
232
|
+
],
|
|
233
|
+
scopes: [
|
|
234
|
+
...candidate.sourceChanges.flatMap((entry) => entry.scopes),
|
|
235
|
+
...candidate.itemChanges.map((entry) => entry.scope),
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function mergeInputs(inputs) {
|
|
241
|
+
const first = inputs[0];
|
|
242
|
+
for (const input of inputs.slice(1)) {
|
|
243
|
+
if (input.projectId !== first.projectId || canonicalJson(input.current) !== canonicalJson(first.current) || canonicalJson(input.task) !== canonicalJson(first.task)) {
|
|
244
|
+
fail("truth-reconciliation-input-invalid", "all reconciliation inputs must share one project, current baseline, and task");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const candidates = inputs.flatMap((input) => input.candidates);
|
|
248
|
+
const candidateIds = candidates.map((entry) => entry.id);
|
|
249
|
+
if (new Set(candidateIds).size !== candidateIds.length) fail("truth-reconciliation-input-invalid", "candidate ids must be unique across input files");
|
|
250
|
+
return {
|
|
251
|
+
...first,
|
|
252
|
+
candidates: candidates.sort((left, right) => left.id.localeCompare(right.id)),
|
|
253
|
+
globalFindings: [...new Map(inputs.flatMap((input) => input.globalFindings).map((entry) => [canonicalJson(entry), entry])).values()]
|
|
254
|
+
.sort((left, right) => canonicalJson(left).localeCompare(canonicalJson(right))),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function findingRootKey(value) {
|
|
259
|
+
return canonicalJson({ code: value.code, affectedSourceIds: value.affectedSourceIds, affectedItemIds: value.affectedItemIds, affectedSubjects: value.affectedSubjects, affectedScopes: value.affectedScopes });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateResolutionAttempt(input, previousReview) {
|
|
263
|
+
const attempt = input.resolutionAttempt;
|
|
264
|
+
if (!attempt && previousReview) fail("truth-resolution-previous-unexpected", "initial reconciliation cannot use --previous-review");
|
|
265
|
+
if (attempt && !previousReview) fail("truth-resolution-previous-required", "resolution attempt requires --previous-review", { exitCode: 1 });
|
|
266
|
+
if (!attempt) return;
|
|
267
|
+
if (attempt.previousReviewDigest !== previousReview.bundleDigest) fail("truth-resolution-previous-digest-mismatch", "resolution attempt does not bind the supplied previous review", { exitCode: 1 });
|
|
268
|
+
if (previousReview.project.id !== input.projectId || previousReview.task.id !== input.task.id || canonicalJson(previousReview.task.paths) !== canonicalJson(input.task.paths)) {
|
|
269
|
+
fail("truth-resolution-previous-scope-mismatch", "previous review belongs to a different project, task, or scope", { exitCode: 1 });
|
|
270
|
+
}
|
|
271
|
+
const previousFindings = new Map(previousReview.findings.map((entry) => [entry.findingDigest, entry]));
|
|
272
|
+
for (const decision of attempt.decisions) {
|
|
273
|
+
const finding = previousFindings.get(decision.findingDigest);
|
|
274
|
+
if (!finding) fail("truth-resolution-finding-unavailable", "resolution decision references a finding not exposed by the previous review", { exitCode: 1 });
|
|
275
|
+
if (!finding.resolutionKinds.includes(decision.resolutionKind)) fail("truth-resolution-kind-unavailable", "resolution kind was not exposed by the previous finding", { exitCode: 1 });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function resolutionOutcomes(input, previousReview, currentFindings) {
|
|
280
|
+
if (!input.resolutionAttempt || !previousReview) return [];
|
|
281
|
+
const currentRoots = new Map(currentFindings.map((entry) => [findingRootKey(entry), entry]));
|
|
282
|
+
const previousByDigest = new Map(previousReview.findings.map((entry) => [entry.findingDigest, entry]));
|
|
283
|
+
return input.resolutionAttempt.decisions.map((decision) => {
|
|
284
|
+
const previous = previousByDigest.get(decision.findingDigest);
|
|
285
|
+
const stillOpen = currentRoots.get(findingRootKey(previous));
|
|
286
|
+
const replacement = currentFindings.find((entry) => entry.severity === "blocked" && entry.affectedSubjects.some((subject) => previous.affectedSubjects.includes(subject)));
|
|
287
|
+
let state = "revalidation-required";
|
|
288
|
+
let outcome = "open";
|
|
289
|
+
if (decision.resolutionKind === "defer-scoped-conflict") { state = "deferred-open"; outcome = "deferred"; }
|
|
290
|
+
else if (decision.resolutionKind === "external-implementation-fix-required" && !decision.externalAction?.completed) { state = "external-fix-required"; outcome = "open"; }
|
|
291
|
+
else if (!stillOpen && !replacement && input.resolutionAttempt.projectContextStatus === "clean") {
|
|
292
|
+
const discarded = input.candidates.length === 0 || input.candidates.every((candidate) => ["withdrawn", "rolled-back"].includes(candidate.lifecycle));
|
|
293
|
+
if (decision.resolutionKind !== "keep-current-baseline" || discarded) { state = "resolved"; outcome = decision.resolutionKind === "keep-current-baseline" ? "rejected" : "resolved"; }
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
findingDigest: decision.findingDigest,
|
|
297
|
+
resolutionKind: decision.resolutionKind,
|
|
298
|
+
resolutionState: state,
|
|
299
|
+
outcome,
|
|
300
|
+
completionCriteriaMet: state === "resolved" ? [...previous.resolutionContract.completionCriteria] : [],
|
|
301
|
+
recomputedFromCurrentBaseline: true,
|
|
302
|
+
};
|
|
303
|
+
}).sort((left, right) => left.findingDigest.localeCompare(right.findingDigest));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function buildTruthReconciliationReview(root, project, inputValues, options = {}) {
|
|
307
|
+
if (!Array.isArray(inputValues) || inputValues.length === 0) fail("truth-reconciliation-input-invalid", "at least one input is required");
|
|
308
|
+
const input = mergeInputs(inputValues.map(normalizeTruthReconciliationInput));
|
|
309
|
+
if (input.projectId !== project.contract.project.id) fail("truth-reconciliation-input-invalid", "input projectId does not match the initialized project");
|
|
310
|
+
const previousReview = options.previousReview ? validateTruthReviewBundle(options.previousReview) : null;
|
|
311
|
+
validateResolutionAttempt(input, previousReview);
|
|
312
|
+
|
|
313
|
+
const findings = [];
|
|
314
|
+
const currentSources = new Map(input.current.sources.map((entry) => [entry.id, entry.digest]));
|
|
315
|
+
const currentItems = new Map(input.current.items.map((entry) => [entry.id, entry]));
|
|
316
|
+
const liveSources = new Map(project.sourcesLock.sources.map((entry) => [entry.id, entry.digest]));
|
|
317
|
+
const liveItems = new Map(project.contract.items.map((entry) => [entry.id, entry]));
|
|
318
|
+
const actualSnapshots = {
|
|
319
|
+
contract: project.contractDigest,
|
|
320
|
+
sourcesLock: project.sourcesLockDigest,
|
|
321
|
+
projectionsLock: project.projectionsLockDigest,
|
|
322
|
+
};
|
|
323
|
+
let currentBaselineValid = snapshotsEqual(input.current.projectSnapshots, actualSnapshots);
|
|
324
|
+
if (!currentBaselineValid) {
|
|
325
|
+
findings.push(finding(root, "truth-current-baseline-invalid", "blocked", {}, {
|
|
326
|
+
expectedDigest: digestJson(input.current.projectSnapshots), actualDigest: digestJson(actualSnapshots), blocksTask: true,
|
|
327
|
+
resolutionKinds: ["fix-source-and-rerun"], humanRoles: ["truth-maintainer"],
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
for (const source of input.current.sources) {
|
|
331
|
+
const actual = liveSources.get(source.id) ?? null;
|
|
332
|
+
if (actual === source.digest) continue;
|
|
333
|
+
currentBaselineValid = false;
|
|
334
|
+
findings.push(finding(root, "truth-current-baseline-invalid", "blocked", { sourceIds: [source.id] }, {
|
|
335
|
+
expectedDigest: source.digest, actualDigest: actual, blocksTask: true, resolutionKinds: ["fix-source-and-rerun"],
|
|
336
|
+
humanRoles: ["truth-maintainer"],
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
for (const [sourceId, actual] of liveSources) {
|
|
340
|
+
if (currentSources.has(sourceId)) continue;
|
|
341
|
+
currentBaselineValid = false;
|
|
342
|
+
findings.push(finding(root, "truth-current-baseline-invalid", "blocked", { sourceIds: [sourceId] }, {
|
|
343
|
+
actualDigest: actual, blocksTask: true, resolutionKinds: ["fix-source-and-rerun"], humanRoles: ["truth-maintainer"],
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
for (const item of input.current.items) {
|
|
347
|
+
const live = liveItems.get(item.id);
|
|
348
|
+
const actual = live ? digestJson(live) : null;
|
|
349
|
+
const metadataMatches = live && live.subject === item.subject && live.kind === item.kind && snapshotsEqual(live.scope, item.scope);
|
|
350
|
+
if (actual === item.digest && metadataMatches) continue;
|
|
351
|
+
currentBaselineValid = false;
|
|
352
|
+
findings.push(finding(root, "truth-current-baseline-invalid", "blocked", {
|
|
353
|
+
sourceIds: live?.sources ?? [], itemIds: [item.id], subjects: [item.subject], scopes: [item.scope],
|
|
354
|
+
}, {
|
|
355
|
+
expectedDigest: item.digest, actualDigest: actual, blocksTask: true, resolutionKinds: ["fix-source-and-rerun"],
|
|
356
|
+
humanRoles: ["truth-maintainer"],
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
for (const [itemId, live] of liveItems) {
|
|
360
|
+
if (currentItems.has(itemId)) continue;
|
|
361
|
+
currentBaselineValid = false;
|
|
362
|
+
findings.push(finding(root, "truth-current-baseline-invalid", "blocked", {
|
|
363
|
+
sourceIds: live.sources, itemIds: [itemId], subjects: [live.subject], scopes: [live.scope],
|
|
364
|
+
}, {
|
|
365
|
+
actualDigest: digestJson(live), blocksTask: true, resolutionKinds: ["fix-source-and-rerun"], humanRoles: ["truth-maintainer"],
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const candidateOutcomes = [];
|
|
370
|
+
const eligible = [];
|
|
371
|
+
for (const candidate of input.candidates) {
|
|
372
|
+
const candidateFindings = [];
|
|
373
|
+
const impacts = candidateImpacts(candidate);
|
|
374
|
+
let stale = !currentBaselineValid || !snapshotsEqual(candidate.expectedProjectSnapshots, input.current.projectSnapshots);
|
|
375
|
+
if (!snapshotsEqual(candidate.expectedProjectSnapshots, input.current.projectSnapshots)) {
|
|
376
|
+
candidateFindings.push(finding(root, "truth-candidate-baseline-stale", "blocked", impacts, {
|
|
377
|
+
expectedDigest: digestJson(candidate.expectedProjectSnapshots), actualDigest: digestJson(input.current.projectSnapshots),
|
|
378
|
+
expectedLabel: candidate.baseRevision, actualLabel: input.current.revision, blocksTask: true,
|
|
379
|
+
resolutionKinds: ["fix-source-and-rerun"],
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
for (const change of candidate.sourceChanges) {
|
|
383
|
+
const actual = currentSources.get(change.sourceId) ?? null;
|
|
384
|
+
if (actual !== change.expectedDigest) {
|
|
385
|
+
stale = true;
|
|
386
|
+
candidateFindings.push(finding(root, "truth-candidate-baseline-stale", "blocked", {
|
|
387
|
+
sourceIds: [change.sourceId], itemIds: change.affectedItemIds, subjects: change.subjects, scopes: change.scopes,
|
|
388
|
+
}, {
|
|
389
|
+
expectedDigest: change.expectedDigest, actualDigest: actual, expectedLabel: candidate.baseRevision,
|
|
390
|
+
actualLabel: input.current.revision, blocksTask: true, resolutionKinds: ["fix-source-and-rerun"],
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const change of candidate.itemChanges) {
|
|
395
|
+
const actual = currentItems.get(change.itemId)?.digest ?? null;
|
|
396
|
+
if (actual !== change.expectedDigest) {
|
|
397
|
+
stale = true;
|
|
398
|
+
candidateFindings.push(finding(root, "truth-candidate-baseline-stale", "blocked", {
|
|
399
|
+
sourceIds: change.sourceIds, itemIds: [change.itemId], subjects: [change.subject], scopes: [change.scope],
|
|
400
|
+
}, {
|
|
401
|
+
expectedDigest: change.expectedDigest, actualDigest: actual, expectedLabel: candidate.baseRevision,
|
|
402
|
+
actualLabel: input.current.revision, blocksTask: true, resolutionKinds: ["fix-source-and-rerun"],
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const promotion = promotionState(candidate);
|
|
408
|
+
if (candidate.targetStage !== "development" && promotion.applicable.length === 0) {
|
|
409
|
+
candidateFindings.push(finding(root, "promotion-code-snapshot-unbound", "blocked", impacts, {
|
|
410
|
+
actualDigest: candidate.currentCodeSnapshotDigest, blocksTask: true, resolutionKinds: ["refresh-promotion-evidence"],
|
|
411
|
+
humanRoles: ["qa-owner", "host-operator"], validateAfterResolution: ["reconcile-truth", "host-verification"],
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
if (promotion.stale.length > 0) {
|
|
415
|
+
candidateFindings.push(finding(root, "promotion-evidence-stale", "blocked", impacts, {
|
|
416
|
+
expectedDigest: promotionBinding(promotion.stale[0]), actualDigest: promotionBinding(candidate),
|
|
417
|
+
blocksTask: candidate.targetStage !== "development", resolutionKinds: ["refresh-promotion-evidence"], humanRoles: ["qa-owner", "host-operator"],
|
|
418
|
+
validateAfterResolution: ["reconcile-truth", "host-verification"],
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
if (["preproduction", "production"].includes(candidate.targetStage) && promotion.integration.length === 0) {
|
|
422
|
+
candidateFindings.push(finding(root, "promotion-integration-verification-required", "blocked", impacts, {
|
|
423
|
+
actualDigest: candidate.currentArtifactDigest, blocksTask: true, resolutionKinds: ["refresh-promotion-evidence"],
|
|
424
|
+
humanRoles: ["qa-owner", "release-owner", "host-operator"], validateAfterResolution: ["reconcile-truth", "integration-verification"],
|
|
425
|
+
}));
|
|
426
|
+
}
|
|
427
|
+
const discarded = ["withdrawn", "rolled-back"].includes(candidate.lifecycle);
|
|
428
|
+
if (discarded && candidate.decisionCandidateIds.length > 0) {
|
|
429
|
+
candidateFindings.push(finding(root, "truth-owner-review-required", "attention", {
|
|
430
|
+
itemIds: candidate.decisionCandidateIds,
|
|
431
|
+
}, {
|
|
432
|
+
blocksTask: false, resolutionKinds: ["keep-current-baseline"], humanRoles: ["truth-maintainer", "business-owner"],
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
if (!stale && !discarded) eligible.push(candidate);
|
|
436
|
+
findings.push(...candidateFindings);
|
|
437
|
+
candidateOutcomes.push({
|
|
438
|
+
candidateId: candidate.id,
|
|
439
|
+
lifecycle: candidate.lifecycle,
|
|
440
|
+
baseline: stale ? "stale" : "current",
|
|
441
|
+
promotionEvidence: {
|
|
442
|
+
assurance: "host-asserted",
|
|
443
|
+
currentEvidenceCount: promotion.current.length,
|
|
444
|
+
staleEvidenceCount: promotion.stale.length,
|
|
445
|
+
localQaReusable: promotion.currentQa.length > 0,
|
|
446
|
+
integrationVerified: promotion.integration.length > 0,
|
|
447
|
+
},
|
|
448
|
+
decisionCandidates: discarded ? "discarded-not-promoted" : "authority-free-candidates",
|
|
449
|
+
eligibleForReconciliation: !stale && !discarded,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const collisionGroups = [
|
|
454
|
+
...groupChanges(eligible, "source", (change) => change.sourceId),
|
|
455
|
+
...groupChanges(eligible, "item", (change) => change.itemId),
|
|
456
|
+
...groupSubjectChanges(eligible),
|
|
457
|
+
].map((group) => {
|
|
458
|
+
const impacts = groupImpacts(group);
|
|
459
|
+
const code = group.kind === "source" ? "truth-source-collision" : group.kind === "item" ? "truth-item-collision" : "truth-subject-collision";
|
|
460
|
+
findings.push(finding(root, code, group.classification === "duplicate" ? "attention" : "conflict", impacts, {
|
|
461
|
+
blocksTask: isTaskRelated(impacts.scopes, input.task.paths), resolutionKinds: RESOLUTIONS[group.classification],
|
|
462
|
+
humanRoles: group.classification === "semantic-conflict" ? ["business-owner", "truth-maintainer"] : ["truth-maintainer"],
|
|
463
|
+
}));
|
|
464
|
+
return {
|
|
465
|
+
kind: group.kind,
|
|
466
|
+
key: group.key,
|
|
467
|
+
classification: group.classification,
|
|
468
|
+
candidateIds: uniqueSorted(group.changes.map((entry) => entry.candidateId)),
|
|
469
|
+
candidateDigests: uniqueSorted(group.changes.map((entry) => entry.change.candidateDigest)),
|
|
470
|
+
affectedSourceIds: uniqueSorted(impacts.sourceIds),
|
|
471
|
+
affectedItemIds: uniqueSorted(impacts.itemIds),
|
|
472
|
+
affectedSubjects: uniqueSorted(impacts.subjects),
|
|
473
|
+
affectedScopes: [...new Map(impacts.scopes.map((scope) => [scopeKey(scope), scope])).values()].sort((a, b) => scopeKey(a).localeCompare(scopeKey(b))),
|
|
474
|
+
resolutionKinds: [...RESOLUTIONS[group.classification]],
|
|
475
|
+
promoted: false,
|
|
476
|
+
};
|
|
477
|
+
}).sort((left, right) => `${left.kind}:${left.key}`.localeCompare(`${right.kind}:${right.key}`));
|
|
478
|
+
|
|
479
|
+
for (const signal of input.globalFindings) {
|
|
480
|
+
const related = isTaskRelated(signal.scopes, input.task.paths);
|
|
481
|
+
const mandatory = signal.itemKinds.some((kind) => ["policy", "validation-description"].includes(kind));
|
|
482
|
+
findings.push(finding(root, signal.code, signal.severity, {
|
|
483
|
+
sourceIds: signal.sourceIds, itemIds: signal.itemIds, subjects: signal.subjects, scopes: signal.scopes,
|
|
484
|
+
}, {
|
|
485
|
+
blocksTask: related && (mandatory || ["conflict", "blocked"].includes(signal.severity)),
|
|
486
|
+
resolutionKinds: related && mandatory ? ["fix-source-and-rerun", "defer-scoped-conflict"] : ["defer-scoped-conflict"],
|
|
487
|
+
humanRoles: mandatory ? ["subject-owner", "truth-maintainer"] : ["truth-maintainer"],
|
|
488
|
+
}));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const stableFindings = findings.sort((left, right) => {
|
|
492
|
+
const leftKey = `${left.code}:${left.affectedSourceIds.join(",")}:${left.affectedItemIds.join(",")}:${left.affectedSubjects.join(",")}`;
|
|
493
|
+
const rightKey = `${right.code}:${right.affectedSourceIds.join(",")}:${right.affectedItemIds.join(",")}:${right.affectedSubjects.join(",")}`;
|
|
494
|
+
return leftKey.localeCompare(rightKey);
|
|
495
|
+
});
|
|
496
|
+
const taskBlocked = stableFindings.some((entry) => entry.blocksTask);
|
|
497
|
+
const outcomes = resolutionOutcomes(input, previousReview, stableFindings);
|
|
498
|
+
return finalizeTruthReviewBundle({
|
|
499
|
+
schemaVersion: TRUTH_RECONCILIATION_REVIEW_BUNDLE_SCHEMA_VERSION,
|
|
500
|
+
kind: "truth-reconciliation-review-bundle",
|
|
501
|
+
project: { id: project.contract.project.id, name: project.contract.project.name },
|
|
502
|
+
current: { revision: input.current.revision, projectSnapshots: input.current.projectSnapshots },
|
|
503
|
+
task: input.task,
|
|
504
|
+
inputDigest: digestJson(input),
|
|
505
|
+
summary: {
|
|
506
|
+
candidates: input.candidates.length,
|
|
507
|
+
eligibleCandidates: eligible.length,
|
|
508
|
+
collisionGroups: collisionGroups.length,
|
|
509
|
+
findings: stableFindings.length,
|
|
510
|
+
taskHealth: taskBlocked ? "blocked" : "ready",
|
|
511
|
+
globalHealth: stableFindings.length > 0 ? "attention" : "clean",
|
|
512
|
+
existingDelivery: "not-blocked",
|
|
513
|
+
},
|
|
514
|
+
candidateOutcomes: candidateOutcomes.sort((left, right) => left.candidateId.localeCompare(right.candidateId)),
|
|
515
|
+
collisionGroups,
|
|
516
|
+
findings: stableFindings,
|
|
517
|
+
resolutionOutcomes: outcomes,
|
|
518
|
+
guarantees: {
|
|
519
|
+
projectContextVerified: ["schema", "canonical-digests", "project-snapshots", "scope-impact", "recovery-contract"],
|
|
520
|
+
hostAsserted: ["revision", "code-snapshot", "source-snapshot", "artifact", "verification-results", "content-equivalence"],
|
|
521
|
+
humanDecided: ["release", "long-term-semantics", "approval", "rollback"],
|
|
522
|
+
},
|
|
523
|
+
boundaries: {
|
|
524
|
+
readOnly: true, git: false, ciClient: false, network: false, provider: false, taskExecution: false,
|
|
525
|
+
automaticApproval: false, automaticPromotion: false, persistentQueue: false, blocksExistingDelivery: false,
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export async function buildTruthReconciliationReviewFiles(root, inputPaths, options = {}) {
|
|
531
|
+
const project = await loadProject(root);
|
|
532
|
+
const values = [];
|
|
533
|
+
for (const inputPath of inputPaths) {
|
|
534
|
+
const resolved = await resolveExistingInside(root, inputPath);
|
|
535
|
+
values.push(await readJsonFile(resolved.absolute, "truth reconciliation input"));
|
|
536
|
+
}
|
|
537
|
+
let previousReview = null;
|
|
538
|
+
if (options.previousReviewPath) {
|
|
539
|
+
const resolved = await resolveExistingInside(root, options.previousReviewPath);
|
|
540
|
+
previousReview = validateTruthReviewBundle(await readJsonFile(resolved.absolute, "previous truth reconciliation review"));
|
|
541
|
+
}
|
|
542
|
+
return buildTruthReconciliationReview(root, project, values, { previousReview });
|
|
543
|
+
}
|