lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch } - #14617

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint
Sep 2, 2026
Merged

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch }#14617
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14394

B-branch items 1-2 of #13681: two authoring-time lint rules for per-iteration containment, plus the docs orthography. No packages/spec change, no engine change, no loop.config.onIterationError — the ruling of record selected branch B by measurement (the combination already works), so this card is lint + docs only.

The two rules (both warning)

flow-loop-body-uncontained — a loop whose body region runs a fallible node with no try_catch between the loop and that node. loop-node.ts iterates with a bare await and has no try/catch at all, so the first failing item ends the whole run. The finding names the loop and the node in where and the loop again in the message; the hint carries the prescribed spelling and the measured minimal catch.

flow-try-catch-without-catch — a try_catch with no catch region, anywhere in a flow (not only inside a loop). Measured on the real engine: the no-catch run and the unwrapped control produce identical output. This is the family's first target, not an extra — an author who wrapped the node and stopped there previously got zero containment and zero diagnostics.

Both stay warnings under the file's own severity bar: a loop deliberately allowed to stop at the first bad row, and a retry-then-fail try_catch, are legitimate readings the rules cannot disprove.

Decisions this PR makes, stated

The fallible set is a closed literal list with a per-type justification read off each executor (FALLIBLE_NODE_TYPES docblock cites file and line for every member): get_record, create_record, update_record, delete_record, http, notify, connector_action, script, subflow, map, approval. Deliberately absent, also by reading the executor: assignment and decision (every path returns success), wait and screen (they suspend and have no failure return), start / end (sentinels a region may not carry), and the three containers loop / parallel / try_catch (they fail only by propagation, so the walk descends to the leaf that carries the real failure instead of double-reporting the wrapper).

Unknown (plugin-registered) node types are NOT counted fallible. An unread executor can certainly return success: false, so counting them would be defensible for a warning — but it would flag nodes nobody here has read, and this family's precedent (INERT_CONDITION_NODE_TYPES) is that membership means "we have read this executor". The cost is a false negative on a third-party node; the alternative is a false positive on every one of them. Stated in the rule's docblock, pinned by a test.

One node, one rule. Rule A treats ANY enclosing try_catch as containment — including a catch-less one, which contains nothing — precisely so the near-miss is reported once, by rule B, which is the rule that can name the missing key. Telling an author who wrapped their node that they must wrap it is the one thing worth avoiding. Both docblocks say so, and a test pins the single finding.

Ancestry, not depth. A try_catch inside a parallel branch inside the loop still contains; a fallible node three levels down with no try_catch on the path still kills the sweep. The descent walks through any container using the shared region-slot table, and stops at a nested loop — that loop is judged in its own graph, so a nested body is never reported once per enclosing loop.

Acceptance (from the issue), each line against a test name

  • unwrapped-loop fixture gives a warning naming the loop and the node — rule A ... > flags the fallible node, naming the loop AND the node (asserts where = flow 'case_sla_monitor' · loop 'each' body · node 'notify_owner' (notify), message contains inside loop 'each', severity undefined).
  • the try_catch-without-catch fixture gives a warning — rule B ... > flags the near-miss inside a loop body, and says it dies like an unwrapped node, plus ... > flags a TOP-LEVEL catch-less try_catch too and ... > says what retry does and does not buy, when one is declared.
  • the documented loop { try_catch { try, catch: [assignment] } } fixture gives no finding — the documented spelling ... > raises NO finding from either containment rule and ... > raises no finding from ANY flow rule.
  • the docs example parses under FlowSchemathe documented spelling ... > parses under FlowSchema — the docs example is authorable (same ids and labels as the page).

Additional controls: every fallible builtin flagged once each; the non-fallible builtins left alone; an unknown node type left alone; a legacy flat-graph loop (no body) left alone; a parallel branch reported with the branch in where; a nested loop reported once against the inner loop; a present-but-malformed catch left to the schema (with TryCatchConfigSchema.safeParse pinning that catch: {} and catch: { nodes: [] } are refused and the minimal spelling is accepted).

Eight pre-existing cases in this file asserted a bare total finding count on fixtures whose loop bodies carry a write or a notify; they are now scoped to the rule under test, with a comment saying why. One of them instead pins the co-occurrence explicitly ([FLOW_RUNAS_UNSCOPED, FLOW_LOOP_BODY_UNCONTAINED]) rather than filtering it away.

Docs

content/docs/automation/flows.mdx, new subsection #### Per-iteration containment: loop { try_catch { … } } under ### Loop container: why an uncontained body dies, the measured numbers on both sides, the full example, the refusal table (catch omitted contains nothing; catch: {} and catch: { nodes: [] } refused, the region's nodes being .min(1)), the minimal handler (one bare assignment, edges and errorVariable omitted) and the two rule ids. The existing ### Loop container example no longer teaches the warned shape — its body node is wrapped. ### Try / catch / retry now states that omitting catch gives zero containment and points at the subsection.

Verification

Command: pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/lint-flow-patterns.test.tsTest Files 1 passed (1) · Tests 132 passed (132) (117 before this PR). Run through the shared verify lock, on this head.

pnpm --filter @objectstack/lint typecheck → exit 0, and its check:test-typecheck leg compiles the test layer under tsconfig.test.json, so the new test file really is type-checked.

pnpm lint (the full-repo eslint . --no-inline-config) → exit 0 on this head. Not narrowed.

Gate union derived in this worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (57 families: 53 by path, 6 by change kind, 2 shared; the union was re-derived after the merge and is unchanged). All 57 run on the final commit 126f9d5e0: 54 RAN-PASS, 3 NOT MEASURED, each refusing on a prerequisite in its own words rather than reporting a finding:

  • node scripts/check-test-completeness.mjs — exit 3, "this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED".
  • pnpm check:dual-build-cjs-loads — exit 3, "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist" (83 packages listed). Self-test green (93 cases).
  • pnpm check:type-check-debt — exit 3, "PREREQUISITE NOT MET"; it needs the whole built workspace closure. Self-test green.

pnpm --filter @objectstack/spec run check:skill-examples first refused the same way ("packages/client-react/dist holds no .d.ts declarations ... a verdict now would be computed against a build that no longer matches src"); after building that closure it RAN and passed: "261 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them". It is counted in the 54.

check:doc-authoring failed once, genuinely: the two new hints carried (#13681, #14394) in runtime string prose, which the gate refuses for a NEW (file, id) pair — "a runtime string reaches authors, operators and generated surfaces — none of whom can resolve #NNNN". The ids moved to the adjacent comments and it is green.

git diff --stat origin/main...HEAD — the five declared paths and nothing else:

 .changeset/flow-loop-per-iteration-containment-lint.md | 35 +++
content/docs/automation/flows.mdx | 89 +++-
packages/lint/src/index.ts | 2 +
packages/lint/src/lint-flow-patterns.test.ts | 349 +++++++++++-
packages/lint/src/lint-flow-patterns.ts | 318 +++++++++-
5 files changed, 780 insertions(+), 13 deletions(-)

Changeset: .changeset/flow-loop-per-iteration-containment-lint.md, @objectstack/lint patch.

Out of scope and untouched, per the card: the run-summary visibility half (FlowRunSummarySchema, the iteration index on nested try/catch steps, $error row identity) stays on #13681, and #13803 is not addressed here.

Draft on purpose: not ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV

Generated by Claude Code


Generated by Claude Code

…no catch
Two authoring-time rules in the flow anti-pattern family, both warnings:
`flow-loop-body-uncontained` (a `loop` body running a fallible node with no
`try_catch` between the loop and it) and `flow-try-catch-without-catch` (the
near-miss shape that gives zero containment while looking like containment).
Documents `loop { try_catch { … } }` in the flow docs as the per-iteration
containment spelling, with the measured minimal `catch` — one bare `assignment`
node, `edges` and `errorVariable` omitted — and the two empty-`catch` spellings
the region schema refuses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
`check:doc-authoring` refuses a NEW internal issue id in string prose a
runtime surface shows an author: the ids move to the adjacent comments, where
the reader who can resolve them already looks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 17 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422packageMentionDocs.

Which tree this was computed on

This run read content/docs from 436833382a9e7c7309fb841bc87488817c2722a6 — the merge of head 126f9d5e0576d3bb0ee286267158eb3440c387b7 into base 2514d49f388e898e666ae04f19ba376d04db5422, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 436833382a9e7c7309fb841bc87488817c2722a6 && git checkout 436833382a9e7c7309fb841bc87488817c2722a6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2514d49f388e898e666ae04f19ba376d04db5422 126f9d5e0576d3bb0ee286267158eb3440c387b7 && git checkout -B drift-repro 2514d49f388e898e666ae04f19ba376d04db5422 && git merge --no-ff 126f9d5e0576d3bb0ee286267158eb3440c387b7
node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2514d49f388e898e666ae04f19ba376d04db5422 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33653485310 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 6 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33665522337 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/6) — 失败步骤: Run this shard's tests

    create-objectstack:test: FAIL src/scaffold-e2e-boot-probe.test.ts > [#9779] scaffold-e2e.yml boot-and-probe blocks assert on their OWN server > scaffold-local / Boot from the artifact and probe heal
    ↳ 失败原因: create-objectstack:test: Error: the neighbour never came up on port 38700
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/scaffold-e2e-boot-probe.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 10 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 8ed9c54Sep 2, 2026
39 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-14394-loop-containment-lint branch September 2, 2026 18:39
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch } - #14617

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint
Sep 2, 2026
Merged

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch }#14617
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14394

