Merge remote-tracking branch 'origin/master' into fix/checkout-workdir-prompt

# Conflicts:
#	packages/ui/app-boot/README.i18n.yaml
#	packages/ui/app-boot/README.md
#	packages/ui/app-boot/README.zh.md
This commit is contained in:
Turtle
2026-08-01 00:02:17 +08:00
1843 changed files with 146944 additions and 15125 deletions
+201 -3
View File
@@ -5,8 +5,8 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -135,6 +135,33 @@ describe('installFailLoud', () => {
uninstallReal()
expect(process.listenerCount('unhandledRejection')).toBe(before)
})
it('does not report an activation rejection shared by entries in the boot audit', async () => {
const proc = fakeProc()
installFailLoud(NAME, proc)
const error = new Error('assembled activation failure')
const audit = assertEntriesActivated({
loader: {
entries: () => ['broken-a', 'broken-b'].map(name => ({
options: { name },
fiber: {
state: 3,
inject: {},
ctx: { get: () => undefined },
await: async () => { throw error },
},
})),
},
} as unknown as Context, NAME)
await Promise.resolve()
await Promise.resolve()
proc.handlers[0]!(error)
expect(proc.written).toEqual([])
expect(proc.exits).toEqual([])
await expect(audit).rejects.toThrow('assembled activation failure')
proc.handlers[0]!(error)
expect(proc.exits).toEqual([1])
})
})
describe('assertEntriesLoaded', () => {
@@ -157,6 +184,116 @@ describe('assertEntriesLoaded', () => {
})
})
describe('assertEntriesActivated', () => {
interface FakeFiber {
state: number
inject: Record<string, unknown>
ctx: { get(name: string): unknown }
await(): Promise<unknown>
}
const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
loader: { entries: () => entries },
}) as unknown as Context
const fiber = (
state: number,
error?: unknown,
inject: Record<string, unknown> = {},
services: string[] = [],
): FakeFiber => ({
state,
inject,
ctx: { get: name => services.includes(name) ? {} : undefined },
await: error === undefined ? async () => undefined : async () => { throw error },
})
it('passes active entries and ignores disabled entries', async () => {
let awaitCalls = 0
const active = fiber(2)
active.await = async () => {
awaitCalls++
return undefined
}
const disabled = fiber(3, new Error('disabled failure'))
disabled.await = async () => {
awaitCalls++
throw new Error('disabled failure')
}
await expect(assertEntriesActivated(ctxWith([
{ fiber: active, options: { name: 'active' } },
{ fiber: disabled, disabled: true, options: { name: 'disabled' } },
]), NAME)).resolves.toBeUndefined()
expect(awaitCalls).toBe(0)
})
it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
const original = new Error('actual plugin failure')
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(3, original), options: { name: 'broken-plugin' } },
]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
})
it('formats stackless and non-Error activation failures', async () => {
const stackless = new Error('stackless failure')
delete (stackless as { stack?: string }).stack
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(3, stackless), options: { name: 'stackless' } },
{ fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
})
it('reports unresolved services for pending entries', async () => {
let awaitCalls = 0
const expected = [
`${NAME}: 3 entries did not activate`,
'waiting: pending (waiting for services: missingA, missingB)',
'single-wait: pending (waiting for service: missing)',
'unknown-wait: pending (waiting for services: unknown)',
].join('\n')
const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
const singleWait = fiber(0, undefined, { missing: {} })
const unknownWait = fiber(0)
for (const item of [waiting, singleWait, unknownWait]) {
item.await = async () => {
awaitCalls++
return undefined
}
}
await expect(assertEntriesActivated(ctxWith([
{ fiber: waiting, options: { name: 'waiting' } },
{ fiber: singleWait, options: { name: 'single-wait' } },
{ fiber: unknownWait, options: { name: 'unknown-wait' } },
]), NAME)).rejects.toThrow(expected)
expect(awaitCalls).toBe(0)
})
it('retains the numeric diagnostic for a settled unexpected state', async () => {
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(4), options: { name: 'disposed' } },
]), NAME)).rejects.toThrow('disposed: fiber state 4')
})
})
describe('loadOverlayPatches', () => {
it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
const dir = tmp()
const valid = join(dir, 'valid.yml')
writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
const malformed = join(dir, 'malformed.yml')
writeFileSync(malformed, ': bad')
expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
const mapping = join(dir, 'mapping.yml')
writeFileSync(mapping, 'id: target\n')
expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
const scalar = join(dir, 'scalar.yml')
writeFileSync(scalar, '- scalar\n')
expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
})
})
describe('boot', () => {
it('boots a leaf config through the real Loader and settles the tree', async () => {
const dir = tmp()
@@ -176,7 +313,11 @@ describe('boot', () => {
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
const prepared: Context[] = []
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) })
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
expect(hostCtx.loader).toBeDefined()
expect([...hostCtx.loader.entries()]).toEqual([])
prepared.push(hostCtx)
})
try {
expect(prepared).toEqual([ctx])
} finally {
@@ -184,11 +325,68 @@ describe('boot', () => {
}
})
it('exposes dshHomePath to Loader config expressions', async () => {
const dir = tmp()
const dshHome = join(dir, 'home')
vi.stubEnv('DSH_HOME', dshHome)
writeFileSync(join(dir, 'capture.mjs'), [
'export const name = "capture"',
'export function apply(ctx, config) {',
' ctx.provide("capturedPath", config.path)',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), [
'- id: capture',
' name: ./capture.mjs',
' config:',
" path: !!js dshHomePath('sessions')",
'',
].join('\n'))
let ctx: Context | undefined
try {
ctx = await boot(NAME, join(dir, 'cordis.yml'))
expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions'))
} finally {
await ctx?.fiber.dispose()
vi.unstubAllEnvs()
}
})
it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
// What a TUI `/exit` does (ui-tui's disposeRootAndExit): dispose the root
// fiber, which lands while boot() is still awaiting the Loader whenever the
// surface renders before the last entry settles. The Loader service goes
// with the tree, so reading it for the post-boot assertions would crash an
// app that exited exactly as the user asked.
const dir = tmp()
writeFileSync(join(dir, 'exiting.mjs'), [
'export const name = "exiting"',
'export function apply(ctx) {',
' void ctx.root.fiber.dispose()',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n')
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
expect(ctx.get('loader')).toBeUndefined()
})
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
const dir = tmp()
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
})
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
const dir = tmp()
writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
`${NAME}: 1 entry did not activate`,
'./waiting.mjs: pending (waiting for service: neverProvided)',
].join('\n'))
})
})
describe('addHarnessSourceSection', () => {
@@ -0,0 +1,187 @@
/**
* `renderConfigDump` behavior: the offline composition must equal what
* `boot()` mounts (same parser, same patch algorithm), print `!!js`
* expressions verbatim, separate provenance runs with comment lines while
* staying one loadable YAML document, and report skipped patches through
* `warn` instead of failing — mirroring the Loader's boot-time warning for a
* shared overlay whose row exists only on another surface.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@cordisjs/plugin-include'
import { loadOverlayPatches, renderConfigDump } from '../src/index.ts'
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
function writeBase(dir: string): string {
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: shared',
' name: ./noop.mjs',
' config:',
' value: base',
' key: !!js process.env.DSH_DUMP_SPEC',
'- id: untouched',
' name: ./noop.mjs',
'',
].join('\n'))
return base
}
describe('renderConfigDump', () => {
it('composes overlay layers in order, prints !!js verbatim, and labels each section with its provenance', () => {
const dir = tmp()
const base = writeBase(dir)
const surface = join(dir, 'surface.yml')
writeFileSync(surface, [
'- id: shared',
' config:',
' value: surface',
' key: !!js process.env.DSH_DUMP_SPEC',
'- insert:',
' - id: surface-extra',
' name: ./noop.mjs',
'',
].join('\n'))
const personal = join(dir, 'personal.yml')
writeFileSync(personal, [
'- id: surface-extra',
' config:',
' value: personal',
'',
].join('\n'))
const dump = renderConfigDump(NAME, base, [
{ label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) },
{ label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) },
], () => {})
// Comments do not break loadability: the dump parses as one document
// equal to what boot() would mount.
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
id: string
config?: Record<string, unknown>
}[]
expect(parsed).toEqual([
{
id: 'shared',
name: './noop.mjs',
config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } },
},
{ id: 'untouched', name: './noop.mjs' },
{ id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } },
])
// Unevaluated: the expression text round-trips as a !!js scalar.
expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC')
// Provenance separators: origin file, plus every layer that changed the
// row; an inserted row carries the inserting layer as its origin.
expect(dump).toContain('# == base.yml, patched by surface.yml')
expect(dump).toContain('# == base.yml\n- id: untouched')
expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra')
expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched'))
})
it('groups contiguous same-provenance rows under one separator', () => {
const dir = tmp()
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: a',
' name: ./noop.mjs',
'- id: b',
' name: ./noop.mjs',
'',
].join('\n'))
const dump = renderConfigDump(NAME, base, [], () => {})
expect(dump.match(/# == base\.yml/g)).toHaveLength(1)
expect(dump).toContain('# == base.yml\n- id: a')
})
it('composes all layers as one flattened patch list, exactly like boot()', () => {
// boot() flattens every layer into ONE applyEntryPatches call, whose id
// index sees inserted rows but NOT children introduced by a plain group
// `config` replacement. A per-layer composition would rebuild the index
// between layers and let the second layer patch that child — a tree the
// real boot never mounts. Pin the single-call semantics: the child patch
// is skipped (with the layer-labeled warning), matching boot.
const dir = tmp()
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: g',
' name: ./group.mjs',
' group: true',
' config: []',
'',
].join('\n'))
const warnings: string[] = []
const dump = renderConfigDump(NAME, base, [
{
label: 'a.yml',
patches: [{ id: 'g', config: [{ id: 'child', name: './noop.mjs', config: { v: 1 } }] }],
},
{ label: 'b.yml', patches: [{ id: 'child', config: { v: 2 } }] },
], line => void warnings.push(line))
expect(warnings).toEqual([`${NAME}: [b.yml] patch: entry "child" not found`])
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
config?: { config?: { v?: number } }[]
}[]
expect(parsed[0]?.config?.[0]?.config?.v).toBe(1)
// The skipped layer did not change the row, so it is not in provenance.
expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g')
expect(dump).not.toContain('b.yml\n- id: g')
})
it('reports a patch whose target row is absent through warn with its layer label and keeps composing', () => {
const dir = tmp()
const base = writeBase(dir)
const overlay = join(dir, 'overlay.yml')
writeFileSync(overlay, [
'- id: only-on-another-surface',
' config:',
' value: ignored',
'- id: shared',
' config:',
' value: patched',
'',
].join('\n'))
const warnings: string[] = []
const dump = renderConfigDump(
NAME, base,
[{ label: 'overlay.yml', patches: loadOverlayPatches(NAME, overlay) }],
line => void warnings.push(line),
)
expect(warnings).toEqual([`${NAME}: [overlay.yml] patch: entry "only-on-another-surface" not found`])
const parsed = yaml.load(dump, { schema: entryListSchema }) as { config?: { value?: string } }[]
expect(parsed[0]?.config?.value).toBe('patched')
})
it('defaults its warn sink to one stderr line per skipped patch', () => {
const dir = tmp()
const base = writeBase(dir)
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
renderConfigDump(NAME, base, [{ label: 'x.yml', patches: [{ id: 'absent', config: {} }] }])
expect(write).toHaveBeenCalledWith(`${NAME}: [x.yml] patch: entry "absent" not found\n`)
} finally {
write.mockRestore()
}
})
it('fails loud on a missing, unparsable, or non-array base config', () => {
const dir = tmp()
expect(() => renderConfigDump(NAME, join(dir, 'absent.yml'), [], () => {}))
.toThrow(new RegExp(`^${NAME}: failed to read config `))
const invalid = join(dir, 'invalid.yml')
writeFileSync(invalid, 'invalid: [unclosed\n')
expect(() => renderConfigDump(NAME, invalid, [], () => {}))
.toThrow(new RegExp(`^${NAME}: failed to parse config `))
const scalar = join(dir, 'scalar.yml')
writeFileSync(scalar, 'id: not-a-list\n')
expect(() => renderConfigDump(NAME, scalar, [], () => {}))
.toThrow('must be a top-level YAML array of entries')
})
})
@@ -126,3 +126,52 @@ describe('include refresh with overlay patches', () => {
}
})
})
describe('include patches layered over one base', () => {
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
// The surface/`--config`/personal composition: `dsh` includes one shared
// base and applies each source as its own patch list at the SAME include
// level, because patches never cross an include boundary. A later layer
// must therefore be able to reach a row an earlier layer inserted —
// otherwise every surface-only row (the whole TUI front door) would be
// invisible to the user's `~/.dsh/config.yaml`.
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
writeFileSync(join(dir, 'cordis.yml'), [
'- id: base',
" name: 'cordis:include'",
' config:',
' path: ./base.yml',
' patches:',
// Layer 1 (a surface overlay): patch a base row and add two of its own.
' - id: shared',
' config:',
' value: surface',
' - insert:',
' - id: surface-kept',
' name: ./noop.mjs',
' config:',
' value: surface-default',
' - id: surface-dropped',
' name: ./noop.mjs',
// Layer 2 (the user): reconfigure one inserted row and disable the other.
' - id: surface-kept',
' config:',
' value: personal',
' - id: surface-dropped',
' disabled: true',
'',
].join('\n'))
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' })
expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' })
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped')
expect(dropped?.options.disabled).toBe(true)
expect(dropped?.fiber).toBeUndefined()
} finally {
await ctx.fiber.dispose()
}
})
})