dsh-tower 2.1.0 → 2.4.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.
Files changed (203) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +78 -5
  4. package/PRINCIPLES.md +163 -0
  5. package/README.en.md +35 -19
  6. package/README.md +28 -18
  7. package/SECURITY.md +6 -0
  8. package/{lib → adapters}/archive.js +1 -1
  9. package/{lib → adapters}/caseSensitivity.js +1 -1
  10. package/adapters/default.js +51 -10
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +1 -1
  13. package/{lib → adapters}/legacy.js +1 -1
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/store-fs.js +28 -1
  16. package/adapters/store.js +204 -0
  17. package/{lib → adapters}/tests.js +9 -5
  18. package/adapters/vcs-git.js +15 -1
  19. package/{lib → adapters}/workspaceLock.js +1 -1
  20. package/{lib → adapters}/worktreeOwnership.js +1 -1
  21. package/app/archive.js +4 -6
  22. package/app/batch.js +9 -10
  23. package/app/board.js +2 -4
  24. package/app/checkCompletion.js +9 -10
  25. package/app/enrichedMissions.js +2 -4
  26. package/app/findings.js +6 -7
  27. package/app/gate.js +1 -2
  28. package/app/init.js +24 -29
  29. package/app/merge.js +58 -51
  30. package/app/messages.js +7 -8
  31. package/app/mission.js +5 -5
  32. package/app/plan.js +8 -5
  33. package/app/provision.js +12 -14
  34. package/app/read.js +6 -3
  35. package/app/rebase.js +14 -16
  36. package/app/recording.js +7 -8
  37. package/app/review.js +15 -8
  38. package/app/roster.js +14 -15
  39. package/{lib → app}/spawn-prechecks.js +4 -3
  40. package/app/spawn.js +12 -13
  41. package/app/teardown.js +8 -9
  42. package/app/tower.js +89 -35
  43. package/cordis.patch.yml +56 -28
  44. package/docs/README.md +7 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +70 -0
  49. package/docs/adr/0005-component-surface.md +130 -0
  50. package/docs/adr/README.md +52 -0
  51. package/docs/agents/workflow.md +51 -0
  52. package/docs/architecture/overview.md +192 -43
  53. package/docs/guide/hot-reload.md +5 -5
  54. package/docs/guide/install-upgrade-migration.md +13 -0
  55. package/docs/guide/release.md +1 -1
  56. package/docs/operations/incident.md +82 -0
  57. package/docs/operations/runbook.md +12 -3
  58. package/docs/operations/security.md +1 -1
  59. package/docs/reference/blockers.md +2 -2
  60. package/docs/reference/component-surface.md +135 -0
  61. package/docs/reference/config.generated.md +2 -9
  62. package/docs/reference/config.md +11 -9
  63. package/docs/reference/doctor.md +53 -2
  64. package/docs/reference/errors.generated.md +41 -37
  65. package/docs/reference/errors.md +53 -41
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +157 -0
  68. package/docs/reference/tools.generated.md +8 -5
  69. package/docs/reference/tools.md +10 -7
  70. package/docs/zh/README.md +1 -1
  71. package/docs/zh/guide/hot-reload.md +4 -4
  72. package/docs/zh/guide/install-upgrade-migration.md +10 -0
  73. package/docs/zh/operations/security.md +1 -1
  74. package/docs/zh/reference/blockers.md +2 -2
  75. package/docs/zh/reference/config.md +9 -9
  76. package/docs/zh/reference/doctor.md +29 -1
  77. package/docs/zh/reference/tools.md +10 -8
  78. package/domain/batch.js +2 -2
  79. package/domain/components.js +641 -0
  80. package/domain/config/schema.js +62 -28
  81. package/domain/contracts/outputs.js +16 -2
  82. package/domain/contracts/tool-inputs/ops.js +1 -0
  83. package/domain/errors.js +308 -43
  84. package/domain/events.js +207 -0
  85. package/domain/integration/gate.js +17 -2
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +12 -1
  89. package/domain/missions/completion.js +14 -0
  90. package/domain/missions/lifecycle.js +9 -3
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/read/views.js +3 -0
  94. package/domain/state/archive.js +1 -1
  95. package/domain/state.js +1 -1
  96. package/domain/surface.js +347 -0
  97. package/host/capability-batch.js +15 -0
  98. package/host/capability-hmr.js +30 -0
  99. package/host/capability-import.js +11 -0
  100. package/host/capability-leases.js +11 -0
  101. package/host/capability-registry.js +149 -0
  102. package/host/capability-tests.js +16 -0
  103. package/{lib → host}/doctor.js +157 -18
  104. package/{lib → host}/hmr.js +2 -4
  105. package/{lib → host}/modeStore.js +1 -1
  106. package/host/presets.js +77 -0
  107. package/{lib → host}/tool-registry.js +235 -44
  108. package/host/tower.js +32 -0
  109. package/index.js +40 -25
  110. package/locale/en.json +31 -11
  111. package/locale/zh.json +30 -10
  112. package/package.json +24 -4
  113. package/ports/archive.js +18 -0
  114. package/ports/caseSensitivity.js +15 -0
  115. package/ports/clock.js +10 -2
  116. package/ports/config.js +19 -0
  117. package/ports/contract.js +38 -0
  118. package/ports/events.js +24 -0
  119. package/ports/ids.js +10 -2
  120. package/ports/index.js +54 -2
  121. package/ports/ownership.js +17 -0
  122. package/ports/state.js +16 -0
  123. package/ports/store.js +52 -3
  124. package/ports/vcs.js +25 -4
  125. package/scripts/architecture-waivers.json +19 -0
  126. package/scripts/architecture.mjs +634 -23
  127. package/scripts/check-components.mjs +235 -0
  128. package/scripts/check-docs.mjs +8 -35
  129. package/scripts/check-pack.mjs +68 -39
  130. package/scripts/check-philosophy.mjs +266 -0
  131. package/scripts/component-surface.json +395 -0
  132. package/scripts/domain-metrics.mjs +13 -4
  133. package/scripts/generate-component-surface.mjs +59 -0
  134. package/scripts/generate-config-reference.mjs +14 -1
  135. package/scripts/generate-error-reference.mjs +68 -14
  136. package/scripts/generate-events-reference.mjs +83 -0
  137. package/scripts/generate-tools-reference.mjs +18 -25
  138. package/scripts/generate-types.mjs +127 -0
  139. package/scripts/issue-audit.mjs +2 -2
  140. package/scripts/lib/surface.mjs +87 -0
  141. package/scripts/lib/tool-surface.mjs +85 -0
  142. package/scripts/mount-smoke/fixtures.mjs +3 -3
  143. package/scripts/mount-smoke/surface.mjs +96 -27
  144. package/scripts/mount-smoke.mjs +3 -1
  145. package/scripts/pack-manifest.txt +75 -22
  146. package/scripts/regression-audit.mjs +164 -0
  147. package/scripts/release-check.mjs +1 -1
  148. package/scripts/surface-baseline.json +52 -0
  149. package/scripts/surface-baseline.mjs +118 -0
  150. package/scripts/types-coverage.mjs +184 -0
  151. package/scripts/types-strict-manifest.txt +10 -0
  152. package/scripts/types-strict.mjs +141 -0
  153. package/skills.js +8 -1
  154. package/tools/adopt.js +2 -3
  155. package/tools/batch.js +2 -3
  156. package/tools/complete.js +2 -3
  157. package/tools/diff.js +3 -3
  158. package/tools/doctor.js +7 -10
  159. package/tools/finding.js +2 -3
  160. package/tools/findings.js +3 -3
  161. package/tools/import-legacy.js +3 -5
  162. package/tools/inbox.js +3 -3
  163. package/tools/index.js +9 -8
  164. package/tools/init.js +3 -6
  165. package/tools/interrupt.js +2 -3
  166. package/tools/legacy-map.js +3 -5
  167. package/tools/merge.js +3 -3
  168. package/tools/mission.js +2 -3
  169. package/tools/plan.js +6 -5
  170. package/tools/preflight.js +2 -3
  171. package/tools/rebase.js +3 -3
  172. package/tools/recover.js +38 -13
  173. package/tools/reload.js +36 -40
  174. package/tools/resource.js +3 -5
  175. package/tools/review.js +3 -4
  176. package/tools/roster.js +2 -3
  177. package/tools/send.js +2 -2
  178. package/tools/spawn.js +8 -5
  179. package/tools/status.js +11 -4
  180. package/tools/teardown.js +2 -3
  181. package/tools/test.js +2 -2
  182. package/types/index.d.ts +29 -13
  183. package/ui-bundle/README.md +37 -3
  184. package/ui-bundle/client.cards.js +356 -0
  185. package/ui-bundle/client.denied.js +142 -0
  186. package/ui-bundle/client.dock.js +333 -0
  187. package/ui-bundle/client.helpers.js +271 -0
  188. package/ui-bundle/client.js +79 -1747
  189. package/ui-bundle/client.locales.js +247 -0
  190. package/ui-bundle/client.mode.js +206 -0
  191. package/ui-bundle/client.overlay.js +64 -0
  192. package/ui-bundle/client.styles.js +151 -0
  193. package/ui-bundle/client.toolview.js +82 -0
  194. package/ui-bundle/package.json +1 -0
  195. package/lib/persistence.js +0 -356
  196. package/lib/presets.js +0 -35
  197. package/lib/store.js +0 -133
  198. /package/{lib → adapters}/stateFile.js +0 -0
  199. /package/{lib → app}/briefing.js +0 -0
  200. /package/{lib → app}/spawn-support.js +0 -0
  201. /package/{lib → host}/compatibility.js +0 -0
  202. /package/{lib → host}/config.js +0 -0
  203. /package/{lib → host}/modeState.js +0 -0
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ // Component-surface policy gate (#120).
3
+ //
4
+ // Principle 11 says optional capabilities are switches and the core is not. This
5
+ // gate is what makes that a rule instead of a slogan. It fails on:
6
+ //
7
+ // 1. a visible component without `title`, `summary` and `disables` in both
8
+ // locale/en.json and locale/zh.json;
9
+ // 2. a forbidden-to-disable capability (host, health, recovery, review
10
+ // contract, merge gates) that any group, row or boolean key can disable;
11
+ // 3. a component count that moved without a decision in
12
+ // scripts/surface-baseline.json (the same brake `surface:check` applies).
13
+ //
14
+ // It also refuses the quiet forms of the same drift: a shipped component row
15
+ // missing from the single source of truth, a live config key without a
16
+ // component-axis class, and a generated reference that is out of date.
17
+ //
18
+ // The single source of truth is `scripts/component-surface.json`; the generated
19
+ // table is `docs/reference/component-surface.md`; the policy is recorded in
20
+ // `docs/adr/0005-component-surface.md`.
21
+ //
22
+ // Usage:
23
+ // node scripts/check-components.mjs [--root <dir>] [--json]
24
+ import fs from 'node:fs'
25
+ import path from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { Config, configSurface } from '../domain/config/schema.js'
28
+ import {
29
+ COMPONENT_REFERENCE_FILE,
30
+ COMPONENT_SURFACE_FILE,
31
+ LOCALE_FILES,
32
+ checkForbidden,
33
+ checkLocaleCoverage,
34
+ parseComponentSurface,
35
+ parseCordisPatch,
36
+ parseOperationalRegistry,
37
+ renderComponentSurface,
38
+ validateComponentSurface,
39
+ } from '../domain/components.js'
40
+ import { validateSurfaceBaselineKey } from '../domain/surface.js'
41
+ import { readBaseline } from './lib/surface.mjs'
42
+ import { parseArgs, runMain } from './lib/cli.mjs'
43
+
44
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
45
+ const REGISTRY_FILE = 'host/tool-registry.js'
46
+ const PATCH_FILE = 'cordis.patch.yml'
47
+ const USAGE = `usage: node scripts/check-components.mjs [--root <dir>] [--json]
48
+
49
+ --root repository root to check (default: this package)
50
+ --json print the report as JSON
51
+ --help print this text
52
+
53
+ Fails on a visible component without title/summary/disables in both locale files,
54
+ a forbidden-to-disable capability disabled by a group, row or boolean key, a
55
+ component count that moved without a scripts/surface-baseline.json decision,
56
+ an unclassified config key or component row, and a stale generated reference.
57
+ Regenerate the table with \`npm run docs:generate:components\`.`
58
+
59
+ function finding(code, level, message) {
60
+ return { code, level, message }
61
+ }
62
+
63
+ /** The live boolean config keys, derived from the schemastery schema. */
64
+ function liveBooleanKeys() {
65
+ const keys = []
66
+ for (const [key, schema] of Object.entries(Config.dict ?? {})) {
67
+ const dict = schema?.dict
68
+ if (dict && typeof dict === 'object') {
69
+ for (const [child, childSchema] of Object.entries(dict)) {
70
+ if (childSchema?.type === 'boolean') keys.push(`${key}.${child}`)
71
+ }
72
+ } else if (schema?.type === 'boolean') {
73
+ keys.push(key)
74
+ }
75
+ }
76
+ return keys
77
+ }
78
+
79
+ /**
80
+ * The static wiring that lets a boolean key remove a tool: the OPERATIONAL_TOOLS
81
+ * table in host/tool-registry.js. Parsing it (rather than importing the runtime)
82
+ * keeps the denylist check offline and free of a booted Host. The parser accepts
83
+ * both the tuple and the `Map.set(...)` forms, and a trailing comma (F11), so a
84
+ * reformatted table cannot fall out of the allowlist.
85
+ */
86
+ function operationalRegistry(root) {
87
+ const file = path.join(root, REGISTRY_FILE)
88
+ const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
89
+ return parseOperationalRegistry(text)
90
+ }
91
+
92
+ /** Read the live inputs the pure checks need: schema, patch, locales, registry. */
93
+ export function componentCheckData(root) {
94
+ const config = configSurface()
95
+ const patchFile = path.join(root, PATCH_FILE)
96
+ const patch = fs.existsSync(patchFile)
97
+ ? parseCordisPatch(fs.readFileSync(patchFile, 'utf8'))
98
+ : { rows: [], visible: [] }
99
+ const locales = {}
100
+ for (const file of LOCALE_FILES) {
101
+ const localeFile = path.join(root, file)
102
+ locales[file] = fs.existsSync(localeFile) ? JSON.parse(fs.readFileSync(localeFile, 'utf8')) : null
103
+ }
104
+ return {
105
+ configKeys: config.keys,
106
+ sections: config.sections,
107
+ booleans: liveBooleanKeys(),
108
+ registry: operationalRegistry(root),
109
+ patch,
110
+ locales,
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Run every component-surface rule against one repository root.
116
+ * @param {string} root
117
+ * @returns {{ ok: boolean, surface: any, live: any, findings: Array<{ code: string, level: string, message: string }> }}
118
+ */
119
+ export function checkComponents(root) {
120
+ const findings = []
121
+ const live = componentCheckData(root)
122
+ const surfaceFile = path.join(root, COMPONENT_SURFACE_FILE)
123
+ if (!fs.existsSync(surfaceFile)) {
124
+ return {
125
+ ok: false,
126
+ surface: null,
127
+ live,
128
+ findings: [finding('surface-missing', 'error', `${COMPONENT_SURFACE_FILE} is missing`)],
129
+ }
130
+ }
131
+ let surface
132
+ try {
133
+ surface = parseComponentSurface(fs.readFileSync(surfaceFile, 'utf8'))
134
+ } catch (error) {
135
+ return {
136
+ ok: false,
137
+ surface: null,
138
+ live,
139
+ findings: [finding('surface-invalid', 'error', `${COMPONENT_SURFACE_FILE}: ${String(error?.message ?? error)}`)],
140
+ }
141
+ }
142
+ findings.push(...validateComponentSurface(surface, { configKeys: live.configKeys, patch: live.patch }))
143
+ findings.push(...checkLocaleCoverage(surface, live.locales))
144
+ findings.push(...checkForbidden(surface, { booleans: live.booleans, registry: live.registry, patch: live.patch }))
145
+
146
+ // #122: an empty OPERATIONAL_TOOLS table is the expected state. Every route
147
+ // that does exist must still match the `gateableTools` allowlist, which
148
+ // checkForbidden enforces above; there is no "unreadable registry" failure to
149
+ // raise when the correct count is zero.
150
+
151
+ // The decision record must exist: the ADR is where the policy is argued.
152
+ if (!fs.existsSync(path.join(root, surface.adr))) {
153
+ findings.push(finding('adr-missing', 'error', `component surface names ${surface.adr}, which does not exist`))
154
+ }
155
+ for (const capability of surface.forbidden) {
156
+ const decision = capability.acceptedSwitches?.decision
157
+ if (!decision) continue
158
+ if (!fs.existsSync(path.join(root, decision.ref))) {
159
+ findings.push(
160
+ finding(
161
+ 'forbidden-decision',
162
+ 'error',
163
+ `forbidden capability ${capability.id} records a decision in ${decision.ref}, which does not exist`,
164
+ ),
165
+ )
166
+ }
167
+ }
168
+
169
+ // Check 3: the component count is a baselined surface. `surface:check` runs
170
+ // the full baseline; this narrow call keeps the component brake available in
171
+ // this gate too, so a moved count fails here without re-measuring the rest.
172
+ const visible = live.patch.visible
173
+ let baseline = null
174
+ try {
175
+ baseline = readBaseline(root)
176
+ findings.push(
177
+ ...validateSurfaceBaselineKey(
178
+ baseline,
179
+ 'components',
180
+ { components: visible.length, liveNames: { components: visible } },
181
+ { exists: (relative) => fs.existsSync(path.join(root, relative)) },
182
+ ),
183
+ )
184
+ } catch (error) {
185
+ findings.push(finding('baseline-unreadable', 'error', `scripts/surface-baseline.json is unreadable: ${String(error?.message ?? error)}`))
186
+ }
187
+
188
+ // The reference is a rendering, not prose. There is exactly one way to update
189
+ // it: edit the source of truth and run the generator.
190
+ try {
191
+ const expected = renderComponentSurface(surface, { baselineDate: baseline?.date })
192
+ const referenceFile = path.join(root, COMPONENT_REFERENCE_FILE)
193
+ const actual = fs.existsSync(referenceFile) ? fs.readFileSync(referenceFile, 'utf8') : ''
194
+ if (actual !== expected) {
195
+ findings.push(
196
+ finding('reference-stale', 'error', `${COMPONENT_REFERENCE_FILE} is stale; run npm run docs:generate:components`),
197
+ )
198
+ }
199
+ } catch (error) {
200
+ findings.push(finding('reference-unreadable', 'error', `${COMPONENT_REFERENCE_FILE} cannot be rendered: ${String(error?.message ?? error)}`))
201
+ }
202
+
203
+ return { ok: findings.every((entry) => entry.level !== 'error'), surface, live, findings }
204
+ }
205
+
206
+ function main(argv) {
207
+ const options = parseArgs(argv, {
208
+ initial: { root: PACKAGE_ROOT, json: false, help: false },
209
+ values: { '--root': { key: 'root', parse: path.resolve } },
210
+ flags: { '--json': 'json', '--help': 'help', '-h': 'help' },
211
+ })
212
+ if (options.help) {
213
+ console.log(USAGE)
214
+ return 0
215
+ }
216
+ const root = path.resolve(options.root)
217
+ const report = checkComponents(root)
218
+ if (options.json) {
219
+ console.log(JSON.stringify({ root, ...report }, null, 2))
220
+ return report.ok ? 0 : 1
221
+ }
222
+ if (report.surface) {
223
+ const visible = report.surface.components.filter((component) => component.scope === 'visible').length
224
+ const internal = report.surface.components.length - visible
225
+ console.log(
226
+ `components: ${visible} visible, ${internal} internal, ${report.surface.keys.length} config keys classified (${COMPONENT_SURFACE_FILE})`,
227
+ )
228
+ }
229
+ for (const item of report.findings) console.log(`components: ${item.level} [${item.code}] ${item.message}`)
230
+ if (report.ok) console.log('components: ok — locale coverage, the forbidden denylist and the component baseline hold')
231
+ else console.log(`components: ${report.findings.length} problem(s); see docs/adr/0005-component-surface.md`)
232
+ return report.ok ? 0 : 1
233
+ }
234
+
235
+ await runMain(import.meta.url, () => main(process.argv.slice(2)), { errorPrefix: 'components', usage: USAGE })
@@ -4,6 +4,7 @@
4
4
  import fs from 'node:fs'
5
5
  import path from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
+ import { EVENT_COUNT, LEGACY_TOOL_EVENT_NAMES } from '../domain/events.js'
7
8
 
8
9
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
9
10
  const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
@@ -15,7 +16,7 @@ const toolModuleSources = fs.existsSync(path.join(root, 'tools'))
15
16
  .map((name) => read(`tools/${name}`))
16
17
  .join('\n')
17
18
  : ''
18
- const toolsSource = read('tools/index.js') + toolModuleSources + read('lib/tool-registry.js') + read('index.js')
19
+ const toolsSource = read('tools/index.js') + toolModuleSources + read('host/tool-registry.js') + read('index.js')
19
20
  const documentedTools = read('docs/reference/tools.md')
20
21
  const toolNames = [...new Set([...toolsSource.matchAll(/name: '(tower_[a-z_]+)'/g)].map((match) => match[1]))]
21
22
  .filter((name) => name !== 'tower-control')
@@ -62,37 +63,9 @@ for (const code of [
62
63
  ]) blockerCodes.add(code)
63
64
  const missingBlockers = [...blockerCodes].filter((code) => !blockersSource.includes(`\`${code}\``)).sort()
64
65
 
65
- const eventNames = [
66
- 'init',
67
- 'plan',
68
- 'mission',
69
- 'spawn',
70
- 'complete',
71
- 'review',
72
- 'merge',
73
- 'rebase',
74
- 'teardown',
75
- 'finding',
76
- 'findings',
77
- 'send',
78
- 'inbox',
79
- 'interrupt',
80
- 'diff',
81
- 'adopt',
82
- 'roster',
83
- 'status',
84
- 'preflight',
85
- 'doctor',
86
- 'recover',
87
- 'test',
88
- 'batch',
89
- 'resource',
90
- 'import_legacy',
91
- 'legacy_map',
92
- 'mode',
93
- 'control',
94
- 'reload',
95
- ]
66
+ // #75: the legacy alias list comes from the same event table the generated
67
+ // reference and the runtime use, so a renamed alias fails here first.
68
+ const eventNames = LEGACY_TOOL_EVENT_NAMES.map((name) => name.replace(/^tower\//, ''))
96
69
  const documentedEvents = read('docs/reference/events.md')
97
70
  const missingEvents = eventNames.filter((name) => !documentedEvents.includes(`\`tower/${name}\``))
98
71
 
@@ -128,8 +101,8 @@ if (!blockersDoc.includes(`${blockerCodes.size} stable blocker/failure codes`))
128
101
  if (!errorsDoc.includes(`${errorCodes.length} tool error codes`)) {
129
102
  problems.push(`docs/reference/errors.md must state ${errorCodes.length} tool error codes`)
130
103
  }
131
- if (!eventsDoc.includes(`${eventNames.length} process-local events`)) {
132
- problems.push(`docs/reference/events.md must state ${eventNames.length} process-local events`)
104
+ if (!eventsDoc.includes(`${EVENT_COUNT} process-local events`)) {
105
+ problems.push(`docs/reference/events.md must state ${EVENT_COUNT} process-local events`)
133
106
  }
134
107
  for (const required of [
135
108
  '| `tests.admission` | `off` |',
@@ -172,5 +145,5 @@ if (problems.length > 0) {
172
145
  process.exit(1)
173
146
  }
174
147
  console.log(
175
- `docs ok: ${toolNames.length} tools, ${blockerCodes.size} blocker codes, ${errorCodes.length} error codes, ${eventNames.length} events, version ${packageJson.version}`,
148
+ `docs ok: ${toolNames.length} tools, ${blockerCodes.size} blocker codes, ${errorCodes.length} error codes, ${EVENT_COUNT} events, version ${packageJson.version}`,
176
149
  )
@@ -1,49 +1,78 @@
1
1
  // Fail when the published file list drifts from the reviewed snapshot.
2
- // Run: node scripts/check-pack.mjs [--write]
2
+ // Run: node scripts/check-pack.mjs [--write] [--root <dir>]
3
+ //
4
+ // #71: the optional Web client ships inside the main package, so the required
5
+ // list asserts the client package manifest and its bundle patch — not just the
6
+ // directory's index.js. `requiredMissing` is exported so a fixture can prove
7
+ // that removing one of them fails the check; `--root` lets that fixture run the
8
+ // real gate against a temporary package.
3
9
  import { execFileSync } from 'node:child_process'
4
10
  import fs from 'node:fs'
5
11
  import path from 'node:path'
6
12
  import { fileURLToPath } from 'node:url'
7
13
 
8
- const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
9
- const manifestFile = path.join(root, 'scripts', 'pack-manifest.txt')
10
- const write = process.argv.includes('--write')
14
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
15
+ const REQUIRED_FILES = [
16
+ 'cordis.patch.yml',
17
+ 'index.js',
18
+ 'locale/en.json',
19
+ 'locale/zh.json',
20
+ 'ui-bundle/index.js',
21
+ 'ui-bundle/package.json',
22
+ 'ui-bundle/cordis.patch.yml',
23
+ 'COMPATIBILITY.md',
24
+ ]
11
25
 
12
- const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
13
- let parsed
14
- try {
15
- const output = execFileSync(npm, ['pack', '--dry-run', '--json'], {
16
- cwd: root,
17
- encoding: 'utf8',
18
- shell: process.platform === 'win32',
19
- stdio: ['ignore', 'pipe', 'pipe'],
20
- })
21
- parsed = JSON.parse(output)
22
- } catch (error) {
23
- console.error(`pack check: npm pack failed: ${String(error?.message ?? error)}`)
24
- process.exit(1)
26
+ function requiredMissing(paths, required = REQUIRED_FILES) {
27
+ return required.filter((file) => !paths.includes(file))
25
28
  }
26
29
 
27
- const entry = Array.isArray(parsed) ? parsed[0] : parsed
28
- const paths = (entry?.files ?? []).map((file) => file.path).filter(Boolean).sort()
29
- if (write) {
30
- fs.writeFileSync(manifestFile, `${paths.join('\n')}\n`)
31
- console.log(`pack check: wrote ${paths.length} paths to ${path.relative(root, manifestFile)}`)
32
- process.exit(0)
33
- }
34
- const expected = fs.readFileSync(manifestFile, 'utf8').split('\n').filter(Boolean).sort()
35
- const added = paths.filter((file) => !expected.includes(file))
36
- const removed = expected.filter((file) => !paths.includes(file))
37
- if (added.length > 0 || removed.length > 0) {
38
- console.error('pack check: published file list changed; review and update scripts/pack-manifest.txt')
39
- if (added.length > 0) console.error(` added: ${added.join(', ')}`)
40
- if (removed.length > 0) console.error(` removed: ${removed.join(', ')}`)
41
- process.exit(1)
42
- }
43
- const required = ['cordis.patch.yml', 'index.js', 'locale/en.json', 'locale/zh.json', 'ui-bundle/index.js', 'COMPATIBILITY.md']
44
- const missing = required.filter((file) => !paths.includes(file))
45
- if (missing.length > 0) {
46
- console.error(`pack check: required runtime files missing: ${missing.join(', ')}`)
47
- process.exit(1)
30
+ function main() {
31
+ const rootArg = process.argv.indexOf('--root')
32
+ const root = rootArg > 0 ? path.resolve(process.argv[rootArg + 1]) : ROOT
33
+ const manifestFile = path.join(root, 'scripts', 'pack-manifest.txt')
34
+ const write = process.argv.includes('--write')
35
+
36
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
37
+ let parsed
38
+ try {
39
+ const output = execFileSync(npm, ['pack', '--dry-run', '--json'], {
40
+ cwd: root,
41
+ encoding: 'utf8',
42
+ shell: process.platform === 'win32',
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ })
45
+ parsed = JSON.parse(output)
46
+ } catch (error) {
47
+ console.error(`pack check: npm pack failed: ${String(error?.message ?? error)}`)
48
+ process.exit(1)
49
+ }
50
+
51
+ const entry = Array.isArray(parsed) ? parsed[0] : parsed
52
+ const paths = (entry?.files ?? []).map((file) => file.path).filter(Boolean).sort()
53
+ if (write) {
54
+ fs.writeFileSync(manifestFile, `${paths.join('\n')}\n`)
55
+ console.log(`pack check: wrote ${paths.length} paths to ${path.relative(root, manifestFile)}`)
56
+ process.exit(0)
57
+ }
58
+ const expected = fs.readFileSync(manifestFile, 'utf8').split('\n').filter(Boolean).sort()
59
+ const added = paths.filter((file) => !expected.includes(file))
60
+ const removed = expected.filter((file) => !paths.includes(file))
61
+ if (added.length > 0 || removed.length > 0) {
62
+ console.error('pack check: published file list changed; review and update scripts/pack-manifest.txt')
63
+ if (added.length > 0) console.error(` added: ${added.join(', ')}`)
64
+ if (removed.length > 0) console.error(` removed: ${removed.join(', ')}`)
65
+ process.exit(1)
66
+ }
67
+ const missing = requiredMissing(paths)
68
+ if (missing.length > 0) {
69
+ console.error(`pack check: required runtime files missing: ${missing.join(', ')}`)
70
+ process.exit(1)
71
+ }
72
+ console.log(`pack check: ${paths.length} files match scripts/pack-manifest.txt`)
48
73
  }
49
- console.log(`pack check: ${paths.length} files match scripts/pack-manifest.txt`)
74
+
75
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
76
+ if (isMain) main()
77
+
78
+ export { REQUIRED_FILES, requiredMissing }