Skip to content

A hook write-set finding on a handler-authored hook is reported at hooks[i].body.source — a key the author never wrote — on both os build and os lint #16546

Closed

Description

@baozhoutao

Filed unassigned from #16095 (branch claude/issue-16095-hook-rules-reach-handler-authored-hooks). Observed on origin/main @ 5d55afec4d for os build, and on that branch for os lint once it judges the lowered view.

What the author sees

Given

hooks: [{
  name: 'escalate',
  object: 'crm_case',
  events: ['afterUpdate'],
  handler: async (ctx) => {
    await ctx.api.object('crm_case').update({ id: ctx.input.id, is_escalated: true });
  },
}]

os build (and now os lint) reports hook-api-update-readonly-field with where: hook "escalate" > body and path: hooks[0].body.source. There is no body in the source file: the body exists only inside the lowered stack the command built. An author grepping their project for body.source finds nothing.

Why it is shaped this way

lowerCallables replaces handler: fn with handler: '<ref>' plus body: { language: 'js', source }, and the validators (validate-readonly-hook-writes.ts, validate-hook-body-writes.ts) judge the stack they are given, so they cannot know the body was lowered. #16095 deliberately kept os lint's output byte-identical to os build's for the same hook (the repo's "same wording on every command" rule), so the remap belongs in one place, not in one command.

Options

  • A. Leave as is; document that a lowered hook's findings point at the lowered body (the hook-bodies.mdx write-set section now says lowering happens, but not where the path points).
  • B. Have the registry runner (runAuthoringRules) or lowerCallables carry a lowered-from marker per hook, and have the two hook validators say path: hooks[i].handler plus a message suffix ("judged on the metadata body lowered from the inline handler") when it is set — one implementation, both commands agree.
  • C. Emit both: keep path on the lowered body and add a sourcePath field pointing at hooks[i].handler.

B keeps the finding actionable without a second dialect; C is additive but widens the finding shape (a published contract for --json consumers).

Related: #16095 · #13653 (the readonly hook rule) · #4271 (the write-set rule's original scope).

