Prechádzať zdrojové kódy

feat: launch dsh source with native TypeScript

imccyu 2 mesiacov pred
rodič
commit
db3b12a0f7

+ 199 - 0
apps/cli/src/tsconfig-paths-loader.ts

@@ -0,0 +1,199 @@
+/**
+ * Node module resolve hook for the `dsh` source launcher. It projects the root
+ * tsconfig `paths` map into Node resolution while leaving all TypeScript syntax
+ * handling to Node's native transform-types runtime.
+ * @module @deepseek-ai/dsh/tsconfig-paths-loader
+ */
+
+import { readFile, stat } from 'node:fs/promises'
+import { dirname, extname, join, resolve } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import type { ResolveHookContext, ResolveFnOutput } from 'node:module'
+import ts from 'typescript'
+
+interface LoaderData {
+  tsconfigPath: string
+}
+
+interface PackageManifest {
+  name?: string
+  dependencies?: Record<string, string>
+  optionalDependencies?: Record<string, string>
+  peerDependencies?: Record<string, string>
+}
+
+interface PathRule {
+  pattern: string
+  prefix: string
+  suffix: string
+  targets: readonly string[]
+}
+
+const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const
+
+/** Resolve package imports through one parsed tsconfig paths table. */
+export class TsconfigPathsResolver {
+  private readonly rules: readonly PathRule[]
+  private readonly configDirectory: string
+  private readonly manifests = new Map<string, Promise<PackageManifest | undefined>>()
+
+  private constructor(tsconfigPath: string, paths: ts.MapLike<string[]>) {
+    this.configDirectory = dirname(tsconfigPath)
+    this.rules = Object.entries(paths)
+      .map(([pattern, targets]) => {
+        const wildcard = pattern.indexOf('*')
+        return {
+          pattern,
+          prefix: wildcard === -1 ? pattern : pattern.slice(0, wildcard),
+          suffix: wildcard === -1 ? '' : pattern.slice(wildcard + 1),
+          targets,
+        }
+      })
+      .sort((left, right) => {
+        const leftExact = left.pattern.includes('*') ? 0 : 1
+        const rightExact = right.pattern.includes('*') ? 0 : 1
+        return rightExact - leftExact || right.prefix.length - left.prefix.length || right.suffix.length - left.suffix.length
+      })
+  }
+
+  /**
+   * Parse a tsconfig including its `extends` chain.
+   * @param tsconfigPath Absolute tsconfig path supplying `compilerOptions.paths`.
+   * @returns A resolver backed by that path table.
+   */
+  static create(tsconfigPath: string): TsconfigPathsResolver {
+    let unrecoverable: ts.Diagnostic | undefined
+    const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, {
+      ...ts.sys,
+      onUnRecoverableConfigFileDiagnostic(diagnostic) { unrecoverable = diagnostic },
+    })
+    if (parsed === undefined) {
+      const detail = unrecoverable === undefined
+        ? 'unknown configuration error'
+        : ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n')
+      throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`)
+    }
+    const paths = parsed.options.paths
+    if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`)
+    return new TsconfigPathsResolver(tsconfigPath, paths)
+  }
+
+  /**
+   * Resolve one bare package specifier to a source file when the importing
+   * package (or config-directory owner) declares that package at runtime.
+   * @param specifier Module specifier passed to Node.
+   * @param parentURL Importing file or Loader config-directory URL.
+   * @returns Source file URL, or `undefined` when normal Node resolution owns the request.
+   */
+  async resolve(specifier: string, parentURL: string | undefined): Promise<string | undefined> {
+    const packageName = packageNameFromSpecifier(specifier)
+    if (packageName === undefined || parentURL === undefined || !parentURL.startsWith('file:')) return undefined
+    const matched = this.match(specifier)
+    if (matched === undefined) return undefined
+    const configParent = parentURL.endsWith('/')
+    const parentPath = fileURLToPath(parentURL)
+    const startDirectory = configParent ? parentPath : dirname(parentPath)
+    if (!await this.isDeclaredRuntimeDependency(startDirectory, packageName, configParent)) return undefined
+
+    for (const target of matched.targets) {
+      const substituted = target.replace('*', matched.wildcard)
+      const candidate = await existingSourcePath(resolve(this.configDirectory, substituted))
+      if (candidate !== undefined) return pathToFileURL(candidate).href
+    }
+    return undefined
+  }
+
+  private match(specifier: string): { targets: readonly string[]; wildcard: string } | undefined {
+    for (const rule of this.rules) {
+      if (!rule.pattern.includes('*')) {
+        if (specifier === rule.pattern) return { targets: rule.targets, wildcard: '' }
+        continue
+      }
+      if (!specifier.startsWith(rule.prefix) || !specifier.endsWith(rule.suffix)) continue
+      const wildcard = specifier.slice(rule.prefix.length, specifier.length - rule.suffix.length)
+      return { targets: rule.targets, wildcard }
+    }
+    return undefined
+  }
+
+  private async isDeclaredRuntimeDependency(
+    startDirectory: string,
+    packageName: string,
+    searchAncestors: boolean,
+  ): Promise<boolean> {
+    for (let directory = startDirectory; ; directory = dirname(directory)) {
+      const manifest = await this.readManifest(join(directory, 'package.json'))
+      if (manifest !== undefined) {
+        if (declaresRuntimeDependency(manifest, packageName)) return true
+        if (!searchAncestors) return false
+      }
+      const parent = dirname(directory)
+      if (parent === directory) return false
+    }
+  }
+
+  private readManifest(path: string): Promise<PackageManifest | undefined> {
+    let pending = this.manifests.get(path)
+    if (pending !== undefined) return pending
+    pending = readFile(path, 'utf8').then(
+      content => JSON.parse(content) as PackageManifest,
+      (error: unknown) => {
+        if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
+        throw error
+      },
+    )
+    this.manifests.set(path, pending)
+    return pending
+  }
+}
+
+let resolver: TsconfigPathsResolver | undefined
+
+/** Initialize the hook worker from the source-launch preloader. */
+export function initialize(data: LoaderData): void {
+  resolver = TsconfigPathsResolver.create(data.tsconfigPath)
+}
+
+/** Resolve declared workspace packages to source and delegate every other request to Node. */
+export async function resolveHook(
+  specifier: string,
+  context: ResolveHookContext,
+  nextResolve: (specifier: string, context: ResolveHookContext) => Promise<ResolveFnOutput>,
+): Promise<ResolveFnOutput> {
+  const url = await resolver?.resolve(specifier, context.parentURL)
+  return url === undefined ? nextResolve(specifier, context) : { url, shortCircuit: true }
+}
+
+// Node customization hooks discover this exact export name.
+export { resolveHook as resolve }
+
+function packageNameFromSpecifier(specifier: string): string | undefined {
+  if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || specifier.startsWith('file:')) {
+    return undefined
+  }
+  const segments = specifier.split('/')
+  return specifier.startsWith('@')
+    ? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
+    : segments[0] || undefined
+}
+
+function declaresRuntimeDependency(manifest: PackageManifest, packageName: string): boolean {
+  return manifest.name === packageName
+    || packageName in (manifest.dependencies ?? {})
+    || packageName in (manifest.optionalDependencies ?? {})
+    || packageName in (manifest.peerDependencies ?? {})
+}
+
+async function existingSourcePath(base: string): Promise<string | undefined> {
+  const candidates = extname(base) === ''
+    ? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))]
+    : [base]
+  for (const candidate of candidates) {
+    try {
+      if ((await stat(candidate)).isFile()) return candidate
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    }
+  }
+  return undefined
+}

