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
@@ -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
+ }
@@ -3,8 +3,11 @@
3
3
  // Principle 11 says optional capabilities are switches and the core is not. This
4
4
  // module is that policy as data plus the pure checks over it: the component rows
5
5
  // in `cordis.patch.yml`, the four-way classification of every config key, and
6
- // the forbidden-to-disable capabilities (host, health, recovery, review contract,
7
- // merge gates) that no group, row or boolean key may gate.
6
+ // the forbidden-to-disable capabilities (host, health, recovery, merge gates)
7
+ // that no group, row or boolean key may gate. The review policy is not one of
8
+ // those never-switches: `review.mode` is the one tri-state control, and only the
9
+ // gate it turns on in `required` mode (evidence, tip binding, exemption path) is
10
+ // non-disableable.
8
11
  //
9
12
  // The single source of truth is `scripts/component-surface.json`. The generated
10
13
  // reference `docs/reference/component-surface.md` is a rendering of it, so
@@ -12,6 +15,12 @@
12
15
  //
13
16
  // This module is pure: no fs, no git. `scripts/check-components.mjs` supplies the
14
17
  // live schema, `cordis.patch.yml`, the locale files and the baseline.
18
+ //
19
+ // #138: the class/row-override rules live in `component-rules.js` so this module
20
+ // stays under the architecture aggregate-size brake; they are re-exported here.
21
+ import { collectConfigOverrides } from './component-rules.js'
22
+ export { checkKeyClassifications, checkRowOverrides } from './component-rules.js'
23
+
15
24
  export const COMPONENT_SURFACE_FILE = 'scripts/component-surface.json'
16
25
  export const COMPONENT_REFERENCE_FILE = 'docs/reference/component-surface.md'
17
26
  export const COMPONENT_SCHEMA_VERSION = 1
@@ -20,6 +29,13 @@ export const COMPONENT_SCOPES = ['visible', 'internal']
20
29
  export const COMPONENT_KINDS = ['group', 'row']
21
30
  export const LOCALE_FILES = ['locale/en.json', 'locale/zh.json']
22
31
  export const LOCALE_FIELDS = ['title', 'summary', 'disables']
32
+ // #134: every shipped Cordis patch has component rows, not only the root one.
33
+ // `ui-bundle/cordis.patch.yml` ships the `tower-ui` row. Labels for its rows live
34
+ // in that package's own locale files (a DSH row renders `meta.title` /
35
+ // `meta.description`, not the root `components` block).
36
+ export const COMPONENT_PATCH_FILES = ['cordis.patch.yml', 'ui-bundle/cordis.patch.yml']
37
+ export const META_LABEL_PATCHES = ['ui-bundle/cordis.patch.yml']
38
+ export const META_LOCALE_FILES = ['ui-bundle/locale/en.json', 'ui-bundle/locale/zh.json']
23
39
 
24
40
  const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
25
41
 
@@ -58,6 +74,7 @@ function normalizeComponent(entry, index) {
58
74
  scope,
59
75
  kind,
60
76
  fallback: text(entry.fallback, `components[${index}].fallback`),
77
+ allowedOverrides: stringList(entry.allowedOverrides, `components[${index}].allowedOverrides`),
61
78
  }
62
79
  }
63
80
 
@@ -71,6 +88,10 @@ function normalizeKey(entry, index) {
71
88
  name: text(entry.name, `keys[${index}].name`),
72
89
  class: className,
73
90
  note: text(entry.note, `keys[${index}].note`),
91
+ // #138: an `optional-config` boolean (or a recorded behaviour knob) needs a
92
+ // dated reason for staying a tuning key; the gate refuses the classification
93
+ // without it.
94
+ exemption: entry.exemption === undefined ? null : normalizeDecision(entry.exemption, `keys[${index}].exemption`),
74
95
  }
75
96
  }
76
97
 
@@ -134,35 +155,6 @@ function normalizeGateableTools(value) {
134
155
  })
135
156
  }
