fix(lint): a flow's edges list drops a non-record member instead of throwing - #18103
Conversation
… throwing `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[]`. 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. Re-pointed at `recordsOf` — the same coercion, from the same one home (`object-graph.ts`), that the seven flow-NODE-list readers were re-pointed at, so two sibling lists on one flow member cannot disagree about what a malformed member means. The junk member is dropped silently; the valid edge beside it is still judged, pinned by identity against a control rather than by "did not throw" alone. 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`, reading the same list through the same double cast. Repairing only the filed one would have left the gate down on the same document. Which reader carried the crash was measured by ablation rather than assumed: 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; reverting either `flow.edges` coercion alone leaves them green. Those two are kept as defence in depth — they hand the coerced array on rather than the raw one, the discipline the node lists already follow — and are labelled as such, not as the fix. The family sweep grows both edge arms beside the existing node ones, at both addressing depths, so the repair is pinned in both directions and the next inline cast cannot re-open it silently. `validateStackExpressions`' read-surface ratchet gains `edges` as the deliberate table visit it is designed to force. Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b07e938bce807954d29e0a1f171f884f47376110 && git checkout b07e938bce807954d29e0a1f171f884f47376110
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a26a114d7e0f29c2459b225f5a4df2846417b49d 4b70081eb861b70a1c82cbdfe8ff7135b9e512f8 && git checkout -B drift-repro a26a114d7e0f29c2459b225f5a4df2846417b49d && git merge --no-ff 4b70081eb861b70a1c82cbdfe8ff7135b9e512f8
node scripts/docs-audit/affected-docs.mjs --json a26a114d7e0f29c2459b225f5a4df2846417b49d
|
|
PM 复核:收下,已 undraft + 武装。 本班最强的一份交付,有四处值得单独记。 1. 分诊预言的第八处是真的 —— 本席逐行核过分诊写:「Seven were re-pointed,
const graphEdges = graph.edges as unknown as AnyRec[]; // ← 双重强转,正是会抛的形状分支上 2. ⭐⭐⭐ 消融测出来的方向与预期相反,而你按测到的报四条腿单独回退:A( 本席去核了机制: ⭐ 而你保留了 A/D 并写明理由(让被强制过的那个数组被交下去),⛔ 没有因为「消融时是绿的」就把它们当冗余删掉。一条消融绿,只说明它今天不是唯一的防线,⛔ 不说明它可以去掉。 这个分辨很多人会做反。 3. ⭐⭐⭐ D 的第一次运行是个 NO-OP,而你把那次读数作废了
⇒ 一次 anchor 参数写反的落盘检查,会把「什么都没改」读成「改好了」,于是后面那条绿是假的。⭐ 你没有把它当成一个结果报上来,而是判定那次读数无效、加固检查、重跑。 ⛔ 这正是 #16310 那次「JS 里最后一个重复键赢, 4. ⭐⭐ 反向读的「变真」那一条,是本班最难发现的一种
⇒ ⭐ 那句话此前对它一半的主语就是假的,而这次改动让它变真。你没有改它,因为它现在照字面就是准确的。 ⛔ 一次只查「我让哪句话变假」的反向读,会完全看不见这一类 —— 它不会红,也不会被任何人发现,而它一直在向读者保证一件当时不成立的事。 5. 语义、越界与其余
立卡#18102( Generated by Claude Code |
Closes #16910
Clause-②: no
The diff is
packages/lint/src/**plus one changeset. It touches no path underpackages/spec/src/**— the contract surfaceSUSPECT_TIER_GLOBSdeclares — adds no schema, no published error code and no new finding id, so the PR-body carrier agrees with the card's. Self-read withreadClause2Line()againstCLAUSE2_KEY_LINEtaken fresh fromscripts/pm/check-clause2-carriers.mjs(three capture groups; the value is the last):{ kind: 'declared', value: 'no' }.What was wrong
lintFlowPatternsread.labeloff each member of a flow'sedgeslist behind nothing but anArray.isArraycheck, which proves the LIST and never its MEMBERS. A YAMLedges:item left empty deserialises tonull, so hand-written metadata turnedobjectstack validateinto an uncaughtTypeErrorout of a function contractually typed(stack) => FlowLintFinding[].Reproduced on
origin/mainat66e34d14d9before any edit — the same frame the card names: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'sisRecfilter — 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:and the whole
lintFlowPatterns(...)result is assertedtoEqualthe control's, so nothing is invented either. The family sweep adds the same pin over the WHOLE rule table (AUTHORING_RULES), fornull/undefined/ a string / a number / an array, at both depths —RESIDUAL_THROWSandRESIDUAL_INVENTEDboth stay empty.The census — list-shaped record reads in
lint-flow-patterns.tsTriage asked how many exist and how many go through
recordsOf. Read off the file, not off memory. Nine sites; four went throughrecordsOfbefore this PR, six do now, three never did and do not need to. Zeros reported.:1430recordsOf(stack.flows)recordsOf:1441recordsOf(flow.nodes)recordsOf:462recordsOf(graph.nodes)—findDataNodeAnywhererecordsOf:1541recordsOf(graph.nodes)— main walkrecordsOf:1442flow.edgesviaArray.isArraycastrecordsOf:1542graph.edges as unknown as AnyRec[]recordsOf:1276regionNodesOf—Array.isArray(nodes)castrecordsOf; member-safe:866cfg.conditionsviaArray.isArraycastrecordsOf; member-safe:1359cfg[slot]regionbrancheslistrecordsOf; member-safeThe 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.recordconfig bycollectFlowGraphsbehind onlyArray.isArray. Measured on a tree carrying the top-level repair alone, a nestedbody.edgesholdingnullstill threw from the identicalscanErrorLabelledEdgesframe.Sites 7–9 stay as they are, deliberately, and each was checked rather than assumed:
regionNodesOf's only consumer guards withif (!child || typeof child !== 'object') continue,cfg.conditionsis read with optional chaining (c?.label), and thebrancheslist hands every member toregionNodesOf, which refuses a non-object. Re-pointing them atrecordsOfwould change no behaviour and would add copies forcollection-coercion-single-copy.test.tsto count.⭐ A tenth and eleventh site, one file over, and they are in this PR.
os validateruns the rule TABLE, so one throwing reader takes every other rule's verdict down with it. The momentlintFlowPatternsstopped throwing, the new sweep arm went red onvalidateStackExpressions(validate-expressions.ts), which read the same list through the same double cast at:1405and handedflow.edgeson 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 -cFon both texts plus agit hash-objectblob-changed check), then restored. ⛔ Not settled by an exit code and ⛔ not bytrapalone: every restore is settled bygit hash-objectequal to the HEAD blob AND an emptygit diff HEAD, both printed.lint-flow-patterns.tsflow.edgesback to the castlint-flow-patterns.tsgraph.edgesback to the castvalidate-expressions.tsgraph.edgesback to the castvalidate-expressions.tsflow.edgesto a member-blind cast⭐ Reported as measured, not as predicted. Both edge walks read
graph.edges, becausecollectFlowGraphsre-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 tocollectFlowGraphsinstead 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.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 throughrecordsOf, so a malformed member cannot make this throw". Its call site at:1505passesnodes, edges— and before this PRedgeswas 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-1440and:1537-1540are scoped to the node lists and stay true;:1531-1533("a non-arraynodesstill cannot throw") stays true. Thenon-record-object-entry.test.tsnarrative 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 pinnedflowas reading exactly['name', 'nodes']; the new literalflow.edgesread 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.edgesis declared byObjectStackSchema.flows[], so the guard's second half (every key read is a key the spec declares) still holds andTRACKED_UNDECLARED_READSstays empty.Tests pinning today's throwing behaviour: ZERO. Searched
toThrowacrosspackages/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 0under the shared verify lock).pnpm --filter @objectstack/lint typecheck— exit 0, includingcheck:test-typecheck.pnpm --filter '@objectstack/lint^...' build— dependency closure built before anything was judged (VERDICT command-exit 0).node --stack-size=4000 eslint . --no-inline-config --format jsonran to completion in the foreground at4b70081eb8— 6743 files linted, 0 errors, 0 warnings, exit 0. The population is read from eslint's own config and the count from its--format jsonoutput; no narrowing was claimed or needed.scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackover the real change set (it takes the merge base itself), reconciled with--rancarrying exit codes: 59 derived, 59 accounted, 56 green, 0 UNRUN. The 3 non-green arecheck:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt, eachexit 3= the gate's ownPREREQUISITE NOT MET(they read a whole-repodist/). ⛔ 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/lintis published (noprivate), itsfiles[]is["dist", "README.md", "CHANGELOG.md"], and afterpnpm --filter @objectstack/lint buildthe changed modules are present indist/index.jsanddist/runtime.jswith a positive control (flow-error-label-not-faultfound) and a negative control (the test-onlyunderNestedRegionEdgesabsent). Behaviour on malformed input changes on a shipped path, sopatch. Noskip-changesetlabel 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.验收备注
collectFlowGraphshands out aFlowGraphwhoseedgescan hold a non-record — the node side #16752 filtered, the edge side it did not #18102 —collectFlowGraphs(packages/spec) filters the NODE members it hands out (spec:collectFlowGraphsdereferences a non-record member of a NESTED region's node list — its own walk, not the caller's #16752) and forwardsedgesuntouched, soFlowGraph.edgesis declaredreadonly FlowEdgeParsed[]and can hold a non-record. Latent, ⛔ not a live crash: bothgraph.edgesconsumers 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 ofdomain:devxissues,state=all, 198 read, local grep) with a positive control:collectFlowGraphshit 2 cards including the known lint: two more flow-node-list readers throw on a non-record member —lintFlowPatternsandcollectFlowVariableNames#16751, whileFlowGraph.edgesandisRegionDicthit zero.validate-expressions.ts:1200'scollectFlowGraphs({ ...flow, nodes, edges })still spreadsflow, though the callee reads onlynodesandedges. 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.validate-expressions.tsandvalidate-expressions.test.tsare 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 lint: validateStackExpressions throws on a non-record entry of a flow's nodes list — two inline casts no collection sweep can reach #15793 at:1279in that same file, no open PR touchespackages/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