Skip to content

Commit ef0dfb4

Browse files
committed
fix(spec,lint): patch round 1 — cross-lane fixtures, lint alias tolerance, comment truths
- service-automation tests: the input-schema and parallel-branch doubles register under a type of their own (probe_step), never the builtin script, whose contract now refuses their undeclared keys at the flow parse. - lint: validateStackExpressions keeps its declared pre-conversion tolerance for a script node's functionName alias; every other undeclared script key is still refused there (pinned). - spec comments: the FlowSchema header and the node-config-refused-by-contract docblock say which arm judges key membership. - changeset: @objectstack/lint patch. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 521e16f commit ef0dfb4

7 files changed

Lines changed: 70 additions & 28 deletions

File tree

‎.changeset/21982-flow-script-subflow-config-undeclared-keys-refused.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
'@objectstack/spec': minor
3+
'@objectstack/lint': patch
34
---
45

56
A `script` or `subflow` flow node whose `config` carries a key its executor contract does not declare is refused at parse, with a location, in the contract's own words: a `script` `bogusKey`, a `subflow` `timeoutMs` written inside `config`, and the like no longer pass the build doors and registration and then fail every run.
@@ -34,6 +35,8 @@ Clause-②: no (narrowing)
3435

3536
**Who is affected, measured.** At `15ec50e528`, every `script` and `subflow` node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (8 nodes: 6 `script`, 2 `subflow`) carries only declared keys, and so does every one in hotcrm at `c9678036d9` (5 `subflow`, no `script`). The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys for both types (its `timeoutMs` field writes the node, not `config`), and seeds a new node with an empty `config`. Deployed metadata, and other repositories, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register.
3637

38+
**`@objectstack/lint`.** `validateStackExpressions` keeps the pre-conversion tolerance it declares: on a raw source, a `script` node's `functionName` alias stays the callable check's to read, not an undeclared-key error, while every other undeclared `script` key is refused there as at the build doors.
39+
3740
### The kit
3841

3942
- **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, judged for the builtins in the spec's schemaless class (`SCHEMALESS_NODE_CONFIG_SCHEMAS`) that have an executor contract; no new code joins `FLOW_SLOT_REFUSAL_CODES`, and `getBuiltinNodeConfigContracts()` keeps its 13 entries.

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4535,6 +4535,21 @@ describe('node config an executor requires (#20316)', () => {
45354535
expect(found.map((i) => i.where)).toEqual(["flow 'config_flow' · node 'n' (script) callable"]);
45364536
expect(errorsOf(stackWith({ type: 'script', config: { functionName: 'recalc_totals' } }))).toHaveLength(0);
45374537
});
4538+
4539+
it('a `script` key its contract does not declare is still refused — only the `functionName` alias is the callable check\'s', () => {
4540+
const config = { function: 'recalc_totals', bogusKey: 1 };
4541+
expect(errorsOf(stackWith({ type: 'script', config })).map((i) => [i.where, i.message, i.source])).toEqual([
4542+
["flow 'config_flow' · node 'n' (script) config.bogusKey", flowNodeConfigRefusals('script', config)[0].message, ''],
4543+
]);
4544+
// On a raw alias source the judge names `function`, `functionName` and
4545+
// `bogusKey`; the pass hands the first two to the callable check and
4546+
// keeps the third.
4547+
const aliased = { functionName: 'recalc_totals', bogusKey: 1 };
4548+
expect(flowNodeConfigRefusals('script', aliased).map((r) => r.path).sort()).toEqual(['bogusKey', 'function', 'functionName']);
4549+
expect(errorsOf(stackWith({ type: 'script', config: aliased })).map((i) => i.where)).toEqual([
4550+
"flow 'config_flow' · node 'n' (script) config.bogusKey",
4551+
]);
4552+
});
45384553
});
45394554