+ 8 - 7
bin/dsh

@@ -1,7 +1,7 @@
 #!/bin/sh
-# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE with this checkout's
-# tsx, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the
-# current working tree — code changes apply on the next launch, no build step.
+# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through Node's native
+# TypeScript transform, so a symlink from anywhere (e.g. ~/.local/bin/dsh)
+# always executes the current working tree without a build step.
 set -eu
 
 # Resolve symlink chains without readlink -f (not on every macOS).
@@ -15,7 +15,8 @@ while [ -L "$script" ]; do
 done
 root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd)
 
-# tsx is imported by absolute path because bare `--import tsx` resolves from
-# the invoking cwd, which is usually outside this repository.
-export TSX_TSCONFIG_PATH="$root/tsconfig.json"
-exec node --import "$root/node_modules/tsx/dist/loader.mjs" "$root/apps/cli/src/bin.ts" "$@"
+# The preloader projects this checkout's tsconfig paths into Node resolution;
+# TypeScript transformation itself remains Node-owned (no tsx/esbuild hook).
+exec node --experimental-transform-types \
+  --import "$root/scripts/tspath-loader.ts" \
+  "$root/apps/cli/src/bin.ts" "$@"

+ 3 - 3
package.json

@@ -95,13 +95,13 @@
     "constraints": "tsx scripts/check-workspace-constraints.ts",
     "doc-sync": "tsx scripts/run-gates.ts doc-sync",
     "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