B-branch items 1-2 of #13681: two authoring-time lint rules for per-iteration containment, plus the docs orthography. No packages/spec change, no engine change, no loop.config.onIterationError — the ruling of record selected branch B by measurement (the combination already works), so this card is lint + docs only.

The two rules (both warning)

flow-loop-body-uncontained — a loop whose body region runs a fallible node with no try_catch between the loop and that node. loop-node.ts iterates with a bare await and has no try/catch at all, so the first failing item ends the whole run. The finding names the loop and the node in where and the loop again in the message; the hint carries the prescribed spelling and the measured minimal catch.

flow-try-catch-without-catch — a try_catch with no catch region, anywhere in a flow (not only inside a loop). Measured on the real engine: the no-catch run and the unwrapped control produce identical output. This is the family's first target, not an extra — an author who wrapped the node and stopped there previously got zero containment and zero diagnostics.

Both stay warnings under the file's own severity bar: a loop deliberately allowed to stop at the first bad row, and a retry-then-fail try_catch, are legitimate readings the rules cannot disprove.

Decisions this PR makes, stated

The fallible set is a closed literal list with a per-type justification read off each executor (FALLIBLE_NODE_TYPES docblock cites file and line for every member): get_record, create_record, update_record, delete_record, http, notify, connector_action, script, subflow, map, approval. Deliberately absent, also by reading the executor: assignment and decision (every path returns success), wait and screen (they suspend and have no failure return), start / end (sentinels a region may not carry), and the three containers loop / parallel / try_catch (they fail only by propagation, so the walk descends to the leaf that carries the real failure instead of double-reporting the wrapper).

Unknown (plugin-registered) node types are NOT counted fallible. An unread executor can certainly return success: false, so counting them would be defensible for a warning — but it would flag nodes nobody here has read, and this family's precedent (INERT_CONDITION_NODE_TYPES) is that membership means "we have read this executor". The cost is a false negative on a third-party node; the alternative is a false positive on every one of them. Stated in the rule's docblock, pinned by a test.

One node, one rule. Rule A treats ANY enclosing try_catch as containment — including a catch-less one, which contains nothing — precisely so the near-miss is reported once, by rule B, which is the rule that can name the missing key. Telling an author who wrapped their node that they must wrap it is the one thing worth avoiding. Both docblocks say so, and a test pins the single finding.

Ancestry, not depth. A try_catch inside a parallel branch inside the loop still contains; a fallible node three levels down with no try_catch on the path still kills the sweep. The descent walks through any container using the shared region-slot table, and stops at a nested loop — that loop is judged in its own graph, so a nested body is never reported once per enclosing loop.

Acceptance (from the issue), each line against a test name

  • unwrapped-loop fixture gives a warning naming the loop and the node — rule A ... > flags the fallible node, naming the loop AND the node (asserts where = flow 'case_sla_monitor' · loop 'each' body · node 'notify_owner' (notify), message contains inside loop 'each', severity undefined).
  • the try_catch-without-catch fixture gives a warning — rule B ... > flags the near-miss inside a loop body, and says it dies like an unwrapped node, plus ... > flags a TOP-LEVEL catch-less try_catch too and ... > says what retry does and does not buy, when one is declared.
  • the documented loop { try_catch { try, catch: [assignment] } } fixture gives no finding — the documented spelling ... > raises NO finding from either containment rule and ... > raises no finding from ANY flow rule.
  • the docs example parses under FlowSchemathe documented spelling ... > parses under FlowSchema — the docs example is authorable (same ids and labels as the page).

Additional controls: every fallible builtin flagged once each; the non-fallible builtins left alone; an unknown node type left alone; a legacy flat-graph loop (no body) left alone; a parallel branch reported with the branch in where; a nested loop reported once against the inner loop; a present-but-malformed catch left to the schema (with TryCatchConfigSchema.safeParse pinning that catch: {} and catch: { nodes: [] } are refused and the minimal spelling is accepted).

Eight pre-existing cases in this file asserted a bare total finding count on fixtures whose loop bodies carry a write or a notify; they are now scoped to the rule under test, with a comment saying why. One of them instead pins the co-occurrence explicitly ([FLOW_RUNAS_UNSCOPED, FLOW_LOOP_BODY_UNCONTAINED]) rather than filtering it away.

Docs

content/docs/automation/flows.mdx, new subsection #### Per-iteration containment: loop { try_catch { … } } under ### Loop container: why an uncontained body dies, the measured numbers on both sides, the full example, the refusal table (catch omitted contains nothing; catch: {} and catch: { nodes: [] } refused, the region's nodes being .min(1)), the minimal handler (one bare assignment, edges and errorVariable omitted) and the two rule ids. The existing ### Loop container example no longer teaches the warned shape — its body node is wrapped. ### Try / catch / retry now states that omitting catch gives zero containment and points at the subsection.

Verification

Command: pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/lint-flow-patterns.test.tsTest Files 1 passed (1) · Tests 132 passed (132) (117 before this PR). Run through the shared verify lock, on this head.

pnpm --filter @objectstack/lint typecheck → exit 0, and its check:test-typecheck leg compiles the test layer under tsconfig.test.json, so the new test file really is type-checked.

pnpm lint (the full-repo eslint . --no-inline-config) → exit 0 on this head. Not narrowed.

Gate union derived in this worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (57 families: 53 by path, 6 by change kind, 2 shared; the union was re-derived after the merge and is unchanged). All 57 run on the final commit 126f9d5e0: 54 RAN-PASS, 3 NOT MEASURED, each refusing on a prerequisite in its own words rather than reporting a finding:

  • node scripts/check-test-completeness.mjs — exit 3, "this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED".
  • pnpm check:dual-build-cjs-loads — exit 3, "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist" (83 packages listed). Self-test green (93 cases).
  • pnpm check:type-check-debt — exit 3, "PREREQUISITE NOT MET"; it needs the whole built workspace closure. Self-test green.

pnpm --filter @objectstack/spec run check:skill-examples first refused the same way ("packages/client-react/dist holds no .d.ts declarations ... a verdict now would be computed against a build that no longer matches src"); after building that closure it RAN and passed: "261 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them". It is counted in the 54.

check:doc-authoring failed once, genuinely: the two new hints carried (#13681, #14394) in runtime string prose, which the gate refuses for a NEW (file, id) pair — "a runtime string reaches authors, operators and generated surfaces — none of whom can resolve #NNNN". The ids moved to the adjacent comments and it is green.

git diff --stat origin/main...HEAD — the five declared paths and nothing else:

 .changeset/flow-loop-per-iteration-containment-lint.md | 35 +++
content/docs/automation/flows.mdx | 89 +++-
packages/lint/src/index.ts | 2 +
packages/lint/src/lint-flow-patterns.test.ts | 349 +++++++++++-
packages/lint/src/lint-flow-patterns.ts | 318 +++++++++-
5 files changed, 780 insertions(+), 13 deletions(-)

Changeset: .changeset/flow-loop-per-iteration-containment-lint.md, @objectstack/lint patch.

Out of scope and untouched, per the card: the run-summary visibility half (FlowRunSummarySchema, the iteration index on nested try/catch steps, $error row identity) stays on #13681, and #13803 is not addressed here.

Draft on purpose: not ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV

Generated by Claude Code


Generated by Claude Code

…no catch
Two authoring-time rules in the flow anti-pattern family, both warnings:
`flow-loop-body-uncontained` (a `loop` body running a fallible node with no
`try_catch` between the loop and it) and `flow-try-catch-without-catch` (the
near-miss shape that gives zero containment while looking like containment).
Documents `loop { try_catch { … } }` in the flow docs as the per-iteration
containment spelling, with the measured minimal `catch` — one bare `assignment`
node, `edges` and `errorVariable` omitted — and the two empty-`catch` spellings
the region schema refuses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
`check:doc-authoring` refuses a NEW internal issue id in string prose a
runtime surface shows an author: the ids move to the adjacent comments, where
the reader who can resolve them already looks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 17 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422packageMentionDocs.

Which tree this was computed on

This run read content/docs from 436833382a9e7c7309fb841bc87488817c2722a6 — the merge of head 126f9d5e0576d3bb0ee286267158eb3440c387b7 into base 2514d49f388e898e666ae04f19ba376d04db5422, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 436833382a9e7c7309fb841bc87488817c2722a6 && git checkout 436833382a9e7c7309fb841bc87488817c2722a6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2514d49f388e898e666ae04f19ba376d04db5422 126f9d5e0576d3bb0ee286267158eb3440c387b7 && git checkout -B drift-repro 2514d49f388e898e666ae04f19ba376d04db5422 && git merge --no-ff 126f9d5e0576d3bb0ee286267158eb3440c387b7
node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2514d49f388e898e666ae04f19ba376d04db5422 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33653485310 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 6 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33665522337 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/6) — 失败步骤: Run this shard's tests

    create-objectstack:test: FAIL src/scaffold-e2e-boot-probe.test.ts > [#9779] scaffold-e2e.yml boot-and-probe blocks assert on their OWN server > scaffold-local / Boot from the artifact and probe heal
    ↳ 失败原因: create-objectstack:test: Error: the neighbour never came up on port 38700
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/scaffold-e2e-boot-probe.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 10 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 8ed9c54Sep 2, 2026
39 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-14394-loop-containment-lint branch September 2, 2026 18:39
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch } - #14617

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint
Sep 2, 2026
Merged

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch }#14617
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14394