136
157
 
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
158
  /**
167
159
  * Parse and validate the single source of truth. Structural problems throw with
168
160
  * an actionable message; semantic problems are findings from the validators
@@ -203,6 +195,9 @@ export function parseComponentSurface(source) {
203
195
  const keys = value.keys.map(normalizeKey)
204
196
  const forbidden = Array.isArray(value.forbidden) ? value.forbidden.map(normalizeForbidden) : []
205
197
  const gateableTools = normalizeGateableTools(value.gateableTools)
198
+ // #138: non-boolean keys that gate behaviour cannot hide behind
199
+ // `optional-config`; they are reviewed on the same axis as booleans.
200
+ const behaviorKnobs = stringList(value.behaviorKnobs, 'component surface behaviorKnobs')
206
201
  const seen = (list, label) => {
207
202
  const names = new Set()
208
203
  for (const entry of list) {
@@ -220,7 +215,17 @@ export function parseComponentSurface(source) {
220
215
  if (!components.some((component) => component.scope === 'visible')) {
221
216
  throw componentError('component surface needs at least one visible component')
222
217
  }
223
- return { schema: COMPONENT_SCHEMA_VERSION, date, adr, classes, components, keys, gateableTools, forbidden }
218
+ return {
219
+ schema: COMPONENT_SCHEMA_VERSION,
220
+ date,
221
+ adr,
222
+ classes,
223
+ components,
224
+ keys,
225
+ behaviorKnobs,
226
+ gateableTools,
227
+ forbidden,
228
+ }
224
229
  }
225
230
 
226
231
  /**
@@ -232,13 +237,13 @@ export function parseComponentSurface(source) {
232
237
  * sections rather than a single plugin.
233
238
  *
234
239
  * @param {string} text
235
- * @returns {{ rows: Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[] }>, visible: string[] }}
240
+ * @returns {{ rows: Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], configOverrides: Array<{ key: string, value: string }>, patch?: string }>, visible: string[] }}
236
241
  */
237
242
  export function parseCordisPatch(text) {
238
243
  const lines = String(text ?? '').split(/\r?\n/)
239
244
  const insertIndex = lines.findIndex((line) => /^\s*-\s+insert:\s*$/.test(line))
240
245
  if (insertIndex < 0) return { rows: [], visible: [] }
241
- /** @type {Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], indent: number, line: number }>} */
246
+ /** @type {Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], configOverrides: Array<{ key: string, value: string }>, indent: number, line: number }>} */
242
247
  const rows = []
243
248
  const stack = []
244
249
  let rowIndent = null
@@ -263,6 +268,7 @@ export function parseCordisPatch(text) {
263
268
  kind: 'row',
264
269
  group: false,
265
270
  overrides: [],
271
+ configOverrides: [],
266
272
  indent,
267
273
  line: index,
268
274
  })
@@ -287,11 +293,10 @@ export function parseCordisPatch(text) {
287
293
  }
288
294
  row.group = block.some((line) => /^\s*group:\s*true\s*$/.test(line))
289
295
  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]
296
+ row.configOverrides = row.group ? [] : collectConfigOverrides(lines, row)
297
+ // #138: keep the false-valued paths for the forbidden denylist, and the full
298
+ // override set for the "row matches the capability it owns" check.
299
+ row.overrides = row.configOverrides.filter((entry) => entry.value === 'false').map((entry) => entry.key)
295
300
  }
296
301
  return {
297
302
  rows: rows.map(({ line, indent, ...row }) => row),
@@ -299,13 +304,32 @@ export function parseCordisPatch(text) {
299
304
  }
300
305
  }
301
306
 
