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

@@ -373,6 +373,116 @@ describe('topological step ordering', () => {
assert.strictEqual(sorted[0].capId, 'a-cap', 'a-cap should come first (alphabetical tiebreak)');
assert.strictEqual(sorted[1].capId, 'z-cap');
});
test('contributions at one point use produces/consumes dependency order', () => {
const capMap = new Map([
['a-consumer', {
id: 'a-consumer',
role: 'feature',
title: 'Consumer',
tier: 'full',
requires: [],
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'Consume produced planning note.' },
produces: [],
consumes: ['PLAN-NOTE.md'],
onError: 'skip',
}],
gates: [],
}],
['b-producer', {
id: 'b-producer',
role: 'feature',
title: 'Producer',
tier: 'full',
requires: [],
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'Produce planning note.' },
produces: ['PLAN-NOTE.md'],
consumes: [],
onError: 'skip',
}],
gates: [],
}],
]);
const registry = buildRegistry(capMap);
assert.deepEqual(
registry.byLoopPoint['plan:pre'].contributions.map((c) => c.capId),
['b-producer', 'a-consumer'],
);
});
test('contribution produces/consumes cycle throws a clear error', () => {
const capMap = new Map([
['cap-a', {
id: 'cap-a',
role: 'feature',
title: 'A',
tier: 'full',
requires: [],
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'A.' },
produces: ['A.md'],
consumes: ['B.md'],
onError: 'skip',
}],
gates: [],
}],
['cap-b', {
id: 'cap-b',
role: 'feature',
title: 'B',
tier: 'full',
requires: [],
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'B.' },
produces: ['B.md'],
consumes: ['A.md'],
onError: 'skip',
}],
gates: [],
}],
]);
assert.throws(
() => buildRegistry(capMap),
(err) => {
assert.ok(err instanceof Error);
assert.match(err.message, /contributions/);
assert.match(err.message, /cycle/);
return true;
},
);
});
});
// ─── 4. --check drift detection ──────────────────────────────────────────────
@@ -1011,6 +1121,78 @@ describe('S1: fragment.path traversal guard', () => {
assert.ok(errors.length > 0, 'Expected rejection for empty path');
assert.ok(errors.some((e) => e.includes('fragment.path')));
});
test('non-string fragment.inline is rejected', () => {
const cap = {
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 42 },
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('fragment.inline') && e.includes('string')));
});
test('empty fragment.inline string is rejected', () => {
const cap = {
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { inline: '' },
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('fragment.inline') && e.includes('non-empty')));
});
test('non-array contribution produces is rejected', () => {
const cap = {
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'Plan with UI context.' },
produces: 'PLAN-NOTE.md',
consumes: [],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('produces') && e.includes('array')));
});
test('non-string contribution consumes entry is rejected', () => {
const cap = {
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'Plan with UI context.' },
produces: [],
consumes: [42],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('consumes entries') && e.includes('strings')));
});
});
// ─── 8. Security: prototype pollution (S2) ────────────────────────────────────

View File

@@ -650,12 +650,42 @@ describe('renderLoopHooks', () => {
capId: 'contrib-cap',
kind: 'contribution',
into: 'planner',
fragment: { inline: 'Apply the project-specific planning guardrails.' },
}],
};
const rendered = renderLoopHooks(resolved);
assert.match(rendered, /contribution/);
assert.match(rendered, /contrib-cap/);
assert.match(rendered, /planner/);
assert.match(rendered, /Apply the project-specific planning guardrails\./);
assert.match(rendered, /<contribution from="contrib-cap" into="planner">/);
assert.match(rendered, /<\/contribution>/);
assert.doesNotMatch(rendered, /<contribution[^>]+\/>/);
});
test('resolveLoopHooks preserves contribution fragment data', () => {
const registry = makeRegistry({
point: 'plan:pre',
contributions: [{
capId: 'contrib-cap',
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'Use artifact-backed evidence.' },
produces: ['PLAN-NOTES.md'],
consumes: ['CONTEXT.md'],
when: 'workflow.contrib',
onError: 'halt',
}],
configSchema: {
'workflow.contrib': { type: 'boolean', default: true, description: 'Enable test contribution.' },
},
});
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
assert.strictEqual(resolved.activeHooks.length, 1);
assert.deepEqual(resolved.activeHooks[0].fragment, { inline: 'Use artifact-backed evidence.' });
assert.deepEqual(resolved.activeHooks[0].produces, ['PLAN-NOTES.md']);
assert.deepEqual(resolved.activeHooks[0].consumes, ['CONTEXT.md']);
assert.strictEqual(resolved.activeHooks[0].onError, 'halt');
});
test('gate hook renders check, blocking, onError', () => {

View File

@@ -314,6 +314,30 @@ test('ambient GSD workstream vars are stripped by the runner', () => {
`expected chunking marker in stderr; STDERR (tail):\n${r.stderr.split('\n').slice(-20).join('\n')}`,
);
});
test('chunks by file count even when argv length is below the ceiling', () => {
const names = Array.from({ length: 7 }, (_, i) => `tiny-${String(i).padStart(2, '0')}.test.cjs`);
seed(tmpDir, names);
const r = runHarness(tmpDir, [], {
RUN_TESTS_MAX_CMDLINE_CHARS: '100000',
RUN_TESTS_MAX_FILES_PER_CHUNK: '3',
});
assert.strictEqual(
r.status,
0,
`expected zero exit; got status=${r.status} signal=${r.signal}\nSTDERR:\n${r.stderr}`,
);
assert.match(
r.stderr,
/run-tests: chunk 1\/3 — 3 files/,
`expected file-count chunking marker in stderr; STDERR:\n${r.stderr}`,
);
assert.match(
r.stderr,
/run-tests: chunk 3\/3 — 1 files/,
`expected final file-count chunking marker in stderr; STDERR:\n${r.stderr}`,
);
});
});
describe('per-chunk timeout + force-exit (windows hang guard, #1051)', () => {

View File

@@ -0,0 +1,28 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
test('root tsconfig supports the default no-emit typecheck command', () => {
const root = path.join(__dirname, '..');
const tscBin = path.join(root, 'node_modules', 'typescript', 'bin', 'tsc');
const result = spawnSync(process.execPath, [tscBin, '--noEmit'], {
cwd: root,
encoding: 'utf8',
});
assert.equal(
result.status,
0,
[
'Expected the default root TypeScript typecheck to pass.',
'stdout:',
result.stdout,
'stderr:',
result.stderr,
].join('\n'),
);
});