Skip to content

Commit d470b3a

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4721-sortnode-strict
2 parents ca67776 + 3edff5c commit d470b3a

19 files changed

Lines changed: 1661 additions & 239 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
---
3+
4+
chore(scripts): `check:doc-authoring` 的扫描范围加入 `.claude`,并把范围接线折成常驻 `--self-test`
5+
6+
这条检查只做一件很窄的事:在 ` ```ts ` / ` ```tsx ` 围栏代码块里匹配
7+
`export const X: <16 个 factory 域之一>[Input] = {` 这种绕开 `defineX()` 工厂的裸
8+
metadata 字面量(#2035 / ADR-0059)。它管的是代码样例的正确性,不是文风 —— 所以
9+
「已发布文档的写作规范是否适用于内部 agent 文件」这个顾虑对它并不成立。
10+
11+
`ROOTS` 此前是 `['skills', 'content']`,即**顶层** `skills/`。而 `.claude/`
12+
(skills、agent 定义、workflows)是 agent 每个会话都会加载并照抄的语料 —— 脚本自己
13+
的文件头写着 *Skills are the corpus AI authors from, so a bad sample there is worse
14+
than one in app code*,这句话对 `.claude/` 只会更成立。范围写 `.claude` 而非
15+
`.claude/skills`,下一个子目录加进来时自动被覆盖。
16+
17+
两处配套:
18+
19+
- `.claude/worktrees/`(并行 agent 的 per-task worktree 落点,`.gitignore` 已声明)
20+
进新的 `SKIP_PATHS`。walker 是 `readdirSync` 不是 `git ls-files`,`.gitignore`
21+
拦不住它,不排除就会走进整个仓库的副本,报出与本分支无关的违规。
22+
- 新增 `--self-test`:在临时目录里用真实 walker 从真实 `ROOTS` 走一遍,断言
23+
`.claude/**` 进得去、`.claude/worktrees/**` 进不去。`.claude` 下当前含 ts 围栏
24+
代码块的文件为 0,加进 ROOTS 后门禁照样是绿的 —— 而「加对了」和「加了仍然扫不到」
25+
从外部看一模一样(#4690 / #4804 / #4835 / #4868 / #4890 同族)。自检把这条反向
26+
证明变成常驻断言,而不是一次性验证。
27+
28+
纯 tooling,不发版。扫描文件数 215 → 219(新增的 4 个 `.claude` markdown),现存
29+
文件零新增违规。
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
---
3+
4+
chore(devx): `check:init-service-contract` now sees every service accessor, not just `getService` (#4835)
5+
6+
Releases nothing — the change is confined to `scripts/check-init-service-contract.mjs`.
7+
8+
The #4471 / ADR-0116 guard asks one question: does a plugin resolve, during
9+
`init()`, a service another workspace plugin provides, without declaring the
10+
ordering? It asked that question of exactly one accessor:
11+
12+
```js
13+
if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'getService') {
14+
```
15+
16+
The kernel has three. `getServiceAsync` (`ObjectKernel`, `packages/core/src/kernel.ts`)
17+
and `getServiceScoped` (`PluginContext`, `packages/core/src/types.ts`) both resolve
18+
a named service out of the same registry — `getServiceScoped`'s kernel body is the
19+
same `pluginLoader.getService(name, scopeId)` call `getServiceAsync` makes. The
20+
ordering hazard is a property of the registry, not of a method name, and ADR-0116's
21+
`dependencies` / `optionalDependencies` / `requiresServices` apply to all three
22+
identically. The guard saw one.
23+
24+
**#4772 is what went through the gap.** Pre-fix `AuthPlugin.init()`
25+
(`f2eb85007^`) resolved the workspace-provided `cache` service with
26+
`await (ctx as { getServiceAsync?: … }).getServiceAsync?.('cache')` while
27+
declaring `requiresServices = ['data', 'manifest']` and depending only on
28+
objectql — textbook undeclared init-time consumption, and precisely the verdict
29+
this guard exists to print. It never constructed the edge. The cost: `undefined`
30+
frozen into the better-auth config on a 21ms ordering margin, rate-limit counters
31+
that never reached the shared store, and ADR-0069 D2 advertising a capability the
32+
runtime did not deliver.
33+
34+
The vocabulary is now a named set (`SERVICE_LOOKUP_CALLEES`) with membership
35+
argued per accessor, and the file pre-filter derives from it rather than hardcoding
36+
a substring that only happens to cover today's three names. `hasService` is
37+
deliberately **out**: `ObjectKernel.hasAnyService` is private and
38+
`PluginLoader.hasService` is only reachable from a loader instance the kernel never
39+
hands a plugin, so adding it would flag unrelated objects while covering no real
40+
edge. `getServices()` (no service-name argument) and `replaceService` (a mutation,
41+
different remedy) are out for their own stated reasons.
42+
43+
Two things follow from a widened vocabulary:
44+
45+
- **`--list` stopped lying.** Every edge printed its call site as `getService('X')`
46+
regardless of which accessor made it. Each edge now records its accessor and both
47+
`--list` and the failure message quote it as written.
48+
- **The self-test proves both directions.** A guard only ever observed green is
49+
indistinguishable from a guard that matches nothing (#4690, #4804). Cases 13-19
50+
include the #4772 pre-fix shape verbatim — optional call, cast `ctx`, best-effort
51+
`try/catch` — and assert it is caught, that the message names the plugin, the
52+
provider and the call's line, and that `start()` and declared coverage still pass.
53+
Narrowing the set back to `['getService']` turns case 13 red.
54+
55+
The repo audit stays green: today's `getServiceAsync` call sites
56+
(`rest/src/rest-server.ts`, `runtime/src/http-dispatcher.ts`,
57+
`runtime/src/dispatcher-plugin.ts`) are all on request-time paths, in no plugin's
58+
`init()`. This closes a latent hole, it does not report an existing one.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
---
3+
4+
Tooling-only: `merge.os-regen.driver` is registered as a worktree-independent
5+
command, and `check:merge-driver` now asserts the registered driver actually
6+
resolves (#4868). Releases nothing.
7+
8+
`setup-git-hooks.mjs` baked an absolute `${REPO_ROOT}/scripts/git-merge-regen.mjs`
9+
into `.git/config`. Linked worktrees SHARE one `.git/config`, so every
10+
`pnpm install` re-pointed the container-wide driver at whichever worktree had just
11+
installed — and the moment that worktree was removed, which AGENTS.md *requires*
12+
on task cleanup, every merge touching a `merge=os-regen` path in every other
13+
worktree died with `MODULE_NOT_FOUND`. Following the cleanup rule is what
14+
triggered the breakage, which is why it recurred across four worktrees.
15+
16+
The value is now `node "$(git rev-parse --show-toplevel)/scripts/git-merge-regen.mjs" %O %A %B %P`.
17+
Git hands a merge driver to a shell, so the substitution runs per invocation
18+
inside the worktree being merged: it binds to no worktree yet still resolves to
19+
the right root — the property the absolute path was there to guarantee. Existing
20+
clones self-heal on the next `pnpm install`.
21+
22+
The gate could not see any of this, because it never looked: every existing
23+
`--self-test` check builds its own temp repo and registers its own driver, so all
24+
of them stayed green while the live config dangled. A new `registeredDriverResolves()`
25+
check reads the *live* config and fails when the script does not exist, when it
26+
points outside the current worktree (the same bug one step before it bites), or
27+
when the value has drifted from what the registrar writes. The registrar and the
28+
gate now read one declaration, `GIT_SETTINGS` in `regen-artifacts.mjs`.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
---
3+
4+
chore(scripts): `check:nul-bytes` 按载体划范围 —— 扫描所有被 git 跟踪的文本文件,而非只扫 JS/TS (#4890)
5+
6+
这条门禁自己的报错文案就写明了它拦的是什么:一个裸 NUL 会让 grep/ripgrep 把**整个文件**当成二进制、**静默返回零匹配**,文件因此从代码检索和每一条基于 grep 的 lint 里消失。那是 **grep 的行为**,与文件是什么语言无关 —— 但它的扫描面此前是一份 JS/TS 扩展名清单,于是范围(用途:源代码)和理由(载体:文本)对不上。
7+
8+
对不上的代价是 `.claude/` 下的全部 markdown 同时落在三道门禁之外:`check:nul-bytes` 只看 JS/TS,`check:doc-authoring``ROOTS` 是顶层 `skills/``content/`,eslint 的 `files` glob 只有 JS/TS 扩展名。#4890 就是这么暴露的:PR #4885 **要写的规则正是「不要写裸 NUL」**,而写的过程中一个真的裸 NUL 落进了 `.claude/skills/pm-dispatch/SKILL.md`,这道门禁报 OK —— 靠一次额外的、非常规的控制字符扫描才发现。一份带裸 NUL 的 SKILL.md 对 `grep -r` 隐形,agent 拿不到它本该遵守的规则,且没有任何信号。
9+
10+
现在扫描面是**所有被 git 跟踪的文本文件**。二进制判据是内容判断,不是扩展名清单(那只是把「为什么是这些文件」挪到下一个新目录去重演):非常规文件(symlink / gitlink)跳过;UTF-16/32 BOM 开头的跳过(那种编码里 NUL 是结构性的);其余**先剔除 NUL 字节、再整文件按 UTF-8 严格解码**,解不通才算二进制。先剔除 NUL 是关键 —— 否则「文件里有 NUL,所以是二进制,所以不检查它有没有 NUL」正是 git 掉进去的那个循环;整文件解码而非只看前缀,也正是因为 git 只嗅前 8000 字节是本检查文案里记着的那个盲区(protocol.ts 的 NUL 在第 147230 字节)。因此一个扩展名从没见过的新文本文件默认被扫到。
11+
12+
实测:2953 个文件 / ~150ms → 4974 个文本文件 / ~505ms(跳过 5 个真二进制资产与 1 个 symlink),main 上无既存文件判红。同时按仓内既有惯例把 `--self-test` 接进 `check:nul-bytes` 脚本(临时 git 仓库里跑**真实的 `scan()` 路径**),否则一条只观察到绿的门禁与一条什么都匹配不上的门禁从外部无法区分。工具链改动,不发版。
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
---
3+
4+
Tooling-only (#4843): `scripts/objectui-range.mjs` — the aggregation layer whose output is
5+
pasted into a release page's **Console** section — now reports the frontend changes objectui
6+
**declared** over the pinned range instead of guessing them from conventional-commit types.
7+
Releases nothing; no package changes.
8+
9+
#4731 fixed this failure mode on the `bump-objectui.sh` side; the release page kept the old
10+
guess (`const KEEP = ALL_TYPES ? null : new Set(['feat', 'fix'])`, with `--all` as an
11+
explicit opt-in). Measured on the same real range (`7d9734d5e321..785b8a5d432c`, 53
12+
non-merge objectui commits), the default output **dropped 13 commits that actually
13+
released** — 6 of them breaking `refactor(...)!`, including `refactor(layout)!: delete
14+
PageNodeRenderer` and burn-ledger batches 2/4/5/6/7, plus `chore(deps): lockstep the
15+
@objectstack family onto 17.0.0-rc.1` — while **listing 5 commits that release nothing** in
16+
objectui (two `fix(ci)`: one with no changeset, one whose changeset has an empty
17+
frontmatter). Breaking changes were the single class structurally unable to appear, in the
18+
artifact that leads with breaking changes.
19+
20+
The two scripts now share **one** criterion rather than each carrying a copy: the
21+
classification moved into an exported `classifyRange()` in
22+
`scripts/objectui-changeset-digest.mjs`, and both `bump-objectui.sh` (the platform release
23+
record) and `objectui-range.mjs` (the release page) go through it. A second copy would
24+
drift, and the first thing it would drift on is the class that already went missing once.
25+
26+
Because the output *is* release-page body text — a reader cannot tell a filtered list from a
27+
complete one — the accounting is now **unconditional**: every run prints how many changesets
28+
released of how many were added across how many commits, plus the excluded counts
29+
(release-nothing changesets, commits carrying no changeset), including when those counts are
30+
zero. `--all` changes meaning from "include every commit type" (the filter is gone) to "also
31+
name the excluded entries, one per line". Headings group by the level objectui declared
32+
(breaking / features / fixes); grouping is presentation and never a filter. Guarded by
33+
`node scripts/objectui-range.mjs --self-test`, folded into the existing
34+
`pnpm check:objectui-changeset` gate.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
---
3+
4+
Docs-only: the `objectstack-formula` skill's `previous` binding-scope table
5+
never said what an unevaluable hook `condition` now costs (#4814).
6+
7+
§5 ("Update hook condition — `previous` vs `record`") is where an AI author is
8+
taught to write `previous.x != record.x`, and the mechanical translation table
9+
sends `OLD.x` / `ISCHANGED(x)` to the same place. The table listed exactly where
10+
`previous` is **unbound** — insert events, and `multi: true` predicate bulk
11+
updates — and then closed with "referencing `previous` where it is unbound makes
12+
the whole expression unevaluable", which was the pre-17 outcome: a `logger.warn`
13+
and a hook that did not fire.
14+
15+
#4775 changed that outcome: an unevaluable condition **aborts the operation**,
16+
`before*` and `after*` in the same direction, with an error naming the hook and
17+
the key. So the table's own rows changed meaning — "this quietly disables your
18+
hook" became "this fails your write" — without a word of the table changing.
19+
That is the drift this fixes: the surface teaching the idiom was the one surface
20+
still describing the old consequence.
21+
22+
Adds, in §5:
23+
24+
- the #4775 rule, with the reason the two outcomes had to split (a `before*`
25+
guard swallowed into `false` let writes through; an audit hook swallowed into
26+
`false` dropped records — opposite failures out of one collapsed result), and
27+
the note that `onError` is not an escape from it (it governs a handler that
28+
throws; the condition is evaluated before any handler runs);
29+
- the `multi: true` cell in full (#4800/B1): one hook condition reading
30+
`previous.*` fails *every* predicate bulk update of that object, fail-loud
31+
takes no exception, and the error is a diagnosis — it names the batch, says
32+
the N matched rows have no single prior record, and gives the two real ways
33+
out (drop `previous`, or write by id). A record-change flow trigger is
34+
explicitly **not** one of them: it binds the same lifecycle hook and gets the
35+
same unbound `previous`. `record` is the bare payload on that path too, so a
36+
declared field this write does not set is unevaluable as well.
37+
38+
Plus a pointer under the legacy → CEL table, since `OLD.x` / `ISCHANGED(x)` are
39+
how a migrating author arrives at `previous.x` in the first place.
40+
41+
Releases nothing.

.github/workflows/lint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,9 @@ jobs:
233233
# throwaway git repos carrying those exact shapes, runs the real scripts
234234
# over them, and asserts the emitted changeset — including that a cap which
235235
# fires announces itself and that an unwalkable range degrades loudly.
236+
# #4843 folded `scripts/objectui-range.mjs` — the release page's Console
237+
# section — onto that same `classifyRange()` criterion, so both self-tests
238+
# run here: one criterion, two consumers, no room to drift apart.
236239
- name: objectui pin-changeset digest guard
237240
run: pnpm check:objectui-changeset
238241

0 commit comments

Comments
 (0)