307
+ /**
308
+ * Merge every shipped patch into the one live shape the policy checks compare
309
+ * against. Rows keep the file they came from, so a finding can name it.
310
+ *
311
+ * @param {Record<string, ReturnType<typeof parseCordisPatch>>} patches file -> parsed patch
312
+ * @returns {{ rows: Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], patch: string }>, visible: string[] }}
313
+ */
314
+ export function mergeCordisPatches(patches = {}) {
315
+ const files = Object.keys(patches)
316
+ const rows = []
317
+ for (const file of files) {
318
+ for (const row of patches[file]?.rows ?? []) rows.push({ ...row, patch: file })
319
+ }
320
+ return {
321
+ rows,
322
+ visible: rows.filter((row) => row.scope === 'visible').map((row) => row.id),
323
+ }
324
+ }
325
+
302
326
  /**
303
327
  * The shipped shape and the classification must describe the same components and
304
328
  * config keys. This is the parity half of the gate: a row without a class, or a
305
329
  * class without a row, is an unowned decision.
306
330
  *
307
331
  * @param {ReturnType<typeof parseComponentSurface>} surface
308
- * @param {{ configKeys: string[], patch: ReturnType<typeof parseCordisPatch> }} live
332
+ * @param {{ configKeys: string[], patch: ReturnType<typeof mergeCordisPatches> }} live
309
333
  */
