Skip to content

Commit 1d2fcc0

Browse files
authored
test(workflows): guard against workflow-level schema fields being silently dropped at parse (#2459)
* test(workflows): guard against workflow-level schema fields being silently dropped at parse parseWorkflow hand-assembles its result field by field, so a field added to workflowDefinitionSchema but not to that object literal is silently discarded: the YAML parses, the workflow loads, and the feature is inert. That already happened. `requires:` landed in workflowBaseSchema in ab81248 (2026-06-01) without touching the loader, and the assembly block only arrived in 2d7bf58 (2026-07-16) — six weeks in which the GitHub capability gate could never fire for a discovered workflow, fixed incidentally inside an unrelated PR. This is the third instance of one pattern: parallel enumerations that must agree with nothing enforcing agreement. The ref-surface enumerations carry a KEEP IN SYNC comment and were found broken anyway (#2450); the nested key sets are derived from each schema's .shape and cannot drift (#2455). This applies the derived form to the second case. The field list comes from workflowDefinitionSchema.shape, so a new schema field fails the test until it is given a fixture. Deliberately not solved by deriving the assembly itself — the hand assembly exists because of warn-and-drop, and schema.parse() would reject a bad field instead of logging and dropping it. The per-field assertion clears the mock logger first so it can tell the two failure causes apart: a warning means the fixture value is invalid (warn-and-drop working as designed), silence means a valid field was dropped (the actual bug). Verified by breaking it both ways: removing `requires` from the object literal reproduces the historical bug and fails with the right diagnosis, and adding a new schema key fails the ratchet until a fixture exists. * test(workflows): tighten the parity guard after review Addresses I1, I2, I3 and S1, S3, S4 from the review on #2459. No change to what the guard catches; all six make a precision tool more precise. I1 — the docblock claimed warn-and-drop universally. Re-verified the field audit against loader.ts rather than taking it on faith: 4 of the 20 hard-reject (name, description, nodes, evidence_policy at :619-629), 13 warn-and-drop, and 3 coerce silently with no log at all (provider :423, model :425, persist_sessions :473 — there is no invalid_provider/invalid_model/invalid_persist_sessions warn event anywhere in the file). Rewritten to say most rather than all, and to point at loader.ts as the authority instead of restating a per-field table that would rot the moment a field changes category. I3 — the two-branch failure message was backwards for exactly those 3 silent fields: a bad `provider: 123` fixture is discarded with no warning, so the message confidently blamed the loader and sent the reader into parseWorkflow when the fixture was at fault. That is the same failure the message exists to prevent, and the one I hit during development with a bad `thinking: true` fixture. Fixed by ranking rather than verdict: a warning is still strong evidence the fixture is wrong, but silence now names both causes and points at the fixture first. Chosen over listing the three exceptions in a comment, which would duplicate loader.ts and rot. This subsumes S2's unstated-invariant concern. I2 — effort, thinking and sandbox used presence checks where the other 17 fixtures check values, and their schemas transform deterministically, so exact checks are available. Verified by mutation: returning effort:'low' and thinking:{type:'disabled'} from the loader now fails both round-trips, where before it left them green. S1 — the hand-assembly literal predates 2d7bf58; only the requires entry landed there. Reworded so it cannot be skimmed as "the mechanism didn't exist until then". S3 — the two diagnostic strings moved out of the assertion into a named message. S4 — nodes?.length, so a dropped nodes yields a clean false instead of a TypeError. Verified: full validate green (132 batches, 0 fail); the I3 message re-checked by running a deliberately invalid provider fixture; I2 re-checked by mutation.
1 parent 8704a65 commit 1d2fcc0

1 file changed

Lines changed: 149 additions & 0 deletions

File tree

packages/workflows/src/loader.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ registerBuiltinProviders();
3535

3636
import { discoverWorkflows, discoverWorkflowsWithConfig } from './workflow-discovery';
3737
import { isBashNode, isCancelNode, isLoopNode } from './schemas';
38+
import { parseWorkflow } from './loader';
39+
import { workflowDefinitionSchema } from './schemas/workflow';
40+
import type { WorkflowDefinition } from './schemas/workflow';
3841
import * as bundledDefaults from './defaults/bundled-defaults';
3942

4043
describe('Workflow Loader', () => {
@@ -4074,3 +4077,149 @@ nodes:
40744077
});
40754078
});
40764079
});
4080+
4081+
// ---------------------------------------------------------------------------
4082+
// Workflow-level field parity (#2457)
4083+
// ---------------------------------------------------------------------------
4084+
4085+
/**
4086+
* `parseWorkflow` does not derive its result from `workflowDefinitionSchema` — it
4087+
* hand-assembles a WorkflowDefinition field by field into an object literal. A field
4088+
* added to the schema but not added to that literal is SILENTLY DISCARDED: the YAML
4089+
* parses, the workflow loads, and the feature is simply inert.
4090+
*
4091+
* That is not hypothetical. `requires:` was added to `workflowBaseSchema` in ab81248d
4092+
* (2026-06-01) without touching the loader, and was not added to that literal until
4093+
* 2d7bf587 (2026-07-16) — six weeks in which the GitHub capability gate could never
4094+
* fire for any discovered workflow, fixed incidentally inside an unrelated PR.
4095+
*
4096+
* This is the guard. The field list is DERIVED from `workflowDefinitionSchema.shape`,
4097+
* so a new schema field fails the test until it is given a fixture here — the same
4098+
* "the derived check fails until the new thing is registered" ratchet used by
4099+
* `check:capability-matrix` and the schema-parity test in `sqlite.test.ts`.
4100+
*
4101+
* Deliberately NOT solved by deriving the assembly itself (`schema.parse(raw)`): most
4102+
* fields warn-and-drop, logging a present-but-invalid value and continuing rather than
4103+
* aborting the whole discovery pass, and `.parse()` would reject the workflow instead.
4104+
* That is not universal — a few fields deliberately hard-reject and a few coerce
4105+
* silently — but one warn-and-drop field is enough to make a blanket `.parse()` wrong.
4106+
* `loader.ts` is the authority on which field does what; do not restate it here.
4107+
* See #2457.
4108+
*/
4109+
describe('workflow-level field parity (#2457)', () => {
4110+
/**
4111+
* One fixture per workflow-level schema key: a YAML fragment setting the field, and a
4112+
* predicate proving it survived `parseWorkflow`. `present` is deliberately a survival
4113+
* check rather than deep equality — several fields are normalised on the way through
4114+
* (tags deduped, betas trimmed, thinking preprocessed), and this guard is about the
4115+
* field reaching the result at all, not about how it is parsed.
4116+
*/
4117+
const FIELD_FIXTURES: Record<
4118+
string,
4119+
{ yaml: string; present: (w: WorkflowDefinition) => boolean }
4120+
> = {
4121+
name: { yaml: '', present: w => w.name === 'parity' },
4122+
description: { yaml: '', present: w => w.description === 'parity fixture' },
4123+
nodes: { yaml: '', present: w => w.nodes?.length === 1 },
4124+
provider: { yaml: 'provider: claude', present: w => w.provider === 'claude' },
4125+
model: { yaml: 'model: sonnet', present: w => w.model === 'sonnet' },
4126+
modelReasoningEffort: {
4127+
yaml: 'modelReasoningEffort: high',
4128+
present: w => w.modelReasoningEffort === 'high',
4129+
},
4130+
webSearchMode: { yaml: 'webSearchMode: live', present: w => w.webSearchMode === 'live' },
4131+
interactive: { yaml: 'interactive: true', present: w => w.interactive === true },
4132+
effort: { yaml: 'effort: high', present: w => w.effort === 'high' },
4133+
thinking: { yaml: 'thinking: adaptive', present: w => w.thinking?.type === 'adaptive' },
4134+
fallbackModel: {
4135+
yaml: 'fallbackModel: haiku',
4136+
present: w => w.fallbackModel === 'haiku',
4137+
},
4138+
betas: { yaml: 'betas:\n - some-beta', present: w => w.betas?.includes('some-beta') === true },
4139+
sandbox: { yaml: 'sandbox:\n enabled: true', present: w => w.sandbox?.enabled === true },
4140+
worktree: { yaml: 'worktree:\n enabled: false', present: w => w.worktree?.enabled === false },
4141+
container: {
4142+
yaml: 'container:\n enabled: true',
4143+
present: w => w.container?.enabled === true,
4144+
},
4145+
evidence_policy: {
4146+
yaml: 'evidence_policy:\n required: true',
4147+
present: w => w.evidence_policy?.required === true,
4148+
},
4149+
mutates_checkout: {
4150+
yaml: 'mutates_checkout: false',
4151+
present: w => w.mutates_checkout === false,
4152+
},
4153+
persist_sessions: {
4154+
yaml: 'persist_sessions: true',
4155+
present: w => w.persist_sessions === true,
4156+
},
4157+
tags: { yaml: 'tags:\n - alpha', present: w => w.tags?.includes('alpha') === true },
4158+
requires: {
4159+
yaml: 'requires:\n - github',
4160+
present: w => w.requires?.includes('github') === true,
4161+
},
4162+
};
4163+
4164+
const schemaKeys = Object.keys(workflowDefinitionSchema.shape);
4165+
4166+
it('has a fixture for every workflow-level schema key (the ratchet)', () => {
4167+
const missing = schemaKeys.filter(k => !(k in FIELD_FIXTURES));
4168+
expect(
4169+
missing,
4170+
`Workflow-level schema keys with no parity fixture: ${missing.join(', ')}. ` +
4171+
'Add a fixture in FIELD_FIXTURES AND make sure parseWorkflow actually carries the ' +
4172+
'field into its returned object literal — a schema field missing from that literal ' +
4173+
'is silently discarded at parse (see #2457).'
4174+
).toEqual([]);
4175+
});
4176+
4177+
it('has no fixture for a key that is not in the schema', () => {
4178+
const stale = Object.keys(FIELD_FIXTURES).filter(k => !schemaKeys.includes(k));
4179+
expect(stale, `Parity fixtures for keys no longer in the schema: ${stale.join(', ')}`).toEqual(
4180+
[]
4181+
);
4182+
});
4183+
4184+
for (const key of Object.keys(FIELD_FIXTURES)) {
4185+
it(`round-trips '${key}' through parseWorkflow`, () => {
4186+
const fixture = FIELD_FIXTURES[key];
4187+
const yaml = [
4188+
'name: parity',
4189+
'description: parity fixture',
4190+
fixture.yaml,
4191+
'nodes:',
4192+
' - id: only',
4193+
' prompt: hello',
4194+
]
4195+
.filter(line => line !== '')
4196+
.join('\n');
4197+
4198+
// An INVALID fixture value is dropped by design, which looks identical to the bug
4199+
// this test hunts. Clearing the logger first lets the failure message rank the two
4200+
// causes: a warning is strong evidence the fixture is at fault. Silence is NOT
4201+
// proof of the opposite — a few fields coerce an invalid value away with no log at
4202+
// all — so the silent branch names both causes rather than rendering a verdict.
4203+
mockLogger.warn.mockClear();
4204+
4205+
const result = parseWorkflow(yaml, `parity-${key}.yaml`);
4206+
expect(
4207+
result.error,
4208+
`parseWorkflow rejected the '${key}' fixture: ${result.error?.error}`
4209+
).toBeNull();
4210+
4211+
const warned = mockLogger.warn.mock.calls.length > 0;
4212+
const message = warned
4213+
? `Field '${key}' did not survive parseWorkflow, and a warning fired — the FIXTURE ` +
4214+
'value above is almost certainly invalid for this field, which warn-and-drop ' +
4215+
'discards by design. Fix the fixture, not the loader.'
4216+
: `Field '${key}' is declared on workflowDefinitionSchema and did NOT survive ` +
4217+
'parseWorkflow, with no warning logged. Two possible causes, likeliest first: ' +
4218+
"(1) the field is missing from the object literal parseWorkflow returns — that's " +
4219+
'the #2457 bug, add it there; or (2) the fixture value is invalid for a field ' +
4220+
'that coerces silently without logging, in which case fix the fixture. Check the ' +
4221+
'fixture value against the schema first — it is the cheaper of the two to rule out.';
4222+
expect(fixture.present(result.workflow as WorkflowDefinition), message).toBe(true);
4223+
});
4224+
}
4225+
});

0 commit comments

Comments
 (0)