Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md # THIRD_PARTY_NOTICES.md # apps/cli/composition.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/bin.ts # apps/cli/tests/args.spec.ts # apps/web/tests/built-boot.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/bundle/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/http-bridge.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/fixture.spec.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/locales.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/components/content.ts # packages/ui/tui/src/components/transcript.ts # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
This commit is contained in:
BIN
scripts/attribute-chunk-bytes.mjs
Normal file
BIN
scripts/attribute-chunk-bytes.mjs
Normal file
Binary file not shown.
@@ -254,10 +254,6 @@ class SingleExeBuild {
|
||||
'--config.node-linker=hoisted',
|
||||
'--config.auto-install-peers=false',
|
||||
'--config.link-workspace-packages=true',
|
||||
// The production closure intentionally omits the patched dev-only
|
||||
// @earendil-works/pi-tui package. The root frozen install still validates
|
||||
// every patch; this exception is scoped only to the production deploy.
|
||||
'--config.allow-unused-patches=true',
|
||||
this.staging,
|
||||
])
|
||||
if (this.cli.dryRun) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { hasTypeRTRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
// vendor/* is single-level; packages/<group>/<pkg> nests one level deeper
|
||||
@@ -14,6 +15,7 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const workspaceGlobs = [
|
||||
{ dir: 'vendor', depth: 1 },
|
||||
{ dir: 'packages', depth: 2 },
|
||||
{ dir: 'apps', depth: 1 },
|
||||
] as const
|
||||
const vendoredPackages = new Set([
|
||||
'cordis',
|
||||
@@ -28,6 +30,10 @@ const vendoredPackages = new Set([
|
||||
])
|
||||
|
||||
const localArtifactDirs = new Set(['node_modules'])
|
||||
const appPackageFiles: Readonly<Record<string, readonly string[]>> = {
|
||||
'@deepseek-ai/dsh': ['lib/*.js', 'config'],
|
||||
'@deepseek-ai/dsh-frontend': ['dist'],
|
||||
}
|
||||
|
||||
/** The subset of package.json fields this constraint check cares about. */
|
||||
interface PackageManifest {
|
||||
@@ -96,8 +102,13 @@ function workspaceManifests(): WorkspaceManifest[] {
|
||||
}
|
||||
|
||||
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
// Profile bundles publish their dsh.bundle.patch layer beside the lib.
|
||||
'@deepseek-ai/dsh-base': ['cordis.patch.yml'],
|
||||
'@deepseek-ai/dsh-web-app': ['cordis.patch.yml'],
|
||||
'@deepseek-ai/dsh-headless': ['cordis.patch.yml'],
|
||||
'@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
|
||||
'@deepseek-ai/dsh-helper': ['lib/assets'],
|
||||
'@deepseek-ai/dsh-tui': ['lib/prompt.js'],
|
||||
'@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'],
|
||||
'@deepseek-ai/dsh-scripts': [
|
||||
'lib/dev/tsdown-config.js',
|
||||
'lib/local-plugin-loader-hooks.js',
|
||||
@@ -111,6 +122,7 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl
|
||||
|
||||
function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : []
|
||||
const typeRTRemoteNavigation = hasTypeRTRemoteNavigation(manifest)
|
||||
return [
|
||||
'lib/index.js',
|
||||
// Every package publishes its invariant ownership companion as a separate
|
||||
@@ -134,11 +146,37 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
// declarations.
|
||||
...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [],
|
||||
'lib/types/**/*.d.ts',
|
||||
'lib/types/**/*.d.ts.map',
|
||||
'src',
|
||||
...hasExportPair(manifest, './typert', './lib/typert.host.d.ts', './lib/typert.host.js')
|
||||
? ['lib/typert.host.js', 'lib/typert.host.d.ts']
|
||||
: [],
|
||||
...hasExportPair(manifest, './client/typert', './lib/typert.client.d.ts', './lib/typert.client.js')
|
||||
? ['lib/typert.client.js', 'lib/typert.client.d.ts']
|
||||
: [],
|
||||
...typeRTRemoteNavigation
|
||||
? [
|
||||
'lib/typert.remote-client.js',
|
||||
'lib/typert.remote-client.d.ts',
|
||||
'lib/typert.remote-client.d.ts.map',
|
||||
'src',
|
||||
]
|
||||
: [],
|
||||
]
|
||||
}
|
||||
|
||||
/** Whether one conditional export exactly names the generated runtime and declaration pair. */
|
||||
function hasExportPair(
|
||||
manifest: PackageManifest,
|
||||
subpath: string,
|
||||
types: string,
|
||||
runtime: string,
|
||||
): boolean {
|
||||
const entry = manifest.exports?.[subpath]
|
||||
return typeof entry === 'object'
|
||||
&& entry !== null
|
||||
&& entry.types === types
|
||||
&& entry.default === runtime
|
||||
}
|
||||
|
||||
/** 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]
|
||||
@@ -165,7 +203,25 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
return errors
|
||||
}
|
||||
|
||||
if (manifest.name?.startsWith('@deepseek-ai/dsh-') && manifest.name !== '@deepseek-ai/dsh-root') {
|
||||
if (manifest.name?.startsWith('@deepseek-ai/')) {
|
||||
const publicationPolicy = { typeRTRemoteNavigation: hasTypeRTRemoteNavigation(manifest) }
|
||||
for (const file of manifest.files ?? []) {
|
||||
if (isForbiddenPublicationFile(file, publicationPolicy)) {
|
||||
errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dir.startsWith('apps/') && manifest.name?.startsWith('@deepseek-ai/')) {
|
||||
const expectedFiles = appPackageFiles[manifest.name]
|
||||
if (expectedFiles === undefined) {
|
||||
errors.push(`${label}: app package has no publication files policy`)
|
||||
} else if (!sameStringList(manifest.files, expectedFiles)) {
|
||||
errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (dir.startsWith('packages/') && manifest.name?.startsWith('@deepseek-ai/dsh-')) {
|
||||
const peer = manifest.peerDependencies?.cordis
|
||||
const dev = manifest.devDependencies?.cordis
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ interface CssModulePlugin {
|
||||
load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
function clientSourceMapPath(packagePath: string): string {
|
||||
return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url))
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -55,6 +59,13 @@ describe('client bundle purity gate', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
|
||||
expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-goal/client')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-goal/remote/nested')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
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/)
|
||||
@@ -74,6 +85,54 @@ describe('client bundle purity gate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('client bundle debug artifacts', () => {
|
||||
it('emits source maps for plugin TS and TSX outside the Vite module graph', () => {
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
expect(configs[1]?.sourcemap).toBe(true)
|
||||
})
|
||||
|
||||
it('maps first-party sources to their repository package paths', () => {
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
const outputOptions = configs[1]?.outputOptions
|
||||
if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
|
||||
const transform = outputOptions.sourcemapPathTransform
|
||||
if (transform === undefined) throw new Error('client sourcemap path transform missing')
|
||||
|
||||
const source = transform('../src/client/GoalBar.tsx', clientSourceMapPath('client/ui-goal'))
|
||||
expect(source).toBe('../../../packages/client/ui-goal/src/client/GoalBar.tsx')
|
||||
const resolved = new URL(source, 'https://dsh.test/plugins/@deepseek-ai/dsh-client-ui-goal/client.js.map')
|
||||
expect(resolved.pathname).toBe('/packages/client/ui-goal/src/client/GoalBar.tsx')
|
||||
})
|
||||
|
||||
it('maps dual-face host sources to the host package group', () => {
|
||||
const configs = clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js'])
|
||||
const outputOptions = configs[1]?.outputOptions
|
||||
if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
|
||||
const transform = outputOptions.sourcemapPathTransform
|
||||
if (transform === undefined) throw new Error('client sourcemap path transform missing')
|
||||
|
||||
const source = transform('../src/client/index.ts', clientSourceMapPath('host/directory-picker-native'))
|
||||
expect(source).toBe('../../../packages/host/directory-picker-native/src/client/index.ts')
|
||||
})
|
||||
|
||||
it('maps inlined workspace sources to packages and leaves dependencies outside it unchanged', () => {
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js'])
|
||||
const outputOptions = configs[1]?.outputOptions
|
||||
if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing')
|
||||
const transform = outputOptions.sourcemapPathTransform
|
||||
if (transform === undefined) throw new Error('client sourcemap path transform missing')
|
||||
|
||||
const sourceMapPath = clientSourceMapPath('client/connection')
|
||||
const workspaceSource = transform('../../../host/apiproxy/src/api/rpc.ts', sourceMapPath)
|
||||
expect(workspaceSource).toBe('../../../packages/host/apiproxy/src/api/rpc.ts')
|
||||
const resolved = new URL(workspaceSource, 'https://dsh.test/plugins/@deepseek-ai/dsh-client-connection/client.js.map')
|
||||
expect(resolved.pathname).toBe('/packages/host/apiproxy/src/api/rpc.ts')
|
||||
|
||||
const dependencySource = '../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/index.js'
|
||||
expect(transform(dependencySource, sourceMapPath)).toBe(dependencySource)
|
||||
})
|
||||
})
|
||||
|
||||
describe('client bundle CSS Modules watch graph', () => {
|
||||
it('registers the physical stylesheet read behind a virtual module', async () => {
|
||||
const plugin = cssModulePlugin()
|
||||
|
||||
108
scripts/coverage-uncovered-locations.cjs
Normal file
108
scripts/coverage-uncovered-locations.cjs
Normal file
@@ -0,0 +1,108 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Istanbul coverage reporter printing one clickable `path:line:col` record per
|
||||
* uncovered statement, branch path, and function. Vitest's per-file threshold
|
||||
* failures name only the file; this reporter supplies the exact locations,
|
||||
* printed just above those ERROR lines (reports run before threshold checks).
|
||||
* Files at 100% print nothing, so a green run stays silent.
|
||||
*
|
||||
* CommonJS by requirement: istanbul-reports loads custom reporters with a bare
|
||||
* require() outside the tsx/ESM pipeline (istanbul-reports index.js create()),
|
||||
* so this file can be neither TypeScript nor ESM. Wired into vitest.config.ts
|
||||
* by absolute path — require() would resolve a relative specifier against
|
||||
* istanbul-reports' own directory.
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
const { ReportBase } = require('istanbul-lib-report');
|
||||
|
||||
/**
|
||||
* Editor-convention `line:column` of an istanbul location start (istanbul
|
||||
* columns are 0-based; editors and terminal link handlers expect 1-based).
|
||||
*/
|
||||
function pos(loc) {
|
||||
return `${loc.start.line}:${loc.start.column + 1}`;
|
||||
}
|
||||
|
||||
/** Whether a location carries a usable 1-based start line. */
|
||||
function usable(loc) {
|
||||
return Boolean(loc && loc.start && Number.isFinite(loc.start.line) && loc.start.line >= 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* ` (to line:col)` suffix when the range end adds information beyond the
|
||||
* start. v8-remapped whole-line statements carry end.column = Infinity; those
|
||||
* degrade to a line-only suffix, or to nothing on a single line.
|
||||
*/
|
||||
function endSuffix(loc) {
|
||||
const end = loc.end;
|
||||
if (!end || !Number.isFinite(end.line) || end.line < 1) return '';
|
||||
if (!Number.isFinite(end.column)) {
|
||||
return end.line === loc.start.line ? '' : ` (to ${end.line})`;
|
||||
}
|
||||
if (end.line === loc.start.line && end.column === loc.start.column) return '';
|
||||
return ` (to ${end.line}:${end.column + 1})`;
|
||||
}
|
||||
|
||||
class UncoveredLocationsReport extends ReportBase {
|
||||
constructor(opts = {}) {
|
||||
super(opts);
|
||||
// Vitest passes the resolved config root alongside reporter options.
|
||||
this.projectRoot = opts.projectRoot || process.cwd();
|
||||
this.records = [];
|
||||
}
|
||||
|
||||
onStart() {
|
||||
this.records = [];
|
||||
}
|
||||
|
||||
onDetail(node) {
|
||||
const fc = node.getFileCoverage();
|
||||
const rel = path.relative(this.projectRoot, fc.path).split(path.sep).join('/');
|
||||
const items = [];
|
||||
const add = (loc, text) => items.push({ line: loc.start.line, column: loc.start.column, text });
|
||||
|
||||
for (const id of Object.keys(fc.statementMap)) {
|
||||
if (fc.s[id] !== 0) continue;
|
||||
const loc = fc.statementMap[id];
|
||||
if (!usable(loc)) continue;
|
||||
add(loc, `${rel}:${pos(loc)} uncovered statement${endSuffix(loc)}`);
|
||||
}
|
||||
|
||||
for (const id of Object.keys(fc.fnMap)) {
|
||||
if (fc.f[id] !== 0) continue;
|
||||
const fn = fc.fnMap[id];
|
||||
const loc = usable(fn.decl) ? fn.decl : fn.loc;
|
||||
if (!usable(loc)) continue;
|
||||
const name = fn.name ? ` ${fn.name}` : '';
|
||||
add(loc, `${rel}:${pos(loc)} uncovered function${name}`);
|
||||
}
|
||||
|
||||
for (const id of Object.keys(fc.branchMap)) {
|
||||
const counts = fc.b[id];
|
||||
const branch = fc.branchMap[id];
|
||||
for (let i = 0; i < counts.length; i += 1) {
|
||||
if (counts[i] !== 0) continue;
|
||||
// Implicit arms (e.g. a missing else) may carry an empty location;
|
||||
// fall back to the branch's own span so the record stays clickable.
|
||||
const loc = usable(branch.locations && branch.locations[i]) ? branch.locations[i] : branch.loc;
|
||||
if (!usable(loc)) continue;
|
||||
add(loc, `${rel}:${pos(loc)} uncovered branch (${branch.type}, path ${i + 1}/${counts.length})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) return;
|
||||
items.sort((a, b) => a.line - b.line || a.column - b.column);
|
||||
for (const item of items) this.records.push(item.text);
|
||||
}
|
||||
|
||||
onEnd() {
|
||||
if (this.records.length === 0) return;
|
||||
console.log(`\nUncovered locations (per-file 100% gate): ${this.records.length}`);
|
||||
for (const record of this.records) console.log(record);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UncoveredLocationsReport;
|
||||
@@ -6,7 +6,7 @@ import { spawn } from 'node:child_process'
|
||||
const SURFACES = new Map([
|
||||
// The browser surface with the cordis toolset layered on: `dsh web --config`
|
||||
// applies this overlay over the shipped web composition; it owns port 3081.
|
||||
['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'web', '--config', 'examples/web-cordis/cordis.yml']],
|
||||
['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'web', '--patch', 'examples/web-cordis/cordis.yml']],
|
||||
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/cordis-tools.cordis.yml']],
|
||||
])
|
||||
|
||||
|
||||
36
scripts/dev-web.spec.ts
Normal file
36
scripts/dev-web.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { TsdownBundle } from 'tsdown'
|
||||
import { watchClientPlugins } from './dev-web.ts'
|
||||
|
||||
it('rebuilds a client-plugin bundle after its source changes', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-'))
|
||||
let bundles: TsdownBundle[] = []
|
||||
try {
|
||||
await symlink(join(import.meta.dirname, '..', 'node_modules'), join(root, 'node_modules'), 'dir')
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({ name: '@dsh-test/dev-web-watch', private: true, type: 'module' }))
|
||||
await writeFile(join(root, 'tsdown.config.ts'), `
|
||||
import { defineConfig } from 'tsdown'
|
||||
export default defineConfig({
|
||||
entry: { client: 'src.ts' }, outDir: 'lib', format: 'cjs', platform: 'browser', dts: false, clean: false,
|
||||
outputOptions: { entryFileNames: 'client.js' },
|
||||
})
|
||||
`)
|
||||
const sourcePath = join(root, 'src.ts')
|
||||
const bundlePath = join(root, 'lib/client.js')
|
||||
await writeFile(sourcePath, 'export const version = "watch-v1"\n')
|
||||
bundles = await watchClientPlugins(root, ['.'], 50)
|
||||
expect(await readFile(bundlePath, 'utf8')).toContain('watch-v1')
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1_000))
|
||||
await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`)
|
||||
await expect.poll(async () => (await readFile(bundlePath, 'utf8')).includes('watch-v2-'), {
|
||||
timeout: 10_000,
|
||||
}).toBe(true)
|
||||
} finally {
|
||||
for (const bundle of bundles) await bundle[Symbol.asyncDispose]()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 20_000)
|
||||
@@ -18,9 +18,10 @@
|
||||
* keys under each package's file config, and no package config defines it).
|
||||
*/
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'tsdown'
|
||||
import type { TsdownBundle } from 'tsdown'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
@@ -29,46 +30,83 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
* whose package.json carries `dshClient` with platform "web" is a client
|
||||
* plugin bundle emitter. Scanned once at startup — a package added while
|
||||
* watching means restarting this script.
|
||||
* @param root - repository root containing the grouped package directories.
|
||||
* @returns workspace-relative plugin package directories.
|
||||
*/
|
||||
function discoverPluginDirs(): string[] {
|
||||
export function discoverPluginDirs(root = repoRoot): string[] {
|
||||
const dirs: string[] = []
|
||||
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: repoRoot }).sort()) {
|
||||
const manifest = JSON.parse(readFileSync(join(repoRoot, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
|
||||
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
|
||||
const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
|
||||
if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
const PLUGIN_DIRS = discoverPluginDirs()
|
||||
if (PLUGIN_DIRS.length === 0) {
|
||||
console.error('dev-web: no dshClient (platform "web") packages found under packages/')
|
||||
process.exit(1)
|
||||
/**
|
||||
* Start the tsdown watch build used by `pnpm run dev:web`.
|
||||
* @param root - repository or fixture root passed to tsdown.
|
||||
* @param pluginDirs - workspace-relative package directories to watch.
|
||||
* @param pollInterval - optional source-watcher polling interval in milliseconds.
|
||||
* @returns live bundles after every watcher has completed its initial build.
|
||||
*/
|
||||
export async function watchClientPlugins(
|
||||
root: string,
|
||||
pluginDirs: readonly string[],
|
||||
pollInterval?: number,
|
||||
): Promise<TsdownBundle[]> {
|
||||
let resolveInitialBuilds: (() => void) | undefined
|
||||
const initialBuilds = new Promise<void>((resolve) => { resolveInitialBuilds = resolve })
|
||||
const initialized = new WeakSet<object>()
|
||||
const readiness: { expectedBuilds?: number; initializedBuilds: number } = { initializedBuilds: 0 }
|
||||
const bundles = await build({
|
||||
cwd: root,
|
||||
workspace: [...pluginDirs],
|
||||
watch: true,
|
||||
hooks: {
|
||||
'build:done': ({ options }) => {
|
||||
if (initialized.has(options)) return
|
||||
initialized.add(options)
|
||||
readiness.initializedBuilds += 1
|
||||
if (
|
||||
readiness.expectedBuilds !== undefined
|
||||
&& readiness.initializedBuilds >= readiness.expectedBuilds
|
||||
) resolveInitialBuilds?.()
|
||||
},
|
||||
},
|
||||
...pollInterval !== undefined
|
||||
? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
|
||||
: {},
|
||||
})
|
||||
readiness.expectedBuilds = bundles.length
|
||||
if (readiness.initializedBuilds >= readiness.expectedBuilds) resolveInitialBuilds?.()
|
||||
await initialBuilds
|
||||
return bundles
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
|
||||
if (args.some(a => a !== pollArg)) {
|
||||
console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
|
||||
process.exit(1)
|
||||
}
|
||||
const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
|
||||
if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
|
||||
console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
const invokedPath = process.argv[1]
|
||||
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
|
||||
if (isMain) {
|
||||
const pluginDirs = discoverPluginDirs()
|
||||
if (pluginDirs.length === 0) {
|
||||
console.error('dev-web: no dshClient (platform "web") packages found under packages/')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await build({
|
||||
cwd: repoRoot,
|
||||
workspace: PLUGIN_DIRS,
|
||||
watch: true,
|
||||
// Rolldown watch options ride through inputOptions (tsdown has no watcher
|
||||
// tuning of its own); polling is opt-in for network mounts without inotify.
|
||||
...pollInterval !== undefined
|
||||
? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
|
||||
: {},
|
||||
})
|
||||
console.log(
|
||||
`dev-web: watching ${String(PLUGIN_DIRS.length)} dshClient plugin packages`
|
||||
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${PLUGIN_DIRS.join('\n ')}`,
|
||||
)
|
||||
const args = process.argv.slice(2)
|
||||
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
|
||||
if (args.some(a => a !== pollArg)) {
|
||||
console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
|
||||
process.exit(1)
|
||||
}
|
||||
const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
|
||||
if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
|
||||
console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await watchClientPlugins(repoRoot, pluginDirs, pollInterval)
|
||||
console.log(
|
||||
`dev-web: watching ${String(pluginDirs.length)} dshClient plugin packages`
|
||||
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"AGENTS.md": 1775,
|
||||
"docs/AGENTS.md": 1150,
|
||||
"docs/architecture.md": 1920,
|
||||
"AGENTS.md": 1782,
|
||||
"docs/AGENTS.md": 1320,
|
||||
"docs/architecture.md": 2160,
|
||||
"docs/cordis-primer.md": 600,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 1100,
|
||||
"docs/testing.md": 1150,
|
||||
"examples/AGENTS.md": 310,
|
||||
"packages/AGENTS.md": 675,
|
||||
"packages/README.md": 920
|
||||
"packages/README.md": 936
|
||||
}
|
||||
|
||||
@@ -126,12 +126,13 @@ function loadFile(abs: string, rel: string, cache: Map<string, FileCtx>): FileCt
|
||||
}
|
||||
|
||||
/** A type declaration a paste can contain. */
|
||||
type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration
|
||||
type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration
|
||||
|
||||
/** Find an interface/type-alias declaration by name in a file, or null. */
|
||||
/** Find a pasteable type declaration by name in a file, or null. */
|
||||
function findTypeDecl(ctx: FileCtx, name: string): TypeDecl | null {
|
||||
for (const stmt of ctx.sf.statements) {
|
||||
if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && stmt.name.text === name) return stmt
|
||||
if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt))
|
||||
&& stmt.name.text === name) return stmt
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -207,7 +208,7 @@ function checkMemberDocs(ctx: FileCtx, decl: TypeDecl, violations: string[]): vo
|
||||
else ts.forEachChild(type, (n) => { walkNested(n, path) })
|
||||
}
|
||||
if (ts.isInterfaceDeclaration(decl)) walkMembers(decl.members, decl.name.text)
|
||||
else walkNested(decl.type, decl.name.text)
|
||||
else if (ts.isTypeAliasDeclaration(decl)) walkNested(decl.type, decl.name.text)
|
||||
}
|
||||
|
||||
/** Cross-file resolution context for the schema-path check. */
|
||||
|
||||
@@ -28,12 +28,11 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ContinuationDecision: 'core.md',
|
||||
ContinuationStop: 'core.md',
|
||||
GenerateOptions: 'core.md',
|
||||
InboxItem: 'core.md',
|
||||
InboxPlacement: 'core.md',
|
||||
MessageId: 'core.md',
|
||||
HookContext: 'core.md',
|
||||
SettleReason: 'core.md',
|
||||
AdapterRegistrationHandle: 'core.md',
|
||||
DirectoryRegistrationHandle: 'core.md',
|
||||
LlmCallConfig: 'core.md',
|
||||
LlmModelContext: 'core.md',
|
||||
LlmModelReasoningInfo: 'core.md',
|
||||
@@ -42,13 +41,16 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
LlmModelInfo: 'core.md',
|
||||
LlmProviderInfo: 'core.md',
|
||||
LlmConfigurableProvider: 'core.md',
|
||||
LlmModelDiscoveryRequest: 'core.md',
|
||||
LlmDiscoveredModel: 'core.md',
|
||||
ResolvedRetryPolicy: 'llm-streaming.md',
|
||||
Message: 'core.md',
|
||||
MessageSource: 'core.md',
|
||||
UserMessage: 'session.md',
|
||||
PromptDecision: 'core.md',
|
||||
RequestError: 'core.md',
|
||||
PreStepDecision: 'core.md',
|
||||
PreStepContext: 'core.md',
|
||||
RequestErrorAction: 'core.md',
|
||||
RequestFailureContext: 'core.md',
|
||||
PreparedReferencedMessage: 'session-reference.md',
|
||||
SessionReferenceCandidate: 'session-reference.md',
|
||||
SessionReferenceInput: 'session-reference.md',
|
||||
@@ -91,6 +93,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
FsWriteIntent: 'filesystem.md',
|
||||
FsWriteOutcome: 'filesystem.md',
|
||||
CreateGoalRequest: 'goal.md',
|
||||
CreateGoalResult: 'goal.md',
|
||||
EditGoalRequest: 'goal.md',
|
||||
GoalBlockReason: 'goal.md',
|
||||
GoalChanged: 'goal.md',
|
||||
@@ -106,8 +109,11 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
StreamChunk: 'llm-streaming.md',
|
||||
SkillProviderControl: 'skills.md',
|
||||
CreateSessionOptions: 'persistence.md',
|
||||
PrepareSessionOptions: 'persistence.md',
|
||||
SessionHeader: 'persistence.md',
|
||||
SessionInspection: 'persistence.md',
|
||||
SessionLocation: 'persistence.md',
|
||||
SessionPreparation: 'persistence.md',
|
||||
SessionPersistenceSnapshot: 'persistence.md',
|
||||
ConfinedArgv: 'sandbox.md',
|
||||
SandboxExecutionPolicy: 'sandbox.md',
|
||||
@@ -163,7 +169,18 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SkillSummary: 'skills.md',
|
||||
SaveTextSpill: 'spill.md',
|
||||
SpillRef: 'spill.md',
|
||||
ContinuableCreateRequest: 'subagent.md',
|
||||
ContinuableCreateSpec: 'subagent.md',
|
||||
ContinuableSetupContribution: 'subagent.md',
|
||||
ContinuableStart: 'subagent.md',
|
||||
ContinuableStartSpec: 'subagent.md',
|
||||
CoordinatorMessageSource: 'subagent.md',
|
||||
SubagentFollowupOptions: 'subagent.md',
|
||||
SubagentListEntry: 'subagent.md',
|
||||
SubagentProvider: 'subagent.md',
|
||||
SubagentReportDelivery: 'subagent.md',
|
||||
SubagentReportMessageSource: 'subagent.md',
|
||||
SubagentReportOptions: 'subagent.md',
|
||||
SubagentRun: 'subagent.md',
|
||||
SubagentService: 'subagent.md',
|
||||
SubagentStartRequest: 'subagent.md',
|
||||
@@ -263,17 +280,19 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
TypertPackageRecord: 'registry package record is owned by packages/typert/registry/README.md',
|
||||
TypertSchemaFilter: 'registry schema query filter is owned by packages/typert/registry/README.md',
|
||||
TypertSchemaRecord: 'registry schema record is owned by packages/typert/registry/README.md',
|
||||
TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md',
|
||||
'z.core.JSONSchema.BaseSchema': 'zod projection output is owned by the zod v4 API',
|
||||
'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API',
|
||||
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',
|
||||
WebBootGraph: 'web boot graph wire shape is owned by packages/client/modules/src/client/index.ts',
|
||||
WebRoute: 'route registration contract is owned by packages/host/webserver/src/index.ts',
|
||||
WebUpgradeRoute:
|
||||
'upgrade route registration contract is owned by packages/host/webserver/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',
|
||||
TuiOverlayRequest: 'service-local extension contract is owned by packages/ui/tui/README.md',
|
||||
TuiOverlaySession: 'service-local extension contract is owned by packages/ui/tui/README.md',
|
||||
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
|
||||
InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/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',
|
||||
@@ -281,8 +300,8 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
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',
|
||||
SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
|
||||
SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
|
||||
SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
SubagentRunInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts',
|
||||
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
@@ -306,7 +325,8 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
|
||||
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
|
||||
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:22' },
|
||||
{ name: 'hmr/config-update-failed', summary: 'A watched config-file refresh failed.', source: 'vendor/hmr/src/index.ts:29' },
|
||||
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
|
||||
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
|
||||
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
|
||||
|
||||
@@ -150,8 +150,15 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'typert-registry',
|
||||
title: 'Runtime type registry',
|
||||
mode: 'core',
|
||||
consumers: ['typert-loader'],
|
||||
note: 'Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges.',
|
||||
consumers: ['typert-loader', 'api-gateway'],
|
||||
note: 'Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges.',
|
||||
},
|
||||
{
|
||||
key: 'typertGateway',
|
||||
pkg: 'api-gateway',
|
||||
title: 'TypeRT Host invocation gateway',
|
||||
mode: 'core',
|
||||
note: 'Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier.',
|
||||
},
|
||||
{
|
||||
key: 'sessionPersistence',
|
||||
@@ -228,7 +235,6 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'session-reference',
|
||||
title: 'Cross-session snapshot preparation',
|
||||
mode: 'core',
|
||||
consumers: ['tui'],
|
||||
note: 'Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax.',
|
||||
},
|
||||
{
|
||||
@@ -260,8 +266,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'user-interaction',
|
||||
title: 'Human question/answer seam',
|
||||
mode: 'seam',
|
||||
implementations: ['tui'],
|
||||
consumers: ['tool-ask-user', 'tui'],
|
||||
consumers: ['tool-ask-user'],
|
||||
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
|
||||
},
|
||||
{
|
||||
@@ -276,8 +281,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'commands',
|
||||
title: 'Human command registry',
|
||||
mode: 'core',
|
||||
consumers: ['tui'],
|
||||
note: 'Plugins register direct human commands; TUI consumes the effective per-agent catalog without sending invocations to the model.',
|
||||
note: 'Plugins register direct human commands without sending invocations to the model.',
|
||||
},
|
||||
{
|
||||
key: 'sessionProjections',
|
||||
@@ -295,13 +299,6 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['host-apiproxy'],
|
||||
note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.',
|
||||
},
|
||||
{
|
||||
key: 'tui',
|
||||
pkg: 'tui',
|
||||
title: 'Mounted-terminal interaction service',
|
||||
mode: 'bundle',
|
||||
note: 'One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state.',
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
pkg: 'skill',
|
||||
@@ -316,7 +313,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'agent',
|
||||
title: 'Agent service',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'tui-demo'],
|
||||
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess'],
|
||||
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
|
||||
},
|
||||
{
|
||||
@@ -340,24 +337,25 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Subprocess seam',
|
||||
mode: 'seam',
|
||||
implementations: ['subprocess-local'],
|
||||
consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp'],
|
||||
note: 'The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.',
|
||||
consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-claude-code'],
|
||||
note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.',
|
||||
},
|
||||
{
|
||||
key: 'bash',
|
||||
pkg: 'bash',
|
||||
title: 'Bash executor seam',
|
||||
mode: 'seam',
|
||||
implementations: ['bash-local', 'bash-sandbox'],
|
||||
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
|
||||
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
|
||||
implementations: ['bash-local', 'bash-sandbox', 'pwsh-local'],
|
||||
consumers: ['tool-bash', 'tool-pwsh', 'hooks-claude', 'hooks-codex'],
|
||||
note: 'The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them.',
|
||||
},
|
||||
{
|
||||
key: 'bashEnv',
|
||||
pkg: 'tool-bash',
|
||||
pkg: 'bash-env',
|
||||
title: 'Managed bash environment registry',
|
||||
mode: 'core',
|
||||
note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.',
|
||||
consumers: ['tool-bash', 'tool-pwsh'],
|
||||
note: 'Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace.',
|
||||
},
|
||||
{
|
||||
key: 'pty',
|
||||
@@ -434,11 +432,11 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
{
|
||||
key: 'subagents',
|
||||
pkg: 'subagent',
|
||||
title: 'Subagent provider registry',
|
||||
title: 'Subagent provider and continuation service',
|
||||
mode: 'seam',
|
||||
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'],
|
||||
consumers: ['tool-subagent', 'tool-ralph'],
|
||||
note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.',
|
||||
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex', 'subagent-claude-code', 'subagent-dsh-sdk'],
|
||||
consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'],
|
||||
note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.',
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
@@ -617,7 +615,8 @@ function parseExampleCordis(rel: string): ExamplePlugin[] {
|
||||
if (current?.name) plugins.push({ id: current.id, name: current.name })
|
||||
}
|
||||
for (const line of text.split('\n')) {
|
||||
const id = /^-\s+id:\s+(.+?)\s*$/.exec(line)
|
||||
// Top-level rows (`- id:`) and bundle-patch insert rows (` - id:`).
|
||||
const id = /^\s*-\s+id:\s+(.+?)\s*$/.exec(line)
|
||||
if (id?.[1] !== undefined) {
|
||||
flush()
|
||||
current = { id: stripYamlScalar(id[1]) }
|
||||
@@ -636,12 +635,12 @@ function stripYamlScalar(value: string): string {
|
||||
|
||||
const APP_EXAMPLES = [
|
||||
{
|
||||
id: 'tui',
|
||||
id: 'dsh_base',
|
||||
rel: 'apps/cli/composition.md',
|
||||
title: 'TUI Agent App Composition',
|
||||
label: 'apps/cli/config',
|
||||
config: 'apps/cli/config/base.cordis.yml',
|
||||
summary: 'The TUI surface combines the shared CLI base with its surface overlay and full-screen terminal package.',
|
||||
title: 'DSH Base Composition',
|
||||
label: 'packages/bundle/base/cordis.patch.yml',
|
||||
config: 'packages/bundle/base/cordis.patch.yml',
|
||||
summary: 'The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app, dsh-headless) and the user\'s profile layer patch over it.',
|
||||
},
|
||||
{
|
||||
id: 'headless',
|
||||
@@ -668,9 +667,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string
|
||||
const jsonl = nodeId('bundle', 'jsonl')
|
||||
lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`)
|
||||
lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
|
||||
if (pluginName === '@deepseek-ai/dsh-tui-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'tui')}["@deepseek-ai/dsh-tui<br/>pre-created main agent"]`)
|
||||
} else if (pluginName === '@deepseek-ai/dsh-cli-demo') {
|
||||
if (pluginName === '@deepseek-ai/dsh-cli-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver<br/>format-pure stdout<br/>fresh top-level agent"]`)
|
||||
} else if (pluginName === '@deepseek-ai/dsh-acp-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`)
|
||||
@@ -698,7 +695,7 @@ function renderAppComposition(example: AppExample): string {
|
||||
const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
|
||||
lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
|
||||
lines.push(` cfg --> ${pluginNode}`)
|
||||
if (plugin.name === '@deepseek-ai/dsh-tui-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
|
||||
if (plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
|
||||
renderAppExpansion(lines, pluginNode, plugin.name)
|
||||
}
|
||||
}
|
||||
@@ -1174,20 +1171,22 @@ function renderLifecycle(): string {
|
||||
' participant Session',
|
||||
' participant SDK as UI or SDK listener',
|
||||
' User->>Agent: followup(content)',
|
||||
` Agent-->>SDK: ${mermaidCode('agent/inbox/enqueue')}`,
|
||||
` Agent-->>SDK: ${mermaidCode('agent/inbox/spliced')}`,
|
||||
` Agent-->>SDK: ${mermaidCode('agent/inbox/inserted')} { message }`,
|
||||
' Agent->>Driver: queued work wakes driver',
|
||||
` Driver-->>SDK: ${mermaidCode('agent/status')} running`,
|
||||
' Note over Agent,Driver: next-step acceptance window opens',
|
||||
` Driver->>Hooks: ${mermaidCode('agent/prompt-submit')} waterfall`,
|
||||
' Hooks-->>Driver: authoritative allow, block, or add context',
|
||||
' alt prompt blocked or admission failed',
|
||||
' Driver-->>Driver: append context-only batch or keep steering boundary pending',
|
||||
' else prompt allowed',
|
||||
' Note over Agent,Driver: claim pending next-step input plus one queued prompt',
|
||||
` Driver-->>SDK: ${mermaidCode('agent/inbox/spliced')} pure deletion`,
|
||||
` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`,
|
||||
' Hooks-->>Driver: authoritative reject or enter(messages)',
|
||||
' alt proposed step rejected or pre-step failed',
|
||||
' Driver-->>Driver: claimed batch stays removed, no turn opens',
|
||||
' else enter proposed step',
|
||||
` Driver->>Session: ${mermaidCode('turn/start')}`,
|
||||
` Driver->>Session: ${mermaidCode('user/message')}`,
|
||||
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`,
|
||||
` Driver-->>Driver: ${mermaidCode('agent/step')} serial checkpoint`,
|
||||
` Driver->>Session: ${mermaidCode('step/start')}`,
|
||||
` Driver->>Session: ${mermaidCode('user/message')} per entered message`,
|
||||
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`,
|
||||
` Driver->>LLM: ${mermaidCode('agent/request')} waterfall, then ${mermaidCode('llm/stream')} waterfall`,
|
||||
' LLM-->>Driver: StreamChunk*',
|
||||
` Driver->>Session: ${mermaidCode('assistant/chunk')}*`,
|
||||
@@ -1210,11 +1209,17 @@ function renderLifecycle(): string {
|
||||
` Driver->>Session: ${mermaidCode('tool/result')}`,
|
||||
' end',
|
||||
' end',
|
||||
' Driver->>Session: post-tool context and steering (no prompt-submit)',
|
||||
` Driver->>Session: ${mermaidCode('step/end')}`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/turn-stopping')} serial terminal checkpoint`,
|
||||
' opt natural stop and next-step inbox empty',
|
||||
` Driver->>Hooks: ${mermaidCode('agent/turn-stopping')} serial terminal checkpoint`,
|
||||
' end',
|
||||
' opt next-step input is pending',
|
||||
' Driver-->>Driver: claim pending next-step input',
|
||||
` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`,
|
||||
' Hooks-->>Driver: authoritative reject or enter(messages)',
|
||||
' end',
|
||||
' end',
|
||||
' Note over Agent,Driver: next-step acceptance window closes',
|
||||
` Driver->>Session: ${mermaidCode('turn/end')}`,
|
||||
' end',
|
||||
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
|
||||
@@ -1222,9 +1227,9 @@ function renderLifecycle(): string {
|
||||
'',
|
||||
'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.',
|
||||
'',
|
||||
'`dsh-compact-basic` uses `agent/step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
|
||||
'`dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
|
||||
'',
|
||||
'The returned `agent/prompt-submit` allow is authoritative; listeners wrapping `next()` preserve downstream content and additional contexts unless replacement is intentional. Steering bypasses that waterfall and joins at its durable checkpoint.',
|
||||
'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later boundary claims their next-step batch.',
|
||||
'',
|
||||
'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.',
|
||||
'',
|
||||
@@ -1313,8 +1318,8 @@ function renderDocs(): GraphDoc[] {
|
||||
function renderIndex(docs: GraphDoc[]): string {
|
||||
const labels: Record<string, string> = {
|
||||
'docs/capability-seams.md': 'capability seams and core services',
|
||||
'apps/cli/composition.md': 'dsh shared base composition',
|
||||
'examples/headless-agent/composition.md': 'headless-agent app composition',
|
||||
'examples/tui-agent/composition.md': 'tui-agent app composition',
|
||||
'examples/cordis-agent/composition.md': 'cordis-agent app composition',
|
||||
'examples/acp-agent/composition.md': 'acp-agent app composition',
|
||||
'docs/event-producer-consumer.md': 'event producer/consumer matrix',
|
||||
@@ -1323,8 +1328,8 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
}
|
||||
const modes: Record<string, string> = {
|
||||
'docs/capability-seams.md': 'hybrid generated',
|
||||
'apps/cli/composition.md': 'hybrid generated',
|
||||
'examples/headless-agent/composition.md': 'hybrid generated',
|
||||
'examples/tui-agent/composition.md': 'hybrid generated',
|
||||
'examples/cordis-agent/composition.md': 'hybrid generated',
|
||||
'examples/acp-agent/composition.md': 'hybrid generated',
|
||||
'docs/event-producer-consumer.md': 'hybrid generated',
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts'
|
||||
import {
|
||||
CLAUDE_AGENT_SDK_PACKAGE,
|
||||
claudeDistributionFromManifest,
|
||||
collectPythonDependencies,
|
||||
isOwnerAuthorizedRuntime,
|
||||
isPermissive,
|
||||
type Manifest,
|
||||
manifestPatterns,
|
||||
parsePyprojectRequirements,
|
||||
parseVendoredRows,
|
||||
render,
|
||||
tierExternalDeps,
|
||||
virtualManifest,
|
||||
} from './gen-third-party-notices.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -11,7 +25,9 @@ describe('THIRD_PARTY_NOTICES.md', () => {
|
||||
// Pre-commit regenerates the file whenever a manifest is staged, so reaching
|
||||
// this assertion means the notices were committed without that hook.
|
||||
it('matches what the generator produces from the current manifests', () => {
|
||||
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render())
|
||||
const generated = render()
|
||||
expect(generated).toContain('It depends on the third-party software listed below.')
|
||||
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,6 +79,56 @@ describe('tierExternalDeps', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('virtualManifest', () => {
|
||||
it('resolves a manifest from an ordinary prefix-matching store directory', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-notices-prefix-'))
|
||||
try {
|
||||
const name = '@scope/pkg'
|
||||
const version = '1.0.0'
|
||||
const store = join(root, 'store')
|
||||
const manifestDir = join(store, `${name.replace('/', '+')}@${version}`, 'node_modules', name)
|
||||
mkdirSync(manifestDir, { recursive: true })
|
||||
writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'MIT' }))
|
||||
|
||||
expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'MIT' })
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to a content scan when pnpm 11 truncates the store directory name', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-notices-truncated-'))
|
||||
try {
|
||||
const name = '@scope/pkg'
|
||||
const version = '2.0.0'
|
||||
const store = join(root, 'store')
|
||||
// The truncated name no longer starts with `@scope+pkg@`, so only the
|
||||
// whole-store content scan can find the package.
|
||||
const manifestDir = join(store, '@scope+pkg_9f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f', 'node_modules', name)
|
||||
mkdirSync(manifestDir, { recursive: true })
|
||||
writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'Apache-2.0' }))
|
||||
|
||||
expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'Apache-2.0' })
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns undefined when neither the prefix nor the content scan finds the package', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-notices-miss-'))
|
||||
try {
|
||||
const store = join(root, 'store')
|
||||
const other = join(store, 'other-pkg@1.0.0', 'node_modules', 'other-pkg')
|
||||
mkdirSync(other, { recursive: true })
|
||||
writeFileSync(join(other, 'package.json'), JSON.stringify({ name: 'other-pkg', version: '1.0.0' }))
|
||||
|
||||
expect(virtualManifest(store, '@scope/missing')).toBeUndefined()
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseVendoredRows', () => {
|
||||
it('reads the committed vendor manifest table', () => {
|
||||
const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
|
||||
@@ -172,7 +238,14 @@ describe('collectPythonDependencies', () => {
|
||||
describe('isPermissive', () => {
|
||||
it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => {
|
||||
expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true)
|
||||
expect(['LGPL-3.0-only', 'MPL-2.0', 'GPL-3.0-or-later', 'SEE LICENSE IN LICENSE'].some(isPermissive)).toBe(false)
|
||||
expect([
|
||||
'LGPL-3.0-only',
|
||||
'MPL-2.0',
|
||||
'GPL-3.0-or-later',
|
||||
'SEE LICENSE IN LICENSE',
|
||||
'SEE LICENSE IN README.md',
|
||||
'SEE LICENSE IN LICENSE.md',
|
||||
].some(isPermissive)).toBe(false)
|
||||
})
|
||||
|
||||
it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => {
|
||||
@@ -194,6 +267,66 @@ describe('isPermissive', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('official Claude distribution authorization', () => {
|
||||
it('authorizes only the direct SDK identity without relabeling its license', () => {
|
||||
expect(isOwnerAuthorizedRuntime(CLAUDE_AGENT_SDK_PACKAGE)).toBe(true)
|
||||
expect(isOwnerAuthorizedRuntime(`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`))
|
||||
.toBe(false)
|
||||
expect(isOwnerAuthorizedRuntime('@anthropic-ai/unrelated')).toBe(false)
|
||||
expect(isPermissive('SEE LICENSE IN README.md')).toBe(false)
|
||||
})
|
||||
|
||||
it('derives version-independent platform payloads from the official SDK manifest', () => {
|
||||
expect(claudeDistributionFromManifest({
|
||||
name: CLAUDE_AGENT_SDK_PACKAGE,
|
||||
version: '9.8.7',
|
||||
license: 'future declared terms',
|
||||
claudeCodeVersion: '6.5.4',
|
||||
optionalDependencies: {
|
||||
[`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '9.8.7',
|
||||
[`${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`]: '9.8.7',
|
||||
},
|
||||
})).toEqual({
|
||||
sdkVersion: '9.8.7',
|
||||
claudeCodeVersion: '6.5.4',
|
||||
payloads: [
|
||||
{
|
||||
name: `${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`,
|
||||
version: '9.8.7',
|
||||
},
|
||||
{
|
||||
name: `${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`,
|
||||
version: '9.8.7',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a wrong SDK identity, missing payloads, and unrelated optionals', () => {
|
||||
expect(() => claudeDistributionFromManifest({
|
||||
name: '@anthropic-ai/unrelated',
|
||||
version: '1.0.0',
|
||||
claudeCodeVersion: '1.0.0',
|
||||
optionalDependencies: {
|
||||
[`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '1.0.0',
|
||||
},
|
||||
})).toThrow(`expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest`)
|
||||
expect(() => claudeDistributionFromManifest({
|
||||
name: CLAUDE_AGENT_SDK_PACKAGE,
|
||||
version: '1.0.0',
|
||||
claudeCodeVersion: '1.0.0',
|
||||
})).toThrow('declares no optional platform payloads')
|
||||
expect(() => claudeDistributionFromManifest({
|
||||
name: CLAUDE_AGENT_SDK_PACKAGE,
|
||||
version: '1.0.0',
|
||||
claudeCodeVersion: '1.0.0',
|
||||
optionalDependencies: {
|
||||
'@anthropic-ai/unrelated': '1.0.0',
|
||||
},
|
||||
})).toThrow('outside its authorized platform-payload identity')
|
||||
})
|
||||
})
|
||||
|
||||
describe('manifestPatterns', () => {
|
||||
it('derives globs from the declared members, so a new member area is read', () => {
|
||||
expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([
|
||||
|
||||
@@ -49,6 +49,21 @@ const FIRST_PARTY = new Set([
|
||||
'node-addon-landlock-run-linux-x64',
|
||||
])
|
||||
|
||||
/** Official SDK identity covered by the project's narrow owner authorization. */
|
||||
export const CLAUDE_AGENT_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk'
|
||||
const CLAUDE_PLATFORM_PACKAGE_PREFIX = `${CLAUDE_AGENT_SDK_PACKAGE}-`
|
||||
const CLAUDE_PLATFORM_DECLARED_LICENSE = 'SEE LICENSE IN LICENSE.md'
|
||||
|
||||
/**
|
||||
* Whether a non-permissive runtime declaration has an identity-scoped owner
|
||||
* authorization. This does not reclassify its terms as permissive.
|
||||
* @param name - exact npm package identity.
|
||||
* @returns true only for the official Claude Agent SDK package.
|
||||
*/
|
||||
export function isOwnerAuthorizedRuntime(name: string): boolean {
|
||||
return name === CLAUDE_AGENT_SDK_PACKAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata overrides where the installed manifest is wrong or unreachable.
|
||||
* Each entry documents why the store cannot answer.
|
||||
@@ -92,6 +107,7 @@ const BUILD_TIME_TOOLS = [
|
||||
/** The `package.json` fields this generator reads. */
|
||||
export interface Manifest {
|
||||
name?: string
|
||||
version?: string
|
||||
private?: boolean
|
||||
license?: string
|
||||
dependencies?: Record<string, string>
|
||||
@@ -141,15 +157,22 @@ function workspaceMembers(rel: string): string[] {
|
||||
return declared.map(member => String(member))
|
||||
}
|
||||
|
||||
/** Every workspace manifest, keyed by path, plus the set of workspace package names. */
|
||||
/**
|
||||
* Every workspace manifest, keyed by repository-relative path, plus the set of
|
||||
* workspace package names. Paths are normalized to `/` at ingestion: Node's
|
||||
* `fs.globSync` returns OS-native separators, and the area matching in
|
||||
* `tierExternalDeps` compares `/`-suffixed prefixes, so Windows backslashes
|
||||
* would silently push dev-area manifests into the runtime tier.
|
||||
*/
|
||||
function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Set<string> } {
|
||||
const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml'))
|
||||
const manifests = new Map<string, Manifest>()
|
||||
const names = new Set<string>()
|
||||
for (const pattern of patterns) {
|
||||
for (const path of globSync(pattern, { cwd: root })) {
|
||||
const manifest = readManifest(path)
|
||||
manifests.set(path, manifest)
|
||||
const normalized = path.replaceAll('\\', '/')
|
||||
const manifest = readManifest(normalized)
|
||||
manifests.set(normalized, manifest)
|
||||
if (manifest.name !== undefined) names.add(manifest.name)
|
||||
}
|
||||
}
|
||||
@@ -157,9 +180,104 @@ function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Se
|
||||
return { manifests, names }
|
||||
}
|
||||
|
||||
/** License and repository URL for an installed external package, from the pnpm store. */
|
||||
function installedMetadata(name: string): { license: string; repo: string } {
|
||||
const override = OVERRIDES[name]
|
||||
type VirtualManifest = Manifest & {
|
||||
claudeCodeVersion?: string
|
||||
license?: string
|
||||
repository?: string | { url?: string }
|
||||
homepage?: string
|
||||
}
|
||||
|
||||
/** One platform payload declared by the official Claude Agent SDK. */
|
||||
export interface ClaudePlatformPayload {
|
||||
readonly name: string
|
||||
readonly version: string
|
||||
}
|
||||
|
||||
/** Current SDK and CLI distribution facts derived from the installed SDK manifest. */
|
||||
export interface ClaudeDistribution {
|
||||
readonly sdkVersion: string
|
||||
readonly claudeCodeVersion: string
|
||||
readonly payloads: ClaudePlatformPayload[]
|
||||
}
|
||||
|
||||
function requiredManifestString(
|
||||
value: string | undefined,
|
||||
field: string,
|
||||
): string {
|
||||
if (value === undefined || value.length === 0) {
|
||||
throw new Error(`gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} has no ${field}.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the official platform payload set without a version or platform
|
||||
* allowlist. Only identities in the SDK's own package namespace are covered.
|
||||
* @param manifest - installed official SDK manifest.
|
||||
* @returns current SDK, CLI, and optional platform payload facts.
|
||||
*/
|
||||
export function claudeDistributionFromManifest(
|
||||
manifest: VirtualManifest,
|
||||
): ClaudeDistribution {
|
||||
if (manifest.name !== CLAUDE_AGENT_SDK_PACKAGE) {
|
||||
throw new Error(
|
||||
`gen-third-party-notices: expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest, got ${JSON.stringify(manifest.name)}.`,
|
||||
)
|
||||
}
|
||||
const sdkVersion = requiredManifestString(manifest.version, 'version')
|
||||
const claudeCodeVersion = requiredManifestString(
|
||||
manifest.claudeCodeVersion,
|
||||
'claudeCodeVersion',
|
||||
)
|
||||
const entries = Object.entries(manifest.optionalDependencies ?? {})
|
||||
if (entries.length === 0) {
|
||||
throw new Error(
|
||||
`gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} declares no optional platform payloads.`,
|
||||
)
|
||||
}
|
||||
const payloads = entries.map(([name, version]) => {
|
||||
if (!name.startsWith(CLAUDE_PLATFORM_PACKAGE_PREFIX)) {
|
||||
throw new Error(
|
||||
`gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} optional dependency ${name} is outside its authorized platform-payload identity.`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
name,
|
||||
version: requiredManifestString(version, `${name} optional dependency version`),
|
||||
}
|
||||
}).sort((left, right) => left.name.localeCompare(right.name))
|
||||
return { sdkVersion, claudeCodeVersion, payloads }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one package's manifest inside a pnpm virtual store. The prefix scan
|
||||
* matches ordinary `@scope+name@version` directory names; pnpm 11 truncates
|
||||
* long names (a peer-suffixed name past the length limit becomes
|
||||
* `<prefix>_<hash>`), so a content scan falls back over the whole store when
|
||||
* the prefix misses.
|
||||
*
|
||||
* @param virtual - the `.pnpm` virtual store directory to scan.
|
||||
* @param name - the external package name, exactly as `node_modules` spells it.
|
||||
* @returns the parsed manifest, or `undefined` when neither the prefix match
|
||||
* nor the content scan finds the package's `package.json`.
|
||||
*/
|
||||
export function virtualManifest(virtual: string, name: string): VirtualManifest | undefined {
|
||||
const prefix = `${name.replace('/', '+')}@`
|
||||
const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix))
|
||||
if (entry !== undefined) {
|
||||
return JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as VirtualManifest
|
||||
}
|
||||
for (const dir of readdirSync(virtual)) {
|
||||
const candidate = resolve(virtual, dir, 'node_modules', name, 'package.json')
|
||||
if (existsSync(candidate)) {
|
||||
return JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Resolve one installed external package manifest from either pnpm store. */
|
||||
function installedManifest(name: string): VirtualManifest | undefined {
|
||||
let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
|
||||
// The nested Landlock workspace installs into its own store, so a package
|
||||
// only that workspace depends on is unreachable from the root one.
|
||||
@@ -171,12 +289,16 @@ function installedMetadata(name: string): { license: string; repo: string } {
|
||||
}
|
||||
const virtual = resolve(root, store, '.pnpm')
|
||||
if (!existsSync(virtual)) continue
|
||||
const prefix = `${name.replace('/', '+')}@`
|
||||
const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix))
|
||||
if (entry === undefined) continue
|
||||
manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as typeof manifest
|
||||
break
|
||||
manifest = virtualManifest(virtual, name)
|
||||
if (manifest !== undefined) break
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
/** License and repository URL for an installed external package, from the pnpm store. */
|
||||
function installedMetadata(name: string): { license: string; repo: string } {
|
||||
const override = OVERRIDES[name]
|
||||
const manifest = installedManifest(name)
|
||||
const license = override?.license ?? manifest?.license
|
||||
const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage
|
||||
const repo = override?.repo ?? normalizeRepo(rawRepo)
|
||||
@@ -186,6 +308,37 @@ function installedMetadata(name: string): { license: string; repo: string } {
|
||||
return { license, repo }
|
||||
}
|
||||
|
||||
function collectClaudeDistribution(): ClaudeDistribution {
|
||||
const manifest = installedManifest(CLAUDE_AGENT_SDK_PACKAGE)
|
||||
if (manifest === undefined) {
|
||||
throw new Error(
|
||||
`gen-third-party-notices: cannot resolve ${CLAUDE_AGENT_SDK_PACKAGE}; run \`pnpm install\`.`,
|
||||
)
|
||||
}
|
||||
const distribution = claudeDistributionFromManifest(manifest)
|
||||
let installedPayloads = 0
|
||||
for (const payload of distribution.payloads) {
|
||||
const installed = installedManifest(payload.name)
|
||||
if (installed === undefined) continue
|
||||
installedPayloads += 1
|
||||
if (
|
||||
installed.name !== payload.name
|
||||
|| installed.version !== payload.version
|
||||
|| installed.license !== CLAUDE_PLATFORM_DECLARED_LICENSE
|
||||
) {
|
||||
throw new Error(
|
||||
`gen-third-party-notices: installed ${payload.name} does not match its SDK-declared version and ${CLAUDE_PLATFORM_DECLARED_LICENSE} license field.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (installedPayloads === 0) {
|
||||
throw new Error(
|
||||
'gen-third-party-notices: no SDK-declared Claude platform payload is installed; install optional dependencies before regenerating.',
|
||||
)
|
||||
}
|
||||
return distribution
|
||||
}
|
||||
|
||||
/** Normalize a manifest repository/homepage value to a browsable https URL. */
|
||||
function normalizeRepo(raw: string | undefined): string | undefined {
|
||||
if (raw === undefined || raw === '') return undefined
|
||||
@@ -486,6 +639,26 @@ function renderNpmTable(deps: ExternalDep[]): string {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function renderClaudeDistribution(
|
||||
distribution: ClaudeDistribution | undefined,
|
||||
): string {
|
||||
if (distribution === undefined) return ''
|
||||
const rows = distribution.payloads.map(payload =>
|
||||
`| [\`${payload.name}\`](https://www.npmjs.com/package/${payload.name}) | ${payload.version} | ${CLAUDE_PLATFORM_DECLARED_LICENSE} |`,
|
||||
)
|
||||
return `
|
||||
## Official Claude Code platform payloads
|
||||
|
||||
The project owner authorizes distribution of every version of the official \`${CLAUDE_AGENT_SDK_PACKAGE}\` package and the official Claude Code CLI/platform payloads that each version declares through \`optionalDependencies\`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review.
|
||||
|
||||
The installed SDK ${distribution.sdkVersion} declares the following optional platform packages. Each carries the official Claude Code ${distribution.claudeCodeVersion} executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host.
|
||||
|
||||
| Optional platform package | Version | Declared license |
|
||||
| --- | --- | --- |
|
||||
${rows.join('\n')}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the complete notices document.
|
||||
* @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
|
||||
@@ -498,11 +671,19 @@ export function render(): string {
|
||||
const vendored = collectVendored()
|
||||
const python = collectPython()
|
||||
const patched = collectPatched()
|
||||
const claudeDistribution = runtimeDeps.some(
|
||||
dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE,
|
||||
)
|
||||
? collectClaudeDistribution()
|
||||
: undefined
|
||||
|
||||
const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license))
|
||||
// A copyleft license reaching a shipped surface is a distribution decision,
|
||||
// not a rendering detail; the notices cannot quietly absorb it.
|
||||
const nonPermissiveRuntime = runtimeDeps.filter(dep => !isPermissive(dep.license))
|
||||
const nonPermissiveRuntime = runtimeDeps.filter(dep =>
|
||||
!isPermissive(dep.license)
|
||||
&& !isOwnerAuthorizedRuntime(dep.name),
|
||||
)
|
||||
if (nonPermissiveRuntime.length > 0) {
|
||||
throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`)
|
||||
}
|
||||
@@ -513,9 +694,9 @@ export function render(): string {
|
||||
|
||||
# Third-Party Notices
|
||||
|
||||
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
|
||||
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
|
||||
|
||||
This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
|
||||
This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
|
||||
|
||||
The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml).
|
||||
|
||||
@@ -529,13 +710,14 @@ ${vendored.map(row => `| \`${row.npmName}\` | [${row.upstream.replace('https://'
|
||||
|
||||
## Runtime npm dependencies
|
||||
|
||||
External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI/TUI, the Web UI, and the Python SDK runtime load by default.
|
||||
External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default.
|
||||
|
||||
${renderNpmTable(runtimeDeps)}
|
||||
|
||||
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
|
||||
|
||||
${patchedLines.join('\n')}
|
||||
${renderClaudeDistribution(claudeDistribution)}
|
||||
|
||||
## Development-only npm dependencies
|
||||
|
||||
|
||||
@@ -11,14 +11,17 @@ import { basename, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
|
||||
import GoalService from '@deepseek-ai/dsh-goal'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -28,11 +31,15 @@ import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
|
||||
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
|
||||
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
|
||||
import * as ToolSubagentListAgents from '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
||||
import * as ToolSubagentReport from '@deepseek-ai/dsh-tool-subagent-report'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
@@ -55,44 +62,6 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/tool-catalog.md'
|
||||
const CATALOG_RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
|
||||
|
||||
/**
|
||||
* Minimal bash service for harvesting `dsh-tool-fs-search` schemas. The search
|
||||
* plugin now probes `rg` at registration time, but the generated catalog must
|
||||
* remain independent of the host PATH and never execute a real search.
|
||||
*/
|
||||
class CatalogSearchBashExecutor extends BashExecutor {
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? root,
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
signal: request.signal,
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
override run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
if (spec.command !== CATALOG_RG_PROBE_COMMAND) {
|
||||
throw new Error(`gen-tool-catalog: unexpected search bash command during schema harvest: ${spec.command}`)
|
||||
}
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: { text: '', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
}
|
||||
|
||||
override start(): BashProcess {
|
||||
throw new Error('gen-tool-catalog: search schema harvest must not start background processes')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the descriptor needed to mount schema-producing consumers. Declares
|
||||
@@ -106,10 +75,32 @@ function registerCatalogSubagentProvider(ctx: Context, name: string): void {
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
|
||||
inheritsParentContext: false,
|
||||
start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')),
|
||||
// Declared so consumers configured for continuable background mode mount.
|
||||
prepareContinuable: () => Promise.reject(new Error('tool-catalog provider cannot prepare a child')),
|
||||
}
|
||||
ctx.subagents.registerProvider(provider)
|
||||
}
|
||||
|
||||
/** Minted child-scope keys for packages whose tools are never global. */
|
||||
const catalogChildScopes = new WeakMap<Context, Agent>()
|
||||
|
||||
/**
|
||||
* Install one scope-local tool package into an agent-like child scope for
|
||||
* schema harvest, without starting a model, Agent loop, or persistence backend.
|
||||
* @param ctx - catalog context owning the scope.
|
||||
* @param mountScoped - package installer for the scoped context.
|
||||
*/
|
||||
async function mountCatalogChildScope(
|
||||
ctx: Context,
|
||||
mountScoped: (childCtx: Context) => void,
|
||||
): Promise<void> {
|
||||
const key = { id: SessionId('tool-catalog-child') } as Agent
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
mountScoped(createScope(inner, key).ctx)
|
||||
}, { inject: ['tools', 'systemPrompt', 'subagents'] }))
|
||||
catalogChildScopes.set(ctx, key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool package plus its hand-maintained boot recipe. The caller mounts the
|
||||
* prompt and registry; each recipe supplies only package-specific seams and
|
||||
@@ -120,8 +111,12 @@ interface ToolPackage {
|
||||
pkg: string
|
||||
/** The `packages/<group>/<dir>` leaf name — matched by the completeness guard. */
|
||||
dir: string
|
||||
/** Repo-relative source path linked from the catalog entry. */
|
||||
source: string
|
||||
/**
|
||||
* Repo-relative implementation source linked per harvested tool. Packages
|
||||
* whose tools share one plugin may use a string; split plugins map each tool
|
||||
* name to its own source.
|
||||
*/
|
||||
source: string | Readonly<Record<string, string>>
|
||||
/** Services or owning runtime surfaces the package requires at execution time. */
|
||||
requires: string[]
|
||||
/** Session events or other visible state the tools write or affect. */
|
||||
@@ -131,6 +126,8 @@ interface ToolPackage {
|
||||
/** Plug the injected seams + the tool plugin onto a context that already
|
||||
* carries `systemPrompt` + `tools`. */
|
||||
mount: (ctx: Context) => Promise<void>
|
||||
/** Agent-like scope key whose tool view is catalogued instead of the global view. */
|
||||
scope?: (ctx: Context) => Agent
|
||||
/**
|
||||
* Config for the caller's `ToolRegistry` mount. The registry itself ships a
|
||||
* model-facing tool (`run_code`, registered under a non-native `mode`), so
|
||||
@@ -179,7 +176,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
toolsConfig: { mode: 'code' },
|
||||
async mount() {},
|
||||
note:
|
||||
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
|
||||
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime\'s language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-plan-mode',
|
||||
@@ -197,16 +194,35 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-bash',
|
||||
dir: 'tool-bash',
|
||||
source: 'packages/bash/tool-bash/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'],
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(LocalBashExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
},
|
||||
note:
|
||||
'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-pwsh',
|
||||
dir: 'tool-pwsh',
|
||||
source: 'packages/bash/tool-pwsh/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt', 'ctx.bashEnv', 'ctx.tasks at call time for run_in_background'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The pwsh tool consumes the bash executor seam; the schema harvest
|
||||
// mounts the pwsh-local implementation so the inject resolves without
|
||||
// executing anything (registration never spawns a process).
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(PwshLocalExecutor)
|
||||
await ctx.plugin(ToolPwsh)
|
||||
},
|
||||
note:
|
||||
'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\\...` paths and `$env:NAME` variables.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-cordis',
|
||||
dir: 'tool-cordis',
|
||||
@@ -264,19 +280,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-fs-search',
|
||||
dir: 'tool-fs-search',
|
||||
source: 'packages/fs/tool-fs-search/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'],
|
||||
requires: ['ctx.tools', 'ctx.subprocess', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tools inject `bash` (search executes fixed `rg` commands through
|
||||
// the executor seam, not ctx.fs). Use a catalog-only executor so the
|
||||
// registration-time `rg` probe stays deterministic and the generator
|
||||
// never depends on the host PATH. `ctx.spillStore` is optional (read via
|
||||
// ctx.get) and does not affect the schemas, so no spill backend is mounted.
|
||||
await ctx.plugin(CatalogSearchBashExecutor)
|
||||
// The tools inject `subprocess` (search spawns the packaged ripgrep
|
||||
// binary through the seam, not ctx.fs); registration itself never
|
||||
// spawns, so the real local service is inert here. `ctx.spillStore` is
|
||||
// optional (read via ctx.get) and does not affect the schemas, so no
|
||||
// spill backend is mounted.
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
|
||||
},
|
||||
note:
|
||||
'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
|
||||
'glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-pty',
|
||||
@@ -296,7 +312,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
dir: 'tool-goal',
|
||||
source: 'packages/goal/tool-goal/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'],
|
||||
writes: ['tool/call', 'user/message goal snapshot for mutations', 'tool/result'],
|
||||
writes: ['tool/call', 'goal/change for mutations', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
@@ -377,7 +393,47 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolSubagent, { provider: 'mock' })
|
||||
},
|
||||
note:
|
||||
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`.',
|
||||
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
dir: 'tool-subagent-control',
|
||||
source: {
|
||||
list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
|
||||
send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
|
||||
},
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.sessionProjections (list_agents catalog rows)'],
|
||||
writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(ToolSubagentControl)
|
||||
await ctx.plugin(ToolSubagentListAgents)
|
||||
},
|
||||
note:
|
||||
'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows are served through the sessionProjections registry).',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-report',
|
||||
dir: 'tool-subagent-report',
|
||||
source: 'packages/subagent/tool-subagent-report/src/index.ts',
|
||||
requires: ['ctx.subagents', 'a live continuable in-process child Agent'],
|
||||
writes: ['tool/call', 'tool/result', 'a user-role message in the direct parent session'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await mountCatalogChildScope(ctx, (childCtx) => {
|
||||
ToolSubagentReport.installReportTool(childCtx, ctx, 'quiet')
|
||||
})
|
||||
},
|
||||
scope: ctx => catalogChildScopes.get(ctx) as Agent,
|
||||
note:
|
||||
'Registered per continuable in-process child rather than globally, so this schema is visible only '
|
||||
+ 'inside such a child and survives its global `toolFilter`. The parent-facing `send_message` tool '
|
||||
+ 'is installed independently.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-tasks',
|
||||
@@ -399,10 +455,10 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
requires: ['ctx.tools', 'owning Agent session'],
|
||||
writes: ['tool/call', 'todo/write', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(ToolTodo)
|
||||
await ctx.plugin(ToolTodo, { allowParallelInProgress: true })
|
||||
},
|
||||
note:
|
||||
'todo_write is session-owned state; UIs render the latest todo/write event as a checklist.',
|
||||
'todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-workflow',
|
||||
@@ -442,7 +498,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
/** One package's contribution to the catalog: its schemas plus attribution. */
|
||||
interface CatalogPackage {
|
||||
pkg: string
|
||||
source: string
|
||||
sources: Readonly<Record<string, string>>
|
||||
requires: string[]
|
||||
writes: string[]
|
||||
shippedNames?: string[]
|
||||
@@ -494,10 +550,13 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry, entry.toolsConfig ?? {})
|
||||
await entry.mount(ctx)
|
||||
const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name))
|
||||
const schemas = ctx.tools.schemas(entry.scope?.(ctx)).sort((a, b) => a.name.localeCompare(b.name))
|
||||
catalog.push({
|
||||
pkg: entry.pkg,
|
||||
source: entry.source,
|
||||
sources: Object.fromEntries(schemas.map(schema => [
|
||||
schema.name,
|
||||
toolSource(entry, schema.name),
|
||||
])),
|
||||
requires: entry.requires,
|
||||
writes: entry.writes,
|
||||
schemas,
|
||||
@@ -511,6 +570,18 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
|
||||
return catalog
|
||||
}
|
||||
|
||||
/** Resolve one harvested tool to the plugin source that registered it. */
|
||||
function toolSource(entry: ToolPackage, toolName: string): string {
|
||||
if (typeof entry.source === 'string') return entry.source
|
||||
const source = entry.source[toolName]
|
||||
if (source === undefined) {
|
||||
throw new Error(
|
||||
`gen-tool-catalog: ${entry.pkg} has no source mapping for harvested tool ${toolName}`,
|
||||
)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/** Render one tool's entry: name, description, JSON-Schema parameters, source. */
|
||||
function renderTool(schema: ToolSchema, source: string): string[] {
|
||||
const out = [`### \`${schema.name}\``, '']
|
||||
@@ -540,7 +611,7 @@ export function render(catalog: ToolCatalog): string {
|
||||
'',
|
||||
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog Agent Note](../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md).',
|
||||
'',
|
||||
'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.',
|
||||
'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config, except where a Config field is REQUIRED with no default — there the generator must choose, and the per-package note records which branch this page shows. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.',
|
||||
'',
|
||||
'## Tool Package Map',
|
||||
'',
|
||||
@@ -553,7 +624,11 @@ export function render(catalog: ToolCatalog): string {
|
||||
]
|
||||
for (const entry of catalog) {
|
||||
lines.push(`## \`${entry.pkg}\``, '')
|
||||
for (const schema of entry.schemas) lines.push(...renderTool(schema, entry.source))
|
||||
for (const schema of entry.schemas) {
|
||||
// Collection validated that every harvested schema has a source.
|
||||
const source = entry.sources[schema.name] as string
|
||||
lines.push(...renderTool(schema, source))
|
||||
}
|
||||
if (entry.note) lines.push(entry.note, '')
|
||||
}
|
||||
return lines.join('\n')
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import lefthookPackage from 'lefthook/package.json' with { type: 'json' }
|
||||
|
||||
const MINIMUM_GIT = [2, 26, 0]
|
||||
const HOOKS_DIRECTORY = 'dsh-hooks'
|
||||
@@ -596,6 +597,7 @@ function refuseScopedHooksPath(entry) {
|
||||
|
||||
async function main() {
|
||||
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return
|
||||
if (typeof lefthookPackage.bin?.lefthook !== 'string') return
|
||||
const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
|
||||
if (probe.status !== 0) return
|
||||
const root = stripGitLineTerminator(probe.stdout)
|
||||
|
||||
@@ -19,6 +19,9 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
|
||||
const fixtures: string[] = []
|
||||
// Multi-worktree cases spawn several Git and Node subprocesses; coverage concurrency can
|
||||
// legitimately exceed Vitest's default deadline without changing the installer behavior.
|
||||
const MULTI_PROCESS_TEST_TIMEOUT_MS = 20_000
|
||||
|
||||
interface Fixture {
|
||||
container: string
|
||||
@@ -260,7 +263,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked])
|
||||
expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval)
|
||||
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
|
||||
})
|
||||
}, MULTI_PROCESS_TEST_TIMEOUT_MS)
|
||||
|
||||
it('replaces the owned hook path Git copies into a newly added worktree', async () => {
|
||||
const fixture = createFixture()
|
||||
@@ -284,7 +287,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
'# config=late-linked-worktree-config',
|
||||
)
|
||||
expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBefore)
|
||||
})
|
||||
}, MULTI_PROCESS_TEST_TIMEOUT_MS)
|
||||
|
||||
it('serializes concurrent installs and keeps repeated output stable', async () => {
|
||||
const fixture = createFixture()
|
||||
@@ -305,7 +308,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook)
|
||||
expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false)
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
|
||||
})
|
||||
}, MULTI_PROCESS_TEST_TIMEOUT_MS)
|
||||
|
||||
it('waits for a concurrent installer to finish publishing its lock record', async () => {
|
||||
const fixture = createFixture()
|
||||
@@ -343,7 +346,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain(
|
||||
JSON.stringify(movedHooks),
|
||||
)
|
||||
})
|
||||
}, MULTI_PROCESS_TEST_TIMEOUT_MS)
|
||||
|
||||
it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => {
|
||||
const fixture = createFixture()
|
||||
@@ -384,7 +387,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
expect(result.stderr).toContain('non-regular or multiply linked hook entry')
|
||||
expect(readFileSync(externalHook, 'utf8')).toBe(externalContent)
|
||||
}
|
||||
})
|
||||
}, MULTI_PROCESS_TEST_TIMEOUT_MS)
|
||||
|
||||
it('restores the marker-backed stale hook path when relocation reinstall fails', async () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
@@ -7,23 +7,33 @@
|
||||
# ~/.dsh/source/master), adds a per-install staging worktree at
|
||||
# ~/.dsh/source/staging-<timestamp> on branch dsh-staging/<timestamp>, checks
|
||||
# host dependencies (git, Node, pnpm) and offers to install a missing pnpm, runs
|
||||
# `pnpm install` (no build — the `bin/dsh` launcher runs the TypeScript source
|
||||
# through the repo's own tsx), points the stable `~/.dsh/source/current` symlink
|
||||
# `pnpm install`, points the stable `~/.dsh/source/current` symlink
|
||||
# at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`,
|
||||
# records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot,
|
||||
# and drops you into `dsh`. Keeping every checkout under ~/.dsh/source keeps
|
||||
# successive upgrades in one place instead of scattered sibling clones, and lets
|
||||
# staging worktrees share the master clone's object store. The PATH symlink
|
||||
# resolves through `current`, so an upgrade repoints one stable symlink instead
|
||||
# of relinking PATH: the `dsh` on PATH never moves and can never dangle.
|
||||
# builds the repository artifacts, and launches the Web UI. Keeping every
|
||||
# checkout under ~/.dsh/source keeps successive
|
||||
# upgrades in one place instead of scattered sibling clones, and lets staging
|
||||
# worktrees share the master clone's object store. The PATH symlink resolves through
|
||||
# `current`, so an upgrade repoints one stable symlink instead of relinking PATH:
|
||||
# the `dsh` on PATH never moves and can never dangle.
|
||||
#
|
||||
# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather
|
||||
# than `curl ... | sh`) it reuses that checkout in place and skips the
|
||||
# clone/worktree setup, leaving the working tree untouched and linking `dsh`
|
||||
# straight at that checkout's `bin/dsh` (no `current` indirection — the checkout
|
||||
# is not a managed staging worktree under the source container); DSH_REF is
|
||||
# ignored in that mode. Setting DSH_SOURCE to a different directory opts back
|
||||
# into the normal clone/worktree path.
|
||||
# than `curl ... | sh`) it never clones and never touches that working tree;
|
||||
# DSH_REF is ignored. Instead it *adopts* the checkout: `git rev-parse
|
||||
# --git-common-dir` resolves the repository behind it (for a linked worktree that
|
||||
# is the real clone, not the worktree), and a fresh staging worktree branched
|
||||
# from the checkout's HEAD lands in the source container beside `current`. The
|
||||
# container owns staging worktrees and `current`; the clone is discovered, not
|
||||
# owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one
|
||||
# layout and stay upgradable. Adoption carries committed work only: the staging
|
||||
# worktree branches from HEAD, so uncommitted changes stay in the checkout.
|
||||
# Setting DSH_SOURCE to a different directory opts back into the normal
|
||||
# clone/worktree path.
|
||||
#
|
||||
# Adopting an arbitrary clone leaves the container not self-contained: its
|
||||
# staging worktrees hold an absolute gitdir pointer into that clone, so deleting
|
||||
# it breaks them. `git worktree list` in that clone is the record of which
|
||||
# worktrees depend on it.
|
||||
#
|
||||
# When run through `curl | sh` the script text arrives on stdin, so every
|
||||
# prompt and the final launch read the controlling terminal (/dev/tty) directly;
|
||||
@@ -36,23 +46,23 @@
|
||||
# DSH_MASTER master clone directory (default: $DSH_SOURCE/master)
|
||||
# DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current)
|
||||
# DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin)
|
||||
# DSH_HOME Harness home holding the personal config (default: ~/.dsh)
|
||||
# DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh)
|
||||
# FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript
|
||||
# entrypoint; keep this POSIX shell file as the curl/source bootstrap.
|
||||
set -eu
|
||||
|
||||
DSH_REF=${DSH_REF:-master}
|
||||
DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git}
|
||||
# DSH_SOURCE is the container directory that holds the master clone and every
|
||||
# staging worktree; DSH_MASTER is the one real clone inside it. Remember whether
|
||||
# the caller pinned the source container before defaulting it, so in-repo
|
||||
# detection only repoints an unset DSH_SOURCE.
|
||||
# DSH_SOURCE is the staging-worktree container and the default home of `current`.
|
||||
# DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE,
|
||||
# while adoption discovers an existing clone anywhere on disk. Remember whether
|
||||
# DSH_SOURCE was explicit so a different path selects clone mode.
|
||||
if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi
|
||||
DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source}
|
||||
DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master}
|
||||
# The stable symlink the PATH launcher resolves through: PATH -> current/bin/dsh
|
||||
# -> <staging>/bin/dsh. Fresh installs and upgrades repoint this one symlink; the
|
||||
# PATH launcher itself is written once and never moves. In-repo reuse ignores it.
|
||||
# The stable symlink the PATH launcher resolves through: PATH/dsh ->
|
||||
# current/bin/dsh -> <staging>/bin/dsh. Installs and upgrades repoint `current`;
|
||||
# the PATH target remains current/bin/dsh.
|
||||
DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current}
|
||||
DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin}
|
||||
# One UTC basic timestamp names this install's staging branch and worktree.
|
||||
@@ -60,26 +70,44 @@ DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP
|
||||
DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP
|
||||
|
||||
# --- path helpers ---------------------------------------------------------------
|
||||
# Every path comparison below runs on physical paths. Git always reports resolved
|
||||
# paths, so comparing one against an unresolved path disagrees whenever a symlink
|
||||
# sits anywhere above the checkout — a symlinked home directory is enough, and
|
||||
# macOS reaches every mktemp path that way through /var -> private/var. The
|
||||
# mismatch silently misclassifies an existing managed install as a foreign clone
|
||||
# and builds a second container beside the real one.
|
||||
# `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+.
|
||||
#
|
||||
# A not-yet-created directory (the container on a fresh install) has no physical
|
||||
# path. Falling back here rather than at each call site keeps every caller a
|
||||
# plain assignment, so no site can compare against an empty path by forgetting
|
||||
# its own fallback.
|
||||
resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; }
|
||||
|
||||
# --- in-repo detection ---------------------------------------------------------
|
||||
# Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell
|
||||
# name and no file path resolves; running a checked-out copy (`sh
|
||||
# scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose
|
||||
# parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present),
|
||||
# reuse that checkout in place — link `dsh` straight at it and skip the
|
||||
# clone/worktree setup. An explicit DSH_SOURCE pointing elsewhere opts back into
|
||||
# the clone/worktree path.
|
||||
# this is in-repo mode: never clone, never touch that working tree. An explicit
|
||||
# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path.
|
||||
IN_REPO=0
|
||||
DSH_CHECKOUT=''
|
||||
if [ -f "$0" ]; then
|
||||
_self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir=''
|
||||
_self_dir=$(resolve_dir "$(dirname -- "$0")")
|
||||
if [ -n "$_self_dir" ]; then
|
||||
# Physical without its own resolve_dir: dirname is textual, so trimming a
|
||||
# resolved path leaves one. The comparison below depends on that.
|
||||
_repo_root=$(dirname -- "$_self_dir")
|
||||
if [ "$(basename -- "$_self_dir")" = scripts ] \
|
||||
&& [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then
|
||||
if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then
|
||||
# Compare the explicit DSH_SOURCE physically: an unresolved but equivalent
|
||||
# path must still count as "the caller meant this checkout".
|
||||
_src_resolved=$(resolve_dir "$DSH_SOURCE")
|
||||
if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ]; then
|
||||
IN_REPO=1
|
||||
# In-repo reuse links `dsh` at this checkout as-is; the master/staging
|
||||
# split applies only to fresh clone installs.
|
||||
DSH_STAGING=$_repo_root
|
||||
DSH_CHECKOUT=$_repo_root
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -148,7 +176,7 @@ confirm() {
|
||||
|
||||
printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}"
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
printf '%ssource %s (in-repo reuse) @ %s%s\n' "$DIM" "$DSH_STAGING" "$DSH_REF" "$RST"
|
||||
printf '%scheckout %s%s\n' "$DIM" "$DSH_CHECKOUT" "$RST"
|
||||
else
|
||||
printf '%smaster %s @ %s%s\n' "$DIM" "$DSH_MASTER" "$DSH_REF" "$RST"
|
||||
printf '%sstaging %s%s\n' "$DIM" "$DSH_STAGING" "$RST"
|
||||
@@ -186,7 +214,7 @@ fi
|
||||
|
||||
# pnpm is the only dependency we offer to install for you.
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
info "pnpm $(pnpm --version 2>/dev/null) ... ok"
|
||||
info "pnpm $(pnpm --version) ... ok"
|
||||
else
|
||||
warn "pnpm is not installed."
|
||||
if confirm "Install pnpm now?" Y; then
|
||||
@@ -203,42 +231,84 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. clone the master and lay out the staging worktree ---------------------
|
||||
# Fresh installs keep one real clone at $DSH_MASTER and check the running code
|
||||
# out as a git worktree at $DSH_STAGING, so every checkout lives under
|
||||
# $DSH_SOURCE and shares one object store. In-repo reuse links `dsh` at the
|
||||
# existing checkout untouched.
|
||||
# --- 2. resolve the repository and lay out the staging worktree ---------------
|
||||
# The source container owns staging worktrees and `current`; the repository is
|
||||
# *discovered*, not owned. A curl install discovers it by cloning to $DSH_MASTER;
|
||||
# in-repo adoption discovers it from the checkout. Both then run one shared
|
||||
# worktree/exclude/lock path, so an arbitrary clone and a managed install
|
||||
# converge on the same layout.
|
||||
#
|
||||
# REPO_COMMON is the shared git directory every worktree of the repository
|
||||
# points at; REPO_ROOT is the working tree that owns it (the master clone).
|
||||
REPO_COMMON=''
|
||||
REPO_ROOT=''
|
||||
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
step "Using existing checkout at $DSH_STAGING"
|
||||
info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)"
|
||||
step "Using existing checkout at $DSH_CHECKOUT"
|
||||
info "running from inside the repo — never cloning, and DSH_REF is ignored"
|
||||
|
||||
# Resolve the repository behind the checkout. --git-common-dir returns the
|
||||
# SHARED git dir, so a linked worktree resolves to the real clone rather than
|
||||
# itself; it is relative for a plain clone, so anchor it before resolving.
|
||||
# Require the resolved git dir to exist: resolve_dir echoes its argument back
|
||||
# for a missing path, so test the directory rather than the returned string.
|
||||
if _common=$(git -C "$DSH_CHECKOUT" rev-parse --git-common-dir 2>/dev/null) && [ -n "$_common" ]; then
|
||||
case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac
|
||||
[ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common")
|
||||
fi
|
||||
[ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it."
|
||||
REPO_ROOT=$(dirname -- "$REPO_COMMON")
|
||||
|
||||
# Reuse the container when the repository already lives inside it (the normal
|
||||
# managed install re-running its own script); otherwise treat that clone as
|
||||
# its own master and keep worktrees in the default container.
|
||||
_src_resolved=$(resolve_dir "$DSH_SOURCE")
|
||||
case "$REPO_ROOT/" in
|
||||
"$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;;
|
||||
*) info "adopting clone $REPO_ROOT as its own master" ;;
|
||||
esac
|
||||
DSH_MASTER=$REPO_ROOT
|
||||
else
|
||||
step "Fetching source into $DSH_MASTER"
|
||||
if [ -d "$DSH_MASTER/.git" ]; then
|
||||
info "existing master clone found — updating"
|
||||
git -C "$DSH_MASTER" fetch origin "$DSH_REF"
|
||||
# Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not
|
||||
# origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
|
||||
# the re-run idempotent whether or not DSH_REF changed since the last install.
|
||||
git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD
|
||||
else
|
||||
mkdir -p "$DSH_SOURCE"
|
||||
git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER"
|
||||
step "Fetching source into $DSH_MASTER"
|
||||
if [ -d "$DSH_MASTER/.git" ]; then
|
||||
info "existing master clone found — updating"
|
||||
git -C "$DSH_MASTER" fetch origin "$DSH_REF"
|
||||
# Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not
|
||||
# origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
|
||||
# the re-run idempotent whether or not DSH_REF changed since the last install.
|
||||
git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD
|
||||
else
|
||||
mkdir -p "$DSH_SOURCE"
|
||||
git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER"
|
||||
fi
|
||||
# Physical on both branches: REPO_ROOT is compared against resolved paths
|
||||
# below, and REPO_COMMON stays symmetric with it so neither can be read as
|
||||
# carrying a different kind of path.
|
||||
REPO_COMMON=$(resolve_dir "$DSH_MASTER/.git")
|
||||
REPO_ROOT=$(resolve_dir "$DSH_MASTER")
|
||||
fi
|
||||
|
||||
step "Adding staging worktree at $DSH_STAGING"
|
||||
[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run."
|
||||
# The staging worktree owns the branch dsh runs from; the master clone stays on
|
||||
# $DSH_REF as the fetch/upgrade base. Exclude the per-worktree merge lock in the
|
||||
# master clone's info/exclude, which every linked worktree inherits.
|
||||
git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \
|
||||
|| git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
|
||||
_exclude="$DSH_MASTER/.git/info/exclude"
|
||||
mkdir -p "$DSH_SOURCE"
|
||||
# The staging worktree owns the branch dsh runs from; the repository stays as
|
||||
# the fetch/upgrade base and is never a launcher target. A clone install
|
||||
# branches from the ref it just fetched; adoption branches from the checkout's
|
||||
# HEAD so the contributor's committed work is what runs.
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
|
||||
else
|
||||
git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \
|
||||
|| git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
|
||||
fi
|
||||
# Exclude the per-worktree merge lock in the shared git dir's info/exclude,
|
||||
# which every linked worktree inherits.
|
||||
_exclude="$REPO_COMMON/info/exclude"
|
||||
if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then
|
||||
printf '.agents/merge.lock\n' >>"$_exclude"
|
||||
fi
|
||||
mkdir -p "$DSH_STAGING/.agents"
|
||||
: >"$DSH_STAGING/.agents/merge.lock"
|
||||
fi
|
||||
|
||||
# --- 3. install dependencies (no build; the launcher runs from source) --------
|
||||
step "Installing dependencies with pnpm (this can take a while)"
|
||||
@@ -247,29 +317,29 @@ step "Installing dependencies with pnpm (this can take a while)"
|
||||
[ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?"
|
||||
|
||||
# --- 4. put `dsh` on PATH ------------------------------------------------------
|
||||
# Clone installs go through a stable `current` symlink so an upgrade repoints
|
||||
# Every install goes through a stable `current` symlink so an upgrade repoints
|
||||
# one symlink (current -> new worktree) and the PATH launcher never moves:
|
||||
# PATH/dsh -> current/bin/dsh -> <staging>/bin/dsh. In-repo reuse links PATH
|
||||
# straight at the checkout, since that checkout is not a managed worktree.
|
||||
# PATH/dsh -> current/bin/dsh -> <staging>/bin/dsh.
|
||||
step "Linking dsh into $DSH_BIN_DIR"
|
||||
mkdir -p "$DSH_BIN_DIR"
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
DSH_LAUNCH_TARGET=$DSH_STAGING/bin/dsh
|
||||
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
|
||||
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
|
||||
else
|
||||
# Point `current` at this staging worktree with `ln -sfn`: -f replaces an
|
||||
# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing
|
||||
# an existing symlink-to-directory and dropping the new link *inside* the old
|
||||
# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir
|
||||
# symlink the same way. The swap is one unlink+symlink pair on a local fs; the
|
||||
# installer holds no other process racing this path.
|
||||
ln -sfn "$DSH_STAGING" "$DSH_CURRENT"
|
||||
info "pointed $DSH_CURRENT -> $DSH_STAGING"
|
||||
DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh
|
||||
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
|
||||
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
|
||||
fi
|
||||
# The launcher must resolve to a staging worktree, never to the repository
|
||||
# itself: an upgrade repoints `current`, so aliasing it onto the master clone
|
||||
# would make every upgrade rewrite the fetch/upgrade base. Compare physical
|
||||
# paths — a symlinked or unresolved path would slip past a string compare.
|
||||
_staging_resolved=$(resolve_dir "$DSH_STAGING")
|
||||
[ "$_staging_resolved" = "$REPO_ROOT" ] \
|
||||
&& die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree."
|
||||
# Point `current` at this staging worktree with `ln -sfn`: -f replaces an
|
||||
# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing
|
||||
# an existing symlink-to-directory and dropping the new link *inside* the old
|
||||
# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir
|
||||
# symlink the same way. The swap is one unlink+symlink pair on a local fs; the
|
||||
# installer holds no other process racing this path.
|
||||
ln -sfn "$DSH_STAGING" "$DSH_CURRENT"
|
||||
info "pointed $DSH_CURRENT -> $DSH_STAGING"
|
||||
DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh
|
||||
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
|
||||
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$DSH_BIN_DIR:"*) ON_PATH=1 ;;
|
||||
@@ -343,12 +413,15 @@ if [ "${SKIP_CREDS:-0}" != 1 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 6. launch -----------------------------------------------------------------
|
||||
# --- 6. build and launch the Web interface -------------------------------------
|
||||
step "Done"
|
||||
if [ "$HAS_TTY" = 1 ]; then
|
||||
info "launching dsh — run 'dsh' anytime to start again"
|
||||
exec "$DSH_BIN_DIR/dsh" </dev/tty
|
||||
step "Building DeepSeek Harness for Web UI"
|
||||
( cd "$DSH_STAGING" && pnpm run build )
|
||||
info "launching Web UI — run 'dsh web' anytime to start again"
|
||||
exec "$DSH_BIN_DIR/dsh" web </dev/tty
|
||||
else
|
||||
info "install complete. Start it with:"
|
||||
printf ' %s\n' "$DSH_BIN_DIR/dsh"
|
||||
info "install complete. Build and start the Web UI with:"
|
||||
printf ' (cd %s && pnpm run build)\n' "$DSH_STAGING"
|
||||
printf ' %s web\n' "$DSH_BIN_DIR/dsh"
|
||||
fi
|
||||
|
||||
@@ -47,7 +47,7 @@ function fixture(options: {
|
||||
default: './lib/invariant.js',
|
||||
},
|
||||
},
|
||||
files: ['lib/index.js', 'lib/invariant.js', 'src'],
|
||||
files: ['lib/index.js', 'lib/invariant.js'],
|
||||
peerDependencies: options.invariantDependency === false ? {} : {
|
||||
'@deepseek-ai/dsh-invariants': '^0.0.1',
|
||||
},
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/** Tests for the documentation website projection adapter. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { docsPages, type DocsPage } from '../website/docs.ts'
|
||||
import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts'
|
||||
import {
|
||||
addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown,
|
||||
} from './project-doc-site.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
const repositoryRoot = resolve(import.meta.dirname, '..')
|
||||
@@ -63,6 +65,32 @@ describe('website source layout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('publishableImage', () => {
|
||||
it('accepts a regular file inside the repository', () => {
|
||||
const { root } = fixture()
|
||||
const real = realpathSync(join(root, 'packages/logo.svg'))
|
||||
expect(publishableImage(join(root, 'packages/logo.svg'), realpathSync(root))).toBe(real)
|
||||
})
|
||||
|
||||
it('refuses a target whose real path escapes the repository', () => {
|
||||
// Publication copies the bytes onto the site, so a reference reaching a
|
||||
// build-machine file must not be treated as an image the repository owns.
|
||||
const { root } = fixture()
|
||||
const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-site-outside-'))
|
||||
roots.push(outside)
|
||||
writeFileSync(join(outside, 'secret.png'), 'not really a png\n')
|
||||
symlinkSync(join(outside, 'secret.png'), join(root, 'packages/linked.png'))
|
||||
|
||||
expect(publishableImage(join(root, 'packages/linked.png'), realpathSync(root))).toBeUndefined()
|
||||
expect(publishableImage(join(outside, 'secret.png'), realpathSync(root))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses a directory', () => {
|
||||
const { root } = fixture()
|
||||
expect(publishableImage(join(root, 'packages'), realpathSync(root))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteMarkdown', () => {
|
||||
it('maps published pages and pins unpublished source links', () => {
|
||||
const { root, pages } = fixture()
|
||||
@@ -93,7 +121,7 @@ describe('rewriteMarkdown', () => {
|
||||
})).toBe('[B](./reference-root/b.md)\n')
|
||||
})
|
||||
|
||||
it('uses raw GitHub content for unpublished images', () => {
|
||||
it('uses raw GitHub content for unpublished images when nothing places them', () => {
|
||||
const { root, pages } = fixture()
|
||||
expect(rewriteMarkdown('\n', {
|
||||
locale: 'en',
|
||||
@@ -105,6 +133,57 @@ describe('rewriteMarkdown', () => {
|
||||
})).toBe('\n')
|
||||
})
|
||||
|
||||
it('hands an image to the placer and uses the URL it returns', () => {
|
||||
// A raw GitHub URL cannot serve a private repository, so the site build
|
||||
// carries images itself; the placer is what puts them there. The stand-in
|
||||
// derives its URL the way the real one does, so a placer that stopped
|
||||
// returning the basename would fail here rather than pass on a constant.
|
||||
const { root, pages } = fixture()
|
||||
const placed: string[] = []
|
||||
expect(rewriteMarkdown('\n', {
|
||||
locale: 'en',
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
placeImage: (absPath) => {
|
||||
const name = absPath.split('/').pop() ?? ''
|
||||
placed.push(name)
|
||||
return `./${name}`
|
||||
},
|
||||
})).toBe('\n')
|
||||
expect(placed).toEqual(['logo.svg'])
|
||||
})
|
||||
|
||||
it('keeps a placed image\u2019s query or fragment', () => {
|
||||
// An SVG view fragment and a Vite query both change what the reference
|
||||
// means, and the GitHub branch has always carried them.
|
||||
const { root, pages } = fixture()
|
||||
expect(rewriteMarkdown('\n', {
|
||||
locale: 'en',
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`,
|
||||
})).toBe('\n')
|
||||
})
|
||||
|
||||
it('leaves a published page link to the route even when a placer exists', () => {
|
||||
const { root, pages } = fixture()
|
||||
expect(rewriteMarkdown('[B](b.md)\n', {
|
||||
locale: 'en',
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
placeImage: () => { throw new Error('a page link must not be placed as an asset') },
|
||||
})).toBe('[B](./reference/b.md)\n')
|
||||
})
|
||||
|
||||
it('does not rewrite Markdown-looking text inside code fences', () => {
|
||||
const { root, pages } = fixture()
|
||||
const source = '```md\n[B](b.md)\n```\n'
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
* tier, while this adapter rewrites cross-source links for the public site.
|
||||
*/
|
||||
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, extname, posix, relative, resolve, sep } from 'node:path'
|
||||
import {
|
||||
copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
@@ -38,6 +40,15 @@ export interface RewriteMarkdownOptions {
|
||||
pages: DocsPage[]
|
||||
repoRoot: string
|
||||
repositoryRef: string
|
||||
/**
|
||||
* Place one referenced image beside the projected page and return the URL to
|
||||
* reach it from that page. A GitHub raw URL cannot serve this repository —
|
||||
* `raw.githubusercontent.com` answers 404 for a private one, and no reader of
|
||||
* the site is authenticated to it — so an image travels into the generated
|
||||
* tree and Vite bundles it like any other site asset. Omitted by callers that
|
||||
* only rewrite text, which then leave images pointing at the repository.
|
||||
*/
|
||||
placeImage?: (absPath: string) => string
|
||||
}
|
||||
|
||||
function repoPath(absPath: string, repoRoot: string): string {
|
||||
@@ -222,9 +233,13 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions)
|
||||
? options.locale === 'root' ? 'en' : 'root'
|
||||
: options.locale
|
||||
const page = published.get(targetPath)?.get(targetLocale)
|
||||
const nextUrl = page === undefined
|
||||
? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
|
||||
: routeTarget(options.route, page.route, suffix)
|
||||
const nextUrl = page !== undefined
|
||||
? routeTarget(options.route, page.route, suffix)
|
||||
: node.type === 'image' && options.placeImage !== undefined
|
||||
// The suffix rides along exactly as the GitHub branch keeps it: an SVG
|
||||
// view fragment or a Vite query changes what the reference means.
|
||||
? `${options.placeImage(absPath)}${suffix}`
|
||||
: githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
|
||||
|
||||
const start = node.position?.start.offset
|
||||
const end = node.position?.end.offset
|
||||
@@ -291,17 +306,78 @@ export function projectedPageContent(markdown: string, page: DocsPage): string {
|
||||
return markdown.slice(0, closing + closingDelimiter.length)
|
||||
}
|
||||
|
||||
/** Canonical Markdown files watched by the local VitePress dev server. */
|
||||
/**
|
||||
* The repository file one image reference resolves to, or `undefined` when the
|
||||
* target is not a local file this build may publish.
|
||||
* @param absPath - resolved image target.
|
||||
* @param repoRoot - repository root every published image must stay inside.
|
||||
* @returns the file's real path, or `undefined` when it must not be copied.
|
||||
*
|
||||
* Only a regular file whose real path stays inside the repository qualifies.
|
||||
* Publication copies the bytes into the site, so a reference escaping the
|
||||
* repository — `../../.ssh/id_rsa`, or a symlink pointing out of the tree —
|
||||
* would put a build-machine file on the site; `existsSync` alone, which is all
|
||||
* link resolution needs, does not answer that.
|
||||
*/
|
||||
export function publishableImage(absPath: string, repoRoot: string): string | undefined {
|
||||
const real = realpathSync(absPath)
|
||||
const inside = real === repoRoot || real.startsWith(`${repoRoot}${sep}`)
|
||||
return inside && statSync(real).isFile() ? real : undefined
|
||||
}
|
||||
|
||||
/** Every local image a published page references, resolved to its repository file. */
|
||||
function referencedImages(): string[] {
|
||||
const found = new Set<string>()
|
||||
for (const page of docsPages) {
|
||||
const sourceAbs = resolve(root, page.source)
|
||||
if (!existsSync(sourceAbs)) continue
|
||||
rewriteMarkdown(readFileSync(sourceAbs, 'utf8'), {
|
||||
sourcePath: page.source,
|
||||
locale: page.locale,
|
||||
route: page.route,
|
||||
pages: docsPages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'master',
|
||||
placeImage: (absPath) => {
|
||||
const real = publishableImage(absPath, root)
|
||||
if (real !== undefined) found.add(real)
|
||||
return ''
|
||||
},
|
||||
})
|
||||
}
|
||||
return [...found]
|
||||
}
|
||||
|
||||
/**
|
||||
* Files watched by the local VitePress dev server: every canonical Markdown
|
||||
* source, plus the images they publish. Without the images, replacing a
|
||||
* screenshot leaves the previous copy in the generated tree until something
|
||||
* touches the Markdown beside it.
|
||||
*/
|
||||
export function docsSourceFiles(): string[] {
|
||||
return [...new Set(docsPages.map(page => resolve(root, page.source)))]
|
||||
return [...new Set([...docsPages.map(page => resolve(root, page.source)), ...referencedImages()])]
|
||||
}
|
||||
|
||||
/** Rebuild the disposable VitePress source tree from the publication manifest. */
|
||||
export function projectDocs(): void {
|
||||
const routes = new Set<string>()
|
||||
/** Projected path to the repository file that claimed it, pages and images alike. */
|
||||
const claimed = new Map<string, string>()
|
||||
const repositoryRef = process.env.GITHUB_SHA ?? 'master'
|
||||
rmSync(generatedRoot, { recursive: true, force: true })
|
||||
|
||||
/** Reserve one projected path, refusing a second source for it. */
|
||||
const claim = (target: string, sourceAbs: string): void => {
|
||||
const holder = claimed.get(target)
|
||||
if (holder !== undefined && holder !== sourceAbs) {
|
||||
throw new Error(
|
||||
`project-doc-site: ${repoPath(sourceAbs, root)} and ${repoPath(holder, root)}`
|
||||
+ ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`,
|
||||
)
|
||||
}
|
||||
claimed.set(target, sourceAbs)
|
||||
}
|
||||
|
||||
for (const page of docsPages) {
|
||||
if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
|
||||
routes.add(page.route)
|
||||
@@ -310,6 +386,9 @@ export function projectDocs(): void {
|
||||
throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`)
|
||||
}
|
||||
const output = resolve(generatedRoot, page.route)
|
||||
// Claimed before the images are placed: a page and an image landing on one
|
||||
// path would otherwise overwrite each other in whichever order they ran.
|
||||
claim(output, sourceAbs)
|
||||
mkdirSync(dirname(output), { recursive: true })
|
||||
const markdown = readFileSync(sourceAbs, 'utf8')
|
||||
const projected = rewriteMarkdown(markdown, {
|
||||
@@ -319,6 +398,25 @@ export function projectDocs(): void {
|
||||
pages: docsPages,
|
||||
repoRoot: root,
|
||||
repositoryRef,
|
||||
placeImage: (absPath) => {
|
||||
const real = publishableImage(absPath, root)
|
||||
if (real === undefined) {
|
||||
throw new Error(
|
||||
`project-doc-site: ${page.source} references image ${repoPath(absPath, root)},`
|
||||
+ ' which is not a regular file inside the repository.',
|
||||
)
|
||||
}
|
||||
// Beside the page that references it, under its own basename: each
|
||||
// locale's route tree gets its own copy, so one relative URL is correct
|
||||
// from both.
|
||||
const name = basename(real)
|
||||
const target = resolve(dirname(output), name)
|
||||
claim(target, real)
|
||||
copyFileSync(real, target)
|
||||
// Encoded because the destination is a Markdown inline target, where an
|
||||
// unescaped space would end it early.
|
||||
return `./${encodeURI(name)}`
|
||||
},
|
||||
})
|
||||
writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page))
|
||||
}
|
||||
|
||||
83
scripts/publication-payload.spec.ts
Normal file
83
scripts/publication-payload.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasTypeRTRemoteNavigation,
|
||||
isForbiddenPublicationFile,
|
||||
validateTarballPayload,
|
||||
} from './publication-payload.ts'
|
||||
|
||||
function validateFixtureTarball(files: readonly string[]): () => void {
|
||||
return () => {
|
||||
validateTarballPayload(files, 'fixture.tgz')
|
||||
}
|
||||
}
|
||||
|
||||
describe('publication payload policy', () => {
|
||||
it.each([
|
||||
'lib/index.js',
|
||||
'lib/types/index.d.ts',
|
||||
'lib/styles/base.css',
|
||||
])('accepts %s', (file) => {
|
||||
expect(isForbiddenPublicationFile(file)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'src',
|
||||
'./src',
|
||||
'src/',
|
||||
'src/index.ts',
|
||||
'./src/index.ts',
|
||||
String.raw`src\index.ts`,
|
||||
'lib/types/index.d.ts.map',
|
||||
'./lib/types/index.d.ts.map',
|
||||
])('rejects static manifest path %s', (file) => {
|
||||
expect(isForbiddenPublicationFile(file)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects source members in packed tarballs', () => {
|
||||
expect(validateFixtureTarball([
|
||||
'package/package.json',
|
||||
'package/src/index.ts',
|
||||
])).toThrow('fixture.tgz publishes source file package/src/index.ts')
|
||||
})
|
||||
|
||||
it('rejects declaration maps in packed tarballs', () => {
|
||||
expect(validateFixtureTarball([
|
||||
'package/package.json',
|
||||
'package/lib/types/index.d.ts.map',
|
||||
])).toThrow('fixture.tgz publishes declaration map package/lib/types/index.d.ts.map')
|
||||
})
|
||||
|
||||
it('accepts a clean packed tarball', () => {
|
||||
expect(validateFixtureTarball([
|
||||
'package/package.json',
|
||||
'package/lib/index.js',
|
||||
'package/lib/types/index.d.ts',
|
||||
'package/lib/styles/base.css',
|
||||
])).not.toThrow()
|
||||
})
|
||||
|
||||
it('allows only the TypeRT declaration map and its navigable source tree when requested', () => {
|
||||
const policy = { typeRTRemoteNavigation: true }
|
||||
expect(isForbiddenPublicationFile('src/index.ts', policy)).toBe(false)
|
||||
expect(isForbiddenPublicationFile('lib/typert.remote-client.d.ts.map', policy)).toBe(false)
|
||||
expect(isForbiddenPublicationFile('lib/types/index.d.ts.map', policy)).toBe(true)
|
||||
expect(() => {
|
||||
validateTarballPayload([
|
||||
'package/lib/typert.remote-client.d.ts.map',
|
||||
'package/src/index.ts',
|
||||
], 'fixture.tgz', policy)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('recognizes only the canonical Host-for-Client export pair', () => {
|
||||
expect(hasTypeRTRemoteNavigation({
|
||||
exports: {
|
||||
'./remote': {
|
||||
types: './lib/typert.remote-client.d.ts',
|
||||
default: './lib/typert.remote-client.js',
|
||||
},
|
||||
},
|
||||
})).toBe(true)
|
||||
expect(hasTypeRTRemoteNavigation({ exports: { './remote': './lib/remote.js' } })).toBe(false)
|
||||
})
|
||||
})
|
||||
57
scripts/publication-payload.ts
Normal file
57
scripts/publication-payload.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** Publication payload policy shared by static manifests and packed tarballs. */
|
||||
|
||||
/** Publication exceptions required for TypeRT declaration-map navigation. */
|
||||
export interface PublicationPayloadPolicy {
|
||||
readonly typeRTRemoteNavigation?: boolean
|
||||
}
|
||||
|
||||
/** Whether a package manifest exports generated Host-for-Client metadata with source navigation. */
|
||||
export function hasTypeRTRemoteNavigation(manifest: unknown): boolean {
|
||||
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return false
|
||||
const exportsField = (manifest as Record<string, unknown>).exports
|
||||
if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false
|
||||
const remote = (exportsField as Record<string, unknown>)['./remote']
|
||||
if (remote === null || typeof remote !== 'object' || Array.isArray(remote)) return false
|
||||
const entry = remote as Record<string, unknown>
|
||||
return entry.types === './lib/typert.remote-client.d.ts'
|
||||
&& entry.default === './lib/typert.remote-client.js'
|
||||
}
|
||||
|
||||
/** Normalize a package manifest path or npm tarball member to its payload-relative path. */
|
||||
function payloadPath(file: string): string {
|
||||
const normalized = file.replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/\/+$/, '')
|
||||
return normalized.startsWith('package/') ? normalized.slice('package/'.length) : normalized
|
||||
}
|
||||
|
||||
/** Whether a package payload path exposes source or declaration-map intermediates. */
|
||||
export function isForbiddenPublicationFile(
|
||||
file: string,
|
||||
policy: PublicationPayloadPolicy = {},
|
||||
): boolean {
|
||||
const normalized = payloadPath(file)
|
||||
if (policy.typeRTRemoteNavigation === true
|
||||
&& (normalized === 'src'
|
||||
|| normalized.startsWith('src/')
|
||||
|| normalized === 'lib/typert.remote-client.d.ts.map')) {
|
||||
return false
|
||||
}
|
||||
return normalized === 'src'
|
||||
|| normalized.startsWith('src/')
|
||||
|| normalized.endsWith('.d.ts.map')
|
||||
}
|
||||
|
||||
/** Reject source and declaration-map members in a packed npm tarball. */
|
||||
export function validateTarballPayload(
|
||||
files: readonly string[],
|
||||
context: string,
|
||||
policy: PublicationPayloadPolicy = {},
|
||||
): void {
|
||||
for (const file of files) {
|
||||
if (!isForbiddenPublicationFile(file, policy)) continue
|
||||
const normalized = payloadPath(file)
|
||||
if (normalized === 'src' || normalized.startsWith('src/')) {
|
||||
throw new Error(`${context} publishes source file ${file}`)
|
||||
}
|
||||
throw new Error(`${context} publishes declaration map ${file}`)
|
||||
}
|
||||
}
|
||||
1085
scripts/publish-npm-baseline.ts
Normal file
1085
scripts/publish-npm-baseline.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -189,6 +189,12 @@ describe('Node 24 lane ownership', () => {
|
||||
expect(subject.find(item => item.id === 'doc-typecheck')?.env).toEqual({
|
||||
DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1',
|
||||
})
|
||||
expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual(
|
||||
expect.arrayContaining([
|
||||
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||
]),
|
||||
)
|
||||
expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({
|
||||
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
|
||||
env: { DSH_SNAPSHOT: 'replay' },
|
||||
|
||||
@@ -224,6 +224,7 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
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('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
snapshotGate(),
|
||||
pnpmScript('build', 'build'),
|
||||
@@ -240,12 +241,19 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
}
|
||||
}
|
||||
|
||||
function ciPrimaryGates(): Gate[] {
|
||||
function ciSharedStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
]
|
||||
}
|
||||
|
||||
function ciPrimaryGates(): Gate[] {
|
||||
return [
|
||||
...ciSharedStaticGates(),
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
@@ -339,10 +347,7 @@ function runningNodeMajor(): number {
|
||||
|
||||
function ciStaticGates(options: { ownsBuild: boolean }): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
...ciSharedStaticGates(),
|
||||
...options.ownsBuild ? [pnpmScript('build', 'build')] : [],
|
||||
...docSyncLeafGates({
|
||||
includeDocTypecheck: options.ownsBuild,
|
||||
@@ -594,10 +599,14 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
||||
'--config',
|
||||
'vitest.e2e.config.ts',
|
||||
'examples/headless-agent/tests/keyless-smoke.e2e.ts',
|
||||
'apps/cli/tests/tui-keyless-smoke.e2e.ts',
|
||||
'apps/cli/tests/built-bin.e2e.ts',
|
||||
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
|
||||
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
||||
'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
|
||||
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
|
||||
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||
'packages/api/remotes/tests/built-lib.e2e.ts',
|
||||
// The worker-entry packages' built bundles: the only automated proof
|
||||
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
||||
// (the e2e lane runs unbuilt, so these files self-skip there).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
/** Unit tests for the prompt-v4 renderer and three-section response parser. */
|
||||
/** Unit tests for the prompt-v7 content and unchanged three-section protocol. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -15,6 +15,20 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const document = readFileSync(join(root, 'docs/i18n/translation-prompt.md'), 'utf8')
|
||||
const terminology = '| English | 中文 |\n|---|---|\n| agent | agent |'
|
||||
|
||||
const retainedExamples = [
|
||||
['### Colloquial verb → Professional verb', 'The repo pins pnpm@11.7.0 in package.json', '该仓库在 package.json 中固定使用 pnpm@11.7.0'],
|
||||
['### Run-on sentence → Natural phrasing with pause', 'Read docs/architecture.md before changing anything under packages/.', '在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。'],
|
||||
['### Stiff passive voice → Active and natural', 'a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.', '门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。'],
|
||||
['### Invented word → Natural expression', 'A sidecar record of both blob hashes makes consistency checkable', '伴随记录保存两侧 blob hash,使一致性可检查'],
|
||||
['### Em-dash → Colon/period', 'FIXME — an issue that should block a new release.', 'FIXME:应当阻塞新版本发布的问题。'],
|
||||
['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to hear without the source anchoring you', '不对照原文时,更容易察觉别扭的表达'],
|
||||
['### Terminology — do not translate what should be kept in English', 'typed service seams, and explicit extension points', '类型化的服务 seam 与显式扩展点'],
|
||||
['### Slang/jargon → Professional phrasing', 'The committed agent workflow lives in .agents/skills/dsh-translate-docs', '仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs'],
|
||||
['### "For humans" — translate the intent, not the word', 'For humans, start with the development guide', '面向开发者:请先阅读开发指南'],
|
||||
['### Code block comments — NEVER translate', '# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)', 'keep exactly as-is, byte-for-byte'],
|
||||
['### Language switcher — flip direction', 'English | [中文](README.zh.md)', '[English](README.md) | 中文'],
|
||||
]
|
||||
|
||||
describe('translation prompt rendering', () => {
|
||||
it('renders both directions with every placeholder resolved', () => {
|
||||
const en = renderTranslationPrompt(document, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })
|
||||
@@ -22,15 +36,31 @@ describe('translation prompt rendering', () => {
|
||||
expect(en).toContain(terminology)
|
||||
expect(en).not.toContain('{{')
|
||||
expect(en).toContain('plain source stays plain (必须)')
|
||||
expect(en).toContain('When the target language is English, use the "English" column without a Chinese gloss')
|
||||
expect(en).toContain('for a Chinese target, use an established Chinese rendering')
|
||||
expect(en).toContain('for an English target, use the established English technical term')
|
||||
expect(en).toContain('does an English target use established English terminology')
|
||||
expect(en).toContain('For an English target, use the established English technical term')
|
||||
expect(en).toContain('does a Chinese target use an established Chinese rendering')
|
||||
expect(en).toContain('does an English target use the established English technical term')
|
||||
expect(en).toContain('The parser removes exactly one framing escape')
|
||||
const zh = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', sourceFilename: 'guide.zh.md', terminology })
|
||||
expect(zh).toContain('from Chinese to English')
|
||||
})
|
||||
|
||||
it('retains every v4 embedded example', () => {
|
||||
for (const example of retainedExamples) {
|
||||
for (const fragment of example) expect(document).toContain(fragment)
|
||||
}
|
||||
})
|
||||
|
||||
it('states the selected v7 safeguards', () => {
|
||||
const rendered = renderTranslationPrompt(document, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })
|
||||
expect(rendered).toContain('## Priority')
|
||||
expect(rendered).toContain('### Faithfulness')
|
||||
expect(rendered).toContain('do not invent a filename or switcher')
|
||||
expect(rendered).toContain('Markdown emphasis markers do not create a word boundary')
|
||||
expect(rendered).toContain('Never invent responsibility merely to avoid a passive construction')
|
||||
expect(rendered).toContain('Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety')
|
||||
expect(rendered).toContain('Return exactly three raw XML sections')
|
||||
})
|
||||
|
||||
it('rejects a template with unknown or missing placeholders', () => {
|
||||
const alien = document.replaceAll('{{terminology}}', '{{terms_prompt}}')
|
||||
expect(() => renderTranslationPrompt(alien, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/unsupported placeholder/)
|
||||
|
||||
@@ -26,6 +26,21 @@
|
||||
"symbol": "MessageSourceMap",
|
||||
"source": "packages/llm/llm/src/message.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "ContextForm",
|
||||
"source": "packages/llm/llm/src/message.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "ContextSnapshotSection",
|
||||
"source": "packages/llm/llm/src/message.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "ContextFormed",
|
||||
"source": "packages/llm/llm/src/message.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "FinishReasonMap",
|
||||
@@ -46,6 +61,16 @@
|
||||
"symbol": "LlmModelInfo",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "LlmModelDiscoveryRequest",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "LlmDiscoveredModel",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "LlmModelContext",
|
||||
@@ -98,33 +123,8 @@
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "SendTarget",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "InboxPlacement",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "InboxItem",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "InboxAction",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "InboxActionResult",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "SendOptions",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
"symbol": "InboxTarget",
|
||||
"source": "packages/core/agent/src/inbox.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
@@ -134,7 +134,7 @@
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "AgentCancelCause",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
@@ -143,7 +143,7 @@
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "PromptDecision",
|
||||
"symbol": "PreStepDecision",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
@@ -151,11 +151,6 @@
|
||||
"symbol": "RequestErrorAction",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "RequestError",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "SessionStartSource",
|
||||
@@ -204,7 +199,7 @@
|
||||
{
|
||||
"doc": "docs/core-data-structures/goal.md",
|
||||
"symbol": "GoalView",
|
||||
"source": "packages/goal/goal/src/domain.ts"
|
||||
"source": "packages/goal/goal/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/goal.md",
|
||||
@@ -224,12 +219,12 @@
|
||||
{
|
||||
"doc": "docs/core-data-structures/goal.md",
|
||||
"symbol": "CreateGoalRequest",
|
||||
"source": "packages/goal/goal/src/domain.ts"
|
||||
"source": "packages/goal/goal/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/goal.md",
|
||||
"symbol": "EditGoalRequest",
|
||||
"source": "packages/goal/goal/src/domain.ts"
|
||||
"source": "packages/goal/goal/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/goal.md",
|
||||
@@ -370,7 +365,7 @@
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "TurnTriggerMap",
|
||||
"symbol": "TurnEndCancelCause",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
@@ -424,6 +419,32 @@
|
||||
"symbol": "CreateSessionOptions",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.md",
|
||||
"symbol": "RestoredSessionOptions",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.md",
|
||||
"symbol": "PrepareSessionOptions",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.md",
|
||||
"symbol": "SessionPreparationOptions",
|
||||
"source": "packages/core/session/src/preparation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.md",
|
||||
"symbol": "SessionPreparation",
|
||||
"source": "packages/core/session/src/preparation.ts",
|
||||
"projection": "public-api"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.md",
|
||||
"symbol": "SessionInspection",
|
||||
"source": "packages/session-persistence/session-persistence/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.md",
|
||||
"symbol": "SessionLocation",
|
||||
@@ -914,6 +935,11 @@
|
||||
"symbol": "SandboxPolicyRequest",
|
||||
"source": "packages/sandbox/sandbox-policy/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/sandbox.md",
|
||||
"symbol": "RunnerFailureRule",
|
||||
"source": "packages/sandbox/sandbox/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/sandbox.md",
|
||||
"symbol": "ConfinedArgv",
|
||||
@@ -1119,6 +1145,51 @@
|
||||
"symbol": "SubagentStartRequest",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ResolvedSubagentStartRequest",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "CoordinatorMessageSource",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentReportMessageSource",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentReportDelivery",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentReportOptions",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentFollowupOptions",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ContinuableStart",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ContinuableCreateRequest",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ContinuableCreateSpec",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentResult",
|
||||
@@ -1448,6 +1519,66 @@
|
||||
"doc": "docs/core-data-structures/settings.md",
|
||||
"symbol": "SettingsPathOp",
|
||||
"source": "packages/settings/settings/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "TypeRTLookupMap",
|
||||
"source": "packages/typert/type-meta/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "TypeRTContextMap",
|
||||
"source": "packages/typert/type-meta/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "TypeRTLookupDefinition",
|
||||
"source": "packages/typert/type-meta/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "TypeRTCodec",
|
||||
"source": "packages/typert/type-meta/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "InvocationParameterDescriptor",
|
||||
"source": "packages/typert/type-meta/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "InvocationDescriptor",
|
||||
"source": "packages/typert/type-meta/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "TypeRTService",
|
||||
"source": "packages/typert/type-meta/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "TypeRTRemoteNamespaceMap",
|
||||
"source": "packages/typert/type-meta/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "InvokeRemoteRequest",
|
||||
"source": "packages/api/gateway/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "TypertGatewayErrorCode",
|
||||
"source": "packages/api/gateway/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "TypertGateway",
|
||||
"source": "packages/api/gateway/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/typert.md",
|
||||
"symbol": "TypeRTClientRemote",
|
||||
"source": "packages/typert/type-meta/src/types.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -149,11 +149,33 @@ function validateExampleResolution(): string[] {
|
||||
}
|
||||
|
||||
function validateAppResolution(): string[] {
|
||||
const dependencies = readManifest('apps/cli/package.json').dependencies ?? {}
|
||||
const violations: string[] = []
|
||||
// App overlays (and any config left under apps/cli/config) resolve from the
|
||||
// dsh app's own dependency surface — the profile module fallback mirrors it.
|
||||
const appDependencies = {
|
||||
...readManifest('apps/cli/package.json').dependencies,
|
||||
// The fallback also links every bundle's own dependencies (healProfilesModuleFallback).
|
||||
...Object.fromEntries(globSync('packages/bundle/*/package.json', { cwd: root })
|
||||
.flatMap(file => Object.entries(readManifest(file).dependencies ?? {}))),
|
||||
}
|
||||
const shipped = new Set(globSync('*.cordis.yml', { cwd: resolve(root, 'apps/cli/config') })
|
||||
.map(file => `apps/cli/config/${file}`))
|
||||
const references = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file))
|
||||
return missingPluginDependencies(references, dependencies, 'apps/cli/package.json')
|
||||
const appReferences = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file))
|
||||
violations.push(...missingPluginDependencies(appReferences, appDependencies, 'apps/cli/package.json or a bundle manifest'))
|
||||
// Each bundle's patch rows must resolve from that bundle's own dependencies:
|
||||
// per-layer resolution anchors on the bundle package directory.
|
||||
for (const manifestPath of globSync('packages/bundle/*/package.json', { cwd: root })) {
|
||||
const bundleDir = manifestPath.replace(/\/package\.json$/, '')
|
||||
const manifest = readManifest(manifestPath)
|
||||
const references = pluginReferences.filter(reference => reference.file.startsWith(`${bundleDir}/`))
|
||||
violations.push(...missingPluginDependencies(
|
||||
// A bundle may mount its own package (the web-app runtime row).
|
||||
references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name),
|
||||
manifest.dependencies ?? {},
|
||||
manifestPath,
|
||||
))
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,7 +44,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' },
|
||||
'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' },
|
||||
'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
|
||||
'packages/bash/bash-env': { kind: 'indirect', reason: 'The env service surfaces managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' },
|
||||
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
|
||||
'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' },
|
||||
'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/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
|
||||
@@ -57,10 +59,12 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers no model surface.' },
|
||||
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and identity policy; selected services own any model-visible effect.' },
|
||||
'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-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'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.' },
|
||||
@@ -86,6 +90,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' },
|
||||
'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' },
|
||||
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
|
||||
'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers no model surface.' },
|
||||
'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model surface.' },
|
||||
'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base/web bundles.' },
|
||||
'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.' },
|
||||
@@ -121,6 +128,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' },
|
||||
'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' },
|
||||
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
|
||||
'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' },
|
||||
'packages/typert/type-meta': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers no model surface.' },
|
||||
'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' },
|
||||
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' },
|
||||
'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' },
|
||||
|
||||
72
scripts/verify-vendored-links.ts
Normal file
72
scripts/verify-vendored-links.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Verify that pnpm-lock.yaml resolves every vendored package name to its
|
||||
* workspace `link:` — never a registry copy. `linkWorkspacePackages: true`
|
||||
* (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the
|
||||
* pinned vendored sources; a registry copy of the same name coexisting with
|
||||
* the vendored one silently forks the framework layer (vendor/README.md).
|
||||
*/
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
async function vendoredNames(): Promise<Set<string>> {
|
||||
const names = new Set<string>()
|
||||
for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
let manifest: { name?: string }
|
||||
try {
|
||||
manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string }
|
||||
} catch {
|
||||
continue // not a package directory (e.g. vendor/README.md siblings)
|
||||
}
|
||||
if (manifest.name !== undefined) names.add(manifest.name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
interface Lockfile {
|
||||
importers?: Record<string, Record<string, unknown>>
|
||||
packages?: Record<string, unknown>
|
||||
snapshots?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const names = await vendoredNames()
|
||||
if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/')
|
||||
const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile
|
||||
|
||||
const violations: string[] = []
|
||||
|
||||
// Importer resolutions: every dependency entry naming a vendored package must
|
||||
// resolve to a link:, or the build silently uses a registry copy.
|
||||
for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) {
|
||||
for (const [section, dependencies] of Object.entries(sections)) {
|
||||
if (typeof dependencies !== 'object' || dependencies === null) continue
|
||||
for (const [dependency, entry] of Object.entries(dependencies as Record<string, { version?: string }>)) {
|
||||
if (!names.has(dependency)) continue
|
||||
const version = entry.version ?? ''
|
||||
if (!version.startsWith('link:')) {
|
||||
violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Package/snapshot keys: a registry copy materializes as a `<name>@<version>`
|
||||
// key; vendored names must never appear there at all.
|
||||
for (const section of ['packages', 'snapshots'] as const) {
|
||||
for (const key of Object.keys(lockfile[section] ?? {})) {
|
||||
const atIndex = key.lastIndexOf('@')
|
||||
if (atIndex <= 0) continue
|
||||
const packageName = key.slice(0, atIndex)
|
||||
if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`)
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`)
|
||||
for (const violation of violations) console.error(` - ${violation}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`)
|
||||
@@ -204,11 +204,14 @@ cat "$scratch/logs/smoke.log"
|
||||
grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; }
|
||||
|
||||
# ---- the two blocking surfaces, concurrently ------------------------------
|
||||
# The same shape run-gates gives ci-windows-blocking on native Windows:
|
||||
# `build` = tsc -b then tsdown, `production site` = the VitePress build. Both
|
||||
# statuses are captured so one failure cannot hide the other's result.
|
||||
# The build preserves the face order from package.json: generate Host contracts
|
||||
# before either aggregate typecheck, then bundle the completed workspace.
|
||||
# Both statuses are captured so one failure cannot hide the other's result.
|
||||
build_gate() {
|
||||
wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $?
|
||||
wine_node "$scratch/logs/contracts-tsc.log" "$tsc_js" -b packages/typert/generator --pretty false || return $?
|
||||
wine_node "$scratch/logs/contracts-tsdown.log" "$tsdown_js" --config tsdown.typert-host.config.ts || return $?
|
||||
wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $?
|
||||
wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $?
|
||||
wine_node "$scratch/logs/tsdown.log" "$tsdown_js"
|
||||
}
|
||||
site_gate() {
|
||||
@@ -235,7 +238,12 @@ report() {
|
||||
for log in "$@"; do tail -n 200 "$log" >&2 || true; done
|
||||
fi
|
||||
}
|
||||
report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log"
|
||||
report 'build (contract prepass, tsc, tsdown)' "$build_status" \
|
||||
"$scratch/logs/contracts-tsc.log" \
|
||||
"$scratch/logs/contracts-tsdown.log" \
|
||||
"$scratch/logs/host-tsc.log" \
|
||||
"$scratch/logs/client-tsc.log" \
|
||||
"$scratch/logs/tsdown.log"
|
||||
report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log"
|
||||
if (( build_status != 0 )); then exit "$build_status"; fi
|
||||
exit "$site_status"
|
||||
|
||||
Reference in New Issue
Block a user