dsh-tower 2.4.2 → 2.6.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 (108) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +5 -3
  4. package/GLOSSARY.md +73 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +16 -15
  7. package/README.md +18 -10
  8. package/app/archive.js +16 -1
  9. package/app/board.js +16 -1
  10. package/app/gate.js +14 -0
  11. package/app/review.js +7 -0
  12. package/app/spawn-prechecks.js +30 -5
  13. package/app/tower.js +5 -5
  14. package/client.js +321 -0
  15. package/cordis.patch.yml +26 -9
  16. package/docs/adr/0004-surface-budget.md +10 -4
  17. package/docs/adr/0005-component-surface.md +222 -11
  18. package/docs/architecture/overview.md +12 -6
  19. package/docs/examples/first-mission.md +3 -2
  20. package/docs/faq.md +9 -7
  21. package/docs/guide/install-upgrade-migration.md +53 -7
  22. package/docs/guide/quickstart.md +91 -10
  23. package/docs/guide/release.md +60 -19
  24. package/docs/guide/scenarios-and-philosophy.md +35 -28
  25. package/docs/operations/security.md +11 -10
  26. package/docs/reference/blockers.md +12 -7
  27. package/docs/reference/component-surface.md +40 -21
  28. package/docs/reference/config.generated.md +6 -8
  29. package/docs/reference/config.md +36 -8
  30. package/docs/reference/doctor.md +11 -5
  31. package/docs/reference/errors.generated.md +1 -0
  32. package/docs/reference/errors.md +2 -1
  33. package/docs/reference/surface-budget.md +47 -12
  34. package/docs/reference/testing.md +1 -1
  35. package/docs/reference/tools.generated.md +5 -5
  36. package/docs/reference/tools.md +5 -5
  37. package/docs/zh/faq.md +7 -5
  38. package/docs/zh/guide/install-upgrade-migration.md +26 -7
  39. package/docs/zh/guide/quickstart.md +17 -10
  40. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  41. package/docs/zh/operations/security.md +9 -8
  42. package/docs/zh/reference/blockers.md +11 -7
  43. package/docs/zh/reference/config.md +28 -8
  44. package/docs/zh/reference/doctor.md +7 -2
  45. package/docs/zh/reference/errors.md +2 -1
  46. package/docs/zh/reference/testing.md +2 -2
  47. package/docs/zh/reference/tools.md +6 -5
  48. package/domain/component-rules.js +216 -0
  49. package/domain/components.js +181 -53
  50. package/domain/config/schema.js +86 -28
  51. package/domain/contracts/outputs.js +2 -0
  52. package/domain/contracts/tool-inputs/missions.js +1 -1
  53. package/domain/errors.js +6 -0
  54. package/domain/gate.js +23 -5
  55. package/domain/integration/review.js +203 -9
  56. package/domain/scope.js +176 -5
  57. package/domain/surface.js +21 -7
  58. package/host/capability-archive.js +18 -0
  59. package/host/capability-board.js +18 -0
  60. package/host/capability-registry.js +4 -0
  61. package/host/config.js +40 -3
  62. package/host/doctor.js +68 -12
  63. package/host/presets.js +26 -8
  64. package/host/tool-registry.js +30 -5
  65. package/index.js +17 -6
  66. package/locale/archive/en.json +6 -0
  67. package/locale/archive/zh.json +6 -0
  68. package/locale/batch/en.json +1 -1
  69. package/locale/batch/zh.json +1 -1
  70. package/locale/board/en.json +6 -0
  71. package/locale/board/zh.json +6 -0
  72. package/locale/en.json +37 -22
  73. package/locale/hmr/en.json +2 -2
  74. package/locale/hmr/zh.json +1 -1
  75. package/locale/import/en.json +1 -1
  76. package/locale/import/zh.json +1 -1
  77. package/locale/leases/en.json +2 -2
  78. package/locale/leases/zh.json +2 -2
  79. package/locale/preset-reviewer/en.json +1 -1
  80. package/locale/preset-reviewer/zh.json +1 -1
  81. package/locale/preset-worker/en.json +1 -1
  82. package/locale/preset-worker/zh.json +1 -1
  83. package/locale/skills/en.json +1 -1
  84. package/locale/skills/zh.json +1 -1
  85. package/locale/tests/en.json +2 -2
  86. package/locale/tests/zh.json +2 -2
  87. package/locale/zh.json +36 -21
  88. package/package.json +17 -2
  89. package/scripts/check-components.mjs +698 -44
  90. package/scripts/check-pack.mjs +3 -0
  91. package/scripts/component-surface.json +101 -37
  92. package/scripts/generate-capability-locale.mjs +3 -0
  93. package/scripts/mount-smoke/surface.mjs +2 -0
  94. package/scripts/pack-manifest.txt +10 -0
  95. package/scripts/release-notes.mjs +256 -0
  96. package/scripts/surface-baseline.json +39 -3
  97. package/scripts/surface-baseline.mjs +15 -5
  98. package/scripts/types-coverage.mjs +7 -0
  99. package/skills.js +4 -1
  100. package/tools/complete.js +3 -1
  101. package/tools/preflight.js +1 -1
  102. package/tools/review.js +1 -1
  103. package/tools/spawn.js +7 -7
  104. package/tools/status.js +8 -1
  105. package/tools/test.js +7 -7
  106. package/types/index.d.ts +10 -0
  107. package/ui-bundle/locale/en.json +1 -1
  108. package/ui-bundle/locale/zh.json +1 -1
@@ -8,10 +8,19 @@
8
8
  // locale/en.json and locale/zh.json — and the same three strings for every
9
9
  // component that ships a generated row label, including the internal
10
10
  // worker preset (#121);
11
- // 2. a forbidden-to-disable capability (host, health, recovery, review
12
- // contract, merge gates) that any group, row or boolean key can disable;
11
+ // 2. a forbidden-to-disable capability (host, health, recovery, merge gates)
12
+ // that any group, row or boolean key can disable;
13
13
  // 3. a component count that moved without a decision in
