@bushizhanghan/dsh-publish-gate 0.13.0 → 0.14.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.md +16 -0
- package/README.md +5 -1
- package/lib/engine.js +115 -4
- package/lib/index.js +9 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
- New test layer: `checkTestRun` runs `node --test` in the plugin directory and reports `tests-fail`
|
|
6
|
+
(error), `tests-timeout` (warn) and `tests-not-run` (warn). Every other layer reads the paperwork —
|
|
7
|
+
the manifest, the tarball, the registry — while nine of my own plugins shipped
|
|
8
|
+
`"test": "node --test test/"`, which throws MODULE_NOT_FOUND on Node 22+ and would have been caught
|
|
9
|
+
by simply running it. The child drops the inherited NODE_TEST_CONTEXT, because a gate that runs
|
|
10
|
+
inside `node --test` would otherwise start the suite in test-child mode and see no summary at all.
|
|
11
|
+
|
|
12
|
+
## 0.13.1
|
|
13
|
+
|
|
14
|
+
- The tag check now asks `git ls-remote origin` instead of `git tag --points-at HEAD`. When the commit is pushed
|
|
15
|
+
through the GitHub API the local HEAD is not the remote one, so the local form reported `git-tag-missing` on
|
|
16
|
+
seven of my nine plugins whose tag existed all along. It now compares the tag against the shipped branch
|
|
17
|
+
head and stays silent when the remote cannot be reached — a check that cannot see the truth must not claim
|
|
18
|
+
a defect.
|
|
3
19
|
## 0.13.0
|
|
4
20
|
|
|
5
21
|
- `market-fork-of-listed` / `market-fork-undisclosed` (warn): the repo is a GitHub fork whose parent is
|
package/README.md
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
门禁分三层:**清单层**看 package.json / patch / 入口源码,**产物层**真的跑一遍 `npm pack --dry-run` 看 npm 到底会发出什么,**运行时层**在独立子进程里 `import()` 入口并 `apply(ctx)` 一遍,看它到底注册了什么。
|
|
10
10
|
|
|
11
|
+
### 测试层:门禁真的会跑一遍你的测试
|
|
12
|
+
|
|
13
|
+
`tests-fail` / `tests-timeout` / `tests-not-run` 三条规则会以 `node --test` 在插件目录里真跑一次(不经过 `npm test`,因为脚本内容由作者决定、可能 shell 出去)。子进程会去掉继承来的 `NODE_TEST_CONTEXT`——否则当门禁本身跑在 `node --test` 里时,子进程进入 test-child 模式、一个摘要都不打印,看起来像"没有测试"。
|
|
14
|
+
|
|
11
15
|
## 典型用法(写完插件后)
|
|
12
16
|
|
|
13
17
|
```
|
|
@@ -223,7 +227,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
223
227
|
## 开发
|
|
224
228
|
|
|
225
229
|
```sh
|
|
226
|
-
node --test #
|
|
230
|
+
node --test # 110 个引擎单测
|
|
227
231
|
node test/integration.test.mjs # 27 条集成断言(9 个真实插件作 fixture)
|
|
228
232
|
```
|
|
229
233
|
|
package/lib/engine.js
CHANGED
|
@@ -1044,7 +1044,70 @@ export function checkTests(dir) {
|
|
|
1044
1044
|
// while the tests themselves are fine. Auto-discovery needs no argument.
|
|
1045
1045
|
const testScript = pkgJson?.scripts?.test || ''
|
|
1046
1046
|
const dirArg = testScript.match(/\bnode\s+--test\s+(?!-)(\S+)/)
|
|
1047
|
-
if (dirArg) out.push(F('test-script-dir-arg',
|
|
1047
|
+
if (dirArg) out.push(F('test-script-dir-arg',
|
|
1048
|
+
'tests-fail',
|
|
1049
|
+
'tests-timeout',
|
|
1050
|
+
'tests-not-run', 'warn', `test script passes a path to node --test ("${dirArg[1]}")`, 'Node 22+ treats that argument as a module path, not a directory to glob: `node --test test/` throws MODULE_NOT_FOUND, so npm test can never pass even though the tests are fine.', 'Drop the path — "test": "node --test" auto-discovers test/**/*.test.mjs.'))
|
|
1051
|
+
return out
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* `checkTests` reads the test setup; this one runs it. The gap is not academic:
|
|
1056
|
+
* nine of my own plugins shipped `"test": "node --test test/"`, which dies with
|
|
1057
|
+
* MODULE_NOT_FOUND on Node 22+, and no rule noticed because none of them ever
|
|
1058
|
+
* executed anything. A gate that does not run the suite reports on paperwork.
|
|
1059
|
+
*
|
|
1060
|
+
* Runs `node --test` directly instead of the package script: the script is
|
|
1061
|
+
* whatever the author typed (and may shell out to a runner that is not
|
|
1062
|
+
* installed), while auto-discovery is the convention the ecosystem settled on.
|
|
1063
|
+
*/
|
|
1064
|
+
export async function checkTestRun(dir, opts = {}) {
|
|
1065
|
+
const out = []
|
|
1066
|
+
if (!existsSync(join(dir, 'test'))) return out // checkTests owns no-tests
|
|
1067
|
+
const timeoutMs = Math.max(1000, Number(opts.timeoutMs) || 180_000)
|
|
1068
|
+
const started = Date.now()
|
|
1069
|
+
let stdout = ''
|
|
1070
|
+
let code = 0
|
|
1071
|
+
let timedOut = false
|
|
1072
|
+
// NODE_TEST_CONTEXT is inherited from whoever called us: when this gate runs
|
|
1073
|
+
// inside `node --test` itself, the child starts in test-child mode and prints
|
|
1074
|
+
// no summary at all, which reads as "no tests" — a false alarm.
|
|
1075
|
+
const env = { ...process.env }
|
|
1076
|
+
delete env.NODE_TEST_CONTEXT
|
|
1077
|
+
try {
|
|
1078
|
+
const r = await runFile(process.execPath, ['--test'], { cwd: dir, env, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, windowsHide: true })
|
|
1079
|
+
stdout = String(r.stdout || '')
|
|
1080
|
+
} catch (error) {
|
|
1081
|
+
stdout = String(error.stdout || '')
|
|
1082
|
+
timedOut = error.killed === true || /timed out/i.test(String(error.message || ''))
|
|
1083
|
+
code = timedOut ? 1 : typeof error.code === 'number' ? error.code : 1
|
|
1084
|
+
}
|
|
1085
|
+
const durationMs = Date.now() - started
|
|
1086
|
+
// Node prints the summary as `ℹ pass 12` (spec reporter) or `# pass 12` (tap).
|
|
1087
|
+
const count = (label) => {
|
|
1088
|
+
const m = new RegExp(`(?:^|\\n)\\s*(?:#|ℹ|i)\\s*${label}\\s+(\\d+)`).exec(stdout)
|
|
1089
|
+
return m ? Number(m[1]) : null
|
|
1090
|
+
}
|
|
1091
|
+
const pass = count('pass')
|
|
1092
|
+
const fail = count('fail')
|
|
1093
|
+
const tests = { pass, fail, durationMs, exitCode: code }
|
|
1094
|
+
const failures = stdout
|
|
1095
|
+
.split('\n')
|
|
1096
|
+
.filter((line) => /^\s*(?:✖|not ok)\s/.test(line))
|
|
1097
|
+
.slice(0, 3)
|
|
1098
|
+
.map((line) => line.trim().slice(0, 160))
|
|
1099
|
+
if (timedOut) {
|
|
1100
|
+
out.push(F('tests-timeout', 'warn', `the suite did not finish in ${Math.round(timeoutMs / 1000)} s`, 'A suite that never finishes blocks every publish and every CI run; the gate gave up waiting.', 'Speed up or split the slow test, or raise Config.testTimeoutMs.'))
|
|
1101
|
+
return out
|
|
1102
|
+
}
|
|
1103
|
+
if (code !== 0 || (fail || 0) > 0) {
|
|
1104
|
+
const shown = failures.length ? ` — ${failures.join(' ; ')}` : ''
|
|
1105
|
+
out.push(F('tests-fail', 'error', `${fail ?? 'some'} test(s) failed in ${Math.round(durationMs / 1000)} s${shown}`, 'The published package is exactly the code whose own tests do not pass; a consumer finds out instead of you.', 'Run `node --test` and fix the failures before publishing.'))
|
|
1106
|
+
return out
|
|
1107
|
+
}
|
|
1108
|
+
if (pass === null && fail === null) {
|
|
1109
|
+
out.push(F('tests-not-run', 'warn', 'node --test exited 0 without reporting a single test', 'Either test/ holds no file node recognises, or the suite is skipped behind a condition — publishing then relies on a green light nobody earned.', 'Check that `node --test` really runs the files you expect.'))
|
|
1110
|
+
}
|
|
1048
1111
|
return out
|
|
1049
1112
|
}
|
|
1050
1113
|
|
|
@@ -2303,11 +2366,43 @@ export async function checkTraceability(dir, opts = {}) {
|
|
|
2303
2366
|
}
|
|
2304
2367
|
}
|
|
2305
2368
|
|
|
2306
|
-
// 2.
|
|
2369
|
+
// 2. A tag on the bytes that actually shipped. When the commit was pushed
|
|
2370
|
+
// through the GitHub API the local HEAD is not the remote one, so a local
|
|
2371
|
+
// `git tag --points-at HEAD` reports a missing tag that exists — ask the
|
|
2372
|
+
// remote instead, and say nothing when it cannot be reached.
|
|
2373
|
+
const remote = opts.remote === undefined ? await readRemoteRefs(dir) : opts.remote
|
|
2307
2374
|
const atHead = opts.atHead === undefined ? await gitOut(dir, ['tag', '--points-at', 'HEAD']) : opts.atHead
|
|
2308
|
-
const
|
|
2375
|
+
const localTags = typeof atHead === 'string' ? atHead.split('\n').map((t) => t.trim()).filter(Boolean) : []
|
|
2309
2376
|
const wanted = [`v${version}`, version]
|
|
2310
|
-
|
|
2377
|
+
const tagOnRemote = remote ? remote.tags[`v${version}`] || remote.tags[version] || null : null
|
|
2378
|
+
const tagIsOnShipped = Boolean(remote && tagOnRemote && tagOnRemote === remote.head)
|
|
2379
|
+
const localHasIt = wanted.some((t) => localTags.includes(t))
|
|
2380
|
+
|
|
2381
|
+
if (remote) {
|
|
2382
|
+
// The remote is the truth: the tag either points at the shipped commit or not.
|
|
2383
|
+
if (tagIsOnShipped) return out
|
|
2384
|
+
const exists = Boolean(tagOnRemote) || (opts.tagExists !== undefined ? Boolean(opts.tagExists) : Boolean(await gitOut(dir, ['rev-parse', '--verify', '--quiet', `refs/tags/v${version}`])))
|
|
2385
|
+
out.push(
|
|
2386
|
+
exists
|
|
2387
|
+
? F(
|
|
2388
|
+
'git-tag-elsewhere',
|
|
2389
|
+
'warn',
|
|
2390
|
+
`tag v${version} does not point at the shipped commit`,
|
|
2391
|
+
`The tag is on ${String(tagOnRemote || 'another commit').slice(0, 12)} but the branch head is ${String(remote.head).slice(0, 12)}. A release built from that tag is not what you just published.`,
|
|
2392
|
+
`Move it: git tag -f v${version} <shipped-sha> && git push -f origin v${version}`,
|
|
2393
|
+
)
|
|
2394
|
+
: F(
|
|
2395
|
+
'git-tag-missing',
|
|
2396
|
+
'warn',
|
|
2397
|
+
`no git tag v${version} on the shipped commit`,
|
|
2398
|
+
'After publishing, the only record of these bytes is the registry. A GitHub release, a changelog entry or a market listing cannot point at a commit that was never tagged.',
|
|
2399
|
+
`git tag v${version} && git push origin v${version}`,
|
|
2400
|
+
),
|
|
2401
|
+
)
|
|
2402
|
+
return out
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
if (typeof atHead === 'string' && !localHasIt) {
|
|
2311
2406
|
const exists = opts.tagExists === undefined ? await gitOut(dir, ['rev-parse', '--verify', '--quiet', `refs/tags/v${version}`]) : opts.tagExists ? 'sha' : null
|
|
2312
2407
|
out.push(
|
|
2313
2408
|
exists
|
|
@@ -2330,6 +2425,22 @@ export async function checkTraceability(dir, opts = {}) {
|
|
|
2330
2425
|
return out
|
|
2331
2426
|
}
|
|
2332
2427
|
|
|
2428
|
+
/** `{ head, tags }` from one `git ls-remote`, or null when it cannot be read. */
|
|
2429
|
+
async function readRemoteRefs(dir) {
|
|
2430
|
+
const raw = await gitOut(dir, ['ls-remote', '--heads', '--tags', 'origin'])
|
|
2431
|
+
if (typeof raw !== 'string' || raw.length === 0) return null
|
|
2432
|
+
const tags = {}
|
|
2433
|
+
let head = null
|
|
2434
|
+
for (const line of raw.split('\n')) {
|
|
2435
|
+
const [sha, ref] = line.split('\t')
|
|
2436
|
+
if (!sha || !ref) continue
|
|
2437
|
+
if (ref === 'refs/heads/main' || ref === 'refs/heads/master') head = sha
|
|
2438
|
+
const m = /^refs\/tags\/(.+?)(?:\^\{\})?$/.exec(ref)
|
|
2439
|
+
if (m) tags[m[1]] = sha // a dereferenced annotated tag overwrites the tag object, which is what we want
|
|
2440
|
+
}
|
|
2441
|
+
return head ? { head, tags } : null
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2333
2444
|
// =====================================================================
|
|
2334
2445
|
// Orchestration
|
|
2335
2446
|
// =====================================================================
|
package/lib/index.js
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
compareVersions,
|
|
29
29
|
latestPublished,
|
|
30
30
|
renderReport,
|
|
31
|
-
runArtifactChecks,
|
|
31
|
+
runArtifactChecks,
|
|
32
|
+
checkTestRun,
|
|
32
33
|
runStaticChecks,
|
|
33
34
|
} from './engine.js'
|
|
34
35
|
|
|
@@ -94,7 +95,7 @@ export function apply(ctx, config = {}) {
|
|
|
94
95
|
{
|
|
95
96
|
name: 'publish_gate',
|
|
96
97
|
description:
|
|
97
|
-
'Run the full pre-publish gate over a DSH plugin directory. Four layers. (1) Manifest: missing exports["."], files omitting lib/, no "type": "module", UTF-8 BOM, JSON/YAML parse errors, cordis.patch.yml insert rows at the wrong indent (the bug that makes a plugin install and never mount), insert name mismatches, missing entry exports, an entry path that points at no file, a cordis.patch.yml the manifest never declares (inert), missing LICENSE or README install line, missing tests, unscoped npm names (the 403 you cannot diagnose). (2) Dependencies: every bare import in lib/ resolved against package.json — an import nothing declares, an import only devDependencies satisfy (npm never installs those for whoever installs you), an unused dependency. (3) Artifact: runs `npm pack --dry-run` and reads what npm will actually ship — lib/ missing from the tarball, a dsh.bundle.patch that never gets packed, oversize tarballs (test fixtures are the usual cause), dev directories shipping, and a prepack hook that means the dry run did not build. (4) Runtime: imports the entry in an isolated child process, calls apply(ctx) with a fake ctx, and reports a throwing import, a throwing apply, tools with no execute, duplicate tool names, a presentCall that throws, and card kinds the host does not render; then, if the package declares exports["./client"], loads the browser bundle against a fake window.__ModuleLoader__ and reports a bundle that never loads, never returns apply, throws, or registers nothing. Deterministic: every rule is a file, subprocess or API check, no LLM. Returns {ok, report, findings:[{rule,severity,title,detail,fix}], counts}.',
|
|
98
|
+
'Run the full pre-publish gate over a DSH plugin directory. Four layers. (1) Manifest: missing exports["."], files omitting lib/, no "type": "module", UTF-8 BOM, JSON/YAML parse errors, cordis.patch.yml insert rows at the wrong indent (the bug that makes a plugin install and never mount), insert name mismatches, missing entry exports, an entry path that points at no file, a cordis.patch.yml the manifest never declares (inert), missing LICENSE or README install line, missing tests, unscoped npm names (the 403 you cannot diagnose). (2) Dependencies: every bare import in lib/ resolved against package.json — an import nothing declares, an import only devDependencies satisfy (npm never installs those for whoever installs you), an unused dependency. (3) Artifact: runs `npm pack --dry-run` and reads what npm will actually ship — lib/ missing from the tarball, a dsh.bundle.patch that never gets packed, oversize tarballs (test fixtures are the usual cause), dev directories shipping, and a prepack hook that means the dry run did not build. (5) Tests: runs `node --test` in the plugin directory and reports a suite that fails, hangs, or exits 0 without running anything — the only layer that reads the code instead of the paperwork. (4) Runtime: imports the entry in an isolated child process, calls apply(ctx) with a fake ctx, and reports a throwing import, a throwing apply, tools with no execute, duplicate tool names, a presentCall that throws, and card kinds the host does not render; then, if the package declares exports["./client"], loads the browser bundle against a fake window.__ModuleLoader__ and reports a bundle that never loads, never returns apply, throws, or registers nothing. Deterministic: every rule is a file, subprocess or API check, no LLM. Returns {ok, report, findings:[{rule,severity,title,detail,fix}], counts}.',
|
|
98
99
|
parameters: {
|
|
99
100
|
type: 'object',
|
|
100
101
|
properties: {
|
|
@@ -102,7 +103,8 @@ export function apply(ctx, config = {}) {
|
|
|
102
103
|
skipNetwork: { type: 'boolean', description: 'Skip the npm registry and GitHub checks (default false).' },
|
|
103
104
|
skipPack: { type: 'boolean', description: 'Skip `npm pack --dry-run` (default false). Faster, but says nothing about the real tarball.' },
|
|
104
105
|
skipTools: { type: 'boolean', description: 'Skip the isolated import of the plugin entry (default false).' },
|
|
105
|
-
skipClient: { type: 'boolean', description: 'Skip the isolated load of the browser bundle (default false).' },
|
|
106
|
+
skipClient: { type: 'boolean', description: 'Skip the isolated load of the browser bundle (default false).' },
|
|
107
|
+
skipTests: { type: 'boolean', description: 'Skip running the test suite (default false). The suite is the only check that reads the code rather than the paperwork.' },
|
|
106
108
|
},
|
|
107
109
|
},
|
|
108
110
|
async execute(args) {
|
|
@@ -111,6 +113,8 @@ export function apply(ctx, config = {}) {
|
|
|
111
113
|
const findings = runStaticChecks(dir)
|
|
112
114
|
const artifact = await runArtifactChecks(dir, { skipPack: args.skipPack, skipTools: args.skipTools, skipClient: args.skipClient })
|
|
113
115
|
findings.push(...artifact.findings)
|
|
116
|
+
const tests = args.skipTests ? null : await checkTestRun(dir, { timeoutMs: cfg.testTimeoutMs })
|
|
117
|
+
if (tests) findings.push(...tests)
|
|
114
118
|
if (!args.skipNetwork) findings.push(...(await networkGate(dir)))
|
|
115
119
|
const counts = {
|
|
116
120
|
error: findings.filter((f) => f.severity === 'error').length,
|
|
@@ -124,6 +128,7 @@ export function apply(ctx, config = {}) {
|
|
|
124
128
|
findings,
|
|
125
129
|
tools: artifact.tools,
|
|
126
130
|
client: artifact.client,
|
|
131
|
+
tests: args.skipTests ? null : findings.filter((f) => f.rule.startsWith("tests-")),
|
|
127
132
|
unpackedSize: artifact.pack?.unpackedSize ?? null,
|
|
128
133
|
report: renderReport(findings, { title: `publish gate · ${dir}` }),
|
|
129
134
|
safeToPublish: counts.error === 0,
|
|
@@ -454,7 +459,7 @@ export function apply(ctx, config = {}) {
|
|
|
454
459
|
description: t.description,
|
|
455
460
|
parameters: t.parameters,
|
|
456
461
|
output: { schema: OUTPUT_SCHEMA, render: outRender },
|
|
457
|
-
timeoutMs:
|
|
462
|
+
timeoutMs: 240_000,
|
|
458
463
|
isConcurrencySafe: () => false,
|
|
459
464
|
presentCall: (args) => ({ card: 'generic', title: t.name, kind: 'read', rawInput: args }),
|
|
460
465
|
execute: t.execute,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bushizhanghan/dsh-publish-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Pre-publish gate for DSH plugins: manifest, dependencies, artifact, runtime, browser half, traceability and market readiness — plus an input fuzzer that drives every declared parameter with the wrong type. 发布前的七层门禁 + 入参模糊测试。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|