B-branch items 1-2 of #13681: two authoring-time lint rules for per-iteration containment, plus the docs orthography. No packages/spec change, no engine change, no loop.config.onIterationError — the ruling of record selected branch B by measurement (the combination already works), so this card is lint + docs only.

The two rules (both warning)

flow-loop-body-uncontained — a loop whose body region runs a fallible node with no try_catch between the loop and that node. loop-node.ts iterates with a bare await and has no try/catch at all, so the first failing item ends the whole run. The finding names the loop and the node in where and the loop again in the message; the hint carries the prescribed spelling and the measured minimal catch.

flow-try-catch-without-catch — a try_catch with no catch region, anywhere in a flow (not only inside a loop). Measured on the real engine: the no-catch run and the unwrapped control produce identical output. This is the family's first target, not an extra — an author who wrapped the node and stopped there previously got zero containment and zero diagnostics.

Both stay warnings under the file's own severity bar: a loop deliberately allowed to stop at the first bad row, and a retry-then-fail try_catch, are legitimate readings the rules cannot disprove.

Decisions this PR makes, stated

The fallible set is a closed literal list with a per-type justification read off each executor (FALLIBLE_NODE_TYPES docblock cites file and line for every member): get_record, create_record, update_record, delete_record, http, notify, connector_action, script, subflow, map, approval. Deliberately absent, also by reading the executor: assignment and decision (every path returns success), wait and screen (they suspend and have no failure return), start / end (sentinels a region may not carry), and the three containers loop / parallel / try_catch (they fail only by propagation, so the walk descends to the leaf that carries the real failure instead of double-reporting the wrapper).

Unknown (plugin-registered) node types are NOT counted fallible. An unread executor can certainly return success: false, so counting them would be defensible for a warning — but it would flag nodes nobody here has read, and this family's precedent (INERT_CONDITION_NODE_TYPES) is that membership means "we have read this executor". The cost is a false negative on a third-party node; the alternative is a false positive on every one of them. Stated in the rule's docblock, pinned by a test.

One node, one rule. Rule A treats ANY enclosing try_catch as containment — including a catch-less one, which contains nothing — precisely so the near-miss is reported once, by rule B, which is the rule that can name the missing key. Telling an author who wrapped their node that they must wrap it is the one thing worth avoiding. Both docblocks say so, and a test pins the single finding.

Ancestry, not depth. A try_catch inside a parallel branch inside the loop still contains; a fallible node three levels down with no try_catch on the path still kills the sweep. The descent walks through any container using the shared region-slot table, and stops at a nested loop — that loop is judged in its own graph, so a nested body is never reported once per enclosing loop.

Acceptance (from the issue), each line against a test name

  • unwrapped-loop fixture gives a warning naming the loop and the node — rule A ... > flags the fallible node, naming the loop AND the node (asserts where = flow 'case_sla_monitor' · loop 'each' body · node 'notify_owner' (notify), message contains inside loop 'each', severity undefined).
  • the try_catch-without-catch fixture gives a warning — rule B ... > flags the near-miss inside a loop body, and says it dies like an unwrapped node, plus ... > flags a TOP-LEVEL catch-less try_catch too and ... > says what retry does and does not buy, when one is declared.
  • the documented loop { try_catch { try, catch: [assignment] } } fixture gives no finding — the documented spelling ... > raises NO finding from either containment rule and ... > raises no finding from ANY flow rule.
  • the docs example parses under FlowSchemathe documented spelling ... > parses under FlowSchema — the docs example is authorable (same ids and labels as the page).

Additional controls: every fallible builtin flagged once each; the non-fallible builtins left alone; an unknown node type left alone; a legacy flat-graph loop (no body) left alone; a parallel branch reported with the branch in where; a nested loop reported once against the inner loop; a present-but-malformed catch left to the schema (with TryCatchConfigSchema.safeParse pinning that catch: {} and catch: { nodes: [] } are refused and the minimal spelling is accepted).

Eight pre-existing cases in this file asserted a bare total finding count on fixtures whose loop bodies carry a write or a notify; they are now scoped to the rule under test, with a comment saying why. One of them instead pins the co-occurrence explicitly ([FLOW_RUNAS_UNSCOPED, FLOW_LOOP_BODY_UNCONTAINED]) rather than filtering it away.

Docs

content/docs/automation/flows.mdx, new subsection #### Per-iteration containment: loop { try_catch { … } } under ### Loop container: why an uncontained body dies, the measured numbers on both sides, the full example, the refusal table (catch omitted contains nothing; catch: {} and catch: { nodes: [] } refused, the region's nodes being .min(1)), the minimal handler (one bare assignment, edges and errorVariable omitted) and the two rule ids. The existing ### Loop container example no longer teaches the warned shape — its body node is wrapped. ### Try / catch / retry now states that omitting catch gives zero containment and points at the subsection.

Verification

Command: pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/lint-flow-patterns.test.tsTest Files 1 passed (1) · Tests 132 passed (132) (117 before this PR). Run through the shared verify lock, on this head.

pnpm --filter @objectstack/lint typecheck → exit 0, and its check:test-typecheck leg compiles the test layer under tsconfig.test.json, so the new test file really is type-checked.

pnpm lint (the full-repo eslint . --no-inline-config) → exit 0 on this head. Not narrowed.

Gate union derived in this worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (57 families: 53 by path, 6 by change kind, 2 shared; the union was re-derived after the merge and is unchanged). All 57 run on the final commit 126f9d5e0: 54 RAN-PASS, 3 NOT MEASURED, each refusing on a prerequisite in its own words rather than reporting a finding:

  • node scripts/check-test-completeness.mjs — exit 3, "this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED".
  • pnpm check:dual-build-cjs-loads — exit 3, "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist" (83 packages listed). Self-test green (93 cases).
  • pnpm check:type-check-debt — exit 3, "PREREQUISITE NOT MET"; it needs the whole built workspace closure. Self-test green.

pnpm --filter @objectstack/spec run check:skill-examples first refused the same way ("packages/client-react/dist holds no .d.ts declarations ... a verdict now would be computed against a build that no longer matches src"); after building that closure it RAN and passed: "261 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them". It is counted in the 54.

check:doc-authoring failed once, genuinely: the two new hints carried (#13681, #14394) in runtime string prose, which the gate refuses for a NEW (file, id) pair — "a runtime string reaches authors, operators and generated surfaces — none of whom can resolve #NNNN". The ids moved to the adjacent comments and it is green.

git diff --stat origin/main...HEAD — the five declared paths and nothing else:

 .changeset/flow-loop-per-iteration-containment-lint.md | 35 +++
content/docs/automation/flows.mdx | 89 +++-
packages/lint/src/index.ts | 2 +
packages/lint/src/lint-flow-patterns.test.ts | 349 +++++++++++-
packages/lint/src/lint-flow-patterns.ts | 318 +++++++++-
5 files changed, 780 insertions(+), 13 deletions(-)

Changeset: .changeset/flow-loop-per-iteration-containment-lint.md, @objectstack/lint patch.

Out of scope and untouched, per the card: the run-summary visibility half (FlowRunSummarySchema, the iteration index on nested try/catch steps, $error row identity) stays on #13681, and #13803 is not addressed here.

Draft on purpose: not ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV

Generated by Claude Code


Generated by Claude Code

…no catch
Two authoring-time rules in the flow anti-pattern family, both warnings:
`flow-loop-body-uncontained` (a `loop` body running a fallible node with no
`try_catch` between the loop and it) and `flow-try-catch-without-catch` (the
near-miss shape that gives zero containment while looking like containment).
Documents `loop { try_catch { … } }` in the flow docs as the per-iteration
containment spelling, with the measured minimal `catch` — one bare `assignment`
node, `edges` and `errorVariable` omitted — and the two empty-`catch` spellings
the region schema refuses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
`check:doc-authoring` refuses a NEW internal issue id in string prose a
runtime surface shows an author: the ids move to the adjacent comments, where
the reader who can resolve them already looks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 17 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422packageMentionDocs.

Which tree this was computed on

This run read content/docs from 436833382a9e7c7309fb841bc87488817c2722a6 — the merge of head 126f9d5e0576d3bb0ee286267158eb3440c387b7 into base 2514d49f388e898e666ae04f19ba376d04db5422, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 436833382a9e7c7309fb841bc87488817c2722a6 && git checkout 436833382a9e7c7309fb841bc87488817c2722a6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2514d49f388e898e666ae04f19ba376d04db5422 126f9d5e0576d3bb0ee286267158eb3440c387b7 && git checkout -B drift-repro 2514d49f388e898e666ae04f19ba376d04db5422 && git merge --no-ff 126f9d5e0576d3bb0ee286267158eb3440c387b7
node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2514d49f388e898e666ae04f19ba376d04db5422 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33653485310 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 6 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33665522337 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/6) — 失败步骤: Run this shard's tests

    create-objectstack:test: FAIL src/scaffold-e2e-boot-probe.test.ts > [#9779] scaffold-e2e.yml boot-and-probe blocks assert on their OWN server > scaffold-local / Boot from the artifact and probe heal
    ↳ 失败原因: create-objectstack:test: Error: the neighbour never came up on port 38700
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/scaffold-e2e-boot-probe.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 10 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 8ed9c54Sep 2, 2026
39 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-14394-loop-containment-lint branch September 2, 2026 18:39
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch } - #14617

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint
Sep 2, 2026
Merged

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch }#14617
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14394

