Merge PR #500 into CI optimization

This commit is contained in:
Tianyi Cui
2026-07-22 18:31:28 +08:00
382 changed files with 33688 additions and 419 deletions

View File

@@ -40,10 +40,12 @@ interface PackageManifest {
bin?: string | Record<string, string>
exports?: Record<
string,
| string
| {
types?: string
default?: string
}
| null
| undefined
>
files?: string[]
@@ -115,13 +117,41 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
'lib/invariant.js',
...manifest.bin ? ['lib/bin.js'] : [],
...manifest.exports?.['./worker'] ? ['lib/worker.cjs'] : [],
// UI plugin packages ship their browser bundle beside the node lib
// (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
// Keyed on the artifact path, not the subpath name: apiproxy's ./client is
// a browser-safe source channel, not a bundle.
...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [],
// runtime's shell-held loader subpath ships as its own bundle beside the client half.
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
// web-react's store subpath ships its own bundle (single-entry builds; no shared chunk).
...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [],
...extras,
// Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js —
// browser-safe source channels rehomed off src so plain Node can import
// them without type stripping) publish the emitted JS alongside the
// declarations.
...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [],
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
]
}
/** Runtime target of an export entry: conditional `default`, or the bare-string shorthand. */
function exportDefault(manifest: PackageManifest, subpath: string): string | undefined {
const entry = manifest.exports?.[subpath]
if (typeof entry === 'string') return entry
if (typeof entry === 'object' && entry !== null) return entry.default
return undefined
}
/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */
function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
return Object.keys(manifest.exports ?? {}).some(subpath =>
exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true)
}
function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
const errors: string[] = []
const label = manifest.name ?? dir
@@ -155,13 +185,16 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
if (manifest.types !== 'lib/types/index.d.ts') {
errors.push(`${label}: package.json must set "types": "lib/types/index.d.ts"`)
}
if (manifest.exports?.['.']?.types !== './lib/types/index.d.ts') {
const rootExport = manifest.exports?.['.']
const rootEntry = typeof rootExport === 'object' && rootExport !== null ? rootExport : undefined
if (rootEntry?.types !== './lib/types/index.d.ts') {
errors.push(`${label}: package.json exports["."].types must be "./lib/types/index.d.ts"`)
}
if (manifest.exports?.['.']?.default !== './lib/index.js') {
if (rootEntry?.default !== './lib/index.js') {
errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`)
}
const invariantExport = manifest.exports?.['./invariant']
const invariantRaw = manifest.exports?.['./invariant']
const invariantExport = typeof invariantRaw === 'object' && invariantRaw !== null ? invariantRaw : undefined
if (invariantExport?.types !== undefined && invariantExport.types !== './lib/types/invariant.d.ts') {
errors.push(`${label}: package.json exports["./invariant"].types must be "./lib/types/invariant.d.ts"`)
}

View File

@@ -0,0 +1,60 @@
/**
* Pins the client-bundle purity gate (tsdown preset resolveId classifier):
* a bare-name import of a module-table package must rewrite to its /client
* external form (inlining it duplicates runtime identity — the P0
/* leak that is not an
* inline-safe wire layer must fail the build loudly.
*/
import { describe, expect, it } from 'vitest'
import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts'
type ResolveId = (source: string) => null | { id: string; external: boolean }
function purityResolveId(): ResolveId {
// libEntry is spelled at every call site (no default) so the
// package-invariants text check can see the invariant entry per package.
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
const plugins = (configs[1] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
return gate.resolveId as ResolveId
}
describe('client bundle purity gate', () => {
const resolveId = purityResolveId()
it('leaves table entries and non-scoped specifiers alone', () => {
expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
expect(resolveId('react')).toBeNull()
expect(resolveId('zod')).toBeNull()
})
it('rewrites a bare table-package name to its external /client form (duplicate-instance prevention)', () => {
expect(resolveId('@deepseek-ai/dsh-client-connection')).toEqual({
id: '@deepseek-ai/dsh-client-connection/client',
external: true,
})
expect(resolveId('@deepseek-ai/dsh-client-ui-layout')).toEqual({
id: '@deepseek-ai/dsh-client-ui-layout/client',
external: true,
})
})
it('lets inline-safe wire layers inline', () => {
expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
})
it('throws on any other @deepseek-ai leak', () => {
expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
})
it('every /client external has no bare-name twin in the table (the rewrite assumption)', () => {
for (const entry of CLIENT_EXTERNALS) {
if (entry.endsWith('/client')) expect(CLIENT_EXTERNALS).not.toContain(entry.slice(0, -'/client'.length))
}
})
})

View File

@@ -1,11 +1,22 @@
/** Map one workspace source alias target to its declaration-build target. */
export function builtDeclarationPath(candidate: string): string {
// Two workspace shapes exist: whole-package entries end in /src, subpath
// wildcards (apiproxy's browser-safe /api and /client channels) in /src/*.
if (candidate.endsWith('/src')) {
return `${candidate.slice(0, -'/src'.length)}/lib/types`
}
if (candidate.endsWith('/src/*')) {
return `${candidate.slice(0, -'/src/*'.length)}/lib/types/*`
}
const sourceFile = /^(.*)\/src\/(.+)\.ts$/.exec(candidate)
if (sourceFile?.[1] && sourceFile[2]) {
return `${sourceFile[1]}/lib/types/${sourceFile[2]}.d.ts`
}
// Directory subpath entries (web-react's /store, runtime's /client): the
// source dir maps to the same dir under lib/types (index resolution applies).
const sourceDir = /^(.*)\/src\/(.+)$/.exec(candidate)
if (sourceDir?.[1] && sourceDir[2]) {
return `${sourceDir[1]}/lib/types/${sourceDir[2]}`
}
throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`)
}

View File

@@ -177,6 +177,9 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts',
Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts',
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',

View File

@@ -20,6 +20,8 @@ type Mode =
| 'ci-windows-complete'
| 'ci-windows-observational'
| 'node-compat'
| 'pre-push'
| 'manual-push'
| 'doc-sync'
type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
@@ -95,20 +97,22 @@ function parseMode(raw: string | undefined): Mode {
case 'ci-windows-complete':
case 'ci-windows-observational':
case 'node-compat':
case 'pre-push':
case 'manual-push':
case 'doc-sync':
return raw
default:
throw new Error(
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | doc-sync, got ${JSON.stringify(raw)}.`,
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | manual-push | doc-sync, got ${JSON.stringify(raw)}.`,
)
}
}
function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault {
const available = availableParallelism()
// The local doc mode caps workers: several gates each build a full ts.Program,
// Local modes cap workers: several doc gates each build a full ts.Program,
// so an uncapped default on a large host trades wall clock for memory blowups.
const localCap = selectedMode === 'doc-sync'
const localCap = selectedMode === 'pre-push' || selectedMode === 'doc-sync'
const modeLimit = localCap ? Math.min(4, available) : available
return {
workers: Math.min(total, modeLimit),
@@ -186,6 +190,24 @@ function gatesForMode(selected: Mode): Gate[] {
return ciWindowsObservationalGates()
case 'node-compat':
return nodeCompatGates()
case 'pre-push': return []
case 'manual-push':
return [
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
pnpmScript('test', 'test'),
pnpmScript('duplication', 'duplication'),
snapshotGate(),
pnpmScript('build', 'build'),
pnpmScript('build:web', 'build:web'),
...hygieneLeafGates({ artifactNeeds: ['build'] }),
...docSyncLeafGates({
docTypecheckNeeds: ['build'],
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
}),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
]
case 'doc-sync':
return docSyncLeafGates()
}
@@ -361,8 +383,8 @@ function coverageGate(): Gate {
}
// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node,
// plugins via real exports). CI pairs it with `build`, so it exercises what ships rather than
// the tsx/source path dev uses and therefore waits on `build`.
// plugins via real exports) CI and pre-push already build, so they exercise what ships rather
// than the tsx/source path dev uses. It therefore waits on `build`.
function snapshotGate(): Gate {
return pnpmScript('snapshot', 'test:snapshot', {
env: { DSH_EXAMPLE_MODE: 'lib' },
@@ -394,6 +416,21 @@ function flagEnabled(envName: string): boolean {
return true
}
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
return [
pnpmScript('knip', 'knip'),
pnpmScript('publint', 'publint', artifactOptions),
pnpmScript('constraints', 'constraints'),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
builtPackageInvariantsGate(options.artifactNeeds),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
...artifactOptions,
}),
]
}
function docSyncLeafGates(options: {
docTypecheckNeeds?: string[]
docTypecheckEnv?: Record<string, string | undefined>

View File

@@ -1,6 +1,13 @@
{
"requiredSince": "2026-07-14",
"required": [
".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
".agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md",
".agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md",
".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md",
".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md",
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
".agents/notes/implemented/process/2026-07-19-web-styling-system.md",
"README.md",
"docs/cookbook/adding-a-package.md",
"docs/cookbook/adding-a-tool.md",
@@ -23,9 +30,6 @@
"docs/user/guide/index.md",
"docs/user/guide/quickstart.md",
"docs/user/index.md",
".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
".agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md",
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
"python/README.md",
"python/sdk-runtime/README.md",
"python/sdk/README.md"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
/**
* Enforce intra-package domain layering inside `packages/client/*\/src/client/`.
* verify-module-graph covers package-level edges; this gate covers the
* directory level the future package split will land on: domain directories
* may import `contract/` and never each other, and only the assembly point
* (`apply.ts` / `index.ts`) may import across domains.
*
* Layer model (lower may not import higher):
* 0 contract/ shared contract surface (types + slot declarations)
* 1 <domain>/ + service domain implementations (skeleton/, chat/, ...)
* 2 apply.ts, index.ts assembly point and re-export shell
*
* Not yet wired into the gate sequence (loose-gate window); run directly:
* pnpm exec tsx scripts/verify-client-domain-graph.ts
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const CLIENT_DIR = join(root, 'packages/client')
/** Directory names treated as the shared contract layer (importable by all). */
const CONTRACT_DIRS = new Set(['contract'])
/** Top-level client files allowed to import across domains (assembly layer). */
const ASSEMBLY_FILES = new Set(['apply.ts', 'index.ts', 'index.tsx'])
interface Violation { file: string; imported: string; reason: string }
/** Recursively list .ts/.tsx files under dir (relative paths). */
function listSources(dir: string, prefix = ''): string[] {
const out: string[] = []
for (const name of readdirSync(dir)) {
const full = join(dir, name)
const rel = prefix ? `${prefix}/${name}` : name
if (statSync(full).isDirectory()) out.push(...listSources(full, rel))
else if (/\.tsx?$/.test(name) && !/\.legacy\./.test(name)) out.push(rel)
}
return out
}
/** First path segment of a client-relative file, or '' for top-level files. */
function domainOf(rel: string): string {
const ix = rel.indexOf('/')
return ix === -1 ? '' : rel.slice(0, ix)
}
function checkPackage(pkgName: string, clientDir: string): Violation[] {
const violations: Violation[] = []
const files = listSources(clientDir)
for (const rel of files) {
const fromDomain = domainOf(rel)
const isAssembly = fromDomain === '' && ASSEMBLY_FILES.has(rel)
if (isAssembly) continue
const source = readFileSync(join(clientDir, rel), 'utf8')
for (const match of source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
const spec = match[1]
if (spec === undefined) continue
// Resolve the relative specifier against the importing file's directory
// to a client-dir-relative path.
const fromDir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
const parts = (fromDir ? fromDir.split('/') : [])
for (const seg of spec.split('/')) {
if (seg === '.') continue
if (seg === '..') parts.pop()
else parts.push(seg)
}
const target = parts.join('/')
if (target.startsWith('..')) continue // out of client dir (package root) — package-level rules govern
const toDomain = domainOf(target)
if (toDomain === '' || CONTRACT_DIRS.has(toDomain)) continue // top-level shared file or contract layer
if (fromDomain === toDomain) continue // inside one domain
violations.push({
file: `${pkgName}/src/client/${rel}`,
imported: spec,
reason: fromDomain === ''
? `top-level non-assembly file imports domain "${toDomain}" (only apply/index may assemble)`
: `domain "${fromDomain}" imports sibling domain "${toDomain}" (route shared surface through contract/)`,
})
}
}
return violations
}
const violations: Violation[] = []
for (const pkg of readdirSync(CLIENT_DIR)) {
const clientDir = join(CLIENT_DIR, pkg, 'src/client')
try {
if (!statSync(clientDir).isDirectory()) continue
} catch {
// No client half in this package — nothing to layer-check.
continue
}
violations.push(...checkPackage(pkg, clientDir))
}
if (violations.length > 0) {
console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
process.exit(1)
}
console.log('verify-client-domain-graph: client domain layering clean.')

View File

@@ -152,15 +152,29 @@ function localPackageDirectories(): Map<string, string> {
}
function rootProjectReferences(): Set<string> {
const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), path => ts.sys.readFile(path))
if (config.error !== undefined) {
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
// Typecheck runs two sibling aggregates (root = host program,
// tsconfig.client.json = client program; the two sides merge cordis Context
// under the same keys, so one program cannot see both). Seed both and follow
// any nested aggregate references to collect the covered leaf project set.
const collected = new Set<string>()
const queue = [resolve(root, 'tsconfig.json'), resolve(root, 'tsconfig.client.json')]
const seen = new Set<string>()
for (let file = queue.pop(); file !== undefined; file = queue.pop()) {
if (seen.has(file)) continue
seen.add(file)
const config = ts.readConfigFile(file, path => ts.sys.readFile(path))
if (config.error !== undefined) {
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
}
const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
for (const reference of references) {
if (typeof reference.path !== 'string') continue
const target = resolve(dirname(file), reference.path)
if (target.endsWith('.json')) queue.push(target)
else collected.add(target)
}
}
const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
return new Set(references.flatMap((reference) => {
if (typeof reference.path !== 'string') return []
return [resolve(root, reference.path)]
}))
return collected
}
function packageNameFromSpecifier(specifier: string): string | undefined {

View File

@@ -45,11 +45,26 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' },
'packages/host/runtime': { kind: 'indirect', reason: 'The assembly mounts model-facing plugins and injects provider/model defaults into agents.' },
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },