fix(lint): flow-decision-unconditional-branch reports the decision that gates on nothing - #16382
Conversation
…at gates on nothing `if (gated.length === 0) continue` dropped the one shape the rule most needed to see: a `decision` whose out-edges carry no `condition` and no `isDefault` and whose node declares no `config.conditions[]`. The rule was framed as "an unconditional edge undercuts a guarded one", so zero guarded edges read as nothing to undercut. That decision selects no branch at all — every successor runs on every pass and the gateway is decoration — and it is the harder shape to notice in review, because the node still says `type: 'decision'`. Same rule id, its own message. A decision routing by `config.conditions[]` labels alone is not inert and stays silent; an unmatched declared label stays the gating `flow-branch-label-unmatched` alone, with no second finding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
`check:doc-authoring` reads a runtime string as text that reaches authors, operators and generated surfaces, none of whom can resolve a bare id. The provenance stays where the reader who can resolve it will be — the adjacent source comment. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 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 9b18ea98ffa4f96c5d549f2d3003c940883fa699 && git checkout 9b18ea98ffa4f96c5d549f2d3003c940883fa699
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0374bcba9f7108a4fec6624e9f4c39f712d618c7 80202cc59d22c425a1dda8f3d47044b6a60b9c78 && git checkout -B drift-repro 0374bcba9f7108a4fec6624e9f4c39f712d618c7 && git merge --no-ff 80202cc59d22c425a1dda8f3d47044b6a60b9c78
node scripts/docs-audit/affected-docs.mjs --json 0374bcba9f7108a4fec6624e9f4c39f712d618c7 |
|
Standing-down note — shard 5 timed out twice; the aggregate read red honestly; the queue regrouped a third time — PM seat This PR (head
Not this PR's failure: the diff is Correction at 18:07Z, before any action was taken: the queue did NOT remove this PR — it regrouped it a third time (group run Queue state at 18:05Z: the timeline had not yet shown removed_from_merge_queue (events lag); re-arm reads the entry event, never the auto_merge field. Generated by Claude Code |
Fixes #16093
lintFlowPatternsreportedflow-decision-unconditional-branchonly when a decision had both gated and ungated out-edges. One line decided that:So the strictly worse shape — a
decisionwhose out-edges carry noconditionand noisDefault, and whose node declares noconfig.conditions[]— fell out of the loop before any finding was pushed. The comment was the defect's own confession: the rule was framed as "an unconditional edge undercuts a guarded one", and under that framing zero guarded edges honestly read as nothing to undercut. The framing was wrong, not the implementation. A decision with no guard anywhere does not have less wrong with it — it selects no branch at all, every successor runs on every pass, and the gateway is decoration. It is also the harder shape to notice in review, because the node still saystype: 'decision'.The runtime confirms the boundary, so the linter uses the engine's own predicate
The decision executor in
packages/services/service-automation/src/builtin/logic-nodes.tsdivides on exactly one condition, and says so in its own docblock:config.conditions— the first matching entry'slabelis the branch, and traversal restricts itself to the out-edge carrying that label;condition/isDefault) and the node reports no branch."execute()implements that asif (conditions.length === 0) return { success: true };. The new lint branch testsArray.isArray(cfg.conditions) && cfg.conditions.length > 0— the same boundary the executor draws, rather than a second hand-rolled reading of it. That is also why a bare edgelabelis not treated as a guard here: for a decision, a label selects a path only when the node reports abranchLabel, which requiresconditions[]. The mixed branch of this same rule has always counted a labelled edge the decision cannot select as ungated, so both halves of one rule id now answer the same way.What changed
Same rule id, same
warningtier, with its own message. The mixed shape keeps its existing wording and its single finding; the fully-inert shape gets a sentence that names the out-edges running unconditionally and offers the three real fixes (aconditionper branch plusisDefault: trueon the fallback, aconfig.conditions[]whoselabelmatches an out-edge, or droppingtype: 'decision'for the node the gateway already behaves as).A new rule id was considered and deliberately not taken — see the note at the end.
Two shapes stay silent, on purpose, and both are pinned:
conditions[]) — the control the triage named as the easiest thing to break;config.conditions[]alone with bare out-edges — its branching is declared on the node, so it is not inert.And a decision declaring a label no out-edge claims stays the gating
flow-branch-label-unmatchedalone: the inert branch excludes it rather than piling a second, contradictory finding on the same node.Premise check, before the first edit
The card's measurement was taken on the pinned
@objectstack/lint@17.3.0as installed by hotcrm, not on this tree, so the gap was re-established here first with the package's own harness. Two readings, one of which corrected the dispatch's expectation:packages/lint/src/lint-flow-patterns.ts, thegated.length === 0short-circuit), underrule: FLOW_DECISION_UNCONDITIONAL_BRANCH. Confirmed.lint-flow-patterns.test.tscarriedit('does NOT flag a decision with no guarded edge at all — nothing to undercut'), assertingtoHaveLength(0)on precisely the shape this card is about. The defect was pinned. That fixture pins the branch being removed, so it was replaced outright rather than edited — it is now the positive case.Red-first on this tree, against the unmodified rule: the four new positive cases failed with
expected [] to have a length of 1 but got +0— zero findings on the fully-inert decision — while all 135 other cases, including every negative control, passed.One existing negative control had to be re-scoped, and why that is not a weakening
flow-error-label-not-fault's casedoes NOT flag label:'error' out of a decision nodeasserted a global zero over all findings. That global zero held only because the fully-inert decision was invisible: itsdecisionfixture declares noconditions[]and guards no edge, so it now correctly draws aflow-decision-unconditional-branchwarning of its own. Exempting labelled edges to preserve the zero would make one rule id answer two ways, so instead the assertion was scoped to the rule it is about. What the case tests is unchanged: an error-ish label out of a branching node is not the error-routing footgun. The reasoning is recorded at the assertion.Verification
Head sha
80202cc59.pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/lint-flow-patterns.test.ts— 139 passed, exit 0.pnpm --filter @objectstack/lint testthrough the shared lock —VERDICT command-exit 0, 100 files / 3407 passed, 5 skipped.pnpm --filter @objectstack/lint typecheckthrough the lock —VERDICT command-exit 0(including the test-layer debt check).turbo ls --affectednames 54 packages, which is the transitive closure below a leaf dependency rather than the set that can observe this change. The only call site of the changed function in the repo isAUTHORING_RULESinpackages/lint/src/authoring-rules.ts, reached byos validate/build/lintand the runtime publish gate, and the new finding iswarning-tier so it fails neither. Every direct dependent of@objectstack/lintwas run instead:metadata-protocol,objectql,cloud-connection,mcp,example-showcase,platform-objects(all via turbotest, 88 tasks, allVERDICT command-exit 0) and@objectstack/cli(--project unit, 181 files / 2453 passed, 6 expected-fail). The CLI'sintegrationtier is declared to CI — this diff touches no integration-layer file and no spawn entrypoint.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives for this diff were run at the head sha: 54 exit 0.dispatch-gates --ranreconciles: 54 derived, 54 run, 0 NOT-MEASURED, 0 UNRUN. That set includescheck:docs-transcript-drift(green — the signal that no new rule id crept in),check:nul-bytes,check:type-check-coverage,check:type-check-debt,check:test-source-aliasandcheck:cross-package-test-inputs.check:doc-authoringcaught a real defect in the first draft and is worth naming: the newhintended with a bare tracker id, and a runtime string reaches authors and operators, who cannot resolve one. The id was stripped from the string, not added to the baseline; the provenance stays in the adjacent source comment.eslint . --no-inline-config --format jsonwas run over the whole repo rather than narrowed: 6226 files, 0 errors, 0 warnings, exit 0.Ablation
The
continuewas restored on top of the committed fix and the suite re-run. The mutation was proven on disk before reading any result — the new message text went 2 occurrences to 0, the original short-circuit line 0 to 1, and the blob hash differed fromHEAD's. Direction: red, exactly as predicted — the same 4 new positive cases failed and 135 passed, so every negative control stayed green under ablation. Restore was proven by blob hash equal toHEADplus an emptygit diff HEAD, not by an exit code. The first ablation attempt aborted on its own guard: the guard's expected occurrence count for a prose phrase was mis-stated (the phrase appears twice in comments, not once). The mutation itself had landed correctly; only the anchor was corrected, and the run was not repeated until something happened to land.Scope notes
conditions[]is indistinguishable from anoopnode, so a distinct message may read better than folding it into the mixed-branch wording. That is exactly what this PR delivers — a distinct message under the existing id. A distinct id would add an exported constant, a registry entry and a rule-count move across the docs transcripts, which is out of this card's dispatched scope. Nothing found while implementing argues the id must split: both shapes are one defect class (a decision whose declared routing does not route), they share a severity tier, and a test pins that they do not double-report.gated.length === 0, which is the complement of the situation card 15429 is about. The two also agree on theconditions[]versus edge-conditionrelationship — one mechanism per decision, never both — which is the shared advice the triage asked to keep consistent.origin/mainat report time. None of those commits touch either changed file;dispatch-gatesflags the staleness only for the closing-keyword guard family, which is not in this diff's derived set.🤖 Generated with Claude Code
https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
Generated by Claude Code