B-branch items 1-2 of #13681: two authoring-time lint rules for per-iteration containment, plus the docs orthography. No packages/spec change, no engine change, no loop.config.onIterationError — the ruling of record selected branch B by measurement (the combination already works), so this card is lint + docs only.

The two rules (both warning)

flow-loop-body-uncontained — a loop whose body region runs a fallible node with no try_catch between the loop and that node. loop-node.ts iterates with a bare await and has no try/catch at all, so the first failing item ends the whole run. The finding names the loop and the node in where and the loop again in the message; the hint carries the prescribed spelling and the measured minimal catch.

flow-try-catch-without-catch — a try_catch with no catch region, anywhere in a flow (not only inside a loop). Measured on the real engine: the no-catch run and the unwrapped control produce identical output. This is the family's first target, not an extra — an author who wrapped the node and stopped there previously got zero containment and zero diagnostics.

Both stay warnings under the file's own severity bar: a loop deliberately allowed to stop at the first bad row, and a retry-then-fail try_catch, are legitimate readings the rules cannot disprove.

Decisions this PR makes, stated

The fallible set is a closed literal list with a per-type justification read off each executor (FALLIBLE_NODE_TYPES docblock cites file and line for every member): get_record, create_record, update_record, delete_record, http, notify, connector_action, script, subflow, map, approval. Deliberately absent, also by reading the executor: assignment and decision (every path returns success), wait and screen (they suspend and have no failure return), start / end (sentinels a region may not carry), and the three containers loop / parallel / try_catch (they fail only by propagation, so the walk descends to the leaf that carries the real failure instead of double-reporting the wrapper).

Unknown (plugin-registered) node types are NOT counted fallible. An unread executor can certainly return success: false, so counting them would be defensible for a warning — but it would flag nodes nobody here has read, and this family's precedent (INERT_CONDITION_NODE_TYPES) is that membership means "we have read this executor". The cost is a false negative on a third-party node; the alternative is a false positive on every one of them. Stated in the rule's docblock, pinned by a test.

One node, one rule. Rule A treats ANY enclosing try_catch as containment — including a catch-less one, which contains nothing — precisely so the near-miss is reported once, by rule B, which is the rule that can name the missing key. Telling an author who wrapped their node that they must wrap it is the one thing worth avoiding. Both docblocks say so, and a test pins the single finding.

Ancestry, not depth. A try_catch inside a parallel branch inside the loop still contains; a fallible node three levels down with no try_catch on the path still kills the sweep. The descent walks through any container using the shared region-slot table, and stops at a nested loop — that loop is judged in its own graph, so a nested body is never reported once per enclosing loop.

Acceptance (from the issue), each line against a test name

  • unwrapped-loop fixture gives a warning naming the loop and the node — rule A ... > flags the fallible node, naming the loop AND the node (asserts where = flow 'case_sla_monitor' · loop 'each' body · node 'notify_owner' (notify), message contains inside loop 'each', severity undefined).
  • the try_catch-without-catch fixture gives a warning — rule B ... > flags the near-miss inside a loop body, and says it dies like an unwrapped node, plus ... > flags a TOP-LEVEL catch-less try_catch too and ... > says what retry does and does not buy, when one is declared.
  • the documented loop { try_catch { try, catch: [assignment] } } fixture gives no finding — the documented spelling ... > raises NO finding from either containment rule and ... > raises no finding from ANY flow rule.
  • the docs example parses under FlowSchemathe documented spelling ... > parses under FlowSchema — the docs example is authorable (same ids and labels as the page).

Additional controls: every fallible builtin flagged once each; the non-fallible builtins left alone; an unknown node type left alone; a legacy flat-graph loop (no body) left alone; a parallel branch reported with the branch in where; a nested loop reported once against the inner loop; a present-but-malformed catch left to the schema (with TryCatchConfigSchema.safeParse pinning that catch: {} and catch: { nodes: [] } are refused and the minimal spelling is accepted).

Eight pre-existing cases in this file asserted a bare total finding count on fixtures whose loop bodies carry a write or a notify; they are now scoped to the rule under test, with a comment saying why. One of them instead pins the co-occurrence explicitly ([FLOW_RUNAS_UNSCOPED, FLOW_LOOP_BODY_UNCONTAINED]) rather than filtering it away.

Docs

content/docs/automation/flows.mdx, new subsection #### Per-iteration containment: loop { try_catch { … } } under ### Loop container: why an uncontained body dies, the measured numbers on both sides, the full example, the refusal table (catch omitted contains nothing; catch: {} and catch: { nodes: [] } refused, the region's nodes being .min(1)), the minimal handler (one bare assignment, edges and errorVariable omitted) and the two rule ids. The existing ### Loop container example no longer teaches the warned shape — its body node is wrapped. ### Try / catch / retry now states that omitting catch gives zero containment and points at the subsection.

Verification

Command: pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/lint-flow-patterns.test.tsTest Files 1 passed (1) · Tests 132 passed (132) (117 before this PR). Run through the shared verify lock, on this head.

pnpm --filter @objectstack/lint typecheck → exit 0, and its check:test-typecheck leg compiles the test layer under tsconfig.test.json, so the new test file really is type-checked.

pnpm lint (the full-repo eslint . --no-inline-config) → exit 0 on this head. Not narrowed.

Gate union derived in this worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (57 families: 53 by path, 6 by change kind, 2 shared; the union was re-derived after the merge and is unchanged). All 57 run on the final commit 126f9d5e0: 54 RAN-PASS, 3 NOT MEASURED, each refusing on a prerequisite in its own words rather than reporting a finding:

  • node scripts/check-test-completeness.mjs — exit 3, "this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED".
  • pnpm check:dual-build-cjs-loads — exit 3, "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist" (83 packages listed). Self-test green (93 cases).
  • pnpm check:type-check-debt — exit 3, "PREREQUISITE NOT MET"; it needs the whole built workspace closure. Self-test green.

pnpm --filter @objectstack/spec run check:skill-examples first refused the same way ("packages/client-react/dist holds no .d.ts declarations ... a verdict now would be computed against a build that no longer matches src"); after building that closure it RAN and passed: "261 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them". It is counted in the 54.

check:doc-authoring failed once, genuinely: the two new hints carried (#13681, #14394) in runtime string prose, which the gate refuses for a NEW (file, id) pair — "a runtime string reaches authors, operators and generated surfaces — none of whom can resolve #NNNN". The ids moved to the adjacent comments and it is green.

git diff --stat origin/main...HEAD — the five declared paths and nothing else:

 .changeset/flow-loop-per-iteration-containment-lint.md | 35 +++
content/docs/automation/flows.mdx | 89 +++-
packages/lint/src/index.ts | 2 +
packages/lint/src/lint-flow-patterns.test.ts | 349 +++++++++++-
packages/lint/src/lint-flow-patterns.ts | 318 +++++++++-
5 files changed, 780 insertions(+), 13 deletions(-)

Changeset: .changeset/flow-loop-per-iteration-containment-lint.md, @objectstack/lint patch.

Out of scope and untouched, per the card: the run-summary visibility half (FlowRunSummarySchema, the iteration index on nested try/catch steps, $error row identity) stays on #13681, and #13803 is not addressed here.

Draft on purpose: not ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV

Generated by Claude Code


Generated by Claude Code

…no catch
Two authoring-time rules in the flow anti-pattern family, both warnings:
`flow-loop-body-uncontained` (a `loop` body running a fallible node with no
`try_catch` between the loop and it) and `flow-try-catch-without-catch` (the
near-miss shape that gives zero containment while looking like containment).
Documents `loop { try_catch { … } }` in the flow docs as the per-iteration
containment spelling, with the measured minimal `catch` — one bare `assignment`
node, `edges` and `errorVariable` omitted — and the two empty-`catch` spellings
the region schema refuses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
`check:doc-authoring` refuses a NEW internal issue id in string prose a
runtime surface shows an author: the ids move to the adjacent comments, where
the reader who can resolve them already looks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 17 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422packageMentionDocs.

Which tree this was computed on

This run read content/docs from 436833382a9e7c7309fb841bc87488817c2722a6 — the merge of head 126f9d5e0576d3bb0ee286267158eb3440c387b7 into base 2514d49f388e898e666ae04f19ba376d04db5422, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 436833382a9e7c7309fb841bc87488817c2722a6 && git checkout 436833382a9e7c7309fb841bc87488817c2722a6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2514d49f388e898e666ae04f19ba376d04db5422 126f9d5e0576d3bb0ee286267158eb3440c387b7 && git checkout -B drift-repro 2514d49f388e898e666ae04f19ba376d04db5422 && git merge --no-ff 126f9d5e0576d3bb0ee286267158eb3440c387b7
node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2514d49f388e898e666ae04f19ba376d04db5422 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33653485310 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 6 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33665522337 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/6) — 失败步骤: Run this shard's tests

    create-objectstack:test: FAIL src/scaffold-e2e-boot-probe.test.ts > [#9779] scaffold-e2e.yml boot-and-probe blocks assert on their OWN server > scaffold-local / Boot from the artifact and probe heal
    ↳ 失败原因: create-objectstack:test: Error: the neighbour never came up on port 38700
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/scaffold-e2e-boot-probe.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 10 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 8ed9c54Sep 2, 2026
39 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-14394-loop-containment-lint branch September 2, 2026 18:39
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch } - #14617

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint
Sep 2, 2026
Merged

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch }#14617
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14394

B-branch items 1-2 of #13681: two authoring-time lint rules for per-iteration containment, plus the docs orthography. No packages/spec change, no engine change, no loop.config.onIterationError — the ruling of record selected branch B by measurement (the combination already works), so this card is lint + docs only.

The two rules (both warning)

flow-loop-body-uncontained — a loop whose body region runs a fallible node with no try_catch between the loop and that node. loop-node.ts iterates with a bare await and has no try/catch at all, so the first failing item ends the whole run. The finding names the loop and the node in where and the loop again in the message; the hint carries the prescribed spelling and the measured minimal catch.

flow-try-catch-without-catch — a try_catch with no catch region, anywhere in a flow (not only inside a loop). Measured on the real engine: the no-catch run and the unwrapped control produce identical output. This is the family's first target, not an extra — an author who wrapped the node and stopped there previously got zero containment and zero diagnostics.

Both stay warnings under the file's own severity bar: a loop deliberately allowed to stop at the first bad row, and a retry-then-fail try_catch, are legitimate readings the rules cannot disprove.

Decisions this PR makes, stated

The fallible set is a closed literal list with a per-type justification read off each executor (FALLIBLE_NODE_TYPES docblock cites file and line for every member): get_record, create_record, update_record, delete_record, http, notify, connector_action, script, subflow, map, approval. Deliberately absent, also by reading the executor: assignment and decision (every path returns success), wait and screen (they suspend and have no failure return), start / end (sentinels a region may not carry), and the three containers loop / parallel / try_catch (they fail only by propagation, so the walk descends to the leaf that carries the real failure instead of double-reporting the wrapper).

Unknown (plugin-registered) node types are NOT counted fallible. An unread executor can certainly return success: false, so counting them would be defensible for a warning — but it would flag nodes nobody here has read, and this family's precedent (INERT_CONDITION_NODE_TYPES) is that membership means "we have read this executor". The cost is a false negative on a third-party node; the alternative is a false positive on every one of them. Stated in the rule's docblock, pinned by a test.

One node, one rule. Rule A treats ANY enclosing try_catch as containment — including a catch-less one, which contains nothing — precisely so the near-miss is reported once, by rule B, which is the rule that can name the missing key. Telling an author who wrapped their node that they must wrap it is the one thing worth avoiding. Both docblocks say so, and a test pins the single finding.

Ancestry, not depth. A try_catch inside a parallel branch inside the loop still contains; a fallible node three levels down with no try_catch on the path still kills the sweep. The descent walks through any container using the shared region-slot table, and stops at a nested loop — that loop is judged in its own graph, so a nested body is never reported once per enclosing loop.

Acceptance (from the issue), each line against a test name

  • unwrapped-loop fixture gives a warning naming the loop and the node — rule A ... > flags the fallible node, naming the loop AND the node (asserts where = flow 'case_sla_monitor' · loop 'each' body · node 'notify_owner' (notify), message contains inside loop 'each', severity undefined).
  • the try_catch-without-catch fixture gives a warning — rule B ... > flags the near-miss inside a loop body, and says it dies like an unwrapped node, plus ... > flags a TOP-LEVEL catch-less try_catch too and ... > says what retry does and does not buy, when one is declared.
  • the documented loop { try_catch { try, catch: [assignment] } } fixture gives no finding — the documented spelling ... > raises NO finding from either containment rule and ... > raises no finding from ANY flow rule.
  • the docs example parses under FlowSchemathe documented spelling ... > parses under FlowSchema — the docs example is authorable (same ids and labels as the page).

Additional controls: every fallible builtin flagged once each; the non-fallible builtins left alone; an unknown node type left alone; a legacy flat-graph loop (no body) left alone; a parallel branch reported with the branch in where; a nested loop reported once against the inner loop; a present-but-malformed catch left to the schema (with TryCatchConfigSchema.safeParse pinning that catch: {} and catch: { nodes: [] } are refused and the minimal spelling is accepted).

Eight pre-existing cases in this file asserted a bare total finding count on fixtures whose loop bodies carry a write or a notify; they are now scoped to the rule under test, with a comment saying why. One of them instead pins the co-occurrence explicitly ([FLOW_RUNAS_UNSCOPED, FLOW_LOOP_BODY_UNCONTAINED]) rather than filtering it away.

Docs

content/docs/automation/flows.mdx, new subsection #### Per-iteration containment: loop { try_catch { … } } under ### Loop container: why an uncontained body dies, the measured numbers on both sides, the full example, the refusal table (catch omitted contains nothing; catch: {} and catch: { nodes: [] } refused, the region's nodes being .min(1)), the minimal handler (one bare assignment, edges and errorVariable omitted) and the two rule ids. The existing ### Loop container example no longer teaches the warned shape — its body node is wrapped. ### Try / catch / retry now states that omitting catch gives zero containment and points at the subsection.

Verification

Command: pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/lint-flow-patterns.test.tsTest Files 1 passed (1) · Tests 132 passed (132) (117 before this PR). Run through the shared verify lock, on this head.

pnpm --filter @objectstack/lint typecheck → exit 0, and its check:test-typecheck leg compiles the test layer under tsconfig.test.json, so the new test file really is type-checked.

pnpm lint (the full-repo eslint . --no-inline-config) → exit 0 on this head. Not narrowed.

Gate union derived in this worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (57 families: 53 by path, 6 by change kind, 2 shared; the union was re-derived after the merge and is unchanged). All 57 run on the final commit 126f9d5e0: 54 RAN-PASS, 3 NOT MEASURED, each refusing on a prerequisite in its own words rather than reporting a finding:

  • node scripts/check-test-completeness.mjs — exit 3, "this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED".
  • pnpm check:dual-build-cjs-loads — exit 3, "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist" (83 packages listed). Self-test green (93 cases).
  • pnpm check:type-check-debt — exit 3, "PREREQUISITE NOT MET"; it needs the whole built workspace closure. Self-test green.

pnpm --filter @objectstack/spec run check:skill-examples first refused the same way ("packages/client-react/dist holds no .d.ts declarations ... a verdict now would be computed against a build that no longer matches src"); after building that closure it RAN and passed: "261 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them". It is counted in the 54.

check:doc-authoring failed once, genuinely: the two new hints carried (#13681, #14394) in runtime string prose, which the gate refuses for a NEW (file, id) pair — "a runtime string reaches authors, operators and generated surfaces — none of whom can resolve #NNNN". The ids moved to the adjacent comments and it is green.

git diff --stat origin/main...HEAD — the five declared paths and nothing else:

 .changeset/flow-loop-per-iteration-containment-lint.md | 35 +++
content/docs/automation/flows.mdx | 89 +++-
packages/lint/src/index.ts | 2 +
packages/lint/src/lint-flow-patterns.test.ts | 349 +++++++++++-
packages/lint/src/lint-flow-patterns.ts | 318 +++++++++-
5 files changed, 780 insertions(+), 13 deletions(-)

Changeset: .changeset/flow-loop-per-iteration-containment-lint.md, @objectstack/lint patch.

Out of scope and untouched, per the card: the run-summary visibility half (FlowRunSummarySchema, the iteration index on nested try/catch steps, $error row identity) stays on #13681, and #13803 is not addressed here.

Draft on purpose: not ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV

Generated by Claude Code


Generated by Claude Code

…no catch
Two authoring-time rules in the flow anti-pattern family, both warnings:
`flow-loop-body-uncontained` (a `loop` body running a fallible node with no
`try_catch` between the loop and it) and `flow-try-catch-without-catch` (the
near-miss shape that gives zero containment while looking like containment).
Documents `loop { try_catch { … } }` in the flow docs as the per-iteration
containment spelling, with the measured minimal `catch` — one bare `assignment`
node, `edges` and `errorVariable` omitted — and the two empty-`catch` spellings
the region schema refuses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
`check:doc-authoring` refuses a NEW internal issue id in string prose a
runtime surface shows an author: the ids move to the adjacent comments, where
the reader who can resolve them already looks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 17 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422packageMentionDocs.

Which tree this was computed on

This run read content/docs from 436833382a9e7c7309fb841bc87488817c2722a6 — the merge of head 126f9d5e0576d3bb0ee286267158eb3440c387b7 into base 2514d49f388e898e666ae04f19ba376d04db5422, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 436833382a9e7c7309fb841bc87488817c2722a6 && git checkout 436833382a9e7c7309fb841bc87488817c2722a6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2514d49f388e898e666ae04f19ba376d04db5422 126f9d5e0576d3bb0ee286267158eb3440c387b7 && git checkout -B drift-repro 2514d49f388e898e666ae04f19ba376d04db5422 && git merge --no-ff 126f9d5e0576d3bb0ee286267158eb3440c387b7
node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2514d49f388e898e666ae04f19ba376d04db5422 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33653485310 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 6 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33665522337 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/6) — 失败步骤: Run this shard's tests

    create-objectstack:test: FAIL src/scaffold-e2e-boot-probe.test.ts > [#9779] scaffold-e2e.yml boot-and-probe blocks assert on their OWN server > scaffold-local / Boot from the artifact and probe heal
    ↳ 失败原因: create-objectstack:test: Error: the neighbour never came up on port 38700
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/scaffold-e2e-boot-probe.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 10 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 8ed9c54Sep 2, 2026
39 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-14394-loop-containment-lint branch September 2, 2026 18:39
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch } - #14617

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint
Sep 2, 2026
Merged

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch }#14617
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14394

