@bushizhanghan/dsh-publish-gate 0.13.1 → 0.15.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0
4
+
5
+ - Three rules shipped with a *rule name* in their severity slot: `market-repo-too-new`,
6
+ `test-script-dir-arg` and `tool-missing-output-schema` were built as
7
+ `F(rule, "some-other-rule", "a-third-rule", "warn", title, ...)`, because a string replace aimed at
8
+ RULE_ORDER matched an earlier `F(...)` call instead and pushed the new names into its arguments.
9
+ Nothing failed: a finding whose severity is not error/warn/info falls out of every counter and never
10
+ reaches the report, so the rules looked like they simply never fired. Fixed all three, added the six
11
+ rule names that never made it into RULE_ORDER, and added a source scan that fails when any `F(...)`
12
+ call passes something other than an error/warn/info literal (or a ternary of two of them).
13
+
14
+ ## 0.14.0
15
+
16
+ - New test layer: `checkTestRun` runs `node --test` in the plugin directory and reports `tests-fail`
17
+ (error), `tests-timeout` (warn) and `tests-not-run` (warn). Every other layer reads the paperwork —
18
+ the manifest, the tarball, the registry — while nine of my own plugins shipped
19
+ `"test": "node --test test/"`, which throws MODULE_NOT_FOUND on Node 22+ and would have been caught
20
+ by simply running it. The child drops the inherited NODE_TEST_CONTEXT, because a gate that runs
21
+ inside `node --test` would otherwise start the suite in test-child mode and see no summary at all.
22
+
3
23
  ## 0.13.1
4
24
 
5
25
  - The tag check now asks `git ls-remote origin` instead of `git tag --points-at HEAD`. When the commit is pushed
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 # 108 个引擎单测
230
+ node --test # 113 个引擎单测
227
231
  node test/integration.test.mjs # 27 条集成断言(9 个真实插件作 fixture)
228
232
  ```
229
233
 
package/lib/engine.js CHANGED
@@ -931,7 +931,7 @@ export async function checkMarketReadiness(dir, opts = {}) {
931
931
  if (hours < 24) {
932
932
  out.push(
933
933
  F(
934
- 'market-repo-too-new', 'market-fork-of-listed', 'market-fork-undisclosed',
934
+ 'market-repo-too-new',
935
935
  'warn',
936
936
  `the repo is ${hours.toFixed(1)}h old; the submission gate needs 24h`,
937
937
  'The gate fails with a "repo too new" message and reruns on its own, so the PR is not lost — but nothing merges until it passes.',
@@ -1048,6 +1048,66 @@ export function checkTests(dir) {
1048
1048
  return out
1049
1049
  }
1050
1050
 
1051
+ /**
1052
+ * `checkTests` reads the test setup; this one runs it. The gap is not academic:
1053
+ * nine of my own plugins shipped `"test": "node --test test/"`, which dies with
1054
+ * MODULE_NOT_FOUND on Node 22+, and no rule noticed because none of them ever
1055
+ * executed anything. A gate that does not run the suite reports on paperwork.
1056
+ *
1057
+ * Runs `node --test` directly instead of the package script: the script is
1058
+ * whatever the author typed (and may shell out to a runner that is not
1059
+ * installed), while auto-discovery is the convention the ecosystem settled on.
1060
+ */
1061
+ export async function checkTestRun(dir, opts = {}) {
1062
+ const out = []
1063
+ if (!existsSync(join(dir, 'test'))) return out // checkTests owns no-tests
1064
+ const timeoutMs = Math.max(1000, Number(opts.timeoutMs) || 180_000)
1065
+ const started = Date.now()
1066
+ let stdout = ''
1067
+ let code = 0
1068
+ let timedOut = false
1069
+ // NODE_TEST_CONTEXT is inherited from whoever called us: when this gate runs
1070
+ // inside `node --test` itself, the child starts in test-child mode and prints
1071
+ // no summary at all, which reads as "no tests" — a false alarm.
1072
+ const env = { ...process.env }
1073
+ delete env.NODE_TEST_CONTEXT
1074
+ try {
1075
+ const r = await runFile(process.execPath, ['--test'], { cwd: dir, env, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, windowsHide: true })
1076
+ stdout = String(r.stdout || '')
1077
+ } catch (error) {
1078
+ stdout = String(error.stdout || '')
1079
+ timedOut = error.killed === true || /timed out/i.test(String(error.message || ''))
1080
+ code = timedOut ? 1 : typeof error.code === 'number' ? error.code : 1
1081
+ }
1082
+ const durationMs = Date.now() - started
1083
+ // Node prints the summary as `ℹ pass 12` (spec reporter) or `# pass 12` (tap).
1084
+ const count = (label) => {
1085
+ const m = new RegExp(`(?:^|\\n)\\s*(?:#|ℹ|i)\\s*${label}\\s+(\\d+)`).exec(stdout)
1086
+ return m ? Number(m[1]) : null
1087
+ }
1088
+ const pass = count('pass')
1089
+ const fail = count('fail')
1090
+ const tests = { pass, fail, durationMs, exitCode: code }
1091
+ const failures = stdout
1092
+ .split('\n')
1093
+ .filter((line) => /^\s*(?:✖|not ok)\s/.test(line))
1094
+ .slice(0, 3)
1095
+ .map((line) => line.trim().slice(0, 160))
1096
+ if (timedOut) {
1097
+ 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.'))
1098
+ return out
1099
+ }
1100
+ if (code !== 0 || (fail || 0) > 0) {
1101
+ const shown = failures.length ? ` — ${failures.join(' ; ')}` : ''
1102
+ 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.'))
1103
+ return out
1104
+ }
1105
+ if (pass === null && fail === null) {
1106
+ 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.'))
1107
+ }
1108
+ return out
1109
+ }
1110
+
1051
1111
  // =====================================================================
