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
package/docs/reference/tools.md
CHANGED
|
@@ -52,7 +52,7 @@ findings but not mutate them. Violations throw `[tower:not-authorized]` or
|
|
|
52
52
|
| `tower_preflight` | `missionId` (alias `id`) | Read-only gate: returns `{ok, blockers[], branchTip, baseTip, changedFiles, review}`. |
|
|
53
53
|
| `tower_merge` | `missionId` (alias `id`), `timeoutMs?` (alias `timeout_ms`) | Merge after a green gate. Records already-landed merges (`reconciled`), recovers a failed call whose commit exists (`recoveredFromFailure`), and separates `merge-hook-failed` from `merge-conflict`. |
|
|
54
54
|
| `tower_rebase` | `missionId` (alias `id`) | Rebase the branch onto the base in its worktree. Refuses while the worker runs or the worktree is dirty. Already-contained branches return `alreadyMerged` untouched. |
|
|
55
|
-
| `tower_teardown` | `force?`, `exclude?`, `dry_run?` (alias `dryRun`) | Remove eligible merged/abandoned worktrees and branches; running agents are always kept. The final pass applies the `archive` policy: terminal missions whose worktree is gone are moved to `<git-common-dir>/dsh-tower/archive/` once `archive.afterDays`/`archive.maxMissions` says so, and `archived` lists their ids. Nothing is deleted; `archive.afterDays:0` archives a just-torn-down mission immediately. |
|
|
55
|
+
| `tower_teardown` | `force?`, `exclude?`, `dry_run?` (alias `dryRun`) | Remove eligible merged/abandoned worktrees and branches; running agents are always kept. The final pass applies the `archive` policy while the `tower-archive` component row is mounted: terminal missions whose worktree is gone are moved to `<git-common-dir>/dsh-tower/archive/` once `archive.afterDays`/`archive.maxMissions` says so, and `archived` lists their ids. Nothing is deleted; `archive.afterDays:0` archives a just-torn-down mission immediately. |
|
|
56
56
|
| `tower_mode` | — | Report mode, last change, paused goal, denied tools, store path, plugin version, HMR availability, workspace-lock holder. |
|
|
57
57
|
| `tower_control` | `action` (`enter`/`exit`), `reason` | Model-initiated mode change; registered by default; disable with `mode.allowAgentControl:false`; reason is mandatory. |
|
|
58
58
|
| `tower_reload` | `repoRoot?` | Dev-only: ask DSH HMR to reconcile the profile and reload the Host plugin from disk. Registered while the `tower-hmr` component row is on (the row ships off); scheduled after the result (default 50 ms); fails with `reload-unavailable` without HMR. When `repoRoot` is supplied the caller must be the tower. |
|
|
@@ -77,4 +77,4 @@ Code default is off; the shipped patch is on for Kimi-compatible single-active-t
|
|
|
77
77
|
Gate tools return `{ok:false, blockers:[{code, detail}]}`; the code list is in
|
|
78
78
|
[blockers.md](blockers.md). Thrown tool errors carry a stable `[tower:<code>]` prefix
|
|
79
79
|
([errors.md](errors.md)). Successful mutations emit process-local events ([events.md](events.md)),
|
|
80
|
-
and, with `
|
|
80
|
+
and, with the `tower-board` component row mounted, refresh `<git-common-dir>/dsh-tower/MISSIONS.md`. The board location stays `docs.boardLocation`; the old `docs.board` switch is a component row now (#133).
|
|
@@ -56,7 +56,7 @@ Host 插件在 DSH 启动时加载:**安装或更新后需要重启 DSH**。
|
|
|
56
56
|
这是发布到 `next` dist-tag 的**预发布**,
|
|
57
57
|
`latest` 仍指向 2.0.4——请用 `dsh plugin --profile web add dsh-tower@next`(或精确版本号)安装。
|
|
58
58
|
运行时行为未变;本版本承载的是交付闸门(CI 中的 mount smoke、`npm run release:check`、证据约定
|
|
59
|
-
与追踪策略),而不是工具变化。本文档先于人工发布步骤合入:只有在[
|
|
59
|
+
与追踪策略),而不是工具变化。本文档先于人工发布步骤合入:只有在[发布清单(英文)](../../guide/release.md)执行
|
|
60
60
|
`npm publish --tag next` 之后,`next` 才会解析到 2.1.0-beta.1;`latest` 在稳定版发布前保持 2.0.4。
|
|
61
61
|
- **2.0.3 → 2.0.4**:不需要迁移 state/config。本版本是安全加固与完整性修复(授权、merge SHA 绑定、
|
|
62
62
|
dirty worktree 完成拒绝、stale lock 恢复、限制 test/git 进程环境、`tower_recover`、UI 0.2.2)。
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
`section` 与 `allowAgentControl` 是 `mode.promptSection` 与 `mode.allowAgentControl` 的兼容别名。
|
|
7
7
|
兼容别名只用于补缺:同时设置两种写法时,规范的 `mode.*` 键优先。
|
|
8
8
|
|
|
9
|
+
两个组件形态的开关已离开配置(#133/#135):`docs.board` 现在是 `tower-board` 组件行,
|
|
10
|
+
`archive.enabled` 现在是 `tower-archive` 组件行。仍设置其中任一个的配置会以 `config-unknown-key`
|
|
11
|
+
失败,并附上指明替代行的升级提示;调优键(`docs.boardLocation`、`archive.afterDays`、
|
|
12
|
+
`archive.maxMissions`)仍保留在这里。
|
|
13
|
+
|
|
9
14
|
## 层级与优先级
|
|
10
15
|
|
|
11
16
|
从低到高:
|
|
@@ -73,7 +78,6 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
|
|
|
73
78
|
| `spawn.requireLease` | `''` | worker spawn 前必须持有的可选租约名。 |
|
|
74
79
|
| `send.maxPerMinute` | `120` | 每个仓库的 `tower_send` 每分钟速率限制(0 关闭);超出返回 `rate-limited`。 |
|
|
75
80
|
| `send.interruptDefault` | `false`(随包 patch:`true`) | `tower_send.interrupt` 的默认值(投递前先中断运行中的回合)。 |
|
|
76
|
-
| `tests.enabled` | `true` | worktree 的 `package.json` 配置了测试发现时运行它。它只调整可选的 `tower-tests` 组件:该行关闭时既没有 `tower_test` 工具也无法记录回执,`gates.requireTests:true` 会被拒绝而不是静默失效。 |
|
|
77
81
|
| `tests.admission` | `off` | `spawn` 在启动 worker 前运行发现(fail-closed,不启动子代理);`complete` 在 `tower_complete` 时运行;`off` 只在显式 `tower_test` 时运行(默认;不会自动执行仓库代码)。 |
|
|
78
82
|
| `tests.timeoutMs` | `60000` | 每条发现命令的超时。 |
|
|
79
83
|
| `tests.maxBytes` | `16777216`(16 MiB) | 每条命令捕获的 stdout 上限。 |
|
|
@@ -83,10 +87,8 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
|
|
|
83
87
|
| `gates.requireTests` | `false` | 要求针对当前 mission 分支 tip 的通过测试回执(`tests-missing`);与审查门禁独立。`tower-tests` 组件行关闭时,规划/进入模式会带解释拒绝该组合。 |
|
|
84
88
|
| `gates.allowReviewExemption` | `false` | 在 `review.requireReview:true` 时,允许用 `reviewPolicy:"none"` + 非空理由跳过审查 blocker;默认故意关闭;全局审查关闭时无影响。 |
|
|
85
89
|
| `gates.maxReportBytes` | `32768`(32 KiB) | `tower_complete.report` 的 UTF-8 字节硬上限。超出时以 `invalid-argument` 拒绝并给出可执行的 `nextAction`(把全文写进 mission worktree 的文件并引用路径),绝不截断。已存在的超限报告仍可读取。 |
|
|
86
|
-
| `docs.
|
|
87
|
-
| `
|
|
88
|
-
| `archive.enabled` | `true` | 在变更边界把 worktree 已移除的终态(`merged`/`abandoned`)任务移入 `<git-common-dir>/dsh-tower/archive/`。归档任务保留 tasks、reviews、findings 与完成报告;这是移动,不是删除。 |
|
|
89
|
-
| `archive.afterDays` | `30` | 年龄触发:终态任务超过这么多天未更新,会在下次变更时归档(`0` 表示立即归档)。 |
|
|
90
|
+
| `docs.boardLocation` | `common` | `common` = `<git-common-dir>/dsh-tower/MISSIONS.md`;`repo` = `<repoRoot>/MISSIONS.md`。是否写出看板由 `tower-board` 组件行决定(#133);`docs.board` 已移除,会作为未知键被拒绝。 |
|
|
91
|
+
| `archive.afterDays` | `30` | 年龄触发:终态任务超过这么多天未更新,会在下次变更时归档(`0` 表示立即归档)。归档本身由 `tower-archive` 组件行决定(#135);`archive.enabled` 已移除,会作为未知键被拒绝。 |
|
|
90
92
|
| `archive.maxMissions` | `100` | 数量触发:热状态中的终态任务超过此数时,从最旧的开始归档直到满足上限(`0` 表示归档所有符合条件的终态任务)。worktree 仍存在的任务永不归档。 |
|
|
91
93
|
| `leases.ttlMs` | `900000` | 默认咨询式租约寿命;过期不会自动释放。 |
|
|
92
94
|
| `dev.reloadDelayMs` | `50` | 计划中的 reload 延迟,先让工具结果送达。属于可选的 `tower-hmr` 组件。 |
|
|
@@ -61,9 +61,14 @@ PID 存活并不等于写者存活:停止心跳的持有者会报告 `stale: t
|
|
|
61
61
|
因此“被持有”是 `warn` 而不是失败;持有者的阶段与时长让卡住的控制塔可被诊断。`tower_status` 返回同一个
|
|
62
62
|
`gitLock` 对象。
|
|
63
63
|
|
|
64
|
+
`board` 报告 `MISSIONS.md` 位置(`docs.boardLocation`)以及 `tower-board` 组件行是否挂载。该行关闭时
|
|
65
|
+
检查为 `warn`,说明看板不会被写出(已有看板会变陈旧)并指出该行名;所有状态工具与门禁仍然工作。
|
|
66
|
+
|
|
64
67
|
`archive` 报告终态任务归档:活跃 `missions`、`archivedMissions` 与 `stateBytes`,归档目录的
|
|
65
|
-
`archiveBytes`/`years`/`files`,每条 `unreadable`
|
|
66
|
-
`maxMissions
|
|
68
|
+
`archiveBytes`/`years`/`files`,每条 `unreadable` 诊断,以及调优字段 `policy`(`afterDays`、
|
|
69
|
+
`maxMissions`)。`policy.enabled` 反映 `tower-archive` 组件行,而不是配置键:该行关闭时策略行说明
|
|
70
|
+
归档被组件禁用,终态任务留在热状态,`state.json` 持续增长。归档目录不存在是健康的空归档
|
|
71
|
+
(`archive` 检查为 `pass`);截断的 index/year 文件是
|
|
67
72
|
`warn` 并附带诊断,且归档会暂停而不是覆盖它——归档不可读期间没有数据会丢失,符合条件的 mission 仍留在
|
|
68
73
|
`state.json`。
|
|
69
74
|
|
|
@@ -74,5 +74,6 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
|
|
|
74
74
|
|
|
75
75
|
门禁类工具返回 `{ok:false, blockers:[{code, detail}]}`;code 列表见 [blockers.md](blockers.md)。
|
|
76
76
|
抛出的工具错误带稳定的 `[tower:<code>]` 前缀(见 [errors.md](errors.md))。成功的变更会发出进程内
|
|
77
|
-
事件(见 [events.md](events.md)
|
|
78
|
-
`<git-common-dir>/dsh-tower/MISSIONS.md
|
|
77
|
+
事件(见 [events.md](events.md)),并在挂载 `tower-board` 组件行时刷新
|
|
78
|
+
`<git-common-dir>/dsh-tower/MISSIONS.md`。看板位置仍由 `docs.boardLocation` 决定;原先的
|
|
79
|
+
`docs.board` 开关现在是组件行(#133)。
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Component-surface rules split out of `domain/components.js` (#138) so the
|
|
2
|
+
// policy module stays under the architecture aggregate-size brake (800 lines).
|
|
3
|
+
// These are pure checks over the parsed source of truth: a key's class must
|
|
4
|
+
// match its value shape, and a row may only override config its component owns.
|
|
5
|
+
// `domain/components.js` re-exports them for the gate and the tests.
|
|
6
|
+
|
|
7
|
+
// A row's `config:` mapping mixes Tower's config with the row package's own
|
|
8
|
+
// metadata (`id`, `name`, `description`, `order`) and its nested `plugins:` list.
|
|
9
|
+
// Only the Tower config leaves are overrides of the component surface.
|
|
10
|
+
const ROW_CONFIG_METADATA = new Set(['id', 'name', 'description', 'order'])
|
|
11
|
+
|
|
12
|
+
function scalarText(value) {
|
|
13
|
+
const trimmed = String(value ?? '').trim()
|
|
14
|
+
const quoted = /^(['"])(.*)\1$/.exec(trimmed)
|
|
15
|
+
return quoted ? quoted[2] : trimmed
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read the scalar overrides under a row's own top-level `config:` mapping.
|
|
20
|
+
*
|
|
21
|
+
* The group rows are skipped by the parser: their `config:` is the list of
|
|
22
|
+
* nested component rows, so the overrides belong to the child row, not the group.
|
|
23
|
+
* A `plugins:` list is skipped for the same reason. This is what keeps a
|
|
24
|
+
* legitimate plugin config (`write: false`, `sampleOverCapGlobResults: false`)
|
|
25
|
+
* from being mistaken for a Tower config key, while a removed switch
|
|
26
|
+
* (`docs.board: false`) is captured as `docs.board` and refused.
|
|
27
|
+
*
|
|
28
|
+
* @param {string[]} lines
|
|
29
|
+
* @param {{ indent: number, line: number }} row
|
|
30
|
+
* @returns {Array<{ key: string, value: string }>}
|
|
31
|
+
*/
|
|
32
|
+
export function collectConfigOverrides(lines, row) {
|
|
33
|
+
let configIndent = null
|
|
34
|
+
let start = null
|
|
35
|
+
for (let index = row.line + 1; index < lines.length; index += 1) {
|
|
36
|
+
const line = lines[index]
|
|
37
|
+
if (!line.trim() || /^\s*#/.test(line)) continue
|
|
38
|
+
const indent = /^(\s*)/.exec(line)?.[1].length ?? 0
|
|
39
|
+
const nextId = /^\s*-\s+id:/.exec(line)
|
|
40
|
+
if (nextId && indent <= row.indent) break
|
|
41
|
+
if (indent <= row.indent && !/^\s*-\s+/.test(line)) break
|
|
42
|
+
if (/^\s*config:\s*$/.test(line)) {
|
|
43
|
+
configIndent = indent
|
|
44
|
+
start = index + 1
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (start === null || configIndent === null) return []
|
|
49
|
+
/** @type {Array<{ indent: number, key: string }>} */
|
|
50
|
+
const stack = []
|
|
51
|
+
const overrides = []
|
|
52
|
+
let skipIndent = null
|
|
53
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
54
|
+
const line = lines[index]
|
|
55
|
+
if (!line.trim() || /^\s*#/.test(line)) continue
|
|
56
|
+
const indent = /^(\s*)/.exec(line)?.[1].length ?? 0
|
|
57
|
+
if (indent <= configIndent) break
|
|
58
|
+
if (skipIndent !== null) {
|
|
59
|
+
if (indent > skipIndent) continue
|
|
60
|
+
skipIndent = null
|
|
61
|
+
}
|
|
62
|
+
if (/^\s*-\s+/.test(line)) {
|
|
63
|
+
// A list item: the group config list or a `plugins:` entry. Skip its whole
|
|
64
|
+
// subtree; it does not belong to this row's config.
|
|
65
|
+
skipIndent = indent
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
const match = /^\s*([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line)
|
|
69
|
+
if (!match) continue
|
|
70
|
+
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop()
|
|
71
|
+
const key = match[1]
|
|
72
|
+
const value = match[2].trim()
|
|
73
|
+
if (value === '') {
|
|
74
|
+
if (key === 'plugins') {
|
|
75
|
+
skipIndent = indent
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
stack.push({ indent, key })
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const parts = [...stack.map((entry) => entry.key), key]
|
|
82
|
+
if (ROW_CONFIG_METADATA.has(parts[0]) || parts[0] === 'plugins') continue
|
|
83
|
+
overrides.push({ key: parts.join('.'), value: scalarText(value) })
|
|
84
|
+
}
|
|
85
|
+
return overrides
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* #138.1: the class is not free text. A boolean key that removes a capability
|
|
90
|
+
* cannot file as `optional-config`; it must be `core`, `optional-component` or
|
|
91
|
+
* `forbidden-to-disable`, or carry a dated tuning-only exemption. The same rule
|
|
92
|
+
* covers the non-boolean behaviour knobs recorded in `behaviorKnobs`, so a
|
|
93
|
+
* string/union switch cannot hide on the tuning axis.
|
|
94
|
+
*
|
|
95
|
+
* @param {{ keys: Array<{ name: string, class: string, exemption?: any }>, behaviorKnobs?: string[] }} surface
|
|
96
|
+
* @param {{ keyTypes?: Record<string, string> }} live live schema key -> type
|
|
97
|
+
* @returns {Array<{ code: string, level: string, message: string }>}
|
|
98
|
+
*/
|
|
99
|
+
export function checkKeyClassifications(surface, live = {}) {
|
|
100
|
+
const findings = []
|
|
101
|
+
const record = (code, message) => findings.push({ code, level: 'error', message })
|
|
102
|
+
const declared = new Map(surface.keys.map((key) => [key.name, key]))
|
|
103
|
+
const types = live.keyTypes ?? {}
|
|
104
|
+
const behavior = new Set(surface.behaviorKnobs ?? [])
|
|
105
|
+
const reviewedClasses = ['core', 'optional-component', 'forbidden-to-disable']
|
|
106
|
+
for (const name of behavior) {
|
|
107
|
+
if (!declared.has(name)) {
|
|
108
|
+
record('behavior-knob-unknown', `behaviorKnobs records ${name}, which has no component-axis class`)
|
|
109
|
+
}
|
|
110
|
+
if (types[name] === undefined) {
|
|
111
|
+
record('behavior-knob-unknown', `behaviorKnobs records ${name}, which is not a live config key`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const key of surface.keys) {
|
|
115
|
+
const type = types[key.name]
|
|
116
|
+
const isBoolean = type === 'boolean'
|
|
117
|
+
const isBehavior = behavior.has(key.name)
|
|
118
|
+
if (isBoolean || isBehavior) {
|
|
119
|
+
if (key.class === 'optional-config') {
|
|
120
|
+
if (!key.exemption) {
|
|
121
|
+
record(
|
|
122
|
+
'boolean-class',
|
|
123
|
+
`${key.name} is a ${isBoolean ? 'boolean' : 'recorded behaviour knob'} classified optional-config; it needs core/optional-component/forbidden-to-disable or a dated tuning-only exemption`,
|
|
124
|
+
)
|
|
125
|
+
} else if (!/tuning|tune/i.test(key.exemption.reason)) {
|
|
126
|
+
record(
|
|
127
|
+
'boolean-exemption',
|
|
128
|
+
`${key.name} keeps optional-config but its exemption reason does not name a tuning-only nature: ${key.exemption.reason}`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
} else if (!reviewedClasses.includes(key.class)) {
|
|
132
|
+
record('boolean-class', `${key.name} is a ${isBoolean ? 'boolean' : 'behaviour knob'} with unknown class "${key.class}"`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (key.exemption) {
|
|
136
|
+
if (!isBoolean && !isBehavior) {
|
|
137
|
+
record('exemption-invalid', `${key.name} carries a tuning exemption but is neither a boolean nor a recorded behaviour knob`)
|
|
138
|
+
}
|
|
139
|
+
if (key.class !== 'optional-config') {
|
|
140
|
+
record('exemption-invalid', `${key.name} carries a tuning exemption but is classified "${key.class}", not optional-config`)
|
|
141
|
+
}
|
|
142
|
+
if (!/tuning|tune/i.test(key.exemption.reason)) {
|
|
143
|
+
record('exemption-invalid', `${key.name} exemption reason must name its tuning-only nature: ${key.exemption.reason}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return findings
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* #138.4: a row may only override config it owns. The parser captures the scalar
|
|
152
|
+
* leaves under the row's own `config:` mapping; each one must be a live config
|
|
153
|
+
* key (so a removed switch such as `docs.board:false` fails) and must be listed
|
|
154
|
+
* in that component's `allowedOverrides` (so a row cannot enable or disable a
|
|
155
|
+
* capability it does not own). `allowedOverrides` itself is checked in the other
|
|
156
|
+
* direction: every listed key must be live and must not be forbidden-to-disable.
|
|
157
|
+
*
|
|
158
|
+
* @param {{ keys: Array<{ name: string, class: string }>, components: Array<{ id: string, allowedOverrides?: string[] }> }} surface
|
|
159
|
+
* @param {{ configKeys?: string[], patch?: { rows?: Array<{ id: string, configOverrides?: Array<{ key: string, value: string }> }> } }} live
|
|
160
|
+
* @returns {Array<{ code: string, level: string, message: string }>}
|
|
161
|
+
*/
|
|
162
|
+
export function checkRowOverrides(surface, live = {}) {
|
|
163
|
+
const findings = []
|
|
164
|
+
const record = (code, message) => findings.push({ code, level: 'error', message })
|
|
165
|
+
const rows = live.patch?.rows ?? []
|
|
166
|
+
const liveKeys = new Set(live.configKeys ?? [])
|
|
167
|
+
const keyClass = new Map(surface.keys.map((key) => [key.name, key.class]))
|
|
168
|
+
const classified = new Map(surface.components.map((component) => [component.id, component]))
|
|
169
|
+
for (const component of surface.components) {
|
|
170
|
+
const allowed = component.allowedOverrides ?? []
|
|
171
|
+
const seen = new Set()
|
|
172
|
+
for (const key of allowed) {
|
|
173
|
+
if (seen.has(key)) record('row-override-duplicate', `component "${component.id}" lists ${key} twice in allowedOverrides`)
|
|
174
|
+
seen.add(key)
|
|
175
|
+
if (!liveKeys.has(key)) {
|
|
176
|
+
record('component-override-unknown', `component "${component.id}" allows ${key}, which is not a live config key`)
|
|
177
|
+
}
|
|
178
|
+
if (keyClass.get(key) === 'forbidden-to-disable') {
|
|
179
|
+
record('row-override-forbidden', `component "${component.id}" allows ${key}, which is classified forbidden-to-disable`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const row of rows) {
|
|
184
|
+
const component = classified.get(row.id)
|
|
185
|
+
if (!component) continue
|
|
186
|
+
const allowed = new Set(component.allowedOverrides ?? [])
|
|
187
|
+
for (const entry of row.configOverrides ?? []) {
|
|
188
|
+
const key = entry?.key
|
|
189
|
+
if (typeof key !== 'string' || !key) {
|
|
190
|
+
record('row-override-invalid', `row "${row.id}" has an override without a config key`)
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (!liveKeys.has(key)) {
|
|
194
|
+
record(
|
|
195
|
+
'row-override-unknown',
|
|
196
|
+
`row "${row.id}" sets ${key}:${entry.value}; it is not a live config key (a switch removed in favour of a component must not keep working from a row config)`,
|
|
197
|
+
)
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (keyClass.get(key) === 'forbidden-to-disable') {
|
|
201
|
+
record(
|
|
202
|
+
'row-override-forbidden',
|
|
203
|
+
`row "${row.id}" sets ${key}:${entry.value}; a component may not switch off a forbidden-to-disable key`,
|
|
204
|
+
)
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if (!allowed.has(key)) {
|
|
208
|
+
record(
|
|
209
|
+
'row-override-foreign',
|
|
210
|
+
`row "${row.id}" sets ${key}:${entry.value}; that key is outside the overrides allowed for component "${component.id}"`,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return findings
|
|
216
|
+
}
|