dsh-tower 2.3.0 → 2.4.1
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 +44 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +20 -3
- package/PRINCIPLES.md +18 -0
- package/README.en.md +29 -16
- package/README.md +22 -15
- package/cordis.patch.yml +56 -28
- package/docs/README.md +2 -1
- package/docs/adr/0004-surface-budget.md +20 -0
- package/docs/adr/0005-component-surface.md +130 -0
- package/docs/adr/README.md +1 -0
- package/docs/agents/workflow.md +1 -1
- package/docs/architecture/overview.md +69 -7
- package/docs/guide/hot-reload.md +4 -4
- package/docs/guide/install-upgrade-migration.md +13 -0
- package/docs/operations/security.md +1 -1
- package/docs/reference/component-surface.md +135 -0
- package/docs/reference/config.generated.md +2 -10
- package/docs/reference/config.md +11 -9
- package/docs/reference/doctor.md +14 -9
- package/docs/reference/surface-budget.md +52 -23
- package/docs/reference/tools.generated.md +4 -2
- package/docs/reference/tools.md +6 -3
- package/docs/zh/README.md +1 -1
- package/docs/zh/guide/hot-reload.md +3 -3
- package/docs/zh/guide/install-upgrade-migration.md +10 -0
- package/docs/zh/operations/security.md +1 -1
- package/docs/zh/reference/config.md +9 -9
- package/docs/zh/reference/tools.md +6 -4
- package/domain/components.js +641 -0
- package/domain/config/schema.js +37 -27
- package/domain/contracts/outputs.js +10 -0
- package/domain/errors.js +11 -1
- package/domain/mission.js +12 -1
- package/domain/surface.js +154 -55
- package/host/capability-batch.js +15 -0
- package/host/capability-hmr.js +30 -0
- package/host/capability-import.js +11 -0
- package/host/capability-leases.js +11 -0
- package/host/capability-registry.js +149 -0
- package/host/capability-tests.js +16 -0
- package/host/doctor.js +41 -9
- package/host/hmr.js +2 -4
- package/host/presets.js +51 -9
- package/host/tool-registry.js +150 -34
- package/index.js +32 -20
- package/locale/batch/en.json +6 -0
- package/locale/batch/zh.json +6 -0
- package/locale/en.json +31 -11
- package/locale/hmr/en.json +6 -0
- package/locale/hmr/zh.json +6 -0
- package/locale/import/en.json +6 -0
- package/locale/import/zh.json +6 -0
- package/locale/leases/en.json +6 -0
- package/locale/leases/zh.json +6 -0
- package/locale/skills/en.json +6 -0
- package/locale/skills/zh.json +6 -0
- package/locale/tests/en.json +6 -0
- package/locale/tests/zh.json +6 -0
- package/locale/zh.json +30 -10
- package/package.json +25 -4
- package/scripts/check-components.mjs +235 -0
- package/scripts/check-pack.mjs +4 -0
- package/scripts/component-surface.json +395 -0
- package/scripts/generate-capability-locale.mjs +148 -0
- package/scripts/generate-component-surface.mjs +59 -0
- package/scripts/generate-tools-reference.mjs +7 -25
- package/scripts/generate-types.mjs +127 -0
- package/scripts/lib/tool-surface.mjs +85 -0
- package/scripts/mount-smoke/fixtures.mjs +3 -3
- package/scripts/mount-smoke/surface.mjs +96 -27
- package/scripts/mount-smoke.mjs +3 -1
- package/scripts/pack-manifest.txt +30 -0
- package/scripts/surface-baseline.json +35 -4
- package/scripts/surface-baseline.mjs +32 -34
- package/scripts/types-coverage.mjs +184 -0
- package/scripts/types-strict-manifest.txt +10 -0
- package/scripts/types-strict.mjs +141 -0
- package/skills.js +8 -1
- package/tools/index.js +2 -1
- package/tools/reload.js +34 -35
- package/tools/status.js +8 -1
- package/types/index.d.ts +28 -12
package/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// plugin from disk when the HMR service is present (dev/linked installs).
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import { randomUUID } from 'node:crypto'
|
|
15
|
-
import {
|
|
15
|
+
import { registerCoreTowerTools, recordToolRegistration } from './host/tool-registry.js'
|
|
16
16
|
import { createDefaultDeps } from './adapters/default.js'
|
|
17
17
|
import { loadModes, saveModes, storePath } from './host/modeStore.js'
|
|
18
18
|
import { ModeState } from './host/modeState.js'
|
|
@@ -26,8 +26,8 @@ import {
|
|
|
26
26
|
touchWorkspaceLock,
|
|
27
27
|
workspaceLockToken,
|
|
28
28
|
} from './adapters/workspaceLock.js'
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
29
|
+
import { assertRuntimePreconditions } from './host/presets.js'
|
|
30
|
+
import { currentHmrBridge } from './host/capability-registry.js'
|
|
31
31
|
|
|
32
32
|
export const name = 'dsh-tower'
|
|
33
33
|
export const inject = ['tools', 'systemPrompt']
|
|
@@ -256,14 +256,6 @@ export function apply(ctx, rawConfig) {
|
|
|
256
256
|
modeState.replace(loadModes(config.mode.storePath || undefined))
|
|
257
257
|
if (typeof ctx.provide === 'function') ctx.provide('tower', modeState)
|
|
258
258
|
denyTools = new Set(config.mode.denyTools)
|
|
259
|
-
const hotReloadEnabled = config.dev?.hotReload !== false
|
|
260
|
-
const hmrBridge = hotReloadEnabled
|
|
261
|
-
? createHmrBridge(ctx, {
|
|
262
|
-
requiredIds: config.dev?.reloadRequiredIds,
|
|
263
|
-
delayMs: config.dev?.reloadDelayMs,
|
|
264
|
-
logger: ctx.logger ?? console,
|
|
265
|
-
})
|
|
266
|
-
: null
|
|
267
259
|
|
|
268
260
|
const workspace = {
|
|
269
261
|
/** Lazily acquire the advisory workspace lock on the first repo-scoped tool. */
|
|
@@ -346,12 +338,24 @@ export function apply(ctx, rawConfig) {
|
|
|
346
338
|
|
|
347
339
|
// #69: the host entry is the composition root — the adapters are constructed
|
|
348
340
|
// once here and injected into the tool layer and every Tower instance.
|
|
349
|
-
|
|
341
|
+
// #121: only the mandatory host surface is registered here. The optional
|
|
342
|
+
// capability rows (batch/import/leases/tests/hmr) are separate loader entries
|
|
343
|
+
// and register their own tools, so a disabled row removes exactly one
|
|
344
|
+
// capability.
|
|
345
|
+
registerCoreTowerTools(ctx, { workspace, deps: createDefaultDeps() })
|
|
346
|
+
|
|
347
|
+
// #118/#121: tower_mode/tower_control are registered directly here instead of
|
|
348
|
+
// through define(), so record them through the same recorder the registry
|
|
349
|
+
// uses. tower_control is recorded before the allowAgentControl gate: the
|
|
350
|
+
// concept budget measures the surface this build can register, not only the
|
|
351
|
+
// tools one profile exposes (#84). Together with the capability-tool names the
|
|
352
|
+
// registry records before row gating, these are the shipped 29.
|
|
353
|
+
recordToolRegistration('tower_mode')
|
|
354
|
+
recordToolRegistration('tower_control')
|
|
350
355
|
|
|
351
356
|
// On dispose (including an HMR reload) release advisory workspace locks this
|
|
352
357
|
// process still owns; a reloaded plugin restores the entries from modes.json.
|
|
353
358
|
ctx.effect(() => () => {
|
|
354
|
-
if (hmrBridge) hmrBridge.dispose?.()
|
|
355
359
|
for (const entry of modeState.values()) {
|
|
356
360
|
if (entry?.lock) {
|
|
357
361
|
releaseWorkspaceLock(entry.lock.commonDir, { token: entry.lock.token }).catch(() => {})
|
|
@@ -451,7 +455,12 @@ export function apply(ctx, rawConfig) {
|
|
|
451
455
|
deniedWhileActive: [...denyTools],
|
|
452
456
|
persistedAt: storePath(config.mode.storePath || undefined),
|
|
453
457
|
pluginVersion: readPluginVersion(),
|
|
454
|
-
hotReload:
|
|
458
|
+
hotReload: (() => {
|
|
459
|
+
// #121/#122: the tower-hmr component row owns the bridge. When the
|
|
460
|
+
// row is off the capability is reported, never silently absent.
|
|
461
|
+
const bridge = currentHmrBridge()
|
|
462
|
+
return bridge ? bridge.status() : { available: false, disabled: true, component: 'tower-hmr' }
|
|
463
|
+
})(),
|
|
455
464
|
planMode: (() => {
|
|
456
465
|
const state = planModeState(ctx, exec.agent)
|
|
457
466
|
return { available: state.available, active: state.active, pending: state.pending }
|
|
@@ -507,7 +516,7 @@ export function apply(ctx, rawConfig) {
|
|
|
507
516
|
}
|
|
508
517
|
const reason = args.reason.trim()
|
|
509
518
|
if (args.action === 'enter' && !modeOn(key)) {
|
|
510
|
-
await
|
|
519
|
+
await assertRuntimePreconditions(ctx, config)
|
|
511
520
|
}
|
|
512
521
|
const outcome =
|
|
513
522
|
args.action === 'enter'
|
|
@@ -558,20 +567,23 @@ export function apply(ctx, rawConfig) {
|
|
|
558
567
|
}
|
|
559
568
|
const parsed = parseTowerInput(rawInput)
|
|
560
569
|
if (parsed.action === 'reload') {
|
|
561
|
-
|
|
562
|
-
|
|
570
|
+
// #121: /tower reload is the tower-hmr component. The row is shipped
|
|
571
|
+
// off, so the error names the component instead of an invisible key.
|
|
572
|
+
const bridge = currentHmrBridge()
|
|
573
|
+
if (!bridge) {
|
|
574
|
+
return { kind: 'error', text: 'Tower reload is disabled by component tower-hmr; enable the tower-hmr row in the plugin page and restart DSH.' }
|
|
563
575
|
}
|
|
564
|
-
if (!
|
|
576
|
+
if (!bridge.available) {
|
|
565
577
|
return { kind: 'error', text: 'Tower reload is unavailable: this DSH profile has no HMR service. Restart DSH instead.' }
|
|
566
578
|
}
|
|
567
|
-
const outcome =
|
|
579
|
+
const outcome = bridge.schedule()
|
|
568
580
|
return {
|
|
569
581
|
kind: 'success',
|
|
570
582
|
text: `Tower reload scheduled in ${outcome.delayMs}ms (required entries: ${outcome.requiredIds.join(', ')}). If the new code does not appear, restart DSH.`,
|
|
571
583
|
}
|
|
572
584
|
}
|
|
573
585
|
const { active, message } = parsed
|
|
574
|
-
if (active) await
|
|
586
|
+
if (active) await assertRuntimePreconditions(ctx, config)
|
|
575
587
|
const outcome = active
|
|
576
588
|
? enterMode(ctx, key, agent, 'user', message || null)
|
|
577
589
|
: await exitMode(key, 'user', message || null)
|
package/locale/en.json
CHANGED
|
@@ -4,25 +4,45 @@
|
|
|
4
4
|
"description": "Mission planning, isolated worktrees, worker/reviewer agents, and review-gated merges."
|
|
5
5
|
},
|
|
6
6
|
"components": {
|
|
7
|
-
"tower
|
|
8
|
-
"title": "Tower",
|
|
9
|
-
"summary": "Mission planning,
|
|
10
|
-
"disables": "Turns off the
|
|
7
|
+
"tower": {
|
|
8
|
+
"title": "Tower host",
|
|
9
|
+
"summary": "Mission planning, worktree isolation, the mode guard, the health contract and the recovery escape.",
|
|
10
|
+
"disables": "Turns off the mandatory host; this equals uninstalling Tower: no tools, presets or skills remain."
|
|
11
11
|
},
|
|
12
12
|
"tower-skills": {
|
|
13
13
|
"title": "Tower skills",
|
|
14
14
|
"summary": "Playbooks for worker/reviewer agents and recovery after a blocked merge.",
|
|
15
|
-
"disables": "
|
|
15
|
+
"disables": "The three Tower skills leave the catalog; the worker/reviewer protocol sections and every mission tool keep working."
|
|
16
16
|
},
|
|
17
|
-
"
|
|
18
|
-
"title": "
|
|
19
|
-
"summary": "
|
|
20
|
-
"disables": "
|
|
17
|
+
"tower-batch": {
|
|
18
|
+
"title": "Evidence batches",
|
|
19
|
+
"summary": "Group missions into finite evidence batches and close them against required checks and receipts.",
|
|
20
|
+
"disables": "tower_batch is not registered; missions, gates and merges are unaffected."
|
|
21
|
+
},
|
|
22
|
+
"tower-import": {
|
|
23
|
+
"title": "Legacy import",
|
|
24
|
+
"summary": "Import a Tower Codex legacy state file and audit its snapshot-to-mission mapping.",
|
|
25
|
+
"disables": "tower_import_legacy and tower_legacy_map are not registered; new state is unaffected."
|
|
26
|
+
},
|
|
27
|
+
"tower-leases": {
|
|
28
|
+
"title": "Advisory leases",
|
|
29
|
+
"summary": "Coordinate heavy per-repository work with advisory resource leases.",
|
|
30
|
+
"disables": "tower_resource is not registered; nothing else depends on a lease."
|
|
31
|
+
},
|
|
32
|
+
"tower-tests": {
|
|
33
|
+
"title": "Test discovery",
|
|
34
|
+
"summary": "Run the worktree's declared test discovery and record a receipt for the mission tip.",
|
|
35
|
+
"disables": "tower_test is not registered and no receipt can be recorded; with gates.requireTests:true planning/mode entry is rejected with an explanation."
|
|
36
|
+
},
|
|
37
|
+
"tower-hmr": {
|
|
38
|
+
"title": "Dev hot reload",
|
|
39
|
+
"summary": "Register tower_reload and /tower reload for linked-profile development.",
|
|
40
|
+
"disables": "tower_reload and /tower reload are unavailable; a non-HMR profile behaves exactly as before. Shipped off."
|
|
21
41
|
},
|
|
22
42
|
"preset-tower-reviewer": {
|
|
23
43
|
"title": "Tower reviewer",
|
|
24
|
-
"summary": "Read-only
|
|
25
|
-
"disables": "
|
|
44
|
+
"summary": "Read-only independent review: inspect the mission diff, verify scope and acceptance, return a verdict.",
|
|
45
|
+
"disables": "The reviewer cannot be dispatched. Supported while review.requireReview is false; with the gate on planning/mode entry is locked with an explanatory error."
|
|
26
46
|
}
|
|
27
47
|
}
|
|
28
48
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Test discovery",
|
|
4
|
+
"description": "Run the worktree's declared test discovery and record a receipt for the mission tip. When off: tower_test is not registered and no receipt can be recorded; with gates.requireTests:true planning/mode entry is rejected with an explanation."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
CHANGED
|
@@ -4,25 +4,45 @@
|
|
|
4
4
|
"description": "多代理任务规划、隔离工作树与审查门禁合并。"
|
|
5
5
|
},
|
|
6
6
|
"components": {
|
|
7
|
-
"tower
|
|
7
|
+
"tower": {
|
|
8
8
|
"title": "Tower 控制塔",
|
|
9
|
-
"summary": "
|
|
10
|
-
"disables": "
|
|
9
|
+
"summary": "任务规划、隔离工作树、模式守卫、健康契约与恢复逃生口。",
|
|
10
|
+
"disables": "关闭强制主组件等同于卸载 Tower:不再保留任何工具、preset 或技能。"
|
|
11
11
|
},
|
|
12
12
|
"tower-skills": {
|
|
13
13
|
"title": "Tower 技能包",
|
|
14
14
|
"summary": "执行与审查代理的操作手册,以及失败后的恢复指南。",
|
|
15
|
-
"disables": "
|
|
15
|
+
"disables": "三个 Tower 技能从目录中移除;执行/审查协议段落与所有 mission 工具继续工作。"
|
|
16
16
|
},
|
|
17
|
-
"
|
|
18
|
-
"title": "
|
|
19
|
-
"summary": "
|
|
20
|
-
"disables": "
|
|
17
|
+
"tower-batch": {
|
|
18
|
+
"title": "证据批次",
|
|
19
|
+
"summary": "把 mission 归入有限证据批次,并按必需检查与回执关闭批次。",
|
|
20
|
+
"disables": "不再注册 tower_batch;mission、门禁与合并不受影响。"
|
|
21
|
+
},
|
|
22
|
+
"tower-import": {
|
|
23
|
+
"title": "旧版导入",
|
|
24
|
+
"summary": "导入 Tower Codex 旧状态文件,并审计快照到 mission 的映射。",
|
|
25
|
+
"disables": "不再注册 tower_import_legacy 与 tower_legacy_map;新状态不受影响。"
|
|
26
|
+
},
|
|
27
|
+
"tower-leases": {
|
|
28
|
+
"title": "咨询式租约",
|
|
29
|
+
"summary": "用咨询式资源租约协调每个仓库中的重型工作。",
|
|
30
|
+
"disables": "不再注册 tower_resource;其他能力不依赖租约。"
|
|
31
|
+
},
|
|
32
|
+
"tower-tests": {
|
|
33
|
+
"title": "测试发现",
|
|
34
|
+
"summary": "运行 worktree 声明的测试发现,并为 mission tip 记录回执。",
|
|
35
|
+
"disables": "不再注册 tower_test,也无法记录回执;若 gates.requireTests:true,规划/进入模式会以解释性错误拒绝。"
|
|
36
|
+
},
|
|
37
|
+
"tower-hmr": {
|
|
38
|
+
"title": "开发热重载",
|
|
39
|
+
"summary": "为联动开发 profile 注册 tower_reload 与 /tower reload。",
|
|
40
|
+
"disables": "tower_reload 与 /tower reload 不可用;非 HMR profile 的行为与之前完全相同。随包默认关闭。"
|
|
21
41
|
},
|
|
22
42
|
"preset-tower-reviewer": {
|
|
23
43
|
"title": "Tower 审查代理",
|
|
24
|
-
"summary": "
|
|
25
|
-
"disables": "
|
|
44
|
+
"summary": "只读独立审查:检查 mission diff、核对范围与验收,给出结论。",
|
|
45
|
+
"disables": "无法派发审查代理。review.requireReview 为 false 时允许关闭;强制审查开启时规划/进入模式会被锁定并给出解释。"
|
|
26
46
|
}
|
|
27
47
|
}
|
|
28
48
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tower",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,6 +31,17 @@
|
|
|
31
31
|
"./worker": "./worker.js",
|
|
32
32
|
"./reviewer": "./reviewer.js",
|
|
33
33
|
"./skills": "./skills.js",
|
|
34
|
+
"./batch": "./host/capability-batch.js",
|
|
35
|
+
"./import": "./host/capability-import.js",
|
|
36
|
+
"./leases": "./host/capability-leases.js",
|
|
37
|
+
"./tests": "./host/capability-tests.js",
|
|
38
|
+
"./hmr": "./host/capability-hmr.js",
|
|
39
|
+
"./skills/locale/*.json": "./locale/skills/*.json",
|
|
40
|
+
"./batch/locale/*.json": "./locale/batch/*.json",
|
|
41
|
+
"./import/locale/*.json": "./locale/import/*.json",
|
|
42
|
+
"./leases/locale/*.json": "./locale/leases/*.json",
|
|
43
|
+
"./tests/locale/*.json": "./locale/tests/*.json",
|
|
44
|
+
"./hmr/locale/*.json": "./locale/hmr/*.json",
|
|
34
45
|
"./locale/*.json": "./locale/*.json",
|
|
35
46
|
"./icon": "./icon.svg",
|
|
36
47
|
"./package.json": "./package.json"
|
|
@@ -49,7 +60,7 @@
|
|
|
49
60
|
"types/",
|
|
50
61
|
"cordis.patch.yml",
|
|
51
62
|
"scripts/",
|
|
52
|
-
"locale
|
|
63
|
+
"locale/",
|
|
53
64
|
"icon.svg",
|
|
54
65
|
"README.md",
|
|
55
66
|
"README.en.md",
|
|
@@ -90,10 +101,20 @@
|
|
|
90
101
|
"docs:check:generated:config": "node scripts/generate-config-reference.mjs --check",
|
|
91
102
|
"docs:generate:events": "node scripts/generate-events-reference.mjs",
|
|
92
103
|
"docs:check:generated:events": "node scripts/generate-events-reference.mjs --check",
|
|
104
|
+
"types:generate": "node scripts/generate-types.mjs",
|
|
105
|
+
"docs:check:generated:types": "node scripts/generate-types.mjs --check",
|
|
106
|
+
"docs:generate:components": "node scripts/generate-component-surface.mjs",
|
|
107
|
+
"docs:check:generated:components": "node scripts/generate-component-surface.mjs --check",
|
|
108
|
+
"docs:generate:locale": "node scripts/generate-capability-locale.mjs",
|
|
109
|
+
"docs:check:generated:locale": "node scripts/generate-capability-locale.mjs --check",
|
|
110
|
+
"docs:check:components": "node scripts/check-components.mjs",
|
|
93
111
|
"pack:check": "node scripts/check-pack.mjs",
|
|
94
112
|
"architecture:check": "node scripts/architecture.mjs",
|
|
95
|
-
"types:check": "tsc --noEmit",
|
|
96
|
-
"
|
|
113
|
+
"types:check": "tsc --noEmit && tsc -p tsconfig.strict.json",
|
|
114
|
+
"types:coverage": "node scripts/types-coverage.mjs",
|
|
115
|
+
"types:strict:check": "node scripts/types-strict.mjs",
|
|
116
|
+
"types:strict:write": "node scripts/types-strict.mjs --write",
|
|
117
|
+
"check": "npm test && npm run docs:check && npm run docs:check:philosophy && npm run docs:check:generated && npm run docs:check:generated:tools && npm run docs:check:generated:config && npm run docs:check:generated:events && npm run docs:check:generated:types && npm run docs:check:generated:components && npm run docs:check:generated:locale && npm run docs:check:components && npm run surface:check && npm run pack:check && npm run architecture:check && npm run types:coverage && npm run types:strict:check && npm run types:check && npm run domain:check",
|
|
97
118
|
"domain:metrics": "node scripts/domain-metrics.mjs",
|
|
98
119
|
"domain:check": "node scripts/domain-metrics.mjs --check"
|
|
99
120
|
},
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Component-surface policy gate (#120).
|
|
3
|
+
//
|
|
4
|
+
// Principle 11 says optional capabilities are switches and the core is not. This
|
|
5
|
+
// gate is what makes that a rule instead of a slogan. It fails on:
|
|
6
|
+
//
|
|
7
|
+
// 1. a visible component without `title`, `summary` and `disables` in both
|
|
8
|
+
// locale/en.json and locale/zh.json;
|
|
9
|
+
// 2. a forbidden-to-disable capability (host, health, recovery, review
|
|
10
|
+
// contract, merge gates) that any group, row or boolean key can disable;
|
|
11
|
+
// 3. a component count that moved without a decision in
|
|
12
|
+
// scripts/surface-baseline.json (the same brake `surface:check` applies).
|
|
13
|
+
//
|
|
14
|
+
// It also refuses the quiet forms of the same drift: a shipped component row
|
|
15
|
+
// missing from the single source of truth, a live config key without a
|
|
16
|
+
// component-axis class, and a generated reference that is out of date.
|
|
17
|
+
//
|
|
18
|
+
// The single source of truth is `scripts/component-surface.json`; the generated
|
|
19
|
+
// table is `docs/reference/component-surface.md`; the policy is recorded in
|
|
20
|
+
// `docs/adr/0005-component-surface.md`.
|
|
21
|
+
//
|
|
22
|
+
// Usage:
|
|
23
|
+
// node scripts/check-components.mjs [--root <dir>] [--json]
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { Config, configSurface } from '../domain/config/schema.js'
|
|
28
|
+
import {
|
|
29
|
+
COMPONENT_REFERENCE_FILE,
|
|
30
|
+
COMPONENT_SURFACE_FILE,
|
|
31
|
+
LOCALE_FILES,
|
|
32
|
+
checkForbidden,
|
|
33
|
+
checkLocaleCoverage,
|
|
34
|
+
parseComponentSurface,
|
|
35
|
+
parseCordisPatch,
|
|
36
|
+
parseOperationalRegistry,
|
|
37
|
+
renderComponentSurface,
|
|
38
|
+
validateComponentSurface,
|
|
39
|
+
} from '../domain/components.js'
|
|
40
|
+
import { validateSurfaceBaselineKey } from '../domain/surface.js'
|
|
41
|
+
import { readBaseline } from './lib/surface.mjs'
|
|
42
|
+
import { parseArgs, runMain } from './lib/cli.mjs'
|
|
43
|
+
|
|
44
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
45
|
+
const REGISTRY_FILE = 'host/tool-registry.js'
|
|
46
|
+
const PATCH_FILE = 'cordis.patch.yml'
|
|
47
|
+
const USAGE = `usage: node scripts/check-components.mjs [--root <dir>] [--json]
|
|
48
|
+
|
|
49
|
+
--root repository root to check (default: this package)
|
|
50
|
+
--json print the report as JSON
|
|
51
|
+
--help print this text
|
|
52
|
+
|
|
53
|
+
Fails on a visible component without title/summary/disables in both locale files,
|
|
54
|
+
a forbidden-to-disable capability disabled by a group, row or boolean key, a
|
|
55
|
+
component count that moved without a scripts/surface-baseline.json decision,
|
|
56
|
+
an unclassified config key or component row, and a stale generated reference.
|
|
57
|
+
Regenerate the table with \`npm run docs:generate:components\`.`
|
|
58
|
+
|
|
59
|
+
function finding(code, level, message) {
|
|
60
|
+
return { code, level, message }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The live boolean config keys, derived from the schemastery schema. */
|
|
64
|
+
function liveBooleanKeys() {
|
|
65
|
+
const keys = []
|
|
66
|
+
for (const [key, schema] of Object.entries(Config.dict ?? {})) {
|
|
67
|
+
const dict = schema?.dict
|
|
68
|
+
if (dict && typeof dict === 'object') {
|
|
69
|
+
for (const [child, childSchema] of Object.entries(dict)) {
|
|
70
|
+
if (childSchema?.type === 'boolean') keys.push(`${key}.${child}`)
|
|
71
|
+
}
|
|
72
|
+
} else if (schema?.type === 'boolean') {
|
|
73
|
+
keys.push(key)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return keys
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The static wiring that lets a boolean key remove a tool: the OPERATIONAL_TOOLS
|
|
81
|
+
* table in host/tool-registry.js. Parsing it (rather than importing the runtime)
|
|
82
|
+
* keeps the denylist check offline and free of a booted Host. The parser accepts
|
|
83
|
+
* both the tuple and the `Map.set(...)` forms, and a trailing comma (F11), so a
|
|
84
|
+
* reformatted table cannot fall out of the allowlist.
|
|
85
|
+
*/
|
|
86
|
+
function operationalRegistry(root) {
|
|
87
|
+
const file = path.join(root, REGISTRY_FILE)
|
|
88
|
+
const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
|
|
89
|
+
return parseOperationalRegistry(text)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Read the live inputs the pure checks need: schema, patch, locales, registry. */
|
|
93
|
+
export function componentCheckData(root) {
|
|
94
|
+
const config = configSurface()
|
|
95
|
+
const patchFile = path.join(root, PATCH_FILE)
|
|
96
|
+
const patch = fs.existsSync(patchFile)
|
|
97
|
+
? parseCordisPatch(fs.readFileSync(patchFile, 'utf8'))
|
|
98
|
+
: { rows: [], visible: [] }
|
|
99
|
+
const locales = {}
|
|
100
|
+
for (const file of LOCALE_FILES) {
|
|
101
|
+
const localeFile = path.join(root, file)
|
|
102
|
+
locales[file] = fs.existsSync(localeFile) ? JSON.parse(fs.readFileSync(localeFile, 'utf8')) : null
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
configKeys: config.keys,
|
|
106
|
+
sections: config.sections,
|
|
107
|
+
booleans: liveBooleanKeys(),
|
|
108
|
+
registry: operationalRegistry(root),
|
|
109
|
+
patch,
|
|
110
|
+
locales,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run every component-surface rule against one repository root.
|
|
116
|
+
* @param {string} root
|
|
117
|
+
* @returns {{ ok: boolean, surface: any, live: any, findings: Array<{ code: string, level: string, message: string }> }}
|
|
118
|
+
*/
|
|
119
|
+
export function checkComponents(root) {
|
|
120
|
+
const findings = []
|
|
121
|
+
const live = componentCheckData(root)
|
|
122
|
+
const surfaceFile = path.join(root, COMPONENT_SURFACE_FILE)
|
|
123
|
+
if (!fs.existsSync(surfaceFile)) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
surface: null,
|
|
127
|
+
live,
|
|
128
|
+
findings: [finding('surface-missing', 'error', `${COMPONENT_SURFACE_FILE} is missing`)],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
let surface
|
|
132
|
+
try {
|
|
133
|
+
surface = parseComponentSurface(fs.readFileSync(surfaceFile, 'utf8'))
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
surface: null,
|
|
138
|
+
live,
|
|
139
|
+
findings: [finding('surface-invalid', 'error', `${COMPONENT_SURFACE_FILE}: ${String(error?.message ?? error)}`)],
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
findings.push(...validateComponentSurface(surface, { configKeys: live.configKeys, patch: live.patch }))
|
|
143
|
+
findings.push(...checkLocaleCoverage(surface, live.locales))
|
|
144
|
+
findings.push(...checkForbidden(surface, { booleans: live.booleans, registry: live.registry, patch: live.patch }))
|
|
145
|
+
|
|
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
|
+
// The decision record must exist: the ADR is where the policy is argued.
|
|
152
|
+
if (!fs.existsSync(path.join(root, surface.adr))) {
|
|
153
|
+
findings.push(finding('adr-missing', 'error', `component surface names ${surface.adr}, which does not exist`))
|
|
154
|
+
}
|
|
155
|
+
for (const capability of surface.forbidden) {
|
|
156
|
+
const decision = capability.acceptedSwitches?.decision
|
|
157
|
+
if (!decision) continue
|
|
158
|
+
if (!fs.existsSync(path.join(root, decision.ref))) {
|
|
159
|
+
findings.push(
|
|
160
|
+
finding(
|
|
161
|
+
'forbidden-decision',
|
|
162
|
+
'error',
|
|
163
|
+
`forbidden capability ${capability.id} records a decision in ${decision.ref}, which does not exist`,
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check 3: the component count is a baselined surface. `surface:check` runs
|
|
170
|
+
// the full baseline; this narrow call keeps the component brake available in
|
|
171
|
+
// this gate too, so a moved count fails here without re-measuring the rest.
|
|
172
|
+
const visible = live.patch.visible
|
|
173
|
+
let baseline = null
|
|
174
|
+
try {
|
|
175
|
+
baseline = readBaseline(root)
|
|
176
|
+
findings.push(
|
|
177
|
+
...validateSurfaceBaselineKey(
|
|
178
|
+
baseline,
|
|
179
|
+
'components',
|
|
180
|
+
{ components: visible.length, liveNames: { components: visible } },
|
|
181
|
+
{ exists: (relative) => fs.existsSync(path.join(root, relative)) },
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
findings.push(finding('baseline-unreadable', 'error', `scripts/surface-baseline.json is unreadable: ${String(error?.message ?? error)}`))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The reference is a rendering, not prose. There is exactly one way to update
|
|
189
|
+
// it: edit the source of truth and run the generator.
|
|
190
|
+
try {
|
|
191
|
+
const expected = renderComponentSurface(surface, { baselineDate: baseline?.date })
|
|
192
|
+
const referenceFile = path.join(root, COMPONENT_REFERENCE_FILE)
|
|
193
|
+
const actual = fs.existsSync(referenceFile) ? fs.readFileSync(referenceFile, 'utf8') : ''
|
|
194
|
+
if (actual !== expected) {
|
|
195
|
+
findings.push(
|
|
196
|
+
finding('reference-stale', 'error', `${COMPONENT_REFERENCE_FILE} is stale; run npm run docs:generate:components`),
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
findings.push(finding('reference-unreadable', 'error', `${COMPONENT_REFERENCE_FILE} cannot be rendered: ${String(error?.message ?? error)}`))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { ok: findings.every((entry) => entry.level !== 'error'), surface, live, findings }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function main(argv) {
|
|
207
|
+
const options = parseArgs(argv, {
|
|
208
|
+
initial: { root: PACKAGE_ROOT, json: false, help: false },
|
|
209
|
+
values: { '--root': { key: 'root', parse: path.resolve } },
|
|
210
|
+
flags: { '--json': 'json', '--help': 'help', '-h': 'help' },
|
|
211
|
+
})
|
|
212
|
+
if (options.help) {
|
|
213
|
+
console.log(USAGE)
|
|
214
|
+
return 0
|
|
215
|
+
}
|
|
216
|
+
const root = path.resolve(options.root)
|
|
217
|
+
const report = checkComponents(root)
|
|
218
|
+
if (options.json) {
|
|
219
|
+
console.log(JSON.stringify({ root, ...report }, null, 2))
|
|
220
|
+
return report.ok ? 0 : 1
|
|
221
|
+
}
|
|
222
|
+
if (report.surface) {
|
|
223
|
+
const visible = report.surface.components.filter((component) => component.scope === 'visible').length
|
|
224
|
+
const internal = report.surface.components.length - visible
|
|
225
|
+
console.log(
|
|
226
|
+
`components: ${visible} visible, ${internal} internal, ${report.surface.keys.length} config keys classified (${COMPONENT_SURFACE_FILE})`,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
for (const item of report.findings) console.log(`components: ${item.level} [${item.code}] ${item.message}`)
|
|
230
|
+
if (report.ok) console.log('components: ok — locale coverage, the forbidden denylist and the component baseline hold')
|
|
231
|
+
else console.log(`components: ${report.findings.length} problem(s); see docs/adr/0005-component-surface.md`)
|
|
232
|
+
return report.ok ? 0 : 1
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
await runMain(import.meta.url, () => main(process.argv.slice(2)), { errorPrefix: 'components', usage: USAGE })
|
package/scripts/check-pack.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
10
10
|
import fs from 'node:fs'
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { capabilityLocalePaths } from './generate-capability-locale.mjs'
|
|
13
14
|
|
|
14
15
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
15
16
|
const REQUIRED_FILES = [
|
|
@@ -17,6 +18,9 @@ const REQUIRED_FILES = [
|
|
|
17
18
|
'index.js',
|
|
18
19
|
'locale/en.json',
|
|
19
20
|
'locale/zh.json',
|
|
21
|
+
// #121: every optional capability row explains itself on the Plugins page
|
|
22
|
+
// through a subpath locale resource, so those generated files are required.
|
|
23
|
+
...capabilityLocalePaths(),
|
|
20
24
|
'ui-bundle/index.js',
|
|
21
25
|
'ui-bundle/package.json',
|
|
22
26
|
'ui-bundle/cordis.patch.yml',
|