14
- // scripts/surface-baseline.json (the same brake `surface:check` applies).
14
+ // scripts/surface-baseline.json (the same brake `surface:check` applies);
15
+ // 4. #137: user-facing label copy that leaks a config key, an internal tool id,
16
+ // a slash command or a module specifier, leaves a term untranslated in
17
+ // Chinese, drifts from the recorded glossary, or diverges between zh and en
18
+ // in the root locale, the generated row locales and the ui-bundle locale;
19
+ // 5. #M42: the review policy presented as a row. `review.mode` is one
20
+ // tri-state configuration control (off/optional/required) on the plugin
21
+ // page, not a component row and not three mutually exclusive rows; no row
22
+ // may own the policy, and the reviewer preset row may not be made
23
+ // incompatible with the off/optional modes.
15
24
  //
16
25
  // It also refuses the quiet forms of the same drift: a shipped component row
17
26
  // missing from the single source of truth, a live config key without a
@@ -25,46 +34,389 @@
25
34
  // node scripts/check-components.mjs [--root <dir>] [--json]
26
35
  import fs from 'node:fs'
27
36
  import path from 'node:path'
28
- import { fileURLToPath } from 'node:url'
37
+ import { fileURLToPath, pathToFileURL } from 'node:url'
29
38
  import { Config, configSurface } from '../domain/config/schema.js'
30
39
  import {
40
+ COMPONENT_PATCH_FILES,
31
41
  COMPONENT_REFERENCE_FILE,
32
42
  COMPONENT_SURFACE_FILE,
33
43
  LOCALE_FIELDS,
34
44
  LOCALE_FILES,
45
+ META_LABEL_PATCHES,
46
+ META_LOCALE_FILES,
35
47
  checkForbidden,
48
+ checkGroupLabelCoverage,
49
+ checkKeyClassifications,
36
50
  checkLocaleCoverage,
51
+ checkMetaLabelCoverage,
52
+ checkRowOverrides,
53
+ mergeCordisPatches,
37
54
  parseComponentSurface,
38
55
  parseCordisPatch,
39
- parseOperationalRegistry,
40
56
  renderComponentSurface,
41
57
  validateComponentSurface,
42
58
  } from '../domain/components.js'
43
59
  import { validateSurfaceBaselineKey } from '../domain/surface.js'
44
60
  import { readBaseline } from './lib/surface.mjs'
45
61
  import { parseArgs, runMain } from './lib/cli.mjs'
46
- import { ROW_LOCALES } from './generate-capability-locale.mjs'
62
+ import { OFF_EFFECT_LABEL, ROW_LOCALES, rowLocalePaths } from './generate-capability-locale.mjs'
47
63
 
48
64
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
49
65
  const REGISTRY_FILE = 'host/tool-registry.js'
