Parcourir la source

fix(cli): harden native source resolution

imccyu il y a 2 mois
Parent
commit
431c2180c1

+ 1 - 1
AGENTS.md

@@ -89,7 +89,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 ## Conventions
 
 - Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
-- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md).
+- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). CLI source-launch code and every module it reaches must support Node `--experimental-transform-types`: use `import type` for erased bindings and native ESM exports, with no TSX/JSX or tsx/esbuild-only transforms. TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md).
 - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
 - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)).
 - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.

+ 2 - 2
apps/cli/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: e250f3b4c3c2031935b9a6abc3c73c03cfb0eccd
-README.zh.md: cf5d0e7f05025bf87e2a47decd265762353b7feb
+README.md: 13a80b1d0e0105bc0c30c019209b2e0295b7bef9
+README.zh.md: 2a5d9c15c57351ef03ebe60a5cdf90f0d0c8f18b

+ 1 - 1
apps/cli/README.md

@@ -26,6 +26,6 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr
 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
 ```
 
-Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. It reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. The loader maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies.
+Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. Every module reachable from the CLI source entry follows Node's transform-types contract: erased bindings use `import type`, exports use native ESM, and the graph contains no TSX/JSX or transforms that only tsx/esbuild provides. The loader reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. It maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies.
 
 `pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.

+ 1 - 1
apps/cli/README.zh.md

@@ -26,6 +26,6 @@ Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将
 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
 ```
 
-源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts`;`scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。设置 `TSX_TSCONFIG_PATH` 时,它会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig;它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时,loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,而 Web/无头 `cordis.yml` 则通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。
+源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts`;`scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。从 CLI 源码入口可达的每个模块都遵守 Node transform-types 契约:会被擦除的绑定使用 `import type`,export 使用原生 ESM,整个依赖图不含 TSX/JSX,也不依赖仅由 tsx/esbuild 提供的转换。设置 `TSX_TSCONFIG_PATH` 时,loader 会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig;它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时,loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,而 Web/无头 `cordis.yml` 则通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。
 
 `pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。

+ 24 - 7
apps/cli/src/tsconfig-paths-loader.ts

@@ -29,16 +29,29 @@ interface PathRule {
   targets: readonly string[]
 }
 
+interface PathsCompilerOptions {
+  readonly baseUrl?: string
+  readonly paths?: ts.MapLike<string[]>
+  readonly pathsBasePath?: string
+}
+
+// Node's native TypeScript transform cannot parse JSX, so `.tsx` is excluded.
 const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const
 
-/** Resolve package imports through one parsed tsconfig paths table. */
+/**
+ * Resolve package imports through one parsed tsconfig paths table.
+ *
+ * Manifest reads are process-scoped and memoized by path. Only matched source
+ * aliases enter the cache, bounding it to directories participating in source
+ * resolution.
+ */
 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)
