@open-agent-toolkit/cli 0.2.25 → 0.2.27
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/assets/NOTICES.md +156 -0
- package/assets/docs/cli-utilities/configuration.md +42 -11
- package/assets/docs/contributing/explainer-kit-verification.md +125 -0
- package/assets/docs/contributing/index.md +1 -0
- package/assets/docs/reference/troubleshooting.md +47 -0
- package/assets/docs/workflows/projects/artifacts.md +24 -6
- package/assets/docs/workflows/projects/dispatch-ceiling.md +67 -16
- package/assets/docs/workflows/skills/explainer-kit-providers.md +144 -0
- package/assets/docs/workflows/skills/explainer-kit.md +121 -69
- package/assets/docs/workflows/skills/index.md +1 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +18 -3
- package/assets/skills/explainer-kit/recipes/project-recap.json +43 -16
- package/assets/skills/explainer-kit/references/contracts.md +167 -20
- package/assets/skills/explainer-kit/references/golden-conformance.md +80 -0
- package/assets/skills/explainer-kit/references/visual-authoring.md +92 -0
- package/assets/skills/explainer-kit/references/visual-review.md +57 -0
- package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +172 -1
- package/assets/skills/explainer-kit/schemas/build-record.schema.json +7 -1
- package/assets/skills/explainer-kit/schemas/fact-base.schema.json +38 -2
- package/assets/skills/explainer-kit/schemas/manifest.schema.json +25 -1
- package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -0
- package/assets/skills/explainer-kit/schemas/set-plan.v1.schema.json +149 -0
- package/assets/skills/explainer-kit/schemas/visual-review-request.v1.schema.json +117 -0
- package/assets/skills/explainer-kit/schemas/visual-review-result.v1.schema.json +80 -0
- package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +148 -4
- package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +243 -0
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +586 -8
- package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +285 -8
- package/assets/skills/explainer-kit/scripts/lib/durability.mjs +35 -0
- package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +144 -8
- package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +379 -0
- package/assets/skills/explainer-kit/scripts/lib/png.mjs +287 -0
- package/assets/skills/explainer-kit/scripts/lib/qa.mjs +280 -8
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +132 -3
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +513 -21
- package/assets/skills/explainer-kit/scripts/lib/render.mjs +67 -3
- package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +43 -1
- package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +208 -0
- package/assets/skills/explainer-kit/scripts/lib/source-backlinks.mjs +218 -0
- package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +380 -0
- package/assets/skills/explainer-kit/scripts/render-qa.mjs +48 -10
- package/assets/skills/explainer-kit/scripts/run.mjs +859 -134
- package/assets/skills/oat-explainer-kit/SKILL.md +40 -12
- package/assets/skills/oat-explainer-kit/references/author-callback.md +12 -10
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +40 -2
- package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +72 -0
- package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +167 -5
- package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +92 -5
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +324 -2
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-implement/SKILL.md +9 -11
- package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +18 -9
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-implement/references/phase-execution.md +13 -4
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +27 -3
- package/dist/commands/project/archive/archive-utils.d.ts +1 -0
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +109 -42
- package/dist/commands/project/archive/explainer-package-coverage.d.ts +14 -0
- package/dist/commands/project/archive/explainer-package-coverage.d.ts.map +1 -0
- package/dist/commands/project/archive/explainer-package-coverage.js +27 -0
- package/dist/commands/project/archive/explainer-source-backlinks.d.ts +18 -0
- package/dist/commands/project/archive/explainer-source-backlinks.d.ts.map +1 -0
- package/dist/commands/project/archive/explainer-source-backlinks.js +27 -0
- package/dist/commands/project/archive/push-runner.d.ts +2 -1
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +5 -1
- package/dist/commands/project/dispatch-ceiling/index.d.ts.map +1 -1
- package/dist/commands/project/dispatch-ceiling/index.js +90 -0
- package/dist/config/dispatch-notices.d.ts +8 -0
- package/dist/config/dispatch-notices.d.ts.map +1 -0
- package/dist/config/dispatch-notices.js +79 -0
- package/dist/config/dispatch-policy-options.d.ts +2 -0
- package/dist/config/dispatch-policy-options.d.ts.map +1 -1
- package/dist/config/dispatch-policy-options.js +14 -2
- package/dist/providers/identity/dispatch-report.d.ts +17 -0
- package/dist/providers/identity/dispatch-report.d.ts.map +1 -1
- package/dist/providers/identity/dispatch-report.js +30 -0
- package/dist/release/public-package-contract.d.ts +6 -0
- package/dist/release/public-package-contract.d.ts.map +1 -1
- package/dist/release/public-package-contract.js +75 -0
- package/package.json +2 -2
|
@@ -68,11 +68,14 @@ export function parseDiagram(source) {
|
|
|
68
68
|
return degraded('Diagram requires at least one node declaration or edge.');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
const topology = analyzeTopology([...nodes.values()], edges);
|
|
71
72
|
return {
|
|
72
73
|
valid: true,
|
|
73
74
|
direction: headerMatch[1],
|
|
74
75
|
nodes: [...nodes.values()],
|
|
75
76
|
edges,
|
|
77
|
+
inlineSupported: topology.kind === 'linear',
|
|
78
|
+
topology,
|
|
76
79
|
warnings: [],
|
|
77
80
|
};
|
|
78
81
|
}
|
|
@@ -86,14 +89,39 @@ export function renderDiagram(source, { theme } = {}) {
|
|
|
86
89
|
degraded: true,
|
|
87
90
|
};
|
|
88
91
|
}
|
|
92
|
+
if (!parsed.inlineSupported) {
|
|
93
|
+
const features = parsed.topology.features.join(', ');
|
|
94
|
+
return {
|
|
95
|
+
html: `<div class="diagram-fallback" role="note"><p class="diagram-warning">${escapeHtml(`Non-linear diagram topology (${features}) requires artistic composition.`)}</p><pre><code>${escapeHtml(source)}</code></pre></div>`,
|
|
96
|
+
warnings: [
|
|
97
|
+
{
|
|
98
|
+
code: 'non-linear-diagram-reroute',
|
|
99
|
+
message: `Non-linear diagram topology (${features}) requires artistic composition.`,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
degraded: true,
|
|
103
|
+
reroute: {
|
|
104
|
+
target: 'artistic',
|
|
105
|
+
source,
|
|
106
|
+
graph: {
|
|
107
|
+
direction: parsed.direction,
|
|
108
|
+
nodes: structuredClone(parsed.nodes),
|
|
109
|
+
edges: structuredClone(parsed.edges),
|
|
110
|
+
},
|
|
111
|
+
topology: structuredClone(parsed.topology),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
89
115
|
|
|
90
116
|
const horizontal = parsed.direction === 'LR';
|
|
91
117
|
const nodeWidth = 180;
|
|
92
118
|
const nodeHeight = 72;
|
|
93
119
|
const gap = 90;
|
|
94
120
|
const margin = 50;
|
|
121
|
+
const nodeById = new Map(parsed.nodes.map((node) => [node.id, node]));
|
|
122
|
+
const orderedNodes = parsed.topology.order.map((id) => nodeById.get(id));
|
|
95
123
|
const positions = new Map(
|
|
96
|
-
|
|
124
|
+
orderedNodes.map((node, index) => [
|
|
97
125
|
node.id,
|
|
98
126
|
horizontal
|
|
99
127
|
? { x: margin + index * (nodeWidth + gap), y: margin }
|
|
@@ -102,14 +130,14 @@ export function renderDiagram(source, { theme } = {}) {
|
|
|
102
130
|
);
|
|
103
131
|
const width = horizontal
|
|
104
132
|
? margin * 2 +
|
|
105
|
-
|
|
106
|
-
(
|
|
133
|
+
orderedNodes.length * nodeWidth +
|
|
134
|
+
(orderedNodes.length - 1) * gap
|
|
107
135
|
: margin * 2 + nodeWidth;
|
|
108
136
|
const height = horizontal
|
|
109
137
|
? margin * 2 + nodeHeight
|
|
110
138
|
: margin * 2 +
|
|
111
|
-
|
|
112
|
-
(
|
|
139
|
+
orderedNodes.length * nodeHeight +
|
|
140
|
+
(orderedNodes.length - 1) * gap;
|
|
113
141
|
const markerId = `diagram-arrow-${stableHash(source)}`;
|
|
114
142
|
const mode = theme?.modes?.[theme.defaultMode];
|
|
115
143
|
const panel = mode?.surface?.panel ?? '#ffffff';
|
|
@@ -126,7 +154,7 @@ export function renderDiagram(source, { theme } = {}) {
|
|
|
126
154
|
}),
|
|
127
155
|
)
|
|
128
156
|
.join('');
|
|
129
|
-
const nodes =
|
|
157
|
+
const nodes = orderedNodes
|
|
130
158
|
.map((node) =>
|
|
131
159
|
renderNode(node, positions.get(node.id), { nodeWidth, nodeHeight }),
|
|
132
160
|
)
|
|
@@ -144,6 +172,206 @@ export function renderDiagram(source, { theme } = {}) {
|
|
|
144
172
|
};
|
|
145
173
|
}
|
|
146
174
|
|
|
175
|
+
export function graphSemanticsForArtisticAuthor(diagrams) {
|
|
176
|
+
if (
|
|
177
|
+
!Array.isArray(diagrams) ||
|
|
178
|
+
diagrams.some(({ valid }) => valid !== true)
|
|
179
|
+
) {
|
|
180
|
+
throw topologyError(
|
|
181
|
+
'Planner-owned artistic diagrams must use the supported graph grammar.',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return diagrams
|
|
185
|
+
.filter(({ inlineSupported }) => inlineSupported === false)
|
|
186
|
+
.map(({ direction, nodes, edges, topology }) =>
|
|
187
|
+
deepFreeze({
|
|
188
|
+
direction,
|
|
189
|
+
nodes: structuredClone(nodes),
|
|
190
|
+
edges: structuredClone(edges),
|
|
191
|
+
topology: structuredClone(topology),
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function assertAuthoredGraphSemantics(html, graphSemantics) {
|
|
197
|
+
if (
|
|
198
|
+
typeof html !== 'string' ||
|
|
199
|
+
!Array.isArray(graphSemantics) ||
|
|
200
|
+
graphSemantics.length !== 1
|
|
201
|
+
) {
|
|
202
|
+
throw topologyError(
|
|
203
|
+
'Artistic graph validation requires one unambiguous planned graph.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const [planned] = graphSemantics;
|
|
207
|
+
const tags = html.match(/<[^>]+>/g) ?? [];
|
|
208
|
+
const directionTags = tags.filter((tag) => tag.includes('data-direction'));
|
|
209
|
+
const observedDirections = directionTags.flatMap((tag) =>
|
|
210
|
+
attributeValues(tag, 'data-direction'),
|
|
211
|
+
);
|
|
212
|
+
if (
|
|
213
|
+
directionTags.length !== 1 ||
|
|
214
|
+
observedDirections.length !== 1 ||
|
|
215
|
+
observedDirections[0] !== planned.direction
|
|
216
|
+
) {
|
|
217
|
+
throw topologyError(
|
|
218
|
+
'Authored graph direction does not exactly match the planned graph.',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const nodeTags = tags.filter((tag) => tag.includes('data-node'));
|
|
223
|
+
const observedNodes = nodeTags.map((tag) => {
|
|
224
|
+
const id = canonicalAttribute(tag, 'data-node', {
|
|
225
|
+
pattern: new RegExp(`^${ID_PATTERN}$`),
|
|
226
|
+
});
|
|
227
|
+
const label = canonicalAttribute(tag, 'data-node-label');
|
|
228
|
+
const shape = canonicalAttribute(tag, 'data-node-shape', {
|
|
229
|
+
allowed: ['rectangle', 'rounded', 'diamond'],
|
|
230
|
+
});
|
|
231
|
+
const explicit = canonicalAttribute(tag, 'data-node-explicit', {
|
|
232
|
+
allowed: ['true', 'false'],
|
|
233
|
+
});
|
|
234
|
+
return canonicalTuple([id, label, shape, explicit]);
|
|
235
|
+
});
|
|
236
|
+
const edgeTags = tags.filter((tag) =>
|
|
237
|
+
['data-from', 'data-to', 'data-edge-kind', 'data-edge-label'].some((name) =>
|
|
238
|
+
tag.includes(name),
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
const observedEdges = edgeTags.map((tag) => {
|
|
242
|
+
const from = canonicalAttribute(tag, 'data-from', {
|
|
243
|
+
pattern: new RegExp(`^${ID_PATTERN}$`),
|
|
244
|
+
});
|
|
245
|
+
const to = canonicalAttribute(tag, 'data-to', {
|
|
246
|
+
pattern: new RegExp(`^${ID_PATTERN}$`),
|
|
247
|
+
});
|
|
248
|
+
const kind = canonicalAttribute(tag, 'data-edge-kind', {
|
|
249
|
+
allowed: ['arrow', 'line'],
|
|
250
|
+
});
|
|
251
|
+
const label = canonicalAttribute(tag, 'data-edge-label');
|
|
252
|
+
return canonicalTuple([from, to, kind, label]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const expectedNodes = planned.nodes.map(({ id, label, shape, explicit }) =>
|
|
256
|
+
canonicalTuple([id, escapeAttribute(label), shape, String(explicit)]),
|
|
257
|
+
);
|
|
258
|
+
const expectedEdges = planned.edges.map(({ from, to, kind, label }) =>
|
|
259
|
+
canonicalTuple([from, to, kind, escapeAttribute(label)]),
|
|
260
|
+
);
|
|
261
|
+
if (
|
|
262
|
+
!sameMultiset(observedNodes, expectedNodes) ||
|
|
263
|
+
!sameMultiset(observedEdges, expectedEdges)
|
|
264
|
+
) {
|
|
265
|
+
throw topologyError(
|
|
266
|
+
'Authored graph node or edge multiset does not exactly match the planned graph.',
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function analyzeTopology(nodes, edges) {
|
|
272
|
+
const nodeIds = nodes.map(({ id }) => id);
|
|
273
|
+
const incoming = new Map(nodeIds.map((id) => [id, []]));
|
|
274
|
+
const outgoing = new Map(nodeIds.map((id) => [id, []]));
|
|
275
|
+
for (const edge of edges) {
|
|
276
|
+
outgoing.get(edge.from).push(edge.to);
|
|
277
|
+
incoming.get(edge.to).push(edge.from);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const branchNodes = nodeIds.filter((id) => outgoing.get(id).length > 1);
|
|
281
|
+
const fanInNodes = nodeIds.filter((id) => incoming.get(id).length > 1);
|
|
282
|
+
const cycle = hasDirectedCycle(nodeIds, outgoing);
|
|
283
|
+
const connected = isWeaklyConnected(nodeIds, incoming, outgoing);
|
|
284
|
+
const features = [
|
|
285
|
+
...(branchNodes.length > 0 ? ['branch'] : []),
|
|
286
|
+
...(fanInNodes.length > 0 ? ['fan-in'] : []),
|
|
287
|
+
...(cycle ? ['cycle'] : []),
|
|
288
|
+
...(!connected ? ['disconnected'] : []),
|
|
289
|
+
];
|
|
290
|
+
const order =
|
|
291
|
+
features.length === 0 ? linearOrder(nodeIds, incoming, outgoing) : [];
|
|
292
|
+
if (features.length === 0 && order.length !== nodeIds.length) {
|
|
293
|
+
features.push('non-linear');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
kind: features.length === 0 ? 'linear' : 'non-linear',
|
|
298
|
+
features,
|
|
299
|
+
branchNodes,
|
|
300
|
+
fanInNodes,
|
|
301
|
+
cycle,
|
|
302
|
+
order: features.length === 0 ? order : [],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function hasDirectedCycle(nodeIds, outgoing) {
|
|
307
|
+
const visiting = new Set();
|
|
308
|
+
const visited = new Set();
|
|
309
|
+
const visit = (id) => {
|
|
310
|
+
if (visiting.has(id)) return true;
|
|
311
|
+
if (visited.has(id)) return false;
|
|
312
|
+
visiting.add(id);
|
|
313
|
+
for (const next of outgoing.get(id)) {
|
|
314
|
+
if (visit(next)) return true;
|
|
315
|
+
}
|
|
316
|
+
visiting.delete(id);
|
|
317
|
+
visited.add(id);
|
|
318
|
+
return false;
|
|
319
|
+
};
|
|
320
|
+
return nodeIds.some((id) => visit(id));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isWeaklyConnected(nodeIds, incoming, outgoing) {
|
|
324
|
+
if (nodeIds.length <= 1) return true;
|
|
325
|
+
const seen = new Set();
|
|
326
|
+
const pending = [nodeIds[0]];
|
|
327
|
+
while (pending.length > 0) {
|
|
328
|
+
const id = pending.pop();
|
|
329
|
+
if (seen.has(id)) continue;
|
|
330
|
+
seen.add(id);
|
|
331
|
+
pending.push(...incoming.get(id), ...outgoing.get(id));
|
|
332
|
+
}
|
|
333
|
+
return seen.size === nodeIds.length;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function linearOrder(nodeIds, incoming, outgoing) {
|
|
337
|
+
if (nodeIds.length === 1 && incoming.get(nodeIds[0]).length === 0) {
|
|
338
|
+
return [...nodeIds];
|
|
339
|
+
}
|
|
340
|
+
const starts = nodeIds.filter(
|
|
341
|
+
(id) => incoming.get(id).length === 0 && outgoing.get(id).length === 1,
|
|
342
|
+
);
|
|
343
|
+
const ends = nodeIds.filter(
|
|
344
|
+
(id) => incoming.get(id).length === 1 && outgoing.get(id).length === 0,
|
|
345
|
+
);
|
|
346
|
+
const middleIsLinear = nodeIds
|
|
347
|
+
.filter((id) => !starts.includes(id) && !ends.includes(id))
|
|
348
|
+
.every(
|
|
349
|
+
(id) => incoming.get(id).length === 1 && outgoing.get(id).length === 1,
|
|
350
|
+
);
|
|
351
|
+
if (
|
|
352
|
+
starts.length !== 1 ||
|
|
353
|
+
ends.length !== 1 ||
|
|
354
|
+
!middleIsLinear ||
|
|
355
|
+
edgesFor(outgoing) !== nodeIds.length - 1
|
|
356
|
+
) {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
const order = [];
|
|
360
|
+
let current = starts[0];
|
|
361
|
+
while (current !== undefined && !order.includes(current)) {
|
|
362
|
+
order.push(current);
|
|
363
|
+
current = outgoing.get(current)[0];
|
|
364
|
+
}
|
|
365
|
+
return order;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function edgesFor(outgoing) {
|
|
369
|
+
return [...outgoing.values()].reduce(
|
|
370
|
+
(total, targets) => total + targets.length,
|
|
371
|
+
0,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
147
375
|
function parseNode(value) {
|
|
148
376
|
const match = value.match(NODE_PATTERN);
|
|
149
377
|
if (!match) return null;
|
|
@@ -176,7 +404,7 @@ function renderNode(node, position, { nodeWidth, nodeHeight }) {
|
|
|
176
404
|
} else {
|
|
177
405
|
shape = `<rect class="diagram-node-shape" x="${position.x}" y="${position.y}" width="${nodeWidth}" height="${nodeHeight}"${node.shape === 'rounded' ? ' rx="24"' : ''}></rect>`;
|
|
178
406
|
}
|
|
179
|
-
return `<g class="diagram-node" data-node="${escapeAttribute(node.id)}">${shape}<text class="diagram-node-label" x="${centerX}" y="${centerY}">${escapeHtml(node.label)}</text></g>`;
|
|
407
|
+
return `<g class="diagram-node" data-node="${escapeAttribute(node.id)}" data-node-label="${escapeAttribute(node.label)}" data-node-shape="${node.shape}" data-node-explicit="${String(node.explicit)}">${shape}<text class="diagram-node-label" x="${centerX}" y="${centerY}">${escapeHtml(node.label)}</text></g>`;
|
|
180
408
|
}
|
|
181
409
|
|
|
182
410
|
function renderEdge(
|
|
@@ -195,7 +423,7 @@ function renderEdge(
|
|
|
195
423
|
const label = edge.label
|
|
196
424
|
? `<text class="diagram-edge-label" x="${(start.x + end.x) / 2}" y="${(start.y + end.y) / 2 - 8}">${escapeHtml(edge.label)}</text>`
|
|
197
425
|
: '';
|
|
198
|
-
return `<g class="diagram-connection" data-from="${escapeAttribute(edge.from)}" data-to="${escapeAttribute(edge.to)}"><path class="diagram-edge" d="M ${start.x} ${start.y} L ${end.x} ${end.y}"${edge.kind === 'arrow' ? ` marker-end="url(#${markerId})"` : ''}></path>${label}</g>`;
|
|
426
|
+
return `<g class="diagram-connection" data-from="${escapeAttribute(edge.from)}" data-to="${escapeAttribute(edge.to)}" data-edge-kind="${edge.kind}" data-edge-label="${escapeAttribute(edge.label)}"><path class="diagram-edge" d="M ${start.x} ${start.y} L ${end.x} ${end.y}"${edge.kind === 'arrow' ? ` marker-end="url(#${markerId})"` : ''}></path>${label}</g>`;
|
|
199
427
|
}
|
|
200
428
|
|
|
201
429
|
function degraded(message) {
|
|
@@ -223,6 +451,55 @@ function stableHash(value) {
|
|
|
223
451
|
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
224
452
|
}
|
|
225
453
|
|
|
454
|
+
function attributeValues(tag, name) {
|
|
455
|
+
return [
|
|
456
|
+
...tag.matchAll(
|
|
457
|
+
new RegExp(`(?:^|\\s)${name}\\s*=\\s*(["'])([^"'<>]*)\\1`, 'g'),
|
|
458
|
+
),
|
|
459
|
+
].map((match) => match[2]);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function canonicalAttribute(tag, name, { pattern, allowed } = {}) {
|
|
463
|
+
const values = attributeValues(tag, name);
|
|
464
|
+
if (
|
|
465
|
+
values.length !== 1 ||
|
|
466
|
+
(pattern && !pattern.test(values[0])) ||
|
|
467
|
+
(allowed && !allowed.includes(values[0]))
|
|
468
|
+
) {
|
|
469
|
+
throw topologyError(
|
|
470
|
+
`Authored graph contains a malformed or ambiguous ${name} observation.`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
return values[0];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function canonicalTuple(values) {
|
|
477
|
+
return JSON.stringify(values);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function sameMultiset(actual, expected) {
|
|
481
|
+
return (
|
|
482
|
+
actual.length === expected.length &&
|
|
483
|
+
[...actual]
|
|
484
|
+
.sort()
|
|
485
|
+
.every((value, index) => value === [...expected].sort()[index])
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function topologyError(message) {
|
|
490
|
+
const error = new Error(message);
|
|
491
|
+
error.code = 'E_DIAGRAM_TOPOLOGY';
|
|
492
|
+
return error;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function deepFreeze(value) {
|
|
496
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
497
|
+
Object.freeze(value);
|
|
498
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
499
|
+
}
|
|
500
|
+
return value;
|
|
501
|
+
}
|
|
502
|
+
|
|
226
503
|
function escapeHtml(value) {
|
|
227
504
|
return String(value)
|
|
228
505
|
.replaceAll('&', '&')
|
|
@@ -27,6 +27,11 @@ export async function recordDurability(request, options = {}) {
|
|
|
27
27
|
const buildRecord = await readJson(buildRecordPath);
|
|
28
28
|
assertValid('build-record', buildRecord);
|
|
29
29
|
assertValid('manifest', manifest, { buildRecord });
|
|
30
|
+
if (manifest.outcome === 'built-needs-review') {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'built-needs-review requires a passing visual review before durability attestation.',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
30
35
|
if (
|
|
31
36
|
manifest.outcome !== 'built-not-durable' &&
|
|
32
37
|
manifest.outcome !== 'built-durable'
|
|
@@ -175,6 +180,13 @@ export async function verifyRebuildability(artifact, runRoot) {
|
|
|
175
180
|
}
|
|
176
181
|
|
|
177
182
|
async function verifyEvidence(evidence, context) {
|
|
183
|
+
const immutableErrors = await verifyImmutablePackage(
|
|
184
|
+
context.runRoot,
|
|
185
|
+
context.manifest,
|
|
186
|
+
);
|
|
187
|
+
if (immutableErrors.length > 0) {
|
|
188
|
+
return { verified: false, errors: immutableErrors };
|
|
189
|
+
}
|
|
178
190
|
const replayErrors = [];
|
|
179
191
|
for (const artifact of context.manifest.artifacts) {
|
|
180
192
|
if (artifact.status === 'built' && artifact.rebuildable === true) {
|
|
@@ -193,6 +205,29 @@ async function verifyEvidence(evidence, context) {
|
|
|
193
205
|
: verifyPublishEvidence(evidence, context);
|
|
194
206
|
}
|
|
195
207
|
|
|
208
|
+
async function verifyImmutablePackage(runRoot, manifest) {
|
|
209
|
+
const errors = [];
|
|
210
|
+
for (const [path, expectedHash] of Object.entries(manifest.immutableHashes)) {
|
|
211
|
+
try {
|
|
212
|
+
const bytes = await readFile(joinWithin(runRoot, path));
|
|
213
|
+
const actualHash = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
214
|
+
if (actualHash !== expectedHash) {
|
|
215
|
+
errors.push(
|
|
216
|
+
error('hash-mismatch', `Immutable package hash changed for ${path}.`),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
} catch (caught) {
|
|
220
|
+
errors.push(
|
|
221
|
+
error(
|
|
222
|
+
'missing-path',
|
|
223
|
+
`Immutable package evidence ${path} is unavailable: ${errorMessage(caught)}`,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return errors;
|
|
229
|
+
}
|
|
230
|
+
|
|
196
231
|
async function verifyCommitEvidence(evidence, { runRoot, manifest }) {
|
|
197
232
|
const errors = [];
|
|
198
233
|
let repoRoot;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { canonicalHash, validateContract } from './contracts.mjs';
|
|
2
|
+
import {
|
|
3
|
+
canonicalGithubBlobBacklink,
|
|
4
|
+
validateCanonicalGithubBlobTuple,
|
|
5
|
+
} from './source-backlinks.mjs';
|
|
2
6
|
|
|
3
7
|
const FACT_BASE_VERSION = 'explainer-kit.fact-base/v1';
|
|
4
8
|
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
@@ -31,7 +35,9 @@ export async function processFactBase(binding, options = {}) {
|
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
function processSupplied(binding, { now, maxAgeMs }) {
|
|
34
|
-
const factBase =
|
|
38
|
+
const factBase = normalizeFactBaseBacklinks(
|
|
39
|
+
structuredClone(binding.factBase),
|
|
40
|
+
);
|
|
35
41
|
if (!factBase || typeof factBase !== 'object') {
|
|
36
42
|
throw new Error('Supplied mode requires a factBase object.');
|
|
37
43
|
}
|
|
@@ -87,7 +93,9 @@ async function processFederated(binding, { critic, now }) {
|
|
|
87
93
|
);
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
const sources = documents.map(({ source }) =>
|
|
96
|
+
const sources = documents.map(({ source }) =>
|
|
97
|
+
normalizeSourceBacklink(structuredClone(source)),
|
|
98
|
+
);
|
|
91
99
|
assertUniqueNonEmptyIds(sources, 'source');
|
|
92
100
|
const sourceById = new Map(sources.map((source) => [source.id, source]));
|
|
93
101
|
const observations = collectObservations(documents, sourceById);
|
|
@@ -266,6 +274,7 @@ function collectObservations(documents, sourceById) {
|
|
|
266
274
|
text: claim.text,
|
|
267
275
|
source: sourceById.get(document.source.id),
|
|
268
276
|
locator: claim.locator ?? document.source.locator,
|
|
277
|
+
...(claim.lineRange && { lineRange: structuredClone(claim.lineRange) }),
|
|
269
278
|
...(claim.sections && { sections: [...claim.sections] }),
|
|
270
279
|
});
|
|
271
280
|
observations.set(claim.id, entries);
|
|
@@ -366,10 +375,9 @@ function resolveObservations(claimId, entries) {
|
|
|
366
375
|
}
|
|
367
376
|
|
|
368
377
|
function citationsFor(entries) {
|
|
369
|
-
const citations = entries.map(({ source, locator }) =>
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}));
|
|
378
|
+
const citations = entries.map(({ source, locator, lineRange }) =>
|
|
379
|
+
citationForSource(source, { locator, lineRange }),
|
|
380
|
+
);
|
|
373
381
|
return uniqueCitations(citations);
|
|
374
382
|
}
|
|
375
383
|
|
|
@@ -432,8 +440,7 @@ function integrateCriticFindings({
|
|
|
432
440
|
locator: `critic-finding:${finding.claimId}`,
|
|
433
441
|
},
|
|
434
442
|
...finding.sourceIds.map((sourceId) => ({
|
|
435
|
-
sourceId,
|
|
436
|
-
locator: sourceById.get(sourceId).locator,
|
|
443
|
+
...citationForSource(sourceById.get(sourceId)),
|
|
437
444
|
})),
|
|
438
445
|
]);
|
|
439
446
|
const existingUnresolved = unresolvedClaims.find(
|
|
@@ -500,6 +507,135 @@ function uniqueCitations(citations) {
|
|
|
500
507
|
];
|
|
501
508
|
}
|
|
502
509
|
|
|
510
|
+
function normalizeFactBaseBacklinks(factBase) {
|
|
511
|
+
if (!factBase || typeof factBase !== 'object') return factBase;
|
|
512
|
+
if (
|
|
513
|
+
!Array.isArray(factBase.sources) ||
|
|
514
|
+
!Array.isArray(factBase.claims) ||
|
|
515
|
+
!Array.isArray(factBase.unresolvedClaims)
|
|
516
|
+
) {
|
|
517
|
+
return factBase;
|
|
518
|
+
}
|
|
519
|
+
factBase.sources = factBase.sources.map((source) =>
|
|
520
|
+
source && typeof source === 'object'
|
|
521
|
+
? normalizeSourceBacklink(source)
|
|
522
|
+
: source,
|
|
523
|
+
);
|
|
524
|
+
const sourceById = new Map(
|
|
525
|
+
factBase.sources
|
|
526
|
+
.filter((source) => source && typeof source === 'object')
|
|
527
|
+
.map((source) => [source.id, source]),
|
|
528
|
+
);
|
|
529
|
+
for (const claim of [...factBase.claims, ...factBase.unresolvedClaims]) {
|
|
530
|
+
if (
|
|
531
|
+
!claim ||
|
|
532
|
+
typeof claim !== 'object' ||
|
|
533
|
+
!Array.isArray(claim.citations)
|
|
534
|
+
) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
claim.citations = claim.citations.map((citation) => {
|
|
538
|
+
if (!citation || typeof citation !== 'object') return citation;
|
|
539
|
+
const source = sourceById.get(citation.sourceId);
|
|
540
|
+
return source ? citationForSource(source, citation) : citation;
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return factBase;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function normalizeSourceBacklink(source) {
|
|
547
|
+
const declaresBacklink = declaresBacklinkTuple(source);
|
|
548
|
+
if (!declaresBacklink) return source;
|
|
549
|
+
const url = canonicalGithubBlobBacklink(source);
|
|
550
|
+
if (source.url !== undefined && source.url !== url) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
'GitHub backlink provenance URL does not match its canonical tuple.',
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
...source,
|
|
557
|
+
kind: 'github',
|
|
558
|
+
locator: url,
|
|
559
|
+
url,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function citationForSource(source, citation = {}) {
|
|
564
|
+
if (source.url === undefined && !declaresBacklinkTuple(citation)) {
|
|
565
|
+
return {
|
|
566
|
+
sourceId: source.id,
|
|
567
|
+
locator: citation.locator ?? source.locator,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (source.url === undefined) {
|
|
571
|
+
if (!validateCanonicalGithubBlobTuple(citation)) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
'GitHub citation backlink requires one complete canonical provenance tuple.',
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
sourceId: source.id,
|
|
578
|
+
locator: citation.url,
|
|
579
|
+
repository: citation.repository,
|
|
580
|
+
revision: citation.revision,
|
|
581
|
+
path: citation.path,
|
|
582
|
+
lineRange: structuredClone(citation.lineRange),
|
|
583
|
+
url: citation.url,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
if (
|
|
587
|
+
declaresBacklinkTuple(citation) &&
|
|
588
|
+
['repository', 'revision', 'path', 'url'].some(
|
|
589
|
+
(field) => citation[field] !== undefined,
|
|
590
|
+
)
|
|
591
|
+
) {
|
|
592
|
+
const supplied = {
|
|
593
|
+
repository: citation.repository,
|
|
594
|
+
revision: citation.revision,
|
|
595
|
+
path: citation.path,
|
|
596
|
+
lineRange: citation.lineRange,
|
|
597
|
+
url: citation.url,
|
|
598
|
+
};
|
|
599
|
+
if (
|
|
600
|
+
!validateCanonicalGithubBlobTuple(supplied) ||
|
|
601
|
+
supplied.repository !== source.repository ||
|
|
602
|
+
supplied.revision !== source.revision ||
|
|
603
|
+
supplied.path !== source.path
|
|
604
|
+
) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
'GitHub citation backlink does not match its canonical source tuple.',
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const range = citation.lineRange ?? source.lineRange;
|
|
611
|
+
const tuple = {
|
|
612
|
+
repository: source.repository,
|
|
613
|
+
revision: source.revision,
|
|
614
|
+
path: source.path,
|
|
615
|
+
lineRange: range,
|
|
616
|
+
};
|
|
617
|
+
const url = canonicalGithubBlobBacklink(tuple);
|
|
618
|
+
return {
|
|
619
|
+
sourceId: source.id,
|
|
620
|
+
locator: url,
|
|
621
|
+
repository: source.repository,
|
|
622
|
+
revision: source.revision,
|
|
623
|
+
path: source.path,
|
|
624
|
+
lineRange: structuredClone(range),
|
|
625
|
+
url,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function declaresBacklinkTuple(value) {
|
|
630
|
+
return (
|
|
631
|
+
value &&
|
|
632
|
+
typeof value === 'object' &&
|
|
633
|
+
['repository', 'path', 'lineRange', 'url'].some(
|
|
634
|
+
(field) => value[field] !== undefined,
|
|
635
|
+
)
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
503
639
|
function validSections(sections) {
|
|
504
640
|
return (
|
|
505
641
|
sections === undefined ||
|