1052
1112
  // Artifact truth: what npm will actually put in the tarball
1053
1113
  // =====================================================================
@@ -1571,7 +1631,7 @@ export async function checkToolRegistrations(dir, opts = {}) {
1571
1631
  ),
1572
1632
  )
1573
1633
  }
1574
- if (!t.hasOutputSchema) out.push(F('tool-missing-output-schema', 'tool-no-description', 'tool-description-thin', 'tool-no-parameters', 'warn', `tool "${label}" declares no output.schema`, 'The host validates tool output against output.schema; without one the contract is implicit and a malformed result reaches the caller.', "Add output: { schema: { type: 'json' }, render }."))
1634
+ if (!t.hasOutputSchema) out.push(F('tool-missing-output-schema', 'warn', `tool "${label}" declares no output.schema`, 'The host validates tool output against output.schema; without one the contract is implicit and a malformed result reaches the caller.', "Add output: { schema: { type: 'json' }, render }."))
1575
1635
  if (t.cardKind && typeof t.cardKind === 'object') {
1576
1636
  out.push(F('tool-present-call-throws', 'error', `tool "${label}" presentCall() throws`, `presentCall renders the transcript card and must be total: ${t.cardKind.threw}`, 'Do not read args fields that may be absent.'))
1577
1637
  } else if (typeof t.cardKind === 'string' && !CARD_KINDS.has(t.cardKind)) {
@@ -2408,12 +2468,12 @@ const RULE_ORDER = [
2408
2468
  'client-factory-throws', 'client-no-apply', 'client-apply-throws', 'client-no-registration', 'client-double-load',
2409
2469
  'client-inject-missing-slots', 'client-inject-missing-sidebarRightTabs', 'client-unavailable',
2410
2470
  'no-patch-file', 'no-insert-row', 'insert-missing-name', 'insert-name-mismatch',
2411
- 'patch-name-unresolvable', 'no-name-export', 'no-apply-export', 'no-inject', 'require-in-esm',
2471
+ 'patch-name-unresolvable', 'tests-fail', 'tests-timeout', 'tests-not-run', 'no-name-export', 'no-apply-export', 'no-inject', 'require-in-esm',
2412
2472
  'entry-import-failed', 'apply-threw', 'tools-unavailable',
2413
2473
  'inject-missing-tools', 'inject-missing-web', 'inject-missing-webServer', 'inject-missing-timer',
2414
2474
  'inject-unused-service',
2415
2475
  'tool-missing-name', 'tool-duplicate-name', 'tool-missing-execute', 'tool-present-call-throws',
2416
- 'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type', 'tool-undeclared-param', 'tool-input-throws', 'tool-call-throws', 'tool-output-invalid', 'tool-call-slow',
2476
+ 'tool-bad-card-kind', 'tool-no-description', 'tool-description-thin', 'tool-no-parameters', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type', 'tool-undeclared-param', 'tool-input-throws', 'tool-call-throws', 'tool-output-invalid', 'tool-call-slow',
2417
2477
  'lib-debugger', 'lib-console-log', 'lib-hardcoded-path', 'lib-url-pathname', 'node-too-old', 'stale-build', 'hand-edited-build',
2418
2478
  'undeclared-import', 'dep-in-dev-only', 'unused-dependency',
2419
2479
  'pack-omits-lib', 'pack-omits-patch', 'pack-omits-client', 'pack-oversize', 'pack-omits-readme', 'pack-omits-license', 'pack-ships-secret',
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.1",
3
+ "version": "0.15.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",