310
334
  export function validateComponentSurface(surface, live) {
311
335
  const findings = []
@@ -315,25 +339,25 @@ export function validateComponentSurface(surface, live) {
315
339
  for (const component of surface.components) {
316
340
  const row = shipped.get(component.id)
317
341
  if (!row) {
318
- record('component-missing', `component surface lists "${component.id}" but cordis.patch.yml does not ship it`)
342
+ record('component-missing', `component surface lists "${component.id}" but no shipped cordis.patch.yml defines it`)
319
343
  continue
320
344
  }
321
345
  if (row.scope !== component.scope) {
322
346
  record(
323
347
  'component-scope',
324
- `component "${component.id}" is ${component.scope} in the source of truth but ${row.scope} in cordis.patch.yml`,
348
+ `component "${component.id}" is ${component.scope} in the source of truth but ${row.scope} in ${row.patch ?? 'cordis.patch.yml'}`,
325
349
  )
326
350
  }
327
351
  if (row.kind !== component.kind) {
328
352
  record(
329
353
  'component-kind',
330
- `component "${component.id}" is a ${component.kind} in the source of truth but a ${row.kind} in cordis.patch.yml`,
354
+ `component "${component.id}" is a ${component.kind} in the source of truth but a ${row.kind} in ${row.patch ?? 'cordis.patch.yml'}`,
331
355
  )
332
356
  }
333
357
  }
334
358
  for (const row of live.patch.rows) {
335
359
  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`)
360
+ record('component-unclassified', `${row.patch ?? 'cordis.patch.yml'} ships component "${row.id}" but the source of truth does not classify it`)
337
361
  }
338
362
  }
339
363
  const declared = new Set(surface.keys.map((key) => key.name))
@@ -364,15 +388,22 @@ export function validateComponentSurface(surface, live) {
364
388
  * shipped locale. The locale set itself must only name known components, so a
365
389
  * typo cannot pass as coverage.
366
390
  *
391
+ * `options.ids` restricts the required set to the components whose labels live in
392
+ * these locale files. #134: the `tower-ui` row ships in `ui-bundle/` and is
393
+ * labelled by that package's own `meta` block, not by the root `components` block.
394
+ *
367
395
  * @param {ReturnType<typeof parseComponentSurface>} surface
368
396
  * @param {Record<string, any>} locales file path -> parsed locale JSON
397
+ * @param {{ ids?: string[] }} [options]
369
398
  */
370
- export function checkLocaleCoverage(surface, locales) {
399
+ export function checkLocaleCoverage(surface, locales, options = {}) {
371
400
  const findings = []
372
401
  const record = (code, message) => findings.push({ code, level: 'error', message })
373
402
  const known = new Set(surface.components.map((component) => component.id))
403
+ const required = options.ids === undefined ? null : new Set(options.ids)
374
404
  for (const component of surface.components) {
375
405
  if (component.scope !== 'visible') continue
406
+ if (required && !required.has(component.id)) continue
376
407
  for (const file of LOCALE_FILES) {
377
408
  const entry = locales?.[file]?.components?.[component.id]
378
409
  if (!entry || typeof entry !== 'object') {
@@ -393,6 +424,75 @@ export function checkLocaleCoverage(surface, locales) {
393
424
  return findings
394
425
  }
395
426
 
427
+ /**
428
+ * #132: the plugin page renders a group row from the root locale `meta.title` /
429
+ * `meta.description`. The off consequence of the `tower` group (off equals
430
+ * uninstalling Tower) must therefore appear in `meta.description`, in every
431
+ * shipped language — otherwise the most important consequence is dead copy.
432
+ *
433
+ * @param {ReturnType<typeof parseComponentSurface>} surface
434
+ * @param {Record<string, any>} locales file path -> parsed locale JSON
435
+ */
436
+ export function checkGroupLabelCoverage(surface, locales) {
437
+ const findings = []
438
+ const record = (code, message) => findings.push({ code, level: 'error', message })
439
+ for (const component of surface.components) {
440
+ if (component.scope !== 'visible' || component.kind !== 'group') continue
441
+ for (const file of LOCALE_FILES) {
442
+ const meta = locales?.[file]?.meta
443
+ const title = typeof meta?.title === 'string' ? meta.title.trim() : ''
444
+ const description = typeof meta?.description === 'string' ? meta.description.trim() : ''
445
+ if (!title) record('group-locale', `${file} misses meta.title for group component "${component.id}"`)
446
+ if (!description) {
447
+ record('group-locale', `${file} misses meta.description for group component "${component.id}"`)
448
+ continue
449
+ }
450
+ const disables = locales?.[file]?.components?.[component.id]?.disables
451
+ const consequence = typeof disables === 'string' ? disables.trim() : ''
452
+ if (!consequence) continue
453
+ if (!description.includes(consequence)) {
454
+ record(
455
+ 'group-locale',
456
+ `${file} meta.description does not state the off consequence of group component "${component.id}"`,
457
+ )
458
+ }
459
+ }
460
+ }
461
+ return findings
462
+ }
463
+
464
+ /**
465
+ * #134: a row that ships in its own package is labelled by that package's
466
+ * `meta.title` / `meta.description`, and DSH shows the "when off" consequence in
467
+ * the description. Require both meta strings and the off marker for every
468
+ * visible row of a meta-labelled patch, in every language file it ships.
469
+ *
470
+ * @param {string[]} ids row ids whose labels live in `locales`
471
+ * @param {Record<string, any>} locales file path -> parsed locale JSON
472
+ * @param {Record<string, string>} markers language -> off-effect marker
473
+ */
474
+ export function checkMetaLabelCoverage(ids, locales, markers) {
475
+ const findings = []
476
+ const record = (code, message) => findings.push({ code, level: 'error', message })
477
+ for (const id of ids) {
478
+ for (const [file, payload] of Object.entries(locales ?? {})) {
479
+ const lang = /([a-z]{2})\.json$/.exec(file)?.[1] ?? ''
480
+ const marker = markers?.[lang] ?? ''
481
+ const title = typeof payload?.meta?.title === 'string' ? payload.meta.title.trim() : ''
482
+ const description = typeof payload?.meta?.description === 'string' ? payload.meta.description.trim() : ''
483
+ if (!title) record('meta-locale', `row "${id}" misses meta.title in ${file}`)
484
+ if (!description) {
485
+ record('meta-locale', `row "${id}" misses meta.description in ${file}`)
486
+ continue
487
+ }
488
+ if (marker && !description.includes(marker)) {
489
+ record('meta-locale', `row "${id}" meta.description does not state the off consequence in ${file}`)
490
+ }
491
+ }
492
+ }
493
+ return findings
494
+ }
495
+
396
496
  /**
397
497
  * The denylist. The host, the health contract, the recovery escape, the review
398
498
  * contract and the merge gates may not be turned off by any group, row or
@@ -427,15 +527,17 @@ export function checkForbidden(surface, live) {
427
527
  // row, whatever capability owns it. `gatedBy` is folded in so a contract key
428
528
  // recorded only on the capability still resolves to its full name.
429
529
  const forbiddenLeaves = new Map()
530
+ const remember = (name) => {
531
+ const leaf = name.split('.').pop()
532
+ if (!forbiddenLeaves.has(name)) forbiddenLeaves.set(name, name)
533
+ if (!forbiddenLeaves.has(leaf)) forbiddenLeaves.set(leaf, name)
534
+ }
430
535
  for (const key of surface.keys) {
431
536
  if (key.class !== 'forbidden-to-disable') continue
432
- forbiddenLeaves.set(key.name.split('.').pop(), key.name)
537
+ remember(key.name)
433
538
  }
434
539
  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
- }
540
+ for (const key of capability.gatedBy ?? []) remember(key)
439
541
  }
440
542
  for (const row of rows) {
441
543
  if (row.scope === 'visible' && forbiddenRowIds.has(row.id)) {
@@ -547,7 +649,17 @@ export function renderComponentSurface(surface, options = {}) {
547
649
  `| \`${component.id}\` | ${component.scope} | ${component.kind} | ${component.fallback} |`,
548
650
  )
549
651
  .join('\n')
550
- const keyRows = surface.keys.map((key) => `| \`${key.name}\` | \`${key.class}\` | ${key.note} |`).join('\n')
652
+ const keyRows = surface.keys
653
+ .map((key) => {
654
+ const exemption = key.exemption
655
+ ? ` Tuning exemption: ${key.exemption.reason} (${key.exemption.date}${key.exemption.issue ? ` · ${key.exemption.issue}` : ''} · [${decidedIn(key.exemption.ref)}](${relativeAdr(key.exemption.ref)}))`
656
+ : ''
657
+ return `| \`${key.name}\` | \`${key.class}\` | ${key.note}${exemption} |`
658
+ })
659
+ .join('\n')
660
+ const behaviorRows = (surface.behaviorKnobs ?? [])
661
+ .map((name) => `| \`${name}\` |`)
662
+ .join('\n') || '| _(none)_ |'
551
663
  const forbiddenRows = surface.forbidden
552
664
  .map((capability) => {
553
665
  const decision = capability.acceptedSwitches?.decision
@@ -591,8 +703,13 @@ ${componentRows}
591
703
 
592
704
  ## Forbidden to disable
593
705
 
594
- The host, the health contract, the recovery escape, the review contract and the
595
- merge gates are configuration or code, never switches. \`npm run
706
+ The host, the health contract, the recovery escape and the merge gates are
707
+ configuration or code, never switches. The review gate in \`required\` mode is
708
+ also non-disableable: a completed mission still needs the review evidence bound
709
+ to the branch tip, and the exemption path stays explicit. The policy itself is
710
+ not a never-switch — \`review.mode\` is the one tri-state control on the
711
+ plugin-page config form (\`off\`, \`optional\` or \`required\`) — and only the gate it
712
+ turns on in \`required\` mode is non-disableable. \`npm run
596
713
  docs:check:components\` derives the live disablers from \`host/tool-registry.js\`
597
714
  and \`cordis.patch.yml\`; a disabler that is not recorded here fails the gate.
598
715
 
@@ -621,6 +738,17 @@ All keys from \`domain/config/schema.js\`, classified on the component axis.
621
738
  |---|---|---|
622
739
  ${keyRows}
623
740
 
741
+ ## Behaviour knobs
742
+
743
+ Non-boolean keys that gate behaviour are reviewed on the same axis as booleans
744
+ (#138): each must be \`core\`, \`optional-component\`, \`forbidden-to-disable\`, or an
745
+ \`optional-config\` key with a dated tuning-only exemption. A string or union that
746
+ hides a capability cannot use the tuning class without that record.
747
+
748
+ | Key |
749
+ |---|
750
+ ${behaviorRows}
751
+
624
752
  ## Non-goals
625
753
 
626
754
  This page does not decide whether a key is useful, and it is not an approval