Repository navigation
fix(lint): resolve flow-variable template roots, and gate the record trigger on the record root alone - #18583
Conversation
… trigger on the record root alone
`validate-flow-template-paths` could resolve exactly one template root,
`record`, and skipped any flow that was not record-triggered. Both limits
hid the same failure it exists to catch: a `get_record` node declares
`objectName` and `outputVariable` in one config, and a `loop` declares
`collection` and `iteratorVariable`, so the names they bind hold records of
a known object — statically, from the authored metadata alone.
- Resolve those variable roots and judge their `.<field>` paths with the
existing `flow-template-unknown-field` and `flow-template-lookup-traversal`
rules, at the same position-based severity (ERROR inside a filter-guarded
CRUD node's `filter`, WARNING elsewhere).
- Apply the record-trigger gate to the `record` root alone. A `schedule`
flow's `get_record` output is as statically typed as a record-change
flow's; `{record.…}` on such a flow stays unjudged as before.
- A variable root resolves only when nothing else in the flow can bind the
name (declared variables, assignment targets, other `outputVariable`s,
`indexVariable`/`errorVariable`, node ids, flattened trigger fields).
Ambiguous means silent — the conservatism the rule already had.
The trigger-root messages and hints are unchanged byte-for-byte.
Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
…biguity silence Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
Poisoning a root because `flow.variables` declares it would make the new resolution inert on the canonical sweep shape the platform's own examples ship (app-todo declares `tasksToRemind` beside the `get_record` that fills it). `seedDeclaredVariables` runs first and the node's write replaces what it seeded, so the declaration is one slot, not two writers. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
…te root resolution Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
…-authoring) A runtime string reaches authors and generated surfaces, none of whom can resolve a tracker id; the adjacent source comment keeps the anchor. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also 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 04ebd39c2ffbcf83fb97da671696d6b668238e37 && git checkout 04ebd39c2ffbcf83fb97da671696d6b668238e37
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5ed7ad9df84a319a9842ffceb97c030406a508a3 c5a83815553b994e4c34a1ec77b2d29235fd4958 && git checkout -B drift-repro 5ed7ad9df84a319a9842ffceb97c030406a508a3 && git merge --no-ff c5a83815553b994e4c34a1ec77b2d29235fd4958
node scripts/docs-audit/affected-docs.mjs --json 5ed7ad9df84a319a9842ffceb97c030406a508a3
|
复核:ACCEPT —— 硬停条件被正面撞上了一次,而它按规矩停在了正确的一侧
⭐ 那条硬停:加宽之后本仓自己的流会不会变红派发令写死:⛔ 不许为了让 CI 绿而把规则缩回去;⛔ 也不许让这张 PR 带着红的必需上下文落地;两者都做不到就停下回报。 它把四个示例 app 各跑了两遍(修前的规则 restore 进树,再跑 HEAD): 那一条是 advisory warning、真阳性: ⇒ 没有变红(warning 不动退出码,且没有 CI 作业对 ⇒ 这条新发现由本席立卡,⛔ 不在本 PR 里修。 条款②:本席按实际 diff 重判,⛔ 不按卡片语义⇒ 零导出变动,且本改动是更多拒绝 = 收窄接受集 ⇒ 两个方向都测了,而且消融腿就是阳性对照把修前的规则 restore 回树(on-disk 变更以 blob 哈希 ⭐ 消融腿同时充当了两件事:证明新 pin 不空转(修前必红,且红的签名逐字可辨),与证明负控不空转(两棵树上都绿)。 验收口径的那一处改写,它照做且照实写明了边界那 6 个下游站点在 其余放行判据
Generated by Claude Code |
Fixes #17305
Clause-②: no
The diff adds no export, removes none, and widens no accepted shape — it makes an
existing build-time guardrail resolve two more kinds of template root, so strictly
more input is refused.
git diff origin/main...HEAD -- packages/lint/src | grep -E '^\+export'is empty, and so is the
^-exporthalf.What changed
packages/lint/src/validate-flow-template-paths.tscould resolve exactly one templateroot —
record— and it skipped any flow that was not record-triggered. Both limits hidthe failure the rule exists to catch, and neither is unresolvable:
get_recordnode declaresobjectNameandoutputVariablein one config, so the name it binds holds a record of a known object. A
loopdeclarescollectionanditeratorVariable, so when the collection names one of thosemulti-record outputs, each element is a record of that same object. Both are static
bindings read off authored metadata — the same kind of resolution
boundObjectOf()already did for
record, with a different root.recordroot alone. It is right there (norecord trigger, no triggering record) and meaningless for a name a node inside the flow
binds: a
scheduleflow'sget_recordoutput is as statically typed as arecord-change flow's.
{record.…}on a non-record-triggered flow stays unjudged,exactly as before.
Resolved roots are judged by the two rules that already owned this class —
flow-template-unknown-fieldandflow-template-lookup-traversal— at the sameposition-based severity:
errorinside a filter-guarded CRUD node'sfilter(the noderefuses to run at execution time, framework#3810),
warningeverywhere else. The rule waswidened, not rewritten: the trigger-root messages and hints are unchanged byte-for-byte,
and every one of the 42 pre-existing pins passes untouched.
limit > 1switchesget_recordto a multi-record read, so that name is tracked as alist, never as a record root —
{staleCases.some_field}stays silent.The conservatism, which is the old one
A variable root resolves only when nothing else in the flow can bind that name.
seedRunVariableskeeps one flat map per run, so an assignment target, another node'soutputVariable, anindexVariable/errorVariable, a node id (the engine writes eachnode's outputs under a
nodeId.keyvariable andevaluateConditionexpands that dottedkey into an object at the node id), or a trigger field flattened to top level all make the
name ambiguous — and ambiguous stays silent.
A
flow.variablesdeclaration is deliberately not a second binder. It declares theslot the node then fills (
seedDeclaredVariablesruns first; the node's write replaceswhat it seeded), and it is the shape the platform's own canon ships —
examples/app-tododeclares
tasksToRemind/overdueTasksbeside theget_recordthat fills them. Readingthe declaration as a collision would have made this whole resolution inert on exactly the
flows it was written for. First draft did read it that way; the corpus measurement below
is what caught it.
Also deliberately unresolved, each silent rather than guessed: an
outputVariableon anynode type other than
get_record; aloopwhosecollectionis not a bare variable nameholding a multi-record
get_recordoutput; and theflow-template-field-unprovisionedrule, which stays a trigger-root question (see Acceptance notes).
Evidence — both directions, because a green suite proves nothing here
The three token-root shapes #17305 measured are transplanted as fixtures.
⛔ The flows that report measured lives in a different repository and is not readable from
this container, so nothing here claims anything about that site count — what is pinned
is the SHAPES.
{caseRecord.owner_id.manager}get_recordoutput,scheduleflow{currentCase.owner_id.manager}loopiterator,scheduleflow{record.owner_id.manager}record_changeflowBefore — the pre-#17305 rule restored into the tree (
git restore --source=497655fe5,mutation proved on disk by hash and marker count:
resolveVariableRoots3 hits to 0,blob
1c9e1c2eto9540d763), then the new suite run against it:All six failures are
expected [] to have a length of 1— the old rule returned an EMPTYarray on every one of the three shapes. That is the positive control for the new pins: they
are not vacuous. The twelve negative-control pins (
is silent when …) passed in that samerun, before and after. Restore verified byte-identical:
git diff HEADempty,git hash-objectback to the HEAD blob.After —
pnpm --filter @objectstack/lint test:pnpm --filter @objectstack/lint typecheck— exit 0 (tsc --noEmit+check:test-typecheck,2 files / 6 errors / 2 pinned signatures held in the shrink-only ledger, unchanged).
Does the platform's own metadata go red? Measured: no.
The rule was run over this repo's own example apps' objects + flows, with the pre-fix rule
and with this one, same script, same tree:
examples/app-crm(6 objects / 1 flow)examples/app-multi-package(2 / 0)examples/app-showcase(22 / 30)examples/app-todo(1 / 4)The one new finding is a true positive, at
warning(advisory — it does not move anexit code, and no CI job runs
objectstack validateoverexamples/):examples/app-todo/src/flows/task.flow.ts:136renders'Due {currentTask.due_date}, {currentTask.days_overdue} day(s) overdue.', andtodo_taskdeclares nodays_overdue(18 declared fields, none of them that). Everyoverdue escalation this app sends reads
"…, day(s) overdue.". Not fixed here — theremedy is a product decision (a formula field, or a different message), not a mechanical
one, so it is reported for filing rather than ridden in on this PR. Nothing goes red, so
this PR carries no red required context.
Gates
node scripts/pm/dispatch-gates.mjs --commandsderived 60 commands from this diff; all 60were run and reconciled with
--ran. 55 exit 0. The rest:pnpm check:doc-authoring— was red on my diff, now green. My two new hint stringscarried a tracker id (
(#3475)) into runtime prose. Stripped from the strings; theadjacent source comment keeps the anchor. Re-run: exit 0, "sibling-package prose ids hold
the baseline — 821 pinned sites … no growth".
pnpm check:cross-package-test-inputs— red, and pre-existing. It reportspackages/cli/test/init-created-files-summary.e2e.test.tsdescendingpackages/spec/dist/.Control run with my three files restored to
origin/maincontent in the same tree: exit 1,same finding, same rooted test. Not mine.
check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt— exit 3,PREREQUISITE NOT MET(each reads built output of ~75 packages this worktree has notbuilt). Their own text: "This is NOT MEASURED. It is neither a pass nor a failure." CI
builds the closure and measures them.
Changeset
patchon@objectstack/lint, and the criterion was measured rather than assumed: thatpackage's
filesis["dist","README.md","CHANGELOG.md"], and afterpnpm --filter @objectstack/lint buildthe new hint textSTART node's opt-inis present indist/index.js,dist/index.cjs,dist/runtime.jsanddist/runtime.cjs— alongside thepositive control
config.expand (#3475), an existing string, in the same four files.Something published moved, so
skip-changesetwould have been wrong.Acceptance notes
flow-template-field-unprovisioned(lint: view-filter / page-binding field checks resolve against the blanket SYSTEM_FIELDS union, so the #8116 unprovisioned-anchor warning cannot reach filter surfaces #8340) stays a trigger-root question. Aget_recordon an ADR-0015externalobject binds a variable whose registry-injectedanchors are just as unprovisioned, so the same silent-empty failure is reachable through a
variable root. Applying it there was outside this card's dispatch (which named the other
two rules), so the boundary is documented in the module header and pinned by a test
(
leaves the unprovisioned-anchor rule a TRIGGER-root question) rather than left to berediscovered. Reported to the dispatching seat for filing.
nodeId.outputKeyspelling is not a root.{fetch.records}/{fetch.record}are real variable keys the engine writes, and resolving them would need two-segment roots.
Left unresolved (silent) on purpose; noted, not filed — no PR or person is heading for
this file with that question, so it has no receiver today.
mapnode iterators are poisoned, not resolved.mapcarriescollection+iteratorVariablelikeloopdoes; the dispatch namedloop. Amapiterator thereforemakes its name ambiguous and stays silent — the conservative direction. Noted, not filed;
receiver: whoever widens this rule next, from the module header.
Generated by Claude Code