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