Skip to content

Commit ca78860

Browse files
claude[bot]claude
andauthored
fix(lint): a flow's edges list drops a non-record member instead of throwing (#18103)
Closes #16910 Clause-②: no The diff is `packages/lint/src/**` plus one changeset. It touches no path under `packages/spec/src/**` — the contract surface `SUSPECT_TIER_GLOBS` declares — adds no schema, no published error code and no new finding id, so the PR-body carrier agrees with the card's. Self-read with `readClause2Line()` against `CLAUSE2_KEY_LINE` taken fresh from `scripts/pm/check-clause2-carriers.mjs` (three capture groups; the value is the last): `{ kind: 'declared', value: 'no' }`. ## What was wrong `lintFlowPatterns` read `.label` off each member of a flow's `edges` list behind nothing but an `Array.isArray` check, which proves the LIST and never its MEMBERS. A YAML `edges:` item left empty deserialises to `null`, so hand-written metadata turned `objectstack validate` into an uncaught `TypeError` out of a function contractually typed `(stack) => FlowLintFinding[]`. Reproduced on `origin/main` at `66e34d14d9` before any edit — the same frame the card names: ``` edges:[null, valid] threw=YES TypeError: Cannot read properties of null (reading 'label') at scanErrorLabelledEdges (lint-flow-patterns.ts:691:28) edges:[undefined, valid] threw=YES TypeError: Cannot read properties of undefined (reading 'label') edges:['a string', valid] threw=NO findings=1 edges:[42, valid] threw=NO findings=1 CONTROL edges:[valid] findings=1 rules=flow-error-label-not-fault ``` A linter that throws instead of reporting takes the whole gate down on exactly the malformed document it exists to catch, and the author gets a stack trace where a diagnostic belongs. ## The semantics chosen, and why it matches #16751 **The junk member is DROPPED, silently**, through `recordsOf`'s `isRec` filter — the same coercion, from the same one home (`object-graph.ts`), that #16751 chose for the seven flow-NODE-list readers. Two sibling lists on one flow member now cannot disagree about what a malformed member means. Dropping is not reporting: inventing a finding about an entry no author wrote is the phantom half of this same defect class, which is why every assertion below is an equality against a control and not a lower bound. ## Positive control — the valid edge is still judged Not throwing is half a contract; a guard that abandoned the list would satisfy it and would have traded the crash for silence. Pinned by IDENTITY (rule id and `where`), not by "did not throw", at both addressing depths and for all four bad-member kinds: ``` edges:[null, valid] findings=1 flow-error-label-not-fault where="flow 'crm_flow' · edge 'act' → 'done'" edges:[undefined, valid] findings=1 (identical) edges:['a string', valid] findings=1 (identical) edges:[42, valid] findings=1 (identical) CONTROL edges:[valid] findings=1 (identical) ``` and the whole `lintFlowPatterns(...)` result is asserted `toEqual` the control's, so nothing is invented either. The family sweep adds the same pin over the WHOLE rule table (`AUTHORING_RULES`), for `null` / `undefined` / a string / a number / an array, at both depths — `RESIDUAL_THROWS` and `RESIDUAL_INVENTED` both stay empty. ## The census — list-shaped record reads in `lint-flow-patterns.ts` Triage asked how many exist and how many go through `recordsOf`. Read off the file, not off memory. **Nine sites; four went through `recordsOf` before this PR, six do now, three never did and do not need to.** Zeros reported. | # | Site | Before | After | |---|---|---|---| | 1 | `:1430` `recordsOf(stack.flows)` | `recordsOf` | unchanged | | 2 | `:1441` `recordsOf(flow.nodes)` | `recordsOf` | unchanged | | 3 | `:462` `recordsOf(graph.nodes)` — `findDataNodeAnywhere` | `recordsOf` | unchanged | | 4 | `:1541` `recordsOf(graph.nodes)` — main walk | `recordsOf` | unchanged | | 5 | `:1442` `flow.edges` via `Array.isArray` cast | ⛔ member-blind — **THREW** | **`recordsOf`** | | 6 | `:1542` `graph.edges as unknown as AnyRec[]` | ⛔ member-blind — **THREW** | **`recordsOf`** | | 7 | `:1276` `regionNodesOf` — `Array.isArray(nodes)` cast | not `recordsOf`; **member-safe** | unchanged | | 8 | `:866` `cfg.conditions` via `Array.isArray` cast | not `recordsOf`; **member-safe** | unchanged | | 9 | `:1359` `cfg[slot]` region `branches` list | not `recordsOf`; **member-safe** | unchanged | **The eighth triage predicted is real, and it is #6.** It is not reachable through the call-site coercion: a nested region's edge list is read out of a container's open `z.record` config by `collectFlowGraphs` behind only `Array.isArray`. Measured on a tree carrying the top-level repair alone, a nested `body.edges` holding `null` still threw from the identical `scanErrorLabelledEdges` frame. Sites 7–9 stay as they are, deliberately, and each was checked rather than assumed: `regionNodesOf`'s only consumer guards with `if (!child || typeof child !== 'object') continue`, `cfg.conditions` is read with optional chaining (`c?.label`), and the `branches` list hands every member to `regionNodesOf`, which refuses a non-object. Re-pointing them at `recordsOf` would change no behaviour and would add copies for `collection-coercion-single-copy.test.ts` to count. ⭐ **A tenth and eleventh site, one file over, and they are in this PR.** `os validate` runs the rule TABLE, so one throwing reader takes every other rule's verdict down with it. The moment `lintFlowPatterns` stopped throwing, the new sweep arm went red on `validateStackExpressions` (`validate-expressions.ts`), which read the same list through the same double cast at `:1405` and handed `flow.edges` on raw out of a `{ ...flow }` spread. Both are re-pointed here. Fixing only the filed rule would have satisfied the card's letter and left the gate down on the same document. ## Ablation — which reader actually carried the crash Each reader reverted one at a time on top of the final commit, the mutation proved on disk (`grep -cF` on both texts plus a `git hash-object` blob-changed check), then restored. ⛔ Not settled by an exit code and ⛔ not by `trap` alone: every restore is settled by `git hash-object` equal to the HEAD blob AND an empty `git diff HEAD`, both printed. | Ablation | Result | |---|---| | A · `lint-flow-patterns.ts` `flow.edges` back to the cast | **519 passed, 0 failed** — green | | B · `lint-flow-patterns.ts` `graph.edges` back to the cast | **10 failed** / 509 passed | | C · `validate-expressions.ts` `graph.edges` back to the cast | **6 failed** / 513 passed | | D · `validate-expressions.ts` `flow.edges` to a member-blind cast | **519 passed, 0 failed** — green | ⭐ **Reported as measured, not as predicted.** Both edge walks read `graph.edges`, because `collectFlowGraphs` re-exposes whatever array it is handed — so B and C are the load-bearing repairs and A and D are **defence in depth, not the fix**. A and D are kept deliberately: they hand the COERCED array to `collectFlowGraphs` instead of the raw one, which is the discipline the node lists already follow, and they keep two sibling lists on one flow member reading the same way. ⛔ Read them as belt and braces, not as one repair written twice. ⚠️ D's first run was a no-op — the anchor arguments were inverted, the replacement asserted zero occurrences, and a hole in the landing check (it could not tell "swapped" from "never had the new text") reported it as landed. That reading was **discarded as void**, the check was strengthened with the blob-changed assertion above, and the row in the table is the re-run. ## Reverse-read — which existing sentence does this make false **One sentence changed truth value, and it changed in the direction that is easy to miss.** `findDataNodeAnywhere`'s docblock (`lint-flow-patterns.ts:450-453`) says the arrays handed in "are the ones the caller already coerced through `recordsOf`, so a malformed member cannot make this throw". Its call site at `:1505` passes `nodes, edges` — and before this PR `edges` was NOT coerced, so that sentence was **already false about half its subject**. This change makes it true. It is left unedited because it is now accurate as written. **Nothing became false.** The comments at `:1432-1440` and `:1537-1540` are scoped to the node lists and stay true; `:1531-1533` ("a non-array `nodes` still cannot throw") stays true. The `non-record-object-entry.test.ts` narrative sentences carrying issue numbers and dates are history and were not touched. **A bare present-tense count DID rot, and its ratchet caught it rather than my reading it.** `validate-expressions.test.ts`' read-surface table pinned `flow` as reading exactly `['name', 'nodes']`; the new literal `flow.edges` read makes that `['edges', 'name', 'nodes']`. That table exists to force a deliberate visit when a read is added, so the visit is the update, with the reason recorded beside it. `edges` is declared by `ObjectStackSchema.flows[]`, so the guard's second half (every key read is a key the spec declares) still holds and `TRACKED_UNDECLARED_READS` stays empty. **Tests pinning today's throwing behaviour: ZERO.** Searched `toThrow` across `packages/lint/src/*.test.ts` — no case anywhere asserted that a flow edge list throws, in either file. Nothing was re-judged and nothing was deleted. ## Verification - `pnpm --filter @objectstack/lint test` — **103 files, 3817 passed, 5 skipped, 0 failed** (`VERDICT command-exit 0` under the shared verify lock). - `pnpm --filter @objectstack/lint typecheck` — exit 0, including `check:test-typecheck`. - `pnpm --filter '@objectstack/lint^...' build` — dependency closure built before anything was judged (`VERDICT command-exit 0`). - **Repo-wide lint, not narrowed**: `node --stack-size=4000 eslint . --no-inline-config --format json` ran to completion in the foreground at `4b70081eb8` — **6743 files linted, 0 errors, 0 warnings, exit 0**. The population is read from eslint's own config and the count from its `--format json` output; no narrowing was claimed or needed. - **Derived gate family**, from `scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` over the real change set (it takes the merge base itself), reconciled with `--ran` carrying exit codes: **59 derived, 59 accounted, 56 green, 0 UNRUN.** The 3 non-green are `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`, each `exit 3` = the gate's own `PREREQUISITE NOT MET` (they read a whole-repo `dist/`). ⛔ Those are NOT MEASURED — neither a pass nor a failure — and CI builds the repo before running them. ## Changeset — measured, not defaulted **A changeset, ⛔ not `skip-changeset`.** `@objectstack/lint` is published (no `private`), its `files[]` is `["dist", "README.md", "CHANGELOG.md"]`, and after `pnpm --filter @objectstack/lint build` the changed modules are present in `dist/index.js` and `dist/runtime.js` with a positive control (`flow-error-label-not-fault` found) and a negative control (the test-only `underNestedRegionEdges` absent). Behaviour on malformed input changes on a shipped path, so `patch`. No `skip-changeset` label applied. **No new published finding id.** No `FLOW_*` rule constant is added and no new diagnostic is emitted; on every well-formed document the output is byte-identical. The only behaviour that changes is on input that previously crashed. ## 验收备注 - **filed as #18102** — `collectFlowGraphs` (`packages/spec`) filters the NODE members it hands out (#16752) and forwards `edges` untouched, so `FlowGraph.edges` is declared `readonly FlowEdgeParsed[]` and can hold a non-record. Latent, ⛔ not a live crash: both `graph.edges` consumers are coerced by this PR and the producer never dereferences an edge member. Filed rather than fixed here because it is a different package and would widen this PR's verification surface past the card's declared landing point. Deduped first — the REST search endpoint is 403 by proxy design and the MCP search quota was exhausted, so the documented fallback was used (REST list of `domain:devx` issues, `state=all`, 198 read, local grep) with a positive control: `collectFlowGraphs` hit 2 cards including the known #16751, while `FlowGraph.edges` and `isRegionDict` hit zero. - **noted, not filed** — `validate-expressions.ts:1200`'s `collectFlowGraphs({ ...flow, nodes, edges })` still spreads `flow`, though the callee reads only `nodes` and `edges`. The spread is now inert. Successor: none — no PR or person is heading for that line, and removing it would be an unmeasured behaviour change for no gain. - ⚠️ **Scope declaration**: `validate-expressions.ts` and `validate-expressions.test.ts` are edited beyond the card's named file. Taken in place under the bounded-repair exemption — identical defect class, mechanical repair whose shape is already pinned by #15793 at `:1279` in that same file, no open PR touches `packages/lint` (5 open PRs checked), and it adds no verification surface: the sweep arm this card required is what caught it and what pins it. The claim comment is the PM's and carries no file list, so this paragraph is the file-surface declaration. --- _Generated by [Claude Code](https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b00f9f commit ca78860