-    "dsh": "node --import tsx apps/cli/src/bin.ts",
+    "dsh": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts",
     "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
-    "demo:tui": "node --import tsx apps/cli/src/bin.ts",
+    "demo:tui": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts",
     "demo:code-mode": "node scripts/demo-code-mode.mjs",
     "demo:cordis": "node scripts/demo-cordis.mjs",
     "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
-    "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web",
+    "demo:web": "npm run build && npm run build:web && node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web",
     "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts",
     "dev:web": "tsx scripts/dev-web.ts --poll",
     "postinstall": "node scripts/install-lefthook.mjs"

+ 3 - 3
packages/ui/app-boot/src/index.ts

@@ -142,8 +142,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process
 }
 
 /**
- * After the tree settles, reject entries with no fiber, which indicates a
- * swallowed module-import failure. Disabled entries are the only valid
+ * After the tree settles, reject entries with no fiber and name every plugin
+ * whose module failed to resolve. Disabled entries are the only valid
  * fiber-less state.
  * @param ctx - the settled context whose loader entries to audit.
  * @param binName - the diagnostic prefix on the thrown error.
@@ -152,7 +152,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
   const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
   if (failed.length > 0) {
     const names = failed.map(entry => entry.options.name).join(', ')
-    throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
+    throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`)
   }
 }
 

+ 8 - 1
scripts/demo-code-mode.mjs

@@ -7,7 +7,14 @@ import { spawn } from 'node:child_process'
 
 // Each UI's node invocation matches its base demo script plus the overlay config.
 const UIS = new Map([
-  ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/tui-agent/code-mode.cordis.yml']],
+  ['tui', [
+    '--experimental-transform-types',
+    '--import',
+    './scripts/tspath-loader.ts',
+    'apps/cli/src/bin.ts',
+    '--config',
+    'examples/tui-agent/code-mode.cordis.yml',
+  ]],
   ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
 ])
 

+ 14 - 0
scripts/tspath-loader.ts

@@ -0,0 +1,14 @@
+/** Register source-only tsconfig paths resolution before a TypeScript entry loads. */
+
+import { register } from 'node:module'
+import { resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const tsconfigPath = process.env.TSX_TSCONFIG_PATH === undefined
+  ? fileURLToPath(new URL('../tsconfig.json', import.meta.url))
+  : resolve(process.env.TSX_TSCONFIG_PATH)
+
+register(new URL('../apps/cli/src/tsconfig-paths-loader.ts', import.meta.url), {
+  parentURL: import.meta.url,
+  data: { tsconfigPath },
+})

+ 39 - 28
scripts/verify-cordis-config.ts

@@ -1,10 +1,10 @@
 /**
- * Validate Cordis Loader entry metadata and example package resolution.
+ * Validate Cordis Loader entry metadata and package resolution.
  *
  * The Loader interpolates only a plugin entry's `config`; expression objects in
  * fields such as `disabled` remain truthy data and silently change composition.
- * Example configs run from built packages, so every named package must resolve
- * from the examples workspace and every local package must be in the root
+ * Example configs and the dsh Web composition resolve named plugins from their
+ * owning workspace manifests. Local example packages must also be in the root
  * TypeScript project graph.
  */
 
@@ -42,7 +42,7 @@ const schema = yaml.JSON_SCHEMA.extend(jsExprType)
 
 const files = cordisConfigFiles(root)
 const errors: string[] = []
-const examplePluginReferences: PluginReference[] = []
+const pluginReferences: PluginReference[] = []
 
 for (const file of files) {
   const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
@@ -56,9 +56,10 @@ for (const file of files) {
 }
 
 errors.push(...validateExampleResolution())
+errors.push(...validateAppResolution())
 
 if (errors.length > 0) {
-  console.error('verify-cordis-config: invalid Loader metadata or example package resolution:')
+  console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:')
   for (const error of errors) console.error(`- ${error}`)
   process.exitCode = 1
 } else {
@@ -70,7 +71,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
     errors.push(`${file}${path}: entry must be an object`)
     return
   }
-  recordExamplePlugin(value, file)
+  recordPlugin(value, file)
   validateMetadata(value, file, path)
   if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
     for (let index = 0; index < value.config.length; index++) {
@@ -84,7 +85,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
     const patch = config.patches[index]
     const patchPath = `${path}.config.patches[${index}]`
     if (!isRecord(patch)) continue
-    recordExamplePlugin(patch, file)
+    recordPlugin(patch, file)
     validateMetadata(patch, file, patchPath)
     if (!isUnknownArray(patch.insert)) continue
     for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
@@ -93,10 +94,8 @@ function validateEntry(value: unknown, file: string, path: string): void {
   }
 }
 
-function recordExamplePlugin(entry: Record<string, unknown>, file: string): void {
-  if (file.startsWith('examples/') && typeof entry.name === 'string') {
-    examplePluginReferences.push({ file, name: entry.name })
-  }
+function recordPlugin(entry: Record<string, unknown>, file: string): void {
+  if (typeof entry.name === 'string') pluginReferences.push({ file, name: entry.name })
 }
 
 function validateExampleResolution(): string[] {
@@ -105,25 +104,13 @@ function validateExampleResolution(): string[] {
   const dependencies = exampleManifest.dependencies ?? {}
   const localPackages = localPackageDirectories()
   const rootReferences = rootProjectReferences()
-  const requiredPackages = new Map<string, Set<string>>()
-
-  for (const reference of examplePluginReferences) {
-    const packageName = packageNameFromSpecifier(reference.name)
-    if (packageName === undefined) continue
-    const locations = requiredPackages.get(packageName) ?? new Set<string>()
-    locations.add(reference.file)
-    requiredPackages.set(packageName, locations)
-  }
-
-  for (const [packageName, locations] of requiredPackages) {
-    if (!(packageName in dependencies)) {
-      violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`)
-    }
-  }
+  const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/'))
+  violations.push(...missingPluginDependencies(exampleReferences, dependencies, 'examples/package.json'))
+  const requiredPackages = new Set(exampleReferences.map(reference => packageNameFromSpecifier(reference.name)))
 
   const localExamplePackages = new Set([
     ...Object.keys(dependencies),
-    ...requiredPackages.keys(),
+    ...[...requiredPackages].filter(packageName => packageName !== undefined),
   ])
   for (const packageName of localExamplePackages) {
     const packageDirectory = localPackages.get(packageName)
@@ -135,6 +122,30 @@ function validateExampleResolution(): string[] {
   return violations
 }
 
+function validateAppResolution(): string[] {
+  const dependencies = readManifest('apps/cli/package.json').dependencies ?? {}
+  const references = pluginReferences.filter(reference => reference.file === 'apps/cli/cordis.yml')
+  return missingPluginDependencies(references, dependencies, 'apps/cli/package.json')
+}
+
+function missingPluginDependencies(
+  references: readonly PluginReference[],
+  dependencies: Readonly<Record<string, string>>,
+  manifestPath: string,
+): string[] {
+  const requiredPackages = new Map<string, Set<string>>()
+  for (const reference of references) {
+    const packageName = packageNameFromSpecifier(reference.name)
+    if (packageName === undefined) continue
+    const locations = requiredPackages.get(packageName) ?? new Set<string>()
+    locations.add(reference.file)
+    requiredPackages.set(packageName, locations)
+  }
+  return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies
+    ? []
+    : `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`)
+}
+
 function readManifest(path: string): PackageManifest {
   return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
 }
@@ -177,7 +188,7 @@ function rootProjectReferences(): Set<string> {
 }
 
 function packageNameFromSpecifier(specifier: string): string | undefined {
-  if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined
+  if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) return undefined
   const segments = specifier.split('/')
   if (specifier.startsWith('@')) {
     return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined