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