dsh-tower 2.1.0 → 2.4.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 +137 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +78 -5
- package/PRINCIPLES.md +163 -0
- package/README.en.md +35 -19
- package/README.md +28 -18
- package/SECURITY.md +6 -0
- package/{lib → adapters}/archive.js +1 -1
- package/{lib → adapters}/caseSensitivity.js +1 -1
- package/adapters/default.js +51 -10
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +1 -1
- package/{lib → adapters}/legacy.js +1 -1
- package/adapters/persistence.js +543 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +9 -5
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +1 -1
- package/app/archive.js +4 -6
- package/app/batch.js +9 -10
- package/app/board.js +2 -4
- package/app/checkCompletion.js +9 -10
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +1 -2
- package/app/init.js +24 -29
- package/app/merge.js +58 -51
- package/app/messages.js +7 -8
- package/app/mission.js +5 -5
- package/app/plan.js +8 -5
- package/app/provision.js +12 -14
- package/app/read.js +6 -3
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +15 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +8 -9
- package/app/tower.js +89 -35
- package/cordis.patch.yml +56 -28
- package/docs/README.md +7 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +70 -0
- package/docs/adr/0005-component-surface.md +130 -0
- package/docs/adr/README.md +52 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +192 -43
- package/docs/guide/hot-reload.md +5 -5
- package/docs/guide/install-upgrade-migration.md +13 -0
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/operations/security.md +1 -1
- package/docs/reference/blockers.md +2 -2
- package/docs/reference/component-surface.md +135 -0
- package/docs/reference/config.generated.md +2 -9
- package/docs/reference/config.md +11 -9
- package/docs/reference/doctor.md +53 -2
- package/docs/reference/errors.generated.md +41 -37
- package/docs/reference/errors.md +53 -41
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +157 -0
- package/docs/reference/tools.generated.md +8 -5
- package/docs/reference/tools.md +10 -7
- package/docs/zh/README.md +1 -1
- package/docs/zh/guide/hot-reload.md +4 -4
- package/docs/zh/guide/install-upgrade-migration.md +10 -0
- package/docs/zh/operations/security.md +1 -1
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +9 -9
- package/docs/zh/reference/doctor.md +29 -1
- package/docs/zh/reference/tools.md +10 -8
- package/domain/batch.js +2 -2
- package/domain/components.js +641 -0
- package/domain/config/schema.js +62 -28
- package/domain/contracts/outputs.js +16 -2
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +308 -43
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +17 -2
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +12 -1
- package/domain/missions/completion.js +14 -0
- package/domain/missions/lifecycle.js +9 -3
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/read/views.js +3 -0
- package/domain/state/archive.js +1 -1
- package/domain/state.js +1 -1
- package/domain/surface.js +347 -0
- 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/{lib → host}/doctor.js +157 -18
- package/{lib → host}/hmr.js +2 -4
- package/{lib → host}/modeStore.js +1 -1
- package/host/presets.js +77 -0
- package/{lib → host}/tool-registry.js +235 -44
- package/host/tower.js +32 -0
- package/index.js +40 -25
- package/locale/en.json +31 -11
- package/locale/zh.json +30 -10
- package/package.json +24 -4
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-components.mjs +235 -0
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/component-surface.json +395 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-component-surface.mjs +59 -0
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +18 -25
- package/scripts/generate-types.mjs +127 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -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 +75 -22
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +52 -0
- package/scripts/surface-baseline.mjs +118 -0
- 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/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +2 -3
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +9 -8
- package/tools/init.js +3 -6
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +6 -5
- package/tools/preflight.js +2 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +36 -40
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +11 -4
- package/tools/teardown.js +2 -3
- package/tools/test.js +2 -2
- package/types/index.d.ts +29 -13
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/persistence.js +0 -356
- package/lib/presets.js +0 -35
- package/lib/store.js +0 -133
- /package/{lib → adapters}/stateFile.js +0 -0
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
// Component-surface policy (#120): who may be switched off, recorded as data.
|
|
2
|
+
//
|
|
3
|
+
// Principle 11 says optional capabilities are switches and the core is not. This
|
|
4
|
+
// module is that policy as data plus the pure checks over it: the component rows
|
|
5
|
+
// in `cordis.patch.yml`, the four-way classification of every config key, and
|
|
6
|
+
// the forbidden-to-disable capabilities (host, health, recovery, review contract,
|
|
7
|
+
// merge gates) that no group, row or boolean key may gate.
|
|
8
|
+
//
|
|
9
|
+
// The single source of truth is `scripts/component-surface.json`. The generated
|
|
10
|
+
// reference `docs/reference/component-surface.md` is a rendering of it, so
|
|
11
|
+
// "should this be a component?" is a table lookup rather than a judgement call.
|
|
12
|
+
//
|
|
13
|
+
// This module is pure: no fs, no git. `scripts/check-components.mjs` supplies the
|
|
14
|
+
// live schema, `cordis.patch.yml`, the locale files and the baseline.
|
|
15
|
+
export const COMPONENT_SURFACE_FILE = 'scripts/component-surface.json'
|
|
16
|
+
export const COMPONENT_REFERENCE_FILE = 'docs/reference/component-surface.md'
|
|
17
|
+
export const COMPONENT_SCHEMA_VERSION = 1
|
|
18
|
+
export const COMPONENT_CLASSES = ['core', 'optional-component', 'optional-config', 'forbidden-to-disable']
|
|
19
|
+
export const COMPONENT_SCOPES = ['visible', 'internal']
|
|
20
|
+
export const COMPONENT_KINDS = ['group', 'row']
|
|
21
|
+
export const LOCALE_FILES = ['locale/en.json', 'locale/zh.json']
|
|
22
|
+
export const LOCALE_FIELDS = ['title', 'summary', 'disables']
|
|
23
|
+
|
|
24
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
25
|
+
|
|
26
|
+
function isPlainObject(value) {
|
|
27
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function componentError(message) {
|
|
31
|
+
return new Error(message)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function text(value, label) {
|
|
35
|
+
const trimmed = typeof value === 'string' ? value.trim() : ''
|
|
36
|
+
if (!trimmed) throw componentError(`${label} must be a non-empty string`)
|
|
37
|
+
return trimmed
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stringList(value, label) {
|
|
41
|
+
if (value === undefined) return []
|
|
42
|
+
if (!Array.isArray(value)) throw componentError(`${label} must be an array of strings`)
|
|
43
|
+
return value.map((item) => text(item, `${label} entry`))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeComponent(entry, index) {
|
|
47
|
+
if (!isPlainObject(entry)) throw componentError(`components[${index}] must be an object`)
|
|
48
|
+
const scope = text(entry.scope, `components[${index}].scope`)
|
|
49
|
+
if (!COMPONENT_SCOPES.includes(scope)) {
|
|
50
|
+
throw componentError(`components[${index}].scope "${scope}" must be one of ${COMPONENT_SCOPES.join('/')}`)
|
|
51
|
+
}
|
|
52
|
+
const kind = text(entry.kind, `components[${index}].kind`)
|
|
53
|
+
if (!COMPONENT_KINDS.includes(kind)) {
|
|
54
|
+
throw componentError(`components[${index}].kind "${kind}" must be one of ${COMPONENT_KINDS.join('/')}`)
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
id: text(entry.id, `components[${index}].id`),
|
|
58
|
+
scope,
|
|
59
|
+
kind,
|
|
60
|
+
fallback: text(entry.fallback, `components[${index}].fallback`),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeKey(entry, index) {
|
|
65
|
+
if (!isPlainObject(entry)) throw componentError(`keys[${index}] must be an object`)
|
|
66
|
+
const className = text(entry.class, `keys[${index}].class`)
|
|
67
|
+
if (!COMPONENT_CLASSES.includes(className)) {
|
|
68
|
+
throw componentError(`keys[${index}].class "${className}" must be one of ${COMPONENT_CLASSES.join('/')}`)
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
name: text(entry.name, `keys[${index}].name`),
|
|
72
|
+
class: className,
|
|
73
|
+
note: text(entry.note, `keys[${index}].note`),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeDecision(decision, label) {
|
|
78
|
+
if (!isPlainObject(decision)) throw componentError(`${label} needs a decision object`)
|
|
79
|
+
const date = text(decision.date, `${label}.date`)
|
|
80
|
+
if (!ISO_DATE.test(date)) throw componentError(`${label}.date must be YYYY-MM-DD`)
|
|
81
|
+
const ref = text(decision.ref, `${label}.ref`).replace(/\\/g, '/')
|
|
82
|
+
return {
|
|
83
|
+
date,
|
|
84
|
+
ref,
|
|
85
|
+
issue: decision.issue === undefined ? null : text(decision.issue, `${label}.issue`),
|
|
86
|
+
reason: text(decision.reason, `${label}.reason`),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeForbidden(entry, index) {
|
|
91
|
+
if (!isPlainObject(entry)) throw componentError(`forbidden[${index}] must be an object`)
|
|
92
|
+
let acceptedSwitches = null
|
|
93
|
+
if (entry.acceptedSwitches !== undefined) {
|
|
94
|
+
if (!isPlainObject(entry.acceptedSwitches)) {
|
|
95
|
+
throw componentError(`forbidden[${index}].acceptedSwitches must be an object`)
|
|
96
|
+
}
|
|
97
|
+
acceptedSwitches = {
|
|
98
|
+
keys: stringList(entry.acceptedSwitches.keys, `forbidden[${index}].acceptedSwitches.keys`),
|
|
99
|
+
decision: normalizeDecision(entry.acceptedSwitches.decision, `forbidden[${index}].acceptedSwitches.decision`),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
id: text(entry.id, `forbidden[${index}].id`),
|
|
104
|
+
capability: text(entry.capability, `forbidden[${index}].capability`),
|
|
105
|
+
reason: text(entry.reason, `forbidden[${index}].reason`),
|
|
106
|
+
tools: stringList(entry.tools, `forbidden[${index}].tools`),
|
|
107
|
+
rowIds: stringList(entry.rowIds, `forbidden[${index}].rowIds`),
|
|
108
|
+
gatedBy: stringList(entry.gatedBy, `forbidden[${index}].gatedBy`),
|
|
109
|
+
acceptedSwitches,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The allowlist of boolean routes: which tool a `tools.expose*` key may skip.
|
|
115
|
+
*
|
|
116
|
+
* `host/tool-registry.js` is the only place a `tools.expose*` boolean removes a
|
|
117
|
+
* tool, so the shipped routes are data here. A registry route that is not listed
|
|
118
|
+
* is a forbidden switch, even when the affected capability lists no tools of its
|
|
119
|
+
* own (the review contract and the merge gates register their tools through the
|
|
120
|
+
* same `define()` path as everything else).
|
|
121
|
+
*/
|
|
122
|
+
function normalizeGateableTools(value) {
|
|
123
|
+
if (value === undefined) return []
|
|
124
|
+
if (!isPlainObject(value)) {
|
|
125
|
+
throw componentError('component surface "gateableTools" must be an object mapping a tool to a tools.* config key')
|
|
126
|
+
}
|
|
127
|
+
return Object.entries(value).map(([tool, key]) => {
|
|
128
|
+
const name = text(tool, 'gateableTools tool')
|
|
129
|
+
const configKey = text(key, `gateableTools.${tool}`)
|
|
130
|
+
if (!/^tools\.[A-Za-z][A-Za-z0-9]*$/.test(configKey)) {
|
|
131
|
+
throw componentError(`gateableTools.${tool} must name a tools.* config key, got ${configKey}`)
|
|
132
|
+
}
|
|
133
|
+
return { tool: name, key: configKey }
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Parse the static `host/tool-registry.js` route table that lets a boolean
|
|
139
|
+
* remove a tool. This is the denylist's live input, so it is deliberately
|
|
140
|
+
* tolerant about formatting (F11): it accepts a tuple with a trailing comma and
|
|
141
|
+
* a `Map.set(...)` route, so a reformatted or rewritten table cannot fall out of
|
|
142
|
+
* the allowlist and silently legalise a forbidden switch.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} source the registry module text
|
|
145
|
+
* @returns {Record<string, string>} tool name -> expose flag (without `tools.`)
|
|
146
|
+
*/
|
|
147
|
+
export function parseOperationalRegistry(source) {
|
|
148
|
+
const text = String(source ?? '')
|
|
149
|
+
/** @type {Record<string, string>} */
|
|
150
|
+
const registry = {}
|
|
151
|
+
// Tuple form: ['tower_batch', 'exposeBatch'] with an optional trailing comma.
|
|
152
|
+
for (const match of text.matchAll(
|
|
153
|
+
/\[\s*['"](tower_[a-z0-9_]+)['"]\s*,\s*['"](expose[A-Za-z0-9]+)['"]\s*,?\s*\]/g,
|
|
154
|
+
)) {
|
|
155
|
+
registry[match[1]] = match[2]
|
|
156
|
+
}
|
|
157
|
+
// Map form: OPERATIONAL_TOOLS.set('tower_batch', 'exposeBatch').
|
|
158
|
+
for (const match of text.matchAll(
|
|
159
|
+
/OPERATIONAL_TOOLS\s*\.\s*set\(\s*['"](tower_[a-z0-9_]+)['"]\s*,\s*['"](expose[A-Za-z0-9]+)['"]\s*\)/g,
|
|
160
|
+
)) {
|
|
161
|
+
registry[match[1]] = match[2]
|
|
162
|
+
}
|
|
163
|
+
return registry
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Parse and validate the single source of truth. Structural problems throw with
|
|
168
|
+
* an actionable message; semantic problems are findings from the validators
|
|
169
|
+
* below, so one run can report all of them.
|
|
170
|
+
* @param {string | object} source
|
|
171
|
+
*/
|
|
172
|
+
export function parseComponentSurface(source) {
|
|
173
|
+
let value = source
|
|
174
|
+
if (typeof source === 'string') {
|
|
175
|
+
try {
|
|
176
|
+
value = JSON.parse(source)
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw componentError(`component surface is not valid JSON: ${error?.message ?? error}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!isPlainObject(value)) throw componentError('component surface must be a JSON object')
|
|
182
|
+
if (value.schema !== undefined && value.schema !== COMPONENT_SCHEMA_VERSION) {
|
|
183
|
+
throw componentError(`component surface schema ${value.schema} is not supported (expected ${COMPONENT_SCHEMA_VERSION})`)
|
|
184
|
+
}
|
|
185
|
+
const date = text(value.date, 'component surface date')
|
|
186
|
+
if (!ISO_DATE.test(date)) throw componentError(`component surface date must be YYYY-MM-DD, got ${JSON.stringify(value.date)}`)
|
|
187
|
+
const adr = text(value.adr, 'component surface adr').replace(/\\/g, '/')
|
|
188
|
+
if (!adr.startsWith('docs/adr/') || !adr.endsWith('.md')) {
|
|
189
|
+
throw componentError(`component surface adr must name a docs/adr/*.md record, got ${adr}`)
|
|
190
|
+
}
|
|
191
|
+
if (!isPlainObject(value.classes)) throw componentError('component surface needs a "classes" object')
|
|
192
|
+
const classes = {}
|
|
193
|
+
for (const name of COMPONENT_CLASSES) {
|
|
194
|
+
classes[name] = text(value.classes[name], `component surface classes.${name}`)
|
|
195
|
+
}
|
|
196
|
+
if (!Array.isArray(value.components) || value.components.length === 0) {
|
|
197
|
+
throw componentError('component surface needs a non-empty "components" array')
|
|
198
|
+
}
|
|
199
|
+
const components = value.components.map(normalizeComponent)
|
|
200
|
+
if (!Array.isArray(value.keys) || value.keys.length === 0) {
|
|
201
|
+
throw componentError('component surface needs a non-empty "keys" array')
|
|
202
|
+
}
|
|
203
|
+
const keys = value.keys.map(normalizeKey)
|
|
204
|
+
const forbidden = Array.isArray(value.forbidden) ? value.forbidden.map(normalizeForbidden) : []
|
|
205
|
+
const gateableTools = normalizeGateableTools(value.gateableTools)
|
|
206
|
+
const seen = (list, label) => {
|
|
207
|
+
const names = new Set()
|
|
208
|
+
for (const entry of list) {
|
|
209
|
+
if (names.has(entry.id)) throw componentError(`${label} "${entry.id}" is declared twice`)
|
|
210
|
+
names.add(entry.id)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
seen(components, 'component')
|
|
214
|
+
seen(forbidden, 'forbidden capability')
|
|
215
|
+
const keyNames = new Set()
|
|
216
|
+
for (const key of keys) {
|
|
217
|
+
if (keyNames.has(key.name)) throw componentError(`config key ${key.name} is classified twice`)
|
|
218
|
+
keyNames.add(key.name)
|
|
219
|
+
}
|
|
220
|
+
if (!components.some((component) => component.scope === 'visible')) {
|
|
221
|
+
throw componentError('component surface needs at least one visible component')
|
|
222
|
+
}
|
|
223
|
+
return { schema: COMPONENT_SCHEMA_VERSION, date, adr, classes, components, keys, gateableTools, forbidden }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read the shipped component rows out of `cordis.patch.yml`.
|
|
228
|
+
*
|
|
229
|
+
* The parse is deliberately small: a row is an `- id:` entry directly under
|
|
230
|
+
* `- insert:` or directly under a group's `config:` list. A plugin id under
|
|
231
|
+
* `plugins:` is not a component. `group` marks a row that mounts a group of
|
|
232
|
+
* sections rather than a single plugin.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} text
|
|
235
|
+
* @returns {{ rows: Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[] }>, visible: string[] }}
|
|
236
|
+
*/
|
|
237
|
+
export function parseCordisPatch(text) {
|
|
238
|
+
const lines = String(text ?? '').split(/\r?\n/)
|
|
239
|
+
const insertIndex = lines.findIndex((line) => /^\s*-\s+insert:\s*$/.test(line))
|
|
240
|
+
if (insertIndex < 0) return { rows: [], visible: [] }
|
|
241
|
+
/** @type {Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], indent: number, line: number }>} */
|
|
242
|
+
const rows = []
|
|
243
|
+
const stack = []
|
|
244
|
+
let rowIndent = null
|
|
245
|
+
for (let index = insertIndex; index < lines.length; index += 1) {
|
|
246
|
+
const line = lines[index]
|
|
247
|
+
if (!line.trim() || /^\s*#/.test(line)) continue
|
|
248
|
+
const indent = /^(\s*)/.exec(line)?.[1].length ?? 0
|
|
249
|
+
if (index === insertIndex) {
|
|
250
|
+
stack.push({ indent, key: 'insert' })
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
const idMatch = /^\s*-\s+id:\s*(\S+)\s*$/.exec(line)
|
|
254
|
+
if (idMatch) {
|
|
255
|
+
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop()
|
|
256
|
+
const container = stack.length > 0 ? stack[stack.length - 1].key : null
|
|
257
|
+
if (container !== 'insert' && container !== 'config') continue
|
|
258
|
+
if (container === 'insert' && rowIndent === null) rowIndent = indent
|
|
259
|
+
const scope = container === 'insert' && indent === rowIndent ? 'visible' : 'internal'
|
|
260
|
+
rows.push({
|
|
261
|
+
id: idMatch[1].replace(/^['"]|['"]$/g, ''),
|
|
262
|
+
scope,
|
|
263
|
+
kind: 'row',
|
|
264
|
+
group: false,
|
|
265
|
+
overrides: [],
|
|
266
|
+
indent,
|
|
267
|
+
line: index,
|
|
268
|
+
})
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
const keyMatch = /^\s*([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line)
|
|
272
|
+
if (keyMatch) {
|
|
273
|
+
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop()
|
|
274
|
+
stack.push({ indent, key: keyMatch[1] })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const row of rows) {
|
|
278
|
+
const block = []
|
|
279
|
+
for (let index = row.line + 1; index < lines.length; index += 1) {
|
|
280
|
+
const line = lines[index]
|
|
281
|
+
const nextId = /^(\s*)-\s+id:/.exec(line)
|
|
282
|
+
if (nextId && nextId[1].length <= row.indent) break
|
|
283
|
+
if (!line.trim() || /^\s*#/.test(line)) continue
|
|
284
|
+
const lineIndent = /^(\s*)/.exec(line)?.[1].length ?? 0
|
|
285
|
+
if (lineIndent <= row.indent && !/^\s*-\s+/.test(line)) break
|
|
286
|
+
block.push(line)
|
|
287
|
+
}
|
|
288
|
+
row.group = block.some((line) => /^\s*group:\s*true\s*$/.test(line))
|
|
289
|
+
row.kind = row.group ? 'group' : 'row'
|
|
290
|
+
const overrides = new Set()
|
|
291
|
+
for (const line of block) {
|
|
292
|
+
for (const match of line.matchAll(/([A-Za-z0-9_-]+)\s*:\s*false\b/g)) overrides.add(match[1])
|
|
293
|
+
}
|
|
294
|
+
row.overrides = [...overrides]
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
rows: rows.map(({ line, indent, ...row }) => row),
|
|
298
|
+
visible: rows.filter((row) => row.scope === 'visible').map((row) => row.id),
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The shipped shape and the classification must describe the same components and
|
|
304
|
+
* config keys. This is the parity half of the gate: a row without a class, or a
|
|
305
|
+
* class without a row, is an unowned decision.
|
|
306
|
+
*
|
|
307
|
+
* @param {ReturnType<typeof parseComponentSurface>} surface
|
|
308
|
+
* @param {{ configKeys: string[], patch: ReturnType<typeof parseCordisPatch> }} live
|
|
309
|
+
*/
|
|
310
|
+
export function validateComponentSurface(surface, live) {
|
|
311
|
+
const findings = []
|
|
312
|
+
const record = (code, message) => findings.push({ code, level: 'error', message })
|
|
313
|
+
const shipped = new Map(live.patch.rows.map((row) => [row.id, row]))
|
|
314
|
+
const classified = new Map(surface.components.map((component) => [component.id, component]))
|
|
315
|
+
for (const component of surface.components) {
|
|
316
|
+
const row = shipped.get(component.id)
|
|
317
|
+
if (!row) {
|
|
318
|
+
record('component-missing', `component surface lists "${component.id}" but cordis.patch.yml does not ship it`)
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if (row.scope !== component.scope) {
|
|
322
|
+
record(
|
|
323
|
+
'component-scope',
|
|
324
|
+
`component "${component.id}" is ${component.scope} in the source of truth but ${row.scope} in cordis.patch.yml`,
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
if (row.kind !== component.kind) {
|
|
328
|
+
record(
|
|
329
|
+
'component-kind',
|
|
330
|
+
`component "${component.id}" is a ${component.kind} in the source of truth but a ${row.kind} in cordis.patch.yml`,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
for (const row of live.patch.rows) {
|
|
335
|
+
if (!classified.has(row.id)) {
|
|
336
|
+
record('component-unclassified', `cordis.patch.yml ships component "${row.id}" but the source of truth does not classify it`)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const declared = new Set(surface.keys.map((key) => key.name))
|
|
340
|
+
const liveKeys = new Set(live.configKeys)
|
|
341
|
+
for (const name of live.configKeys) {
|
|
342
|
+
if (!declared.has(name)) record('key-unclassified', `config key ${name} has no component-axis class`)
|
|
343
|
+
}
|
|
344
|
+
for (const name of declared) {
|
|
345
|
+
if (!liveKeys.has(name)) record('key-unknown', `component surface classifies ${name}, which is not a live config key`)
|
|
346
|
+
}
|
|
347
|
+
const accepted = new Set()
|
|
348
|
+
for (const capability of surface.forbidden) {
|
|
349
|
+
for (const key of capability.acceptedSwitches?.keys ?? []) accepted.add(key)
|
|
350
|
+
}
|
|
351
|
+
for (const key of accepted) {
|
|
352
|
+
const entry = surface.keys.find((candidate) => candidate.name === key)
|
|
353
|
+
if (!entry) {
|
|
354
|
+
record('forbidden-class', `forbidden capability records ${key}, which has no component-axis class`)
|
|
355
|
+
} else if (entry.class !== 'forbidden-to-disable') {
|
|
356
|
+
record('forbidden-class', `${key} gates a forbidden capability but is classified "${entry.class}", not forbidden-to-disable`)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return findings
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Every visible component must carry `title`, `summary` and `disables` in every
|
|
364
|
+
* shipped locale. The locale set itself must only name known components, so a
|
|
365
|
+
* typo cannot pass as coverage.
|
|
366
|
+
*
|
|
367
|
+
* @param {ReturnType<typeof parseComponentSurface>} surface
|
|
368
|
+
* @param {Record<string, any>} locales file path -> parsed locale JSON
|
|
369
|
+
*/
|
|
370
|
+
export function checkLocaleCoverage(surface, locales) {
|
|
371
|
+
const findings = []
|
|
372
|
+
const record = (code, message) => findings.push({ code, level: 'error', message })
|
|
373
|
+
const known = new Set(surface.components.map((component) => component.id))
|
|
374
|
+
for (const component of surface.components) {
|
|
375
|
+
if (component.scope !== 'visible') continue
|
|
376
|
+
for (const file of LOCALE_FILES) {
|
|
377
|
+
const entry = locales?.[file]?.components?.[component.id]
|
|
378
|
+
if (!entry || typeof entry !== 'object') {
|
|
379
|
+
record('component-locale', `visible component "${component.id}" has no entry in ${file}`)
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
for (const field of LOCALE_FIELDS) {
|
|
383
|
+
const value = typeof entry[field] === 'string' ? entry[field].trim() : ''
|
|
384
|
+
if (!value) record('component-locale', `visible component "${component.id}" misses "${field}" in ${file}`)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const file of LOCALE_FILES) {
|
|
389
|
+
for (const id of Object.keys(locales?.[file]?.components ?? {})) {
|
|
390
|
+
if (!known.has(id)) record('component-locale-unknown', `${file} labels "${id}", which is not a shipped component`)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return findings
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The denylist. The host, the health contract, the recovery escape, the review
|
|
398
|
+
* contract and the merge gates may not be turned off by any group, row or
|
|
399
|
+
* boolean key.
|
|
400
|
+
*
|
|
401
|
+
* The live disablers are derived statically:
|
|
402
|
+
* - `host/tool-registry.js` may map a tool to a `tools.expose*` boolean that
|
|
403
|
+
* skips registering it (#122 removed the shipped table; the parser keeps the
|
|
404
|
+
* allowlist ready if one ever returns);
|
|
405
|
+
* - `cordis.patch.yml` rows may set a forbidden-to-disable key to `false`, or
|
|
406
|
+
* expose a forbidden capability as its own switchable row.
|
|
407
|
+
*
|
|
408
|
+
* Two routes are checked, not one. A row override is refused when its leaf names
|
|
409
|
+
* *any* key classified `forbidden-to-disable`, not only a capability's `gatedBy`
|
|
410
|
+
* keys. And every registry route must match the `gateableTools` allowlist in the
|
|
411
|
+
* source of truth, so adding `['tower_merge', 'exposeBatch']` to
|
|
412
|
+
* `host/tool-registry.js` fails even though the merge-gate capability lists no
|
|
413
|
+
* tools that a switch could remove.
|
|
414
|
+
*
|
|
415
|
+
* @param {ReturnType<typeof parseComponentSurface>} surface
|
|
416
|
+
* @param {{ booleans: string[], registry: Record<string, string>, patch: ReturnType<typeof parseCordisPatch> }} live
|
|
417
|
+
*/
|
|
418
|
+
export function checkForbidden(surface, live) {
|
|
419
|
+
const findings = []
|
|
420
|
+
const record = (code, level, message) => findings.push({ code, level, message })
|
|
421
|
+
const booleanKeys = new Set(live.booleans ?? [])
|
|
422
|
+
const registry = live.registry ?? {}
|
|
423
|
+
const rows = live.patch?.rows ?? []
|
|
424
|
+
const forbiddenRowIds = new Set(surface.forbidden.flatMap((capability) => capability.rowIds ?? []))
|
|
425
|
+
|
|
426
|
+
// Row route: every key classified forbidden-to-disable is unreachable from a
|
|
427
|
+
// row, whatever capability owns it. `gatedBy` is folded in so a contract key
|
|
428
|
+
// recorded only on the capability still resolves to its full name.
|
|
429
|
+
const forbiddenLeaves = new Map()
|
|
430
|
+
for (const key of surface.keys) {
|
|
431
|
+
if (key.class !== 'forbidden-to-disable') continue
|
|
432
|
+
forbiddenLeaves.set(key.name.split('.').pop(), key.name)
|
|
433
|
+
}
|
|
434
|
+
for (const capability of surface.forbidden) {
|
|
435
|
+
for (const key of capability.gatedBy ?? []) {
|
|
436
|
+
const leaf = key.split('.').pop()
|
|
437
|
+
if (!forbiddenLeaves.has(leaf)) forbiddenLeaves.set(leaf, key)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (const row of rows) {
|
|
441
|
+
if (row.scope === 'visible' && forbiddenRowIds.has(row.id)) {
|
|
442
|
+
record('forbidden-component', 'error', `component "${row.id}" exposes a forbidden-to-disable capability as a switch`)
|
|
443
|
+
}
|
|
444
|
+
for (const leaf of row.overrides ?? []) {
|
|
445
|
+
const key = forbiddenLeaves.get(leaf)
|
|
446
|
+
if (!key) continue
|
|
447
|
+
record(
|
|
448
|
+
'forbidden-override',
|
|
449
|
+
'error',
|
|
450
|
+
`row "${row.id}" sets ${key}:false in cordis.patch.yml; a component may not switch off a forbidden-to-disable key`,
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Boolean route: the allowlist is the shipped set of tool -> switch mappings.
|
|
456
|
+
// Anything else in OPERATIONAL_TOOLS is a new way to remove a tool, and a
|
|
457
|
+
// review, merge or host tool must never gain one.
|
|
458
|
+
const gateable = new Map((surface.gateableTools ?? []).map((entry) => [entry.tool, entry.key]))
|
|
459
|
+
for (const [tool, flag] of Object.entries(registry)) {
|
|
460
|
+
const route = `tools.${flag}`
|
|
461
|
+
const declared = gateable.get(tool)
|
|
462
|
+
if (declared === route) continue
|
|
463
|
+
record(
|
|
464
|
+
'forbidden-tool-gate',
|
|
465
|
+
'error',
|
|
466
|
+
declared
|
|
467
|
+
? `${tool} is gated by ${route} in host/tool-registry.js, but the source of truth records ${declared}; a tool route may not move to another switch`
|
|
468
|
+
: `${tool} is gated by ${route} in host/tool-registry.js but is not a recorded gateable tool; a review, merge or host tool may not become a switch`,
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
for (const entry of surface.gateableTools ?? []) {
|
|
472
|
+
if (registry[entry.tool] === undefined) {
|
|
473
|
+
record(
|
|
474
|
+
'gateable-stale',
|
|
475
|
+
'warn',
|
|
476
|
+
`${entry.tool} is recorded as gateable by ${entry.key} but host/tool-registry.js no longer gates it; remove the allowlist entry`,
|
|
477
|
+
)
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
const keyEntry = surface.keys.find((candidate) => candidate.name === entry.key)
|
|
481
|
+
if (!keyEntry) {
|
|
482
|
+
record('gateable-class', 'error', `${entry.tool} is recorded gateable by ${entry.key}, which has no component-axis class`)
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
if (!['optional-component', 'forbidden-to-disable'].includes(keyEntry.class)) {
|
|
486
|
+
record(
|
|
487
|
+
'gateable-class',
|
|
488
|
+
'error',
|
|
489
|
+
`${entry.key} gates ${entry.tool} but is classified "${keyEntry.class}"; only an optional component or recorded forbidden debt may gate a tool`,
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
for (const capability of surface.forbidden) {
|
|
493
|
+
if (!(capability.tools ?? []).includes(entry.tool)) continue
|
|
494
|
+
const accepted = new Set(capability.acceptedSwitches?.keys ?? [])
|
|
495
|
+
if (!accepted.has(entry.key)) {
|
|
496
|
+
record(
|
|
497
|
+
'forbidden-tool-gate',
|
|
498
|
+
'error',
|
|
499
|
+
`${entry.tool} belongs to ${capability.capability} but ${entry.key} is not a recorded disabler; the allowlist may not legalise a forbidden switch`,
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
for (const capability of surface.forbidden) {
|
|
506
|
+
const liveSwitches = new Set()
|
|
507
|
+
for (const tool of capability.tools ?? []) {
|
|
508
|
+
const flag = registry[tool]
|
|
509
|
+
if (!flag) continue
|
|
510
|
+
liveSwitches.add(`tools.${flag}`)
|
|
511
|
+
}
|
|
512
|
+
const accepted = capability.acceptedSwitches?.keys ?? []
|
|
513
|
+
const acceptedSet = new Set(accepted)
|
|
514
|
+
for (const key of liveSwitches) {
|
|
515
|
+
if (!acceptedSet.has(key)) {
|
|
516
|
+
record(
|
|
517
|
+
'forbidden-disabler',
|
|
518
|
+
'error',
|
|
519
|
+
`${key} can disable ${capability.capability}; a forbidden-to-disable capability may not be gated by a group, row or boolean key`,
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
for (const key of accepted) {
|
|
524
|
+
if (!booleanKeys.has(key)) {
|
|
525
|
+
record('forbidden-disabler', 'error', `recorded disabler ${key} for ${capability.id} is not a live boolean config key`)
|
|
526
|
+
}
|
|
527
|
+
if (!liveSwitches.has(key)) {
|
|
528
|
+
record(
|
|
529
|
+
'forbidden-stale',
|
|
530
|
+
'warn',
|
|
531
|
+
`${key} is recorded as a disabler for ${capability.id} but host/tool-registry.js no longer wires it; remove the record`,
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return findings
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Render the generated component reference from the single source of truth. */
|
|
540
|
+
export function renderComponentSurface(surface, options = {}) {
|
|
541
|
+
const baselineDate = options.baselineDate ?? surface.date
|
|
542
|
+
const visible = surface.components.filter((component) => component.scope === 'visible')
|
|
543
|
+
const classRows = COMPONENT_CLASSES.map((name) => `| \`${name}\` | ${surface.classes[name]} |`).join('\n')
|
|
544
|
+
const componentRows = surface.components
|
|
545
|
+
.map(
|
|
546
|
+
(component) =>
|
|
547
|
+
`| \`${component.id}\` | ${component.scope} | ${component.kind} | ${component.fallback} |`,
|
|
548
|
+
)
|
|
549
|
+
.join('\n')
|
|
550
|
+
const keyRows = surface.keys.map((key) => `| \`${key.name}\` | \`${key.class}\` | ${key.note} |`).join('\n')
|
|
551
|
+
const forbiddenRows = surface.forbidden
|
|
552
|
+
.map((capability) => {
|
|
553
|
+
const decision = capability.acceptedSwitches?.decision
|
|
554
|
+
const accepted = capability.acceptedSwitches?.keys?.length
|
|
555
|
+
? capability.acceptedSwitches.keys.map((key) => `\`${key}\``).join(', ')
|
|
556
|
+
: 'none'
|
|
557
|
+
const evidence = decision
|
|
558
|
+
? `recorded ${decision.date}${decision.issue ? ` · ${decision.issue}` : ''} · [${decidedIn(decision.ref)}](${relativeAdr(decision.ref)})`
|
|
559
|
+
: 'none may exist'
|
|
560
|
+
return `| ${capability.capability} | ${capability.reason} | ${accepted} | ${evidence} |`
|
|
561
|
+
})
|
|
562
|
+
.join('\n')
|
|
563
|
+
const gateableRows = (surface.gateableTools ?? [])
|
|
564
|
+
.map((entry) => `| \`${entry.tool}\` | \`${entry.key}\` |`)
|
|
565
|
+
.join('\n') || '| _(none)_ | — |'
|
|
566
|
+
const visibleIds = visible.map((component) => `\`${component.id}\``).join(', ')
|
|
567
|
+
return `# Component surface (generated)
|
|
568
|
+
|
|
569
|
+
> Generated by \`npm run docs:generate:components\` from \`${COMPONENT_SURFACE_FILE}\` and
|
|
570
|
+
> \`scripts/surface-baseline.json\`. Do not edit by hand.
|
|
571
|
+
|
|
572
|
+
Decision: [ADR 0005](${relativeAdr(surface.adr)}) · decision date: ${surface.date} · baseline ${baselineDate} · visible components: ${visible.length} (${visibleIds})
|
|
573
|
+
|
|
574
|
+
The component axis answers one question for every config key: may a user turn it
|
|
575
|
+
off, and if so, must that switch be a component? Four classes cover every key, so
|
|
576
|
+
"should this be a component?" is a table lookup rather than a judgement call.
|
|
577
|
+
|
|
578
|
+
| Class | Meaning |
|
|
579
|
+
|---|---|
|
|
580
|
+
${classRows}
|
|
581
|
+
|
|
582
|
+
## Components
|
|
583
|
+
|
|
584
|
+
The shipped \`cordis.patch.yml\` rows, with the fallback contract each off state
|
|
585
|
+
must satisfy. A visible row is a component the plugin page may switch; an internal
|
|
586
|
+
row is a section of its parent and is not independently switchable.
|
|
587
|
+
|
|
588
|
+
| Component | Scope | Kind | Off state and fallback |
|
|
589
|
+
|---|---|---|---|
|
|
590
|
+
${componentRows}
|
|
591
|
+
|
|
592
|
+
## Forbidden to disable
|
|
593
|
+
|
|
594
|
+
The host, the health contract, the recovery escape, the review contract and the
|
|
595
|
+
merge gates are configuration or code, never switches. \`npm run
|
|
596
|
+
docs:check:components\` derives the live disablers from \`host/tool-registry.js\`
|
|
597
|
+
and \`cordis.patch.yml\`; a disabler that is not recorded here fails the gate.
|
|
598
|
+
|
|
599
|
+
| Capability | Why it may not be disabled | Recorded disablers | Decision |
|
|
600
|
+
|---|---|---|---|
|
|
601
|
+
${forbiddenRows}
|
|
602
|
+
|
|
603
|
+
## Gateable tools
|
|
604
|
+
|
|
605
|
+
The allowlist of boolean routes in \`host/tool-registry.js\`. Every shipped
|
|
606
|
+
\`tools.expose*\` route must match this table. A route to a tool that is not listed
|
|
607
|
+
here is refused, so no boolean key can quietly remove a review, merge or host
|
|
608
|
+
tool. Since #122 the shipped table is empty: \`tower_doctor\` and \`tower_recover\`
|
|
609
|
+
are registered for every configuration and no other capability may become a
|
|
610
|
+
boolean switch.
|
|
611
|
+
|
|
612
|
+
| Tool | Switch key |
|
|
613
|
+
|---|---|
|
|
614
|
+
${gateableRows}
|
|
615
|
+
|
|
616
|
+
## Config keys
|
|
617
|
+
|
|
618
|
+
All keys from \`domain/config/schema.js\`, classified on the component axis.
|
|
619
|
+
|
|
620
|
+
| Key | Component class | Decision note |
|
|
621
|
+
|---|---|---|
|
|
622
|
+
${keyRows}
|
|
623
|
+
|
|
624
|
+
## Non-goals
|
|
625
|
+
|
|
626
|
+
This page does not decide whether a key is useful, and it is not an approval
|
|
627
|
+
process. It records the class so the gate can refuse an unclassified or
|
|
628
|
+
forbidden surface.
|
|
629
|
+
|
|
630
|
+
Enforced by: \`scripts/check-components.mjs\`; tests in \`tests/components.test.mjs\`; gate: \`npm run docs:check:components\`.
|
|
631
|
+
`
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function relativeAdr(repositoryPath) {
|
|
635
|
+
const name = String(repositoryPath ?? '').split('/').pop()
|
|
636
|
+
return `../adr/${name}`
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function decidedIn(repositoryPath) {
|
|
640
|
+
return String(repositoryPath ?? '').split('/').pop()
|
|
641
|
+
}
|