6 files changed

Lines changed: 279 additions & 6 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
A flow's `edges` list no longer takes the whole authoring gate down when one of its members is not a record — the sibling list #16751's repair did not reach (#16910).
6+
7+
`lintFlowPatterns` read `.label` off each member of `flow.edges` behind nothing but an `Array.isArray` check, which proves the LIST and never its MEMBERS. A YAML `edges:` list item left empty deserialises to `null`, so hand-written metadata turned `objectstack validate` into an uncaught `TypeError` out of a function contractually typed `(stack) => FlowLintFinding[]`:
8+
9+
```
10+
edges:[null, valid] threw=YES TypeError: Cannot read properties of null (reading 'label')
11+
edges:[undefined, valid] threw=YES TypeError: Cannot read properties of undefined (reading 'label')
12+
```
13+
14+
A linter that throws instead of reporting fails hardest on exactly the documents it is most needed for, and the author gets a stack trace where a diagnostic belongs.
15+
16+
- **The junk member is DROPPED, silently**, through `recordsOf` — the same coercion, from the same one home (`object-graph.ts`), that #16751 chose for the seven flow-NODE-list readers, so two sibling lists on one flow member cannot disagree about what a malformed member means.
17+
- ⭐ **The valid edge beside it is still JUDGED.** "No longer throws" is half a contract: a guard that abandoned the list would satisfy it and would have traded the crash for silence. Measured against a control holding the same flow without the junk member, the surviving finding is identical in rule and location, and no finding is invented about an entry no author wrote.
18+
- **Two rules, not one.** `os validate` runs the rule TABLE, so one throwing reader takes every other rule's verdict down with it: once `lintFlowPatterns` stopped throwing, the identical defect surfaced one file over in `validateStackExpressions`, which read the same list through the same double cast. Both are repaired here; repairing only the filed one would have left the gate down on the same document.
19+
- ⭐ **Which reader actually carried the crash, measured by ablation** — both edge walks read `graph.edges`, not the flow's own list, because `collectFlowGraphs` re-exposes whatever array it is handed. Reverting `graph.edges` alone in either file reds the new cases (10 failures in `lintFlowPatterns`, 6 in `validateStackExpressions`); reverting either `flow.edges` coercion alone leaves them green. The two `flow.edges` coercions are therefore **defence in depth, not the load-bearing fix**, and are kept deliberately: they hand the COERCED array to `collectFlowGraphs` rather than the raw one, which is the discipline the node lists already follow, and they keep two sibling lists on one flow member reading the same way. ⛔ Read them as belt and braces, not as one repair written twice.
20+
- **The producer's edge side is still member-blind.** `collectFlowGraphs` filters the nodes it hands out and forwards edges untouched, so `FlowGraph.edges` is declared `FlowEdgeParsed[]` and can contain a non-record. It does not dereference them today, which is why the consumer coercion is sufficient; that asymmetry is filed separately rather than widened here.
21+
- **No new finding id and no new diagnostic.** On every well-formed document the output is byte-identical; the only behaviour that changes is on input that previously crashed.

‎packages/lint/src/lint-flow-patterns.test.ts‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2484,3 +2484,124 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule
24842484
expect(top).toHaveLength(3);
24852485
});
24862486
});
2487+
2488+
/**
2489+
* #16910 — a non-record member of a flow's `edges` list must not throw, and the
2490+
* VALID edge beside it must still be judged.
2491+
*
2492+
* ## Why both halves are asserted, and why the second one is the real one
2493+
*
2494+
* A linter that throws instead of reporting takes the whole gate down on
2495+
* malformed input — and malformed input is exactly what a linter exists to
2496+
* catch, so the tool failed hardest on the documents it is most needed for and
2497+
* the author got a stack trace where a diagnostic belongs. That is the defect.
2498+
* But "no longer throws" is half a contract: a guard that abandoned the whole
2499+
* edge list would satisfy it and would have traded the crash for silence, which
2500+
* is strictly worse than the crash because nothing reports it. So every case
2501+
* below pins the surviving finding by IDENTITY — rule id and `where` — against
2502+
* a control holding the same flow without the junk member, never by "did not
2503+
* throw" alone.
2504+
*
2505+
* ## The semantics, and why it matches #16751
2506+
*
2507+
* The junk member is DROPPED, silently, by `recordsOf`'s `isRec` filter — the
2508+
* same coercion and the same one home (`object-graph.ts`) #16751 chose for the
2509+
* seven flow-NODE-list readers, so the two sibling lists on the same flow
2510+
* member cannot disagree about what a malformed member means. Dropping is not
2511+
* reporting: a rule that INVENTED a finding about an entry no author wrote is
2512+
* the phantom half of this same defect class, which is why the control
2513+
* comparison below is an equality and not a `toBeGreaterThan`.
2514+
*
2515+
* ## Both addressing depths
2516+
*
2517+
* The flow's own list and a nested region's list are separate readers reached by
2518+
* separate routes — the second arrives through `collectFlowGraphs`, out of a
2519+
* container's open `z.record` config, behind nothing but `Array.isArray`, so no
2520+
* coercion at the call site can reach it. The top-level repair alone left it
2521+
* throwing; measured, not assumed.
2522+
*/
2523+
describe('a non-record member of a flow `edges` list (#16910)', () => {
2524+
/** Labelled like an error path, left at the default type: the #3863 shape. */
2525+
const VALID_EDGE = { source: 'act', target: 'done', label: 'error' };
2526+
const ENDPOINTS = [
2527+
{ id: 'start', type: 'start', config: {} },
2528+
{ id: 'act', type: 'create_record', config: {} },
2529+
{ id: 'done', type: 'end', config: {} },
2530+
];
2531+
2532+
/** The four shapes a raw `edges` list can hold that are not a record. */
2533+
const JUNK: readonly (readonly [string, unknown])[] = [
2534+
['null', null],
2535+
['undefined', undefined],
2536+
['a string', 'x'],
2537+
['a number', 42],
2538+
];
2539+
2540+
const topLevel = (edges: readonly unknown[]) => ({
2541+
objects: [{ name: 'crm_account', fields: [{ name: 'name', type: 'text' }] }],
2542+
flows: [{ name: 'crm_flow', nodes: ENDPOINTS, edges }],
2543+
});
2544+
2545+
const nestedRegion = (edges: readonly unknown[]) => ({
2546+
objects: [{ name: 'crm_account', fields: [{ name: 'name', type: 'text' }] }],
2547+
flows: [{
2548+
name: 'crm_flow',
2549+
nodes: [
2550+
{ id: 'start', type: 'start', config: {} },
2551+
{ id: 'lp', type: 'loop', config: { collection: 'x', body: { nodes: ENDPOINTS, edges } } },
2552+
],
2553+
edges: [],
2554+
}],
2555+
});
2556+
2557+
const errorLabelFindings = (stack: unknown) =>
2558+
lintFlowPatterns(stack as Record<string, unknown>)
2559+
.filter((f) => f.rule === FLOW_ERROR_LABEL_NOT_FAULT);
2560+
2561+
describe.each([
2562+
["the flow's own list", topLevel, "flow 'crm_flow' · edge 'act' → 'done'"],
2563+
["a nested region's list", nestedRegion, "flow 'crm_flow' · loop 'lp' body · edge 'act' → 'done'"],
2564+
])('%s', (_label, build, expectedWhere) => {
2565+
it('judges the valid edge with no junk member beside it (the control)', () => {
2566+
const fnds = errorLabelFindings(build([VALID_EDGE]));
2567+
expect(fnds).toHaveLength(1);
2568+
expect(fnds[0].where).toBe(expectedWhere);
2569+
});
2570+
2571+
it.each(JUNK)('drops %s and still judges the valid edge beside it', (_shape, junk) => {
2572+
// Half one: the rule returns rather than throwing. `lintFlowPatterns` is
2573+
// typed `(stack) => FlowLintFinding[]` and its docblock promises it never
2574+
// throws; before this repair a `null` here broke that promise outright.
2575+
expect(() => lintFlowPatterns(build([junk, VALID_EDGE]) as Record<string, unknown>)).not.toThrow();
2576+
2577+
// Half two — the one that matters: the surviving edge is still JUDGED,
2578+
// by identity against the control, so a guard that abandoned the list
2579+
// reds here instead of passing half the assertion.
2580+
const fnds = errorLabelFindings(build([junk, VALID_EDGE]));
2581+
expect(fnds).toHaveLength(1);
2582+
expect(fnds[0].where).toBe(expectedWhere);
2583+
});
2584+
2585+
it.each(JUNK)('invents no finding about %s, which no author wrote', (_shape, junk) => {
2586+
// The phantom half of the same defect class: dropping a member must be
2587+
// invisible to the rule table in BOTH directions, so this is an equality
2588+
// against the control and not a lower bound.
2589+
expect(lintFlowPatterns(build([junk, VALID_EDGE]) as Record<string, unknown>))
2590+
.toEqual(lintFlowPatterns(build([VALID_EDGE]) as Record<string, unknown>));
2591+
});
2592+
});
2593+
2594+
/**
2595+
* The whole gate, not one rule: `os validate` runs the rule TABLE, so a single
2596+
* throwing reader takes every other rule's verdict down with it. Measured on
2597+
* this input, `validateStackExpressions` threw from its own `graph.edges`
2598+
* reader after `lintFlowPatterns` stopped — the same defect one file over,
2599+
* repaired in the same change for the same reason.
2600+
*/
2601+
it.each(JUNK)('takes no rule in the authoring table down with %s', (_shape, junk) => {
2602+
for (const rule of AUTHORING_RULES) {
2603+
expect(() => rule.run(topLevel([junk, VALID_EDGE]) as Record<string, unknown>, {}), rule.name).not.toThrow();
2604+
expect(() => rule.run(nestedRegion([junk, VALID_EDGE]) as Record<string, unknown>, {}), rule.name).not.toThrow();
2605+
}
2606+
});
2607+
});

