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
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
// 2. a forbidden-to-disable capability (host, health, recovery, review
|
|
12
12
|
// contract, merge gates) 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.
|
|
15
19
|
//
|
|
16
20
|
// It also refuses the quiet forms of the same drift: a shipped component row
|
|
17
21
|
// missing from the single source of truth, a live config key without a
|
|
@@ -25,46 +29,388 @@
|
|
|
25
29
|
// node scripts/check-components.mjs [--root <dir>] [--json]
|
|
26
30
|
import fs from 'node:fs'
|
|
27
31
|
import path from 'node:path'
|
|
28
|
-
import { fileURLToPath } from 'node:url'
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
29
33
|
import { Config, configSurface } from '../domain/config/schema.js'
|
|
30
34
|
import {
|
|
35
|
+
COMPONENT_PATCH_FILES,
|
|
31
36
|
COMPONENT_REFERENCE_FILE,
|
|
32
37
|
COMPONENT_SURFACE_FILE,
|
|
33
38
|
LOCALE_FIELDS,
|
|
34
39
|
LOCALE_FILES,
|
|
40
|
+
META_LABEL_PATCHES,
|
|
41
|
+
META_LOCALE_FILES,
|
|
35
42
|
checkForbidden,
|
|
43
|
+
checkGroupLabelCoverage,
|
|
44
|
+
checkKeyClassifications,
|
|
36
45
|
checkLocaleCoverage,
|
|
46
|
+
checkMetaLabelCoverage,
|
|
47
|
+
checkRowOverrides,
|
|
48
|
+
mergeCordisPatches,
|
|
37
49
|
parseComponentSurface,
|
|
38
50
|
parseCordisPatch,
|
|
39
|
-
parseOperationalRegistry,
|
|
40
51
|
renderComponentSurface,
|
|
41
52
|
validateComponentSurface,
|
|
42
53
|
} from '../domain/components.js'
|
|
43
54
|
import { validateSurfaceBaselineKey } from '../domain/surface.js'
|
|
44
55
|
import { readBaseline } from './lib/surface.mjs'
|
|
45
56
|
import { parseArgs, runMain } from './lib/cli.mjs'
|
|
46
|
-
import { ROW_LOCALES } from './generate-capability-locale.mjs'
|
|
57
|
+
import { OFF_EFFECT_LABEL, ROW_LOCALES, rowLocalePaths } from './generate-capability-locale.mjs'
|
|
47
58
|
|
|
48
59
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
49
60
|
const REGISTRY_FILE = 'host/tool-registry.js'
|
|
50
|
-
const
|
|
61
|
+
const ROOT_PATCH = COMPONENT_PATCH_FILES[0]
|
|
51
62
|
const USAGE = `usage: node scripts/check-components.mjs [--root <dir>] [--json]
|
|
52
63
|
|
|
53
64
|
--root repository root to check (default: this package)
|
|
54
65
|
--json print the report as JSON
|
|
55
66
|
--help print this text
|
|
56
67
|
|
|
68
|
+
Reads every shipped cordis.patch.yml (${COMPONENT_PATCH_FILES.join(', ')}).
|
|
57
69
|
Fails on a visible component without title/summary/disables in both locale files,
|
|
58
70
|
a generated row label (capability or preset) whose component entry is missing,
|
|
59
|
-
a
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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\`.`
|
|
63
80
|
|
|
64
81
|
function finding(code, level, message) {
|
|
65
82
|
return { code, level, message }
|
|
66
83
|
}
|
|
67
84
|
|
|
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
|
+
|
|
68
414
|
/**
|
|
69
415
|
* The generated row-label map is a coverage axis of its own (#121). A component
|
|
70
416
|
* that ships a `<specifier>/locale/<lang>.json` label must carry `title`,
|
|
@@ -116,65 +462,202 @@ export function checkRowLocaleCoverage(surface, locales, rows) {
|
|
|
116
462
|
return findings
|
|
117
463
|
}
|
|
118
464
|
|
|
119
|
-
/** The live
|
|
120
|
-
function
|
|
121
|
-
|
|
465
|
+
/** The live config keys and their schema type, derived from schemastery. */
|
|
466
|
+
export function liveKeyTypes() {
|
|
467
|
+
/** @type {Record<string, string>} */
|
|
468
|
+
const types = {}
|
|
122
469
|
for (const [key, schema] of Object.entries(Config.dict ?? {})) {
|
|
123
470
|
const dict = schema?.dict
|
|
124
471
|
if (dict && typeof dict === 'object') {
|
|
125
472
|
for (const [child, childSchema] of Object.entries(dict)) {
|
|
126
|
-
|
|
473
|
+
types[`${key}.${child}`] = childSchema?.type ?? 'unknown'
|
|
127
474
|
}
|
|
128
|
-
} else
|
|
129
|
-
|
|
475
|
+
} else {
|
|
476
|
+
types[key] = schema?.type ?? 'unknown'
|
|
130
477
|
}
|
|
131
478
|
}
|
|
132
|
-
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)
|
|
133
487
|
}
|
|
134
488
|
|
|
135
489
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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 }>}
|
|
141
500
|
*/
|
|
142
|
-
function operationalRegistry(root) {
|
|
501
|
+
async function operationalRegistry(root) {
|
|
143
502
|
const file = path.join(root, REGISTRY_FILE)
|
|
144
|
-
|
|
145
|
-
|
|
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 }
|
|
146
525
|
}
|
|
147
526
|
|
|
148
|
-
/** Read the live inputs the pure checks need: schema,
|
|
149
|
-
export function componentCheckData(root) {
|
|
527
|
+
/** Read the live inputs the pure checks need: schema, patches, locales, registry. */
|
|
528
|
+
export async function componentCheckData(root) {
|
|
150
529
|
const config = configSurface()
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
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)
|
|
155
542
|
const locales = {}
|
|
156
543
|
for (const file of LOCALE_FILES) {
|
|
157
544
|
const localeFile = path.join(root, file)
|
|
158
545
|
locales[file] = fs.existsSync(localeFile) ? JSON.parse(fs.readFileSync(localeFile, 'utf8')) : null
|
|
159
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
|
+
}
|
|
160
560
|
return {
|
|
161
561
|
configKeys: config.keys,
|
|
162
562
|
sections: config.sections,
|
|
563
|
+
keyTypes,
|
|
163
564
|
booleans: liveBooleanKeys(),
|
|
164
|
-
registry:
|
|
565
|
+
registry: registry.routes ?? {},
|
|
566
|
+
registryError: registry.error ?? null,
|
|
567
|
+
patches,
|
|
165
568
|
patch,
|
|
166
569
|
locales,
|
|
570
|
+
metaLocales,
|
|
571
|
+
rowLocales,
|
|
167
572
|
}
|
|
168
573
|
}
|
|
169
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
|
+
|
|
170
653
|
/**
|
|
171
654
|
* Run every component-surface rule against one repository root.
|
|
172
655
|
* @param {string} root
|
|
173
|
-
* @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 }> }>}
|
|
174
657
|
*/
|
|
175
|
-
export function checkComponents(root) {
|
|
658
|
+
export async function checkComponents(root) {
|
|
176
659
|
const findings = []
|
|
177
|
-
const live = componentCheckData(root)
|
|
660
|
+
const live = await componentCheckData(root)
|
|
178
661
|
const surfaceFile = path.join(root, COMPONENT_SURFACE_FILE)
|
|
179
662
|
if (!fs.existsSync(surfaceFile)) {
|
|
180
663
|
return {
|
|
@@ -196,22 +679,79 @@ export function checkComponents(root) {
|
|
|
196
679
|
}
|
|
197
680
|
}
|
|
198
681
|
findings.push(...validateComponentSurface(surface, { configKeys: live.configKeys, patch: live.patch }))
|
|
199
|
-
|
|
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 }))
|
|
200
695
|
// #121: a row that ships a generated `<specifier>/locale/*.json` label needs
|
|
201
696
|
// the same three strings in both locales, including an internal row (the
|
|
202
697
|
// mandatory worker preset) that the visible-only rule above does not cover.
|
|
203
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
|
+
}
|
|
204
737
|
findings.push(...checkForbidden(surface, { booleans: live.booleans, registry: live.registry, patch: live.patch }))
|
|
205
738
|
|
|
206
|
-
// #122: an empty OPERATIONAL_TOOLS table is the expected state. Every route
|
|
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.
|
|
210
|
-
|
|
211
739
|
// The decision record must exist: the ADR is where the policy is argued.
|
|
212
740
|
if (!fs.existsSync(path.join(root, surface.adr))) {
|
|
213
741
|
findings.push(finding('adr-missing', 'error', `component surface names ${surface.adr}, which does not exist`))
|
|
214
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
|
+
}
|
|
215
755
|
for (const capability of surface.forbidden) {
|
|
216
756
|
const decision = capability.acceptedSwitches?.decision
|
|
217
757
|
if (!decision) continue
|
|
@@ -263,7 +803,7 @@ export function checkComponents(root) {
|
|
|
263
803
|
return { ok: findings.every((entry) => entry.level !== 'error'), surface, live, findings }
|
|
264
804
|
}
|
|
265
805
|
|
|
266
|
-
function main(argv) {
|
|
806
|
+
async function main(argv) {
|
|
267
807
|
const options = parseArgs(argv, {
|
|
268
808
|
initial: { root: PACKAGE_ROOT, json: false, help: false },
|
|
269
809
|
values: { '--root': { key: 'root', parse: path.resolve } },
|
|
@@ -274,7 +814,7 @@ function main(argv) {
|
|
|
274
814
|
return 0
|
|
275
815
|
}
|
|
276
816
|
const root = path.resolve(options.root)
|
|
277
|
-
const report = checkComponents(root)
|
|
817
|
+
const report = await checkComponents(root)
|
|
278
818
|
if (options.json) {
|
|
279
819
|
console.log(JSON.stringify({ root, ...report }, null, 2))
|
|
280
820
|
return report.ok ? 0 : 1
|