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.
- package/CHANGELOG.md +40 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +5 -3
- package/GLOSSARY.md +67 -0
- package/README.md +6 -0
- package/app/archive.js +16 -1
- package/app/board.js +16 -1
- package/app/gate.js +14 -0
- package/app/spawn-prechecks.js +18 -3
- package/cordis.patch.yml +21 -6
- package/docs/adr/0004-surface-budget.md +10 -4
- package/docs/adr/0005-component-surface.md +166 -0
- package/docs/guide/install-upgrade-migration.md +21 -0
- package/docs/guide/quickstart.md +71 -0
- package/docs/guide/release.md +60 -19
- package/docs/reference/component-surface.md +27 -15
- package/docs/reference/config.generated.md +4 -7
- package/docs/reference/config.md +7 -5
- package/docs/reference/doctor.md +11 -5
- package/docs/reference/surface-budget.md +29 -11
- package/docs/reference/tools.generated.md +1 -1
- package/docs/reference/tools.md +2 -2
- package/docs/zh/guide/install-upgrade-migration.md +1 -1
- package/docs/zh/reference/config.md +7 -5
- package/docs/zh/reference/doctor.md +7 -2
- package/docs/zh/reference/tools.md +3 -2
- package/domain/component-rules.js +216 -0
- package/domain/components.js +169 -49
- package/domain/config/schema.js +46 -23
- package/domain/gate.js +17 -2
- 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/doctor.js +50 -5
- package/host/presets.js +17 -0
- package/host/tool-registry.js +21 -4
- 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 +11 -2
- package/scripts/check-components.mjs +582 -42
- package/scripts/component-surface.json +85 -27
- package/scripts/generate-capability-locale.mjs +3 -0
- package/scripts/mount-smoke/surface.mjs +2 -0
- package/scripts/pack-manifest.txt +9 -0
- package/scripts/release-notes.mjs +256 -0
- package/scripts/surface-baseline.json +32 -3
- package/scripts/surface-baseline.mjs +15 -5
- package/skills.js +4 -1
- package/tools/complete.js +3 -1
- package/tools/spawn.js +5 -5
- package/tools/test.js +7 -7
- 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
|
@@ -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 {
|
|
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
|
-
|
|
291
|
-
for
|
|
292
|
-
|
|
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
|
|
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
|
|
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',
|
|
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
|
-
|
|
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
|
|
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
|