@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 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 # 107 个引擎单测
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', '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.'))
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. No tag on HEAD: nothing points at these bytes afterwards.
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 tags = typeof atHead === 'string' ? atHead.split('\n').map((t) => t.trim()).filter(Boolean) : []
2375
+ const localTags = typeof atHead === 'string' ? atHead.split('\n').map((t) => t.trim()).filter(Boolean) : []
2309
2376
  const wanted = [`v${version}`, version]
2310
- if (typeof atHead === 'string' && !wanted.some((t) => tags.includes(t))) {
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: 120_000,
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.13.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",