50
- const PATCH_FILE = 'cordis.patch.yml'
66
+ const ROOT_PATCH = COMPONENT_PATCH_FILES[0]
51
67
  const USAGE = `usage: node scripts/check-components.mjs [--root <dir>] [--json]
52
68
 
53
69
  --root repository root to check (default: this package)
54
70
  --json print the report as JSON
55
71
  --help print this text
56
72
 
73
+ Reads every shipped cordis.patch.yml (${COMPONENT_PATCH_FILES.join(', ')}).
57
74
  Fails on a visible component without title/summary/disables in both locale files,
58
75
  a generated row label (capability or preset) whose component entry is missing,
59
- a forbidden-to-disable capability disabled by a group, row or boolean key, a
60
- component count that moved without a scripts/surface-baseline.json decision,
61
- an unclassified config key or component row, and a stale generated reference.
62
- Regenerate the table with \`npm run docs:generate:components\`.`
76
+ a visible row with no label source at all, a group row whose plugin-page
77
+ description omits its off consequence, a forbidden-to-disable capability disabled
78
+ by a group, row or boolean key, a component count that moved without a
79
+ scripts/surface-baseline.json decision, an unclassified config key or component
80
+ row, a stale generated reference, (#137) any label copy that names a config key or
81
+ internal tool id, leaves a glossary term untranslated, or diverges between zh and
82
+ en, and (#M42) the review policy exposed as a row instead of one tri-state
83
+ configuration control.
84
+ Regenerate the table with \`npm run docs:generate:components\` and the row labels
85
+ with \`npm run docs:generate:locale\`.`
63
86
 
64
87
  function finding(code, level, message) {
65
88
  return { code, level, message }
66
89
  }
67
90
 
91
+ /** The recorded bilingual vocabulary lives at the repository root. */
92
+ export const GLOSSARY_FILE = 'GLOSSARY.md'
93
+
94
+ /**
95
+ * #137: the canonical bilingual vocabulary for every user-facing label. The
96
+ * glossary is the contract between the English and the Chinese copy: an English
97
+ * concept that appears in a label must use the recorded Chinese term, and the
98
+ * Chinese side may not leave the English word untranslated.
99
+ *
100
+ * `worker` and `reviewer` match the generic role at the start of a word, in
101
+ * either case ("Workers are …"), but never the product names "Tower Worker" and
102
+ * "Tower Reviewer": the negative lookbehind keeps the brand out of the role
103
+ * check, so a label may say "Tower Worker preset" without owing 执行代理.
104
+ */
105
+ export const COMPONENT_GLOSSARY = Object.freeze([
106
+ { en: 'mission', zh: '任务线', pattern: /\b[Mm]issions?\b/ },
107
+ { en: 'batch', zh: '批次', pattern: /\b[Bb]atch(?:es)?\b/ },
108
+ { en: 'doctor', zh: '体检', pattern: /\b[Dd]octor\b/ },
109
+ { en: 'board', zh: '看板', pattern: /\b[Bb]oard\b/ },
110
+ { en: 'archive', zh: '归档', pattern: /\b[Aa]rchives?\b/ },
111
+ { en: 'preset', zh: '预设', pattern: /\b[Pp]resets?\b/ },
112
+ { en: 'worktree', zh: '工作树', pattern: /\b[Ww]orktrees?\b/ },
113
+ { en: 'review', zh: '审查', pattern: /\b[Rr]eviews?\b/ },
114
+ { en: 'lease', zh: '租约', pattern: /\b[Ll]eases?\b/ },
115
+ { en: 'worker', zh: '执行代理', pattern: /\b(?<!Tower )[Ww]orkers?\b/ },
116
+ { en: 'reviewer', zh: '审查代理', pattern: /\b(?<!Tower )[Rr]eviewers?\b/ },
117
+ ])
118
+
119
+ /**
120
+ * #137: copy is written by people, and a Chinese IME (or a careful editor) can
121
+ * emit case variants and full-width punctuation. Normalize those before
122
+ * matching so `Gates.requireTests`, `Tower_batch`, `gates。requireTests` and the
123
+ * fully full-width `tower_batch` are all caught. Matching runs on the
124
+ * normalized text; the reported token is then the actionable ASCII spelling.
125
+ *
126
+ * @param {unknown} text
127
+ * @returns {string}
128
+ */
129
+ export function normalizeCopy(text) {
130
+ return String(text ?? '')
131
+ .replace(/[\uFF01-\uFF5E]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
132
+ .replace(/\u3000/g, ' ')
133
+ .replace(/\u3002/g, '.')
134
+ }
135
+
136
+ // A section plus a dot is config-key syntax (`gates.requireTests:true`), not
137
+ // prose. The same rule catches every section a user can edit, in either
138
+ // language, so a new key family cannot sneak back into a label. Matching is
139
+ // case-insensitive: `Gates.requireTests` is the same leak.
140
+ export const CONFIG_KEY_SYNTAX =
141
+ /\b(?:gates|review|spawn|tests|docs|mode|merge|git|worktrees|archive|leases|dev)\.[A-Za-z][A-Za-z0-9_.]*/i
142
+ // Bare internal identifiers: tool ids, slash commands and package specifiers.
143
+ // Case-insensitive for the same reason: `Tower_batch` and `/Tower reload` are
144
+ // still internal spelling, not prose.
145
+ export const TOOL_ID_SYNTAX = /\btower_[a-z][a-z0-9_]*/i
146
+ export const COMMAND_SYNTAX = /\/tower\s/i
147
+ export const MODULE_SPECIFIER_SYNTAX = /\bdsh-tower\/[a-z][a-z0-9._/-]*/i
148
+ // Words that must be translated in the Chinese copy. Lowercase only, so
149
+ // product names ("Tower Worker"), file names (MISSIONS.md) and brand names
150
+ // (Tower, DSH) are not mistakes.
151
+ export const ZH_UNTRANSLATED = Object.freeze([
152
+ 'mission',
153
+ 'batch',
154
+ 'doctor',
155
+ 'board',
156
+ 'archive',
157
+ 'preset',
158
+ 'worktree',
159
+ 'review',
160
+ 'lease',
161
+ 'host',
162
+ 'state',
163
+ 'worker',
164
+ 'reviewer',
165
+ ])
166
+ const ZH_UNTRANSLATED_SYNTAX = ZH_UNTRANSLATED.map((term) => ({
167
+ term,
168
+ pattern: new RegExp(`\\b${term}\\b`),
169
+ }))
170
+
171
+ /**
172
+ * Flatten the user-facing fields of one locale file into `{ path, text }`
173
+ * entries: `meta.title`, `meta.description` and every
174
+ * `components.<id>.title/summary/disables`. A missing field is kept as an empty
175
+ * string so parity and emptiness are reported once, with its path.
176
+ *
177
+ * @param {string} file repository-relative path
178
+ * @param {any} payload parsed locale JSON (or null when the file is missing)
179
+ * @returns {Array<{ file: string, path: string, text: string }>}
180
+ */
181
+ export function labelCopyFields(file, payload) {
182
+ const fields = []
183
+ const push = (key, value) => {
184
+ fields.push({ file, path: key, text: typeof value === 'string' ? value : '' })
185
+ }
186
+ if (payload && typeof payload === 'object') {
187
+ push('meta.title', payload.meta?.title)
188
+ push('meta.description', payload.meta?.description)
189
+ for (const [id, entry] of Object.entries(payload.components ?? {})) {
190
+ for (const field of LOCALE_FIELDS) push(`components.${id}.${field}`, entry?.[field])
191
+ }
192
+ }
193
+ return fields
194
+ }
195
+
196
+ function checkBannedCopy(field, lang, record) {
197
+ const label = `${field.file} ${field.path}`
198
+ // Match on the normalized spelling so case variants and full-width
199
+ // punctuation cannot bypass the rule (#137).
200
+ const text = normalizeCopy(field.text)
201
+ const configKey = text.match(CONFIG_KEY_SYNTAX)
202
+ if (configKey) {
203
+ record(
204
+ 'locale-copy-config-key',
205
+ `${label} names a config key ("${configKey[0]}"); describe the capability and its off consequence in plain language instead`,
206
+ )
207
+ }
208
+ const toolId = text.match(TOOL_ID_SYNTAX)
209
+ if (toolId) {
210
+ record(
211
+ 'locale-copy-tool-id',
212
+ `${label} names the internal tool id "${toolId[0]}"; describe what the user can do instead`,
213
+ )
214
+ }
215
+ const command = text.match(COMMAND_SYNTAX)
216
+ if (command) {
217
+ record(
218
+ 'locale-copy-command',
219
+ `${label} names the slash command "${command[0].trim()}"; describe the capability instead`,
220
+ )
221
+ }
222
+ const specifier = text.match(MODULE_SPECIFIER_SYNTAX)
223
+ if (specifier) {
224
+ record(
225
+ 'locale-copy-module',
226
+ `${label} names the module specifier "${specifier[0]}"; describe the capability instead`,
227
+ )
228
+ }
229
+ if (lang !== 'zh') return
230
+ for (const { term, pattern } of ZH_UNTRANSLATED_SYNTAX) {
231
+ if (pattern.test(text)) {
232
+ record(
233
+ 'locale-copy-untranslated',
234
+ `${label} leaves "${term}" untranslated; use the glossary term from ${GLOSSARY_FILE}`,
235
+ )
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The Chinese side of the glossary check. Chinese has no word boundaries, so a
242
+ * shorter term (审查) can be a substring of a longer one (审查代理). The longest
243
+ * recorded term wins: it is removed before shorter terms are matched, which
244
+ * keeps the two roles distinct instead of reporting a false mismatch.
245
+ */
246
+ function checkZhGlossary(field, enText, enFile, record) {
247
+ const ordered = [...COMPONENT_GLOSSARY].sort((a, b) => b.zh.length - a.zh.length)
248
+ let remaining = field.text
249
+ for (const term of ordered) {
250
+ if (!remaining.includes(term.zh)) continue
251
+ remaining = remaining.split(term.zh).join(' ')
252
+ if (!term.pattern.test(enText)) {
253
+ record(
254
+ 'locale-glossary',
255
+ `${enFile} ${field.path} must name the English ${term.en} for the Chinese "${term.zh}"`,
256
+ )
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * #137: check one en/zh pair of locale files. Every user-facing field must be
263
+ * non-empty, free of config-key and internal-identifier syntax, and consistent
264
+ * with the recorded glossary in both directions. Both files must carry the same
265
+ * field paths.
266
+ *
267
+ * @param {Array<{ en: { file: string, payload: any }, zh: { file: string, payload: any } }>} pairs
268
+ */
269
+ export function checkLocaleCopy(pairs) {
270
+ const findings = []
271
+ const record = (code, message) => findings.push(finding(code, 'error', message))
272
+ for (const pair of pairs ?? []) {
273
+ if (!pair?.en?.payload || !pair?.zh?.payload) {
274
+ record(
275
+ 'locale-copy-missing',
276
+ `locale copy pair is incomplete: ${pair?.en?.file ?? '<en>'} and ${pair?.zh?.file ?? '<zh>'} must both exist`,
277
+ )
278
+ continue
279
+ }
280
+ const en = labelCopyFields(pair.en.file, pair.en.payload)
281
+ const zh = labelCopyFields(pair.zh.file, pair.zh.payload)
282
+ const enPaths = new Set(en.map((field) => field.path))
283
+ const zhPaths = new Set(zh.map((field) => field.path))
284
+ for (const path of enPaths) {
285
+ if (!zhPaths.has(path)) {
286
+ record('locale-copy-parity', `${pair.en.file} labels ${path} but ${pair.zh.file} does not`)
287
+ }
288
+ }
289
+ for (const path of zhPaths) {
290
+ if (!enPaths.has(path)) {
291
+ record('locale-copy-parity', `${pair.zh.file} labels ${path} but ${pair.en.file} does not`)
292
+ }
293
+ }
294
+ const enByPath = new Map(en.map((field) => [field.path, field.text]))
295
+ const zhByPath = new Map(zh.map((field) => [field.path, field.text]))
296
+ for (const side of [
297
+ { lang: 'en', fields: en, other: zhByPath },
298
+ { lang: 'zh', fields: zh, other: enByPath },
299
+ ]) {
300
+ for (const field of side.fields) {
301
+ if (!field.text.trim()) {
302
+ record('locale-copy-empty', `${field.file} ${field.path} is empty`)
303
+ continue
304
+ }
305
+ checkBannedCopy(field, side.lang, record)
306
+ const otherText = side.other.get(field.path)
307
+ if (otherText === undefined) continue
308
+ if (side.lang === 'en') {
309
+ for (const term of COMPONENT_GLOSSARY) {
310
+ if (term.pattern.test(field.text) && !otherText.includes(term.zh)) {
311
+ record(
312
+ 'locale-glossary',
313
+ `${pair.zh.file} ${field.path} must name "${term.zh}" for the English ${term.en} in ${pair.en.file}`,
314
+ )
315
+ }
316
+ }
317
+ } else {
318
+ checkZhGlossary(field, otherText, pair.en.file, record)
319
+ }
320
+ }
321
+ }
322
+ }
323
+ return findings
324
+ }
325
+
326
+ /**
327
+ * #137: the ui-bundle row labels its own package with a hand-written `meta`
328
+ * block, but its single source is still `components['tower-ui']` in the root
329
+ * locale. Pin the meta text to exactly what the row-locale generator would
330
+ * render, so the hand-written copy cannot drift from the source of truth.
331
+ *
332
+ * @param {string[]} metaRowIds visible rows that ship their own meta label
333
+ * @param {Record<string, any>} locales root locale file -> payload
334
+ * @param {Record<string, any>} metaLocales package meta locale file -> payload
335
+ * @param {Record<string, string>} markers off-effect label per language
336
+ */
337
+ export function checkMetaRowCopy(metaRowIds, locales, metaLocales, markers) {
338
+ const findings = []
339
+ const normalize = (value) => String(value ?? '').replace(/\s+/g, ' ').trim()
340
+ const rootLocaleFor = (lang) => LOCALE_FILES.find((file) => file.endsWith(`/${lang}.json`))
341
+ for (const file of META_LOCALE_FILES) {
342
+ const lang = path.basename(file, '.json') === 'zh' ? 'zh' : 'en'
343
+ const meta = metaLocales?.[file]?.meta
344
+ if (!meta || typeof meta.title !== 'string' || typeof meta.description !== 'string') continue
345
+ const rootFile = rootLocaleFor(lang)
346
+ const space = lang === 'zh' ? '' : ' '
347
+ for (const id of metaRowIds ?? []) {
348
+ const entry = locales?.[rootFile]?.components?.[id]
349
+ if (!entry) {
350
+ findings.push(
351
+ finding(
352
+ 'meta-row-source',
353
+ 'error',
354
+ `${file} labels ${id}, but ${rootFile} has no components.${id} entry to source it from`,
355
+ ),
356
+ )
357
+ continue
358
+ }
359
+ if (
360
+ typeof entry.title !== 'string' ||
361
+ typeof entry.summary !== 'string' ||
362
+ typeof entry.disables !== 'string'
363
+ ) {
364
+ continue // already reported by the locale coverage rule
365
+ }
366
+ if (normalize(meta.title) !== normalize(entry.title)) {
367
+ findings.push(
368
+ finding('meta-row-copy', 'error', `${file} meta.title must equal components.${id}.title in ${rootFile}`),
369
+ )
370
+ }
371
+ const expected = normalize(`${entry.summary}${space}${markers[lang]}${space}${entry.disables}`)
372
+ if (normalize(meta.description) !== expected) {
373
+ findings.push(
374
+ finding(
375
+ 'meta-row-copy',
376
+ 'error',
377
+ `${file} meta.description must be components.${id} summary + off-effect label + disables, exactly as the row-locale generator renders it`,
378
+ ),
379
+ )
380
+ }
381
+ }
382
+ }
383
+ return findings
384
+ }
385
+
386
+ /**
387
+ * #137: the glossary is a gate input, so the document that records it must
388
+ * exist and still name every canonical term pair. Copy and policy cannot drift
389
+ * apart silently.
390
+ *
391
+ * @param {string} root
392
+ */
393
+ export function checkGlossaryFile(root) {
394
+ const file = path.join(root, GLOSSARY_FILE)
395
+ if (!fs.existsSync(file)) {
396
+ return [
397
+ finding(
398
+ 'glossary-missing',
399
+ 'error',
400
+ `${GLOSSARY_FILE} is missing; the bilingual label vocabulary has to be recorded in one place`,
401
+ ),
402
+ ]
403
+ }
404
+ const text = fs.readFileSync(file, 'utf8')
405
+ const findings = []
406
+ for (const term of COMPONENT_GLOSSARY) {
407
+ if (!text.includes(term.en) || !text.includes(term.zh)) {
408
+ findings.push(
409
+ finding(
410
+ 'glossary-term',
411
+ 'error',
412
+ `${GLOSSARY_FILE} does not record both "${term.en}" and "${term.zh}"`,
413
+ ),
414
+ )
415
+ }
416
+ }
417
+ return findings
418
+ }
419
+
68
420
  /**
69
421
  * The generated row-label map is a coverage axis of its own (#121). A component
70
422
  * that ships a `<specifier>/locale/<lang>.json` label must carry `title`,
@@ -116,65 +468,308 @@ export function checkRowLocaleCoverage(surface, locales, rows) {
116
468
  return findings
117
469
  }
118
470
 
119
- /** The live boolean config keys, derived from the schemastery schema. */
120
- function liveBooleanKeys() {
121
- const keys = []
471
+ /** The live config keys and their schema type, derived from schemastery. */
472
+ export function liveKeyTypes() {
473
+ /** @type {Record<string, string>} */
474
+ const types = {}
122
475
  for (const [key, schema] of Object.entries(Config.dict ?? {})) {
123
476
  const dict = schema?.dict
124
477
  if (dict && typeof dict === 'object') {
125
478
  for (const [child, childSchema] of Object.entries(dict)) {
126
- if (childSchema?.type === 'boolean') keys.push(`${key}.${child}`)
479
+ types[`${key}.${child}`] = childSchema?.type ?? 'unknown'
127
480
  }
128
- } else if (schema?.type === 'boolean') {
129
- keys.push(key)
481
+ } else {
482
+ types[key] = schema?.type ?? 'unknown'
130
483
  }
131
484
  }
132
- return keys
485
+ return types
486
+ }
487
+
488
+ /** The live boolean config keys, derived from the schemastery schema. */
489
+ function liveBooleanKeys() {
490
+ return Object.entries(liveKeyTypes())
491
+ .filter(([, type]) => type === 'boolean')
492
+ .map(([key]) => key)
133
493
  }
134
494
 
135
495
  /**
136
- * The static wiring that lets a boolean key remove a tool: the OPERATIONAL_TOOLS
137
- * table in host/tool-registry.js. Parsing it (rather than importing the runtime)
138
- * keeps the denylist check offline and free of a booted Host. The parser accepts
139
- * both the tuple and the `Map.set(...)` forms, and a trailing comma (F11), so a
140
- * reformatted table cannot fall out of the allowlist.
496
+ * #138.2: import the OPERATIONAL_TOOLS table from host/tool-registry.js. The old
497
+ * regex scraper treated "found nothing" and "could not read the file" as the same
498
+ * empty route table. An empty Map is a legitimate determined state; a missing
499
+ * file, an import error or a missing/non-Map export is a gate failure.
500
+ *
501
+ * The import is dynamic and rooted at the checked directory, so the gate reads
502
+ * the module the package actually ships. The table is checked for shape before
503
+ * it reaches the denylist.
504
+ *
505
+ * @returns {Promise<{ routes?: Record<string, string>, error?: string }>}
141
506
  */
142
- function operationalRegistry(root) {
507
+ async function operationalRegistry(root) {
143
508
  const file = path.join(root, REGISTRY_FILE)
144
- const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
145
- return parseOperationalRegistry(text)
509
+ if (!fs.existsSync(file)) {
510
+ return { error: `${REGISTRY_FILE} is missing; the operational-tool route table cannot be determined` }
511
+ }
512
+ let module
513
+ try {
514
+ module = await import(pathToFileURL(file).href)
515
+ } catch (error) {
516
+ return { error: `${REGISTRY_FILE} cannot be imported: ${String(error?.message ?? error)}` }
517
+ }
518
+ const table = module.OPERATIONAL_TOOLS
519
+ if (!(table instanceof Map)) {
520
+ return { error: `${REGISTRY_FILE} must export OPERATIONAL_TOOLS as a Map; the route table cannot be determined` }
521
+ }
522
+ /** @type {Record<string, string>} */
523
+ const routes = {}
524
+ for (const [tool, flag] of table) {
525
+ if (typeof tool !== 'string' || !tool || typeof flag !== 'string' || !flag) {
526
+ return { error: `${REGISTRY_FILE} OPERATIONAL_TOOLS contains a route whose tool or switch is not a non-empty string` }
527
+ }
528
+ routes[tool] = flag
529
+ }
530
+ return { routes }
146
531
  }
147
532
 
148
- /** Read the live inputs the pure checks need: schema, patch, locales, registry. */
149
- export function componentCheckData(root) {
533
+ /** Read the live inputs the pure checks need: schema, patches, locales, registry. */
534
+ export async function componentCheckData(root) {
150
535
  const config = configSurface()
151
- const patchFile = path.join(root, PATCH_FILE)
152
- const patch = fs.existsSync(patchFile)
153
- ? parseCordisPatch(fs.readFileSync(patchFile, 'utf8'))
154
- : { rows: [], visible: [] }
536
+ const keyTypes = liveKeyTypes()
537
+ const registry = await operationalRegistry(root)
538
+ // #134: the policy gate reads every shipped patch, not only the root one. A row
539
+ // added to `ui-bundle/cordis.patch.yml` without a classification must fail here.
540
+ const patches = {}
541
+ for (const file of COMPONENT_PATCH_FILES) {
542
+ const patchFile = path.join(root, file)
543
+ patches[file] = fs.existsSync(patchFile)
544
+ ? parseCordisPatch(fs.readFileSync(patchFile, 'utf8'))
545
+ : { rows: [], visible: [] }
546
+ }
547
+ const patch = mergeCordisPatches(patches)
155
548
  const locales = {}
156
549
  for (const file of LOCALE_FILES) {
157
550
  const localeFile = path.join(root, file)
158
551
  locales[file] = fs.existsSync(localeFile) ? JSON.parse(fs.readFileSync(localeFile, 'utf8')) : null
159
552
  }
553
+ // A package that ships its own patch labels its rows with its own meta block.
554
+ const metaLocales = {}
555
+ for (const file of META_LOCALE_FILES) {
556
+ const localeFile = path.join(root, file)
557
+ metaLocales[file] = fs.existsSync(localeFile) ? JSON.parse(fs.readFileSync(localeFile, 'utf8')) : null
558
+ }
559
+ // #137: the generated row labels are user-facing copy too, so the copy gate
560
+ // reads them alongside the hand-written root and ui-bundle locales.
561
+ const rowLocales = {}
562
+ for (const file of rowLocalePaths()) {
563
+ const localeFile = path.join(root, file)
564
+ rowLocales[file] = fs.existsSync(localeFile) ? JSON.parse(fs.readFileSync(localeFile, 'utf8')) : null
565
+ }
160
566
  return {
161
567
  configKeys: config.keys,
162
568
  sections: config.sections,
569
+ keyTypes,
163
570
  booleans: liveBooleanKeys(),
164
- registry: operationalRegistry(root),
571
+ registry: registry.routes ?? {},
572
+ registryError: registry.error ?? null,
573
+ patches,
165
574
  patch,
166
575
  locales,
576
+ metaLocales,
577
+ rowLocales,
578
+ }
579
+ }
580
+
581
+ /**
582
+ * #138.3: a shipped `cordis.patch.yml` that the policy does not read is an
583
+ * unreviewed component surface. Discover every patch in the package and compare
584
+ * it with COMPONENT_PATCH_FILES in both directions, so a new patch fails until
585
+ * its rows are classified and a listed-but-missing patch fails rather than
586
+ * silently contributing zero rows.
587
+ *
588
+ * @param {string} root
589
+ * @returns {string[]} repository-relative paths, POSIX separators
590
+ */
591
+ export function discoverShippedPatches(root) {
592
+ const found = []
593
+ const skip = new Set(['.git', 'node_modules', 'coverage', '.dsh-tower-worktrees'])
594
+ const walk = (dir) => {
595
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
596
+ if (skip.has(entry.name)) continue
597
+ const full = path.join(dir, entry.name)
598
+ if (entry.isDirectory()) walk(full)
599
+ else if (entry.name === 'cordis.patch.yml') found.push(path.relative(root, full).replace(/\\/g, '/'))
600
+ }
167
601
  }
602
+ walk(root)
603
+ return found.sort()
604
+ }
605
+
606
+ /**
607
+ * @param {string[]} discovered repository-relative patch paths
608
+ * @returns {Array<{ code: string, level: string, message: string }>}
609
+ */
610
+ export function checkShippedPatchCoverage(discovered) {
611
+ const findings = []
612
+ for (const file of discovered) {
613
+ if (!COMPONENT_PATCH_FILES.includes(file)) {
614
+ findings.push(
615
+ finding(
616
+ 'patch-unclassified',
617
+ 'error',
618
+ `${file} is a shipped cordis.patch.yml but is not in COMPONENT_PATCH_FILES; add it to the policy before its rows can ship`,
619
+ ),
620
+ )
621
+ }
622
+ }
623
+ for (const file of COMPONENT_PATCH_FILES) {
624
+ if (!discovered.includes(file)) {
625
+ findings.push(finding('patch-missing', 'error', `${file} is listed in COMPONENT_PATCH_FILES but does not exist`))
626
+ }
627
+ }
628
+ return findings
629
+ }
630
+
631
+ /**
632
+ * #134: every visible row needs a label source. Rows in the root patch use the
633
+ * generated `<specifier>/locale/*.json` files (capability rows) or the package
634
+ * root `meta` (the group); rows that ship in their own package use that package's
635
+ * `meta.title` / `meta.description`. A visible row with neither source shows only
636
+ * its module specifier on the Plugins page, so it is a gate failure.
637
+ *
638
+ * @param {{ rows: Array<{ id: string, scope: string, kind: string, patch: string }> }} patch merged patch
639
+ * @param {Array<{ component: string }>} rowLocales the generator declaration
640
+ */
641
+ export function checkRowLabelSources(patch, rowLocales) {
642
+ const findings = []
643
+ const generated = new Set((rowLocales ?? []).map((row) => row.component))
644
+ for (const row of patch?.rows ?? []) {
645
+ if (row.scope !== 'visible' || row.kind === 'group') continue
646
+ if (generated.has(row.id)) continue
647
+ if (META_LABEL_PATCHES.includes(row.patch)) continue
648
+ findings.push(
649
+ finding(
650
+ 'row-locale-missing',
651
+ 'error',
652
+ `visible component "${row.id}" (${row.patch}) ships neither a generated <specifier>/locale label nor a package meta label`,
653
+ ),
654
+ )
655
+ }
656
+ return findings
657
+ }
658
+
659
+ // #M42: the review policy is one tri-state configuration control on the plugin
660
+ // page. These names are the policy-shaped rows the gate refuses to see: a row
661
+ // per mode (or one "review policy" row) is the shape the product replaced.
662
+ export const REVIEW_POLICY_KEY = 'review.mode'
663
+ export const REVIEW_ALIAS_KEY = 'review.requireReview'
664
+ export const REVIEW_REVIEWER_ROW = 'preset-tower-reviewer'
665
+ const REVIEW_POLICY_ROW_IDS = Object.freeze([
666
+ 'review',
667
+ 'review-policy',
668
+ 'review-mode',
669
+ 'review-modes',
670
+ 'tower-review',
671
+ 'tower-review-policy',
672
+ ])
673
+
674
+ /**
675
+ * #M42: hold the review control to its declared shape. The policy is one
676
+ * tri-state control (`off`/`optional`/`required`) on the plugin-page
677
+ * configuration form, not a component row and not three mutually exclusive rows.
678
+ *
679
+ * The gate fails when a review-policy-shaped row exists, when a component
680
+ * `allowedOverrides` or a shipped row's `config:` block owns `review.mode` (or
681
+ * the deprecated alias), when the non-disableable required-mode gate is not
682
+ * recorded on the forbidden list, and when a forbidden capability claims the
683
+ * reviewer preset row as a disabler. The reviewer row is an optional capability:
684
+ * it may be off while the policy is off or optional, and is only a precondition
685
+ * (with an explanatory refusal) under required.
686
+ *
687
+ * @param {ReturnType<typeof parseComponentSurface>} surface
688
+ * @param {{ patch?: ReturnType<typeof parseCordisPatch> }} live
689
+ */
690
+ export function checkReviewPolicySurface(surface, live) {
691
+ const findings = []
692
+ const record = (code, message) => findings.push(finding(code, 'error', message))
693
+ const components = surface?.components ?? []
694
+ const byName = new Map((surface?.keys ?? []).map((key) => [key.name, key]))
695
+
696
+ for (const name of [REVIEW_POLICY_KEY, REVIEW_ALIAS_KEY]) {
697
+ const key = byName.get(name)
698
+ if (!key) {
699
+ record('review-policy-key', `${name} is missing from the component surface; the review policy needs an explicit class`)
700
+ continue
701
+ }
702
+ if (key.class !== 'core') {
703
+ record(
704
+ 'review-policy-class',
705
+ `${name} is classified "${key.class}"; the review policy is one tri-state configuration control and its keys must be core, never a switch a row can turn off`,
706
+ )
707
+ }
708
+ }
709
+
710
+ const policyRow = components.find((component) => REVIEW_POLICY_ROW_IDS.includes(component.id))
711
+ if (policyRow) {
712
+ record(
713
+ 'review-policy-row',
714
+ `component "${policyRow.id}" is a review-policy row; the review policy is one tri-state control on the plugin-page configuration form, not a component row and never one row per mode`,
715
+ )
716
+ }
717
+
718
+ const reviewerRow = components.find((component) => component.id === REVIEW_REVIEWER_ROW)
719
+ if (!reviewerRow) {
720
+ record(
721
+ 'review-policy-reviewer-row',
722
+ `${REVIEW_REVIEWER_ROW} is missing from the component surface; the reviewer preset must stay an optional capability the review policy may leave unused`,
723
+ )
724
+ }
725
+
726
+ for (const component of components) {
727
+ for (const override of component.allowedOverrides ?? []) {
728
+ if (override !== REVIEW_POLICY_KEY && override !== REVIEW_ALIAS_KEY) continue
729
+ record(
730
+ 'review-policy-switch',
731
+ `component "${component.id}" lists ${override} in allowedOverrides; the review policy is one tri-state configuration control and no component row may own it`,
732
+ )
733
+ }
734
+ }
735
+
736
+ for (const row of live?.patch?.rows ?? []) {
737
+ for (const override of row.configOverrides ?? []) {
738
+ if (override.key !== REVIEW_POLICY_KEY && override.key !== REVIEW_ALIAS_KEY) continue
739
+ record(
740
+ 'review-policy-switch',
741
+ `row "${row.id}" sets ${override.key} in cordis.patch.yml; the review policy is one tri-state configuration control, not a per-row switch`,
742
+ )
743
+ }
744
+ }
745
+
746
+ const gate = (surface?.forbidden ?? []).find((capability) => (capability.gatedBy ?? []).includes(REVIEW_POLICY_KEY))
747
+ if (!gate) {
748
+ record(
749
+ 'review-policy-gate',
750
+ `no forbidden capability records the required-mode review gate via gatedBy ${REVIEW_POLICY_KEY}; required mode must keep its review evidence and exemption path non-disableable`,
751
+ )
752
+ }
753
+
754
+ for (const capability of surface?.forbidden ?? []) {
755
+ if (!(capability.rowIds ?? []).includes(REVIEW_REVIEWER_ROW)) continue
756
+ record(
757
+ 'review-policy-exclusive',
758
+ `forbidden capability "${capability.id}" lists ${REVIEW_REVIEWER_ROW} as a row it may disable; a component may not be mutually exclusive with the review policy, which is one control, not a row`,
759
+ )
760
+ }
761
+
762
+ return findings
168
763
  }
169
764
 
170
765
  /**
171
766
  * Run every component-surface rule against one repository root.
172
767
  * @param {string} root
173
- * @returns {{ ok: boolean, surface: any, live: any, findings: Array<{ code: string, level: string, message: string }> }}
768
+ * @returns {Promise<{ ok: boolean, surface: any, live: any, findings: Array<{ code: string, level: string, message: string }> }>}
174
769
  */
175
- export function checkComponents(root) {
770
+ export async function checkComponents(root) {
176
771
  const findings = []
177
- const live = componentCheckData(root)
772
+ const live = await componentCheckData(root)
178
773
  const surfaceFile = path.join(root, COMPONENT_SURFACE_FILE)
179
774
  if (!fs.existsSync(surfaceFile)) {
180
775
  return {
@@ -196,22 +791,81 @@ export function checkComponents(root) {
196
791
  }
197
792
  }
198
793
  findings.push(...validateComponentSurface(surface, { configKeys: live.configKeys, patch: live.patch }))
199
- findings.push(...checkLocaleCoverage(surface, live.locales))
794
+ // #138.3: every shipped patch is discovered, and every listed patch exists. A
795
+ // new package patch therefore fails until it joins COMPONENT_PATCH_FILES.
796
+ findings.push(...checkShippedPatchCoverage(discoverShippedPatches(root)))
797
+ // #138.1: a boolean (or recorded behaviour knob) cannot hide its capability on
798
+ // the `optional-config` axis without a dated tuning-only exemption.
799
+ findings.push(...checkKeyClassifications(surface, live))
800
+ // #138.4: a row may only override the config keys its component owns.
801
+ findings.push(...checkRowOverrides(surface, live))
802
+ // #134: the root locale `components` block labels the rows that ship in the root
803
+ // patch. A row that ships in its own package (`tower-ui`) is labelled by that
804
+ // package's `meta` block and is checked below instead.
805
+ const rootVisible = live.patches?.[ROOT_PATCH]?.visible ?? []
806
+ findings.push(...checkLocaleCoverage(surface, live.locales, { ids: rootVisible }))
200
807
  // #121: a row that ships a generated `<specifier>/locale/*.json` label needs
201
808
  // the same three strings in both locales, including an internal row (the
202
809
  // mandatory worker preset) that the visible-only rule above does not cover.
203
810
  findings.push(...checkRowLocaleCoverage(surface, live.locales, ROW_LOCALES))
811
+ // #134: every visible row must have a label source; one without is a page that
812
+ // shows only `dsh-tower/board`.
813
+ findings.push(...checkRowLabelSources(live.patch, ROW_LOCALES))
814
+ // #132: the group row's plugin-page description must state its off consequence.
815
+ findings.push(...checkGroupLabelCoverage(surface, live.locales))
816
+ // #134: rows shipping in their own package are labelled by that package's meta.
817
+ const metaRowIds = (live.patch?.rows ?? [])
818
+ .filter((row) => META_LABEL_PATCHES.includes(row.patch) && row.scope === 'visible')
819
+ .map((row) => row.id)
820
+ findings.push(...checkMetaLabelCoverage(metaRowIds, live.metaLocales, OFF_EFFECT_LABEL))
821
+ // #137: the package meta label is hand-written but sourced from the root
822
+ // locale; pin it to the same composition the generator renders.
823
+ findings.push(...checkMetaRowCopy(metaRowIds, live.locales, live.metaLocales, OFF_EFFECT_LABEL))
824
+ // #137: every user-facing label is copy, not a config dump. Check both
825
+ // languages of the root locale, the ui-bundle meta and every generated row
826
+ // locale: plain language, zh/en parity, recorded glossary terms only.
827
+ const labelPairs = [
828
+ {
829
+ en: { file: LOCALE_FILES[0], payload: live.locales[LOCALE_FILES[0]] },
830
+ zh: { file: LOCALE_FILES[1], payload: live.locales[LOCALE_FILES[1]] },
831
+ },
832
+ {
833
+ en: { file: META_LOCALE_FILES[0], payload: live.metaLocales[META_LOCALE_FILES[0]] },
834
+ zh: { file: META_LOCALE_FILES[1], payload: live.metaLocales[META_LOCALE_FILES[1]] },
835
+ },
836
+ ...ROW_LOCALES.map((row) => ({
837
+ en: { file: `locale/${row.specifier}/en.json`, payload: live.rowLocales?.[`locale/${row.specifier}/en.json`] },
838
+ zh: { file: `locale/${row.specifier}/zh.json`, payload: live.rowLocales?.[`locale/${row.specifier}/zh.json`] },
839
+ })),
840
+ ]
841
+ findings.push(...checkLocaleCopy(labelPairs))
842
+ findings.push(...checkGlossaryFile(root))
843
+ // #138.2: the route table is imported, so an absent file, a failed import or a
844
+ // missing/non-Map export fails closed. An empty Map remains the correct
845
+ // determined state (#122): "nothing is gated" is distinct from "could not read".
846
+ if (live.registryError) {
847
+ findings.push(finding('registry-unreadable', 'error', live.registryError))
848
+ }
204
849
  findings.push(...checkForbidden(surface, { booleans: live.booleans, registry: live.registry, patch: live.patch }))
205
-
206
- // #122: an empty OPERATIONAL_TOOLS table is the expected state. Every route
207
- // that does exist must still match the `gateableTools` allowlist, which
208
- // checkForbidden enforces above; there is no "unreadable registry" failure to
209
- // raise when the correct count is zero.
850
+ // #M42: the review policy is one tri-state configuration control, not a row.
851
+ findings.push(...checkReviewPolicySurface(surface, live))
210
852
 
211
853
  // The decision record must exist: the ADR is where the policy is argued.
212
854
  if (!fs.existsSync(path.join(root, surface.adr))) {
213
855
  findings.push(finding('adr-missing', 'error', `component surface names ${surface.adr}, which does not exist`))
214
856
  }
857
+ for (const key of surface.keys) {
858
+ if (!key.exemption) continue
859
+ if (!fs.existsSync(path.join(root, key.exemption.ref))) {
860
+ findings.push(
861
+ finding(
862
+ 'key-exemption-decision',
863
+ 'error',
864
+ `${key.name} keeps optional-config by an exemption recorded in ${key.exemption.ref}, which does not exist`,
865
+ ),
866
+ )
867
+ }
868
+ }
215
869
  for (const capability of surface.forbidden) {
216
870
  const decision = capability.acceptedSwitches?.decision
217
871
  if (!decision) continue
@@ -263,7 +917,7 @@ export function checkComponents(root) {
263
917
  return { ok: findings.every((entry) => entry.level !== 'error'), surface, live, findings }
264
918
  }
265
919
 
266
- function main(argv) {
920
+ async function main(argv) {
267
921
  const options = parseArgs(argv, {
268
922
  initial: { root: PACKAGE_ROOT, json: false, help: false },
269
923
  values: { '--root': { key: 'root', parse: path.resolve } },
@@ -274,7 +928,7 @@ function main(argv) {
274
928
  return 0
275
929
  }
276
930
  const root = path.resolve(options.root)
277
- const report = checkComponents(root)
931
+ const report = await checkComponents(root)
278
932
  if (options.json) {
279
933
  console.log(JSON.stringify({ root, ...report }, null, 2))
280
934
  return report.ok ? 0 : 1