Merge origin/master into task/command-feedback-master
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* CSS Modules enter client bundles through virtual modules, so the loader must
|
||||
* explicitly register the underlying stylesheet as a watch dependency.
|
||||
*/
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clientBundle } from '../packages/client/tsdown.client.ts'
|
||||
|
||||
interface CssPlugin {
|
||||
name: string
|
||||
resolveId?: (source: string, importer?: string) => string | null
|
||||
load?: (this: { addWatchFile(id: string): void }, id: string) => Promise<string | null>
|
||||
}
|
||||
|
||||
function cssPlugin(): CssPlugin {
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
const plugins = (configs[1] as { plugins: CssPlugin[] }).plugins
|
||||
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
|
||||
if (plugin === undefined) throw new Error('CSS Modules plugin missing from client config')
|
||||
return plugin
|
||||
}
|
||||
|
||||
describe('client bundle CSS Modules', () => {
|
||||
it('registers the source stylesheet as a watch dependency', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-client-css-watch-'))
|
||||
try {
|
||||
const stylesheet = join(root, 'Fixture.module.css')
|
||||
const importer = join(root, 'index.ts')
|
||||
await writeFile(stylesheet, '.root { color: red; }\n')
|
||||
const plugin = cssPlugin()
|
||||
const virtualId = plugin.resolveId?.('./Fixture.module.css', importer)
|
||||
if (typeof virtualId !== 'string' || plugin.load === undefined) {
|
||||
throw new Error('CSS Modules plugin hooks are incomplete')
|
||||
}
|
||||
const watched: string[] = []
|
||||
|
||||
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
|
||||
|
||||
expect(watched).toEqual([stylesheet])
|
||||
expect(output).toContain('data-plugin-css')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -8,9 +8,8 @@ import { spawn } from 'node:child_process'
|
||||
// Each UI's node invocation matches its base demo script plus the overlay config.
|
||||
const UIS = new Map([
|
||||
['tui', [
|
||||
'--experimental-transform-types',
|
||||
'--import',
|
||||
'./scripts/tspath-loader.ts',
|
||||
'tsx/esm',
|
||||
'apps/cli/src/bin.ts',
|
||||
'--config',
|
||||
'examples/tui-agent/code-mode.cordis.yml',
|
||||
|
||||
@@ -252,6 +252,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
|
||||
PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md',
|
||||
PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md',
|
||||
KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md',
|
||||
PermissionSelect: 'permissions projection payload is owned by packages/ui/permission/src/types.ts',
|
||||
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
|
||||
ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md',
|
||||
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
|
||||
const MINIMUM_GIT = [2, 26, 0]
|
||||
const HOOKS_DIRECTORY = 'dsh-hooks'
|
||||
@@ -437,6 +437,15 @@ function inspectOwnedHooksDirectory(hooksPath) {
|
||||
return { markerPath, ...marker }
|
||||
}
|
||||
|
||||
function isRegisteredOwnedHooksPath(commonDirectory, hooksPath) {
|
||||
const normalizedHooksPath = normalizedPath(hooksPath)
|
||||
const isRegistered = registeredWorktreeConfigPaths(commonDirectory).some(
|
||||
configPath => normalizedPath(join(dirname(configPath), HOOKS_DIRECTORY)) === normalizedHooksPath,
|
||||
)
|
||||
if (!isRegistered) return false
|
||||
return inspectOwnedHooksDirectory(hooksPath)?.hooksPath === hooksPath
|
||||
}
|
||||
|
||||
function ensureOwnedHooksDirectory(hooksPath) {
|
||||
const inspected = inspectOwnedHooksDirectory(hooksPath)
|
||||
if (inspected !== undefined) return inspected
|
||||
@@ -561,14 +570,22 @@ async function main() {
|
||||
'worktree core.hooksPath',
|
||||
)
|
||||
let ownedHooksDirectory
|
||||
let copiedWorktreePathIsOwned = false
|
||||
if (worktreePath !== undefined && worktreePath !== hooksPath) {
|
||||
ownedHooksDirectory = inspectOwnedHooksDirectory(hooksPath)
|
||||
if (ownedHooksDirectory === undefined || ownedHooksDirectory.hooksPath !== worktreePath) {
|
||||
const worktreePathIsRelocated = ownedHooksDirectory?.hooksPath === worktreePath
|
||||
copiedWorktreePathIsOwned = !worktreePathIsRelocated
|
||||
&& isRegisteredOwnedHooksPath(commonDirectory, worktreePath)
|
||||
if (!worktreePathIsRelocated && !copiedWorktreePathIsOwned) {
|
||||
refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath })
|
||||
}
|
||||
}
|
||||
const directWorktreePathIsOwned = worktreePath !== undefined
|
||||
&& (worktreePath === hooksPath || ownedHooksDirectory?.hooksPath === worktreePath)
|
||||
&& (
|
||||
worktreePath === hooksPath
|
||||
|| ownedHooksDirectory?.hooksPath === worktreePath
|
||||
|| copiedWorktreePathIsOwned
|
||||
)
|
||||
const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath')
|
||||
if (effectiveEntry !== undefined) {
|
||||
const effectivePathIsOwned = effectiveEntry.scope === 'worktree'
|
||||
@@ -593,6 +610,7 @@ async function main() {
|
||||
worktreePath !== undefined
|
||||
&& worktreePath !== hooksPath
|
||||
&& ownedHooksDirectory.hooksPath !== worktreePath
|
||||
&& !copiedWorktreePathIsOwned
|
||||
) {
|
||||
throw new Error(`hooks directory ownership changed while relocating ${JSON.stringify(worktreePath)}`)
|
||||
}
|
||||
|
||||
@@ -262,6 +262,30 @@ describe('worktree-local Lefthook installer', () => {
|
||||
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
|
||||
})
|
||||
|
||||
it('replaces the owned hook path Git copies into a newly added worktree', async () => {
|
||||
const fixture = createFixture()
|
||||
const mainInstall = await runInstaller(fixture, fixture.main)
|
||||
expect(mainInstall.status, mainInstall.stderr).toBe(0)
|
||||
const mainHooks = hooksPath(fixture, fixture.main)
|
||||
const mainHookBefore = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
|
||||
const lateLinked = join(fixture.container, 'late-linked')
|
||||
git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked])
|
||||
write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n')
|
||||
installFakeLefthook(lateLinked)
|
||||
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
|
||||
|
||||
const linkedInstall = await runInstaller(fixture, lateLinked)
|
||||
|
||||
expect(linkedInstall.status, linkedInstall.stderr).toBe(0)
|
||||
const linkedHooks = hooksPath(fixture, lateLinked)
|
||||
expect(linkedHooks).not.toBe(mainHooks)
|
||||
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
|
||||
expect(readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')).toContain(
|
||||
'# config=late-linked-worktree-config',
|
||||
)
|
||||
expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBefore)
|
||||
})
|
||||
|
||||
it('serializes concurrent installs and keeps repeated output stable', async () => {
|
||||
const fixture = createFixture()
|
||||
const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' }
|
||||
@@ -509,6 +533,30 @@ describe('worktree-local Lefthook installer', () => {
|
||||
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks')
|
||||
})
|
||||
|
||||
it('does not trust an ownership marker outside a registered worktree hook path', async () => {
|
||||
const fixture = createFixture()
|
||||
const mainInstall = await runInstaller(fixture, fixture.main)
|
||||
expect(mainInstall.status, mainInstall.stderr).toBe(0)
|
||||
const externalHooks = join(fixture.container, 'external-owned-hooks')
|
||||
write(
|
||||
join(externalHooks, '.dsh-lefthook-owned'),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
owner: 'deepseek-harness worktree-local lefthook hooks',
|
||||
hooksPath: externalHooks,
|
||||
})}\n`,
|
||||
0o600,
|
||||
)
|
||||
git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', externalHooks])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.linked)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('worktree-scoped core.hooksPath')
|
||||
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(externalHooks)
|
||||
expect(existsSync(hooksPath(fixture, fixture.linked))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to activate a sibling worktree dormant hook path', async () => {
|
||||
const fixture = createFixture()
|
||||
const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree')
|
||||
|
||||
@@ -287,6 +287,11 @@ function nodeCompatSmokeGates(): Gate[] {
|
||||
'run',
|
||||
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
||||
], { label: 'JSONL Zstandard smoke' }),
|
||||
pnpmExec('dsh-source-launch-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'apps/cli/tests/source-launch.compat.spec.ts',
|
||||
], { label: 'dsh source-launch smoke' }),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,50 @@
|
||||
/** Git-blob operations owned by the bilingual pairing workflow. */
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const SNAPSHOT_REF_PREFIX = 'refs/dsh/translation-pairing/snapshots'
|
||||
|
||||
/** Full SHA-1 Git blob hash (the 40-hex format used by pairing records). */
|
||||
export function gitBlobHash(content: Buffer): string {
|
||||
const hash = createHash('sha1')
|
||||
hash.update(`blob ${content.byteLength}\0`)
|
||||
hash.update(content)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
|
||||
const result = spawnSync('git', ['-C', root, ...args], {
|
||||
input,
|
||||
maxBuffer: 1 << 26,
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(`${operation} failed: ${result.error.message}`, { cause: result.error })
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${operation} failed with status ${String(result.status)}: ${result.stderr.toString('utf8').trim()}`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist exact working-tree bytes so a pairing record can later recover them
|
||||
* with `git cat-file`, even when they have never appeared in the index or a
|
||||
* commit. The returned object ID is checked against the pairing format's own
|
||||
* content hash before the caller writes a sidecar.
|
||||
*/
|
||||
export function storeGitBlob(root: string, content: Buffer): string {
|
||||
const expected = gitBlobHash(content)
|
||||
const stored = runGit(root, ['hash-object', '-w', '--stdin'], 'git hash-object -w --stdin', content)
|
||||
.toString('utf8')
|
||||
.trim()
|
||||
if (stored !== expected) {
|
||||
throw new Error(`git hash-object -w --stdin returned unexpected object ID ${JSON.stringify(stored)}; expected ${expected}`)
|
||||
}
|
||||
runGit(
|
||||
root,
|
||||
['update-ref', `${SNAPSHOT_REF_PREFIX}/${stored}`, stored],
|
||||
'git update-ref for translation snapshot',
|
||||
)
|
||||
return stored
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
/** Regression tests for the bilingual corpus scope and structural signature. */
|
||||
/** Regression tests for bilingual snapshots, corpus scope, and structure. */
|
||||
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
|
||||
import {
|
||||
isTranslationScopeFile,
|
||||
pairAnchorOfArgument,
|
||||
@@ -15,6 +20,71 @@ function signature(markdown: string) {
|
||||
return translationStructureSignature(parseTranslationMarkdown(markdown), 'counterpart.zh.md')
|
||||
}
|
||||
|
||||
function gitSupportsObjectFormat(format: 'sha256'): boolean {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-git-object-format-'))
|
||||
try {
|
||||
return spawnSync('git', ['init', '--quiet', `--object-format=${format}`, root], {
|
||||
stdio: 'ignore',
|
||||
}).status === 0
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const supportsSha256ObjectFormat = gitSupportsObjectFormat('sha256')
|
||||
|
||||
describe('translation pairing snapshots', () => {
|
||||
it('stores exact uncommitted bytes for later recovery by object ID', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
|
||||
try {
|
||||
execFileSync('git', ['init', '--quiet', root], {
|
||||
env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' },
|
||||
})
|
||||
const content = Buffer.from([0x75, 0x6e, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x0a, 0xff])
|
||||
|
||||
const objectId = storeGitBlob(root, content)
|
||||
|
||||
expect(objectId).toBe(gitBlobHash(content))
|
||||
expect(execFileSync('git', [
|
||||
'-C', root, 'rev-parse', `refs/dsh/translation-pairing/snapshots/${objectId}`,
|
||||
], { encoding: 'utf8' }).trim()).toBe(objectId)
|
||||
execFileSync('git', ['-C', root, 'gc', '--prune=now'])
|
||||
expect(execFileSync('git', ['-C', root, 'cat-file', '-p', objectId])).toEqual(content)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails before a sidecar can reference an unavailable object', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
|
||||
try {
|
||||
expect(() => storeGitBlob(root, Buffer.from('snapshot'))).toThrow('git hash-object -w --stdin failed')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails clearly when Git cannot be started', () => {
|
||||
const previousPath = process.env.PATH
|
||||
try {
|
||||
process.env.PATH = ''
|
||||
expect(() => storeGitBlob('.', Buffer.from('snapshot'))).toThrow('git hash-object -w --stdin failed')
|
||||
} finally {
|
||||
process.env.PATH = previousPath
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(!supportsSha256ObjectFormat)('rejects an object format that pairing records cannot represent', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
|
||||
try {
|
||||
execFileSync('git', ['init', '--quiet', '--object-format=sha256', root])
|
||||
expect(() => storeGitBlob(root, Buffer.from('snapshot'))).toThrow('returned unexpected object ID')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('translation pairing manifest', () => {
|
||||
it('accepts an exclusions-only manifest', () => {
|
||||
expect(parseTranslationPairingManifest(JSON.stringify({
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/** Register source-only tsconfig paths resolution before a TypeScript entry loads. */
|
||||
|
||||
import { register } from 'node:module'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const tsconfigPath = process.env.TSX_TSCONFIG_PATH === undefined
|
||||
? fileURLToPath(new URL('../tsconfig.json', import.meta.url))
|
||||
: resolve(process.env.TSX_TSCONFIG_PATH)
|
||||
|
||||
register(new URL('../apps/cli/src/tsconfig-paths-loader.ts', import.meta.url), {
|
||||
parentURL: import.meta.url,
|
||||
data: { tsconfigPath },
|
||||
})
|
||||
@@ -60,6 +60,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' },
|
||||
'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' },
|
||||
'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' },
|
||||
'packages/client/ui-permission': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' },
|
||||
'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' },
|
||||
'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
|
||||
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
* See `docs/i18n/README.md` for the owning contract.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, join, resolve, sep } from 'node:path'
|
||||
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
|
||||
import {
|
||||
linksTo,
|
||||
parseTranslationMarkdown,
|
||||
@@ -54,14 +54,6 @@ function isExcluded(file: string): boolean {
|
||||
return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
|
||||
}
|
||||
|
||||
/** Full git blob hash (what `git hash-object` prints). */
|
||||
function blobHash(content: Buffer): string {
|
||||
const hash = createHash('sha1')
|
||||
hash.update(`blob ${content.byteLength}\0`)
|
||||
hash.update(content)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
/** The three paths of a pair, derived from the English-file path. */
|
||||
function pairPaths(source: string): { zh: string; meta: string } {
|
||||
return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') }
|
||||
@@ -148,7 +140,12 @@ if (writeMode) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh))))
|
||||
const sourceContent = readFileSync(join(root, source))
|
||||
const zhContent = readFileSync(join(root, zh))
|
||||
// A consistency record is also a recovery pointer for the briefing
|
||||
// generator. Persist both snapshots even when the sidecar text is already
|
||||
// current, because the bytes may exist only in this working tree.
|
||||
const record = renderMeta(source, storeGitBlob(root, sourceContent), zh, storeGitBlob(root, zhContent))
|
||||
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
|
||||
writeFileSync(join(root, meta), record)
|
||||
console.log(`verify-translation-pairing: recorded ${meta}`)
|
||||
@@ -203,7 +200,7 @@ for (const source of [...pairAnchors].sort()) {
|
||||
|
||||
let consistent = true
|
||||
for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) {
|
||||
const current = blobHash(content)
|
||||
const current = gitBlobHash(content)
|
||||
if (record.get(basename(file)) !== current) {
|
||||
errors.push(`${file}: out of sync — content no longer matches the pair's last confirmed-consistent state in ${meta} (bring the other side along, then re-record with --write)`)
|
||||
consistent = false
|
||||
|
||||
Reference in New Issue
Block a user