dsh-mini-utility-dock 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.en.md CHANGED
@@ -3,14 +3,32 @@
3
3
  Release notes are generated from the matching version section; newest first.
4
4
  For Chinese, see [CHANGELOG.md](CHANGELOG.md).
5
5
 
6
+ ## 0.6.0 - 2026-09-28
7
+
8
+ - New `dist/host-http.js` (marker `dsh-host-http`): the host half's reply glue becomes one block — `sendJson` (always `no-store`), the POST gate, the browser authorizer, `optionalSessionId` — so a reply policy has one definition. An already published vocabulary (`need_post`, Chinese wording, the echoed `action`) is a `policy` override, so no public identifier changes.
9
+ - `createBrowserAuthorizer` takes accessors, not values: a service reload reassigns a host half's Connection to `null` and later to a new instance, and the value-shaped version would keep handing requests to a destroyed Connection.
10
+ - The comments that ride verbatim into a consumer's repository with the launcher and host-http fragments now state the facts: no sentence misdescribes what the other copies do, and host-http's analogy to a guard-block name says where that name comes from.
11
+ - `dsh-mini-utility-dock`'s `sync`/`check` reject a file whose marker ranges overlap (`<A> <B> </A> </B>`) and leave it unwritten, closing off the corruption the bottom-up splice could previously produce.
12
+ - `dsh-plugin-parity` compares the `dsh-host-http` block byte for byte, and its block-position assertion now covers all three host blocks.
13
+ - The parity bin's "private implementation outside the blocks" scan matches code shapes only: both the bracketed and the destructured Fetch Metadata read count, `LOOPBACK_HOSTNAMES` is caught on assignment but not comparison, and a composite heuristic that could false-FAIL a reasonable repository is gone — so a comment that explains why a helper is not a second guard no longer reads as drift.
14
+ - The parity bin's block comparison strips each block's common indentation, so a consumer that indents its markers by 4 spaces or a tab is no longer reported as drift; `--fleet` naming a repository that is not a member now fails with a non-zero exit instead of the "skipped" line reading as a pass.
15
+ - `dsh-plugin-docs --base` no longer dies inside `git diff` on an all-zero SHA — which is what `github.event.before` carries for a new or force-pushed branch; it notes that there is no previous state and still completes the structural checks.
16
+ - `dsh-plugin-docs` now compares the `Unreleased` section for version, section and item structure — it is the most-edited section each round and was previously outside the check.
17
+ - Tests filled in: process-level behavior for the two diagnostic bins, decision-level behavior for `dist/loopback.js`, `dist/guard.js` and the new fragment, and one `FRAGMENTS` integrity assertion, with a paired case for each behavior change above.
18
+ - Maintenance: CI installs with `npm ci --ignore-scripts` and caches npm, both workflows run `docs:check`, syntax checks cover `bin` and `dist`, `publish.yml` gates on the tag being an ancestor of `main`, and `homepage`, `bugs` and `author` are filled in. Being dependency-free, this package now tracks `package-lock.json` so `npm ci` and the cache have one (reason in `.gitignore`).
19
+
20
+ ## 0.5.1 - 2026-09-25
21
+
22
+ - Fix the launcher disappearing after a hot reload: the claim is released with its owner, which wakes the other copies to register again instead of requiring a full page reload.
23
+
6
24
  ## 0.5.0 - 2026-09-24
7
25
 
8
- - New `dist/launcher.js` (marker `dsh-utility-launcher`): the family's shared entry point — one icon at the bottom-left that opens a menu of the family's panels. It goes through slots rather than a page-local protocol: one copy of the assembly runs per page (the first plugin to load wins the `window` mutex and declares the menu seat `createhelper.utility.item`), and every other plugin contributes one row to that seat. Consumers maintain it with `launcher:sync` / `launcher:check`, so the three client halves no longer each carry their own copy.
26
+ - New `dist/launcher.js` (marker `dsh-utility-launcher`): one icon at the bottom-left that opens a menu of utility panels, assembled through the host's slots rather than a page-local protocol. Exactly one copy runs per page (the first to load wins a `window` mutex and declares the menu seat `createhelper.utility.item`) and every other copy adds one row to it; consumers maintain it with `launcher:sync` / `launcher:check`.
9
27
 
10
28
  ## 0.4.0 - 2026-09-24
11
29
 
12
- - Remove `dist/bootstrap.js` (the dock bootstrap fragment) and its `FRAGMENTS` row. It made every consumer append a `position:fixed; z-index:9997` container to `document.body` and locate it by measuring the sidebar — so it necessarily floated above the page and covered the composer's own controls. The three consumers now register the host's `sidebar.footer.action` seat instead (an icon button directly above Settings), embed no fragment in `lib/client.js` at all, and drop their `dock:sync` / `dock:check` scripts.
13
- - Consumers stay pinned to `0.3.0`: the two remaining fragments (loopback predicates, host guard) are unchanged, so `loopback:check` / `guard:check` keep comparing against that version byte for byte.
30
+ - Remove `dist/bootstrap.js` (the page-local dock bootstrap fragment) and its `FRAGMENTS` row: it appended a self-measuring `position:fixed` container to every consumer's `document.body`, so it necessarily floated above the page and covered the composer's own controls. Consumers now register the host's `sidebar.footer.action` seat, embed no fragment in `lib/client.js`, and have dropped `dock:sync` / `dock:check`.
31
+ - The two host-side fragments (loopback predicates, host guard) are unchanged, so consumers stay pinned to `0.3.0` and `loopback:check` / `guard:check` keep comparing against that version.
14
32
 
15
33
  ## 0.3.0 - 2026-09-22
16
34
 
@@ -24,18 +42,15 @@ For Chinese, see [CHANGELOG.md](CHANGELOG.md).
24
42
 
25
43
  ## 0.1.8 - 2026-09-21
26
44
 
