Skip to content

Commit 4bfd455

Browse files
os-zhuangclaude
andauthored
refactor(spec,lint): one declaration of where ADR-0031 regions live (#4401) (#4408)
A region is a sub-graph inside `FlowNodeSchema.config`, an open `z.record`. Nothing in the type system says which key on which node type holds one, so every pass that needs to reach a region node has to be told — and within one week three of them were told separately, by two changes that were each correct on their own (#4381 and #4388): mapFlowNodes (ADR-0087 conversions) spec FLOW_REGION_SLOTS validateControlFlow / normalizeControlFlowRegions / collectFlowGraphs spec regionSlotsOf walkFlowNodes (lint flow rules) lint REGION_SLOTS Each pinned its own copy with its own reconciliation test. So every copy was protected from drifting away from the schemas, and nothing would have failed if the copies drifted from each other — while adding a fourth construct meant editing three places, and missing one reproduces exactly the silent blind spot #4347 and #4380 were both filed about. - New `@objectstack/spec/automation` export `FLOW_REGION_SLOTS` (+ the `FLOW_REGION_SLOTS_BY_TYPE` / `FLOW_REGION_CONFIG_KEYS` views) is the only statement of the fact. Import-free module, so `spec/conversions/walk.ts` can read it and stay the pure shape walker it was written as; mapping a slot onto the Zod schema its value parses as stays in `control-flow.zod.ts`. - The three reconciliation tests collapse into `region-slots.test.ts`, keeping the strongest of them (#4388's): it derives each construct's region keys BEHAVIOURALLY, by asking the config schema what it accepts in a region shape, rather than reading names off `.shape`. It also probes every other exported `*ConfigSchema`, so a new region-bearing construct cannot be added without either declaring its slots or failing here. The three walks are deliberately left separate: different inputs (parsed vs raw authored records), different units (a graph, a node, a copy-on-write rewritten tree), and the lint one formats human diagnostic trails from node labels — consumer logic, not protocol (Prime Directive #2). No behaviour change: every existing test passes unchanged. Claude-Session: https://claude.ai/code/session_01HSBbKMdDgHjGpQdrvQUQXj Co-authored-by: Claude <noreply@anthropic.com>
1 parent 91f4c78 commit 4bfd455

10 files changed

Lines changed: 388 additions & 157 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/lint': patch
4+
---
5+
6+
One declaration of where ADR-0031 regions live (#4401).
7+
8+
A region is a sub-graph inside `FlowNodeSchema.config`, an open `z.record`. Nothing in the
9+
type system says which key on which node type holds one, so every pass that needs to reach
10+
a region node has to be told — and within one week three of them were told separately, by
11+
two changes that were each correct on their own:
12+
13+
| pass | package | table it carried |
14+
|------|---------|------------------|
15+
| `mapFlowNodes` (ADR-0087 conversions) | `spec` | `FLOW_REGION_SLOTS` |
16+
| `validateControlFlow` / `normalizeControlFlowRegions` / `collectFlowGraphs` | `spec` | `regionSlotsOf` |
17+
| `walkFlowNodes` (lint flow rules) | `lint` | `REGION_SLOTS` |
18+
19+
Each pinned its own copy with its own reconciliation test. So every copy was protected from
20+
drifting away from the schemas, and **nothing would have failed if the copies drifted from
21+
each other** — while adding a fourth construct meant editing three places, and missing one
22+
reproduces exactly the silent blind spot #4347 and #4380 were both filed about.
23+
24+
- New `@objectstack/spec/automation` export `FLOW_REGION_SLOTS` (plus the
25+
`FLOW_REGION_SLOTS_BY_TYPE` / `FLOW_REGION_CONFIG_KEYS` views) is now the only statement
26+
of the fact. It lives in an **import-free** module so `spec/conversions/walk.ts` can read
27+
it and stay the pure shape walker it was written as; mapping a slot onto the Zod schema
28+
its value parses as stays in `control-flow.zod.ts`, which is schema business.
29+
- The three reconciliation tests collapse into one, `region-slots.test.ts`, keeping the
30+
strongest of them: it derives each construct's region keys **behaviourally**, by asking
31+
the config schema what it actually accepts in a region shape, rather than reading names
32+
off `.shape`. It also probes every other exported `*ConfigSchema`, so a new
33+
region-bearing construct cannot be added without either declaring its slots or failing
34+
here.
35+
36+
The three **walks** are deliberately left separate. They take different inputs (parsed
37+
`FlowNodeParsed` vs raw authored records), yield different units (a graph, a node, a
38+
copy-on-write rewritten tree), and the lint one formats human diagnostic trails from node
39+
labels — consumer logic, not protocol (Prime Directive #2). Merging them would trade a
40+
duplicated four-line table for a walker that serves nobody well. Only the fact they all
41+
need is shared.
42+
43+
No behaviour change: every existing test passes unchanged, which is the point of the
44+
exercise.

packages/lint/src/flow-walk.test.ts

Lines changed: 15 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22

33
import { describe, it, expect } from 'vitest';
44
import {
5-
LoopConfigSchema,
6-
ParallelConfigSchema,
7-
TryCatchConfigSchema,
5+
FLOW_REGION_SLOTS,
6+
FLOW_REGION_CONFIG_KEYS,
87
} from '@objectstack/spec/automation';
98

109
import {
@@ -17,49 +16,21 @@ import {
1716

1817
const node = (id: string, extra: Record<string, unknown> = {}) => ({ id, type: 'script', ...extra });
1918

20-
describe('REGION_SLOTS — pinned against the spec, not restated', () => {
21-
// The ledger's job is to make a NEW region-bearing construct fail here rather
22-
// than become a fifth silent blind spot. Derived behaviourally from the
23-
// spec's own config schemas: a region slot is one that accepts `{nodes: […]}`.
24-
const REGION_BEARING_CONFIGS = {
25-
try_catch: TryCatchConfigSchema,
26-
loop: LoopConfigSchema,
27-
parallel: ParallelConfigSchema,
28-
} as const;
29-
30-
/** Keys of `schema` that accept a region (or an array of them). */
31-
const regionKeysOf = (schema: { safeParse: (v: unknown) => { success: boolean; data?: unknown } }): string[] => {
32-
// `label` is required by FlowNodeSchema — a probe node without it fails the
33-
// parse and would make every slot look non-region.
34-
const region = { nodes: [{ id: 'probe', type: 'script', label: 'Probe' }], edges: [] };
35-
const probes: Record<string, unknown> = {
36-
// Required siblings so the parse reaches the region keys at all.
37-
collection: '{items}',
38-
try: region,
39-
catch: region,
40-
body: region,
41-
branches: [{ nodes: region.nodes, edges: [] }, { nodes: region.nodes, edges: [] }],
42-
};
43-
const parsed = schema.safeParse(probes);
44-
if (!parsed.success) return [];
45-
const data = parsed.data as Record<string, unknown>;
46-
return Object.keys(data).filter((k) => {
47-
const v = data[k];
48-
if (Array.isArray(v)) return v.every((e) => !!e && typeof e === 'object' && Array.isArray((e as never)['nodes']));
49-
return !!v && typeof v === 'object' && Array.isArray((v as Record<string, unknown>).nodes);
50-
});
51-
};
52-
53-
it('declares exactly the region slots each construct actually accepts', () => {
54-
for (const [type, schema] of Object.entries(REGION_BEARING_CONFIGS)) {
55-
expect([...(REGION_SLOTS.get(type) ?? [])].sort(), `region slots for '${type}'`).toEqual(
56-
regionKeysOf(schema).sort(),
57-
);
58-
}
19+
/**
20+
* The behavioural probe that used to live here — deriving each construct's
21+
* region keys from its own config schema — moved to `region-slots.test.ts` in
22+
* the spec, next to the single declaration it now reconciles (#4401). This side
23+
* only has to prove the projection is faithful; a renamed or added slot fails
24+
* over there, once, for all three walks.
25+
*/
26+
describe('REGION_SLOTS — projected from the spec, not restated', () => {
27+
it('carries every declared slot, grouped by container type', () => {
28+
const projected = [...REGION_SLOTS].flatMap(([type, keys]) => keys.map(key => `${type}.${key}`)).sort();
29+
expect(projected).toEqual(FLOW_REGION_SLOTS.map(s => `${s.nodeType}.${s.key}`).sort());
5930
});
6031

61-
it('derives REGION_CONFIG_KEYS from the per-type slots', () => {
62-
expect([...REGION_CONFIG_KEYS].sort()).toEqual([...new Set([...REGION_SLOTS.values()].flat())].sort());
32+
it('shares the flat config-key set by reference', () => {
33+
expect(REGION_CONFIG_KEYS).toBe(FLOW_REGION_CONFIG_KEYS);
6334
});
6435
});
6536

packages/lint/src/flow-walk.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
* read named keys (`config.fields`, `config.objectName`) can use either.
4949
*/
5050

51+
import { FLOW_REGION_SLOTS_BY_TYPE, FLOW_REGION_CONFIG_KEYS } from '@objectstack/spec/automation';
52+
5153
export type AnyRec = Record<string, unknown>;
5254

5355
function isRec(v: unknown): v is AnyRec {
@@ -59,24 +61,28 @@ function strName(v: unknown): string | undefined {
5961
}
6062

6163
/**
62-
* Config keys that hold a nested region, by owning node type. Declared as data
63-
* so a new construct is one entry here rather than a fifth silent blind spot,
64-
* and pinned against the spec's own region-bearing schemas by
65-
* `flow-walk.test.ts`.
64+
* Config keys that hold a nested region, by owning node type.
65+
*
66+
* Projected from `@objectstack/spec/automation` rather than declared here
67+
* (#4401). This used to be a local copy with its own reconciliation test — as
68+
* did the ADR-0087 conversion walk's copy and the spec-side control-flow walk's.
69+
* Three tables, three tests each pinning its own copy, and nothing that would
70+
* fail if they drifted from ONE ANOTHER: every copy was individually protected
71+
* and the set was not. A fourth construct is now a single entry in
72+
* `spec/src/automation/region-slots.ts`.
73+
*
74+
* The **walk** below stays here. It takes raw authored records (not
75+
* `FlowNodeParsed`), and it yields per-node diagnostic paths and label trails —
76+
* formatting that is lint's business, not the protocol's (Prime Directive #2).
77+
* Only the table is shared; the three traversals stay separate because they walk
78+
* different units for different consumers.
6679
*/
67-
export const REGION_SLOTS: ReadonlyMap<string, readonly string[]> = new Map([
68-
['try_catch', ['try', 'catch']],
69-
['loop', ['body']],
70-
// `parallel` is the odd one: `config.branches[]` is an ARRAY of regions, not
71-
// a region. Handled separately in the walk; named here so the key set stays
72-
// one list.
73-
['parallel', ['branches']],
74-
]);
80+
export const REGION_SLOTS: ReadonlyMap<string, readonly string[]> = new Map(
81+
[...FLOW_REGION_SLOTS_BY_TYPE].map(([type, slots]) => [type, slots.map(s => s.key)]),
82+
);
7583

7684
/** Every config key that may hold region nodes, across all node types. */
77-
export const REGION_CONFIG_KEYS: ReadonlySet<string> = new Set(
78-
[...REGION_SLOTS.values()].flat(),
79-
);
85+
export const REGION_CONFIG_KEYS: ReadonlySet<string> = FLOW_REGION_CONFIG_KEYS;
8086

8187
/**
8288
* Depth cap. Regions are a tree in parsed metadata, so this is not a cycle

packages/spec/api-surface.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2141,6 +2141,9 @@
21412141
"ExecutionStepSkipReasonSchema (const)",
21422142
"FLOW_BUILTIN_NODE_TYPES (const)",
21432143
"FLOW_NODE_EXPRESSION_PATHS (const)",
2144+
"FLOW_REGION_CONFIG_KEYS (const)",
2145+
"FLOW_REGION_SLOTS (const)",
2146+
"FLOW_REGION_SLOTS_BY_TYPE (const)",
21442147
"FLOW_STRUCTURAL_NODE_TYPES (const)",
21452148
"Flow (type)",
21462149
"FlowEdge (type)",
@@ -2155,8 +2158,10 @@
21552158
"FlowNodeSchema (const)",
21562159
"FlowParsed (type)",
21572160
"FlowRegion (type)",
2161+
"FlowRegionArity (type)",
21582162
"FlowRegionParsed (type)",
21592163
"FlowRegionSchema (const)",
2164+
"FlowRegionSlotDecl (interface)",
21602165
"FlowRunGateSummary (type)",
21612166
"FlowRunGateSummarySchema (const)",
21622167
"FlowRunNodeSummary (type)",

packages/spec/src/automation/control-flow.zod.ts

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { z } from 'zod';
4949
import { lazySchema } from '../shared/lazy-schema';
5050
import { FlowNodeSchema, FlowEdgeSchema } from './flow.zod';
5151
import type { FlowNodeParsed, FlowEdgeParsed } from './flow.zod';
52+
import { FLOW_REGION_SLOTS_BY_TYPE } from './region-slots';
5253

5354
// ─── Canonical construct type ids ────────────────────────────────────
5455

@@ -322,46 +323,51 @@ interface RegionSlot {
322323
}
323324

324325
/**
325-
* The region slots one node carries — the single place that knows where each
326-
* ADR-0031 container keeps its nested graph(s). Three passes read it
327-
* ({@link validateControlFlow}, {@link normalizeControlFlowRegions},
328-
* {@link collectFlowGraphs}), which is exactly why it is one function: a fourth
329-
* container construct is added here once, not in three walks that drift.
326+
* Resolve one node's region slots against the shared declaration
327+
* ({@link FLOW_REGION_SLOTS_BY_TYPE}, #4401) — binding each declared slot to
328+
* the value it holds, the Zod schema that value parses as, and a diagnostic
329+
* label.
330+
*
331+
* The three passes in this module read it ({@link validateControlFlow},
332+
* {@link normalizeControlFlowRegions}, {@link collectFlowGraphs}). WHERE the
333+
* slots are is no longer stated here — that moved to `region-slots.ts` so the
334+
* conversion walk and the lint walk read the same list. What stays here is the
335+
* schema half, which is this module's business.
330336
*
331337
* Emits a slot for a declared key even when its value is not region-shaped —
332338
* `validateControlFlow` needs to reject that, not skip it.
333339
*/
334340
function regionSlotsOf(node: FlowNodeParsed): RegionSlot[] {
335341
const cfg = node.config as Record<string, unknown> | undefined;
336342
if (!cfg) return [];
337-
338-
if (node.type === LOOP_NODE_TYPE) {
339-
return cfg.body == null
340-
? []
341-
: [{ raw: cfg.body, key: 'body', label: `loop '${node.id}' body`, schema: FlowRegionSchema }];
342-
}
343-
if (node.type === PARALLEL_NODE_TYPE && Array.isArray(cfg.branches)) {
344-
return cfg.branches.map((raw, index) => ({
345-
raw,
346-
key: 'branches',
347-
index,
348-
label: `parallel '${node.id}' branch ${index}`,
349-
// A branch also carries an optional `name`, which the plain region schema
350-
// (a non-strict `z.object`) would strip.
351-
schema: ParallelBranchSchema,
352-
}));
353-
}
354-
if (node.type === TRY_CATCH_NODE_TYPE) {
355-
const slots: RegionSlot[] = [];
356-
if (cfg.try != null) {
357-
slots.push({ raw: cfg.try, key: 'try', label: `try_catch '${node.id}' try`, schema: FlowRegionSchema });
343+
const declared = FLOW_REGION_SLOTS_BY_TYPE.get(node.type);
344+
if (!declared) return [];
345+
346+
const slots: RegionSlot[] = [];
347+
for (const { key, arity } of declared) {
348+
const value = cfg[key];
349+
if (arity === 'many') {
350+
if (!Array.isArray(value)) continue;
351+
value.forEach((raw, index) => slots.push({
352+
raw,
353+
key,
354+
index,
355+
label: `${node.type} '${node.id}' ${singularize(key)} ${index}`,
356+
// A branch also carries an optional `name`, which the plain region
357+
// schema (a non-strict `z.object`) would strip.
358+
schema: ParallelBranchSchema,
359+
}));
360+
continue;
358361
}
359-
if (cfg.catch != null) {
360-
slots.push({ raw: cfg.catch, key: 'catch', label: `try_catch '${node.id}' catch`, schema: FlowRegionSchema });
361-
}
362-
return slots;
362+
if (value == null) continue;
363+
slots.push({ raw: value, key, label: `${node.type} '${node.id}' ${key}`, schema: FlowRegionSchema });
363364
}
364-
return [];
365+
return slots;
366+
}
367+
368+
/** `branches` → `branch`, for the per-item label of a `many` slot. */
369+
function singularize(key: string): string {
370+
return key.endsWith('es') ? key.slice(0, -2) : key.replace(/s$/, '');
365371
}
366372

367373
/**

packages/spec/src/automation/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33

44
export * from './flow.zod';
5+
export * from './region-slots';
56
export * from './control-flow.zod';
67
export * from './io-node-config.zod';
78
export * from './builtin-node-config.zod';

0 commit comments

Comments
 (0)