45404555
/**

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1714,8 +1714,15 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
17141714
// #4343): this pass may be handed a pre-conversion source, and that
17151715
// check reads what such a source spells — the `functionName` alias,
17161716
// the retired dispatch keys — and names each, where the judge would
1717-
// only see `function` absent.
1718-
.filter((configRefusal) => !(nodeType === 'script' && configRefusal.path === 'function'));
1717+
// only see `function` absent. Since the judge also refuses a key a
1718+
// `script`'s contract does not declare (#21982), it would name that
1719+
// same `functionName` alias undeclared too, so that one refusal is
1720+
// the callable check's as well: the pass keeps the pre-conversion
1721+
// tolerance it declares. Every other undeclared key stays refused.
1722+
.filter((configRefusal) => !(nodeType === 'script' && (
1723+
configRefusal.path === 'function'
1724+
|| (configRefusal.code === 'node-config-refused-by-contract' && configRefusal.path === 'functionName')
1725+
)));
17191726
for (const configRefusal of configRefusals) {
17201727
issues.push({
17211728
where: `${at} · node '${node.id}' (${nodeType}) config.${configRefusal.path}`,

‎packages/services/service-automation/src/engine.test.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2337,8 +2337,11 @@ describe('AutomationEngine - Parallel Branch Execution', () => {
23372337
// sequential engine satisfies just as well.
23382338
const trace: string[] = [];
23392339

2340+
// A test double under a type of its own, never the builtin `script`:
2341+
// `delay` is this double's key, and the `script` contract refuses an
2342+
// undeclared key at the flow parse that `registerFlow` runs first.
23402343
engine.registerNodeExecutor({
2341-
type: 'script',
2344+
type: 'probe_step',
23422345
async execute(node) {
23432346
trace.push(`enter:${node.id}`);
23442347
const delay = (node.config as any)?.delay ?? 0;
@@ -2355,8 +2358,8 @@ describe('AutomationEngine - Parallel Branch Execution', () => {
23552358
type: 'autolaunched',
23562359
nodes: [
23572360
{ id: 'start', type: 'start', label: 'Start' },
2358-
{ id: 'branch_a', type: 'script', label: 'Branch A', config: { function: 'noop', delay: 10 } },
2359-
{ id: 'branch_b', type: 'script', label: 'Branch B', config: { function: 'noop', delay: 10 } },
2361+
{ id: 'branch_a', type: 'probe_step', label: 'Branch A', config: { delay: 10 } },
2362+
{ id: 'branch_b', type: 'probe_step', label: 'Branch B', config: { delay: 10 } },
23602363
{ id: 'end', type: 'end', label: 'End' },
23612364
],
23622365
edges: [
@@ -2442,8 +2445,11 @@ describe('AutomationEngine - Node Input Schema Validation', () => {
24422445
});
24432446

24442447
it('should fail when parameter type is wrong', async () => {
2448+
// A test double under a type of its own: `inputSchema` reads TOP-LEVEL
2449+
// config keys, and the builtin `script` contract refuses an undeclared
2450+
// `count` at the flow parse before this check could run.
24452451
engine.registerNodeExecutor({
2446-
type: 'script',
2452+
type: 'probe_step',
24472453
async execute() {
24482454
return { success: true };
24492455
},
@@ -2457,9 +2463,9 @@ describe('AutomationEngine - Node Input Schema Validation', () => {
24572463
{ id: 'start', type: 'start', label: 'Start' },
24582464
{
24592465
id: 'validated',
2460-
type: 'script',
2466+
type: 'probe_step',
24612467
label: 'Validated',
2462-
config: { function: 'noop', count: 'not_a_number' },
2468+
config: { count: 'not_a_number' },
24632469
inputSchema: {
24642470
count: { type: 'number', required: true },
24652471
},

‎packages/services/service-automation/src/input-schema-retry-parity.test.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,11 @@ function countingFlowEngine(opts: {
5555
const engine = new AutomationEngine(createTestLogger());
5656
const runs = { count: 0 };
5757

58+
// A test double under a type of its own, never the builtin `script`:
59+
// `inputSchema` reads TOP-LEVEL config keys, and the `script` contract
60+
// refuses an undeclared key at the flow parse `registerFlow` runs first.
5861
engine.registerNodeExecutor({
59-
type: 'script',
62+
type: 'probe_step',
6063
async execute() {
6164
runs.count++;
6265
return opts.executeResult ? opts.executeResult(runs.count) : { success: true };
@@ -72,11 +75,9 @@ function countingFlowEngine(opts: {
7275
{ id: 'start', type: 'start', label: 'Start' },
7376
{
7477
id: 'work',
75-
type: 'script' as any,
78+
type: 'probe_step',
7679
label: 'Work',
77-
// `function` is the key the script executor contract requires;
78-
// the flow parse refuses a script node without it (#20316).
79-
config: { function: 'noop', ...opts.config },
80+
config: { ...opts.config },
8081
inputSchema: opts.inputSchema,
8182
},
8283
{ id: 'end', type: 'end', label: 'End' },

‎packages/spec/src/automation/flow-node-expression-paths.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -556,10 +556,13 @@ export interface FlowSlotRefusalParams {
556556
readonly objectName: string;
557557
};
558558
/**
559-
* (#21850) A key a WHOLE-judged node contract does not declare, or a value it
560-
* refuses, at the key the author wrote — today the `approval` node's, the one
561-
* plugin node contract the spec declares. Its message is the contract's own
562-
* sentence, inside one naming the node type and the key.
559+
* A node's executor contract refuses what the author wrote, at the key they
560+
* wrote it: (#21850) a key the WHOLE-judged `approval` contract — the one
561+
* plugin node contract the spec declares — does not declare, or a value it
562+
* refuses; (#21898) a value a builtin node's executor contract refuses; and
563+
* (#21982) a key a `script` or `subflow` executor contract does not declare.
564+
* Its message is the contract's own sentence, inside one naming the node type
565+
* and the key.
563566
*/
564567
'node-config-refused-by-contract': { readonly nodeType: string; readonly key: string };
565568
}

