Quellcode durchsuchen

fix: cover supported experimental isolation inputs

Tianyi Cui vor 1 Monat
Ursprung
Commit
22c5ae6abc

+ 2 - 2
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.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 .agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md
-2026-09-12-default-product-experimental-isolation.md: a72a367e3f766df9baf90a98a15e8e28a175a66f
-2026-09-12-default-product-experimental-isolation.zh.md: b929e85a8a742cea64af4790b9e3c0cd6f2ac225
+2026-09-12-default-product-experimental-isolation.md: d2312d6061f372e910bfd625512230ccd48a586a
+2026-09-12-default-product-experimental-isolation.zh.md: 6ea86f1f3e859719ffd8287e66ebe63df77d64c2

+ 1 - 1
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md

@@ -36,4 +36,4 @@ This check enforces the existing [experimental dependency isolation rules](../ar
 
 ## Consequences
 
-Experimental packages may publish without joining default installations or compositions. Source, effective composition, build-input, installed-dependency, and runtime-registry checks provide independent evidence. Runtime smokes observe startup and Client activation; they do not replace tests for every later user-triggered execution path or for explicitly installed profile extensions.
+Experimental packages may publish without joining default installations or compositions. Source, effective composition, build-input, installed-dependency, and runtime-registry checks provide independent evidence. The SDK's [source-launch compatibility patch](../../../../apps/cli/src/sdk-source.cordis.patch.yml) is selected by a computed filesystem path and is outside static configuration discovery. Runtime smokes observe startup and Client activation; they do not replace tests for every later user-triggered execution path or for explicitly installed profile extensions.

+ 1 - 1
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md

@@ -36,4 +36,4 @@ Status: implemented
 
 ## Consequences
 
-实验包可以发布而不加入默认安装或组合。源码、最终组合、构建输入、安装依赖和运行时 registry 检查提供相互独立的证据。运行时 smoke 观察启动和 Client 激活;它们不替代后续每条用户触发路径的测试,也不替代显式安装的 profile 扩展测试。
+实验包可以发布而不加入默认安装或组合。源码、最终组合、构建输入、安装依赖和运行时 registry 检查提供相互独立的证据。SDK 的[源码启动兼容 patch](../../../../apps/cli/src/sdk-source.cordis.patch.yml) 通过计算出的文件路径选择,不在静态配置发现范围内。运行时 smoke 观察启动和 Client 激活;它们不替代后续每条用户触发路径的测试,也不替代显式安装的 profile 扩展测试。

+ 19 - 0
scripts/verify-client-packages.spec.ts

@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'
 import {
   collectClientPackageViolations,
   collectLocalSourceSpecifiers,
+  collectRuntimeLocalSourceSpecifiers,
   collectRuntimeSourcePackageUses,
   collectRuntimeSourceSpecifiers,
   collectSourcePackageUses,
@@ -75,6 +76,24 @@ function facts(
 }
 
 describe('source package uses', () => {
+  it('includes root-relative runtime requests only when explicitly requested', () => {
+    const source = [
+      "import './local.ts'",
+      "import '/src/value.ts'",
+      "export * from '/src/export.ts'",
+      "const load = () => import('/src/lazy.ts')",
+      "const legacy = require('/src/legacy.ts')",
+      "import type { A } from '/src/import-type.ts'",
+      "import { type B } from '/src/import-type-only.ts'",
+      "export type { A } from '/src/export-type.ts'",
+      "export { type B } from '/src/export-type-only.ts'",
+    ].join('\n')
+    expect([...collectRuntimeLocalSourceSpecifiers('feature.ts', source)]).toEqual(['./local.ts'])
+    expect([...collectRuntimeLocalSourceSpecifiers('feature.ts', source, true)]).toEqual([
+      './local.ts', '/src/value.ts', '/src/export.ts', '/src/lazy.ts', '/src/legacy.ts',
+    ])
+  })
+
   it('counts type imports, module augmentations, dynamic imports, and JSX', () => {
     const uses = collectSourcePackageUses('feature.tsx', [
       "import type { A } from '@deepseek-ai/dsh-a/subpath'",

+ 7 - 5
scripts/verify-client-packages.ts

@@ -101,14 +101,15 @@ export function collectLocalSourceSpecifiers(path: string, source: string): Set<
 }
 
 /**
- * Collect relative module specifiers retained by one production source file.
+ * Collect local module specifiers retained by one production source file.
  * @param path - File path used to select TypeScript's parser mode.
  * @param source - Source text to inspect.
- * @returns Relative imports, exports, and requires that survive compilation.
+ * @param includeRootRelative - Include absolute paths, such as Vite's Web-root imports.
+ * @returns Local imports, exports, and requires that survive compilation.
  */
-export function collectRuntimeLocalSourceSpecifiers(path: string, source: string): Set<string> {
+export function collectRuntimeLocalSourceSpecifiers(path: string, source: string, includeRootRelative = false): Set<string> {
   const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
-  return collectSourceFileUses(sourceFile, true, 'local')
+  return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative)
 }
 
 function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
@@ -134,13 +135,14 @@ function collectSourceFileUses(
   sourceFile: ts.SourceFile,
   runtimeOnly: boolean,
   key: 'local' | 'package' | 'specifier',
+  includeRootRelative = false,
 ): Set<string> {
   const uses = new Set<string>()
 
   const add = (specifier: ts.Expression | undefined): void => {
     if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return
     if (key === 'local') {
-      if (specifier.text.startsWith('.')) uses.add(specifier.text)
+      if (specifier.text.startsWith('.') || includeRootRelative && specifier.text.startsWith('/')) uses.add(specifier.text)
       return
     }
     if (!isBareSpecifier(specifier.text)) return

+ 128 - 1
scripts/verify-default-product-isolation.spec.ts

@@ -27,7 +27,7 @@ function manifest(root: string, path: string, fields: Record<string, unknown>):
 }
 
 function fixture(): string {
-  const root = mkdtempSync(join(tmpdir(), 'dsh-default-isolation-'))
+  const root = mkdtempSync(join(tmpdir(), 'dsh.default-isolation-'))
   roots.push(root)
   write(root, 'apps/cli/package.json', { name: '@deepseek-ai/dsh', dependencies: { [core]: 'workspace:^' } })
   write(root, 'apps/cli/src/bin.ts', 'export {}\n')
@@ -126,6 +126,68 @@ describe('default product isolation', () => {
     expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(experimental)
   })
 
+  it.each([
+    "import '/src/preview.ts'",
+    "export * from '/src/preview.ts'",
+    "await import('/src/preview.ts')",
+    "new Worker(new URL('/src/preview.ts', import.meta.url))",
+  ])('follows Web-root runtime reference %s', (source) => {
+    const root = fixture()
+    write(root, 'apps/web/src/main.ts', source)
+    write(root, 'apps/web/src/preview.ts', `import '${experimental}'\n`)
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(experimental)
+  })
+
+  it('does not follow type-only Web-root references', () => {
+    const root = fixture()
+    write(root, 'apps/web/src/main.ts', "import type { Value } from '/src/preview.ts'\nexport type { Value } from '/src/preview.ts'\n")
+    write(root, 'apps/web/src/preview.ts', `import '${experimental}'\n`)
+
+    expect(verifyDefaultProductIsolation(root).failures).toEqual([])
+  })
+
+  it.each(['.js', '.jsx', '.mjs', '.ts', '.tsx', '.mts'])('follows extensionless Web imports to %s in a dotted checkout', (extension) => {
+    const root = fixture()
+    write(root, 'apps/web/src/main.ts', "import './preview'\n")
+    write(root, `apps/web/src/preview${extension}`, `import '${experimental}'\n`)
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(experimental)
+  })
+
+  it.each(['.js', '.jsx', '.mjs'])('follows Web directory imports to index%s', (extension) => {
+    const root = fixture()
+    write(root, 'apps/web/src/main.ts', "import './preview'\n")
+    write(root, `apps/web/src/preview/index${extension}`, `import '${experimental}'\n`)
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(experimental)
+  })
+
+  it.each([['.mjs', '.mts'], ['.cjs', '.cts']])('follows %s runtime requests to their %s source', (request, source) => {
+    const root = fixture()
+    write(root, 'apps/web/src/main.ts', `import './preview${request}'\n`)
+    write(root, `apps/web/src/preview${source}`, `import '${experimental}'\n`)
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(experimental)
+  })
+
+  it('follows a Web directory import whose directory name contains a dot', () => {
+    const root = fixture()
+    write(root, 'apps/web/src/main.ts', "import './preview.v1'\n")
+    write(root, 'apps/web/src/preview.v1/index.js', `import '${experimental}'\n`)
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(experimental)
+  })
+
+  it('identifies a relative package-directory import using its exact directory owner', () => {
+    const root = fixture()
+    write(root, 'apps/web/src/main.ts', "import '../../../packages/experimental/prototype'\n")
+    manifest(root, 'packages/experimental/prototype/package.json', { main: 'src/entry.js' })
+    write(root, 'packages/experimental/prototype/src/entry.js', 'export {}\n')
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(experimental)
+  })
+
   it.each([
     [{ name: experimental, disabled: true }],
     [{ group: true, config: [{ name: experimental }] }],
@@ -148,6 +210,43 @@ describe('default product isolation', () => {
     expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(experimental)
   })
 
+  it.each([false, true])('checks Include.initial when its file exists: %s', (existing) => {
+    const root = fixture()
+    const included = join(root, 'included.yml')
+    if (existing) write(root, 'included.yml', [{ name: core }])
+    write(root, patch, [{ insert: [{ name: 'cordis:include', config: {
+      path: pathToFileURL(included).href,
+      initial: [{ name: experimental }],
+    } }] }])
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(experimental)
+  })
+
+  it('accepts a missing Include file initialized with stable entries and applies its patches', () => {
+    const root = fixture()
+    write(root, patch, [{ insert: [{ name: 'cordis:include', config: {
+      path: pathToFileURL(join(root, 'included.yml')).href,
+      initial: [{ id: 'feature', group: true, config: [{ name: core }] }],
+    } }] }])
+    expect(verifyDefaultProductIsolation(root).failures).toEqual([])
+    write(root, patch, [{ insert: [{ name: 'cordis:include', config: {
+      path: pathToFileURL(join(root, 'included.yml')).href,
+      initial: [{ id: 'feature', group: true, config: [{ name: core }] }],
+      patches: [{ id: 'feature', config: [{ name: experimental }] }],
+    } }] }])
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(experimental)
+  })
+
+  it('rejects an Include.initial tree that includes its own generated file', () => {
+    const root = fixture()
+    const path = pathToFileURL(join(root, 'included.yml')).href
+    write(root, patch, [{ insert: [{ name: 'cordis:include', config: {
+      path, initial: [{ name: 'cordis:include', config: { path } }],
+    } }] }])
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain('cyclic Include path included.yml')
+  })
+
   it('checks default profile bundle names and declared config trees', () => {
     const root = fixture()
     write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${experimental}'] } }\n`
@@ -159,6 +258,22 @@ describe('default product isolation', () => {
     expect(failures).toContain('apps/cli/config/extra.cordis.yml')
   })
 
+  it.each([false, true])('rejects a declared config tree without composition files when its directory exists: %s', (existing) => {
+    const root = fixture()
+    manifest(root, 'apps/cli/package.json', { dsh: { configTrees: [{ path: './config' }] } })
+    if (existing) write(root, 'apps/cli/config/README.i18n.yaml', 'en: test\n')
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain('apps/cli/config')
+  })
+
+  it('does not classify ordinary YAML as a Cordis config because of an ancestor directory name', () => {
+    const root = fixture()
+    write(root, 'packages/core/core/src/cordis/index.ts', "new URL('./ordinary.yml', import.meta.url)\n")
+    write(root, 'packages/core/core/src/cordis/ordinary.yml', 'ordinary: data\n')
+
+    expect(verifyDefaultProductIsolation(root).failures).toEqual([])
+  })
+
   it('checks desktop configuration reached through a source URL', () => {
     const root = fixture()
     write(root, 'apps/desktop-host/package.json', { name: '@fixture/desktop-host', private: true })
@@ -194,6 +309,18 @@ describe('default product isolation', () => {
     expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`included tree/cordis.yml -> ${experimental}`)
   })
 
+  it('checks the replacement Include path after an id-only Web patch', () => {
+    const root = fixture()
+    write(root, 'original.yml', [{ name: core }])
+    write(root, 'replacement.yml', [{ name: experimental }])
+    write(root, patch, [
+      { insert: [{ id: 'feature-include', name: 'cordis:include', config: { path: pathToFileURL(join(root, 'original.yml')).href } }] },
+      { id: 'feature-include', config: { path: pathToFileURL(join(root, 'replacement.yml')).href } },
+    ])
+
+    expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`replacement.yml -> ${experimental}`)
+  })
+
   it('reports an empty effective Web tree even when the raw patch names a package', () => {
     const root = fixture()
     write(root, patch, [{ id: 'missing-target', name: core }])

+ 46 - 35
scripts/verify-default-product-isolation.ts

@@ -1,7 +1,7 @@
 /** Keep experimental packages outside default installations, runtime imports, and shipped compositions. */
 
 import { existsSync, globSync, readFileSync, statSync } from 'node:fs'
-import { dirname, relative, resolve } from 'node:path'
+import { basename, dirname, extname, relative, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { JSDOM } from 'jsdom'
 import ts from 'typescript'
@@ -137,11 +137,14 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
     const source = inlineSource ?? readFileSync(path, 'utf8')
     for (const specifier of collectRuntimeSourceSpecifiers(path, source)) sourceReference(specifier, path)
     for (const specifier of runtimeLocalSpecifiers(path, source)) {
-      const target = resolveSource(path, specifier)
-      const resolved = target ?? resolve(dirname(path), specifier)
-      const owner = ownerOf(resolved)
+      const local = followLocal && specifier.startsWith('/')
+        ? resolve(root, 'apps/web', `.${specifier.replace(/[?#].*$/, '')}`)
+        : resolve(dirname(path), specifier.replace(/[?#].*$/, ''))
+      const target = resolveSource(local)
+      const resolved = target ?? local
+      const owner = directories.get(resolved) ?? ownerOf(resolved)
       if (owner !== undefined && isExperimental(owner)) add(owner, display(path))
-      if (/[^/]*cordis[^/]*\.ya?ml$/.test(resolved)) scanConfig(resolved)
+      if (/cordis[^/]*\.ya?ml$/.test(basename(resolved))) scanConfig(resolved)
       if (followLocal && target !== undefined) scanSource(target, true)
     }
   }
@@ -165,28 +168,27 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
       if (Array.isArray(entry.insert)) entry.insert.forEach(visit)
       if ((entry.name === '@deepseek-ai/cordis-plugin-include' || entry.name === 'cordis:include') && isRecord(entry.config)) {
         if (!composedWeb && Array.isArray(entry.config.patches)) entry.config.patches.forEach(visit)
-        const paths = typeof entry.config.path === 'string' ? [entry.config.path] : entry.config.path
-        if (Array.isArray(paths)) {
-          for (const included of paths) {
-            if (typeof included !== 'string') continue
-            const filename = included.startsWith('file:') ? fileURLToPath(included) : resolve(dirname(path), included)
-            if (!composedWeb) {
-              scanConfig(filename)
-              continue
-            }
-            if (includeStack.has(filename)) {
-              failures.push(`${display(path)}: cyclic Include path ${display(filename)}`)
-              continue
-            }
-            const content = loadCordisYaml(readFileSync(filename, 'utf8'))
-            if (!Array.isArray(content)) {
-              failures.push(`${display(filename)}: included composition must contain an entry array`)
-              continue
-            }
-            const entries = applyEntryPatches(content as EntryOptions[], entry.config.patches as PatchOptions[] | undefined, () => {})
-            scanEntries(entries, filename, true, new Set([...includeStack, filename]))
-          }
+        const included = entry.config.path
+        if (typeof included !== 'string') return
+        const filename = included.startsWith('file:') ? fileURLToPath(included) : resolve(dirname(path), included)
+        if (includeStack.has(filename)) {
+          failures.push(`${display(path)}: cyclic Include path ${display(filename)}`)
+          return
         }
+        const nestedStack = new Set([...includeStack, filename])
+        const initial = Array.isArray(entry.config.initial) ? entry.config.initial : undefined
+        if (initial !== undefined) scanEntries(initial, filename, false, nestedStack)
+        if (!composedWeb) {
+          if (existsSync(filename) || initial === undefined) scanConfig(filename)
+          return
+        }
+        const content = existsSync(filename) ? loadCordisYaml(readFileSync(filename, 'utf8')) : initial
+        if (!Array.isArray(content)) {
+          failures.push(`${display(filename)}: included composition must contain an entry array`)
+          return
+        }
+        const entries = applyEntryPatches(content as EntryOptions[], entry.config.patches as PatchOptions[] | undefined, () => {})
+        scanEntries(entries, filename, true, nestedStack)
       }
     }
     document.forEach(visit)
@@ -265,8 +267,11 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
     }
     if (manifest.dsh?.bundle?.patch !== undefined) scanConfig(resolve(pkg.directory, manifest.dsh.bundle.patch))
     for (const tree of manifest.dsh?.configTrees ?? []) {
-      for (const path of globSync('**/*.{yml,yaml}', { cwd: resolve(pkg.directory, tree.path),
-        exclude: ['**/*.i18n.yaml', '**/preset.yml'] })) scanConfig(resolve(pkg.directory, tree.path, path))
+      const treePath = resolve(pkg.directory, tree.path)
+      const files = existsSync(treePath) && statSync(treePath).isDirectory()
+        ? globSync('**/*.{yml,yaml}', { cwd: treePath, exclude: ['**/*.i18n.yaml', '**/preset.yml'] }) : []
+      if (files.length === 0) failures.push(`${display(treePath)}: declared config tree has no composition files`)
+      for (const path of files) scanConfig(resolve(treePath, path))
     }
     if (display(pkg.directory) === 'apps/web') continue
     const files = globSync('src/**/*.{ts,tsx,mts,cts,js,mjs,cjs}', { cwd: pkg.directory,
@@ -282,21 +287,27 @@ function barePackageName(specifier: string): string {
   return /^(?:@[^/]+\/)?[^/@]+/.exec(specifier)?.[0] ?? specifier
 }
 
-function resolveSource(from: string, specifier: string): string | undefined {
-  const path = resolve(dirname(from), specifier.replace(/[?#].*$/, ''))
-  if (!/\.(?:[cm]?[jt]sx?)$/.test(path) && /\.[^/]+$/.test(path)) return undefined
-  const base = path.replace(/\.[cm]?js$/, '')
-  return [path, `${base}.ts`, `${base}.tsx`, `${path}/index.ts`, `${path}/index.tsx`]
+function resolveSource(path: string): string | undefined {
+  const extension = extname(path)
+  if (extension !== '' && !/^\.[cm]?[jt]sx?$/.test(extension)
+    && !(existsSync(path) && statSync(path).isDirectory())) return undefined
+  const extensions = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx']
+  const typed = extension === '.js' ? [path.replace(/\.js$/, '.ts'), path.replace(/\.js$/, '.tsx')]
+    : extension === '.mjs' ? [path.replace(/\.mjs$/, '.mts')]
+      : extension === '.cjs' ? [path.replace(/\.cjs$/, '.cts')] : []
+  return [path, ...typed, ...extension === '' ? extensions.map(suffix => `${path}${suffix}`) : [],
+    ...extensions.map(suffix => resolve(path, `index${suffix}`))]
     .find(candidate => existsSync(candidate) && statSync(candidate).isFile())
 }
 
 function runtimeLocalSpecifiers(path: string, source: string): Set<string> {
-  const specifiers = collectRuntimeLocalSourceSpecifiers(path, source)
+  const specifiers = collectRuntimeLocalSourceSpecifiers(path, source, true)
   const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
   const visit = (node: ts.Node): void => {
     if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'URL') {
       const first = node.arguments?.[0]
-      if (first !== undefined && ts.isStringLiteralLike(first) && first.text.startsWith('.')) specifiers.add(first.text)
+      if (first !== undefined && ts.isStringLiteralLike(first)
+        && (first.text.startsWith('.') || first.text.startsWith('/'))) specifiers.add(first.text)
     }
     ts.forEachChild(node, visit)
   }