B-branch items 1-2 of #13681: two authoring-time lint rules for per-iteration containment, plus the docs orthography. No packages/spec change, no engine change, no loop.config.onIterationError — the ruling of record selected branch B by measurement (the combination already works), so this card is lint + docs only.

The two rules (both warning)

flow-loop-body-uncontained — a loop whose body region runs a fallible node with no try_catch between the loop and that node. loop-node.ts iterates with a bare await and has no try/catch at all, so the first failing item ends the whole run. The finding names the loop and the node in where and the loop again in the message; the hint carries the prescribed spelling and the measured minimal catch.

flow-try-catch-without-catch — a try_catch with no catch region, anywhere in a flow (not only inside a loop). Measured on the real engine: the no-catch run and the unwrapped control produce identical output. This is the family's first target, not an extra — an author who wrapped the node and stopped there previously got zero containment and zero diagnostics.

Both stay warnings under the file's own severity bar: a loop deliberately allowed to stop at the first bad row, and a retry-then-fail try_catch, are legitimate readings the rules cannot disprove.

Decisions this PR makes, stated

The fallible set is a closed literal list with a per-type justification read off each executor (FALLIBLE_NODE_TYPES docblock cites file and line for every member): get_record, create_record, update_record, delete_record, http, notify, connector_action, script, subflow, map, approval. Deliberately absent, also by reading the executor: assignment and decision (every path returns success), wait and screen (they suspend and have no failure return), start / end (sentinels a region may not carry), and the three containers loop / parallel / try_catch (they fail only by propagation, so the walk descends to the leaf that carries the real failure instead of double-reporting the wrapper).

Unknown (plugin-registered) node types are NOT counted fallible. An unread executor can certainly return success: false, so counting them would be defensible for a warning — but it would flag nodes nobody here has read, and this family's precedent (INERT_CONDITION_NODE_TYPES) is that membership means "we have read this executor". The cost is a false negative on a third-party node; the alternative is a false positive on every one of them. Stated in the rule's docblock, pinned by a test.

One node, one rule. Rule A treats ANY enclosing try_catch as containment — including a catch-less one, which contains nothing — precisely so the near-miss is reported once, by rule B, which is the rule that can name the missing key. Telling an author who wrapped their node that they must wrap it is the one thing worth avoiding. Both docblocks say so, and a test pins the single finding.

Ancestry, not depth. A try_catch inside a parallel branch inside the loop still contains; a fallible node three levels down with no try_catch on the path still kills the sweep. The descent walks through any container using the shared region-slot table, and stops at a nested loop — that loop is judged in its own graph, so a nested body is never reported once per enclosing loop.

Acceptance (from the issue), each line against a test name

  • unwrapped-loop fixture gives a warning naming the loop and the node — rule A ... > flags the fallible node, naming the loop AND the node (asserts where = flow 'case_sla_monitor' · loop 'each' body · node 'notify_owner' (notify), message contains inside loop 'each', severity undefined).
  • the try_catch-without-catch fixture gives a warning — rule B ... > flags the near-miss inside a loop body, and says it dies like an unwrapped node, plus ... > flags a TOP-LEVEL catch-less try_catch too and ... > says what retry does and does not buy, when one is declared.
  • the documented loop { try_catch { try, catch: [assignment] } } fixture gives no finding — the documented spelling ... > raises NO finding from either containment rule and ... > raises no finding from ANY flow rule.
  • the docs example parses under FlowSchemathe documented spelling ... > parses under FlowSchema — the docs example is authorable (same ids and labels as the page).

Additional controls: every fallible builtin flagged once each; the non-fallible builtins left alone; an unknown node type left alone; a legacy flat-graph loop (no body) left alone; a parallel branch reported with the branch in where; a nested loop reported once against the inner loop; a present-but-malformed catch left to the schema (with TryCatchConfigSchema.safeParse pinning that catch: {} and catch: { nodes: [] } are refused and the minimal spelling is accepted).

Eight pre-existing cases in this file asserted a bare total finding count on fixtures whose loop bodies carry a write or a notify; they are now scoped to the rule under test, with a comment saying why. One of them instead pins the co-occurrence explicitly ([FLOW_RUNAS_UNSCOPED, FLOW_LOOP_BODY_UNCONTAINED]) rather than filtering it away.

Docs

content/docs/automation/flows.mdx, new subsection #### Per-iteration containment: loop { try_catch { … } } under ### Loop container: why an uncontained body dies, the measured numbers on both sides, the full example, the refusal table (catch omitted contains nothing; catch: {} and catch: { nodes: [] } refused, the region's nodes being .min(1)), the minimal handler (one bare assignment, edges and errorVariable omitted) and the two rule ids. The existing ### Loop container example no longer teaches the warned shape — its body node is wrapped. ### Try / catch / retry now states that omitting catch gives zero containment and points at the subsection.

Verification

Command: pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/lint-flow-patterns.test.tsTest Files 1 passed (1) · Tests 132 passed (132) (117 before this PR). Run through the shared verify lock, on this head.

pnpm --filter @objectstack/lint typecheck → exit 0, and its check:test-typecheck leg compiles the test layer under tsconfig.test.json, so the new test file really is type-checked.

pnpm lint (the full-repo eslint . --no-inline-config) → exit 0 on this head. Not narrowed.

Gate union derived in this worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (57 families: 53 by path, 6 by change kind, 2 shared; the union was re-derived after the merge and is unchanged). All 57 run on the final commit 126f9d5e0: 54 RAN-PASS, 3 NOT MEASURED, each refusing on a prerequisite in its own words rather than reporting a finding:

  • node scripts/check-test-completeness.mjs — exit 3, "this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED".
  • pnpm check:dual-build-cjs-loads — exit 3, "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist" (83 packages listed). Self-test green (93 cases).
  • pnpm check:type-check-debt — exit 3, "PREREQUISITE NOT MET"; it needs the whole built workspace closure. Self-test green.

pnpm --filter @objectstack/spec run check:skill-examples first refused the same way ("packages/client-react/dist holds no .d.ts declarations ... a verdict now would be computed against a build that no longer matches src"); after building that closure it RAN and passed: "261 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them". It is counted in the 54.

check:doc-authoring failed once, genuinely: the two new hints carried (#13681, #14394) in runtime string prose, which the gate refuses for a NEW (file, id) pair — "a runtime string reaches authors, operators and generated surfaces — none of whom can resolve #NNNN". The ids moved to the adjacent comments and it is green.

git diff --stat origin/main...HEAD — the five declared paths and nothing else:

 .changeset/flow-loop-per-iteration-containment-lint.md | 35 +++
content/docs/automation/flows.mdx | 89 +++-
packages/lint/src/index.ts | 2 +
packages/lint/src/lint-flow-patterns.test.ts | 349 +++++++++++-
packages/lint/src/lint-flow-patterns.ts | 318 +++++++++-
5 files changed, 780 insertions(+), 13 deletions(-)

Changeset: .changeset/flow-loop-per-iteration-containment-lint.md, @objectstack/lint patch.

Out of scope and untouched, per the card: the run-summary visibility half (FlowRunSummarySchema, the iteration index on nested try/catch steps, $error row identity) stays on #13681, and #13803 is not addressed here.

Draft on purpose: not ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV

Generated by Claude Code


Generated by Claude Code

…no catch
Two authoring-time rules in the flow anti-pattern family, both warnings:
`flow-loop-body-uncontained` (a `loop` body running a fallible node with no
`try_catch` between the loop and it) and `flow-try-catch-without-catch` (the
near-miss shape that gives zero containment while looking like containment).
Documents `loop { try_catch { … } }` in the flow docs as the per-iteration
containment spelling, with the measured minimal `catch` — one bare `assignment`
node, `edges` and `errorVariable` omitted — and the two empty-`catch` spellings
the region schema refuses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
`check:doc-authoring` refuses a NEW internal issue id in string prose a
runtime surface shows an author: the ids move to the adjacent comments, where
the reader who can resolve them already looks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 17 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422packageMentionDocs.

Which tree this was computed on

This run read content/docs from 436833382a9e7c7309fb841bc87488817c2722a6 — the merge of head 126f9d5e0576d3bb0ee286267158eb3440c387b7 into base 2514d49f388e898e666ae04f19ba376d04db5422, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 436833382a9e7c7309fb841bc87488817c2722a6 && git checkout 436833382a9e7c7309fb841bc87488817c2722a6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2514d49f388e898e666ae04f19ba376d04db5422 126f9d5e0576d3bb0ee286267158eb3440c387b7 && git checkout -B drift-repro 2514d49f388e898e666ae04f19ba376d04db5422 && git merge --no-ff 126f9d5e0576d3bb0ee286267158eb3440c387b7
node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2514d49f388e898e666ae04f19ba376d04db5422 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33653485310 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 6 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33665522337 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/6) — 失败步骤: Run this shard's tests

    create-objectstack:test: FAIL src/scaffold-e2e-boot-probe.test.ts > [#9779] scaffold-e2e.yml boot-and-probe blocks assert on their OWN server > scaffold-local / Boot from the artifact and probe heal
    ↳ 失败原因: create-objectstack:test: Error: the neighbour never came up on port 38700
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/scaffold-e2e-boot-probe.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 10 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 8ed9c54Sep 2, 2026
39 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-14394-loop-containment-lint branch September 2, 2026 18:39
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch } - #14617

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint
Sep 2, 2026
Merged

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch }#14617
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14394

