frontend-project-context 1.0.1 → 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.
- package/CHANGELOG.md +18 -0
- package/README.md +104 -8
- package/UPGRADING.md +28 -0
- package/docs/00-PRODUCT-CONSTITUTION.md +42 -10
- package/docs/04-PROGRAM-DESIGN.md +76 -2
- package/docs/05-ACCEPTANCE-CONTRACT.md +35 -3
- package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +27 -15
- package/docs/16-GUIDED-ONBOARDING-AND-AI-RECONCILIATION-DESIGN.md +469 -0
- package/docs/17-AI-EXCHANGE-BOUNDARY-DESIGN.md +268 -0
- package/docs/README.md +12 -4
- package/examples/README.md +15 -2
- package/examples/package.json +6 -2
- package/package.json +4 -3
- package/schemas/action-plan.schema.json +250 -0
- package/schemas/assist-bundle.schema.json +75 -0
- package/schemas/capabilities.schema.json +123 -0
- package/schemas/review-bundle.schema.json +109 -0
- package/src/project-context/assist.mjs +422 -0
- package/src/project-context/cli.mjs +143 -13
- package/src/project-context/exchange-schema.mjs +528 -0
- package/src/project-context/exchange.mjs +623 -0
- package/src/project-context/project-store.mjs +22 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { approvePendingItems, approveProposal } from "./approver.mjs";
|
|
3
|
+
import { registerSource, buildItemProposal } from "./authoring.mjs";
|
|
4
|
+
import { blockingContextFindings, checkProject } from "./checker.mjs";
|
|
5
|
+
import { canonicalJson, digestJson, prettyCanonicalJson, sha256 } from "./canonical-json.mjs";
|
|
6
|
+
import { DASHBOARD_SCHEMA_VERSION } from "./dashboard-model.mjs";
|
|
7
|
+
import { ProjectContextError, fail } from "./errors.mjs";
|
|
8
|
+
import {
|
|
9
|
+
ACTION_KINDS,
|
|
10
|
+
ACTION_PLAN_SCHEMA_VERSION,
|
|
11
|
+
assertActionPlanConflictFree,
|
|
12
|
+
CAPABILITIES_SCHEMA_VERSION,
|
|
13
|
+
COMMANDS,
|
|
14
|
+
EXCHANGE_PROTOCOL_VERSION,
|
|
15
|
+
itemPrimitiveInput,
|
|
16
|
+
PACKAGE_VERSION,
|
|
17
|
+
REVIEW_BUNDLE_SCHEMA_VERSION,
|
|
18
|
+
validateActionPlan,
|
|
19
|
+
validateReviewBundle,
|
|
20
|
+
} from "./exchange-schema.mjs";
|
|
21
|
+
import { readJsonFile } from "./io.mjs";
|
|
22
|
+
import { acceptSourceChange, deprecateItem, deprecateSource, reviewSource, reviseItem } from "./maintenance.mjs";
|
|
23
|
+
import { resolveExistingInside, resolveWritableInside } from "./path-policy.mjs";
|
|
24
|
+
import { inspectProjectInitialization, loadProject } from "./project-store.mjs";
|
|
25
|
+
import { publishProjection } from "./projection-store.mjs";
|
|
26
|
+
import { RENDERER_VERSION } from "./renderer.mjs";
|
|
27
|
+
import { ASSIST_BUNDLE_SCHEMA_VERSION } from "./assist.mjs";
|
|
28
|
+
|
|
29
|
+
const INTERNAL_AUTHORITY_SENTINEL = "exchange-preflight-not-authority";
|
|
30
|
+
const INTERNAL_TIMESTAMP = "1970-01-01T00:00:00.000Z";
|
|
31
|
+
const CONTRACT_SCHEMA_VERSION = 2;
|
|
32
|
+
const PROPOSAL_SCHEMA_VERSION = 1;
|
|
33
|
+
const SOURCE_LOCK_SCHEMA_VERSION = 1;
|
|
34
|
+
const PROJECTION_LOCK_SCHEMA_VERSION = 1;
|
|
35
|
+
const GROUP_BY_ACTION = new Map([
|
|
36
|
+
["accept-source-change", "source-change"],
|
|
37
|
+
["register-source", "source-change"],
|
|
38
|
+
["propose-item", "new-or-revised-knowledge"],
|
|
39
|
+
["revise-item", "new-or-revised-knowledge"],
|
|
40
|
+
["deprecate-item", "deprecation"],
|
|
41
|
+
["deprecate-source", "deprecation"],
|
|
42
|
+
["request-item-approval", "approval-request"],
|
|
43
|
+
["publish-projection", "projection"],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function uniqueSorted(values) {
|
|
47
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function schemas() {
|
|
51
|
+
return {
|
|
52
|
+
actionPlan: ACTION_PLAN_SCHEMA_VERSION,
|
|
53
|
+
assistBundle: ASSIST_BUNDLE_SCHEMA_VERSION,
|
|
54
|
+
capabilities: CAPABILITIES_SCHEMA_VERSION,
|
|
55
|
+
contract: CONTRACT_SCHEMA_VERSION,
|
|
56
|
+
dashboardViewModel: DASHBOARD_SCHEMA_VERSION,
|
|
57
|
+
projectionLock: PROJECTION_LOCK_SCHEMA_VERSION,
|
|
58
|
+
projectionRenderer: RENDERER_VERSION,
|
|
59
|
+
proposal: PROPOSAL_SCHEMA_VERSION,
|
|
60
|
+
reviewBundle: REVIEW_BUNDLE_SCHEMA_VERSION,
|
|
61
|
+
sourceLock: SOURCE_LOCK_SCHEMA_VERSION,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function buildCapabilities(root) {
|
|
66
|
+
const initialization = await inspectProjectInitialization(root);
|
|
67
|
+
let project = null;
|
|
68
|
+
if (initialization.status === "initialized") {
|
|
69
|
+
const loaded = await loadProject(root);
|
|
70
|
+
project = { id: loaded.contract.project.id, name: loaded.contract.project.name };
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
schemaVersion: CAPABILITIES_SCHEMA_VERSION,
|
|
74
|
+
package: { name: "frontend-project-context", version: PACKAGE_VERSION },
|
|
75
|
+
exchangeProtocolVersion: EXCHANGE_PROTOCOL_VERSION,
|
|
76
|
+
schemas: schemas(),
|
|
77
|
+
commands: [...COMMANDS],
|
|
78
|
+
actionKinds: [...ACTION_KINDS],
|
|
79
|
+
initialization: initialization.status,
|
|
80
|
+
initialized: initialization.status === "initialized",
|
|
81
|
+
project,
|
|
82
|
+
boundaries: {
|
|
83
|
+
provider: false,
|
|
84
|
+
agentRuntime: false,
|
|
85
|
+
git: false,
|
|
86
|
+
network: false,
|
|
87
|
+
dependencyInstallation: false,
|
|
88
|
+
automaticApproval: false,
|
|
89
|
+
businessCodeWrites: false,
|
|
90
|
+
applyPlan: false,
|
|
91
|
+
scheduler: false,
|
|
92
|
+
daemon: false,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function allProjectionPaths(project) {
|
|
98
|
+
return project.projectionsLock.projections.map((entry) => entry.path).sort();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function directProjectionPaths(project, itemIds) {
|
|
102
|
+
const ids = new Set(itemIds);
|
|
103
|
+
return project.projectionsLock.projections
|
|
104
|
+
.filter((entry) => entry.itemIds.some((id) => ids.has(id)))
|
|
105
|
+
.map((entry) => entry.path)
|
|
106
|
+
.sort();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function emptyImpact() {
|
|
110
|
+
return { itemIds: [], sourceIds: [], paths: [], projectionPaths: [] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function invocation(root, command, args) {
|
|
114
|
+
return { command, args: ["--project", root, ...args] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function itemArgs(input, options = {}) {
|
|
118
|
+
const args = [
|
|
119
|
+
"--id", input.id,
|
|
120
|
+
...(options.expectedItemDigest ? ["--expected-item-digest", options.expectedItemDigest] : []),
|
|
121
|
+
"--kind", input.kind,
|
|
122
|
+
"--subject", input.subject,
|
|
123
|
+
"--value-json", canonicalJson(input.value),
|
|
124
|
+
"--statement", input.statement,
|
|
125
|
+
"--sources", ...input.sources,
|
|
126
|
+
"--scope", input.scope.kind,
|
|
127
|
+
...(input.scope.path ? ["--scope-path", input.scope.path] : []),
|
|
128
|
+
...(input.overrides.length > 0 ? ["--overrides", ...input.overrides] : []),
|
|
129
|
+
];
|
|
130
|
+
if (input.verification) {
|
|
131
|
+
args.push("--verification", input.verification.kind);
|
|
132
|
+
if (input.verification.source) args.push("--verification-source", input.verification.source);
|
|
133
|
+
if (Object.hasOwn(input.verification, "expected")) {
|
|
134
|
+
args.push("--verification-expected-json", canonicalJson(input.verification.expected));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return args;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sourceArgs(input) {
|
|
141
|
+
return [
|
|
142
|
+
"--id", input.id,
|
|
143
|
+
"--kind", input.kind,
|
|
144
|
+
...(input.path ? ["--path", input.path] : []),
|
|
145
|
+
...(input.pointer !== undefined ? ["--pointer", input.pointer] : []),
|
|
146
|
+
...(input.reference ? ["--reference", input.reference] : []),
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readIfPresent(filePath) {
|
|
151
|
+
try {
|
|
152
|
+
return await readFile(filePath, "utf8");
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error?.code === "ENOENT") return null;
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function actionBaseline(project, extra = {}) {
|
|
160
|
+
return {
|
|
161
|
+
contract: project.contractDigest,
|
|
162
|
+
sourcesLock: project.sourcesLockDigest,
|
|
163
|
+
projectionsLock: project.projectionsLockDigest,
|
|
164
|
+
...extra,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function previewRegister(root, project, action) {
|
|
169
|
+
const current = project.contract.sources.find((source) => source.id === action.input.id) ?? null;
|
|
170
|
+
const result = await registerSource(root, project, action.input, { write: false });
|
|
171
|
+
const projectionPaths = result.action === "create" ? allProjectionPaths(project) : [];
|
|
172
|
+
return {
|
|
173
|
+
current: current ? structuredClone(current) : null,
|
|
174
|
+
proposed: { action: result.action, source: structuredClone(result.source) },
|
|
175
|
+
baselines: actionBaseline(project, { sourceLocator: action.input }),
|
|
176
|
+
impact: {
|
|
177
|
+
itemIds: [],
|
|
178
|
+
sourceIds: [action.input.id],
|
|
179
|
+
paths: action.input.path ? [action.input.path] : [],
|
|
180
|
+
projectionPaths,
|
|
181
|
+
},
|
|
182
|
+
invocation: invocation(root, "register", sourceArgs(action.input)),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function previewPropose(root, project, action) {
|
|
187
|
+
const proposal = buildItemProposal(project, itemPrimitiveInput(action.input));
|
|
188
|
+
const content = prettyCanonicalJson(proposal);
|
|
189
|
+
const resolved = await resolveWritableInside(root, action.input.output);
|
|
190
|
+
const existing = await readIfPresent(resolved.absolute);
|
|
191
|
+
if (existing !== null && existing !== content) {
|
|
192
|
+
fail("proposal-output-conflict", `refusing to overwrite a different proposal: ${action.input.output}`, {
|
|
193
|
+
details: { path: action.input.output, currentDigest: sha256(existing), proposedDigest: sha256(content) },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
current: existing === null ? null : { path: action.input.output, contentDigest: sha256(existing) },
|
|
198
|
+
proposed: { path: action.input.output, proposal, contentDigest: sha256(content) },
|
|
199
|
+
baselines: actionBaseline(project, { proposalOutput: action.input.output, currentContentDigest: existing === null ? null : sha256(existing) }),
|
|
200
|
+
impact: { itemIds: [action.input.id], sourceIds: [...action.input.sources], paths: [action.input.output], projectionPaths: [] },
|
|
201
|
+
invocation: invocation(root, "propose", [...itemArgs(action.input), "--output", action.input.output]),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function previewAcceptSource(root, project, action) {
|
|
206
|
+
const review = await reviewSource(root, project, action.input.id);
|
|
207
|
+
const mismatches = {};
|
|
208
|
+
if (review.sourceObjectDigest !== action.input.sourceObjectDigest) mismatches.sourceObjectDigest = { expected: action.input.sourceObjectDigest, actual: review.sourceObjectDigest };
|
|
209
|
+
if (review.lockedDigest !== action.input.lockedDigest) mismatches.lockedDigest = { expected: action.input.lockedDigest, actual: review.lockedDigest };
|
|
210
|
+
if (review.currentDigest !== action.input.currentDigest) mismatches.currentDigest = { expected: action.input.currentDigest, actual: review.currentDigest };
|
|
211
|
+
if (Object.keys(mismatches).length > 0) {
|
|
212
|
+
fail("source-baseline-changed", "source review baseline changed after the action plan was created", { exitCode: 1, details: { source: action.input.id, mismatches } });
|
|
213
|
+
}
|
|
214
|
+
const result = await acceptSourceChange(root, project, {
|
|
215
|
+
id: action.input.id,
|
|
216
|
+
expectedDigest: action.input.currentDigest,
|
|
217
|
+
affectedItems: action.input.affectedItems,
|
|
218
|
+
}, { write: false });
|
|
219
|
+
return {
|
|
220
|
+
current: {
|
|
221
|
+
source: structuredClone(review.source),
|
|
222
|
+
sourceObjectDigest: review.sourceObjectDigest,
|
|
223
|
+
lockedDigest: review.lockedDigest,
|
|
224
|
+
currentDigest: review.currentDigest,
|
|
225
|
+
status: review.status,
|
|
226
|
+
},
|
|
227
|
+
proposed: {
|
|
228
|
+
acceptedDigest: result.acceptedDigest,
|
|
229
|
+
affectedItemsBecomePending: [...result.affectedItems],
|
|
230
|
+
nextContractDigest: result.nextContractDigest,
|
|
231
|
+
},
|
|
232
|
+
baselines: actionBaseline(project, {
|
|
233
|
+
sourceObjectDigest: review.sourceObjectDigest,
|
|
234
|
+
lockedDigest: review.lockedDigest,
|
|
235
|
+
currentDigest: review.currentDigest,
|
|
236
|
+
affectedItems: [...result.affectedItems],
|
|
237
|
+
}),
|
|
238
|
+
impact: {
|
|
239
|
+
itemIds: [...result.affectedItems],
|
|
240
|
+
sourceIds: [action.input.id],
|
|
241
|
+
paths: review.source.path ? [review.source.path] : [],
|
|
242
|
+
projectionPaths: uniqueSorted([...review.impact.directProjectionPaths, ...review.impact.staleProjectionPaths]),
|
|
243
|
+
},
|
|
244
|
+
invocation: invocation(root, "accept-source-change", [
|
|
245
|
+
"--id", action.input.id,
|
|
246
|
+
"--expected-digest", action.input.currentDigest,
|
|
247
|
+
...(action.input.affectedItems.length > 0 ? ["--affected-items", ...action.input.affectedItems] : []),
|
|
248
|
+
]),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function previewRevise(root, project, action) {
|
|
253
|
+
const current = project.contract.items.find((item) => item.id === action.input.id);
|
|
254
|
+
const actualDigest = current ? digestJson(current) : null;
|
|
255
|
+
if (actualDigest !== action.input.expectedItemDigest) {
|
|
256
|
+
fail("item-baseline-changed", "item changed after the action plan was created", { exitCode: 1, details: { item: action.input.id, expected: action.input.expectedItemDigest, actual: actualDigest } });
|
|
257
|
+
}
|
|
258
|
+
const result = await reviseItem(root, project, { ...itemPrimitiveInput(action.input), expectedItemDigest: action.input.expectedItemDigest }, { write: false });
|
|
259
|
+
return {
|
|
260
|
+
current: structuredClone(result.currentItem),
|
|
261
|
+
proposed: structuredClone(result.proposedItem),
|
|
262
|
+
baselines: actionBaseline(project, { currentItemDigest: result.currentItemDigest }),
|
|
263
|
+
impact: {
|
|
264
|
+
itemIds: [action.input.id],
|
|
265
|
+
sourceIds: uniqueSorted([...current.sources, ...action.input.sources]),
|
|
266
|
+
paths: uniqueSorted([current.scope.path, action.input.scope.path].filter(Boolean)),
|
|
267
|
+
projectionPaths: allProjectionPaths(project),
|
|
268
|
+
directProjectionPaths: directProjectionPaths(project, [action.input.id]),
|
|
269
|
+
},
|
|
270
|
+
invocation: invocation(root, "revise", itemArgs(action.input, { expectedItemDigest: action.input.expectedItemDigest })),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function withoutAuthority(record, field) {
|
|
275
|
+
const copy = structuredClone(record);
|
|
276
|
+
if (field === "approval") delete copy.approval;
|
|
277
|
+
if (field === "deprecation") delete copy.deprecation;
|
|
278
|
+
return { ...copy, authorizationRequired: ["human-identity", "execution-time"] };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function previewDeprecateItem(root, project, action) {
|
|
282
|
+
const current = project.contract.items.find((item) => item.id === action.input.id);
|
|
283
|
+
const actualDigest = current ? digestJson(current) : null;
|
|
284
|
+
if (actualDigest !== action.input.expectedItemDigest) {
|
|
285
|
+
fail("item-baseline-changed", "item changed after the action plan was created", { exitCode: 1, details: { item: action.input.id, expected: action.input.expectedItemDigest, actual: actualDigest } });
|
|
286
|
+
}
|
|
287
|
+
const result = await deprecateItem(project, {
|
|
288
|
+
id: action.input.id,
|
|
289
|
+
expectedItemDigest: action.input.expectedItemDigest,
|
|
290
|
+
rationale: action.input.rationale,
|
|
291
|
+
by: INTERNAL_AUTHORITY_SENTINEL,
|
|
292
|
+
at: INTERNAL_TIMESTAMP,
|
|
293
|
+
}, { write: false });
|
|
294
|
+
const blockers = result.preflightFindings.length > 0
|
|
295
|
+
? [{
|
|
296
|
+
code: "maintenance-preflight-failed",
|
|
297
|
+
message: "item deprecation would leave invalid overrides or conflicts",
|
|
298
|
+
details: { item: action.input.id, findings: result.preflightFindings },
|
|
299
|
+
}]
|
|
300
|
+
: [];
|
|
301
|
+
return {
|
|
302
|
+
current: structuredClone(result.currentItem),
|
|
303
|
+
proposed: withoutAuthority(result.deprecatedItem, "approval"),
|
|
304
|
+
baselines: actionBaseline(project, { currentItemDigest: result.currentItemDigest }),
|
|
305
|
+
impact: {
|
|
306
|
+
itemIds: uniqueSorted([
|
|
307
|
+
action.input.id,
|
|
308
|
+
...result.relationships.overrideDependents.map((item) => item.id),
|
|
309
|
+
...result.relationships.fallbackItems.map((item) => item.id),
|
|
310
|
+
]),
|
|
311
|
+
sourceIds: [...result.currentItem.sources],
|
|
312
|
+
paths: result.currentItem.scope.path ? [result.currentItem.scope.path] : [],
|
|
313
|
+
projectionPaths: allProjectionPaths(project),
|
|
314
|
+
directProjectionPaths: directProjectionPaths(project, [action.input.id]),
|
|
315
|
+
},
|
|
316
|
+
blockers,
|
|
317
|
+
invocation: invocation(root, "deprecate", [
|
|
318
|
+
"--id", action.input.id,
|
|
319
|
+
"--expected-item-digest", action.input.expectedItemDigest,
|
|
320
|
+
"--rationale", action.input.rationale,
|
|
321
|
+
]),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function previewDeprecateSource(root, project, action) {
|
|
326
|
+
const current = project.contract.sources.find((source) => source.id === action.input.id);
|
|
327
|
+
const actualDigest = current ? digestJson(current) : null;
|
|
328
|
+
if (actualDigest !== action.input.expectedSourceDigest) {
|
|
329
|
+
fail("source-baseline-changed", "source changed after the action plan was created", { exitCode: 1, details: { source: action.input.id, expected: action.input.expectedSourceDigest, actual: actualDigest } });
|
|
330
|
+
}
|
|
331
|
+
const result = await deprecateSource(project, {
|
|
332
|
+
id: action.input.id,
|
|
333
|
+
expectedSourceDigest: action.input.expectedSourceDigest,
|
|
334
|
+
rationale: action.input.rationale,
|
|
335
|
+
by: INTERNAL_AUTHORITY_SENTINEL,
|
|
336
|
+
at: INTERNAL_TIMESTAMP,
|
|
337
|
+
}, { write: false });
|
|
338
|
+
const affectedItems = result.impact.items.map((item) => item.id);
|
|
339
|
+
if (JSON.stringify(affectedItems) !== JSON.stringify(action.input.affectedItems)) {
|
|
340
|
+
fail("source-impact-changed", "source impact changed after the action plan was created", {
|
|
341
|
+
exitCode: 1,
|
|
342
|
+
details: { source: action.input.id, expected: action.input.affectedItems, actual: affectedItems },
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
const blockers = result.blockingItems.length > 0
|
|
346
|
+
? [{
|
|
347
|
+
code: "source-still-referenced",
|
|
348
|
+
message: "source is still referenced by proposed or approved items",
|
|
349
|
+
details: { source: action.input.id, items: result.blockingItems },
|
|
350
|
+
}]
|
|
351
|
+
: [];
|
|
352
|
+
const proposed = result.deprecatedSource
|
|
353
|
+
? withoutAuthority(result.deprecatedSource, "deprecation")
|
|
354
|
+
: {
|
|
355
|
+
...structuredClone(result.currentSource),
|
|
356
|
+
status: "deprecated",
|
|
357
|
+
rationale: action.input.rationale,
|
|
358
|
+
authorizationRequired: ["human-identity", "execution-time"],
|
|
359
|
+
};
|
|
360
|
+
return {
|
|
361
|
+
current: structuredClone(result.currentSource),
|
|
362
|
+
proposed,
|
|
363
|
+
baselines: actionBaseline(project, { currentSourceDigest: result.currentSourceDigest }),
|
|
364
|
+
impact: {
|
|
365
|
+
itemIds: affectedItems,
|
|
366
|
+
sourceIds: [action.input.id],
|
|
367
|
+
paths: result.currentSource.path ? [result.currentSource.path] : [],
|
|
368
|
+
projectionPaths: uniqueSorted([...result.impact.directProjectionPaths, ...result.impact.staleProjectionPaths]),
|
|
369
|
+
},
|
|
370
|
+
blockers,
|
|
371
|
+
invocation: invocation(root, "deprecate-source", [
|
|
372
|
+
"--id", action.input.id,
|
|
373
|
+
"--expected-source-digest", action.input.expectedSourceDigest,
|
|
374
|
+
"--rationale", action.input.rationale,
|
|
375
|
+
]),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function approvedIntent(item, rationale) {
|
|
380
|
+
const proposed = structuredClone(item);
|
|
381
|
+
proposed.status = "approved";
|
|
382
|
+
delete proposed.approval;
|
|
383
|
+
return { ...proposed, authorizationRequired: ["human-identity", "execution-time"], ...(rationale !== undefined ? { rationale } : {}) };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function previewApproval(root, project, action) {
|
|
387
|
+
let result;
|
|
388
|
+
let current;
|
|
389
|
+
let proposalDigest = null;
|
|
390
|
+
if (action.input.mode === "pending") {
|
|
391
|
+
current = action.input.ids.map((id) => project.contract.items.find((item) => item.id === id) ?? null);
|
|
392
|
+
result = await approvePendingItems(root, project, {
|
|
393
|
+
ids: action.input.ids,
|
|
394
|
+
by: INTERNAL_AUTHORITY_SENTINEL,
|
|
395
|
+
at: INTERNAL_TIMESTAMP,
|
|
396
|
+
rationale: action.input.rationale,
|
|
397
|
+
write: false,
|
|
398
|
+
});
|
|
399
|
+
} else {
|
|
400
|
+
const resolved = await resolveExistingInside(root, action.input.proposal);
|
|
401
|
+
const proposal = await readJsonFile(resolved.absolute, "proposal");
|
|
402
|
+
proposalDigest = digestJson(proposal);
|
|
403
|
+
if (proposalDigest !== action.input.proposalDigest) {
|
|
404
|
+
fail("proposal-baseline-changed", "proposal changed after the action plan was created", { exitCode: 1, details: { path: action.input.proposal, expected: action.input.proposalDigest, actual: proposalDigest } });
|
|
405
|
+
}
|
|
406
|
+
current = action.input.ids.map((id) => proposal.items?.find((item) => item.id === id) ?? null);
|
|
407
|
+
result = await approveProposal(root, project, proposal, {
|
|
408
|
+
ids: action.input.ids,
|
|
409
|
+
by: INTERNAL_AUTHORITY_SENTINEL,
|
|
410
|
+
at: INTERNAL_TIMESTAMP,
|
|
411
|
+
rationale: action.input.rationale,
|
|
412
|
+
write: false,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const approvedById = new Map(result.contract.items.map((item) => [item.id, item]));
|
|
416
|
+
const proposed = action.input.ids.map((id) => approvedIntent(approvedById.get(id), action.input.rationale));
|
|
417
|
+
const sourceIds = uniqueSorted(proposed.flatMap((item) => item.sources));
|
|
418
|
+
return {
|
|
419
|
+
current: structuredClone(current),
|
|
420
|
+
proposed,
|
|
421
|
+
baselines: actionBaseline(project, {
|
|
422
|
+
mode: action.input.mode,
|
|
423
|
+
...(action.input.mode === "proposal" ? { proposalPath: action.input.proposal, proposalDigest } : {}),
|
|
424
|
+
itemDigests: Object.fromEntries(current.filter(Boolean).map((item) => [item.id, digestJson(item)]).sort()),
|
|
425
|
+
}),
|
|
426
|
+
impact: {
|
|
427
|
+
itemIds: [...action.input.ids],
|
|
428
|
+
sourceIds,
|
|
429
|
+
paths: action.input.mode === "proposal" ? [action.input.proposal] : [],
|
|
430
|
+
projectionPaths: allProjectionPaths(project),
|
|
431
|
+
directProjectionPaths: directProjectionPaths(project, action.input.ids),
|
|
432
|
+
},
|
|
433
|
+
invocation: invocation(root, "approve", [
|
|
434
|
+
...(action.input.mode === "pending" ? ["--pending"] : ["--proposal", action.input.proposal]),
|
|
435
|
+
"--ids", ...action.input.ids,
|
|
436
|
+
...(action.input.rationale !== undefined ? ["--rationale", action.input.rationale] : []),
|
|
437
|
+
]),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function previewPublish(root, project, action) {
|
|
442
|
+
const blocking = blockingContextFindings(await checkProject(root, project));
|
|
443
|
+
if (blocking.length > 0) fail("publish-blocked", "projection is blocked by contract or source findings", { exitCode: 1, details: { findings: blocking } });
|
|
444
|
+
const resolved = await resolveWritableInside(root, action.input.output);
|
|
445
|
+
const existing = await readIfPresent(resolved.absolute);
|
|
446
|
+
const currentContentDigest = existing === null ? null : sha256(existing);
|
|
447
|
+
if (currentContentDigest !== action.input.expectedContentDigest) {
|
|
448
|
+
fail("projection-baseline-changed", "projection content changed after the action plan was created", { exitCode: 1, details: { path: action.input.output, expected: action.input.expectedContentDigest, actual: currentContentDigest } });
|
|
449
|
+
}
|
|
450
|
+
const currentEntry = project.projectionsLock.projections.find((entry) => entry.path === action.input.output) ?? null;
|
|
451
|
+
const result = await publishProjection(root, project, {
|
|
452
|
+
target: action.input.target,
|
|
453
|
+
output: action.input.output,
|
|
454
|
+
paths: action.input.paths,
|
|
455
|
+
write: false,
|
|
456
|
+
});
|
|
457
|
+
return {
|
|
458
|
+
current: { entry: structuredClone(currentEntry), content: existing, contentDigest: currentContentDigest },
|
|
459
|
+
proposed: { action: result.action, entry: result.entry, content: result.content },
|
|
460
|
+
baselines: actionBaseline(project, { currentContentDigest, currentLockEntry: structuredClone(currentEntry) }),
|
|
461
|
+
impact: {
|
|
462
|
+
itemIds: [...result.entry.itemIds],
|
|
463
|
+
sourceIds: uniqueSorted(project.contract.items.filter((item) => result.entry.itemIds.includes(item.id)).flatMap((item) => item.sources)),
|
|
464
|
+
paths: uniqueSorted([...action.input.paths, action.input.output]),
|
|
465
|
+
projectionPaths: [action.input.output],
|
|
466
|
+
},
|
|
467
|
+
invocation: invocation(root, "publish", [
|
|
468
|
+
"--target", action.input.target,
|
|
469
|
+
"--output", action.input.output,
|
|
470
|
+
"--path", ...action.input.paths,
|
|
471
|
+
]),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function previewAction(root, project, action) {
|
|
476
|
+
if (action.kind === "register-source") return previewRegister(root, project, action);
|
|
477
|
+
if (action.kind === "propose-item") return previewPropose(root, project, action);
|
|
478
|
+
if (action.kind === "accept-source-change") return previewAcceptSource(root, project, action);
|
|
479
|
+
if (action.kind === "revise-item") return previewRevise(root, project, action);
|
|
480
|
+
if (action.kind === "deprecate-item") return previewDeprecateItem(root, project, action);
|
|
481
|
+
if (action.kind === "deprecate-source") return previewDeprecateSource(root, project, action);
|
|
482
|
+
if (action.kind === "request-item-approval") return previewApproval(root, project, action);
|
|
483
|
+
if (action.kind === "publish-projection") return previewPublish(root, project, action);
|
|
484
|
+
fail("action-plan-schema-invalid", `unsupported action kind: ${action.kind}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function blocker(error) {
|
|
488
|
+
return {
|
|
489
|
+
code: error instanceof ProjectContextError ? error.code : "internal-error",
|
|
490
|
+
message: error instanceof Error ? error.message : String(error),
|
|
491
|
+
...(error?.details ? { details: structuredClone(error.details) } : {}),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function blockedContext(project, action, error) {
|
|
496
|
+
let current = null;
|
|
497
|
+
const impact = emptyImpact();
|
|
498
|
+
if (["accept-source-change", "deprecate-source", "register-source"].includes(action.kind)) {
|
|
499
|
+
current = structuredClone(project.contract.sources.find((source) => source.id === action.input.id) ?? null);
|
|
500
|
+
impact.sourceIds = [action.input.id];
|
|
501
|
+
if (current?.path) impact.paths = [current.path];
|
|
502
|
+
}
|
|
503
|
+
if (["deprecate-item", "revise-item", "propose-item"].includes(action.kind)) {
|
|
504
|
+
current = structuredClone(project.contract.items.find((item) => item.id === action.input.id) ?? null);
|
|
505
|
+
impact.itemIds = [action.input.id];
|
|
506
|
+
impact.sourceIds = [...(current?.sources ?? action.input.sources ?? [])].sort();
|
|
507
|
+
if (current?.scope?.path) impact.paths = [current.scope.path];
|
|
508
|
+
}
|
|
509
|
+
if (action.kind === "request-item-approval") {
|
|
510
|
+
current = action.input.ids.map((id) => structuredClone(project.contract.items.find((item) => item.id === id) ?? null));
|
|
511
|
+
impact.itemIds = [...action.input.ids];
|
|
512
|
+
}
|
|
513
|
+
if (action.kind === "publish-projection") {
|
|
514
|
+
current = structuredClone(project.projectionsLock.projections.find((entry) => entry.path === action.input.output) ?? null);
|
|
515
|
+
impact.paths = uniqueSorted([...action.input.paths, action.input.output]);
|
|
516
|
+
impact.projectionPaths = [action.input.output];
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
current,
|
|
520
|
+
proposed: { intent: structuredClone(action.input), authorizationRequired: ["human-approval"] },
|
|
521
|
+
baselines: actionBaseline(project, { blocker: error.code }),
|
|
522
|
+
impact,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function groupsFor(actions) {
|
|
527
|
+
const byKind = new Map();
|
|
528
|
+
for (const action of actions) {
|
|
529
|
+
const kind = GROUP_BY_ACTION.get(action.kind);
|
|
530
|
+
if (!byKind.has(kind)) byKind.set(kind, []);
|
|
531
|
+
byKind.get(kind).push(action.id);
|
|
532
|
+
if (action.status === "blocked") {
|
|
533
|
+
if (!byKind.has("blocker")) byKind.set("blocker", []);
|
|
534
|
+
byKind.get("blocker").push(action.id);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return [...byKind]
|
|
538
|
+
.map(([kind, actionIds]) => ({ kind, actionIds: uniqueSorted(actionIds) }))
|
|
539
|
+
.sort((left, right) => left.kind.localeCompare(right.kind));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export async function preflightActionPlan(root, project, input) {
|
|
543
|
+
const plan = validateActionPlan(input);
|
|
544
|
+
if (plan.projectId !== project.contract.project.id) {
|
|
545
|
+
fail("action-plan-project-mismatch", "action plan belongs to a different project", { details: { expected: project.contract.project.id, actual: plan.projectId } });
|
|
546
|
+
}
|
|
547
|
+
const currentBaselines = {
|
|
548
|
+
contract: project.contractDigest,
|
|
549
|
+
sourcesLock: project.sourcesLockDigest,
|
|
550
|
+
projectionsLock: project.projectionsLockDigest,
|
|
551
|
+
};
|
|
552
|
+
const changed = Object.keys(currentBaselines).filter((key) => currentBaselines[key] !== plan.baselines[key]);
|
|
553
|
+
if (changed.length > 0) {
|
|
554
|
+
fail("action-plan-baseline-changed", "action plan project snapshot is stale", { exitCode: 1, details: { changed, expected: plan.baselines, actual: currentBaselines } });
|
|
555
|
+
}
|
|
556
|
+
assertActionPlanConflictFree(plan);
|
|
557
|
+
|
|
558
|
+
const actions = [];
|
|
559
|
+
const findings = [];
|
|
560
|
+
for (const action of plan.actions) {
|
|
561
|
+
try {
|
|
562
|
+
const preview = await previewAction(root, project, action);
|
|
563
|
+
const blockers = preview.blockers ?? [];
|
|
564
|
+
const reviewable = blockers.length === 0;
|
|
565
|
+
actions.push({
|
|
566
|
+
id: action.id,
|
|
567
|
+
kind: action.kind,
|
|
568
|
+
status: reviewable ? "reviewable" : "blocked",
|
|
569
|
+
current: preview.current,
|
|
570
|
+
proposed: preview.proposed,
|
|
571
|
+
baselines: preview.baselines,
|
|
572
|
+
impact: preview.impact,
|
|
573
|
+
blockers,
|
|
574
|
+
humanApprovalRequired: true,
|
|
575
|
+
...(reviewable ? { invocation: preview.invocation } : {}),
|
|
576
|
+
});
|
|
577
|
+
for (const entry of blockers) findings.push({ actionId: action.id, ...entry });
|
|
578
|
+
} catch (error) {
|
|
579
|
+
if (!(error instanceof ProjectContextError)) throw error;
|
|
580
|
+
const finding = { actionId: action.id, ...blocker(error) };
|
|
581
|
+
const context = blockedContext(project, action, error);
|
|
582
|
+
findings.push(finding);
|
|
583
|
+
actions.push({
|
|
584
|
+
id: action.id,
|
|
585
|
+
kind: action.kind,
|
|
586
|
+
status: "blocked",
|
|
587
|
+
current: context.current,
|
|
588
|
+
proposed: context.proposed,
|
|
589
|
+
baselines: context.baselines,
|
|
590
|
+
impact: context.impact,
|
|
591
|
+
blockers: [finding],
|
|
592
|
+
humanApprovalRequired: true,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const groups = groupsFor(actions);
|
|
597
|
+
const invocations = actions.filter((action) => action.status === "reviewable").map((action) => ({ actionId: action.id, ...action.invocation }));
|
|
598
|
+
const reviewable = actions.filter((action) => action.status === "reviewable").length;
|
|
599
|
+
return validateReviewBundle({
|
|
600
|
+
schemaVersion: REVIEW_BUNDLE_SCHEMA_VERSION,
|
|
601
|
+
projectId: plan.projectId,
|
|
602
|
+
planDigest: digestJson(plan),
|
|
603
|
+
baselines: currentBaselines,
|
|
604
|
+
status: reviewable === actions.length ? "reviewable" : "blocked",
|
|
605
|
+
summary: {
|
|
606
|
+
actions: actions.length,
|
|
607
|
+
reviewable,
|
|
608
|
+
blocked: actions.length - reviewable,
|
|
609
|
+
groups: groups.length,
|
|
610
|
+
},
|
|
611
|
+
actions,
|
|
612
|
+
groups,
|
|
613
|
+
findings,
|
|
614
|
+
invocations,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export async function preflightActionPlanFile(root, planPath) {
|
|
619
|
+
const resolved = await resolveExistingInside(root, planPath);
|
|
620
|
+
const plan = await readJsonFile(resolved.absolute, "action plan");
|
|
621
|
+
const project = await loadProject(root);
|
|
622
|
+
return preflightActionPlan(root, project, plan);
|
|
623
|
+
}
|
|
@@ -43,6 +43,28 @@ async function exists(filePath) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export async function inspectProjectInitialization(root) {
|
|
47
|
+
const files = projectFiles(root);
|
|
48
|
+
const entries = [
|
|
49
|
+
[CONTEXT_DIRECTORY, files.directory],
|
|
50
|
+
[`${CONTEXT_DIRECTORY}/${CONTRACT_FILE}`, files.contract],
|
|
51
|
+
[`${CONTEXT_DIRECTORY}/${SOURCES_LOCK_FILE}`, files.sourcesLock],
|
|
52
|
+
[`${CONTEXT_DIRECTORY}/${PROJECTIONS_LOCK_FILE}`, files.projectionsLock],
|
|
53
|
+
];
|
|
54
|
+
const present = [];
|
|
55
|
+
for (const [relative, absolute] of entries) {
|
|
56
|
+
if (await exists(absolute)) present.push(relative);
|
|
57
|
+
}
|
|
58
|
+
const storePaths = entries.slice(1).map(([relative]) => relative);
|
|
59
|
+
const complete = storePaths.every((relative) => present.includes(relative));
|
|
60
|
+
return {
|
|
61
|
+
status: present.length === 0 ? "uninitialized" : complete ? "initialized" : "partial",
|
|
62
|
+
files,
|
|
63
|
+
present,
|
|
64
|
+
missing: storePaths.filter((relative) => !present.includes(relative)),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
export async function initializeProject(root, id, name, write) {
|
|
47
69
|
const files = projectFiles(root);
|
|
48
70
|
const initial = initialProjectFiles(id, name);
|