dsh-mini-utility-dock 0.1.8 → 0.3.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 +10 -0
- package/CHANGELOG.md +10 -0
- package/README.en.md +11 -2
- package/README.md +11 -2
- package/bin/dsh-plugin-docs.js +122 -0
- package/bin/dsh-plugin-parity.js +498 -0
- package/dist/loopback.js +1 -1
- package/package.json +6 -3
package/CHANGELOG.en.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
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.3.0 - 2026-09-22
|
|
7
|
+
|
|
8
|
+
- New `dsh-plugin-docs`: a bilingual docs structure check, adopted from the `check-docs.mjs` copy each plugin repository carried. Same interface: no arguments checks the README and CHANGELOG pairs for structural alignment, `--base <revision>` requires bilingual pairs to change together. This package's own bilingual docs are now checked by it too.
|
|
9
|
+
- README gains badges and a positioning statement; the two diagnostic bins (`dsh-plugin-parity`, `dsh-plugin-docs`) are documented with their purpose and opt-in mechanism; the package description states the "family shared assets" scope.
|
|
10
|
+
|
|
11
|
+
## 0.2.0 - 2026-09-21
|
|
12
|
+
|
|
13
|
+
- New `dsh-plugin-parity`: a cross-repository drift check, adopted from the copy each plugin repository carried. The member list is caller-supplied via `--member <repo>:<export>`; this package names no repository of its own. Without `--member` it runs the static checks only and discovers every repository that embeds the host-guard block.
|
|
14
|
+
- The copy in each plugin repository is gone with it; the bilingual-structure half of `docs:check` stays with the repositories.
|
|
15
|
+
|
|
6
16
|
## 0.1.8 - 2026-09-21
|
|
7
17
|
|
|
8
18
|
- 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.
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
Release Notes 由对应版本段生成;最新版本在前。
|
|
4
4
|
英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
|
|
5
5
|
|
|
6
|
+
## 0.3.0 - 2026-09-22
|
|
7
|
+
|
|
8
|
+
- 新增 `dsh-plugin-docs`:双语文档结构校验,收编自插件仓里各自维护的 `check-docs.mjs`。接口不变:无参数查 README 与 CHANGELOG 的中英结构对齐,`--base <revision>` 检查双语对同改。本包自身的双语文档也由此接入校验。
|
|
9
|
+
- README 补上 badges 与包定位说明;两个诊断 bin(`dsh-plugin-parity`、`dsh-plugin-docs`)的用途与接入方式补进文档;包描述同步「族级共享资产」的定位。
|
|
10
|
+
|
|
11
|
+
## 0.2.0 - 2026-09-21
|
|
12
|
+
|
|
13
|
+
- 新增 `dsh-plugin-parity`:跨仓漂移诊断,收编自插件仓里各自维护的那份脚本。成员表由调用方通过 `--member <repo>:<export>` 提供,本包不点名任何仓;不带 `--member` 时只跑静态检查,自动发现所有嵌入了宿主守卫块的仓。
|
|
14
|
+
- 插件仓里的该脚本由此删除;`docs:check` 的双语结构校验仍归各仓。
|
|
15
|
+
|
|
6
16
|
## 0.1.8 - 2026-09-21
|
|
7
17
|
|
|
8
18
|
- 两个 host 侧片段的注释不再出现 `sibling`:三处改为陈述设计目标本身——插件独立运行,不依赖同级安装。片段会逐字嵌入消费仓,那个词在那里没有可指对象。
|
package/README.en.md
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
[中文](./README.md) | [English](./README.en.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://github.com/xswt442-cmd/dsh-mini-utility-dock/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/dsh-mini-utility-dock)
|
|
7
|
+
[](https://github.com/xswt442-cmd/dsh-mini-utility-dock/releases)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[](https://www.npmjs.com/package/dsh-mini-utility-dock)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
12
|
+
The DSH plugin family's shared assets: source fragments, an embedding CLI, and the cross-repo diagnostics and bilingual docs checks built around them.
|
|
6
13
|
|
|
7
14
|
## Fragments
|
|
8
15
|
|
|
@@ -44,4 +51,6 @@ Consumers invoke the same CLI as `loopback:sync` / `guard:sync`, with the target
|
|
|
44
51
|
|
|
45
52
|
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.
|
|
46
53
|
|
|
47
|
-
`
|
|
54
|
+
`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.
|
|
55
|
+
|
|
56
|
+
`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.
|
package/README.md
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
[中文](./README.md) | [English](./README.en.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://github.com/xswt442-cmd/dsh-mini-utility-dock/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/dsh-mini-utility-dock)
|
|
7
|
+
[](https://github.com/xswt442-cmd/dsh-mini-utility-dock/releases)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[](https://www.npmjs.com/package/dsh-mini-utility-dock)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
12
|
+
DSH 插件族的共享资产:源码片段、嵌入 CLI,以及围绕它们的跨仓诊断与双语文档校验工具。
|
|
6
13
|
|
|
7
14
|
## 片段
|
|
8
15
|
|
|
@@ -44,4 +51,6 @@ npm run dock:embed -- sync path/to/client.js
|
|
|
44
51
|
|
|
45
52
|
消费仓的 `npm test` 用 `loopback:check` / `guard:check` 把本仓两个片段与所 pin 的 dock 版本逐字节比对。dock 版本不可变且消费仓 pin 精确版本,故「pin 一致」即「片段一致」。
|
|
46
53
|
|
|
47
|
-
`
|
|
54
|
+
`dsh-plugin-parity`(本包提供的 bin)直接检这条跨仓性质,含 pin 一致性断言与行为级对比。它是人工诊断工具,**不在 CI 中运行**:peer 处于不同分支时该性质本就不成立。成员表由调用方经 `--member <repo>:<export>` 提供——守卫工厂的导出名按片段设计是各仓自定的,故本工具不预设任何成员;不带 `--member` 时只跑静态检查,自动发现所有嵌入了宿主守卫块的仓。
|
|
55
|
+
|
|
56
|
+
`dsh-plugin-docs`(同样是本包的 bin)校验双语文档的结构对齐:README 中英标题层级与代码围栏一致,CHANGELOG 中英版本段、分类段与条目数一致(分类标题经双语映射归一);`--base <revision>` 额外要求双语对同改,单边改动即缺翻译。接入方式是各仓自己的 `docs:check` script 指向它——本包自身的双语文档也由此校验。
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bilingual structure check for the documentation pairs the DSH plugins ship:
|
|
3
|
+
// README.md against README.en.md, CHANGELOG.md against CHANGELOG.en.md.
|
|
4
|
+
//
|
|
5
|
+
// Verifies, naming no repository and taking no file arguments (the four names
|
|
6
|
+
// are the family convention):
|
|
7
|
+
// * both READMEs share the same heading-level sequence and code-fence
|
|
8
|
+
// languages — content inside fences is exempt, since that is where
|
|
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
|
|
12
|
+
// category map (新增/Added, 修复/Fixed, ...);
|
|
13
|
+
// * with `--base <revision>`, both files of each pair changed together
|
|
14
|
+
// since that revision — a one-sided edit is a missing translation.
|
|
15
|
+
//
|
|
16
|
+
// Repositories opt in through their own `docs:check` script; this package
|
|
17
|
+
// runs the same check on its own bilingual docs.
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from 'node:child_process'
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
|
|
22
|
+
const read = (file) => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n')
|
|
23
|
+
|
|
24
|
+
function markdownShape(file) {
|
|
25
|
+
let fenced = false
|
|
26
|
+
const headings = []
|
|
27
|
+
const fences = []
|
|
28
|
+
|
|
29
|
+
for (const line of read(file).split('\n')) {
|
|
30
|
+
const fence = line.match(/^\s*```\s*(\S*)/)
|
|
31
|
+
if (fence) {
|
|
32
|
+
if (!fenced) fences.push(fence[1])
|
|
33
|
+
fenced = !fenced
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
if (fenced) continue
|
|
37
|
+
const heading = line.match(/^(#{1,3})\s+/)
|
|
38
|
+
if (heading) headings.push(heading[1].length)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (fenced) throw new Error(`${file}: unclosed code fence`)
|
|
42
|
+
return { headings, fences }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function changelogShape(file) {
|
|
46
|
+
const releases = []
|
|
47
|
+
let release
|
|
48
|
+
let section
|
|
49
|
+
|
|
50
|
+
for (const line of read(file).split('\n')) {
|
|
51
|
+
const version = line.match(/^##\s+(\d+\.\d+\.\d+)(?:\s+-\s+(\d{4}-\d{2}-\d{2}))?\s*$/)
|
|
52
|
+
if (version) {
|
|
53
|
+
release = { version: version[1], date: version[2] ?? '', sections: [] }
|
|
54
|
+
releases.push(release)
|
|
55
|
+
section = undefined
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
if (!release) continue
|
|
59
|
+
|
|
60
|
+
const heading = line.match(/^###\s+(.+?)\s*$/)
|
|
61
|
+
if (heading) {
|
|
62
|
+
section = { title: heading[1], items: 0 }
|
|
63
|
+
release.sections.push(section)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (section && /^\s*-\s+/.test(line)) section.items += 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return releases
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const category = new Map([
|
|
73
|
+
['新增', 'added'], ['Added', 'added'],
|
|
74
|
+
['修复', 'fixed'], ['Fixed', 'fixed'],
|
|
75
|
+
['变更', 'changed'], ['Changed', 'changed'],
|
|
76
|
+
['移除', 'removed'], ['Removed', 'removed'],
|
|
77
|
+
['安全', 'security'], ['Security', 'security'],
|
|
78
|
+
['性能', 'performance'], ['Performance', 'performance'],
|
|
79
|
+
['兼容性', 'compatibility'], ['Compatibility', 'compatibility'],
|
|
80
|
+
['维护', 'maintenance'], ['Maintenance', 'maintenance'],
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
function assertEqual(left, right, message) {
|
|
84
|
+
if (JSON.stringify(left) !== JSON.stringify(right)) {
|
|
85
|
+
throw new Error(`${message}\nleft: ${JSON.stringify(left)}\nright: ${JSON.stringify(right)}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const zhReadme = markdownShape('README.md')
|
|
90
|
+
const enReadme = markdownShape('README.en.md')
|
|
91
|
+
assertEqual(zhReadme, enReadme, 'README structure differs between languages')
|
|
92
|
+
|
|
93
|
+
const normalizeLog = (file) => changelogShape(file).map((release) => ({
|
|
94
|
+
version: release.version,
|
|
95
|
+
date: release.date,
|
|
96
|
+
sections: release.sections.map(({ title, items }) => ({
|
|
97
|
+
title: category.get(title) ?? title.toLowerCase(),
|
|
98
|
+
items,
|
|
99
|
+
})),
|
|
100
|
+
}))
|
|
101
|
+
assertEqual(normalizeLog('CHANGELOG.md'), normalizeLog('CHANGELOG.en.md'), 'CHANGELOG structure differs between languages')
|
|
102
|
+
|
|
103
|
+
const baseIndex = process.argv.indexOf('--base')
|
|
104
|
+
if (baseIndex !== -1) {
|
|
105
|
+
const base = process.argv[baseIndex + 1]
|
|
106
|
+
if (!base) throw new Error('--base requires a Git revision')
|
|
107
|
+
|
|
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`)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log('bilingual docs are structurally aligned')
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// Cross-repo drift check for the shared host fragments.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: each member repository ships its own copy of the loopback
|
|
4
|
+
// predicates and the request guard, because each package has to stand alone.
|
|
5
|
+
// Those copies drifted repeatedly, and every drift was security-relevant:
|
|
6
|
+
//
|
|
7
|
+
// 1. every copy rejected IPv6 loopback (`::1`) — a legitimate browser was
|
|
8
|
+
// locked out of its own API;
|
|
9
|
+
// 2. the copies disagreed on which Host spellings count as loopback, leaving
|
|
10
|
+
// one of them with an allowlist entry its Origin path could never reach;
|
|
11
|
+
// 3. a Host that parsed to no hostname silently skipped the allowlist in one
|
|
12
|
+
// copy and was denied in the others.
|
|
13
|
+
//
|
|
14
|
+
// The copies are no longer hand-written: they are two embedded blocks generated
|
|
15
|
+
// from dsh-mini-utility-dock by the dock CLI — `dist/loopback.js` (the predicates)
|
|
16
|
+
// and `dist/guard.js` (the enforcement policy). So this checker asserts three
|
|
17
|
+
// things a generator alone cannot:
|
|
18
|
+
//
|
|
19
|
+
// * both embedded blocks are byte-identical in every member (a hand edit to
|
|
20
|
+
// one copy, or a repo that never re-ran `loopback:sync` / `guard:sync`, fails
|
|
21
|
+
// here), and they appear in dependency order — the guard uses the predicates
|
|
22
|
+
// the loopback block declares in the same file;
|
|
23
|
+
// * no repo keeps a private copy of an enforcement decision beside them — that
|
|
24
|
+
// is how drifts 1-3 happened, and embedding the blocks is worthless if a repo
|
|
25
|
+
// also branches on its own version;
|
|
26
|
+
// * the members AGREE ON EVERY DECISION. Error codes and wording deliberately
|
|
27
|
+
// differ (each plugin's published API vocabulary), so the comparison is on
|
|
28
|
+
// the allow/deny outcome only. This is the assertion that survives a policy
|
|
29
|
+
// change: byte equality implies it, but only this proves each consumer
|
|
30
|
+
// actually routes through the blocks.
|
|
31
|
+
//
|
|
32
|
+
// Usage:
|
|
33
|
+
// dsh-plugin-parity --root <dir> --member <repo>:<export> [--member ...]
|
|
34
|
+
// dsh-plugin-parity --root <dir> # static checks for every member found
|
|
35
|
+
// dsh-plugin-parity --self-test # exercises the extractor alone
|
|
36
|
+
//
|
|
37
|
+
// `--member` names one participating repository and the guard factory it
|
|
38
|
+
// exports. That name is per-repository by design — the shared block is not
|
|
39
|
+
// exported under a plugin-facing name, so each repository publishes its own —
|
|
40
|
+
// which is why the member list is caller-supplied: this tool names no
|
|
41
|
+
// repository of its own.
|
|
42
|
+
//
|
|
43
|
+
// With no `--member`, every directory under `--root` that carries the guard
|
|
44
|
+
// markers is checked statically, and the decision comparison is skipped.
|
|
45
|
+
//
|
|
46
|
+
// This is a LOCAL DIAGNOSTIC, deliberately not a CI gate. Per-repo CI already
|
|
47
|
+
// proves the stronger local property — `loopback:check` / `guard:check` compare
|
|
48
|
+
// each repo's embedded blocks against the `dist/` of the exact dock version it
|
|
49
|
+
// pins, and that version is immutable on npm. Members pinning one version
|
|
50
|
+
// therefore hold byte-identical blocks by construction, which is why this script
|
|
51
|
+
// is redundant as a gate and was removed from the compat workflow.
|
|
52
|
+
//
|
|
53
|
+
// The property it asserts is inherently cross-repository and cannot hold at an
|
|
54
|
+
// arbitrary moment: on a `dev` push the peer checkouts resolve to their default
|
|
55
|
+
// branch, so a difference reported here may mean only that the peers are on
|
|
56
|
+
// `main`. Run it when every checkout is on the same branch — before a
|
|
57
|
+
// release, or after one — and read a failure as a real signal then.
|
|
58
|
+
//
|
|
59
|
+
// Exit code 0 = identical blocks, one shared dock pin, no private copies, and
|
|
60
|
+
// every decision agrees.
|
|
61
|
+
|
|
62
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
63
|
+
import { pathToFileURL } from 'node:url'
|
|
64
|
+
import { join } from 'node:path'
|
|
65
|
+
|
|
66
|
+
const argv = process.argv.slice(2)
|
|
67
|
+
const flagOf = (name) => {
|
|
68
|
+
const at = argv.indexOf(name)
|
|
69
|
+
return at >= 0 ? argv[at + 1] : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const root = flagOf('--root') || process.env.DSH_PLUGINS_ROOT || process.cwd()
|
|
73
|
+
|
|
74
|
+
// `--member <repo>:<export>` names one participating repository and the guard
|
|
75
|
+
// factory it exports. Repeated once per participant.
|
|
76
|
+
const members = []
|
|
77
|
+
for (let i = 0; i < argv.length; i++) {
|
|
78
|
+
if (argv[i] !== '--member') continue
|
|
79
|
+
const [repo, factory] = String(argv[i + 1] || '').split(':')
|
|
80
|
+
if (!repo || !factory) {
|
|
81
|
+
console.error('dsh-plugin-parity: --member expects <repo>:<export>')
|
|
82
|
+
process.exit(2)
|
|
83
|
+
}
|
|
84
|
+
members.push([repo, repo, factory])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// With no member list, take every directory under the root that embeds the guard
|
|
88
|
+
// block. Those members take part in the static checks only, which is all a
|
|
89
|
+
// discovered member can support: the decision comparison needs a factory name.
|
|
90
|
+
const discovered = () => readdirSync(root, { withFileTypes: true })
|
|
91
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
92
|
+
.map((entry) => entry.name)
|
|
93
|
+
.filter((name) => {
|
|
94
|
+
try { return readFileSync(join(root, name, 'lib', 'shared.js'), 'utf8').includes('// <dsh-host-guard>') }
|
|
95
|
+
catch { return false }
|
|
96
|
+
})
|
|
97
|
+
.sort()
|
|
98
|
+
|
|
99
|
+
const repos = members.length ? members : discovered().map((name) => [name, name, null])
|
|
100
|
+
const tags = repos.map(([, tag]) => tag).join('/')
|
|
101
|
+
|
|
102
|
+
// Dependency order: the guard block uses the predicates the loopback block
|
|
103
|
+
// declares, so it must come second.
|
|
104
|
+
const BLOCKS = [
|
|
105
|
+
{ name: 'dsh-loopback-helpers', label: 'loopback predicates' },
|
|
106
|
+
{ name: 'dsh-host-guard', label: 'host guard' }
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
const marks = (name) => ({ start: `// <${name}>`, end: `// </${name}>` })
|
|
110
|
+
|
|
111
|
+
// Pull a generated block out of a consumer's lib/shared.js. Everything between
|
|
112
|
+
// the markers is generator output, so it must match byte for byte; the consumer
|
|
113
|
+
// indents it, which is stripped before comparing.
|
|
114
|
+
const extractBlock = (src, name) => {
|
|
115
|
+
const { start, end } = marks(name)
|
|
116
|
+
const lines = src.split(/\r?\n/)
|
|
117
|
+
const starts = lines.reduce((hits, line, i) => line.trim() === start ? [...hits, i] : hits, [])
|
|
118
|
+
const ends = lines.reduce((hits, line, i) => line.trim() === end ? [...hits, i] : hits, [])
|
|
119
|
+
if (starts.length !== 1 || ends.length !== 1 || ends[0] <= starts[0]) return null
|
|
120
|
+
return lines.slice(starts[0] + 1, ends[0]).map((line) => line.replace(/^\s{2}/, '')).join('\n').trim()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const read = (repo) => readFileSync(join(root, repo, 'lib', 'shared.js'), 'utf8')
|
|
124
|
+
|
|
125
|
+
const spanOf = (src, name) => {
|
|
126
|
+
const { start, end } = marks(name)
|
|
127
|
+
const lines = src.split(/\r?\n/)
|
|
128
|
+
const from = lines.findIndex((line) => line.trim() === start)
|
|
129
|
+
const to = lines.findIndex((line) => line.trim() === end)
|
|
130
|
+
return from >= 0 && to > from ? { from, to } : null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Everything that is neither block: the plugin's own code, which is where a
|
|
134
|
+
// private copy of an enforcement decision would hide.
|
|
135
|
+
const dropBlocks = (src) => {
|
|
136
|
+
const spans = BLOCKS.map((b) => spanOf(src, b.name)).filter(Boolean).sort((a, b) => b.from - a.from)
|
|
137
|
+
const lines = src.split(/\r?\n/)
|
|
138
|
+
for (const { from, to } of spans) lines.splice(from, to - from + 1)
|
|
139
|
+
return lines.join('\n')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The dock version a repo embeds its blocks from. This is the one fact that is
|
|
143
|
+
// genuinely cross-repository: the blocks themselves are proven locally against
|
|
144
|
+
// whatever version is pinned here, and a version is immutable on npm, so equal
|
|
145
|
+
// pins are what make the blocks equal — not the other way round.
|
|
146
|
+
const dockPinOf = (repo) => {
|
|
147
|
+
const pkg = JSON.parse(readFileSync(join(root, repo, 'package.json'), 'utf8'))
|
|
148
|
+
const dev = (pkg.devDependencies || {})['dsh-mini-utility-dock']
|
|
149
|
+
const any = (pkg.dependencies || {})['dsh-mini-utility-dock']
|
|
150
|
+
return dev || any || null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (process.argv.includes('--self-test')) {
|
|
154
|
+
const good = [
|
|
155
|
+
marks('dsh-loopback-helpers').start, 'const a = 1', marks('dsh-loopback-helpers').end,
|
|
156
|
+
marks('dsh-host-guard').start, 'const b = 2', marks('dsh-host-guard').end
|
|
157
|
+
].join('\n')
|
|
158
|
+
const cases = [
|
|
159
|
+
['first block extracted', 'dsh-loopback-helpers', good, 'const a = 1'],
|
|
160
|
+
['second block extracted', 'dsh-host-guard', good, 'const b = 2'],
|
|
161
|
+
['indent is stripped', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, ' const a = 1', marks('dsh-loopback-helpers').end].join('\n'), 'const a = 1'],
|
|
162
|
+
['missing end marker returns null', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, 'const a = 1'].join('\n'), null],
|
|
163
|
+
['absent block returns null', 'dsh-host-guard', good.replace(/dsh-host-guard/g, 'other'), null]
|
|
164
|
+
]
|
|
165
|
+
let bad = 0
|
|
166
|
+
for (const [label, name, src, expected] of cases) {
|
|
167
|
+
const got = extractBlock(src, name)
|
|
168
|
+
const ok = got === expected
|
|
169
|
+
if (!ok) console.log(`self-test FAIL ${label}\n expected: ${JSON.stringify(expected)}\n got: ${JSON.stringify(got)}`)
|
|
170
|
+
else console.log(`self-test ok ${label}`)
|
|
171
|
+
if (!ok) bad++
|
|
172
|
+
}
|
|
173
|
+
// Dropping both blocks must leave only the plugin's own code.
|
|
174
|
+
const residual = dropBlocks(good)
|
|
175
|
+
const dropOk = !/const a = 1|const b = 2/.test(residual)
|
|
176
|
+
console.log(dropOk ? 'self-test ok both blocks dropped' : `self-test FAIL both blocks dropped: ${JSON.stringify(residual)}`)
|
|
177
|
+
if (!dropOk) bad++
|
|
178
|
+
process.exit(bad ? 1 : 0)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let failures = 0
|
|
182
|
+
|
|
183
|
+
// 1. Both generated blocks must be present, byte-identical everywhere, and in
|
|
184
|
+
// dependency order.
|
|
185
|
+
for (const block of BLOCKS) {
|
|
186
|
+
const copies = repos.map(([repo, tag]) => ({ repo, tag, body: extractBlock(read(repo), block.name) }))
|
|
187
|
+
const missing = copies.filter((c) => c.body === null)
|
|
188
|
+
if (missing.length) {
|
|
189
|
+
console.log(`FAIL ${block.label}: missing or malformed markers in ${missing.map((m) => m.tag).join(', ')}`)
|
|
190
|
+
failures++
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
const base = copies[0]
|
|
194
|
+
const drifted = copies.slice(1).filter((c) => c.body !== base.body)
|
|
195
|
+
if (drifted.length) {
|
|
196
|
+
console.log(`FAIL ${block.label}: ${drifted.map((d) => d.tag).join(', ')} differ from ${base.tag}`)
|
|
197
|
+
for (const d of drifted) {
|
|
198
|
+
console.log(`\n--- ${base.tag} (${base.repo}/lib/shared.js) ---\n${base.body}`)
|
|
199
|
+
console.log(`\n--- ${d.tag} (${d.repo}/lib/shared.js) ---\n${d.body}`)
|
|
200
|
+
}
|
|
201
|
+
failures++
|
|
202
|
+
} else {
|
|
203
|
+
console.log(`ok ${block.label}: identical across ${tags} (${base.body.length} chars)`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 2. Every repo must embed from the same dock version. A skew here is the one
|
|
208
|
+
// cross-repo cause of divergence that per-repo checks cannot see: each repo's
|
|
209
|
+
// own `guard:check` passes against whatever it pins, so a repo left on an older
|
|
210
|
+
// dock stays green while its blocks differ from its siblings'.
|
|
211
|
+
{
|
|
212
|
+
const pins = repos.map(([repo, tag]) => ({ tag, pin: dockPinOf(repo) }))
|
|
213
|
+
const unset = pins.filter((p) => p.pin === null)
|
|
214
|
+
const distinct = [...new Set(pins.map((p) => p.pin).filter((p) => p !== null))]
|
|
215
|
+
if (unset.length) {
|
|
216
|
+
console.log(`FAIL dock pin: not declared by ${unset.map((u) => u.tag).join(', ')}`)
|
|
217
|
+
failures++
|
|
218
|
+
} else if (distinct.length !== 1) {
|
|
219
|
+
console.log(`FAIL dock pin: versions differ — ${pins.map((p) => `${p.tag}=${p.pin}`).join(' ')}`)
|
|
220
|
+
failures++
|
|
221
|
+
} else {
|
|
222
|
+
// An exact pin is what makes the sibling repos hold identical blocks: a range
|
|
223
|
+
// could resolve to different builds under one declared value.
|
|
224
|
+
const exact = /^\d+\.\d+\.\d+$/.test(distinct[0])
|
|
225
|
+
if (!exact) {
|
|
226
|
+
console.log(`FAIL dock pin: ${JSON.stringify(distinct[0])} is not an exact version, so one declared value can resolve to different blocks`)
|
|
227
|
+
failures++
|
|
228
|
+
} else {
|
|
229
|
+
console.log(`ok dock pin: every member embeds dsh-mini-utility-dock ${distinct[0]}`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The guard block reads what the loopback block declares in the same file, so the
|
|
235
|
+
// order is load-bearing, not cosmetic.
|
|
236
|
+
{
|
|
237
|
+
let orderBad = 0
|
|
238
|
+
for (const [repo, tag] of repos) {
|
|
239
|
+
const src = read(repo)
|
|
240
|
+
const loop = spanOf(src, 'dsh-loopback-helpers')
|
|
241
|
+
const guard = spanOf(src, 'dsh-host-guard')
|
|
242
|
+
if (!loop || !guard) continue
|
|
243
|
+
if (loop.from > guard.from) {
|
|
244
|
+
console.log(`FAIL ${tag}: the host guard block precedes the loopback predicates`)
|
|
245
|
+
orderBad++
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (orderBad) failures += orderBad
|
|
249
|
+
else console.log('ok block order: the loopback predicates precede the host guard everywhere')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 3. Embedding the blocks is worthless if a repo also keeps its own enforcement
|
|
253
|
+
// beside them. Policy is expected outside the blocks; decisions are not.
|
|
254
|
+
//
|
|
255
|
+
// `remoteAddress` is deliberately NOT listed on its own: a plugin may keep a
|
|
256
|
+
// second helper that applies the same criterion for a different gate
|
|
257
|
+
// (dsh-instance-manager's `requestNeedsBearer` does exactly that, so the API
|
|
258
|
+
// route and the guard cannot drift apart). A private criterion only matters
|
|
259
|
+
// when the repo also owns a private guard, which the factory check catches.
|
|
260
|
+
const ENFORCEMENT = [
|
|
261
|
+
[/LOOPBACK_HOSTNAMES\s*=/, 'redefines LOOPBACK_HOSTNAMES'],
|
|
262
|
+
[/(?:const|function|let)\s+isLoopbackName\b/, 'defines a private isLoopbackName'],
|
|
263
|
+
[/(?:const|function|let)\s+isLoopbackAddress\b/, 'defines a private isLoopbackAddress'],
|
|
264
|
+
[/(?:const|function|let)\s+hostHostname\b/, 'defines a private hostHostname'],
|
|
265
|
+
[/(?:const|function|let)\s+bindGuard\b/, 'defines a private guard factory'],
|
|
266
|
+
[/sec-fetch-site/, 'branches on Fetch Metadata outside the blocks']
|
|
267
|
+
]
|
|
268
|
+
for (const [repo, tag] of repos) {
|
|
269
|
+
const outside = dropBlocks(read(repo))
|
|
270
|
+
const problems = ENFORCEMENT.filter(([re]) => re.test(outside)).map(([, why]) => why)
|
|
271
|
+
if (/remoteAddress/.test(outside) && /function\s+guard\s*\(|=>\s*\{\s*$/.test(outside)) {
|
|
272
|
+
problems.push('looks like a private guard reading the socket peer')
|
|
273
|
+
}
|
|
274
|
+
if (problems.length) {
|
|
275
|
+
console.log(`FAIL ${tag}: ${problems.join('; ')}`)
|
|
276
|
+
failures++
|
|
277
|
+
} else {
|
|
278
|
+
console.log(`ok ${tag}: no enforcement outside the generated blocks`)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Everything above is static, so it covers discovered members too. The decision
|
|
283
|
+
// comparison needs a factory name per member, which only `--member` supplies.
|
|
284
|
+
if (!members.length) {
|
|
285
|
+
console.log(failures
|
|
286
|
+
? `\n${failures} drift problem(s)`
|
|
287
|
+
: '\nno drift (static checks only; pass --member <repo>:<export> to compare decisions)')
|
|
288
|
+
process.exit(failures ? 1 : 0)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 4. Every decision must agree across the members. Codes differ by design, so
|
|
292
|
+
// only the verdict is compared.
|
|
293
|
+
//
|
|
294
|
+
// First confirm each repo exports the guard it is expected to. A repo whose
|
|
295
|
+
// blocks failed to parse is already reported above; without this check the
|
|
296
|
+
// decision cases below would throw `mods[repo][factory] is not a function` and
|
|
297
|
+
// bury the real diagnosis under a stack trace.
|
|
298
|
+
const makeRes = () => ({
|
|
299
|
+
statusCode: null,
|
|
300
|
+
body: null,
|
|
301
|
+
writeHead(code) { this.statusCode = code; return this },
|
|
302
|
+
setHeader() {},
|
|
303
|
+
end() {}
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const mods = {}
|
|
307
|
+
for (const [repo] of repos) {
|
|
308
|
+
mods[repo] = await import(pathToFileURL(join(root, repo, 'lib', 'shared.js')).href)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const unusable = repos.filter(([repo, , factory]) => typeof mods[repo][factory] !== 'function')
|
|
312
|
+
if (unusable.length) {
|
|
313
|
+
for (const [repo, tag, factory] of unusable) {
|
|
314
|
+
console.log(`FAIL ${tag}: lib/shared.js does not export a usable ${factory}() — the embedded blocks are stale or absent`)
|
|
315
|
+
}
|
|
316
|
+
failures += unusable.length
|
|
317
|
+
console.log('\ncannot compare decisions while a repo exports no guard')
|
|
318
|
+
console.log(failures ? `\n${failures} drift problem(s)` : '\nno drift')
|
|
319
|
+
process.exit(1)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Each plugin names its own guard, so resolve it per repo. The guard is built
|
|
323
|
+
// with `allowRemoteHost` only when the case asks for fleet mode, because a plugin
|
|
324
|
+
// that never opts in must behave strictly.
|
|
325
|
+
const verdict = (repo, factory, { host, origin, peer = '127.0.0.1', site = 'same-origin', fleet = false }) => {
|
|
326
|
+
const req = {
|
|
327
|
+
method: 'GET',
|
|
328
|
+
headers: { ...(host === undefined ? {} : { host }), ...(origin ? { origin } : {}), ...(site === null ? {} : { 'sec-fetch-site': site }) },
|
|
329
|
+
socket: peer === null ? {} : { remoteAddress: peer }
|
|
330
|
+
}
|
|
331
|
+
const res = makeRes()
|
|
332
|
+
const guard = mods[repo][factory]({
|
|
333
|
+
currentPort: () => 3080,
|
|
334
|
+
// Every plugin routes rejections through its own sink; a fake one keeps the
|
|
335
|
+
// comparison independent of each plugin's response shape.
|
|
336
|
+
respond: (target, code, body) => { target.statusCode = code; target.body = body },
|
|
337
|
+
...(fleet ? { allowRemoteHost: () => true } : {})
|
|
338
|
+
})
|
|
339
|
+
return guard(req, res) ? 'allow' : 'deny'
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const cases = [
|
|
343
|
+
// Host allowlist, including both spellings of the mapped IPv6 loopback.
|
|
344
|
+
['loopback ipv4 host', { host: '127.0.0.1:3080' }, 'allow'],
|
|
345
|
+
['loopback name host', { host: 'localhost:3080' }, 'allow'],
|
|
346
|
+
['loopback ipv6 host', { host: '[::1]:3080' }, 'allow'],
|
|
347
|
+
['mapped loopback host (dotted)', { host: '[::ffff:127.0.0.1]:3080' }, 'allow'],
|
|
348
|
+
['mapped loopback host (hex)', { host: '[::ffff:7f00:1]:3080' }, 'allow'],
|
|
349
|
+
['case-insensitive name', { host: 'LOCALHOST:3080' }, 'allow'],
|
|
350
|
+
['absent host (host-side caller)', { host: undefined }, 'allow'],
|
|
351
|
+
['foreign host', { host: 'rebound.example' }, 'deny'],
|
|
352
|
+
['localhost subdomain', { host: 'api.localhost' }, 'deny'],
|
|
353
|
+
['evil localhost subdomain', { host: 'evil.localhost:3080' }, 'deny'],
|
|
354
|
+
['rebinding suffix', { host: '127.0.0.1.evil.example:3080' }, 'deny'],
|
|
355
|
+
['public ip host', { host: '203.0.113.5:3080' }, 'deny'],
|
|
356
|
+
// An unparseable Host must fail closed rather than skip the allowlist.
|
|
357
|
+
['unbracketed ipv6 host', { host: '::1:3080' }, 'deny'],
|
|
358
|
+
['unbracketed mapped host', { host: '::ffff:127.0.0.1:3080' }, 'deny'],
|
|
359
|
+
// Fetch Metadata.
|
|
360
|
+
['cross-site metadata', { host: '127.0.0.1:3080', site: 'cross-site' }, 'deny'],
|
|
361
|
+
['same-origin metadata', { host: '127.0.0.1:3080', site: 'same-origin' }, 'allow'],
|
|
362
|
+
['no metadata (non-browser)', { host: '127.0.0.1:3080', site: null }, 'allow'],
|
|
363
|
+
// Origin.
|
|
364
|
+
['matching origin', { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080' }, 'allow'],
|
|
365
|
+
['ipv6 origin', { host: '[::1]:3080', origin: 'http://[::1]:3080' }, 'allow'],
|
|
366
|
+
['mapped origin (hex, as URL normalises)', { host: '127.0.0.1:3080', origin: 'http://[::ffff:7f00:1]:3080' }, 'allow'],
|
|
367
|
+
['origin on another port', { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3081' }, 'deny'],
|
|
368
|
+
['foreign origin', { host: '127.0.0.1:3080', origin: 'https://evil.example' }, 'deny'],
|
|
369
|
+
['rebinding origin', { host: '127.0.0.1:3080', origin: 'http://127.0.0.1.evil.example:3080' }, 'deny'],
|
|
370
|
+
// Peer address.
|
|
371
|
+
['ipv6 loopback peer', { host: '127.0.0.1:3080', peer: '::1' }, 'allow'],
|
|
372
|
+
['mapped loopback peer', { host: '127.0.0.1:3080', peer: '::ffff:127.0.0.1' }, 'allow'],
|
|
373
|
+
['remote peer', { host: '127.0.0.1:3080', peer: '203.0.113.7' }, 'deny'],
|
|
374
|
+
['private-range peer', { host: '127.0.0.1:3080', peer: '10.0.0.5' }, 'deny'],
|
|
375
|
+
['missing peer', { host: '127.0.0.1:3080', peer: null }, 'deny'],
|
|
376
|
+
['blank peer', { host: '127.0.0.1:3080', peer: '' }, 'deny'],
|
|
377
|
+
// A remote caller that also spoofs a loopback Host is still remote.
|
|
378
|
+
['remote peer, loopback host', { host: '127.0.0.1:3080', peer: '203.0.113.7' }, 'deny']
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
let bad = 0
|
|
382
|
+
for (const [label, req, expected] of cases) {
|
|
383
|
+
const verdicts = repos.map(([repo, , factory]) => verdict(repo, factory, req))
|
|
384
|
+
const agrees = new Set(verdicts).size === 1
|
|
385
|
+
const got = verdicts[0]
|
|
386
|
+
if (!agrees) {
|
|
387
|
+
console.log(`FAIL ${label}: ${repos.map(([, tag], i) => `${tag}=${verdicts[i]}`).join(' ')}`)
|
|
388
|
+
bad++
|
|
389
|
+
} else if (got !== expected) {
|
|
390
|
+
console.log(`FAIL ${label}: every member answered ${got}, expected ${expected}`)
|
|
391
|
+
bad++
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (bad) failures += bad
|
|
395
|
+
else console.log(`ok decisions: ${cases.length} cases agree across ${tags}`)
|
|
396
|
+
|
|
397
|
+
// 5. Fleet mode is the one place the guard deliberately relaxes, and only the
|
|
398
|
+
// member named by `--fleet` opts in. Assert the relaxation stays bounded: a
|
|
399
|
+
// fleet guard still rejects a cross-site request, a foreign Origin, and a
|
|
400
|
+
// missing peer.
|
|
401
|
+
const fleetMember = members.find(([repo]) => repo === flagOf('--fleet')) || null
|
|
402
|
+
const fleetCases = [
|
|
403
|
+
['fleet admits a peer host', { host: 'box.lan:3080' }, 'allow'],
|
|
404
|
+
['fleet admits a remote peer', { host: '127.0.0.1:3080', peer: '203.0.113.7' }, 'allow'],
|
|
405
|
+
['fleet still rejects cross-site', { host: 'box.lan:3080', site: 'cross-site' }, 'deny'],
|
|
406
|
+
['fleet still rejects foreign Origin', { host: 'box.lan:3080', origin: 'https://evil.example' }, 'deny'],
|
|
407
|
+
['fleet still rejects a missing peer', { host: 'box.lan:3080', peer: null }, 'deny']
|
|
408
|
+
]
|
|
409
|
+
if (!fleetMember) {
|
|
410
|
+
console.log('ok fleet bounds: skipped (pass --fleet <repo> to check the member that opts in)')
|
|
411
|
+
} else {
|
|
412
|
+
const fleetVerdict = (req) => verdict(fleetMember[0], fleetMember[2], { ...req, fleet: true })
|
|
413
|
+
let fleetBad = 0
|
|
414
|
+
for (const [label, req, expected] of fleetCases) {
|
|
415
|
+
const got = fleetVerdict(req)
|
|
416
|
+
if (got !== expected) {
|
|
417
|
+
console.log(`FAIL ${label}: fleet guard answered ${got}, expected ${expected}`)
|
|
418
|
+
fleetBad++
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (fleetBad) failures += fleetBad
|
|
422
|
+
else console.log(`ok fleet bounds: ${fleetCases.length} cases hold`)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 6. The predicates themselves, called directly, across every repo.
|
|
426
|
+
const predicateCases = [
|
|
427
|
+
['isLoopbackName', ['127.0.0.1', true], ['localhost', true], ['::1', true],
|
|
428
|
+
['LOCALHOST', true], ['[::1]', false], ['::ffff:127.0.0.1', true],
|
|
429
|
+
['::ffff:7f00:1', true], ['::ffff:8.8.8.8', false], ['::ffff:127.0.0.2', false],
|
|
430
|
+
['127.0.0.2', false], ['127.0.0.1.evil.example', false], ['api.localhost', false],
|
|
431
|
+
['', false], [null, false]],
|
|
432
|
+
['isLoopbackAddress', ['127.0.0.1', true], ['127.255.0.1', true], ['::1', true],
|
|
433
|
+
['::ffff:127.0.0.1', true], ['::ffff:7f00:1', true], ['::ffff:127.0.0.2', true],
|
|
434
|
+
['::ffff:8.8.8.8', false], ['10.0.0.1', false], ['::2', false], ['localhost', false],
|
|
435
|
+
['127.0.0.1.evil.example', false], ['::ffff:999.1.1.1', false], ['', false],
|
|
436
|
+
[null, false], [' ', false]],
|
|
437
|
+
['hostHostname', ['127.0.0.1:3080', '127.0.0.1'], ['[::1]:3080', '::1'],
|
|
438
|
+
['[::ffff:127.0.0.1]:80', '::ffff:127.0.0.1'], ['REBOUND.EXAMPLE', 'rebound.example'],
|
|
439
|
+
[undefined, ''], ['localhost', 'localhost']]
|
|
440
|
+
]
|
|
441
|
+
for (const [fn, ...fnCases] of predicateCases) {
|
|
442
|
+
let fnBad = 0
|
|
443
|
+
for (const [input, expected] of fnCases) {
|
|
444
|
+
const answers = repos.map(([repo]) => mods[repo][fn](input))
|
|
445
|
+
if (new Set(answers).size !== 1) {
|
|
446
|
+
console.log(`FAIL ${fn}(${JSON.stringify(input)}): ${repos.map(([, tag], i) => `${tag}=${JSON.stringify(answers[i])}`).join(' ')}`)
|
|
447
|
+
fnBad++
|
|
448
|
+
} else if (answers[0] !== expected) {
|
|
449
|
+
console.log(`FAIL ${fn}(${JSON.stringify(input)}): every member answered ${JSON.stringify(answers[0])}, expected ${JSON.stringify(expected)}`)
|
|
450
|
+
fnBad++
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (fnBad) failures += fnBad
|
|
454
|
+
else console.log(`ok ${fn}: ${fnCases.length} cases agree across ${tags}`)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 7. portOf normalises the default ports, which is what stopped a same-origin
|
|
458
|
+
// request on 80/443 from reading as a foreign Origin.
|
|
459
|
+
{
|
|
460
|
+
let portBad = 0
|
|
461
|
+
const portCases = [
|
|
462
|
+
['http://127.0.0.1', '80'], ['https://127.0.0.1', '443'],
|
|
463
|
+
['http://127.0.0.1:3080', '3080'], ['https://[::1]:443', '443']
|
|
464
|
+
]
|
|
465
|
+
for (const [url, expected] of portCases) {
|
|
466
|
+
const answers = repos.map(([repo]) => mods[repo].portOf(new URL(url)))
|
|
467
|
+
if (new Set(answers).size !== 1 || answers[0] !== expected) {
|
|
468
|
+
console.log(`FAIL portOf(${url}): ${repos.map(([, tag], i) => `${tag}=${JSON.stringify(answers[i])}`).join(' ')} (expected ${expected})`)
|
|
469
|
+
portBad++
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (portBad) failures += portBad
|
|
473
|
+
else console.log(`ok portOf: ${portCases.length} cases agree across ${tags}`)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// 8. An unknown policy key is a typo, and a typo would silently leave the default
|
|
477
|
+
// in place, so the shared factory throws. A plugin that names its own policy
|
|
478
|
+
// must not swallow that — it should reach the caller.
|
|
479
|
+
{
|
|
480
|
+
let policyBad = 0
|
|
481
|
+
for (const [repo, tag, factory] of repos) {
|
|
482
|
+
let threw = false
|
|
483
|
+
try {
|
|
484
|
+
mods[repo][factory]({ currentPort: () => 3080, respond: () => {}, policy: { not_a_reason: { code: 'x' } } })
|
|
485
|
+
} catch {
|
|
486
|
+
threw = true
|
|
487
|
+
}
|
|
488
|
+
if (!threw) {
|
|
489
|
+
console.log(`FAIL ${tag}: an unknown policy key was accepted instead of throwing`)
|
|
490
|
+
policyBad++
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (policyBad) failures += policyBad
|
|
494
|
+
else console.log('ok policy validation: an unknown key is rejected by every plugin')
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
console.log(failures ? `\n${failures} drift problem(s)` : '\nno drift')
|
|
498
|
+
process.exit(failures ? 1 : 0)
|
package/dist/loopback.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// Why it is shared at all: these predicates were copy-pasted per repo and
|
|
13
13
|
// drifted twice. The first drift rejected IPv6 loopback everywhere; the second
|
|
14
14
|
// is that consumers disagreed on which Host spellings count as loopback
|
|
15
|
-
// (see
|
|
15
|
+
// (see the parity bin this package ships, which compares this block across repositories).
|
|
16
16
|
//
|
|
17
17
|
// "Loopback" is decided in exactly one place — LOOPBACK_HOSTNAMES plus the
|
|
18
18
|
// IPv4-mapped IPv6 form of each entry — and both the name and the address
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mini-utility-dock",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "The DSH plugin family's shared assets: source fragments (the Mini Utility Dock bootstrap for client halves, the loopback predicates and host request guard for host halves), the embedding CLI, and the family diagnostics — cross-repo drift check and bilingual docs structure check.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"dsh-mini-utility-dock": "bin/dsh-mini-utility-dock.js"
|
|
7
|
+
"dsh-mini-utility-dock": "bin/dsh-mini-utility-dock.js",
|
|
8
|
+
"dsh-plugin-parity": "bin/dsh-plugin-parity.js",
|
|
9
|
+
"dsh-plugin-docs": "bin/dsh-plugin-docs.js"
|
|
8
10
|
},
|
|
9
11
|
"files": [
|
|
10
12
|
"bin",
|
|
@@ -17,6 +19,7 @@
|
|
|
17
19
|
],
|
|
18
20
|
"scripts": {
|
|
19
21
|
"dock:embed": "node bin/dsh-mini-utility-dock.js",
|
|
22
|
+
"docs:check": "node bin/dsh-plugin-docs.js",
|
|
20
23
|
"test": "node --test test/*.test.js"
|
|
21
24
|
},
|
|
22
25
|
"engines": {
|