27
- - The two host-side fragments no longer say "sibling": the three phrasings now state the design goal itself — a plugin ships standalone, with nothing else required. A fragment is embedded verbatim into each consumer, where the word had no referent.
28
- - The fragments' bytes change, so consumers must re-run `loopback:sync` / `guard:sync` and raise their pin to 0.1.8.
45
+ - The two host-side fragments drop "sibling" and state the design goal directly: a plugin ships standalone, with nothing else required.
29
46
 
30
47
  ## 0.1.7 - 2026-09-20
31
48
 
32
- - The two host-side fragments no longer state how many consumers exist: "all three plugins", "the three disagree" and "the three plugins / diverged three times" give way to wording that names no count. A fragment is embedded verbatim into each consumer, where the reader cannot know how many other consumers there are — the count is workspace knowledge, not that repository's.
33
- - The fragments' bytes change, so consumers must re-run `loopback:sync` / `guard:sync` and raise their pin to 0.1.7.
49
+ - The two host-side fragments no longer state how many consumers exist ("all three plugins", "the three disagree", "diverged three times" give way to wording that names no count): a fragment is embedded verbatim into a consumer's repository, where no reader can check that count.
34
50
 
35
51
  ## 0.1.6 - 2026-09-20
36
52
 
37
- - Fix the `allowRemoteHost` note in `dist/guard.js`: it read "stop being admitted", the opposite of what the code does — that mode skips both checks, so it admits them. The JSDoc was already right; only this inline comment was inverted.
38
- - The fragment's bytes change, so consumers must re-run `guard:sync` and raise their pin to 0.1.6.
53
+ - Fix the `allowRemoteHost` note in `dist/guard.js`: it read "stop being admitted", the opposite of what the code does — that mode skips both checks, so it admits them.
39
54
 
40
55
  ## 0.1.5 - 2026-09-17
41
56
 
package/CHANGELOG.md CHANGED
@@ -3,14 +3,32 @@
3
3
  Release Notes 由对应版本段生成;最新版本在前。
4
4
  英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
5
5
 
6
+ ## 0.6.0 - 2026-09-28
7
+
8
+ - 新增 `dist/host-http.js`(标记 `dsh-host-http`):宿主半的响应胶水收成一块——`sendJson`(一律带 `no-store`)、POST 门、浏览器授权器、`optionalSessionId`,回复策略自此只有一份定义。已发布的词汇(`need_post`、中文文案、回带 `action`)由 `policy` 覆盖,公开标识符不变。
9
+ - `createBrowserAuthorizer` 收访问器而非值:宿主半的 Connection 会被服务重载置空再赋新值,捕获值的那一版会一直把请求交给一个已销毁的 Connection。
10
+ - launcher 与 host-http 片段随片段逐字进入消费者的注释改为直陈事实:不再有误述其余副本行为的句子,host-http 把引用守卫块名字的类比写明其来源。
11
+ - `dsh-mini-utility-dock` 的 `sync`/`check` 遇到标记区间交叠(`<A> <B> </A> </B>`)的文件直接拒绝且不写出,杜绝此前可能产生的损坏。
12
+ - `dsh-plugin-parity` 把 `dsh-host-http` 块纳入逐块比对,块落点顺序的断言同时覆盖三个 host 块。
13
+ - `dsh-plugin-parity` 的「块外私有实现」扫描只匹配代码形状:Fetch Metadata 的方括号取头与解构取头都算、`LOOPBACK_HOSTNAMES` 只认赋值不认比较,并删去会误伤合理仓库的复合启发式,讲清了理由的注释与散文不再被误判成漂移。
14
+ - `dsh-plugin-parity` 的逐块比对改为剥离块的公共缩进,按 4 空格或 Tab 缩进标记的消费者不再被误判为漂移;`--fleet` 点名不在成员表中的仓即判 FAIL 并非零退出,不再把「未测」报成「跳过」。
15
+ - `dsh-plugin-docs --base` 收到全 0 SHA(新建分支或强推时 `github.event.before` 的值)不再崩在 `git diff`,改为按「无可比前态」给提示并跑完结构校验。
16
+ - `dsh-plugin-docs` 把 `Unreleased` 段纳入版本段/分类段/条目数比较——它是每轮改动最多的段落,此前完全不在检查范围内。
17
+ - 补齐测试:两个诊断 bin 的进程级行为、`dist/loopback.js` / `dist/guard.js` 与新片段的判定行为、一条 `FRAGMENTS` 完整性断言,并为上述行为各补对照用例。
18
+ - 维护:CI 改 `npm ci --ignore-scripts` 并启用缓存、两处都跑 `docs:check`,语法检查覆盖 `bin` 与 `dist`,`publish.yml` 加「tag 必须是 `main` 的祖先」闸门,补齐 `homepage`、`bugs`、`author`;本包零依赖,故开始跟踪 `package-lock.json` 以满足 `npm ci` 与缓存(理由见 `.gitignore`)。
19
+
20
+ ## 0.5.1 - 2026-09-25
21
+
22
+ - 修复 launcher 热重载后消失:归属从一次性闩锁改为可释放的认领,owner 被销毁时唤醒其余副本重新注册,不再需要刷新整页。
23
+
6
24
  ## 0.5.0 - 2026-09-24
7
25
 
8
- - 新增 `dist/launcher.js`(片段标记 `dsh-utility-launcher`):族的共享入口——左下角一个图标,点开一个列出各插件面板的菜单。它走槽位而不是页面级协议:每页只有一份装配(先加载的插件赢得 `window` 互斥并声明菜单座位 `createhelper.utility.item`),其余插件各往那个座位投一行。消费插件用 `launcher:sync` / `launcher:check` 维护,三个 client 半从此不再各写一份。
26
+ - 新增 `dist/launcher.js`(标记 `dsh-utility-launcher`):左下角一个图标,点开一个列出各插件面板的菜单,走宿主的槽位而不是页面级协议。每页只有一份装配(先加载的一方赢得 `window` 互斥并声明菜单座位 `createhelper.utility.item`),其余副本各往那个座位投一行;消费方用 `launcher:sync` / `launcher:check` 维护。
9
27
 