‎packages/spec/src/automation/flow.zod.ts‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -159,17 +159,24 @@ export const FLOW_PAUSE_CAPABLE_NODE_TYPES: readonly string[] = [
159159
* note). One type-specific exception to the open slot (#14945): the structural
160160
* `end` node has no executor and no descriptor, so the flow parse is the ONLY
161161
* door its config passes through — {@link parseEndNodeConfig} applies
162-
* {@link EndConfigSchema} to it. Every other type's `config` stays the
163-
* executor's to close. One VALUE rule reaches into an open `config` at the
164-
* flow level without closing its key set (#17493): a blank string in a slot
165-
* the expression ledger declares with the `predicate` role is refused by the
166-
* `FlowSchema` superRefine — see the block there for its scope. A PRESENCE
167-
* rule reaches in beside it, still without closing the key set (#20316): a
168-
* key the node's executor contract requires, left out, and a `decision`
169-
* branch list the executor cannot read — `flowNodeConfigRefusals`, in the
170-
* same superRefine — and (#21898) a VALUE rule with it, again without closing
171-
* the key set: a value a builtin node's executor contract refuses, where the
172-
* build can know what the run will parse.
162+
* {@link EndConfigSchema} to it. For every other type the `config` SHAPE
163+
* stays open here (`FlowNodeSchema.config` is a `z.record`) and the executor's
164+
* contract is what closes it; the flow level reads into it through one judge,
165+
* `flowNodeConfigRefusals`, in the `FlowSchema` superRefine, and never
166+
* re-declares a type's shape. A VALUE rule reaches in (#17493): a blank string
167+
* in a slot the expression ledger declares with the `predicate` role is
168+
* refused — see the block there for its scope. A PRESENCE rule beside it
169+
* (#20316): a key the node's executor contract requires, left out, and a
170+
* `decision` branch list the executor cannot read. A builtin VALUE rule with
171+
* it (#21898): a value a builtin node's executor contract refuses, where the
172+
* build can know what the run will parse. And KEY MEMBERSHIP only where the
173+
* build is the one door before the run: the declared `approval` contract is
174+
* judged whole (#21850), and a key a `script` or `subflow` executor contract
175+
* does not declare is refused (#21982) — those two descriptors publish no
176+
* `configSchema` for registration's undeclared-key walk to read. Every other
177+
* builtin's undeclared key is judged at `registerFlow` against its descriptor,
178+
* and so is a plugin type's whose contract the spec does not declare; the
179+
* flow parse does not judge those keys.
173180
*/
174181

175182
/**

0 commit comments

Comments
 (0)