fix(dsh): самопроверка гейтит bootstrap/commands — бандл не падает без ctx.on/ctx.inject (I-2, #69)

This commit is contained in:
Andrey Epifancev 2026-10-25 01:08:34 +04:00
parent f7b616d32e
commit 6d366ca177
4 changed files with 73 additions and 15 deletions

View File

@ -361,8 +361,9 @@ profile was removed; the real `desktop` profile was not touched.
(`ctx.tools.guard`, `ctx.on` for `agent/pre-step`, `ctx.inject` for commands,
`workingDirectory.get`), logs
`[dsh-harness] self-check: <feature> not-ok: <detail>` for each missing
service, and skips only the guard wiring when `tools.guard` is missing
(bootstrap and `/role` keep working when their own services exist).
service, and skips the feature whose service is missing (guards, bootstrap,
or commands — each gated by its own check), so a DSH version without one of
the services does not crash the bundle.
`/dsh-harness-check` re-runs the same check on demand. This is the in-DSH
compat check that closes RV-11 (D-6) — the terminal `--check` cannot see the
SDK inside `app.asar`.

View File

@ -222,8 +222,10 @@ repository layout:
- The bundle self-checks the DSH contract it depends on at activation:
`ctx.tools.guard`, `ctx.on` for `agent/pre-step`, `ctx.inject` for commands,
`workingDirectory.get`. Each missing service is logged as
`[dsh-harness] self-check: <feature> not-ok: …`; the guard wiring is the only
feature skipped when its service is missing — bootstrap and `/role` keep
working when their own services exist. The `/dsh-harness-check` command
re-runs the same check on demand (one `ok` / `not-ok` line per feature).
Disable with `selfCheck: false` / `checkCommand: false`.
`[dsh-harness] self-check: <feature> not-ok: …`, and the feature whose
service is missing is skipped (guards, bootstrap, or commands — each gated
by its own check), so a DSH version without one of the services does not
crash the bundle; the remaining features keep working. The
`/dsh-harness-check` command re-runs the same check on demand (one
`ok` / `not-ok` line per feature). Disable with `selfCheck: false` /
`checkCommand: false`.

View File

@ -105,13 +105,18 @@ export function apply(ctx, rawConfig = {}) {
const selfCheckReport =
config.enabled && config.selfCheck ? collectSelfCheck(ctx, config) : null
const guardsOk = selfCheckReport
? selfCheckReport.find((entry) => entry.feature === 'guards')?.ok === true
: true
// A feature is wired only when its own service passed the self-check (or
// the self-check itself is off): a missing service must disable its feature
// instead of crashing the whole bundle.
const serviceOk = (feature) =>
!selfCheckReport ||
selfCheckReport.find((entry) => entry.feature === feature)?.ok === true
const guardsOk = serviceOk('guards')
const bootstrapOk = serviceOk('bootstrap')
const commandsOk = serviceOk('commands')
// Log every not-ok feature before any wiring runs, so a service that is
// missing from this DSH version leaves a diagnostic line even when a later
// wiring block throws on first use.
// Log every not-ok feature before any wiring runs, so a missing service
// always leaves a diagnostic line for the feature being skipped.
if (selfCheckReport) {
for (const entry of selfCheckReport) {
if (!entry.ok) {
@ -133,7 +138,7 @@ export function apply(ctx, rawConfig = {}) {
})
}
if (config.enabled && config.bootstrap) {
if (config.enabled && config.bootstrap && bootstrapOk) {
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
const decision = await next()
if (decision.kind === 'reject' || signal?.aborted) return decision
@ -161,7 +166,7 @@ export function apply(ctx, rawConfig = {}) {
})
}
if (config.enabled && (config.roleCommand || config.checkCommand)) {
if (config.enabled && (config.roleCommand || config.checkCommand) && commandsOk) {
ctx.inject(['commands'], (commandCtx) => {
if (config.roleCommand) {
registerRoleCommand({

View File

@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { apply } from '../bundle/index.js'
import { collectSelfCheck } from '../bundle/lib/selfcheck.js'
import { registerCheckCommand } from '../bundle/command-check.js'
@ -117,3 +118,52 @@ test('registerCheckCommand builds report text', async () => {
assert.match(badResult.text, /guards: not-ok/)
assert.match(badResult.text, /problem/)
})
/** Logger fake that interpolates `%s` and records error lines. */
function recordingLogger(errors) {
return {
info() {},
warn() {},
error(fmt, ...args) {
const line = fmt.replace(/%s/g, () => String(args.shift() ?? ''))
errors.push(line)
},
}
}
test('apply does not throw when ctx.on and ctx.inject are missing', () => {
const calls = { guard: 0 }
const errors = []
const ctx = {
tools: { guard() { calls.guard += 1 } },
workingDirectory: { get() {} },
logger: recordingLogger(errors),
}
assert.doesNotThrow(() => apply(ctx))
assert.equal(calls.guard, 1, 'guards feature stays wired')
assert.ok(
errors.some((line) => line.includes('self-check: bootstrap not-ok')),
'bootstrap not-ok is logged',
)
assert.ok(
errors.some((line) => line.includes('self-check: commands not-ok')),
'commands not-ok is logged',
)
})
test('apply wires guards, bootstrap, and commands with a full ctx', () => {
const calls = { guard: 0, on: 0, inject: 0 }
const ctx = {
tools: { guard() { calls.guard += 1 } },
on() { calls.on += 1 },
inject() { calls.inject += 1 },
workingDirectory: { get() {} },
logger: { info() {}, warn() {}, error() {} },
}
assert.doesNotThrow(() => apply(ctx))
assert.equal(calls.guard, 1, 'guards wired')
assert.equal(calls.on, 1, 'bootstrap wired')
assert.equal(calls.inject, 1, 'commands wired')
})