10
28
  ## 0.4.0 - 2026-09-24
11
29
 
12
- - 移除 `dist/bootstrap.js`(dock 引导片段)与对应的 `FRAGMENTS` 条目。它让每个消费插件在 `document.body` 上挂一个 `position:fixed; z-index:9997` 的容器,位置靠自己量侧边栏算出来——于是它必然悬在页面上方,会压住 composer 自己的控件。三个消费插件已改为注册宿主槽位 `sidebar.footer.action`(图标按钮,就在设置上方),`lib/client.js` 不再嵌任何片段,各仓的 `dock:sync` / `dock:check` 随之删除。
13
- - 消费仓仍 pin `0.3.0`:本仓剩下两个片段(loopback 判定、host 守卫)未变,`loopback:check` / `guard:check` 继续按该版本逐字节比对。
30
+ - 移除 `dist/bootstrap.js`(页面级 dock 引导片段)与其 `FRAGMENTS` 条目:它给每个消费插件在 `document.body` 上挂一个自量位置的 `position:fixed` 容器,必然浮在页面之上、压住 composer 自己的控件。消费方改为注册宿主的 `sidebar.footer.action` 座位,`lib/client.js` 不再嵌片段,各仓的 `dock:sync` / `dock:check` 随之删除。
31
+ - 两个 host 侧片段(loopback 判定、host 守卫)字节未变,消费方仍可 pin `0.3.0`,`loopback:check` / `guard:check` 继续按该版本比对。
14
32
 
15
33
  ## 0.3.0 - 2026-09-22
16
34
 
@@ -24,18 +42,15 @@ Release Notes 由对应版本段生成;最新版本在前。
24
42
 
25
43
  ## 0.1.8 - 2026-09-21
26
44
 
27
- - 两个 host 侧片段的注释不再出现 `sibling`:三处改为陈述设计目标本身——插件独立运行,不依赖同级安装。片段会逐字嵌入消费仓,那个词在那里没有可指对象。
28
- - 片段字节因此变化,消费仓需重新运行 `loopback:sync` / `guard:sync` 并把 pin 升到 0.1.8。
45
+ - 两个 host 侧片段的注释去掉 `sibling`,改为直接陈述设计目标:插件独立发布,不依赖同级安装。
29
46
 
30
47
  ## 0.1.7 - 2026-09-20
31
48
 
32
- - 两个 host 侧片段的注释不再带消费仓数量:「all three plugins」「the three disagree」「the three plugins / diverged three times」改为不指名、不计数的说法。片段会逐字嵌入消费仓,那里的读者无从得知存在几个消费方,计数是工作区知识而非该仓知识。
33
- - 片段字节因此变化,消费仓需重新运行 `loopback:sync` / `guard:sync` 并把 pin 升到 0.1.7。
49
+ - 两个 host 侧片段的注释不再声明消费方数量(「all three plugins」「diverged three times」等改为不指名、不计数的说法):片段逐字嵌入消费者的仓库,那里的读者无从得知有几个消费方。
34
50
 
35
51
  ## 0.1.6 - 2026-09-20
36
52
 
37
- - 修正 `dist/guard.js` 里 `allowRemoteHost` 的说明文字:原文写作「不再被放行」,与代码相反——开启该模式时这两项检查被跳过,即被放行。JSDoc 一直是对的,只有这处行内注释写反。
38
- - 该片段的字节因此变化,消费仓需重新运行 `guard:sync` 并把 pin 升到 0.1.6。
53
+ - 修正 `dist/guard.js` 里 `allowRemoteHost` 的行内注释:原文写作「不再被放行」,与代码相反——该模式是跳过这两项检查,即放行。
39
54
 
40
55
  ## 0.1.5 - 2026-09-17
41
56
 
package/README.en.md CHANGED
@@ -17,6 +17,7 @@ The DSH plugin family's shared assets: source fragments, an embedding CLI, and t
17
17
  | --- | --- | --- | --- |
18
18
  | loopback predicates | `dsh-loopback-helpers` | `lib/shared.js` | `LOOPBACK_HOSTNAMES`, `normalizeHostValue`, `hostHostname`, `isLoopbackName`, `isLoopbackAddress` |
19
19
  | host request guard | `dsh-host-guard` | `lib/shared.js` | `portOf`, `GUARD_REASONS`, `DEFAULT_GUARD_POLICY` |
20
+ | host HTTP glue | `dsh-host-http` | `lib/shared.js` | `sendJson`, `CONNECTION_UNAVAILABLE`, `connectionUnavailable`, `REQUIRE_POST_REASONS`, `DEFAULT_REQUIRE_POST_POLICY`, `createRequirePost`, `createBrowserAuthorizer`, `optionalSessionId` |
20
21
  | utility launcher | `dsh-utility-launcher` | `lib/client.js` | `registerUtilityLauncher`, `UTILITY_ITEM_SLOT` |
21
22
 
22
23
  ## Usage
@@ -37,19 +38,34 @@ npm run dock:embed -- check path/to/shared.js
37
38
  npm run dock:embed -- sync path/to/shared.js