B-branch items 1-2 of #13681: two authoring-time lint rules for per-iteration containment, plus the docs orthography. No packages/spec change, no engine change, no loop.config.onIterationError — the ruling of record selected branch B by measurement (the combination already works), so this card is lint + docs only.

The two rules (both warning)

flow-loop-body-uncontained — a loop whose body region runs a fallible node with no try_catch between the loop and that node. loop-node.ts iterates with a bare await and has no try/catch at all, so the first failing item ends the whole run. The finding names the loop and the node in where and the loop again in the message; the hint carries the prescribed spelling and the measured minimal catch.

flow-try-catch-without-catch — a try_catch with no catch region, anywhere in a flow (not only inside a loop). Measured on the real engine: the no-catch run and the unwrapped control produce identical output. This is the family's first target, not an extra — an author who wrapped the node and stopped there previously got zero containment and zero diagnostics.

Both stay warnings under the file's own severity bar: a loop deliberately allowed to stop at the first bad row, and a retry-then-fail try_catch, are legitimate readings the rules cannot disprove.

Decisions this PR makes, stated

The fallible set is a closed literal list with a per-type justification read off each executor (FALLIBLE_NODE_TYPES docblock cites file and line for every member): get_record, create_record, update_record, delete_record, http, notify, connector_action, script, subflow, map, approval. Deliberately absent, also by reading the executor: assignment and decision (every path returns success), wait and screen (they suspend and have no failure return), start / end (sentinels a region may not carry), and the three containers loop / parallel / try_catch (they fail only by propagation, so the walk descends to the leaf that carries the real failure instead of double-reporting the wrapper).

Unknown (plugin-registered) node types are NOT counted fallible. An unread executor can certainly return success: false, so counting them would be defensible for a warning — but it would flag nodes nobody here has read, and this family's precedent (INERT_CONDITION_NODE_TYPES) is that membership means "we have read this executor". The cost is a false negative on a third-party node; the alternative is a false positive on every one of them. Stated in the rule's docblock, pinned by a test.

One node, one rule. Rule A treats ANY enclosing try_catch as containment — including a catch-less one, which contains nothing — precisely so the near-miss is reported once, by rule B, which is the rule that can name the missing key. Telling an author who wrapped their node that they must wrap it is the one thing worth avoiding. Both docblocks say so, and a test pins the single finding.

Ancestry, not depth. A try_catch inside a parallel branch inside the loop still contains; a fallible node three levels down with no try_catch on the path still kills the sweep. The descent walks through any container using the shared region-slot table, and stops at a nested loop — that loop is judged in its own graph, so a nested body is never reported once per enclosing loop.

Acceptance (from the issue), each line against a test name

  • unwrapped-loop fixture gives a warning naming the loop and the node — rule A ... > flags the fallible node, naming the loop AND the node (asserts where = flow 'case_sla_monitor' · loop 'each' body · node 'notify_owner' (notify), message contains inside loop 'each', severity undefined).
  • the try_catch-without-catch fixture gives a warning — rule B ... > flags the near-miss inside a loop body, and says it dies like an unwrapped node, plus ... > flags a TOP-LEVEL catch-less try_catch too and ... > says what retry does and does not buy, when one is declared.
  • the documented loop { try_catch { try, catch: [assignment] } } fixture gives no finding — the documented spelling ... > raises NO finding from either containment rule and ... > raises no finding from ANY flow rule.
  • the docs example parses under FlowSchemathe documented spelling ... > parses under FlowSchema — the docs example is authorable (same ids and labels as the page).

Additional controls: every fallible builtin flagged once each; the non-fallible builtins left alone; an unknown node type left alone; a legacy flat-graph loop (no body) left alone; a parallel branch reported with the branch in where; a nested loop reported once against the inner loop; a present-but-malformed catch left to the schema (with TryCatchConfigSchema.safeParse pinning that catch: {} and catch: { nodes: [] } are refused and the minimal spelling is accepted).

Eight pre-existing cases in this file asserted a bare total finding count on fixtures whose loop bodies carry a write or a notify; they are now scoped to the rule under test, with a comment saying why. One of them instead pins the co-occurrence explicitly ([FLOW_RUNAS_UNSCOPED, FLOW_LOOP_BODY_UNCONTAINED]) rather than filtering it away.

Docs

content/docs/automation/flows.mdx, new subsection #### Per-iteration containment: loop { try_catch { … } } under ### Loop container: why an uncontained body dies, the measured numbers on both sides, the full example, the refusal table (catch omitted contains nothing; catch: {} and catch: { nodes: [] } refused, the region's nodes being .min(1)), the minimal handler (one bare assignment, edges and errorVariable omitted) and the two rule ids. The existing ### Loop container example no longer teaches the warned shape — its body node is wrapped. ### Try / catch / retry now states that omitting catch gives zero containment and points at the subsection.

Verification

Command: pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/lint-flow-patterns.test.tsTest Files 1 passed (1) · Tests 132 passed (132) (117 before this PR). Run through the shared verify lock, on this head.

pnpm --filter @objectstack/lint typecheck → exit 0, and its check:test-typecheck leg compiles the test layer under tsconfig.test.json, so the new test file really is type-checked.

pnpm lint (the full-repo eslint . --no-inline-config) → exit 0 on this head. Not narrowed.

Gate union derived in this worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (57 families: 53 by path, 6 by change kind, 2 shared; the union was re-derived after the merge and is unchanged). All 57 run on the final commit 126f9d5e0: 54 RAN-PASS, 3 NOT MEASURED, each refusing on a prerequisite in its own words rather than reporting a finding:

  • node scripts/check-test-completeness.mjs — exit 3, "this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED".
  • pnpm check:dual-build-cjs-loads — exit 3, "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist" (83 packages listed). Self-test green (93 cases).
  • pnpm check:type-check-debt — exit 3, "PREREQUISITE NOT MET"; it needs the whole built workspace closure. Self-test green.

pnpm --filter @objectstack/spec run check:skill-examples first refused the same way ("packages/client-react/dist holds no .d.ts declarations ... a verdict now would be computed against a build that no longer matches src"); after building that closure it RAN and passed: "261 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them". It is counted in the 54.

check:doc-authoring failed once, genuinely: the two new hints carried (#13681, #14394) in runtime string prose, which the gate refuses for a NEW (file, id) pair — "a runtime string reaches authors, operators and generated surfaces — none of whom can resolve #NNNN". The ids moved to the adjacent comments and it is green.

git diff --stat origin/main...HEAD — the five declared paths and nothing else:

 .changeset/flow-loop-per-iteration-containment-lint.md | 35 +++
content/docs/automation/flows.mdx | 89 +++-
packages/lint/src/index.ts | 2 +
packages/lint/src/lint-flow-patterns.test.ts | 349 +++++++++++-
packages/lint/src/lint-flow-patterns.ts | 318 +++++++++-
5 files changed, 780 insertions(+), 13 deletions(-)

Changeset: .changeset/flow-loop-per-iteration-containment-lint.md, @objectstack/lint patch.

Out of scope and untouched, per the card: the run-summary visibility half (FlowRunSummarySchema, the iteration index on nested try/catch steps, $error row identity) stays on #13681, and #13803 is not addressed here.

Draft on purpose: not ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV

Generated by Claude Code


Generated by Claude Code

…no catch
Two authoring-time rules in the flow anti-pattern family, both warnings:
`flow-loop-body-uncontained` (a `loop` body running a fallible node with no
`try_catch` between the loop and it) and `flow-try-catch-without-catch` (the
near-miss shape that gives zero containment while looking like containment).
Documents `loop { try_catch { … } }` in the flow docs as the per-iteration
containment spelling, with the measured minimal `catch` — one bare `assignment`
node, `edges` and `errorVariable` omitted — and the two empty-`catch` spellings
the region schema refuses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
`check:doc-authoring` refuses a NEW internal issue id in string prose a
runtime surface shows an author: the ids move to the adjacent comments, where
the reader who can resolve them already looks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 17 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422packageMentionDocs.

Which tree this was computed on

This run read content/docs from 436833382a9e7c7309fb841bc87488817c2722a6 — the merge of head 126f9d5e0576d3bb0ee286267158eb3440c387b7 into base 2514d49f388e898e666ae04f19ba376d04db5422, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 436833382a9e7c7309fb841bc87488817c2722a6 && git checkout 436833382a9e7c7309fb841bc87488817c2722a6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2514d49f388e898e666ae04f19ba376d04db5422 126f9d5e0576d3bb0ee286267158eb3440c387b7 && git checkout -B drift-repro 2514d49f388e898e666ae04f19ba376d04db5422 && git merge --no-ff 126f9d5e0576d3bb0ee286267158eb3440c387b7
node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2514d49f388e898e666ae04f19ba376d04db5422 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33653485310 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 6 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33665522337 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/6) — 失败步骤: Run this shard's tests

    create-objectstack:test: FAIL src/scaffold-e2e-boot-probe.test.ts > [#9779] scaffold-e2e.yml boot-and-probe blocks assert on their OWN server > scaffold-local / Boot from the artifact and probe heal
    ↳ 失败原因: create-objectstack:test: Error: the neighbour never came up on port 38700
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/scaffold-e2e-boot-probe.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 10 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 8ed9c54Sep 2, 2026
39 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-14394-loop-containment-lint branch September 2, 2026 18:39
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch } - #14617

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint
Sep 2, 2026
Merged

lint(flows): warn on an uncontained loop body and on a try_catch with no catch, and document loop { try_catch }#14617
baozhoutao merged 3 commits into
mainfrom
claude/issue-14394-loop-containment-lint

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14394

