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