38
39
  ```
39
40
 
40
- Consumers invoke the same CLI as `loopback:sync` / `guard:sync` with the target fixed to their own `lib/shared.js`, and as `launcher:sync` for the `dsh-utility-launcher` block in their own `lib/client.js`.
41
+ Consumers invoke the same CLI as `loopback:sync` / `guard:sync` / `http:sync` with the target fixed to their own `lib/shared.js`, and as `launcher:sync` for the `dsh-utility-launcher` block in their own `lib/client.js`. One command handles the three host-half blocks together: a block is selected by the marker that exists in the file, so `http:sync` and `loopback:sync` write different blocks of the same file.
41
42
 
42
43
  ## Constraints
43
44
 
44
- - `dist/` is the single source. Run `npm test` after a change, and re-`sync` consumers.
45
- - The two fragments in `lib/shared.js` have a fixed order, `dsh-loopback-helpers` first: `dsh-host-guard` uses the module-scope names the preceding fragment exports, and neither redeclares nor imports them.
45
+ - `dist/` is the single source. After changing a fragment, run the whole set below under "Development and verification", and have consumers re-`sync`.
46
+ - The three fragments in `lib/shared.js` have fixed positions: `dsh-loopback-helpers` → `dsh-host-guard` → `dsh-host-http`. The guard uses the module-scope names the preceding fragment exports, and neither redeclares nor imports them; `dsh-host-http` reads nothing from the two blocks above it and declares none of their names, so it can be embedded alone. Those positions are the `FRAGMENTS` order, and `sync` replaces blocks from the bottom of the file up; a file whose marker ranges overlap is rejected outright and left unwritten.
47
+ - `dsh-utility-launcher` lands in the client half: exactly one copy of the assembly runs per page (the first to load wins the `window` mutex and declares the menu seat), and every other copy contributes one row to that seat. That is a runtime fact, not a styling choice.
46
48
  - A fragment must not contain `import` or `require`; a consumer must publish standalone.
47
49
  - `bindGuard` is not exported — a consumer declares its own guard export in the same file. Each plugin passes its own error codes and wording through `policy`; the enforcement is shared.
50
+ - `createRequirePost({ respond, policy })` works the same way: the default code is `method`, and a repository's published vocabulary — `need_post`, Chinese wording, the `action` echoed back — is an override of `policy.method_not_allowed`, not a behavior difference. An unknown key throws.
51
+ - `createBrowserAuthorizer({ getConnection, getConnectionSeen, guard, respond })` takes accessors, not values: a host half's Connection is reassigned to `null` by a service reload, and the value-shaped version would keep admitting requests through a destroyed Connection. The semantics are fixed: a Connection that throws answers 503 `connection_unavailable`; a rejection is answered with its own code (401 → `unauthorized`, anything else → `forbidden`); a Connection that was seen but is absent now answers 503 and never falls back to the weaker guard; a host that never had one is decided by `guard`.
48
52
 
49
53
  ## Cross-repo consistency
50
54
 
51
- A consumer's `npm test` runs `loopback:check` / `guard:check`, comparing its two fragments byte for byte against the dock version it pins. A published version is immutable and consumers pin an exact version, so pin agreement implies fragment agreement.
55
+ A consumer's `npm test` runs `loopback:check` / `guard:check` / `http:check`, comparing the three blocks in its own `lib/shared.js` byte for byte against the dock version it pins. A published version is immutable and consumers pin an exact version, so pin agreement implies fragment agreement. The converse does not hold: `check` selects blocks by the markers it recognizes, so a consumer that has not raised its pin never looks at a block the older CLI does not know and that one check passes vacuously. After a fragment is added, a consumer re-runs all four `sync` scripts and raises its pin to the released version in the same commit.
52
56
 
53
- `dsh-plugin-parity` (a bin provided by this package) checks that cross-repo property directly, including pin agreement and a behavioral comparison. It is a manual diagnostic and **does not run in CI**: the property cannot hold while peers sit on a different branch. The member list is caller-supplied via `--member <repo>:<export>` — the guard factory's export name is per-repository by fragment design, so the tool presupposes no members; without `--member` it runs the static checks only and discovers every checkout that embeds the guard block.
57
+ `dsh-plugin-parity` (a bin provided by this package) checks that cross-repo property directly: every host block compared in isolation after stripping the block's common indentation (the three, `dsh-host-http` included), pin agreement, block position, and a behavioral comparison. It is a manual diagnostic and **does not run in CI**: the property cannot hold while peers sit on a different branch. The member list is caller-supplied via `--member <repo>:<export>` — the guard factory's export name is per-repository by fragment design, so the tool presupposes no members; `--fleet <repo>` names the single member that opts into fleet mode, and only its guard is checked for "the relaxation stays bounded", while naming a repository that is not a member fails with a non-zero exit; without `--member` it runs the static checks only and discovers every checkout that embeds the guard block. `--self-test` needs no checkout at all: it exercises the block extractor and the block-dropping step against inline samples, so run it first whenever either changes.
54
58
 
55
- `dsh-plugin-docs` (another bin of this package) checks the documentation pairs for structural alignment: the two READMEs share heading levels and code-fence languages, the two CHANGELOGs expose the same releases, sections and item counts (section titles normalized through a bilingual category map); `--base <revision>` additionally requires both files of a pair to change together — a one-sided edit is a missing translation. Repositories opt in by pointing their own `docs:check` script at it; this package runs the same check on its own bilingual docs.
59
+ `dsh-plugin-docs` (another bin of this package) checks the documentation pairs for structural alignment: the two READMEs share heading levels and code-fence languages, the two CHANGELOGs expose the same releases — the `Unreleased` section included — with sections and item counts (section titles normalized through a bilingual category map); `--base <revision>` additionally requires both files of a pair to change together — a one-sided edit is a missing translation. When that revision is the all-zero null OID — which is what `github.event.before` carries for a newly created or force-pushed branch — there is no previous state to compare, so that one requirement is skipped with a note while the structural checks still run. Repositories opt in by pointing their own `docs:check` script at it; this package runs the same check on its own bilingual docs.
60
+
61
+ ## Development and verification
62
+
63
+ `dist/` is the source, so one change has to pass three layers: fragment behavior, the CLI, and the documentation pair. This is the full check of this repository (identical to the Verify block in `AGENTS.md`; the release procedure is in `RELEASING.md`):
64
+
65
+ ```sh
66
+ npm test
67
+ node bin/dsh-plugin-docs.js
68
+ for f in bin/*.js dist/*.js; do node --check "$f"; done
69
+ node bin/dsh-plugin-parity.js --self-test
70
+ npm pack --dry-run
71
+ ```
package/README.md CHANGED
@@ -17,6 +17,7 @@ DSH 插件族的共享资产:源码片段、嵌入 CLI,以及围绕它们的
17
17
  | --- | --- | --- | --- |
18
18
  | loopback 判定 | `dsh-loopback-helpers` | `lib/shared.js` | `LOOPBACK_HOSTNAMES`、`normalizeHostValue`、`hostHostname`、`isLoopbackName`、`isLoopbackAddress` |
19
19
  | host 请求守卫 | `dsh-host-guard` | `lib/shared.js` | `portOf`、`GUARD_REASONS`、`DEFAULT_GUARD_POLICY` |
20
+ | host HTTP 胶水 | `dsh-host-http` | `lib/shared.js` | `sendJson`、`CONNECTION_UNAVAILABLE`、`connectionUnavailable`、`REQUIRE_POST_REASONS`、`DEFAULT_REQUIRE_POST_POLICY`、`createRequirePost`、`createBrowserAuthorizer`、`optionalSessionId` |
20
21
  | utility 入口 | `dsh-utility-launcher` | `lib/client.js` | `registerUtilityLauncher`、`UTILITY_ITEM_SLOT` |
21
22
 
22
23
  ## 使用
@@ -37,19 +38,34 @@ npm run dock:embed -- check path/to/shared.js
37
38
  npm run dock:embed -- sync path/to/shared.js
38
39
  ```
39
40
 
40
- 消费插件以 `loopback:sync` / `guard:sync` 调用同一 CLI,目标固定为自身 `lib/shared.js`;client 半以 `launcher:sync` 维护 `dsh-utility-launcher` 块,目标固定为自身 `lib/client.js`。
41
+ 消费插件以 `loopback:sync` / `guard:sync` / `http:sync` 调用同一 CLI,目标固定为自身 `lib/shared.js`;client 半以 `launcher:sync` 维护 `dsh-utility-launcher` 块,目标固定为自身 `lib/client.js`。宿主半的三个块由一条命令一并处理:标记存在即被选中,因此 `http:sync` 与 `loopback:sync` 写的是同一个文件里的不同块。
41
42
 
42
43
  ## 约束
43
44
 
44
- - `dist/` 是唯一来源。改片段后运行 `npm test`,并让消费插件重新 `sync`。
45
- - `lib/shared.js` 中两个片段顺序固定,`dsh-loopback-helpers` 在前:`dsh-host-guard` 直接使用前一片段导出的模块级名字,既不重新声明也不 import。
45
+ - `dist/` 是唯一来源。改片段后跑一遍下面「开发与验证」的命令,并让消费插件重新 `sync`。
46
+ - `lib/shared.js` 中三个片段的落点固定:`dsh-loopback-helpers` → `dsh-host-guard` → `dsh-host-http`。守卫直接使用前一块声明的模块级名字,既不重新声明也不 import;`dsh-host-http` 不读上面两块、也不声明它们的名字,故可单独嵌入。落点顺序即 `FRAGMENTS` 顺序;`sync` 自下而上替换块,标记区间彼此交叠的文件会被直接拒绝、不写出任何内容。
47
+ - `dsh-utility-launcher` 落在 client 半:每页只运行一份该装配(先加载的一方赢得 `window` 互斥并声明菜单座位),其余副本只往座位里投一行。这是运行时事实,不是样式选择。
46
48
  - 片段不得包含 `import` 或 `require`;消费插件必须能独立发布。
47
49
  - `bindGuard` 不从片段导出——消费插件在同一文件中声明自己的 guard 导出。各插件通过 `policy` 传入自身错误码与文案,判定逻辑共用。
50
+ - `createRequirePost({ respond, policy })` 同上:默认 code 是 `method`,一个仓已发布的词汇(例如 `need_post`、中文文案、回带 `action`)由 `policy.method_not_allowed` 覆盖,行为不变;未知 key 抛错。
51
+ - `createBrowserAuthorizer({ getConnection, getConnectionSeen, guard, respond })` 收的是访问器而非值:宿主半的 Connection 会被服务重载置空,捕获值的那一版会一直放行给一个已销毁的 Connection。语义固定:Connection 抛异常 → 503 `connection_unavailable`;给出 rejection → 以该码回绝(401 → `unauthorized`,其余 → `forbidden`);见过 Connection 而当下没有 → 503,且绝不退回更弱的 guard;从未见过 → 交给 `guard`。
48
52
 
49
53
  ## 跨仓一致性
50
54
 
51
- 消费仓的 `npm test` 用 `loopback:check` / `guard:check` 把本仓两个片段与所 pin 的 dock 版本逐字节比对。dock 版本不可变且消费仓 pin 精确版本,故「pin 一致」即「片段一致」。
55
+ 消费插件的 `npm test` 用 `loopback:check` / `guard:check` / `http:check` 把自己 `lib/shared.js` 里的三个块与所 pin 的 dock 版本逐字节比对。dock 版本不可变且消费方 pin 精确版本,故「pin 一致」即「片段一致」。反向不成立:`check` 只按它能识别的标记选块,一个还没抬 pin 的消费方根本不比对新增的那个块,对它这一项是空过的。所以新增片段后,消费方要重跑四个 `sync`,并把 pin 一起抬到发布版本。
52
56
 
53
- `dsh-plugin-parity`(本包提供的 bin)直接检这条跨仓性质,含 pin 一致性断言与行为级对比。它是人工诊断工具,**不在 CI 中运行**:peer 处于不同分支时该性质本就不成立。成员表由调用方经 `--member <repo>:<export>` 提供——守卫工厂的导出名按片段设计是各仓自定的,故本工具不预设任何成员;不带 `--member` 时只跑静态检查,自动发现所有嵌入了宿主守卫块的仓。
57
+ `dsh-plugin-parity`(本包提供的 bin)直接检这条跨仓性质:逐块(剥离块的公共缩进后)比对三个 host 块(含 `dsh-host-http`)、pin 一致性断言、块落点顺序断言与行为级对比。它是人工诊断工具,**不在 CI 中运行**:peer 处于不同分支时该性质本就不成立。成员表由调用方经 `--member <repo>:<export>` 提供——守卫工厂的导出名按片段设计是各仓自定的,故本工具不预设任何成员;`--fleet <repo>` 点名唯一启用 fleet 模式的成员,只对它检查「放宽有边界」,点名不在成员表中的仓即判 FAIL 并非零退出;不带 `--member` 时只跑静态检查,自动发现所有嵌入了宿主守卫块的仓。`--self-test` 不需要任何 checkout:它用内联样例检验块提取与「丢块」两步,改动这两个函数后先跑它。
54
58
 
55
- `dsh-plugin-docs`(同样是本包的 bin)校验双语文档的结构对齐:README 中英标题层级与代码围栏一致,CHANGELOG 中英版本段、分类段与条目数一致(分类标题经双语映射归一);`--base <revision>` 额外要求双语对同改,单边改动即缺翻译。接入方式是各仓自己的 `docs:check` script 指向它——本包自身的双语文档也由此校验。
59
+ `dsh-plugin-docs`(同样是本包的 bin)校验双语文档的结构对齐:README 中英标题层级与代码围栏一致,CHANGELOG 中英版本段(含 `Unreleased` 段)、分类段与条目数一致(分类标题经双语映射归一);`--base <revision>` 额外要求双语对同改,单边改动即缺翻译。该 revision 是全 0 的 null OID 时(新建分支或强推时 `github.event.before` 就是这个值)没有可比的前态,这一项跳过并给出提示,其余结构校验照常。接入方式是各仓自己的 `docs:check` script 指向它——本包自身的双语文档也由此校验。
60
+
61
+ ## 开发与验证
62
+
63
+ 片段是唯一来源,所以一次改动要同时过片段行为、CLI 与文档这三层;下面是本仓的完整校验(与 `AGENTS.md` 的 Verify 块一致,发布步骤见 `RELEASING.md`):
64
+
65
+ ```sh
66
+ npm test
67
+ node bin/dsh-plugin-docs.js
68
+ for f in bin/*.js dist/*.js; do node --check "$f"; done
69
+ node bin/dsh-plugin-parity.js --self-test
70
+ npm pack --dry-run
71
+ ```
@@ -1,40 +1,59 @@
1
1
  #!/usr/bin/env node
2
2
  // Embed a canonical fragment from this package into a consumer file.
3
3
  //
4
- // Two fragments are maintained the same way, because both have the same shape
5
- // of problem: the consumer must ship the code standalone (a browser classic
6
- // script cannot `import`, and a host half must not depend on the dock being
4
+ // Every fragment is maintained the same way, because all of them have the same
5
+ // shape of problem: the consumer must ship the code standalone (a browser classic
6
+ // script cannot `import`, and a host half must not depend on this package being
7
7
  // installed), so the code is embedded at build time and drift is a build error.
8
8
  //
9
- // dsh-mini-utility-dock -> <dsh-mini-utility-dock> ... </dsh-mini-utility-dock>
10
- // the Mini Utility Dock bootstrap, embedded into a plugin's lib/client.js
11
- //
12
9
  // dsh-loopback-helpers -> <dsh-loopback-helpers> ... </dsh-loopback-helpers>
13
10
  // the loopback predicates, embedded into a plugin's lib/shared.js
14
11
  //
15
12
  // dsh-host-guard -> <dsh-host-guard> ... </dsh-host-guard>
16
13
  // the same-origin request guard, embedded into the same file
17
14
  //
18
- // The host half of a plugin embeds BOTH shared.js fragments, in the order above,
19
- // because the guard imports the predicates. Each is an independent block with its
20
- // own markers and its own check, so a plugin that only needs the predicates can
21
- // embed just those.
15
+ // dsh-host-http -> <dsh-host-http> ... </dsh-host-http>
16
+ // the host response glue: JSON replies, the POST gate, the browser
17
+ // authorizer, embedded into the same file below the guard block
18
+ //
19
+ // dsh-utility-launcher -> <dsh-utility-launcher> ... </dsh-utility-launcher>
20
+ // the shared launcher assembly: one icon plus the menu of utility panels it
21
+ // opens, embedded into a plugin's lib/client.js
22
+ //
23
+ // The host half of a plugin embeds ALL THREE shared.js fragments, in the order
24
+ // above, because its guard reads the predicates the block above it declares. Each
25
+ // is an independent block with its own markers and its own check, so a plugin that
26
+ // only needs the predicates can embed just those.
22
27
  //
23
28
  // The fragment is selected by the marker already present in the target file, so
24
- // one command serves either target. A consumer calls this through its own
25
- // `dock:sync` / `guard:sync` script; the paired `check` mode is what CI runs.
29
+ // one command serves every target and only a named block is ever touched. A
30
+ // consumer calls this through its own `launcher:sync` / `guard:sync` /
31
+ // `loopback:sync` / `http:sync` script; the paired `check` mode is what CI runs.
26
32
 
27
33
  import { readFile, writeFile, stat } from 'node:fs/promises'
28
34
  import { resolve } from 'node:path'
29
35
  import { fileURLToPath } from 'node:url'
30
36
 
31
- // Listed in dependency order: the guard fragment reads the predicates the
32
- // loopback fragment declares, so a consumer that wants the guard syncs both, in
33
- // this order, into the same file. The launcher is independent of both and lands
34
- // in the client half instead, so it sits last.
37
+ // Listed in the order the blocks sit in a consumer's file. That order is
38
+ // load-bearing for the consumer, not for this tool:
39
+ //
40
+ // 1. the guard fragment reads the predicates the loopback fragment declares, so
41
+ // a consumer that wants the guard syncs both, loopback first;
42
+ // 2. the host-http fragment lands below the guard block, where a consumer's own
43
+ // `lib/shared.js` glue has always lived. It depends on neither block above
44
+ // it and declares nothing they declare, so the three coexist in one file;
45
+ // 3. the launcher is independent of all of them and lands in the client half
46
+ // instead, so it sits last.
47
+ //
48
+ // `main()` replaces each block working from the bottom of the file upward, and it
49
+ // orders that pass by the line numbers it found rather than by this list, so
50
+ // adding a row here states where a block sits and cannot shift another block's
51
+ // splice. Registering a fragment whose markers sit in a different order is still
52
+ // worth catching — `dsh-plugin-parity` checks it across repositories.
35
53
  const FRAGMENTS = [
36
54
  { name: 'dsh-loopback-helpers', source: 'loopback.js' },
37
55
  { name: 'dsh-host-guard', source: 'guard.js' },
56
+ { name: 'dsh-host-http', source: 'host-http.js' },
38
57
  { name: 'dsh-utility-launcher', source: 'launcher.js' }
39
58
  ]
40
59
 
@@ -42,12 +61,19 @@ const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
42
61
  const marker = (name, edge) => `// <${edge}${name}>`
43
62
 
44
63
  function usage() {
45
- const fragments = FRAGMENTS.map((f) => ` ${marker(f.name, '')} -> dist/${f.source}`).join('\n')
64
+ const fragments = FRAGMENTS.map((f) => ` ${marker(f.name, '')} ... ${marker(f.name, '/')} -> dist/${f.source}`).join('\n')
46
65
  return [
47
66
  'Usage: dsh-mini-utility-dock <sync|check> <consumer-file>',
48
67
  '',
49
- 'The fragment is chosen by the marker already present in <consumer-file>:',
50
- fragments
68
+ 'The blocks to maintain are the ones whose markers are present in <consumer-file>;',
69
+ 'one command handles every block in that file:',
70
+ fragments,
71
+ '',
72
+ 'A plugin host half embeds the three lib/shared.js blocks: the loopback',
73
+ 'predicates, the same-origin guard that reads them, and the host HTTP glue',
74
+ '(JSON replies, the POST gate, the browser authorizer) below the guard. The',
75
+ 'launcher block belongs to the client half in lib/client.js.',
76
+ '`sync` writes them, `check` exits non-zero on drift.'
51
77
  ].join('\n')
52
78
  }
53
79
 
@@ -70,9 +96,10 @@ function indentation(line) {
70
96
  }
71
97
 
72
98
  // Locate every marked block in the file, in fragment order. A host half embeds
73
- // both shared.js fragments, so one command must maintain both; processing them in
74
- // FRAGMENTS order also keeps the dependency order (the guard reads the predicates
75
- // declared by the loopback block above it).
99
+ // all three shared.js fragments, so one command must maintain all of them;
100
+ // processing them in FRAGMENTS order also keeps the dependency order (the guard
101
+ // reads the predicates declared by the loopback block above it, and the HTTP glue
102
+ // sits below both without reading either).
76
103
  function locate(source) {
77
104
  const lines = source.split(/\r?\n/)
78
105
  const candidates = FRAGMENTS.map((fragment) => {
@@ -89,7 +116,20 @@ function locate(source) {
89
116
  throw new Error(`expected exactly one marked block for ${fragment.name} (${marker(fragment.name, '')} ... ${marker(fragment.name, '/')})`)
90
117
  }
91
118
  }
92
- return { lines, blocks: candidates.map(({ fragment, start, end }) => ({ fragment, start: start[0], end: end[0] })) }
119
+ const blocks = candidates.map(({ fragment, start, end }) => ({ fragment, start: start[0], end: end[0] }))
120
+ // Each block is replaced by splicing its own line range, so two different
121
+ // fragments' ranges must not overlap. Interleaved markers (`<A> <B> </A> </B>`)
122
+ // would make one pass write into the range another pass had just rewritten, so
123
+ // the result would be corrupted rather than merely stale; the uniqueness of each
124
+ // marker pair is enforced above, and this closes the case a single pair cannot.
125
+ for (let i = 0; i < blocks.length; i++) {
126
+ for (let j = i + 1; j < blocks.length; j++) {
127
+ if (blocks[i].start <= blocks[j].end && blocks[j].start <= blocks[i].end) {
128
+ throw new Error(`marked blocks for ${blocks[i].fragment.name} and ${blocks[j].fragment.name} overlap; each marker pair must enclose its own lines`)
129
+ }
130
+ }
131
+ }
132
+ return { lines, blocks }
93
133
  }
94
134
 
95
135
  function rendered(lines, fragmentSource, block) {
@@ -99,9 +139,9 @@ function rendered(lines, fragmentSource, block) {
99
139
  return [...lines.slice(0, block.start + 1), ...body, ...lines.slice(block.end)]
100
140
  }
101
141
 
102
- // Applied from the LAST block to the first. Each pass replaces a line range, so
103
- // doing it bottom-up leaves every earlier block's line indices valid; going
104
- // top-down would shift them and a later pass would splice the wrong lines.
142
+ // One pass over the target file maintains EVERY marked block it carries, applied
143
+ // from the bottom of the file upward so each block's line indices are still valid
144
+ // when its turn comes. See the loop below for why that order is the whole trick.
105
145
  async function main() {
106
146
  const [command, fileName, ...rest] = process.argv.slice(2)
107
147
  if (!['sync', 'check'].includes(command) || !fileName || rest.length) throw new Error(usage())
@@ -110,7 +150,14 @@ async function main() {
110
150
 
111
151
  let lines = located.lines
112
152
  const applied = []
113
- for (const block of [...located.blocks].reverse()) {
153
+ // Walk by line position, the block nearest the end of the file first. Each pass
154
+ // replaces a line range, so every block above it must keep valid indices; going
155
+ // top-down would shift them and a later pass would splice the wrong lines.
156
+ // `locate()` has already rejected any file whose marker ranges overlap, so these
157
+ // blocks are disjoint: the pass can be ordered by the lines themselves rather
158
+ // than by the table, and a table/file order disagreement then only rewrites
159
+ // blocks in a different sequence, corrupting nothing.
160
+ for (const block of [...located.blocks].sort((a, b) => b.start - a.start)) {
114
161
  const fragmentSource = await readFile(resolve(packageRoot, 'dist', block.fragment.source), 'utf8')
115
162
  lines = rendered(lines, fragmentSource, block)
116
163
  applied.unshift(block.fragment.name)
@@ -1,17 +1,22 @@
1
1
  #!/usr/bin/env node
2
- // Bilingual structure check for the documentation pairs the DSH plugins ship:
2
+ // Bilingual structure check for the documentation pair a repository ships:
3
3
  // README.md against README.en.md, CHANGELOG.md against CHANGELOG.en.md.
4
4
  //
5
- // Verifies, naming no repository and taking no file arguments (the four names
6
- // are the family convention):
5
+ // Verifies, naming no repository and taking no file arguments (the four file
6
+ // names are fixed by convention, not passed in):
7
7
  // * both READMEs share the same heading-level sequence and code-fence
8
8
  // languages — content inside fences is exempt, since that is where
9
9
  // language-specific examples live;
10
- // * both CHANGELOGs expose the same releases: version, date, per-section
11
- // item counts, with section titles normalized through a bilingual
10
+ // * both CHANGELOGs expose the same releases — the `Unreleased` section
11
+ // included, since that is where the newest edits land — with version, date,
12
+ // per-section item counts, section titles normalized through a bilingual
12
13
  // category map (新增/Added, 修复/Fixed, ...);
13
14
  // * with `--base <revision>`, both files of each pair changed together
14
- // since that revision — a one-sided edit is a missing translation.
15
+ // since that revision — a one-sided edit is a missing translation. An
16
+ // all-zero revision is the null OID, which is what `github.event.before`
17
+ // carries for a branch that was just created or force-pushed: there is no
18
+ // previous revision to diff against, so that one requirement is skipped
19
+ // with a note instead of dying inside `git diff`.
15
20
  //
16
21
  // Repositories opt in through their own `docs:check` script; this package
17
22
  // runs the same check on its own bilingual docs.
@@ -47,12 +52,26 @@ function changelogShape(file) {
47
52
  let release
48
53
  let section
49
54
 
55
+ // A `## ...` heading opens a release and clears the current section, so bullets
56
+ // that follow it before any `###` still count — into an anonymous leading
57
+ // section — instead of being silently dropped.
58
+ const openRelease = (version, date) => {
59
+ release = { version, date, sections: [] }
60
+ releases.push(release)
61
+ section = undefined
62
+ }
63
+
50
64
  for (const line of read(file).split('\n')) {
51
65
  const version = line.match(/^##\s+(\d+\.\d+\.\d+)(?:\s+-\s+(\d{4}-\d{2}-\d{2}))?\s*$/)
52
66
  if (version) {
53
- release = { version: version[1], date: version[2] ?? '', sections: [] }
54
- releases.push(release)
55
- section = undefined
67
+ openRelease(version[1], version[2] ?? '')
68
+ continue
69
+ }
70
+ // `Unreleased` is the version-less top release and usually the most-edited
71
+ // section, so it is compared with the same shape as a numbered one. Both
72
+ // languages keep the literal heading, so the labels match directly.
73
+ if (/^##\s+Unreleased\s*$/i.test(line)) {
74
+ openRelease('Unreleased', '')
56
75
  continue
57
76
  }
58
77
  if (!release) continue
@@ -63,7 +82,13 @@ function changelogShape(file) {
63
82
  release.sections.push(section)
64
83
  continue
65
84
  }
66
- if (section && /^\s*-\s+/.test(line)) section.items += 1
85
+ if (/^\s*-\s+/.test(line)) {
86
+ if (!section) {
87
+ section = { title: '', items: 0 }
88
+ release.sections.push(section)
89
+ }
90
+ section.items += 1
91
+ }
67
92
  }
68
93
 
69
94
  return releases
@@ -105,16 +130,25 @@ if (baseIndex !== -1) {
105
130
  const base = process.argv[baseIndex + 1]
106
131
  if (!base) throw new Error('--base requires a Git revision')
107
132
 
108
- const changed = new Set(execFileSync('git', ['diff', '--name-only', base, 'HEAD'], { encoding: 'utf8' })
109
- .split(/\r?\n/)
110
- .filter(Boolean))
111
-
112
- for (const [primary, translation] of [
113
- ['README.md', 'README.en.md'],
114
- ['CHANGELOG.md', 'CHANGELOG.en.md'],
115
- ]) {
116
- if (changed.has(primary) !== changed.has(translation)) {
117
- throw new Error(`${primary} and ${translation} must change together`)
133
+ // The null OID, in either hash length. A workflow that passes
134
+ // `github.event.before` here gets it whenever a branch is created or
135
+ // force-pushed, and `git diff 000... HEAD` is not a comparison with no base —
136
+ // it is an error. So the honest reading is the one the push event states: there
137
+ // is nothing before this push, and the pair test cannot run.
138
+ if (/^0+$/.test(base)) {
139
+ console.log(`note: --base ${base} is the null OID (new branch or force-push) — pair-change check skipped`)
140
+ } else {
141
+ const changed = new Set(execFileSync('git', ['diff', '--name-only', base, 'HEAD'], { encoding: 'utf8' })
142
+ .split(/\r?\n/)
143
+ .filter(Boolean))
144
+
145
+ for (const [primary, translation] of [
146
+ ['README.md', 'README.en.md'],
147
+ ['CHANGELOG.md', 'CHANGELOG.en.md'],
148
+ ]) {
149
+ if (changed.has(primary) !== changed.has(translation)) {
150
+ throw new Error(`${primary} and ${translation} must change together`)
151
+ }
118
152
  }
119
153
  }
120
154
  }