B-branch items 1-2 of #13681: two authoring-time lint rules for per-iteration containment, plus the docs orthography. No packages/spec change, no engine change, no loop.config.onIterationError — the ruling of record selected branch B by measurement (the combination already works), so this card is lint + docs only.

The two rules (both warning)

flow-loop-body-uncontained — a loop whose body region runs a fallible node with no try_catch between the loop and that node. loop-node.ts iterates with a bare await and has no try/catch at all, so the first failing item ends the whole run. The finding names the loop and the node in where and the loop again in the message; the hint carries the prescribed spelling and the measured minimal catch.

flow-try-catch-without-catch — a try_catch with no catch region, anywhere in a flow (not only inside a loop). Measured on the real engine: the no-catch run and the unwrapped control produce identical output. This is the family's first target, not an extra — an author who wrapped the node and stopped there previously got zero containment and zero diagnostics.

Both stay warnings under the file's own severity bar: a loop deliberately allowed to stop at the first bad row, and a retry-then-fail try_catch, are legitimate readings the rules cannot disprove.

Decisions this PR makes, stated

The fallible set is a closed literal list with a per-type justification read off each executor (FALLIBLE_NODE_TYPES docblock cites file and line for every member): get_record, create_record, update_record, delete_record, http, notify, connector_action, script, subflow, map, approval. Deliberately absent, also by reading the executor: assignment and decision (every path returns success), wait and screen (they suspend and have no failure return), start / end (sentinels a region may not carry), and the three containers loop / parallel / try_catch (they fail only by propagation, so the walk descends to the leaf that carries the real failure instead of double-reporting the wrapper).

Unknown (plugin-registered) node types are NOT counted fallible. An unread executor can certainly return success: false, so counting them would be defensible for a warning — but it would flag nodes nobody here has read, and this family's precedent (INERT_CONDITION_NODE_TYPES) is that membership means "we have read this executor". The cost is a false negative on a third-party node; the alternative is a false positive on every one of them. Stated in the rule's docblock, pinned by a test.

One node, one rule. Rule A treats ANY enclosing try_catch as containment — including a catch-less one, which contains nothing — precisely so the near-miss is reported once, by rule B, which is the rule that can name the missing key. Telling an author who wrapped their node that they must wrap it is the one thing worth avoiding. Both docblocks say so, and a test pins the single finding.

Ancestry, not depth. A try_catch inside a parallel branch inside the loop still contains; a fallible node three levels down with no try_catch on the path still kills the sweep. The descent walks through any container using the shared region-slot table, and stops at a nested loop — that loop is judged in its own graph, so a nested body is never reported once per enclosing loop.

Acceptance (from the issue), each line against a test name

  • unwrapped-loop fixture gives a warning naming the loop and the node — rule A ... > flags the fallible node, naming the loop AND the node (asserts where = flow 'case_sla_monitor' · loop 'each' body · node 'notify_owner' (notify), message contains inside loop 'each', severity undefined).
  • the try_catch-without-catch fixture gives a warning — rule B ... > flags the near-miss inside a loop body, and says it dies like an unwrapped node, plus ... > flags a TOP-LEVEL catch-less try_catch too and ... > says what retry does and does not buy, when one is declared.
  • the documented loop { try_catch { try, catch: [assignment] } } fixture gives no finding — the documented spelling ... > raises NO finding from either containment rule and ... > raises no finding from ANY flow rule.
  • the docs example parses under FlowSchemathe documented spelling ... > parses under FlowSchema — the docs example is authorable (same ids and labels as the page).

Additional controls: every fallible builtin flagged once each; the non-fallible builtins left alone; an unknown node type left alone; a legacy flat-graph loop (no body) left alone; a parallel branch reported with the branch in where; a nested loop reported once against the inner loop; a present-but-malformed catch left to the schema (with TryCatchConfigSchema.safeParse pinning that catch: {} and catch: { nodes: [] } are refused and the minimal spelling is accepted).

Eight pre-existing cases in this file asserted a bare total finding count on fixtures whose loop bodies carry a write or a notify; they are now scoped to the rule under test, with a comment saying why. One of them instead pins the co-occurrence explicitly ([FLOW_RUNAS_UNSCOPED, FLOW_LOOP_BODY_UNCONTAINED]) rather than filtering it away.

Docs

content/docs/automation/flows.mdx, new subsection #### Per-iteration containment: loop { try_catch { … } } under ### Loop container: why an uncontained body dies, the measured numbers on both sides, the full example, the refusal table (catch omitted contains nothing; catch: {} and catch: { nodes: [] } refused, the region's nodes being .min(1)), the minimal handler (one bare assignment, edges and errorVariable omitted) and the two rule ids. The existing ### Loop container example no longer teaches the warned shape — its body node is wrapped. ### Try / catch / retry now states that omitting catch gives zero containment and points at the subsection.

Verification

Command: pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/lint-flow-patterns.test.tsTest Files 1 passed (1) · Tests 132 passed (132) (117 before this PR). Run through the shared verify lock, on this head.

pnpm --filter @objectstack/lint typecheck → exit 0, and its check:test-typecheck leg compiles the test layer under tsconfig.test.json, so the new test file really is type-checked.

pnpm lint (the full-repo eslint . --no-inline-config) → exit 0 on this head. Not narrowed.

Gate union derived in this worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (57 families: 53 by path, 6 by change kind, 2 shared; the union was re-derived after the merge and is unchanged). All 57 run on the final commit 126f9d5e0: 54 RAN-PASS, 3 NOT MEASURED, each refusing on a prerequisite in its own words rather than reporting a finding:

  • node scripts/check-test-completeness.mjs — exit 3, "this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED".
  • pnpm check:dual-build-cjs-loads — exit 3, "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist" (83 packages listed). Self-test green (93 cases).
  • pnpm check:type-check-debt — exit 3, "PREREQUISITE NOT MET"; it needs the whole built workspace closure. Self-test green.

pnpm --filter @objectstack/spec run check:skill-examples first refused the same way ("packages/client-react/dist holds no .d.ts declarations ... a verdict now would be computed against a build that no longer matches src"); after building that closure it RAN and passed: "261 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them". It is counted in the 54.

check:doc-authoring failed once, genuinely: the two new hints carried (#13681, #14394) in runtime string prose, which the gate refuses for a NEW (file, id) pair — "a runtime string reaches authors, operators and generated surfaces — none of whom can resolve #NNNN". The ids moved to the adjacent comments and it is green.

git diff --stat origin/main...HEAD — the five declared paths and nothing else:

 .changeset/flow-loop-per-iteration-containment-lint.md | 35 +++
content/docs/automation/flows.mdx | 89 +++-
packages/lint/src/index.ts | 2 +
packages/lint/src/lint-flow-patterns.test.ts | 349 +++++++++++-
packages/lint/src/lint-flow-patterns.ts | 318 +++++++++-
5 files changed, 780 insertions(+), 13 deletions(-)

Changeset: .changeset/flow-loop-per-iteration-containment-lint.md, @objectstack/lint patch.

Out of scope and untouched, per the card: the run-summary visibility half (FlowRunSummarySchema, the iteration index on nested try/catch steps, $error row identity) stays on #13681, and #13803 is not addressed here.

Draft on purpose: not ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV

Generated by Claude Code


Generated by Claude Code

…no catch
Two authoring-time rules in the flow anti-pattern family, both warnings:
`flow-loop-body-uncontained` (a `loop` body running a fallible node with no
`try_catch` between the loop and it) and `flow-try-catch-without-catch` (the
near-miss shape that gives zero containment while looking like containment).
Documents `loop { try_catch { … } }` in the flow docs as the per-iteration
containment spelling, with the measured minimal `catch` — one bare `assignment`
node, `edges` and `errorVariable` omitted — and the two empty-`catch` spellings
the region schema refuses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
`check:doc-authoring` refuses a NEW internal issue id in string prose a
runtime surface shows an author: the ids move to the adjacent comments, where
the reader who can resolve them already looks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 17 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422packageMentionDocs.

Which tree this was computed on

This run read content/docs from 436833382a9e7c7309fb841bc87488817c2722a6 — the merge of head 126f9d5e0576d3bb0ee286267158eb3440c387b7 into base 2514d49f388e898e666ae04f19ba376d04db5422, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 436833382a9e7c7309fb841bc87488817c2722a6 && git checkout 436833382a9e7c7309fb841bc87488817c2722a6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2514d49f388e898e666ae04f19ba376d04db5422 126f9d5e0576d3bb0ee286267158eb3440c387b7 && git checkout -B drift-repro 2514d49f388e898e666ae04f19ba376d04db5422 && git merge --no-ff 126f9d5e0576d3bb0ee286267158eb3440c387b7
node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2514d49f388e898e666ae04f19ba376d04db5422 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33653485310 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 6 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33665522337 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/6) — 失败步骤: Run this shard's tests

    create-objectstack:test: FAIL src/scaffold-e2e-boot-probe.test.ts > [#9779] scaffold-e2e.yml boot-and-probe blocks assert on their OWN server > scaffold-local / Boot from the artifact and probe heal
    ↳ 失败原因: create-objectstack:test: Error: the neighbour never came up on port 38700
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/scaffold-e2e-boot-probe.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 10 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 8ed9c54Sep 2, 2026
39 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-14394-loop-containment-lint branch September 2, 2026 18:39
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@baozhoutao@claude