‎packages/lint/src/lint-flow-patterns.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1438,8 +1438,17 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
14381438
// array and re-exposes the same objects), so coercing only for the local
14391439
// read relocates the crash into `packages/spec` instead of removing it —
14401440
// measured on #15793, and measured again here.
1441+
//
1442+
// `edges` is the SAME reader one list over, and #16751 reached only the
1443+
// node lists: `scanErrorLabelledEdges` read `.label` off each member and a
1444+
// `null` edge threw out of a function contractually typed
1445+
// `(stack) => Finding[]` (#16910). A linter that throws instead of
1446+
// reporting takes the gate down on exactly the malformed document it
1447+
// exists to catch, so the author gets a stack trace where a diagnostic
1448+
// belongs. Same coercion, same home, and the same onward-handoff
1449+
// condition: the COERCED array is what goes to `collectFlowGraphs` below.
14411450
const nodes = recordsOf(flow.nodes);
1442-
const edges = Array.isArray(flow.edges) ? (flow.edges as AnyRec[]) : [];
1451+
const edges = recordsOf(flow.edges);
14431452

14441453
// (a) #1874 — date-equality time condition on a record-change start node.
14451454
const start = nodes.find((n) => n.type === 'start');
@@ -1539,7 +1548,16 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
15391548
// at the call site above, and a nested region's node list arrives here
15401549
// with only `Array.isArray` behind it.
15411550
const graphNodes = recordsOf(graph.nodes);
1542-
const graphEdges = graph.edges as unknown as AnyRec[];
1551+
// `graph.edges` needed the identical repair, and it is NOT covered by the
1552+
// coercion at the call site above: a nested region's edge list is read
1553+
// out of a container's open `z.record` config by `collectFlowGraphs`
1554+
// behind nothing but `Array.isArray` (`control-flow.zod.ts`, where the
1555+
// node side is member-filtered since #16752 and the edge side is not), so
1556+
// it reaches here carrying the producer's word about its members rather
1557+
// than a check. Measured on this tree: a nested `body.edges` holding a
1558+
// `null` threw from the same `scanErrorLabelledEdges` frame as the
1559+
// top-level list did (#16910). Belt and braces, ⛔ not one repair twice.
1560+
const graphEdges = recordsOf(graph.edges);
15431561

