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.
- package/CHANGELOG.md +72 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +5 -3
- package/GLOSSARY.md +73 -0
- package/PRINCIPLES.md +15 -7
- package/README.en.md +16 -15
- package/README.md +18 -10
- package/app/archive.js +16 -1
- package/app/board.js +16 -1
- package/app/gate.js +14 -0
- package/app/review.js +7 -0
- package/app/spawn-prechecks.js +30 -5
- package/app/tower.js +5 -5
- package/client.js +321 -0
- package/cordis.patch.yml +26 -9
- package/docs/adr/0004-surface-budget.md +10 -4
- package/docs/adr/0005-component-surface.md +222 -11
- package/docs/architecture/overview.md +12 -6
- package/docs/examples/first-mission.md +3 -2
- package/docs/faq.md +9 -7
- package/docs/guide/install-upgrade-migration.md +53 -7
- package/docs/guide/quickstart.md +91 -10
- package/docs/guide/release.md +60 -19
- package/docs/guide/scenarios-and-philosophy.md +35 -28
- package/docs/operations/security.md +11 -10
- package/docs/reference/blockers.md +12 -7
- package/docs/reference/component-surface.md +40 -21
- package/docs/reference/config.generated.md +6 -8
- package/docs/reference/config.md +36 -8
- package/docs/reference/doctor.md +11 -5
- package/docs/reference/errors.generated.md +1 -0
- package/docs/reference/errors.md +2 -1
- package/docs/reference/surface-budget.md +47 -12
- package/docs/reference/testing.md +1 -1
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +5 -5
- package/docs/zh/faq.md +7 -5
- package/docs/zh/guide/install-upgrade-migration.md +26 -7
- package/docs/zh/guide/quickstart.md +17 -10
- package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
- package/docs/zh/operations/security.md +9 -8
- package/docs/zh/reference/blockers.md +11 -7
- package/docs/zh/reference/config.md +28 -8
- package/docs/zh/reference/doctor.md +7 -2
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/testing.md +2 -2
- package/docs/zh/reference/tools.md +6 -5
- package/domain/component-rules.js +216 -0
- package/domain/components.js +181 -53
- package/domain/config/schema.js +86 -28
- package/domain/contracts/outputs.js +2 -0
- package/domain/contracts/tool-inputs/missions.js +1 -1
- package/domain/errors.js +6 -0
- package/domain/gate.js +23 -5
- package/domain/integration/review.js +203 -9
- package/domain/scope.js +176 -5
- package/domain/surface.js +21 -7
- package/host/capability-archive.js +18 -0
- package/host/capability-board.js +18 -0
- package/host/capability-registry.js +4 -0
- package/host/config.js +40 -3
- package/host/doctor.js +68 -12
- package/host/presets.js +26 -8
- package/host/tool-registry.js +30 -5
- package/index.js +17 -6
- package/locale/archive/en.json +6 -0
- package/locale/archive/zh.json +6 -0
- package/locale/batch/en.json +1 -1
- package/locale/batch/zh.json +1 -1
- package/locale/board/en.json +6 -0
- package/locale/board/zh.json +6 -0
- package/locale/en.json +37 -22
- package/locale/hmr/en.json +2 -2
- package/locale/hmr/zh.json +1 -1
- package/locale/import/en.json +1 -1
- package/locale/import/zh.json +1 -1
- package/locale/leases/en.json +2 -2
- package/locale/leases/zh.json +2 -2
- package/locale/preset-reviewer/en.json +1 -1
- package/locale/preset-reviewer/zh.json +1 -1
- package/locale/preset-worker/en.json +1 -1
- package/locale/preset-worker/zh.json +1 -1
- package/locale/skills/en.json +1 -1
- package/locale/skills/zh.json +1 -1
- package/locale/tests/en.json +2 -2
- package/locale/tests/zh.json +2 -2
- package/locale/zh.json +36 -21
- package/package.json +17 -2
- package/scripts/check-components.mjs +698 -44
- package/scripts/check-pack.mjs +3 -0
- package/scripts/component-surface.json +101 -37
- package/scripts/generate-capability-locale.mjs +3 -0
- package/scripts/mount-smoke/surface.mjs +2 -0
- package/scripts/pack-manifest.txt +10 -0
- package/scripts/release-notes.mjs +256 -0
- package/scripts/surface-baseline.json +39 -3
- package/scripts/surface-baseline.mjs +15 -5
- package/scripts/types-coverage.mjs +7 -0
- package/skills.js +4 -1
- package/tools/complete.js +3 -1
- package/tools/preflight.js +1 -1
- package/tools/review.js +1 -1
- package/tools/spawn.js +7 -7
- package/tools/status.js +8 -1
- package/tools/test.js +7 -7
- package/types/index.d.ts +10 -0
- package/ui-bundle/locale/en.json +1 -1
- 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
|
+
}
|
package/domain/components.js
CHANGED
|
@@ -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,
|
|
7
|
-
//
|
|
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 {
|
|
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
|
-
|
|
291
|
-
for
|
|
292
|
-
|
|
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
|
|
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
|
|
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',
|
|
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
|
-
|
|
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
|
|
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
|
|
595
|
-
|
|
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
|