Activity

  1. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊 · domain:devx / bug / priority:p3 / pm:queue / type: Bug

    分诊席,session session_01SwJQDFKe8tVit3BXQ9EfR5。本卡此前零标签 ⇒ 无车道无状态,不在任何队列。归位。

    车道 domain:devx:两个判定器(validate-readonly-hook-writes.ts / validate-hook-body-writes.ts)与 runAuthoringRules 在 packages/lint,车道表该行含 packages/lint。⚠️ 若落法选在 lowerCallables 而它不在 packages/lint,那是一次声明过的跨域外溢,认领时在认领评论里申报文件面。

    类别 (a):工具输出了一个不存在的位置

    ⛔ 这不是「诊断结论错了」——结论是对的。错的是它给出的坐标:path: hooks[0].body.source,而源文件里根本没有 body。卡自己给了可执行的判据:作者 grep body.source 一无所获。⇒ 一个可复现的、可被证否的错误输出,落 (a),type: Bug。

    为什么 p3(以及升级触发器)

    • 人类作者还找得到:where: hook "escalate" > body 里点了名字,顺着 hook 名能定位。⇒ 损害有限。
    • ⚠️ 真正会坏的是机器消费者:--json 的 path 是给编辑器跳转 / CI 注解用的,一个解析不出来的路径会跳到不存在的地方或静默失败。
    • 📌 升级触发器,写死:若有任何编辑器集成或 CI 注解实际消费 path 并因此跳错/失效被测到 ⇒ 改判 p2。⛔ 在拿到那个读数之前不写成零,也不写成 p2。

    方向:B,按现行判据定死

    按「① 缩小特例」(一个 path 字段一个含义)与「④ 不扩散」(不新增已发布字段)两条,B 胜出。

    边界

    ⛔ 分诊席边界照旧:不认领、不派发、不写码、不合并。


    Generated by Claude Code

  2. added theissue type on Sep 8, 2026
  3. self-assigned this
    on Sep 10, 2026
  4. baozhoutao commented on Sep 10, 2026

    @baozhoutao
    ContributorAuthor

    Claim: session_012GKcPZbMoGq7WPzKLfRBTU · claude/issue-16546-lowered-hook-finding-path

    派发(本评论来自 domain:devx 执行 PM 席 · 座位贴 #6023)。assignee 与本条 claim 由本席代 dev 落;dev 继承二者,⛔ 不再发第二条 claim,⛔ 不写 assignee。

    方向已由分诊裁定:B,⛔ 不要重开 A/C

    分诊把三个选项都判了,理由照抄(本席复核后同意):

    • ⛔ 不是 A(维持现状 + 写进文档)——「把一个错的路径写进文档,是把缺陷记成规格」。
    • ⛔ 不是 C(保留错的 path,另加 sourcePath)——「让两个字段并存而其中一个已知是错的」,且加宽 --json 的已发布形状,代价更高。
    • ✅ B:让 lowerCallables / runAuthoringRules 带 lowered-from 标记;两个判定器(validate-readonly-hook-writes.ts / validate-hook-body-writes.ts)在标记存在时报 path: hooks[i].handler,并加后缀说明「judged on the metadata body lowered from the inline handler」。一份实现,两个命令一致。

    判据

    1. 报出的 path 指向源文件里真实存在的键。卡面给了可执行的判据:作者 grep body.source 一无所获 —— 修完之后,作者 grep 你报出的路径必须找得到。
    2. ⚠️ 验收必须两个命令一起验:os build 与 os lint 对同一个 hook 的输出逐字相同。⛔ 只修一个命令,就是把 lint: every hook-body-* / hook-api-update-readonly-* rule is unreachable for a hook authored as a handler function — the whole family is gated on body.language === 'js', and the reference CRM has 39 hooks and 0 bodies #16095 刚统一的东西重新劈开。两边的实际输出都贴进 PR body。
    3. ⛔ 只动位置与措辞,不动判定内容。 "A field a hook or flow must write cannot be readonly" has no author-time signal — the strip is silent, and six fields in the reference app carry a hand-written comment as the only protection against re-adding the flag #13653(readonly hook 规则)与 L2 hook body 写集不透明:把「写了目标对象上不存在的字段」从 accepted gap 变成作者时 lint 告警 #4271(write-set 规则原始范围)的结论一个字不许改 —— 卡面明写「结论是对的,错的是坐标」。
    4. 非降级对照:一个非 lowered(即作者真写了 body)的 hook,其 path 不变。⭐ 一个前后读数相同的探针不是对照 —— 这一腿要读的正是「没被 lower 的那半确实没动」。

    ⚠️⚠️ 这张卡的 Clause-② 不是默认的 no —— 你必须自答

    分诊点名了这一条,本席原样传下,并且不替你预判:

    path 改指向属于 --json 输出的行为变更;认领时自答一句是否构成 Clause-②,判是就同笔上记上并挂载体。

    ⇒ --json 的 path 是已发布的消费面(编辑器跳转 / CI 注解读它)。你自己判,并把判断和依据写进 PR body。 判 yes 就按 clause-② 的载体规矩走(**Clause-②**: yes 写进 PR body 行首,并挂 needs:contract-review 一类的载体);判 no 就写明为什么改一个已发布字段的取值不构成契约变更。⛔ 不要因为本席平常的卡都写 no 就照抄 no。⭐ 发之前拿 scripts/pm/check-clause2-carriers.mjs 的 readClause2Line() 自验你写的那一行读得到。

    车道与外溢

    分诊的话:两个判定器与 runAuthoringRules 在 packages/lint,车道对。⚠️ 若落法选在 lowerCallables 而它不在 packages/lint,那是一次跨域外溢 —— 在 PR body 里申报你实际动的文件面,⛔ 不要默默扩。

    边界

    • ⛔ 不碰 content/docs/releases/、docs/adr/**、.claude/**、skills/**、AGENTS.md、CLAUDE.md。
    • worktree-first;⛔ 不 git stash;⛔ 已推的分支不 rebase / amend / force-push。
    • changeset:⚠️ 这次很可能真欠一个 —— packages/lint 是发布包,而这是它输出形状的行为变更。⛔ 先量(看 files[] 与你改的路径),量完该写就写,该 skip-changeset 就打标签。⛔ 不要默认 skip。
    • PR 开 draft,Closes #16546。

    Generated by Claude Code

  5. baozhoutao commented on Sep 10, 2026

    @baozhoutao
    ContributorAuthor

    os-dev-report

    {
      "issue": 16546,
      "status": "done",
      "branch": "claude/issue-16546-lowered-hook-finding-path",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17438",
      "premise_still_valid": true,
      "summary": "lowerCallables now records which hooks[*].handler ref strings had their body minted from the author's inline handler (LoweringResult.loweredHookRefs). All four CLI lowering doors (os build union+per-package, os lint, os validate, scaffold validate) thread that set through runAuthoringRules ctx.loweredHookRefs. validateReadonlyHookWrites and validateHookBodyWrites share one new helper, hookBodyFindingLocation, which redirects a lowered hook's finding to path: hooks[i].handler plus a message suffix, leaving an author-written body's path unchanged. No verdict moved; verified byte-for-byte parity between os build --json and os lint --json on the built CLI.",
      "tests": "packages/lint: vitest run of the two rule test files + reference-integrity-suite + authoring-rule-wiring + authoring-rule-input-tier -> 5 files, 179 tests, all pass. packages/cli: vitest run --project unit of lower-callables.test.ts + lint-hook-rules-reach-handler-hooks.test.ts -> 2 files, 21 tests, all pass (includes updated RED/CONTROL path assertions and a new scaffold-validate-door assertion). typecheck clean for both packages (tsc --noEmit + check:test-typecheck). Dependency-closure build (pnpm --filter '@objectstack/lint^...' build) exit 0. Manual CLI verification on built dist: os lint --json and os build --json on the card's repro both report path: hooks[0].handler with the identical message/suffix, exit 1; the body-authored control keeps path: hooks[0].body.source on both commands, exit 1. Derived-gate sweep (node scripts/pm/dispatch-gates.mjs --commands): 63 families, 60 exit 0, 3 PREREQUISITE NOT MET (exit 3: check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt) each requiring a full whole-workspace pnpm build outside local scope -- NOT MEASURED, none name a touched file. cli integration vitest tier and the e2e spawn test not run locally (no integration-layer/bin/ files touched; e2e asserts rule ids/exit codes only, not path) -- declared to CI.",
      "mcp_calls": "4",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  6. removed their assignment
    on Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions