dsh-tower 2.3.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 +34 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +17 -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 +25 -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/en.json +31 -11
- package/locale/zh.json +30 -10
- package/package.json +16 -3
- package/scripts/check-components.mjs +235 -0
- package/scripts/component-surface.json +395 -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 +17 -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/domain/config/schema.js
CHANGED
|
@@ -33,7 +33,10 @@ export const DEFAULT_DENY_TOOLS = [
|
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
const LEGACY_KEYS = ['section', 'allowAgentControl']
|
|
36
|
-
|
|
36
|
+
// #121: the `tools` group is gone. Every capability that used to be a
|
|
37
|
+
// `tools.expose*` boolean is now a component row in `cordis.patch.yml`, and a
|
|
38
|
+
// component row is the switch. What remains here is genuine tuning policy.
|
|
39
|
+
const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'archive', 'leases', 'dev']
|
|
37
40
|
|
|
38
41
|
const SECTION_KEYS = {
|
|
39
42
|
init: ['autoGitInit', 'snapshotDirty', 'autoExclude'],
|
|
@@ -75,9 +78,8 @@ const SECTION_KEYS = {
|
|
|
75
78
|
gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'maxReportBytes'],
|
|
76
79
|
docs: ['board', 'boardLocation'],
|
|
77
80
|
archive: ['enabled', 'afterDays', 'maxMissions'],
|
|
78
|
-
tools: ['exposeOperational', 'exposeBatch', 'exposeImport', 'exposeLeases', 'exposeTests', 'exposeRecovery', 'exposeDoctor'],
|
|
79
81
|
leases: ['ttlMs'],
|
|
80
|
-
dev: ['
|
|
82
|
+
dev: ['reloadDelayMs', 'reloadRequiredIds'],
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
const MODEL_KEYS = ['provider', 'model', 'reasoningEffort', 'maxTokens']
|
|
@@ -261,30 +263,16 @@ export const Config = Schema.object({
|
|
|
261
263
|
})
|
|
262
264
|
.default({})
|
|
263
265
|
.description('Terminal-mission archival policy. Archived missions are moved, never deleted.'),
|
|
264
|
-
tools: Schema.object({
|
|
265
|
-
exposeOperational: Schema.boolean()
|
|
266
|
-
.default(true)
|
|
267
|
-
.description('Master switch for batch/resource/import/legacy/recover/test/doctor tools.'),
|
|
268
|
-
exposeBatch: Schema.boolean().default(true).description('Register tower_batch.'),
|
|
269
|
-
exposeImport: Schema.boolean().default(true).description('Register tower_import_legacy/tower_legacy_map.'),
|
|
270
|
-
exposeLeases: Schema.boolean().default(true).description('Register tower_resource advisory leases.'),
|
|
271
|
-
exposeTests: Schema.boolean().default(true).description('Register tower_test; gates.requireTests requires it.'),
|
|
272
|
-
exposeRecovery: Schema.boolean().default(true).description('Register tower_recover (stale state-lock escape).'),
|
|
273
|
-
exposeDoctor: Schema.boolean().default(true).description('Register tower_doctor (read-only health contract).'),
|
|
274
|
-
})
|
|
275
|
-
.default({})
|
|
276
|
-
.description('Tool-surface policy.'),
|
|
277
266
|
leases: Schema.object({
|
|
278
267
|
ttlMs: Schema.natural().min(1000).max(86400000).default(900000).description('Default advisory lease lifetime; expiry never auto-releases.'),
|
|
279
268
|
})
|
|
280
269
|
.default({})
|
|
281
270
|
.description('Advisory resource leases.'),
|
|
282
271
|
dev: Schema.object({
|
|
283
|
-
hotReload: Schema.boolean().default(true).description('Register tower_reload and /tower reload when the DSH HMR service is available.'),
|
|
284
272
|
reloadDelayMs: Schema.natural().min(1).max(5000).default(50).description('Delay before a scheduled reload, so the tool result is delivered first.'),
|
|
285
273
|
reloadRequiredIds: Schema.array(Schema.string())
|
|
286
|
-
.default(['tower
|
|
287
|
-
.description('Loader entry ids passed to reconcileProfile as required.'),
|
|
274
|
+
.default(['tower', 'tower-host', 'preset-tower-worker'])
|
|
275
|
+
.description('Loader entry ids passed to reconcileProfile as required. Optional component rows are deliberately not required, so a row switched off is not a reload failure.'),
|
|
288
276
|
})
|
|
289
277
|
.default({})
|
|
290
278
|
.description('Development and hot-reload behavior.'),
|
|
@@ -314,14 +302,23 @@ export function configSurface(config = Config) {
|
|
|
314
302
|
return { keys, sections, count: keys.length }
|
|
315
303
|
}
|
|
316
304
|
|
|
305
|
+
/** @param {any} value */
|
|
317
306
|
export function clone(value) {
|
|
318
307
|
return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
|
|
319
308
|
}
|
|
320
309
|
|
|
310
|
+
/**
|
|
311
|
+
* @param {any} value
|
|
312
|
+
* @returns {boolean}
|
|
313
|
+
*/
|
|
321
314
|
export function isPlainObject(value) {
|
|
322
315
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
323
316
|
}
|
|
324
317
|
|
|
318
|
+
/**
|
|
319
|
+
* @param {any} base
|
|
320
|
+
* @param {any} override
|
|
321
|
+
*/
|
|
325
322
|
export function deepMerge(base, override) {
|
|
326
323
|
if (override === undefined) return clone(base) ?? {}
|
|
327
324
|
if (!isPlainObject(base) || !isPlainObject(override)) return clone(override)
|
|
@@ -350,6 +347,10 @@ export function normalizeLegacy(raw = {}) {
|
|
|
350
347
|
return out
|
|
351
348
|
}
|
|
352
349
|
|
|
350
|
+
/**
|
|
351
|
+
* @param {any} value
|
|
352
|
+
* @param {string} label
|
|
353
|
+
*/
|
|
353
354
|
function assertModelKeys(value, label) {
|
|
354
355
|
const bad = Object.keys(value).filter((key) => !MODEL_KEYS.includes(key))
|
|
355
356
|
if (bad.length > 0) {
|
|
@@ -357,12 +358,16 @@ function assertModelKeys(value, label) {
|
|
|
357
358
|
}
|
|
358
359
|
}
|
|
359
360
|
|
|
361
|
+
/**
|
|
362
|
+
* @param {any} raw
|
|
363
|
+
* @param {string} label
|
|
364
|
+
*/
|
|
360
365
|
export function assertKnownKeys(raw, label) {
|
|
361
366
|
const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
|
|
362
367
|
if (unknown.length > 0) {
|
|
363
368
|
throw new TowerToolError(
|
|
364
369
|
'config-unknown-key',
|
|
365
|
-
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, archive?, leases?, dev
|
|
370
|
+
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, archive?, leases?, dev? }`,
|
|
366
371
|
)
|
|
367
372
|
}
|
|
368
373
|
for (const [group, allowed] of Object.entries(SECTION_KEYS)) {
|
|
@@ -387,7 +392,11 @@ export function assertKnownKeys(raw, label) {
|
|
|
387
392
|
|
|
388
393
|
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
389
394
|
|
|
390
|
-
/**
|
|
395
|
+
/**
|
|
396
|
+
* Reject prototype-pollution style keys anywhere in a repository config.
|
|
397
|
+
* @param {any} value
|
|
398
|
+
* @param {string} label
|
|
399
|
+
*/
|
|
391
400
|
export function assertSafeKeys(value, label) {
|
|
392
401
|
if (Array.isArray(value)) {
|
|
393
402
|
for (const item of value) assertSafeKeys(item, label)
|
|
@@ -402,6 +411,7 @@ export function assertSafeKeys(value, label) {
|
|
|
402
411
|
}
|
|
403
412
|
}
|
|
404
413
|
|
|
414
|
+
/** @param {any} [rawConfig] */
|
|
405
415
|
export function resolveTowerConfig(rawConfig) {
|
|
406
416
|
const raw = rawConfig ?? {}
|
|
407
417
|
if (raw !== null && typeof raw !== 'object') throw new TowerToolError('config-invalid', 'Tower config must be an object')
|
|
@@ -413,11 +423,12 @@ export function resolveTowerConfig(rawConfig) {
|
|
|
413
423
|
throw new TowerToolError('config-invalid', `Tower config is invalid: ${String(error?.message ?? error)}`)
|
|
414
424
|
}
|
|
415
425
|
const mode = clone(parsed.mode ?? {})
|
|
416
|
-
if (parsed.gates?.requireTests === true) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
426
|
+
if (parsed.gates?.requireTests === true && parsed.tests?.enabled === false) {
|
|
427
|
+
// #121: the tower-tests *component row* is the capability switch now; a
|
|
428
|
+
// repository that turns the row off cannot satisfy gates.requireTests. The
|
|
429
|
+
// component-level half of this invariant is enforced at plan/mode entry,
|
|
430
|
+
// where the mounted rows are known (host/presets.js).
|
|
431
|
+
throw new TowerToolError('config-invalid', 'Tower config is invalid: gates.requireTests is true but test discovery is disabled (tests.enabled:false)')
|
|
421
432
|
}
|
|
422
433
|
// Canonical keys win; the legacy top-level aliases only fill gaps.
|
|
423
434
|
const rawMode = raw.mode ?? {}
|
|
@@ -444,7 +455,6 @@ export function resolveTowerConfig(rawConfig) {
|
|
|
444
455
|
gates: clone(parsed.gates ?? {}),
|
|
445
456
|
docs: clone(parsed.docs ?? {}),
|
|
446
457
|
archive: clone(parsed.archive ?? {}),
|
|
447
|
-
tools: clone(parsed.tools ?? {}),
|
|
448
458
|
leases: clone(parsed.leases ?? {}),
|
|
449
459
|
dev: clone(parsed.dev ?? {}),
|
|
450
460
|
}
|
|
@@ -4,7 +4,12 @@ const S = {
|
|
|
4
4
|
str: { type: 'string' },
|
|
5
5
|
int: { type: 'integer' },
|
|
6
6
|
bool: { type: 'boolean' },
|
|
7
|
+
/** @param {any} items */
|
|
7
8
|
arr: (items) => ({ type: 'array', items }),
|
|
9
|
+
/**
|
|
10
|
+
* @param {Record<string, any>} [properties]
|
|
11
|
+
* @param {string[]} [required]
|
|
12
|
+
*/
|
|
8
13
|
obj: (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: true }),
|
|
9
14
|
any: {},
|
|
10
15
|
}
|
|
@@ -298,10 +303,15 @@ const SCHEMAS = {
|
|
|
298
303
|
legacyMap: S.obj({ ok: S.bool, map: S.obj(), maps: S.arr(S.obj()) }, ['ok']),
|
|
299
304
|
}
|
|
300
305
|
|
|
306
|
+
/**
|
|
307
|
+
* @param {any} _args
|
|
308
|
+
* @param {any} value
|
|
309
|
+
*/
|
|
301
310
|
function renderValue(_args, value) {
|
|
302
311
|
return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
303
312
|
}
|
|
304
313
|
|
|
314
|
+
/** @param {any} schema */
|
|
305
315
|
function output(schema) {
|
|
306
316
|
return { schema, render: renderValue }
|
|
307
317
|
}
|
package/domain/errors.js
CHANGED
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
|
|
25
25
|
/** Neutral error type shared by the git adapter and the domain classifier. */
|
|
26
26
|
export class GitError extends Error {
|
|
27
|
+
/**
|
|
28
|
+
* @param {string[]} args
|
|
29
|
+
* @param {string} detail
|
|
30
|
+
* @param {number | undefined} code the child-process errno, not a Tower code
|
|
31
|
+
* @param {{ timedOut?: boolean, bufferOverflow?: boolean, stdout?: string }} [meta]
|
|
32
|
+
*/
|
|
27
33
|
constructor(args, detail, code, meta = {}) {
|
|
28
34
|
super(`git ${args.join(' ')} failed: ${detail}`)
|
|
29
35
|
this.name = 'GitError'
|
|
@@ -327,11 +333,15 @@ export class TowerToolError extends Error {
|
|
|
327
333
|
}
|
|
328
334
|
}
|
|
329
335
|
|
|
336
|
+
/** @param {any} error */
|
|
330
337
|
function originalMessage(error) {
|
|
331
338
|
return String(error?.message ?? error)
|
|
332
339
|
}
|
|
333
340
|
|
|
334
|
-
/**
|
|
341
|
+
/**
|
|
342
|
+
* Wrap any thrown value into a TowerToolError with a stable code.
|
|
343
|
+
* @param {any} error
|
|
344
|
+
*/
|
|
335
345
|
export function classifyError(error) {
|
|
336
346
|
if (error instanceof TowerToolError) return error
|
|
337
347
|
if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
|
package/domain/mission.js
CHANGED
|
@@ -12,7 +12,18 @@ export const MISSION_STATUSES = ['planned', 'active', 'completed', 'blocked', 'p
|
|
|
12
12
|
export const SCHEMA_VERSION = 3
|
|
13
13
|
/** @type {readonly MergeVerdict[]} */
|
|
14
14
|
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
15
|
-
|
|
15
|
+
// #115: the review status vocabulary is one runtime source. The validator
|
|
16
|
+
// accepts any positive item count (`p1-7items`), while the published
|
|
17
|
+
// `ReviewStatus` type is generated from REVIEW_STATUS_ITEMS, which fixes the
|
|
18
|
+
// documented 1..5 range so a consumer sees a closed union.
|
|
19
|
+
export const REVIEW_STATUS_PRIORITIES = ['p1', 'p2']
|
|
20
|
+
export const REVIEW_STATUS_ITEMS = [1, 2, 3, 4, 5]
|
|
21
|
+
/** @type {readonly string[]} */
|
|
22
|
+
export const REVIEW_STATUSES = [
|
|
23
|
+
'clean',
|
|
24
|
+
...REVIEW_STATUS_PRIORITIES.flatMap((priority) => REVIEW_STATUS_ITEMS.map((count) => `${priority}-${count}items`)),
|
|
25
|
+
]
|
|
26
|
+
export const REVIEW_STATUS_RE = new RegExp(`^(?:clean|(?:${REVIEW_STATUS_PRIORITIES.join('|')})-\\d+items)$`)
|
|
16
27
|
/** @type {ReadonlySet<string>} */
|
|
17
28
|
export const TERMINAL_STATUSES = new Set(['merged', 'abandoned'])
|
|
18
29
|
/** @type {readonly FindingType[]} */
|
package/domain/surface.js
CHANGED
|
@@ -9,9 +9,19 @@
|
|
|
9
9
|
//
|
|
10
10
|
// This module is pure: no fs, no git. `scripts/surface-baseline.mjs` supplies
|
|
11
11
|
// the live counts and the file reads; `scripts/lib/surface.mjs` wraps them.
|
|
12
|
+
//
|
|
13
|
+
// Since #120 `components` is a fourth surface: the number of user-visible
|
|
14
|
+
// component rows in `cordis.patch.yml`. That is what makes a contraction (the
|
|
15
|
+
// 2.3.0 4 -> 1 group row) and the coming #121 growth require a written decision
|
|
16
|
+
// instead of arriving quietly.
|
|
12
17
|
export const SURFACE_BASELINE_FILE = 'scripts/surface-baseline.json'
|
|
13
18
|
export const SURFACE_CLASSIFICATION_FILE = 'docs/reference/surface-budget.md'
|
|
14
|
-
export const SURFACE_KEYS = ['configKeys', 'sections', 'tools']
|
|
19
|
+
export const SURFACE_KEYS = ['configKeys', 'sections', 'tools', 'components']
|
|
20
|
+
// A count in this list is two-way: a contraction needs a decision record just
|
|
21
|
+
// like growth does. The component surface is where the failure actually
|
|
22
|
+
// happened (4 top-level rows -> 1 group row), so "a reduction is cheap" is
|
|
23
|
+
// exactly the policy hole #120 closes.
|
|
24
|
+
export const SURFACE_TWO_WAY_KEYS = ['components']
|
|
15
25
|
export const CONFIG_CLASSES = ['core', 'tuning', 'niche']
|
|
16
26
|
export const TOOL_CLASSES = ['core', 'optional']
|
|
17
27
|
export const SURFACE_SCHEMA_VERSION = 1
|
|
@@ -178,71 +188,160 @@ export function validateClassification(markdown, liveNames, options = {}) {
|
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
/**
|
|
181
|
-
* Validate
|
|
182
|
-
* chain. This is the brake: `current[surface] !== entry.value` fails, and every
|
|
183
|
-
* decision that grows a count must name an existing ADR or deletions that are
|
|
184
|
-
* provably absent from the live surface.
|
|
191
|
+
* Validate an explicit measurement-correction decision (#118).
|
|
185
192
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
193
|
+
* A correction says the live surface did not grow: the earlier measurement
|
|
194
|
+
* missed a registration path. It is deliberately narrower than an ADR — it only
|
|
195
|
+
* authorizes the written `from`/`to` step it carries, needs a reason and a date,
|
|
196
|
+
* and (when it names a `ref`) that reference must resolve. A later count change
|
|
197
|
+
* still needs its own decision, so the brake stays strict.
|
|
198
|
+
*
|
|
199
|
+
* @returns {boolean} true when the correction is valid evidence for the growth
|
|
200
|
+
*/
|
|
201
|
+
function correctionEvidence(decision, where, record, exists) {
|
|
202
|
+
if (decision.correction === undefined) return false
|
|
203
|
+
if (!isPlainObject(decision.correction)) {
|
|
204
|
+
record('correction-invalid', `${where}.correction must be an object with a reason and a date`)
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
const correction = decision.correction
|
|
208
|
+
const reason = typeof correction.reason === 'string' ? correction.reason.trim() : ''
|
|
209
|
+
if (!reason) {
|
|
210
|
+
record('correction-invalid', `${where}.correction needs a non-empty reason explaining why the measurement changed`)
|
|
211
|
+
}
|
|
212
|
+
const date = correction.date ?? decision.date
|
|
213
|
+
const dateOk = typeof date === 'string' && ISO_DATE.test(date)
|
|
214
|
+
if (!dateOk) {
|
|
215
|
+
record('correction-invalid', `${where}.correction needs a date (YYYY-MM-DD)`)
|
|
216
|
+
} else if (typeof decision.date === 'string' && date !== decision.date) {
|
|
217
|
+
record('correction-invalid', `${where}.correction.date ${date} does not match the decision date ${decision.date}`)
|
|
218
|
+
}
|
|
219
|
+
let refOk = true
|
|
220
|
+
if (correction.ref !== undefined) {
|
|
221
|
+
const ref = typeof correction.ref === 'string' ? correction.ref.trim().replace(/\\/g, '/') : ''
|
|
222
|
+
if (!ref) {
|
|
223
|
+
record('correction-invalid', `${where}.correction.ref must be a repository path when present`)
|
|
224
|
+
refOk = false
|
|
225
|
+
} else if (!ref.startsWith('docs/') || !ref.endsWith('.md')) {
|
|
226
|
+
record('correction-invalid', `${where}.correction.ref must name a docs/*.md record, got ${ref}`)
|
|
227
|
+
refOk = false
|
|
228
|
+
} else if (!exists(ref)) {
|
|
229
|
+
record('correction-invalid', `${where}.correction.ref ${ref} does not exist`)
|
|
230
|
+
refOk = false
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return reason.length > 0 && dateOk && refOk
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Validate one surface entry against its live count and its decision chain. This
|
|
238
|
+
* is the brake: `live !== entry.value` fails, and every decision that grows a
|
|
239
|
+
* count must name an existing ADR, deletions that are provably absent from the
|
|
240
|
+
* live surface, or a valid measurement-correction record.
|
|
241
|
+
*
|
|
242
|
+
* A two-way surface (see SURFACE_TWO_WAY_KEYS) also refuses a contraction whose
|
|
243
|
+
* new count is not reached by a decision record; the other surfaces keep the
|
|
244
|
+
* "a reduction is cheap" rule from ADR 0004.
|
|
245
|
+
*
|
|
246
|
+
* Extracted from `validateBaseline` so a narrower gate (`docs:check:components`,
|
|
247
|
+
* #120) can validate just the component count without re-measuring the others.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} key one of SURFACE_KEYS
|
|
250
|
+
* @param {{ seed: number, value: number, decisions: any[] }} entry
|
|
251
|
+
* @param {number} live
|
|
252
|
+
* @param {string[]} liveNames
|
|
188
253
|
* @param {{ exists?: (relativePath: string) => boolean }} [options]
|
|
189
254
|
* @returns {Array<{ code: string, level: string, message: string }>}
|
|
190
255
|
*/
|
|
191
|
-
export function
|
|
256
|
+
export function validateSurfaceEntry(key, entry, live, liveNames, options = {}) {
|
|
192
257
|
const exists = options.exists ?? (() => false)
|
|
193
258
|
const findings = []
|
|
194
259
|
const record = (code, message) => findings.push({ code, level: 'error', message })
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
260
|
+
if (!Number.isInteger(live)) {
|
|
261
|
+
record('surface-missing', `live ${key} count is unavailable`)
|
|
262
|
+
return findings
|
|
263
|
+
}
|
|
264
|
+
if (live !== entry.value) {
|
|
265
|
+
const direction = live > entry.value ? 'grew' : 'shrank'
|
|
266
|
+
record(
|
|
267
|
+
'stale-baseline',
|
|
268
|
+
`${key} ${direction} from the committed baseline ${entry.value} to ${live}; update ${SURFACE_BASELINE_FILE} with an ADR or a deletion record before landing`,
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
let running = entry.seed
|
|
272
|
+
entry.decisions.forEach((decision, index) => {
|
|
273
|
+
const where = `${key}.decisions[${index}]`
|
|
274
|
+
if (decision.from !== running) {
|
|
275
|
+
record('broken-chain', `${where}.from ${decision.from} does not continue from ${running}`)
|
|
202
276
|
}
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
record(
|
|
206
|
-
'stale-baseline',
|
|
207
|
-
`${key} ${direction} from the committed baseline ${entry.value} to ${live}; update ${SURFACE_BASELINE_FILE} with an ADR or a deletion record before landing`,
|
|
208
|
-
)
|
|
277
|
+
if (decision.date !== undefined && !ISO_DATE.test(String(decision.date))) {
|
|
278
|
+
record('broken-chain', `${where}.date is not YYYY-MM-DD`)
|
|
209
279
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (!adrExists && !deletionEvidence) {
|
|
230
|
-
const reasons = []
|
|
231
|
-
if (adr && !adrExists) reasons.push(`ADR ${adr} does not exist under docs/adr/`)
|
|
232
|
-
if (deletions.length === 0) reasons.push('no deletions recorded')
|
|
233
|
-
else if (stillLive.length > 0) reasons.push(`deleted name(s) are still in the live surface: ${stillLive.join(', ')}`)
|
|
234
|
-
else if (deletions.length < growth) reasons.push(`only ${deletions.length} deletion(s) for a growth of ${growth}`)
|
|
235
|
-
record('growth-without-evidence', `${where} grows ${key} by ${growth} without an ADR or a matching deletion (${reasons.join('; ')})`)
|
|
236
|
-
}
|
|
280
|
+
const growth = decision.to - decision.from
|
|
281
|
+
if (growth > 0) {
|
|
282
|
+
const deletions = Array.isArray(decision.deletions)
|
|
283
|
+
? decision.deletions.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim())
|
|
284
|
+
: []
|
|
285
|
+
const adr = typeof decision.adr === 'string' && decision.adr.trim() ? decision.adr.trim().replace(/\\/g, '/') : null
|
|
286
|
+
const adrExists = Boolean(adr) && adr.startsWith('docs/adr/') && adr.endsWith('.md') && exists(adr)
|
|
287
|
+
const liveSet = new Set(liveNames ?? [])
|
|
288
|
+
const stillLive = deletions.filter((name) => liveSet.has(name))
|
|
289
|
+
const deletionEvidence = deletions.length >= growth && stillLive.length === 0
|
|
290
|
+
const correction = correctionEvidence(decision, where, record, exists)
|
|
291
|
+
if (!adrExists && !deletionEvidence && !correction) {
|
|
292
|
+
const reasons = []
|
|
293
|
+
if (adr && !adrExists) reasons.push(`ADR ${adr} does not exist under docs/adr/`)
|
|
294
|
+
if (deletions.length === 0) reasons.push('no deletions recorded')
|
|
295
|
+
else if (stillLive.length > 0) reasons.push(`deleted name(s) are still in the live surface: ${stillLive.join(', ')}`)
|
|
296
|
+
else if (deletions.length < growth) reasons.push(`only ${deletions.length} deletion(s) for a growth of ${growth}`)
|
|
297
|
+
if (decision.correction !== undefined && !correction) reasons.push('the correction record is invalid')
|
|
298
|
+
record('growth-without-evidence', `${where} grows ${key} by ${growth} without an ADR, a matching deletion or a valid correction (${reasons.join('; ')})`)
|
|
237
299
|
}
|
|
238
|
-
running = decision.to
|
|
239
|
-
})
|
|
240
|
-
if (running !== entry.value && !(entry.decisions.length === 0 && entry.value < running)) {
|
|
241
|
-
record(
|
|
242
|
-
'broken-chain',
|
|
243
|
-
`${key} value ${entry.value} does not match the last decision (${running}); every count change needs a decision record`,
|
|
244
|
-
)
|
|
245
300
|
}
|
|
301
|
+
running = decision.to
|
|
302
|
+
})
|
|
303
|
+
const twoWay = SURFACE_TWO_WAY_KEYS.includes(key)
|
|
304
|
+
if (running !== entry.value && !(entry.decisions.length === 0 && entry.value < running && !twoWay)) {
|
|
305
|
+
record(
|
|
306
|
+
'broken-chain',
|
|
307
|
+
`${key} value ${entry.value} does not match the last decision (${running}); every count change needs a decision record`,
|
|
308
|
+
)
|
|
246
309
|
}
|
|
247
310
|
return findings
|
|
248
311
|
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Validate the whole committed baseline. Every surface in SURFACE_KEYS is
|
|
315
|
+
* checked, so a missing `current` entry is a `surface-missing` finding.
|
|
316
|
+
*
|
|
317
|
+
* @param {ReturnType<typeof parseBaseline>} baseline
|
|
318
|
+
* @param {{ configKeys: number, sections: number, tools: number, components: number, liveNames: { configKeys: string[], sections: string[], tools: string[], components: string[] } }} current
|
|
319
|
+
* @param {{ exists?: (relativePath: string) => boolean }} [options]
|
|
320
|
+
* @returns {Array<{ code: string, level: string, message: string }>}
|
|
321
|
+
*/
|
|
322
|
+
export function validateBaseline(baseline, current, options = {}) {
|
|
323
|
+
const findings = []
|
|
324
|
+
for (const key of SURFACE_KEYS) {
|
|
325
|
+
findings.push(...validateSurfaceEntry(key, baseline.surfaces[key], current[key], current.liveNames?.[key], options))
|
|
326
|
+
}
|
|
327
|
+
return findings
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Validate one surface entry of the committed baseline (the narrower form used
|
|
332
|
+
* by `docs:check:components`).
|
|
333
|
+
*
|
|
334
|
+
* @param {ReturnType<typeof parseBaseline>} baseline
|
|
335
|
+
* @param {string} key
|
|
336
|
+
* @param {Record<string, any>} current
|
|
337
|
+
* @param {{ exists?: (relativePath: string) => boolean }} [options]
|
|
338
|
+
* @returns {Array<{ code: string, level: string, message: string }>}
|
|
339
|
+
*/
|
|
340
|
+
export function validateSurfaceBaselineKey(baseline, key, current, options = {}) {
|
|
341
|
+
if (!SURFACE_KEYS.includes(key)) {
|
|
342
|
+
throw surfaceError(`surface ${key} is not one of ${SURFACE_KEYS.join('/')}`)
|
|
343
|
+
}
|
|
344
|
+
const entry = baseline.surfaces[key]
|
|
345
|
+
if (!entry) throw surfaceError(`surface baseline has no "${key}" entry`)
|
|
346
|
+
return validateSurfaceEntry(key, entry, current[key], current.liveNames?.[key], options)
|
|
347
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// tower-batch component entry (#121): evidence batches only.
|
|
2
|
+
//
|
|
3
|
+
// Each optional capability is its own loader row, so switching this component
|
|
4
|
+
// off removes exactly `tower_batch` and leaves the rest of Tower working. The
|
|
5
|
+
// row is declared in cordis.patch.yml; the package export is `./batch`.
|
|
6
|
+
import { holdComponent } from './capability-registry.js'
|
|
7
|
+
import { registerBatchCapability } from './tool-registry.js'
|
|
8
|
+
|
|
9
|
+
export const name = 'dsh-tower/batch'
|
|
10
|
+
export const inject = ['tools', 'tower']
|
|
11
|
+
|
|
12
|
+
export function apply(ctx) {
|
|
13
|
+
holdComponent(ctx, 'tower-batch')
|
|
14
|
+
registerBatchCapability(ctx)
|
|
15
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// tower-hmr component entry (#121): dev hot reload, shipped off.
|
|
2
|
+
//
|
|
3
|
+
// The row defaults to `disabled: true` in cordis.patch.yml, because a profile
|
|
4
|
+
// without HMR is the normal case and the capability only makes sense for linked
|
|
5
|
+
// development installs. Switching the row on registers `tower_reload` and the
|
|
6
|
+
// `/tower reload` branch; switching it off leaves both absent while
|
|
7
|
+
// `tower_status` reports "disabled by component tower-hmr".
|
|
8
|
+
import { createHmrBridge } from './hmr.js'
|
|
9
|
+
import { runtimeConfig } from './config.js'
|
|
10
|
+
import { clearHmrBridge, holdComponent, setHmrBridge } from './capability-registry.js'
|
|
11
|
+
import { registerHmrCapability } from './tool-registry.js'
|
|
12
|
+
|
|
13
|
+
export const name = 'dsh-tower/hmr'
|
|
14
|
+
export const inject = ['tools', 'tower']
|
|
15
|
+
|
|
16
|
+
export function apply(ctx) {
|
|
17
|
+
holdComponent(ctx, 'tower-hmr')
|
|
18
|
+
const dev = runtimeConfig().dev ?? {}
|
|
19
|
+
const bridge = createHmrBridge(ctx, {
|
|
20
|
+
requiredIds: dev.reloadRequiredIds,
|
|
21
|
+
delayMs: dev.reloadDelayMs,
|
|
22
|
+
logger: ctx.logger ?? console,
|
|
23
|
+
})
|
|
24
|
+
setHmrBridge(bridge)
|
|
25
|
+
ctx.effect(() => () => {
|
|
26
|
+
clearHmrBridge(bridge)
|
|
27
|
+
bridge.dispose()
|
|
28
|
+
})
|
|
29
|
+
registerHmrCapability(ctx, { bridge })
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// tower-import component entry (#121): legacy import and its audit map only.
|
|
2
|
+
import { holdComponent } from './capability-registry.js'
|
|
3
|
+
import { registerImportCapability } from './tool-registry.js'
|
|
4
|
+
|
|
5
|
+
export const name = 'dsh-tower/import'
|
|
6
|
+
export const inject = ['tools', 'tower']
|
|
7
|
+
|
|
8
|
+
export function apply(ctx) {
|
|
9
|
+
holdComponent(ctx, 'tower-import')
|
|
10
|
+
registerImportCapability(ctx)
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// tower-leases component entry (#121): advisory resource leases only.
|
|
2
|
+
import { holdComponent } from './capability-registry.js'
|
|
3
|
+
import { registerLeasesCapability } from './tool-registry.js'
|
|
4
|
+
|
|
5
|
+
export const name = 'dsh-tower/leases'
|
|
6
|
+
export const inject = ['tools', 'tower']
|
|
7
|
+
|
|
8
|
+
export function apply(ctx) {
|
|
9
|
+
holdComponent(ctx, 'tower-leases')
|
|
10
|
+
registerLeasesCapability(ctx)
|
|
11
|
+
}
|