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
@@ -0,0 +1,216 @@
1
+ // Component-surface rules split out of `domain/components.js` (#138) so the
2
+ // policy module stays under the architecture aggregate-size brake (800 lines).
3
+ // These are pure checks over the parsed source of truth: a key's class must
4
+ // match its value shape, and a row may only override config its component owns.
5
+ // `domain/components.js` re-exports them for the gate and the tests.
6
+
7
+ // A row's `config:` mapping mixes Tower's config with the row package's own
8
+ // metadata (`id`, `name`, `description`, `order`) and its nested `plugins:` list.
9
+ // Only the Tower config leaves are overrides of the component surface.
10
+ const ROW_CONFIG_METADATA = new Set(['id', 'name', 'description', 'order'])
11
+
12
+ function scalarText(value) {
13
+ const trimmed = String(value ?? '').trim()
14
+ const quoted = /^(['"])(.*)\1$/.exec(trimmed)
15
+ return quoted ? quoted[2] : trimmed
16
+ }
17
+
18
+ /**
19
+ * Read the scalar overrides under a row's own top-level `config:` mapping.
20
+ *
21
+ * The group rows are skipped by the parser: their `config:` is the list of
22
+ * nested component rows, so the overrides belong to the child row, not the group.
23
+ * A `plugins:` list is skipped for the same reason. This is what keeps a
24
+ * legitimate plugin config (`write: false`, `sampleOverCapGlobResults: false`)
25
+ * from being mistaken for a Tower config key, while a removed switch
26
+ * (`docs.board: false`) is captured as `docs.board` and refused.
27
+ *
28
+ * @param {string[]} lines
29
+ * @param {{ indent: number, line: number }} row
30
+ * @returns {Array<{ key: string, value: string }>}
31
+ */
32
+ export function collectConfigOverrides(lines, row) {
33
+ let configIndent = null
34
+ let start = null
35
+ for (let index = row.line + 1; index < lines.length; index += 1) {
36
+ const line = lines[index]
37
+ if (!line.trim() || /^\s*#/.test(line)) continue
38
+ const indent = /^(\s*)/.exec(line)?.[1].length ?? 0
39
+ const nextId = /^\s*-\s+id:/.exec(line)
40
+ if (nextId && indent <= row.indent) break
41
+ if (indent <= row.indent && !/^\s*-\s+/.test(line)) break
42
+ if (/^\s*config:\s*$/.test(line)) {
43
+ configIndent = indent
44
+ start = index + 1
45
+ break
46
+ }
47
+ }
48
+ if (start === null || configIndent === null) return []
49
+ /** @type {Array<{ indent: number, key: string }>} */
50
+ const stack = []
51
+ const overrides = []
52
+ let skipIndent = null
53
+ for (let index = start; index < lines.length; index += 1) {
54
+ const line = lines[index]
55
+ if (!line.trim() || /^\s*#/.test(line)) continue
56
+ const indent = /^(\s*)/.exec(line)?.[1].length ?? 0
57
+ if (indent <= configIndent) break
58
+ if (skipIndent !== null) {
59
+ if (indent > skipIndent) continue
60
+ skipIndent = null
61
+ }
62
+ if (/^\s*-\s+/.test(line)) {
63
+ // A list item: the group config list or a `plugins:` entry. Skip its whole
64
+ // subtree; it does not belong to this row's config.
65
+ skipIndent = indent
66
+ continue
67
+ }
68
+ const match = /^\s*([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line)
69
+ if (!match) continue
70
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop()
71
+ const key = match[1]
72
+ const value = match[2].trim()
73
+ if (value === '') {
74
+ if (key === 'plugins') {
75
+ skipIndent = indent
76
+ continue
77
+ }
78
+ stack.push({ indent, key })
79
+ continue
80
+ }
81
+ const parts = [...stack.map((entry) => entry.key), key]
82
+ if (ROW_CONFIG_METADATA.has(parts[0]) || parts[0] === 'plugins') continue
83
+ overrides.push({ key: parts.join('.'), value: scalarText(value) })
84
+ }
85
+ return overrides
86
+ }
87
+
88
+ /**
89
+ * #138.1: the class is not free text. A boolean key that removes a capability
90
+ * cannot file as `optional-config`; it must be `core`, `optional-component` or
91
+ * `forbidden-to-disable`, or carry a dated tuning-only exemption. The same rule
92
+ * covers the non-boolean behaviour knobs recorded in `behaviorKnobs`, so a
93
+ * string/union switch cannot hide on the tuning axis.
94
+ *
95
+ * @param {{ keys: Array<{ name: string, class: string, exemption?: any }>, behaviorKnobs?: string[] }} surface
96
+ * @param {{ keyTypes?: Record<string, string> }} live live schema key -> type
97
+ * @returns {Array<{ code: string, level: string, message: string }>}
98
+ */
99
+ export function checkKeyClassifications(surface, live = {}) {
100
+ const findings = []
101
+ const record = (code, message) => findings.push({ code, level: 'error', message })
102
+ const declared = new Map(surface.keys.map((key) => [key.name, key]))
103
+ const types = live.keyTypes ?? {}
104
+ const behavior = new Set(surface.behaviorKnobs ?? [])
105
+ const reviewedClasses = ['core', 'optional-component', 'forbidden-to-disable']
106
+ for (const name of behavior) {
107
+ if (!declared.has(name)) {
108
+ record('behavior-knob-unknown', `behaviorKnobs records ${name}, which has no component-axis class`)
109
+ }
110
+ if (types[name] === undefined) {
111
+ record('behavior-knob-unknown', `behaviorKnobs records ${name}, which is not a live config key`)
112
+ }
113
+ }
114
+ for (const key of surface.keys) {
115
+ const type = types[key.name]
116
+ const isBoolean = type === 'boolean'
117
+ const isBehavior = behavior.has(key.name)
118
+ if (isBoolean || isBehavior) {
119
+ if (key.class === 'optional-config') {
120
+ if (!key.exemption) {
121
+ record(
122
+ 'boolean-class',
123
+ `${key.name} is a ${isBoolean ? 'boolean' : 'recorded behaviour knob'} classified optional-config; it needs core/optional-component/forbidden-to-disable or a dated tuning-only exemption`,
124
+ )
125
+ } else if (!/tuning|tune/i.test(key.exemption.reason)) {
126
+ record(
127
+ 'boolean-exemption',
128
+ `${key.name} keeps optional-config but its exemption reason does not name a tuning-only nature: ${key.exemption.reason}`,
129
+ )
130
+ }
131
+ } else if (!reviewedClasses.includes(key.class)) {
132
+ record('boolean-class', `${key.name} is a ${isBoolean ? 'boolean' : 'behaviour knob'} with unknown class "${key.class}"`)
133
+ }
134
+ }
135
+ if (key.exemption) {
136
+ if (!isBoolean && !isBehavior) {
137
+ record('exemption-invalid', `${key.name} carries a tuning exemption but is neither a boolean nor a recorded behaviour knob`)
138
+ }
139
+ if (key.class !== 'optional-config') {
140
+ record('exemption-invalid', `${key.name} carries a tuning exemption but is classified "${key.class}", not optional-config`)
141
+ }
142
+ if (!/tuning|tune/i.test(key.exemption.reason)) {
143
+ record('exemption-invalid', `${key.name} exemption reason must name its tuning-only nature: ${key.exemption.reason}`)
144
+ }
145
+ }
146
+ }
147
+ return findings
148
+ }
149
+
150
+ /**
151
+ * #138.4: a row may only override config it owns. The parser captures the scalar
152
+ * leaves under the row's own `config:` mapping; each one must be a live config
153
+ * key (so a removed switch such as `docs.board:false` fails) and must be listed
154
+ * in that component's `allowedOverrides` (so a row cannot enable or disable a
155
+ * capability it does not own). `allowedOverrides` itself is checked in the other
156
+ * direction: every listed key must be live and must not be forbidden-to-disable.
157
+ *
158
+ * @param {{ keys: Array<{ name: string, class: string }>, components: Array<{ id: string, allowedOverrides?: string[] }> }} surface
159
+ * @param {{ configKeys?: string[], patch?: { rows?: Array<{ id: string, configOverrides?: Array<{ key: string, value: string }> }> } }} live
160
+ * @returns {Array<{ code: string, level: string, message: string }>}
161
+ */
162
+ export function checkRowOverrides(surface, live = {}) {
163
+ const findings = []
164
+ const record = (code, message) => findings.push({ code, level: 'error', message })
165
+ const rows = live.patch?.rows ?? []
166
+ const liveKeys = new Set(live.configKeys ?? [])
167
+ const keyClass = new Map(surface.keys.map((key) => [key.name, key.class]))
168
+ const classified = new Map(surface.components.map((component) => [component.id, component]))
169
+ for (const component of surface.components) {
170
+ const allowed = component.allowedOverrides ?? []
171
+ const seen = new Set()
172
+ for (const key of allowed) {
173
+ if (seen.has(key)) record('row-override-duplicate', `component "${component.id}" lists ${key} twice in allowedOverrides`)
174
+ seen.add(key)
175
+ if (!liveKeys.has(key)) {
176
+ record('component-override-unknown', `component "${component.id}" allows ${key}, which is not a live config key`)
177
+ }
178
+ if (keyClass.get(key) === 'forbidden-to-disable') {
179
+ record('row-override-forbidden', `component "${component.id}" allows ${key}, which is classified forbidden-to-disable`)
180
+ }
181
+ }
182
+ }
183
+ for (const row of rows) {
184
+ const component = classified.get(row.id)
185
+ if (!component) continue
186
+ const allowed = new Set(component.allowedOverrides ?? [])
187
+ for (const entry of row.configOverrides ?? []) {
188
+ const key = entry?.key
189
+ if (typeof key !== 'string' || !key) {
190
+ record('row-override-invalid', `row "${row.id}" has an override without a config key`)
191
+ continue
192
+ }
193
+ if (!liveKeys.has(key)) {
194
+ record(
195
+ 'row-override-unknown',
196
+ `row "${row.id}" sets ${key}:${entry.value}; it is not a live config key (a switch removed in favour of a component must not keep working from a row config)`,
197
+ )
198
+ continue
199
+ }
200
+ if (keyClass.get(key) === 'forbidden-to-disable') {
201
+ record(
202
+ 'row-override-forbidden',
203
+ `row "${row.id}" sets ${key}:${entry.value}; a component may not switch off a forbidden-to-disable key`,
204
+ )
205
+ continue
206
+ }
207
+ if (!allowed.has(key)) {
208
+ record(
209
+ 'row-override-foreign',
210
+ `row "${row.id}" sets ${key}:${entry.value}; that key is outside the overrides allowed for component "${component.id}"`,
211
+ )
212
+ }
213
+ }
214
+ }
215
+ return findings
216
+ }
@@ -12,6 +12,12 @@
12
12
  //
13
13
  // This module is pure: no fs, no git. `scripts/check-components.mjs` supplies the
14
14
  // live schema, `cordis.patch.yml`, the locale files and the baseline.
15
+ //
16
+ // #138: the class/row-override rules live in `component-rules.js` so this module
17
+ // stays under the architecture aggregate-size brake; they are re-exported here.
18
+ import { collectConfigOverrides } from './component-rules.js'
19
+ export { checkKeyClassifications, checkRowOverrides } from './component-rules.js'
20
+
15
21
  export const COMPONENT_SURFACE_FILE = 'scripts/component-surface.json'
16
22
  export const COMPONENT_REFERENCE_FILE = 'docs/reference/component-surface.md'
17
23
  export const COMPONENT_SCHEMA_VERSION = 1
@@ -20,6 +26,13 @@ export const COMPONENT_SCOPES = ['visible', 'internal']
20
26
  export const COMPONENT_KINDS = ['group', 'row']
21
27
  export const LOCALE_FILES = ['locale/en.json', 'locale/zh.json']
22
28
  export const LOCALE_FIELDS = ['title', 'summary', 'disables']
29
+ // #134: every shipped Cordis patch has component rows, not only the root one.
30
+ // `ui-bundle/cordis.patch.yml` ships the `tower-ui` row. Labels for its rows live
31
+ // in that package's own locale files (a DSH row renders `meta.title` /
32
+ // `meta.description`, not the root `components` block).
33
+ export const COMPONENT_PATCH_FILES = ['cordis.patch.yml', 'ui-bundle/cordis.patch.yml']
34
+ export const META_LABEL_PATCHES = ['ui-bundle/cordis.patch.yml']
35
+ export const META_LOCALE_FILES = ['ui-bundle/locale/en.json', 'ui-bundle/locale/zh.json']
23
36
 
24
37
  const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
25
38
 
@@ -58,6 +71,7 @@ function normalizeComponent(entry, index) {
58
71
  scope,
59
72
  kind,
60
73
  fallback: text(entry.fallback, `components[${index}].fallback`),
74
+ allowedOverrides: stringList(entry.allowedOverrides, `components[${index}].allowedOverrides`),
61
75
  }
62
76
  }
63
77
 
@@ -71,6 +85,10 @@ function normalizeKey(entry, index) {
71
85
  name: text(entry.name, `keys[${index}].name`),
72
86
  class: className,
73
87
  note: text(entry.note, `keys[${index}].note`),
88
+ // #138: an `optional-config` boolean (or a recorded behaviour knob) needs a
89
+ // dated reason for staying a tuning key; the gate refuses the classification
90
+ // without it.
91
+ exemption: entry.exemption === undefined ? null : normalizeDecision(entry.exemption, `keys[${index}].exemption`),
74
92
  }
75
93
  }
76
94
 
@@ -134,35 +152,6 @@ function normalizeGateableTools(value) {
134
152
  })
135
153
  }
136
154
 
137
- /**
138
- * Parse the static `host/tool-registry.js` route table that lets a boolean
139
- * remove a tool. This is the denylist's live input, so it is deliberately
140
- * tolerant about formatting (F11): it accepts a tuple with a trailing comma and
141
- * a `Map.set(...)` route, so a reformatted or rewritten table cannot fall out of
142
- * the allowlist and silently legalise a forbidden switch.
143
- *
144
- * @param {string} source the registry module text
145
- * @returns {Record<string, string>} tool name -> expose flag (without `tools.`)
146
- */
147
- export function parseOperationalRegistry(source) {
148
- const text = String(source ?? '')
149
- /** @type {Record<string, string>} */
150
- const registry = {}
151
- // Tuple form: ['tower_batch', 'exposeBatch'] with an optional trailing comma.
152
- for (const match of text.matchAll(
153
- /\[\s*['"](tower_[a-z0-9_]+)['"]\s*,\s*['"](expose[A-Za-z0-9]+)['"]\s*,?\s*\]/g,
154
- )) {
155
- registry[match[1]] = match[2]
156
- }
157
- // Map form: OPERATIONAL_TOOLS.set('tower_batch', 'exposeBatch').
158
- for (const match of text.matchAll(
159
- /OPERATIONAL_TOOLS\s*\.\s*set\(\s*['"](tower_[a-z0-9_]+)['"]\s*,\s*['"](expose[A-Za-z0-9]+)['"]\s*\)/g,
160
- )) {
161
- registry[match[1]] = match[2]
162
- }
163
- return registry
164
- }
165
-
166
155
  /**
167
156
  * Parse and validate the single source of truth. Structural problems throw with
168
157
  * an actionable message; semantic problems are findings from the validators
@@ -203,6 +192,9 @@ export function parseComponentSurface(source) {
203
192
  const keys = value.keys.map(normalizeKey)
204
193
  const forbidden = Array.isArray(value.forbidden) ? value.forbidden.map(normalizeForbidden) : []
205
194
  const gateableTools = normalizeGateableTools(value.gateableTools)
195
+ // #138: non-boolean keys that gate behaviour cannot hide behind
196
+ // `optional-config`; they are reviewed on the same axis as booleans.
197
+ const behaviorKnobs = stringList(value.behaviorKnobs, 'component surface behaviorKnobs')
206
198
  const seen = (list, label) => {
207
199
  const names = new Set()
208
200
  for (const entry of list) {
@@ -220,7 +212,17 @@ export function parseComponentSurface(source) {
220
212
  if (!components.some((component) => component.scope === 'visible')) {
221
213
  throw componentError('component surface needs at least one visible component')
222
214
  }
223
- return { schema: COMPONENT_SCHEMA_VERSION, date, adr, classes, components, keys, gateableTools, forbidden }
215
+ return {
216
+ schema: COMPONENT_SCHEMA_VERSION,
217
+ date,
218
+ adr,
219
+ classes,
220
+ components,
221
+ keys,
222
+ behaviorKnobs,
223
+ gateableTools,
224
+ forbidden,
225
+ }
224
226
  }
225
227
 
226
228
  /**
@@ -232,13 +234,13 @@ export function parseComponentSurface(source) {
232
234
  * sections rather than a single plugin.
233
235
  *
234
236
  * @param {string} text
235
- * @returns {{ rows: Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[] }>, visible: string[] }}
237
+ * @returns {{ rows: Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], configOverrides: Array<{ key: string, value: string }>, patch?: string }>, visible: string[] }}
236
238
  */
237
239
  export function parseCordisPatch(text) {
238
240
  const lines = String(text ?? '').split(/\r?\n/)
239
241
  const insertIndex = lines.findIndex((line) => /^\s*-\s+insert:\s*$/.test(line))
240
242
  if (insertIndex < 0) return { rows: [], visible: [] }
241
- /** @type {Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], indent: number, line: number }>} */
243
+ /** @type {Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], configOverrides: Array<{ key: string, value: string }>, indent: number, line: number }>} */
242
244
  const rows = []
243
245
  const stack = []
244
246
  let rowIndent = null
@@ -263,6 +265,7 @@ export function parseCordisPatch(text) {
263
265
  kind: 'row',
264
266
  group: false,
265
267
  overrides: [],
268
+ configOverrides: [],
266
269
  indent,
267
270
  line: index,
268
271
  })
@@ -287,11 +290,10 @@ export function parseCordisPatch(text) {
287
290
  }
288
291
  row.group = block.some((line) => /^\s*group:\s*true\s*$/.test(line))
289
292
  row.kind = row.group ? 'group' : 'row'
290
- const overrides = new Set()
291
- for (const line of block) {
292
- for (const match of line.matchAll(/([A-Za-z0-9_-]+)\s*:\s*false\b/g)) overrides.add(match[1])
293
- }
294
- row.overrides = [...overrides]
293
+ row.configOverrides = row.group ? [] : collectConfigOverrides(lines, row)
294
+ // #138: keep the false-valued paths for the forbidden denylist, and the full
295
+ // override set for the "row matches the capability it owns" check.
296
+ row.overrides = row.configOverrides.filter((entry) => entry.value === 'false').map((entry) => entry.key)
295
297
  }
296
298
  return {
297
299
  rows: rows.map(({ line, indent, ...row }) => row),
@@ -299,13 +301,32 @@ export function parseCordisPatch(text) {
299
301
  }
300
302
  }
301
303
 
304
+ /**
305
+ * Merge every shipped patch into the one live shape the policy checks compare
306
+ * against. Rows keep the file they came from, so a finding can name it.
307
+ *
308
+ * @param {Record<string, ReturnType<typeof parseCordisPatch>>} patches file -> parsed patch
309
+ * @returns {{ rows: Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], patch: string }>, visible: string[] }}
310
+ */
311
+ export function mergeCordisPatches(patches = {}) {
312
+ const files = Object.keys(patches)
313
+ const rows = []
314
+ for (const file of files) {
315
+ for (const row of patches[file]?.rows ?? []) rows.push({ ...row, patch: file })
316
+ }
317
+ return {
318
+ rows,
319
+ visible: rows.filter((row) => row.scope === 'visible').map((row) => row.id),
320
+ }
321
+ }
322
+
302
323
  /**
303
324
  * The shipped shape and the classification must describe the same components and
304
325
  * config keys. This is the parity half of the gate: a row without a class, or a
305
326
  * class without a row, is an unowned decision.
306
327
  *
307
328
  * @param {ReturnType<typeof parseComponentSurface>} surface
308
- * @param {{ configKeys: string[], patch: ReturnType<typeof parseCordisPatch> }} live
329
+ * @param {{ configKeys: string[], patch: ReturnType<typeof mergeCordisPatches> }} live
309
330
  */
310
331
  export function validateComponentSurface(surface, live) {
311
332
  const findings = []
@@ -315,25 +336,25 @@ export function validateComponentSurface(surface, live) {
315
336
  for (const component of surface.components) {
316
337
  const row = shipped.get(component.id)
317
338
  if (!row) {
318
- record('component-missing', `component surface lists "${component.id}" but cordis.patch.yml does not ship it`)
339
+ record('component-missing', `component surface lists "${component.id}" but no shipped cordis.patch.yml defines it`)
319
340
  continue
320
341
  }
321
342
  if (row.scope !== component.scope) {
322
343
  record(
323
344
  'component-scope',
324
- `component "${component.id}" is ${component.scope} in the source of truth but ${row.scope} in cordis.patch.yml`,
345
+ `component "${component.id}" is ${component.scope} in the source of truth but ${row.scope} in ${row.patch ?? 'cordis.patch.yml'}`,
325
346
  )
326
347
  }
327
348
  if (row.kind !== component.kind) {
328
349
  record(
329
350
  'component-kind',
330
- `component "${component.id}" is a ${component.kind} in the source of truth but a ${row.kind} in cordis.patch.yml`,
351
+ `component "${component.id}" is a ${component.kind} in the source of truth but a ${row.kind} in ${row.patch ?? 'cordis.patch.yml'}`,
331
352
  )
332
353
  }
333
354
  }
334
355
  for (const row of live.patch.rows) {
335
356
  if (!classified.has(row.id)) {
336
- record('component-unclassified', `cordis.patch.yml ships component "${row.id}" but the source of truth does not classify it`)
357
+ record('component-unclassified', `${row.patch ?? 'cordis.patch.yml'} ships component "${row.id}" but the source of truth does not classify it`)
337
358
  }
338
359
  }
339
360
  const declared = new Set(surface.keys.map((key) => key.name))
@@ -364,15 +385,22 @@ export function validateComponentSurface(surface, live) {
364
385
  * shipped locale. The locale set itself must only name known components, so a
365
386
  * typo cannot pass as coverage.
366
387
  *
388
+ * `options.ids` restricts the required set to the components whose labels live in
389
+ * these locale files. #134: the `tower-ui` row ships in `ui-bundle/` and is
390
+ * labelled by that package's own `meta` block, not by the root `components` block.
391
+ *
367
392
  * @param {ReturnType<typeof parseComponentSurface>} surface
368
393
  * @param {Record<string, any>} locales file path -> parsed locale JSON
394
+ * @param {{ ids?: string[] }} [options]
369
395
  */
370
- export function checkLocaleCoverage(surface, locales) {
396
+ export function checkLocaleCoverage(surface, locales, options = {}) {
371
397
  const findings = []
372
398
  const record = (code, message) => findings.push({ code, level: 'error', message })
373
399
  const known = new Set(surface.components.map((component) => component.id))
400
+ const required = options.ids === undefined ? null : new Set(options.ids)
374
401
  for (const component of surface.components) {
375
402
  if (component.scope !== 'visible') continue
403
+ if (required && !required.has(component.id)) continue
376
404
  for (const file of LOCALE_FILES) {
377
405
  const entry = locales?.[file]?.components?.[component.id]
378
406
  if (!entry || typeof entry !== 'object') {
@@ -393,6 +421,75 @@ export function checkLocaleCoverage(surface, locales) {
393
421
  return findings
394
422
  }
395
423
 
424
+ /**
425
+ * #132: the plugin page renders a group row from the root locale `meta.title` /
426
+ * `meta.description`. The off consequence of the `tower` group (off equals
427
+ * uninstalling Tower) must therefore appear in `meta.description`, in every
428
+ * shipped language — otherwise the most important consequence is dead copy.
429
+ *
430
+ * @param {ReturnType<typeof parseComponentSurface>} surface
431
+ * @param {Record<string, any>} locales file path -> parsed locale JSON
432
+ */
433
+ export function checkGroupLabelCoverage(surface, locales) {
434
+ const findings = []
435
+ const record = (code, message) => findings.push({ code, level: 'error', message })
436
+ for (const component of surface.components) {
437
+ if (component.scope !== 'visible' || component.kind !== 'group') continue
438
+ for (const file of LOCALE_FILES) {
439
+ const meta = locales?.[file]?.meta
440
+ const title = typeof meta?.title === 'string' ? meta.title.trim() : ''
441
+ const description = typeof meta?.description === 'string' ? meta.description.trim() : ''
442
+ if (!title) record('group-locale', `${file} misses meta.title for group component "${component.id}"`)
443
+ if (!description) {
444
+ record('group-locale', `${file} misses meta.description for group component "${component.id}"`)
445
+ continue
446
+ }
447
+ const disables = locales?.[file]?.components?.[component.id]?.disables
448
+ const consequence = typeof disables === 'string' ? disables.trim() : ''
449
+ if (!consequence) continue
450
+ if (!description.includes(consequence)) {
451
+ record(
452
+ 'group-locale',
453
+ `${file} meta.description does not state the off consequence of group component "${component.id}"`,
454
+ )
455
+ }
456
+ }
457
+ }
458
+ return findings
459
+ }
460
+
461
+ /**
462
+ * #134: a row that ships in its own package is labelled by that package's
463
+ * `meta.title` / `meta.description`, and DSH shows the "when off" consequence in
464
+ * the description. Require both meta strings and the off marker for every
465
+ * visible row of a meta-labelled patch, in every language file it ships.
466
+ *
467
+ * @param {string[]} ids row ids whose labels live in `locales`
468
+ * @param {Record<string, any>} locales file path -> parsed locale JSON
469
+ * @param {Record<string, string>} markers language -> off-effect marker
470
+ */
471
+ export function checkMetaLabelCoverage(ids, locales, markers) {
472
+ const findings = []
473
+ const record = (code, message) => findings.push({ code, level: 'error', message })
474
+ for (const id of ids) {
475
+ for (const [file, payload] of Object.entries(locales ?? {})) {
476
+ const lang = /([a-z]{2})\.json$/.exec(file)?.[1] ?? ''
477
+ const marker = markers?.[lang] ?? ''
478
+ const title = typeof payload?.meta?.title === 'string' ? payload.meta.title.trim() : ''
479
+ const description = typeof payload?.meta?.description === 'string' ? payload.meta.description.trim() : ''
480
+ if (!title) record('meta-locale', `row "${id}" misses meta.title in ${file}`)
481
+ if (!description) {
482
+ record('meta-locale', `row "${id}" misses meta.description in ${file}`)
483
+ continue
484
+ }
485
+ if (marker && !description.includes(marker)) {
486
+ record('meta-locale', `row "${id}" meta.description does not state the off consequence in ${file}`)
487
+ }
488
+ }
489
+ }
490
+ return findings
491
+ }
492
+
396
493
  /**
397
494
  * The denylist. The host, the health contract, the recovery escape, the review
398
495
  * contract and the merge gates may not be turned off by any group, row or
@@ -427,15 +524,17 @@ export function checkForbidden(surface, live) {
427
524
  // row, whatever capability owns it. `gatedBy` is folded in so a contract key
428
525
  // recorded only on the capability still resolves to its full name.
429
526
  const forbiddenLeaves = new Map()
527
+ const remember = (name) => {
528
+ const leaf = name.split('.').pop()
529
+ if (!forbiddenLeaves.has(name)) forbiddenLeaves.set(name, name)
530
+ if (!forbiddenLeaves.has(leaf)) forbiddenLeaves.set(leaf, name)
531
+ }
430
532
  for (const key of surface.keys) {
431
533
  if (key.class !== 'forbidden-to-disable') continue
432
- forbiddenLeaves.set(key.name.split('.').pop(), key.name)
534
+ remember(key.name)
433
535
  }
434
536
  for (const capability of surface.forbidden) {
435
- for (const key of capability.gatedBy ?? []) {
436
- const leaf = key.split('.').pop()
437
- if (!forbiddenLeaves.has(leaf)) forbiddenLeaves.set(leaf, key)
438
- }
537
+ for (const key of capability.gatedBy ?? []) remember(key)
439
538
  }
440
539
  for (const row of rows) {
441
540
  if (row.scope === 'visible' && forbiddenRowIds.has(row.id)) {
@@ -547,7 +646,17 @@ export function renderComponentSurface(surface, options = {}) {
547
646
  `| \`${component.id}\` | ${component.scope} | ${component.kind} | ${component.fallback} |`,
548
647
  )
549
648
  .join('\n')
550
- const keyRows = surface.keys.map((key) => `| \`${key.name}\` | \`${key.class}\` | ${key.note} |`).join('\n')
649
+ const keyRows = surface.keys
650
+ .map((key) => {
651
+ const exemption = key.exemption
652
+ ? ` Tuning exemption: ${key.exemption.reason} (${key.exemption.date}${key.exemption.issue ? ` · ${key.exemption.issue}` : ''} · [${decidedIn(key.exemption.ref)}](${relativeAdr(key.exemption.ref)}))`
653
+ : ''
654
+ return `| \`${key.name}\` | \`${key.class}\` | ${key.note}${exemption} |`
655
+ })
656
+ .join('\n')
657
+ const behaviorRows = (surface.behaviorKnobs ?? [])
658
+ .map((name) => `| \`${name}\` |`)
659
+ .join('\n') || '| _(none)_ |'
551
660
  const forbiddenRows = surface.forbidden
552
661
  .map((capability) => {
553
662
  const decision = capability.acceptedSwitches?.decision
@@ -621,6 +730,17 @@ All keys from \`domain/config/schema.js\`, classified on the component axis.
621
730
  |---|---|---|
622
731
  ${keyRows}
623
732
 
733
+ ## Behaviour knobs
734
+
735
+ Non-boolean keys that gate behaviour are reviewed on the same axis as booleans
736
+ (#138): each must be \`core\`, \`optional-component\`, \`forbidden-to-disable\`, or an
737
+ \`optional-config\` key with a dated tuning-only exemption. A string or union that
738
+ hides a capability cannot use the tuning class without that record.
739
+
740
+ | Key |
741
+ |---|
742
+ ${behaviorRows}
743
+
624
744
  ## Non-goals
625
745
 
626
746
  This page does not decide whether a key is useful, and it is not an approval