Skip to content

test(spec): 让 flow.test.ts 剩下的跑不通 fixture 形状真的能跑,并逐条钉住 (#5500) - #5686

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5500-flow-fixture-shapes
Aug 6, 2026
Merged

test(spec): 让 flow.test.ts 剩下的跑不通 fixture 形状真的能跑,并逐条钉住 (#5500)#5686
os-zhuang merged 1 commit into
mainfrom
claude/issue-5500-flow-fixture-shapes

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5500

#4924 / PR #5502 在同一文件上的工作。issue 列的五类里,四类属实并已修;第 1 类的机制说法经实测不成立,按 Prime Directive 6 如实回报而不硬套模板。文件面仅 packages/spec/src/automation/flow.test.ts

行号已按当前 origin/main 重新定位(issue 里的行号以 #5502 合入时为准,main 已经移动):object::118 / :485 / :527(issue 写的是 117/433/474),brace-CEL 出边在 :978 / :1000 / :1001

第 1 类:前提被证伪,未按 issue 的分析实现

issue 说「{节点id.字段} 这套输出引用方言,引擎从来不绑」。不成立。

  • service-automation/src/engine.ts:4243-4247 对每个节点的 result.output 逐键写入 variables.set(`${node.id}.${key}`, value)
  • builtin/template.ts:91resolveToken 有一条扁平键兜底(if (variables.has(trimmed)) return variables.get(trimmed);),正好读这个键。
  • builtin/crud-nodes.ts:156-158 的模块文档把它写成默认绑定:「Writes the result back to the variable context under outputVariable (or under nodeId.id / nodeId.records by default)」。

实测(临时装一个 probe executor 快照变量表,跑完即删):

CLASS1a  create_contact.id  = "mock-contact-1785971151173"
CLASS1a  has(create_contact) = false

{create_contact.id} 能解析,因此 :499 那个 token 原样保留,只在旁边把机制写清楚。

issue 引的另一处 :492 '{get_old_records.records}' 确实解析不到,但原因窄得多、也不是「方言不绑」:get_record 只有在 limit > 1 时才走 find 并输出 records 列表,否则走 findOne 输出单个 record(builtin-node-config.zod.ts:208-209 的契约文档明写)。所以修法是给该节点补 limit,见下。

第 2 类:assignment 缺 assignments 包裹 —— 属实

logic-nodes.ts:129-132 规范化三种形状,最后一条分支是「没有 assignments 包裹时,顶层 config 键就是变量名」。先证红(实测):

CLASS1a  variable var = "contactId"      ← 字面量变量名 variable
CLASS1a  value var    = "mock-contact-…" ← 字面量变量名 value
CLASS1a  contactId    = undefined        ← 声明了 isOutput 的那个,从没被写过

改为 config: { assignments: { contactId: '{create_contact.id}' } }

第 3 类:legacy flat-graph loop —— 属实,且比 issue 说的更彻底

loop-node.ts:70-80 的 back-compat 分支不只是「不迭代」:它把 config.collection 当作裸变量名(variables.get(collectionName))而非模板,所以 '{get_old_records.records}'$loopItems 都没设上。实测:

CLASS3  $loopItems = undefined / has($loopItems) = false
CLASS3  item       = undefined

改成 ADR-0031 结构化容器:delete_record 移入 config.body,iteratorVariable: 'item' 才是真正绑 {item.id} 的东西;主图里 loop -> delete -> loop 的回边随之删除,loop 的普通出边就是 after-loop 续接。

第 4 类:字符串 filter —— 别名部分照修,形状部分已测出有定论的答案

object: -> objectName: 机械照修。字符串 filter 实测直接 safeParse 失败:

CLASS4 string filter parses: false
  {"expected":"record","code":"invalid_type","path":["filter"],
   "message":"Invalid input: expected record, received string"}

关于形状:issue 提的候选是 { created_at: { $lt: '{TODAY() - 90}' } },并担心 $lt 的 Zod 声明不含 string。实测下来这个担心指错了地方,而且有一个不用猜的更好答案:

  • GetRecordConfigSchema.filterz.record(z.string(), z.unknown())(builtin-node-config.zod.ts:219),值一律 unknown —— 两个候选都 parse 绿,ComparisonOperatorSchema 在这条路上根本不参与校验
  • 真正该用的是 spec 自带的 filter 占位符方言:{90_days_ago} 命中 DATE_MACRO_PARAM_RE,isKnownFilterToken('90_days_ago') === true,于是 interpolateFilter(template.ts:216-235,Flow node filters silently blank date macros: the template engine consumes {…} before the query engine sees it #3810 的槽位归属移交)把它原样透传给查询引擎的 resolveFilterTokens 展开;date-macros.zod.ts:26-28 明确把「flow node filters」列为该机制的消费者。

实测两个候选的落点:

A  {TODAY() - 90}  -> {"created_at":{"$lt":"2026-05-07"}}   ← 在 flow 层就被吃掉
B  {90_days_ago}   -> {"created_at":{"$lt":"{90_days_ago}"}} ← 透传给拥有该槽位的方言

采用 B。这不是猜:它是 spec 为这个槽位声明的方言,两处文档都点名。

另补 limit: 200 + outputVariable: 'oldRecords' —— limit 是让上游产出数组的契约细节(否则 loop 拿到单条 record 会直接报「did not resolve to an array」),不是调参。

残留的契约问题已单独立 finding:#5685 —— ComparisonOperatorSchema$gt/$gte/$lt/$lte 声明为 number | date | FieldReference 不含 string,而平台自己的日期宏解析器只能产出字符串(filter-tokens.ts:18-19 的规范示例就是 { $gte: '2026-01-01' }),一方调用方(lifecycle-service.ts:991outbox-sweep.ts:160)也一律传 .toISOString()。该 schema 零运行期消费者,今天不产生失败,故按 observation-class 记录、未在本 PR 顺手改。

第 5 类:object 别名 + brace-CEL 出边 —— 属实

三处 object:objectName:。三处 brace-CEL 出边条件改裸 CEL,先证红(实测 registerFlow 是硬报错):

CLASS5 registerFlow threw: Flow 'm3' has 1 invalid expression (ADR-0032 §1a).
  Predicates … must not wrap references in `{…}` template braces;
  template slots (e.g. `loop.collection`) require them

顺带一提:这条报错本身证明了第 3 类里 collection 用花括号是对的 —— 模板槽位要花括号,谓词槽位不要,两者的区别正是这几条钉子要守的东西。

钉法与反向验证

每类都按 #5502 在本文件的惯例加了契约断言,并且逐条把坏形状放回去验证确实转红(方向事先预测,全部为「红」):

还原的形状 转红的用例
objectName -> object should accept node with config
assignments -> { variable, value } should accept screen flow for user input
去掉 loop 的 body should accept scheduled flow
filter 记录形 -> 原字符串 should accept scheduled flow
去掉 limit should accept scheduled flow
出边条件加回花括号(两处) should accept conditional edge type / should validate a decision with default and conditional branches

反向验证抓到了我自己第一版钉子里的一个幽灵断言,值得记一笔:ExpressionInputSchema(shared/expression.zod.ts:93)会把裸字符串谓词规范化成 { dialect: 'cel', source } 信封,所以对 parse 之后conditionexpect(condition).not.toContain('{') 是在对一个对象断言,无论谓词写成什么都恒绿 —— 把花括号放回去时测试照样全绿,才暴露出来。现在两处都改读 condition.source,并在注释里写明这个坑。(#5502 原有的那处 String(guarded?.condition) 读的是未 parse 的原始 fixture,是真断言,未动。)

验证

pnpm --filter @objectstack/spec test       → 316 files / 8060 tests passed
pnpm --filter @objectstack/spec typecheck  → tsc --noEmit OK + check:test-typecheck OK
node scripts/check-nul-bytes.mjs           → OK (5581 files, no raw control bytes)

另外对本仓做了同形状普查:DAYS_AGO 别处无出现;其余 brace-CEL 命中点(engine.test.ts:385/391validate-expressions.test.ts 若干)都是故意的负向 fixture(断言必须被拒),正确,未动。

变更集

纯测试改动,无用户可见行为变化,未加 changeset(tests-only)。

🤖 Generated with Claude Code


Generated by Claude Code

…s runnable, and pin them (#5500)

Follows #4924 / PR #5502 on the same file. Four of the five classes #5500
listed were real and are fixed; the first class's stated mechanism did not
survive verification and is reported instead of forced.

- assignment (`assign_output`): `{ variable, value }` -> `{ assignments: { … } }`.
  logic-nodes.ts normalizes three shapes and its last branch makes top-level
  config keys the variable NAMES, so the old config declared two variables
  literally named `variable` and `value` while `contactId` (declared
  `isOutput: true`) was never written. Measured end to end before the fix.

- loop (`loop_records`): legacy flat-graph loop -> ADR-0031 structured
  container. With no `config.body` loop-node.ts reads `config.collection` as a
  bare VARIABLE NAME, so `'{get_old_records.records}'` matched nothing, bound
  nothing and fell through; the `loop -> delete -> loop` back-edge was ordinary
  traversal and `{item.id}` referenced a variable no one set. The delete node
  moves into `config.body` and `iteratorVariable` binds `item`.

- get_record (`get_old_records`): string `filter` -> record form. The contract
  declares `z.record(z.string(), z.unknown())`, so the string failed safeParse
  outright, and `DAYS_AGO()` is implemented nowhere. The date window is spelled
  `{90_days_ago}` — a spec date macro whose slot ownership is declared: a known
  filter token passes through interpolateFilter verbatim for the query engine's
  resolveFilterTokens to expand, and date-macros.zod.ts names "flow node
  filters" as a consumer. `limit` added because it selects the `find`/`records`
  branch the loop needs.

- `object` -> `objectName` at the three remaining sites (ADR-0087 D2
  `flow-node-crud-object-alias`).

- edge conditions: `{…}` template braces -> bare CEL (ADR-0032 §1a). Verified
  that registerFlow rejects the braced form outright, so those fixtures could
  not register at all.

Class 1 ("the `{<node id>.<field>}` output dialect is never bound") is NOT
implemented, because it is false: engine.ts writes every node's `result.output`
under `<nodeId>.<key>` and template.ts resolves that flat key, which
crud-nodes.ts documents as the default binding. `{create_contact.id}` resolves
and is kept verbatim. The real defect at the other cited site was narrower —
get_record only emits `records` on the `limit > 1` branch — and is fixed above.

Each fix carries a contract assertion, and each was reverse-verified by
restoring the broken shape. That caught a phantom in the first draft of the
brace pin: ExpressionInputSchema normalizes a bare-string predicate into a
`{ dialect, source }` envelope, so asserting `toContain` on the parsed
`condition` object passed regardless of the predicate. The pins now read
`condition.source`.

Refs #5500
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 12:16am

Request Review

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 0 changed package(s). ✅

@os-zhuang os-zhuang added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/m tests labels Aug 6, 2026 — with Claude
@os-zhuang
os-zhuang marked this pull request as ready for review August 6, 2026 00:24
@os-zhuang
os-zhuang enabled auto-merge August 6, 2026 00:24
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 39e43c8 Aug 6, 2026
33 of 34 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5500-flow-fixture-shapes branch August 6, 2026 00:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants