feat(#1132): complete contribution hook prerequisite

Closes #1132
This commit is contained in:
Tom Boucher
2026-06-12 18:39:57 -04:00
committed by GitHub
parent 1d90ad3c30
commit fd01e7a12e
8 changed files with 347 additions and 24 deletions

View File

@@ -970,6 +970,22 @@ function validateContribution(contrib, prefix) {
errors.push(prefix + '.into must be a string (agent role name)');
}
if (!Array.isArray(contrib.produces)) {
errors.push(prefix + '.produces must be an array');
} else {
for (const p of contrib.produces) {
if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings');
}
}
if (!Array.isArray(contrib.consumes)) {
errors.push(prefix + '.consumes must be an array');
} else {
for (const c of contrib.consumes) {
if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings');
}
}
if (typeof contrib.fragment !== 'object' || contrib.fragment === null) {
errors.push(prefix + '.fragment must be an object with path or inline key');
} else {
@@ -978,6 +994,14 @@ function validateContribution(contrib, prefix) {
if (!hasPath && !hasInline) {
errors.push(prefix + '.fragment must have a "path" or "inline" key');
}
if (hasInline) {
const inline = contrib.fragment.inline;
if (typeof inline !== 'string') {
errors.push(prefix + '.fragment.inline must be a string');
} else if (inline === '') {
errors.push(prefix + '.fragment.inline must be a non-empty string');
}
}
// S1: fragment.path traversal guard — must be a relative path with no ".." segments
if (hasPath) {
const p = contrib.fragment.path;
@@ -1440,14 +1464,7 @@ function computeRequiresClosure(id, capMap) {
// ─── Topological ordering ─────────────────────────────────────────────────────
/**
* Topologically sort steps at a given point by produces/consumes.
* Capability-id tiebreak for determinism.
*
* @param {{ capId: string, step: object }[]} entries
* @returns {{ capId: string, step: object }[]}
*/
function topoSortSteps(entries) {
function topoSortHookEntries(entries, hookKey, hookKind) {
if (entries.length <= 1) return entries;
// Build adjacency: entry A must come before entry B if B consumes something A produces
@@ -1456,10 +1473,10 @@ function topoSortSteps(entries) {
const adj = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) {
const producesI = new Set(entries[i].step.produces || []);
const producesI = new Set(entries[i][hookKey].produces || []);
for (let j = 0; j < n; j++) {
if (i === j) continue;
const consumesJ = entries[j].step.consumes || [];
const consumesJ = entries[j][hookKey].consumes || [];
for (const artifact of consumesJ) {
if (producesI.has(artifact)) {
adj[i].push(j);
@@ -1497,8 +1514,8 @@ function topoSortSteps(entries) {
if (result.length < n) {
const sortedIds = entries.map((e) => e.capId).join(', ');
throw new Error(
'produces/consumes cycle detected in steps at point "' +
(entries[0] && entries[0].step ? entries[0].step.point : '?') +
'produces/consumes cycle detected in ' + hookKind + ' at point "' +
(entries[0] && entries[0][hookKey] ? entries[0][hookKey].point : '?') +
'" among capabilities [' + sortedIds + ']: ' +
'a cycle in hook produces/consumes prevents deterministic ordering',
);
@@ -1506,6 +1523,21 @@ function topoSortSteps(entries) {
return result;
}
/**
* Topologically sort steps at a given point by produces/consumes.
* Capability-id tiebreak for determinism.
*
* @param {{ capId: string, step: object }[]} entries
* @returns {{ capId: string, step: object }[]}
*/
function topoSortSteps(entries) {
return topoSortHookEntries(entries, 'step', 'steps');
}
function topoSortContributions(entries) {
return topoSortHookEntries(entries, 'contrib', 'contributions');
}
// ─── ADR-857 Phase 4a: Derived views ─────────────────────────────────────────
// FIX 5 (lazy requires): paths are declared at top level but the actual require()
@@ -1948,14 +1980,9 @@ function buildRegistry(capMap) {
...e.step,
}));
// Contributions: group by into, then capability-id order within group
const contribs = pointContribs.get(point);
contribs.sort((a, b) => {
const intoCompare = a.contrib.into.localeCompare(b.contrib.into);
if (intoCompare !== 0) return intoCompare;
return a.capId.localeCompare(b.capId);
});
byLoopPoint[point].contributions = contribs.map((e) => ({
// Contributions: topological sort by produces/consumes, cap-id tiebreak
const sortedContribs = topoSortContributions(pointContribs.get(point));
byLoopPoint[point].contributions = sortedContribs.map((e) => ({
capId: e.capId,
...e.contrib,
}));

View File

@@ -342,6 +342,9 @@ function main() {
const MAX_CMDLINE_CHARS = process.env.RUN_TESTS_MAX_CMDLINE_CHARS
? Number(process.env.RUN_TESTS_MAX_CMDLINE_CHARS)
: 28000; // headroom below the 32,767 Windows ceiling
const MAX_FILES_PER_CHUNK = process.env.RUN_TESTS_MAX_FILES_PER_CHUNK
? Number(process.env.RUN_TESTS_MAX_FILES_PER_CHUNK)
: 180;
// node:test does not exit until the event loop drains. A unit test that leaks
// an open handle (un-terminated Worker, un-killed child_process, ref'd timer)
@@ -362,7 +365,10 @@ function main() {
let currentLen = FIXED_OVERHEAD;
for (const file of selected) {
const add = file.length + 1; // +1 for the inter-arg separator
if (current.length > 0 && currentLen + add > MAX_CMDLINE_CHARS) {
if (
current.length > 0 &&
(currentLen + add > MAX_CMDLINE_CHARS || current.length >= MAX_FILES_PER_CHUNK)
) {
chunks.push(current);
current = [];
currentLen = FIXED_OVERHEAD;