15441562
// (b) #1315 — wrong interpolation syntax in any node's template values. Flow
15451563
// node values use SINGLE braces; double-brace `{{ }}` and bare `$ref.x`

‎packages/lint/src/non-record-object-entry.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,88 @@ const underFlow = (key: string, valid?: AnyRec): SweptCollection => ({
322322
valid,
323323
});
324324

325+
/**
326+
* A judgeable flow EDGE: labelled like an error path, left at the default type,
327+
* out of a node whose executor does not select by label — the `#3863` shape, so
328+
* a control built from it draws exactly one finding
329+
* (`flow-error-label-not-fault`) and the population pin below has something to
330+
* count on the edge side too.
331+
*/
332+
const VALID_EDGE: AnyRec = { source: 'act', target: 'done', label: 'error' };
333+
334+
/** The nodes {@link VALID_EDGE} runs between, so the edge is judged in context. */
335+
const EDGE_ENDPOINT_NODES: readonly AnyRec[] = [
336+
VALID_NODE,
337+
{ id: 'act', type: 'create_record', config: { objectName: 'crm_account' } },
338+
{ id: 'done', type: 'end', config: {} },
339+
];
340+
341+
/**
342+
* A flow's own EDGE list — `stack.flows[].edges` (#16910).
343+
*
344+
* ## Why this is a row of its own and not covered by `underFlow('nodes')`
345+
*
346+
* {@link underFlow} addresses ONE key of a flow member, and every existing row
347+
* built with it addresses `nodes`. `edges` is a second list on the same member,
348+
* read by the same function, four lines from the same repair — and #16751
349+
* re-pointed the seven NODE-list readers without reaching it. That is precisely
350+
* why this sweep is written over a table: the reader was fixed, the sibling
351+
* list beside it was not, and nothing in the suite could say so because no arm
352+
* addressed it. `scanErrorLabelledEdges` read `.label` off each member, so a
353+
* YAML `edges:` item left empty (`null`) threw a `TypeError` out of a function
354+
* contractually typed `(stack) => Finding[]`.
355+
*
356+
* The constructor supplies real endpoint nodes rather than reusing
357+
* {@link underFlow}: the point of the population pin here is that the VALID
358+
* edge beside the junk one is still JUDGED, and an edge judged against an empty
359+
* node table is a weaker control than one judged in context.
360+
*/
361+
const underFlowEdges = (valid?: AnyRec): SweptCollection => ({
362+
label: 'flows[].edges',
363+
stack: (members) => ({
364+
objects: [VALID_OBJECT],
365+
flows: [{ name: 'crm_flow', nodes: [...EDGE_ENDPOINT_NODES], edges: members }],
366+
}),
367+
valid,
368+
});
369+
370+
/**
371+
* A NESTED region's EDGE list — `flows[].nodes[].config.body.edges` (#16910).
372+
*
373+
* The eighth reader, and the one the card that filed `flows[].edges` predicted
374+
* would exist ("an eighth is likelier than not"). It is NOT reachable through
375+
* {@link underFlowEdges}: this list is read out of a container's open
376+
* `z.record` config by `collectFlowGraphs` behind nothing but `Array.isArray`
377+
* — the node side of that same producer walk has been member-filtered since
378+
* #16752, the edge side has not — so it arrives at `lint-flow-patterns.ts`'
379+
* `graph.edges` reader carrying the producer's word about its members rather
380+
* than a check, exactly as `graph.nodes` did before #16751. Measured on the
381+
* tree that carried the top-level repair alone: a `null` here still threw, from
382+
* the identical `scanErrorLabelledEdges` frame.
383+
*
384+
* ⛔ The consumer coercion at the call site cannot reach it, which is the whole
385+
* reason this is a separate arm rather than a second shape of the one above.
386+
*/
387+
const underNestedRegionEdges = (valid?: AnyRec): SweptCollection => ({
388+
label: 'flows[].nodes[].config.body.edges',
389+
stack: (members) => ({
390+
objects: [VALID_OBJECT],
391+
flows: [{
392+
name: 'crm_flow',
393+
nodes: [
394+
VALID_NODE,
395+
{
396+
id: 'lp',
397+
type: 'loop',
398+
config: { collection: 'x', body: { nodes: [...EDGE_ENDPOINT_NODES], edges: members } },
399+
},
400+
],
401+
edges: [],
402+
}],
403+
}),
404+
valid,
405+
});
406+
325407
/**
326408
* A NESTED region's node list — `flows[].nodes[].config.body.nodes` (#15793).
327409
*
@@ -381,6 +463,10 @@ const SWEPT_COLLECTIONS: readonly SweptCollection[] = [
381463
// is a container's sub-graph, which only the producer can hand out.
382464
underFlow('nodes', VALID_NODE),
383465
underNestedRegion(VALID_NODE),
466+
// The EDGE lists beside them (#16910) — the same reader, the sibling list
467+
// #16751 did not reach, at both addressing depths.
468+
underFlowEdges(VALID_EDGE),
469+
underNestedRegionEdges(VALID_EDGE),
384470
// Per-object sub-collections the same readers walk.
385471
underObject('fields', VALID_FIELD),
386472
underObject('actions'),

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2793,8 +2793,15 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
27932793
keys: () => shapeKeysOf(ObjectStackSchema.shape.hooks),
27942794
},
27952795
{
2796+
// [#16910] `edges` joined `nodes` here when the flow's EDGE list stopped
2797+
// being read out of the `{ ...flow }` spread and became a literal
2798+
// `flow.edges` read through `recordsOf`. Adding it is the deliberate visit
2799+
// this table exists to force, and the read is literal for exactly the
2800+
// reason the `f.options` note below gives: the spread handed the raw array
2801+
// to `collectFlowGraphs` while hiding the surface from this scan, which is
2802+
// how a `null` edge member reached the edge walk and threw.
27962803
receiver: 'flow',
2797-
expected: ['name', 'nodes'],
2804+
expected: ['edges', 'name', 'nodes'],
27982805
declaredBy: 'ObjectStackSchema.flows[]',
27992806
keys: () => shapeKeysOf(ObjectStackSchema.shape.flows),
28002807
},

0 commit comments

Comments
 (0)