archctx-contracts 0.2.2 → 0.4.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/fixtures/boundary/architecture-node-extension.json +1 -1
- package/fixtures/boundary/explorer-projection-v2-budget.json +87 -0
- package/fixtures/invalid/architecture-snapshot-unknown-mode.json +22 -4
- package/fixtures/invalid/explorer-projection-query-v2-caller-scope.json +7 -0
- package/fixtures/invalid/explorer-projection-v2-derived-subject.json +101 -0
- package/fixtures/invalid/node-unknown-field.json +1 -1
- package/fixtures/valid/archctx-capabilities.json +14 -0
- package/fixtures/valid/architecture-flow.json +53 -0
- package/fixtures/valid/architecture-node.json +19 -2
- package/fixtures/valid/architecture-refresh-signal.json +35 -0
- package/fixtures/valid/architecture-snapshot.json +22 -4
- package/fixtures/valid/explorer-delta-query.json +11 -0
- package/fixtures/valid/explorer-projection-delta.json +32 -0
- package/fixtures/valid/explorer-projection-query-v2.json +6 -0
- package/fixtures/valid/explorer-projection-v2.json +138 -0
- package/fixtures/valid/product-version-manifest.json +7 -7
- package/fixtures/valid/projection-request.json +14 -0
- package/fixtures/valid/projection-result.json +47 -0
- package/package.json +1 -1
- package/schemas/repo/architecture-flow.schema.json +97 -0
- package/schemas/repo/architecture-node.schema.json +39 -2
- package/schemas/runtime/archctx-capabilities.schema.json +45 -0
- package/schemas/runtime/architecture-event.schema.json +172 -2
- package/schemas/runtime/architecture-refresh-signal.schema.json +88 -0
- package/schemas/runtime/architecture-snapshot.schema.json +37 -4
- package/schemas/runtime/changeset.schema.json +1 -1
- package/schemas/runtime/explorer-delta-query.schema.json +24 -0
- package/schemas/runtime/explorer-projection-delta.schema.json +79 -0
- package/schemas/runtime/explorer-projection-query-v2.schema.json +42 -0
- package/schemas/runtime/explorer-projection-v2.schema.json +538 -0
- package/schemas/runtime/projection-request.schema.json +61 -0
- package/schemas/runtime/projection-result.schema.json +162 -0
- package/src/architecture.ts +73 -0
- package/src/index.ts +2 -0
- package/src/ledger.ts +125 -3
- package/src/ports.ts +399 -32
- package/src/product-version.ts +5 -5
- package/src/projection.ts +301 -0
- package/src/schema.ts +3 -2
- package/src/validator.ts +70 -6
- package/fixtures/invalid/explorer-projection-write-field.json +0 -21
- package/fixtures/valid/explorer-projection.json +0 -53
- package/schemas/runtime/explorer-projection.schema.json +0 -92
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { digestJson, type Json } from "./schema";
|
|
2
|
+
|
|
3
|
+
export const PROJECTION_REQUEST_SCHEMA_VERSION = "archcontext.projection-request/v1" as const;
|
|
4
|
+
export const PROJECTION_RESULT_SCHEMA_VERSION = "archcontext.projection-result/v1" as const;
|
|
5
|
+
export const ARCHITECTURE_REFRESH_SIGNAL_SCHEMA_VERSION = "archcontext.architecture-refresh-signal/v1" as const;
|
|
6
|
+
export const ARCHCTX_CAPABILITIES_SCHEMA_VERSION = "archcontext.capabilities/v1" as const;
|
|
7
|
+
export const ARCHITECTURE_DOCS_RENDERER_VERSION = "archcontext.docs-renderer/v2" as const;
|
|
8
|
+
export const AGENT_CONTEXT_RENDERER_VERSION = "archcontext.agent-context-renderer/v1" as const;
|
|
9
|
+
|
|
10
|
+
export const PROJECTION_MODES = ["check", "plan", "apply", "adopt"] as const;
|
|
11
|
+
export const PROJECTION_TARGETS = ["agent-context", "architecture-docs"] as const;
|
|
12
|
+
export const PROJECTION_RESULT_STATUSES = [
|
|
13
|
+
"adoption-required",
|
|
14
|
+
"applied",
|
|
15
|
+
"blocked",
|
|
16
|
+
"human-action-required",
|
|
17
|
+
"noop",
|
|
18
|
+
"permanent-failure",
|
|
19
|
+
"planned",
|
|
20
|
+
"retryable-failure"
|
|
21
|
+
] as const;
|
|
22
|
+
export const PROJECTION_HUMAN_ACTION_REASON_CODES = [
|
|
23
|
+
"adoption-required",
|
|
24
|
+
"manual-region-conflict",
|
|
25
|
+
"target-collision",
|
|
26
|
+
"unprovable-required-flow",
|
|
27
|
+
"unresolved-major-change"
|
|
28
|
+
] as const;
|
|
29
|
+
export const ARCHITECTURE_MAJOR_CHANGE_REASON_CODES = [
|
|
30
|
+
"constraint-changed",
|
|
31
|
+
"entrypoint-changed",
|
|
32
|
+
"interface-changed",
|
|
33
|
+
"lifecycle-changed",
|
|
34
|
+
"node-added",
|
|
35
|
+
"node-moved",
|
|
36
|
+
"node-removed",
|
|
37
|
+
"node-renamed",
|
|
38
|
+
"ownership-changed",
|
|
39
|
+
"relation-changed",
|
|
40
|
+
"responsibility-changed",
|
|
41
|
+
"risk-boundary-changed",
|
|
42
|
+
"verified-flow-proof-changed"
|
|
43
|
+
] as const;
|
|
44
|
+
export const ARCHITECTURE_REFRESH_TARGETS = [
|
|
45
|
+
"architecture-contract-context",
|
|
46
|
+
"architecture-readiness",
|
|
47
|
+
"architecture-request-index",
|
|
48
|
+
"capability-context",
|
|
49
|
+
"capability-index"
|
|
50
|
+
] as const;
|
|
51
|
+
export const ARCHCTX_FEATURES = [
|
|
52
|
+
"architecture-docs-renderer-v2",
|
|
53
|
+
"architecture-refresh-signal-v1",
|
|
54
|
+
"projection-protocol-v1"
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
export type ProjectionMode = (typeof PROJECTION_MODES)[number];
|
|
58
|
+
export type ProjectionTarget = (typeof PROJECTION_TARGETS)[number];
|
|
59
|
+
export type ProjectionResultStatus = (typeof PROJECTION_RESULT_STATUSES)[number];
|
|
60
|
+
export type ProjectionHumanActionReasonCode = (typeof PROJECTION_HUMAN_ACTION_REASON_CODES)[number];
|
|
61
|
+
export type ArchitectureMajorChangeReasonCode = (typeof ARCHITECTURE_MAJOR_CHANGE_REASON_CODES)[number];
|
|
62
|
+
export type ArchitectureRefreshTarget = (typeof ARCHITECTURE_REFRESH_TARGETS)[number];
|
|
63
|
+
export type ArchctxFeature = (typeof ARCHCTX_FEATURES)[number];
|
|
64
|
+
export type Sha256Digest = `sha256:${string}`;
|
|
65
|
+
|
|
66
|
+
export interface ProjectionExpectedSnapshotV1 {
|
|
67
|
+
repositoryId: string;
|
|
68
|
+
workspaceId: string;
|
|
69
|
+
headSha: string;
|
|
70
|
+
worktreeDigest: Sha256Digest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ProjectionRequestV1 {
|
|
74
|
+
schemaVersion: typeof PROJECTION_REQUEST_SCHEMA_VERSION;
|
|
75
|
+
requestId: string;
|
|
76
|
+
profile: "repo-harness/v1";
|
|
77
|
+
mode: ProjectionMode;
|
|
78
|
+
targets: ProjectionTarget[];
|
|
79
|
+
changedPaths: string[];
|
|
80
|
+
expected: ProjectionExpectedSnapshotV1;
|
|
81
|
+
adoptionPlanId?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ProjectionSnapshotV1 extends ProjectionExpectedSnapshotV1 {
|
|
85
|
+
baseHeadSha: string;
|
|
86
|
+
sourceTreeDigest: Sha256Digest;
|
|
87
|
+
modelDigest: Sha256Digest;
|
|
88
|
+
codeGraphDigest: Sha256Digest;
|
|
89
|
+
indexedWorktreeDigest: Sha256Digest | null;
|
|
90
|
+
projectionInputDigest: Sha256Digest;
|
|
91
|
+
rendererVersion: typeof ARCHITECTURE_DOCS_RENDERER_VERSION;
|
|
92
|
+
layoutVersion: "archcontext.docs-layout/v1";
|
|
93
|
+
generatedFrom: {
|
|
94
|
+
codeGraphPackage: "@colbymchenry/codegraph";
|
|
95
|
+
codeGraphVersion: "1.5.0";
|
|
96
|
+
codeGraphBinaryDigest: Sha256Digest;
|
|
97
|
+
codeGraphStatus: "ready" | "unavailable";
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ProjectionFileResultV1 {
|
|
102
|
+
path: string;
|
|
103
|
+
action: "create" | "delete" | "unchanged" | "update";
|
|
104
|
+
preimageDigest: Sha256Digest | null;
|
|
105
|
+
outputDigest: Sha256Digest | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ProjectionHumanActionV1 {
|
|
109
|
+
reasonCode: ProjectionHumanActionReasonCode;
|
|
110
|
+
affectedNodeIds: string[];
|
|
111
|
+
requestPayloadDigest: Sha256Digest;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ArchitectureDigestSetV1 {
|
|
115
|
+
modelDigest: Sha256Digest;
|
|
116
|
+
sourceTreeDigest: Sha256Digest;
|
|
117
|
+
flowProofDigest: Sha256Digest;
|
|
118
|
+
projectionDigest: Sha256Digest;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface AcceptedArchitectureChangeReferenceV1 {
|
|
122
|
+
changeSetId: string;
|
|
123
|
+
eventId: string;
|
|
124
|
+
reasonCodes: ArchitectureMajorChangeReasonCode[];
|
|
125
|
+
affectedNodeIds: string[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ArchitectureRefreshSignalV1 {
|
|
129
|
+
schemaVersion: typeof ARCHITECTURE_REFRESH_SIGNAL_SCHEMA_VERSION;
|
|
130
|
+
signalId: Sha256Digest;
|
|
131
|
+
idempotencyKey: Sha256Digest;
|
|
132
|
+
mode: "human-action-required" | "refresh-required";
|
|
133
|
+
repository: { repositoryId: string };
|
|
134
|
+
worktree: { workspaceId: string; headSha: string; worktreeDigest: Sha256Digest };
|
|
135
|
+
cause: "accepted-semantic-delta" | "unresolved-major-candidate" | "verified-flow-proof-delta";
|
|
136
|
+
acceptedChange?: AcceptedArchitectureChangeReferenceV1;
|
|
137
|
+
reasonCodes: ArchitectureMajorChangeReasonCode[];
|
|
138
|
+
affectedNodeIds: string[];
|
|
139
|
+
refreshTargets: ArchitectureRefreshTarget[];
|
|
140
|
+
baseDigests: ArchitectureDigestSetV1;
|
|
141
|
+
resultingDigests: ArchitectureDigestSetV1;
|
|
142
|
+
projectionReceiptDigest: Sha256Digest;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ProjectionResultV1 {
|
|
146
|
+
schemaVersion: typeof PROJECTION_RESULT_SCHEMA_VERSION;
|
|
147
|
+
requestId: string;
|
|
148
|
+
status: ProjectionResultStatus;
|
|
149
|
+
inputSnapshot: ProjectionSnapshotV1;
|
|
150
|
+
outputSnapshot: ProjectionSnapshotV1;
|
|
151
|
+
affectedNodeIds: string[];
|
|
152
|
+
files: ProjectionFileResultV1[];
|
|
153
|
+
humanActions: ProjectionHumanActionV1[];
|
|
154
|
+
refreshSignals: ArchitectureRefreshSignalV1[];
|
|
155
|
+
receiptDigest: Sha256Digest;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ArchctxCapabilitiesV1 {
|
|
159
|
+
schemaVersion: typeof ARCHCTX_CAPABILITIES_SCHEMA_VERSION;
|
|
160
|
+
package: {
|
|
161
|
+
name: "archctx";
|
|
162
|
+
version: string;
|
|
163
|
+
};
|
|
164
|
+
protocols: {
|
|
165
|
+
projectionRequest: typeof PROJECTION_REQUEST_SCHEMA_VERSION;
|
|
166
|
+
projectionResult: typeof PROJECTION_RESULT_SCHEMA_VERSION;
|
|
167
|
+
architectureRefreshSignal: typeof ARCHITECTURE_REFRESH_SIGNAL_SCHEMA_VERSION;
|
|
168
|
+
};
|
|
169
|
+
renderers: {
|
|
170
|
+
architectureDocs: typeof ARCHITECTURE_DOCS_RENDERER_VERSION;
|
|
171
|
+
agentContext: typeof AGENT_CONTEXT_RENDERER_VERSION;
|
|
172
|
+
};
|
|
173
|
+
features: ArchctxFeature[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function archctxCapabilities(packageVersion: string): ArchctxCapabilitiesV1 {
|
|
177
|
+
return {
|
|
178
|
+
schemaVersion: ARCHCTX_CAPABILITIES_SCHEMA_VERSION,
|
|
179
|
+
package: { name: "archctx", version: packageVersion },
|
|
180
|
+
protocols: {
|
|
181
|
+
projectionRequest: PROJECTION_REQUEST_SCHEMA_VERSION,
|
|
182
|
+
projectionResult: PROJECTION_RESULT_SCHEMA_VERSION,
|
|
183
|
+
architectureRefreshSignal: ARCHITECTURE_REFRESH_SIGNAL_SCHEMA_VERSION
|
|
184
|
+
},
|
|
185
|
+
renderers: {
|
|
186
|
+
architectureDocs: ARCHITECTURE_DOCS_RENDERER_VERSION,
|
|
187
|
+
agentContext: AGENT_CONTEXT_RENDERER_VERSION
|
|
188
|
+
},
|
|
189
|
+
features: [...ARCHCTX_FEATURES]
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function projectionRequestInvariantIssues(input: ProjectionRequestV1): string[] {
|
|
194
|
+
const issues = [
|
|
195
|
+
...sortedUniqueIssues("targets", input.targets),
|
|
196
|
+
...sortedUniqueIssues("changedPaths", input.changedPaths)
|
|
197
|
+
];
|
|
198
|
+
if (input.targets.length === 0) issues.push("targets must contain at least one projection target");
|
|
199
|
+
if (!/^[a-zA-Z0-9_.:-]+$/.test(input.requestId)) issues.push("requestId must use the stable identifier character set");
|
|
200
|
+
if (input.mode === "adopt" && !input.adoptionPlanId) issues.push("adoptionPlanId is required when mode=adopt");
|
|
201
|
+
if (input.mode !== "adopt" && input.adoptionPlanId !== undefined) issues.push("adoptionPlanId is only allowed when mode=adopt");
|
|
202
|
+
return issues;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function projectionResultInvariantIssues(input: ProjectionResultV1): string[] {
|
|
206
|
+
const issues = [
|
|
207
|
+
...sortedUniqueIssues("affectedNodeIds", input.affectedNodeIds),
|
|
208
|
+
...sortedUniqueIssues("files.path", input.files.map((file) => file.path)),
|
|
209
|
+
...sortedUniqueIssues("refreshSignals.signalId", input.refreshSignals.map((signal) => signal.signalId)),
|
|
210
|
+
...input.humanActions.flatMap((action, index) => sortedUniqueIssues(`humanActions[${index}].affectedNodeIds`, action.affectedNodeIds)),
|
|
211
|
+
...input.refreshSignals.flatMap((signal, index) => architectureRefreshSignalInvariantIssues(signal, `refreshSignals[${index}]`))
|
|
212
|
+
];
|
|
213
|
+
if (!/^[a-zA-Z0-9_.:-]+$/.test(input.requestId)) issues.push("requestId must use the stable identifier character set");
|
|
214
|
+
if (input.inputSnapshot.repositoryId !== input.outputSnapshot.repositoryId) issues.push("outputSnapshot.repositoryId must match inputSnapshot.repositoryId");
|
|
215
|
+
if (input.inputSnapshot.workspaceId !== input.outputSnapshot.workspaceId) issues.push("outputSnapshot.workspaceId must match inputSnapshot.workspaceId");
|
|
216
|
+
if (input.inputSnapshot.baseHeadSha !== input.outputSnapshot.baseHeadSha) issues.push("outputSnapshot.baseHeadSha must match inputSnapshot.baseHeadSha");
|
|
217
|
+
const statusRequiresHumanAction = input.status === "adoption-required" || input.status === "human-action-required";
|
|
218
|
+
if (statusRequiresHumanAction && input.humanActions.length === 0) issues.push(`${input.status} status requires at least one human action`);
|
|
219
|
+
if (!statusRequiresHumanAction && input.humanActions.length > 0) issues.push(`human actions are not allowed when status=${input.status}`);
|
|
220
|
+
for (const [index, file] of input.files.entries()) {
|
|
221
|
+
const prefix = `files[${index}]`;
|
|
222
|
+
if (file.action === "create" && (file.preimageDigest !== null || file.outputDigest === null)) {
|
|
223
|
+
issues.push(`${prefix} create requires null preimageDigest and non-null outputDigest`);
|
|
224
|
+
}
|
|
225
|
+
if (file.action === "delete" && (file.preimageDigest === null || file.outputDigest !== null)) {
|
|
226
|
+
issues.push(`${prefix} delete requires non-null preimageDigest and null outputDigest`);
|
|
227
|
+
}
|
|
228
|
+
if (file.action === "update" && (file.preimageDigest === null || file.outputDigest === null || file.preimageDigest === file.outputDigest)) {
|
|
229
|
+
issues.push(`${prefix} update requires two different non-null digests`);
|
|
230
|
+
}
|
|
231
|
+
if (file.action === "unchanged" && (file.preimageDigest === null || file.outputDigest === null || file.preimageDigest !== file.outputDigest)) {
|
|
232
|
+
issues.push(`${prefix} unchanged requires equal non-null digests`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const { receiptDigest, ...receiptPayload } = input;
|
|
236
|
+
if (projectionResultReceiptDigest(receiptPayload) !== receiptDigest) issues.push("receiptDigest must match the canonical projection result payload");
|
|
237
|
+
for (const [index, signal] of input.refreshSignals.entries()) {
|
|
238
|
+
const prefix = `refreshSignals[${index}]`;
|
|
239
|
+
if (signal.projectionReceiptDigest !== receiptDigest) issues.push(`${prefix}.projectionReceiptDigest must match receiptDigest`);
|
|
240
|
+
if (signal.repository.repositoryId !== input.outputSnapshot.repositoryId) issues.push(`${prefix}.repositoryId must match outputSnapshot.repositoryId`);
|
|
241
|
+
if (signal.worktree.workspaceId !== input.outputSnapshot.workspaceId) issues.push(`${prefix}.workspaceId must match outputSnapshot.workspaceId`);
|
|
242
|
+
if (signal.worktree.headSha !== input.outputSnapshot.headSha) issues.push(`${prefix}.headSha must match outputSnapshot.headSha`);
|
|
243
|
+
if (signal.worktree.worktreeDigest !== input.outputSnapshot.worktreeDigest) issues.push(`${prefix}.worktreeDigest must match outputSnapshot.worktreeDigest`);
|
|
244
|
+
}
|
|
245
|
+
return issues;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Computes the accepted projection receipt. Signal back-references are omitted from the
|
|
250
|
+
* receipt payload to avoid a circular hash; every signal must then bind that receipt via
|
|
251
|
+
* projectionReceiptDigest, which projectionResultInvariantIssues verifies.
|
|
252
|
+
*/
|
|
253
|
+
export function projectionResultReceiptDigest(input: Omit<ProjectionResultV1, "receiptDigest">): Sha256Digest {
|
|
254
|
+
const refreshSignals = input.refreshSignals.map(({ projectionReceiptDigest: _projectionReceiptDigest, ...signal }) => signal);
|
|
255
|
+
return digestJson({ ...input, refreshSignals } as unknown as Json) as Sha256Digest;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function architectureRefreshSignalInvariantIssues(input: ArchitectureRefreshSignalV1, prefix = "signal"): string[] {
|
|
259
|
+
const issues = [
|
|
260
|
+
...sortedUniqueIssues(`${prefix}.reasonCodes`, input.reasonCodes),
|
|
261
|
+
...sortedUniqueIssues(`${prefix}.affectedNodeIds`, input.affectedNodeIds),
|
|
262
|
+
...sortedUniqueIssues(`${prefix}.refreshTargets`, input.refreshTargets)
|
|
263
|
+
];
|
|
264
|
+
if (input.acceptedChange) {
|
|
265
|
+
issues.push(
|
|
266
|
+
...sortedUniqueIssues(`${prefix}.acceptedChange.reasonCodes`, input.acceptedChange.reasonCodes),
|
|
267
|
+
...sortedUniqueIssues(`${prefix}.acceptedChange.affectedNodeIds`, input.acceptedChange.affectedNodeIds)
|
|
268
|
+
);
|
|
269
|
+
if (input.acceptedChange.changeSetId.trim() === "") issues.push(`${prefix}.acceptedChange.changeSetId must not be empty`);
|
|
270
|
+
if (input.acceptedChange.eventId.trim() === "") issues.push(`${prefix}.acceptedChange.eventId must not be empty`);
|
|
271
|
+
if (input.acceptedChange.reasonCodes.join("\u0000") !== input.reasonCodes.join("\u0000")) {
|
|
272
|
+
issues.push(`${prefix}.acceptedChange.reasonCodes must match signal reasonCodes`);
|
|
273
|
+
}
|
|
274
|
+
if (input.acceptedChange.affectedNodeIds.join("\u0000") !== input.affectedNodeIds.join("\u0000")) {
|
|
275
|
+
issues.push(`${prefix}.acceptedChange.affectedNodeIds must match signal affectedNodeIds`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (input.reasonCodes.length === 0) issues.push(`${prefix}.reasonCodes must contain at least one reason`);
|
|
279
|
+
if (input.affectedNodeIds.length === 0) issues.push(`${prefix}.affectedNodeIds must contain at least one node`);
|
|
280
|
+
if (input.refreshTargets.length === 0) issues.push(`${prefix}.refreshTargets must contain at least one target`);
|
|
281
|
+
if (input.cause === "unresolved-major-candidate" && input.mode !== "human-action-required") {
|
|
282
|
+
issues.push(`${prefix}.unresolved-major-candidate requires human-action-required mode`);
|
|
283
|
+
}
|
|
284
|
+
if (input.cause !== "unresolved-major-candidate" && input.mode !== "refresh-required") {
|
|
285
|
+
issues.push(`${prefix}.${input.cause} requires refresh-required mode`);
|
|
286
|
+
}
|
|
287
|
+
if (input.mode === "refresh-required" && !input.acceptedChange) {
|
|
288
|
+
issues.push(`${prefix}.refresh-required requires acceptedChange`);
|
|
289
|
+
}
|
|
290
|
+
if (input.mode === "human-action-required" && input.acceptedChange) {
|
|
291
|
+
issues.push(`${prefix}.human-action-required forbids acceptedChange`);
|
|
292
|
+
}
|
|
293
|
+
return issues;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function sortedUniqueIssues(label: string, values: readonly string[]): string[] {
|
|
297
|
+
const expected = [...new Set(values)].sort();
|
|
298
|
+
return expected.length === values.length && expected.every((value, index) => value === values[index])
|
|
299
|
+
? []
|
|
300
|
+
: [`${label} must be sorted and unique`];
|
|
301
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -36,6 +36,7 @@ export interface ArchContextError {
|
|
|
36
36
|
severity: Severity;
|
|
37
37
|
retryable: boolean;
|
|
38
38
|
action: string;
|
|
39
|
+
reasonCode?: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export interface JsonEnvelope<T extends Json = Json> {
|
|
@@ -71,12 +72,12 @@ export function okEnvelope<T extends Json>(requestId: string, data: T): JsonEnve
|
|
|
71
72
|
return { schemaVersion: "archcontext.envelope/v1", ok: true, requestId, data };
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
export function errorEnvelope(requestId: string, code: ArchContextErrorCode, message: string): JsonEnvelope {
|
|
75
|
+
export function errorEnvelope(requestId: string, code: ArchContextErrorCode, message: string, reasonCode?: string): JsonEnvelope {
|
|
75
76
|
return {
|
|
76
77
|
schemaVersion: "archcontext.envelope/v1",
|
|
77
78
|
ok: false,
|
|
78
79
|
requestId,
|
|
79
|
-
error: { ...ERROR_CATALOG[code], message }
|
|
80
|
+
error: { ...ERROR_CATALOG[code], message, ...(reasonCode ? { reasonCode } : {}) }
|
|
80
81
|
};
|
|
81
82
|
}
|
|
82
83
|
|
package/src/validator.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { canonicalize, type Json } from "./schema";
|
|
2
2
|
|
|
3
3
|
export interface ValidationIssue {
|
|
4
4
|
path: string;
|
|
@@ -11,6 +11,8 @@ export interface ValidationResult {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
type JsonSchema = {
|
|
14
|
+
$ref?: string;
|
|
15
|
+
$defs?: Record<string, JsonSchema>;
|
|
14
16
|
type?: string | string[];
|
|
15
17
|
const?: Json;
|
|
16
18
|
enum?: Json[];
|
|
@@ -19,23 +21,42 @@ type JsonSchema = {
|
|
|
19
21
|
properties?: Record<string, JsonSchema>;
|
|
20
22
|
items?: JsonSchema;
|
|
21
23
|
oneOf?: JsonSchema[];
|
|
24
|
+
anyOf?: JsonSchema[];
|
|
25
|
+
allOf?: JsonSchema[];
|
|
26
|
+
if?: JsonSchema;
|
|
27
|
+
then?: JsonSchema;
|
|
28
|
+
else?: JsonSchema;
|
|
29
|
+
not?: JsonSchema;
|
|
22
30
|
additionalProperties?: boolean | JsonSchema;
|
|
23
31
|
minItems?: number;
|
|
32
|
+
maxItems?: number;
|
|
33
|
+
uniqueItems?: boolean;
|
|
34
|
+
minLength?: number;
|
|
35
|
+
maxLength?: number;
|
|
24
36
|
minimum?: number;
|
|
25
37
|
maximum?: number;
|
|
26
38
|
};
|
|
27
39
|
|
|
28
40
|
export function validateJsonSchema(schema: JsonSchema, value: Json): ValidationResult {
|
|
29
41
|
const issues: ValidationIssue[] = [];
|
|
30
|
-
visit(schema, value, "$", issues);
|
|
42
|
+
visit(schema, value, "$", issues, schema);
|
|
31
43
|
return { valid: issues.length === 0, issues };
|
|
32
44
|
}
|
|
33
45
|
|
|
34
|
-
function visit(schema: JsonSchema, value: Json, path: string, issues: ValidationIssue[]): void {
|
|
46
|
+
function visit(schema: JsonSchema, value: Json, path: string, issues: ValidationIssue[], root: JsonSchema): void {
|
|
47
|
+
if (schema.$ref) {
|
|
48
|
+
if (!schema.$ref.startsWith("#/")) return;
|
|
49
|
+
const resolved = resolveLocalRef(root, schema.$ref);
|
|
50
|
+
if (!resolved) {
|
|
51
|
+
issues.push({ path, message: `unresolved schema reference ${schema.$ref}` });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
visit(resolved, value, path, issues, root);
|
|
55
|
+
}
|
|
35
56
|
if (schema.oneOf) {
|
|
36
57
|
const matched = schema.oneOf.filter((candidate) => {
|
|
37
58
|
const candidateIssues: ValidationIssue[] = [];
|
|
38
|
-
visit(candidate, value, path, candidateIssues);
|
|
59
|
+
visit(candidate, value, path, candidateIssues, root);
|
|
39
60
|
return candidateIssues.length === 0;
|
|
40
61
|
}).length;
|
|
41
62
|
if (matched !== 1) {
|
|
@@ -43,6 +64,26 @@ function visit(schema: JsonSchema, value: Json, path: string, issues: Validation
|
|
|
43
64
|
return;
|
|
44
65
|
}
|
|
45
66
|
}
|
|
67
|
+
if (schema.anyOf) {
|
|
68
|
+
const matched = schema.anyOf.some((candidate) => {
|
|
69
|
+
const candidateIssues: ValidationIssue[] = [];
|
|
70
|
+
visit(candidate, value, path, candidateIssues, root);
|
|
71
|
+
return candidateIssues.length === 0;
|
|
72
|
+
});
|
|
73
|
+
if (!matched) issues.push({ path, message: "expected at least one matching schema" });
|
|
74
|
+
}
|
|
75
|
+
for (const candidate of schema.allOf ?? []) visit(candidate, value, path, issues, root);
|
|
76
|
+
if (schema.if) {
|
|
77
|
+
const conditionIssues: ValidationIssue[] = [];
|
|
78
|
+
visit(schema.if, value, path, conditionIssues, root);
|
|
79
|
+
const branch = conditionIssues.length === 0 ? schema.then : schema.else;
|
|
80
|
+
if (branch) visit(branch, value, path, issues, root);
|
|
81
|
+
}
|
|
82
|
+
if (schema.not) {
|
|
83
|
+
const candidateIssues: ValidationIssue[] = [];
|
|
84
|
+
visit(schema.not, value, path, candidateIssues, root);
|
|
85
|
+
if (candidateIssues.length === 0) issues.push({ path, message: "matched forbidden schema" });
|
|
86
|
+
}
|
|
46
87
|
if (schema.const !== undefined && JSON.stringify(value) !== JSON.stringify(schema.const)) {
|
|
47
88
|
issues.push({ path, message: `expected const ${JSON.stringify(schema.const)}` });
|
|
48
89
|
return;
|
|
@@ -57,6 +98,12 @@ function visit(schema: JsonSchema, value: Json, path: string, issues: Validation
|
|
|
57
98
|
if (typeof value === "string" && schema.pattern && !new RegExp(schema.pattern).test(value)) {
|
|
58
99
|
issues.push({ path, message: `does not match ${schema.pattern}` });
|
|
59
100
|
}
|
|
101
|
+
if (typeof value === "string" && schema.minLength !== undefined && value.length < schema.minLength) {
|
|
102
|
+
issues.push({ path, message: `shorter than minimum length ${schema.minLength}` });
|
|
103
|
+
}
|
|
104
|
+
if (typeof value === "string" && schema.maxLength !== undefined && value.length > schema.maxLength) {
|
|
105
|
+
issues.push({ path, message: `longer than maximum length ${schema.maxLength}` });
|
|
106
|
+
}
|
|
60
107
|
if (typeof value === "number") {
|
|
61
108
|
if (schema.minimum !== undefined && value < schema.minimum) issues.push({ path, message: `below minimum ${schema.minimum}` });
|
|
62
109
|
if (schema.maximum !== undefined && value > schema.maximum) issues.push({ path, message: `above maximum ${schema.maximum}` });
|
|
@@ -65,7 +112,13 @@ function visit(schema: JsonSchema, value: Json, path: string, issues: Validation
|
|
|
65
112
|
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
66
113
|
issues.push({ path, message: `expected at least ${schema.minItems} items` });
|
|
67
114
|
}
|
|
68
|
-
if (schema.
|
|
115
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
|
|
116
|
+
issues.push({ path, message: `expected at most ${schema.maxItems} items` });
|
|
117
|
+
}
|
|
118
|
+
if (schema.uniqueItems && new Set(value.map(canonicalize)).size !== value.length) {
|
|
119
|
+
issues.push({ path, message: "expected unique items" });
|
|
120
|
+
}
|
|
121
|
+
if (schema.items) value.forEach((item, index) => visit(schema.items!, item, `${path}[${index}]`, issues, root));
|
|
69
122
|
}
|
|
70
123
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
71
124
|
const objectValue = value as Record<string, Json>;
|
|
@@ -73,7 +126,7 @@ function visit(schema: JsonSchema, value: Json, path: string, issues: Validation
|
|
|
73
126
|
if (!(key in objectValue)) issues.push({ path: `${path}.${key}`, message: "required" });
|
|
74
127
|
}
|
|
75
128
|
for (const [key, child] of Object.entries(schema.properties ?? {})) {
|
|
76
|
-
if (key in objectValue) visit(child, objectValue[key], `${path}.${key}`, issues);
|
|
129
|
+
if (key in objectValue) visit(child, objectValue[key], `${path}.${key}`, issues, root);
|
|
77
130
|
}
|
|
78
131
|
if (schema.additionalProperties === false && schema.properties) {
|
|
79
132
|
for (const key of Object.keys(objectValue)) {
|
|
@@ -83,6 +136,17 @@ function visit(schema: JsonSchema, value: Json, path: string, issues: Validation
|
|
|
83
136
|
}
|
|
84
137
|
}
|
|
85
138
|
|
|
139
|
+
function resolveLocalRef(root: JsonSchema, ref: string): JsonSchema | undefined {
|
|
140
|
+
if (!ref.startsWith("#/")) return undefined;
|
|
141
|
+
let current: unknown = root;
|
|
142
|
+
for (const rawSegment of ref.slice(2).split("/")) {
|
|
143
|
+
const segment = rawSegment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
144
|
+
if (!current || typeof current !== "object" || Array.isArray(current) || !(segment in current)) return undefined;
|
|
145
|
+
current = (current as Record<string, unknown>)[segment];
|
|
146
|
+
}
|
|
147
|
+
return current && typeof current === "object" && !Array.isArray(current) ? current as JsonSchema : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
86
150
|
function matchesType(type: string | string[], value: Json): boolean {
|
|
87
151
|
const allowed = Array.isArray(type) ? type : [type];
|
|
88
152
|
return allowed.some((candidate) => {
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"schemaVersion": "archcontext.explorer-projection/v1",
|
|
3
|
-
"generatedAt": "2026-06-20T00:00:00.000Z",
|
|
4
|
-
"repository": {
|
|
5
|
-
"repositoryId": "repo.local",
|
|
6
|
-
"headSha": "abc123",
|
|
7
|
-
"worktreeDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
|
8
|
-
},
|
|
9
|
-
"nodes": [],
|
|
10
|
-
"relations": [],
|
|
11
|
-
"verification": [],
|
|
12
|
-
"pressure": [],
|
|
13
|
-
"interventions": [],
|
|
14
|
-
"capabilities": {
|
|
15
|
-
"readOnly": true,
|
|
16
|
-
"mutationMode": "forbidden",
|
|
17
|
-
"egress": "none",
|
|
18
|
-
"tokenRequired": true
|
|
19
|
-
},
|
|
20
|
-
"mutationEndpoint": "/changesets/apply"
|
|
21
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"schemaVersion": "archcontext.explorer-projection/v1",
|
|
3
|
-
"generatedAt": "2026-06-20T00:00:00.000Z",
|
|
4
|
-
"repository": {
|
|
5
|
-
"repositoryId": "repo.local",
|
|
6
|
-
"headSha": "abc123",
|
|
7
|
-
"worktreeDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
|
8
|
-
"modelDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
|
9
|
-
},
|
|
10
|
-
"nodes": [
|
|
11
|
-
{
|
|
12
|
-
"id": "module.runtime-daemon",
|
|
13
|
-
"name": "Runtime Daemon",
|
|
14
|
-
"kind": "module",
|
|
15
|
-
"repositoryId": "repo.local",
|
|
16
|
-
"verificationStatus": "MATCHED",
|
|
17
|
-
"pressure": {
|
|
18
|
-
"level": "low",
|
|
19
|
-
"score": 12,
|
|
20
|
-
"signals": []
|
|
21
|
-
},
|
|
22
|
-
"sourceSelectors": [
|
|
23
|
-
{
|
|
24
|
-
"path": "packages/local-runtime/runtime-daemon/src/index.ts",
|
|
25
|
-
"symbolId": "ArchctxDaemon",
|
|
26
|
-
"startLine": 41,
|
|
27
|
-
"endLine": 300
|
|
28
|
-
}
|
|
29
|
-
]
|
|
30
|
-
}
|
|
31
|
-
],
|
|
32
|
-
"relations": [
|
|
33
|
-
{
|
|
34
|
-
"id": "relation.cli-runtime",
|
|
35
|
-
"source": "module.cli",
|
|
36
|
-
"target": "module.runtime-daemon",
|
|
37
|
-
"kind": "uses",
|
|
38
|
-
"verificationStatus": "MATCHED"
|
|
39
|
-
}
|
|
40
|
-
],
|
|
41
|
-
"landscape": {
|
|
42
|
-
"repositories": ["repo.local"]
|
|
43
|
-
},
|
|
44
|
-
"verification": [],
|
|
45
|
-
"pressure": [],
|
|
46
|
-
"interventions": [],
|
|
47
|
-
"capabilities": {
|
|
48
|
-
"readOnly": true,
|
|
49
|
-
"mutationMode": "forbidden",
|
|
50
|
-
"egress": "none",
|
|
51
|
-
"tokenRequired": true
|
|
52
|
-
}
|
|
53
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
-
"$id": "https://archctx.repoharness.com/schemas/runtime/explorer-projection.schema.json",
|
|
4
|
-
"title": "ExplorerProjection",
|
|
5
|
-
"type": "object",
|
|
6
|
-
"additionalProperties": false,
|
|
7
|
-
"required": ["schemaVersion", "generatedAt", "repository", "nodes", "relations", "verification", "pressure", "interventions", "capabilities"],
|
|
8
|
-
"properties": {
|
|
9
|
-
"schemaVersion": { "const": "archcontext.explorer-projection/v1" },
|
|
10
|
-
"generatedAt": { "type": "string" },
|
|
11
|
-
"repository": {
|
|
12
|
-
"type": "object",
|
|
13
|
-
"additionalProperties": false,
|
|
14
|
-
"required": ["repositoryId", "headSha", "worktreeDigest"],
|
|
15
|
-
"properties": {
|
|
16
|
-
"repositoryId": { "type": "string" },
|
|
17
|
-
"headSha": { "type": "string" },
|
|
18
|
-
"worktreeDigest": { "type": "string" },
|
|
19
|
-
"modelDigest": { "type": "string" }
|
|
20
|
-
}
|
|
21
|
-
},
|
|
22
|
-
"nodes": {
|
|
23
|
-
"type": "array",
|
|
24
|
-
"items": {
|
|
25
|
-
"type": "object",
|
|
26
|
-
"additionalProperties": false,
|
|
27
|
-
"required": ["id", "name", "kind", "verificationStatus", "pressure", "sourceSelectors"],
|
|
28
|
-
"properties": {
|
|
29
|
-
"id": { "type": "string" },
|
|
30
|
-
"name": { "type": "string" },
|
|
31
|
-
"kind": { "type": "string" },
|
|
32
|
-
"repositoryId": { "type": "string" },
|
|
33
|
-
"verificationStatus": { "type": "string", "enum": ["MATCHED", "DRIFT", "UNKNOWN", "VERIFIED"] },
|
|
34
|
-
"pressure": {
|
|
35
|
-
"type": "object",
|
|
36
|
-
"additionalProperties": false,
|
|
37
|
-
"required": ["level", "score", "signals"],
|
|
38
|
-
"properties": {
|
|
39
|
-
"level": { "type": "string", "enum": ["low", "medium", "high"] },
|
|
40
|
-
"score": { "type": "number", "minimum": 0, "maximum": 100 },
|
|
41
|
-
"signals": { "type": "array", "items": { "type": "string" } }
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
"sourceSelectors": {
|
|
45
|
-
"type": "array",
|
|
46
|
-
"items": {
|
|
47
|
-
"type": "object",
|
|
48
|
-
"additionalProperties": false,
|
|
49
|
-
"required": ["path"],
|
|
50
|
-
"properties": {
|
|
51
|
-
"path": { "type": "string" },
|
|
52
|
-
"symbolId": { "type": "string" },
|
|
53
|
-
"startLine": { "type": "integer", "minimum": 1 },
|
|
54
|
-
"endLine": { "type": "integer", "minimum": 1 }
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
},
|
|
61
|
-
"relations": {
|
|
62
|
-
"type": "array",
|
|
63
|
-
"items": {
|
|
64
|
-
"type": "object",
|
|
65
|
-
"additionalProperties": false,
|
|
66
|
-
"required": ["id", "source", "target", "kind", "verificationStatus"],
|
|
67
|
-
"properties": {
|
|
68
|
-
"id": { "type": "string" },
|
|
69
|
-
"source": { "type": "string" },
|
|
70
|
-
"target": { "type": "string" },
|
|
71
|
-
"kind": { "type": "string" },
|
|
72
|
-
"verificationStatus": { "type": "string", "enum": ["MATCHED", "DRIFT", "UNKNOWN", "VERIFIED"] }
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
},
|
|
76
|
-
"landscape": { "type": "object", "additionalProperties": true },
|
|
77
|
-
"verification": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
|
|
78
|
-
"pressure": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
|
|
79
|
-
"interventions": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
|
|
80
|
-
"capabilities": {
|
|
81
|
-
"type": "object",
|
|
82
|
-
"additionalProperties": false,
|
|
83
|
-
"required": ["readOnly", "mutationMode", "egress", "tokenRequired"],
|
|
84
|
-
"properties": {
|
|
85
|
-
"readOnly": { "const": true },
|
|
86
|
-
"mutationMode": { "type": "string", "enum": ["forbidden"] },
|
|
87
|
-
"egress": { "type": "string", "enum": ["none"] },
|
|
88
|
-
"tokenRequired": { "type": "boolean" }
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|