+  private constructor(configDirectory: string, paths: ts.MapLike<string[]>) {
+    this.configDirectory = configDirectory
     this.rules = Object.entries(paths)
       .map(([pattern, targets]) => {
         const wildcard = pattern.indexOf('*')
@@ -73,9 +86,11 @@ export class TsconfigPathsResolver {
         : ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n')
       throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`)
     }
-    const paths = parsed.options.paths
+    const options = parsed.options as PathsCompilerOptions
+    const paths = options.paths
     if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`)
-    return new TsconfigPathsResolver(tsconfigPath, paths)
+    const configDirectory = options.baseUrl ?? options.pathsBasePath ?? dirname(tsconfigPath)
+    return new TsconfigPathsResolver(configDirectory, paths)
   }
 
   /**
@@ -168,7 +183,7 @@ export async function resolveHook(
 export { resolveHook as resolve }
 
 function packageNameFromSpecifier(specifier: string): string | undefined {
-  if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || specifier.startsWith('file:')) {
+  if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) {
     return undefined
   }
   const segments = specifier.split('/')
@@ -185,7 +200,9 @@ function declaresRuntimeDependency(manifest: PackageManifest, packageName: strin
 }
 
 async function existingSourcePath(base: string): Promise<string | undefined> {
-  const candidates = extname(base) === ''
+  const extension = extname(base)
+  if (extension === '.tsx') return undefined
+  const candidates = extension === ''
     ? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))]
     : [base]
   for (const candidate of candidates) {

+ 180 - 0
apps/cli/tests/tsconfig-paths-loader.spec.ts

@@ -0,0 +1,180 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import type { ResolveFnOutput, ResolveHookContext } from 'node:module'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { initialize, resolveHook, TsconfigPathsResolver } from '../src/tsconfig-paths-loader.ts'
+
+class ResolverFixture {
+  readonly root = mkdtempSync(join(tmpdir(), 'dsh-tsconfig-paths-'))
+
+  path(relativePath: string): string {
+    return join(this.root, relativePath)
+  }
+
+  write(relativePath: string, content = 'export {}\n'): string {
+    const path = this.path(relativePath)
+    mkdirSync(dirname(path), { recursive: true })
+    writeFileSync(path, content)
+    return path
+  }
+
+  writeJson(relativePath: string, value: unknown): string {
+    return this.write(relativePath, `${JSON.stringify(value)}\n`)
+  }
+
+  createResolver(paths: Record<string, string[]>): TsconfigPathsResolver {
+    const tsconfigPath = this.writeJson('tsconfig.json', { compilerOptions: { paths } })
+    return TsconfigPathsResolver.create(tsconfigPath)
+  }
+
+  parentURL(relativePath = 'consumer/src/nested/index.ts'): string {
+    return pathToFileURL(this.path(relativePath)).href
+  }
+
+  dispose(): void {
+    rmSync(this.root, { recursive: true, force: true })
+  }
+}
+
+const fixtures: ResolverFixture[] = []
+
+function fixture(): ResolverFixture {
+  const value = new ResolverFixture()
+  fixtures.push(value)
+  return value
+}
+
+afterEach(() => {
+  for (const value of fixtures.splice(0)) value.dispose()
+})
+
+describe('TsconfigPathsResolver', () => {
+  it('orders exact, longer-prefix, and longer-suffix path rules', async () => {
+    const files = fixture()
+    files.writeJson('consumer/package.json', {
+      dependencies: {
+        '@scope/feature-name': '*',
+        '@scope/feature-other': '*',
+        '@scope/plain-suffix': '*',
+      },
+    })
+    files.write('targets/exact.ts')
+    files.write('targets/prefix/other.ts')
+    files.write('targets/generic/feature-other.ts')
+    files.write('targets/suffix/plain.ts')
+    files.write('targets/generic/plain-suffix.ts')
+    const resolver = files.createResolver({
+      '@scope/*': ['./targets/generic/*'],
+      '@scope/*-suffix': ['./targets/suffix/*'],
+      '@scope/feature-*': ['./targets/prefix/*'],
+      '@scope/feature-name': ['./targets/exact.ts'],
+    })
+
+    await expect(resolver.resolve('@scope/feature-name', files.parentURL()))
+      .resolves.toBe(pathToFileURL(files.path('targets/exact.ts')).href)
+    await expect(resolver.resolve('@scope/feature-other', files.parentURL()))
+      .resolves.toBe(pathToFileURL(files.path('targets/prefix/other.ts')).href)
+    await expect(resolver.resolve('@scope/plain-suffix', files.parentURL()))
+      .resolves.toBe(pathToFileURL(files.path('targets/suffix/plain.ts')).href)
+  })
+
+  it('resolves only self-references and runtime dependencies from the nearest ancestor manifest', async () => {
+    const files = fixture()
+    files.writeJson('consumer/package.json', {
+      name: 'self-package',
+      dependencies: { dependency: '*' },
+      optionalDependencies: { optional: '*' },
+      peerDependencies: { peer: '*' },
+    })
+    for (const name of ['self-package', 'dependency', 'optional', 'peer', 'undeclared']) {
+      files.write(`targets/${name}.ts`)
+    }
+    const resolver = files.createResolver(Object.fromEntries(
+      ['self-package', 'dependency', 'optional', 'peer', 'undeclared']
+        .map(name => [name, [`./targets/${name}`]]),
+    ))
+
+    for (const name of ['self-package', 'dependency', 'optional', 'peer']) {
+      await expect(resolver.resolve(name, files.parentURL()))
+        .resolves.toBe(pathToFileURL(files.path(`targets/${name}.ts`)).href)
+    }
+    await expect(resolver.resolve('undeclared', files.parentURL())).resolves.toBeUndefined()
+  })
+
+  it('probes native TypeScript extensions and index files but excludes TSX and missing targets', async () => {
+    const files = fixture()
+    const names = ['plain-ts', 'module-mts', 'common-cts', 'directory', 'tsx-implicit', 'tsx-explicit', 'missing']
+    files.writeJson('consumer/package.json', {
+      dependencies: Object.fromEntries(names.map(name => [name, '*'])),
+    })
+    files.write('targets/plain.ts')
+    files.write('targets/module.mts')
+    files.write('targets/common.cts')
+    files.write('targets/directory/index.ts')
+    files.write('targets/component.tsx')
+    const resolver = files.createResolver({
+      'plain-ts': ['./targets/plain'],
+      'module-mts': ['./targets/module'],
+      'common-cts': ['./targets/common'],
+      'directory': ['./targets/directory'],
+      'tsx-implicit': ['./targets/component'],
+      'tsx-explicit': ['./targets/component.tsx'],
+      'missing': ['./targets/missing'],
+    })
+
+    for (const [name, target] of [
+      ['plain-ts', 'targets/plain.ts'],
+      ['module-mts', 'targets/module.mts'],
+      ['common-cts', 'targets/common.cts'],
+      ['directory', 'targets/directory/index.ts'],
+    ] as const) {
+      await expect(resolver.resolve(name, files.parentURL()))
+        .resolves.toBe(pathToFileURL(files.path(target)).href)
+    }
+    await expect(resolver.resolve('tsx-implicit', files.parentURL())).resolves.toBeUndefined()
+    await expect(resolver.resolve('tsx-explicit', files.parentURL())).resolves.toBeUndefined()
+    await expect(resolver.resolve('missing', files.parentURL())).resolves.toBeUndefined()
+  })
+
+  it('anchors inherited paths at the config that declared them', async () => {
+    const files = fixture()
+    files.writeJson('consumer/package.json', { dependencies: { custom: '*' } })
+    files.write('targets/custom.ts')
+    files.writeJson('base.json', { compilerOptions: { paths: { custom: ['./targets/custom'] } } })
+    const customTsconfig = files.writeJson('configs/custom.json', { extends: '../base.json' })
+    const resolver = TsconfigPathsResolver.create(customTsconfig)
+
+    await expect(resolver.resolve('custom', files.parentURL()))
+      .resolves.toBe(pathToFileURL(files.path('targets/custom.ts')).href)
+  })
+
+  it('short-circuits matched aliases and delegates unsupported schemes or unmatched requests', async () => {
+    const files = fixture()
+    files.writeJson('consumer/package.json', { dependencies: { matched: '*' } })
+    const target = files.write('targets/matched.ts')
+    const tsconfigPath = files.writeJson('tsconfig.json', {
+      compilerOptions: { paths: { matched: ['./targets/matched'] } },
+    })
+    initialize({ tsconfigPath })
+    const context: ResolveHookContext = {
+      conditions: [],
+      importAttributes: {},
+      parentURL: files.parentURL(),
+    }
+    const nextResolve = vi.fn(async (
+      specifier: string,
+      _context: ResolveHookContext,
+    ): Promise<ResolveFnOutput> => ({ url: `next:${specifier}` }))
+
+    await expect(resolveHook('matched', context, nextResolve))
+      .resolves.toEqual({ url: pathToFileURL(target).href, shortCircuit: true })
+    expect(nextResolve).not.toHaveBeenCalled()
+
+    for (const specifier of ['unmatched', 'node:fs', 'data:text/javascript,export default 1', 'https://example.test/mod.ts']) {
+      await expect(resolveHook(specifier, context, nextResolve)).resolves.toEqual({ url: `next:${specifier}` })
+      expect(nextResolve).toHaveBeenLastCalledWith(specifier, context)
+    }
+  })
+})

+ 1 - 1
scripts/doc-budgets.manifest.json

@@ -1,5 +1,5 @@
 {
-  "AGENTS.md": 1720,
+  "AGENTS.md": 1750,
   "docs/AGENTS.md": 1150,
   "docs/architecture.md": 1800,
   "docs/cordis-primer.md": 600,