From cf41c158bae2e1d0d2e2e84a2c6e6ce486495033 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:42:49 +0800 Subject: [PATCH] fix(boot): keep host lookups native and fix source smoke fixtures --- ...9-profile-resolution-generations.i18n.yaml | 4 +- ...26-09-09-profile-resolution-generations.md | 4 +- ...09-09-profile-resolution-generations.zh.md | 4 +- ...-profile-resolution-lookup-order.i18n.yaml | 4 +- ...6-09-19-profile-resolution-lookup-order.md | 7 +- ...9-19-profile-resolution-lookup-order.zh.md | 7 +- .../headless/tests/profile-resolution.ts | 39 ++- docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 2 + docs/user/develop/basic/publish.zh.md | 2 + packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 5 +- packages/boot/app-boot/README.zh.md | 5 +- .../src/profile-resolution/resolver.ts | 22 +- packages/boot/app-boot/src/profile.ts | 24 +- .../app-boot/tests/profile-resolution.spec.ts | 278 +++++++++++++++++- 16 files changed, 354 insertions(+), 61 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml index 087f067fb9..0f95b21360 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.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/architecture/2026-09-09-profile-resolution-generations.md -2026-09-09-profile-resolution-generations.md: e524150b4928e0ed239cd10efd668e3400718ecc -2026-09-09-profile-resolution-generations.zh.md: 1a6104586f0d4c7d5911128557485a1bc277f426 +2026-09-09-profile-resolution-generations.md: 39ce2415ca51b31641d643a9635ad57205385975 +2026-09-09-profile-resolution-generations.zh.md: ea15cb2792a49c2c99951f2faf619d68b6b5b98a diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md index e524150b49..39ce2415ca 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md @@ -62,6 +62,8 @@ The [source launcher](2026-07-29-dsh-source-launch-tsx-esm.md) uses tsx's ESM-on For example, `@deepseek-ai/dsh-tools` creates its scheduler key with `Symbol()`. A Tools instance loaded from `lib/` cannot expose that scheduler through a key imported from the separate `src/` module instance. Source launches keep Tools and AgentLoop in `src/`; plain Node launches keep them in `lib/`. The scheduler retains its local Symbol; correct imports share one module instance. +The source launcher does not install a CommonJS TypeScript hook. `createRequire().resolve()` still selects the package's published JavaScript entry and requires that file to exist. Source-mode resolution tests therefore use fixture-provided CommonJS files; checks of real installation CommonJS entries run with build outputs present. + ### Immutable generations A runtime interception holds one `current` generation. Each synchronous resolution captures that reference once. Generation construction reads every required dependency-graph manifest before publication; an error leaves the current generation unchanged. Successful publication replaces one reference, and in-flight calls may finish against the generation they captured. @@ -104,7 +106,7 @@ Replacing, upgrading, or removing an already loaded package requires process res ### Filesystem and runtime carriers -The resolver does not create, update, or remove fallback symlinks and proxy packages. Runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`; every other name sees that directory as an ordinary ancestor. Construction also records profile links to real directories outside the profiles tree, including directories without their own manifest. Linked importers retain native ancestor order and apply peer mappings at the matching manifest's position, even without a physical `node_modules` there. Profile-local package metadata and bundle dependency discovery follow the same Node lookup. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) records precedence and scope. Writable profile state and package-manager transactions remain outside the resolver. +The resolver does not create, update, or remove fallback symlinks and proxy packages. Runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`; every other name sees that directory as an ordinary ancestor. Construction records links to directories outside the shared profiles tree and the active profile itself, including targets without their own manifest. Installation-scope package directories stay outside linked interception even when a broader linked root contains them. Eligible linked importers retain native ancestor order and per-position peer mappings. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) records precedence and scope. Writable profile state and package-manager transactions remain outside the resolver. Runtime resolution requires a supported Node Internal loader interface. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`; packaged builds read the dsh tree from ASAR and map executable ASAR entries to electron-builder's unpacked tree. Pkg and Electron use the same runtime resolution mechanism as ordinary Node launches. diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md index 1a6104586f..ea15cb2792 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md @@ -62,6 +62,8 @@ ESM `import` 和 CommonJS `require` 都选中这些版本。在每种模块格 例如,`@deepseek-ai/dsh-tools` 使用 `Symbol()` 创建 scheduler 键。从 `lib/` 加载的 Tools 实例,无法通过另一个 `src/` 模块实例导入的键暴露该 scheduler。源码启动让 Tools 与 AgentLoop 都位于 `src/`;普通 Node 启动让两者都位于 `lib/`。scheduler 保留模块本地的 Symbol;正确的 import 共享同一个模块实例。 +源码启动器不安装 CommonJS TypeScript 钩子。`createRequire().resolve()` 仍选择包发布的 JavaScript 入口,并要求该文件存在。因此,源码模式的解析测试使用 fixture 提供的 CommonJS 文件,真实安装包的 CommonJS 入口检查则在构建产物存在时运行。 + ### 不可变 generation 一个 runtime interception 持有一个 `current` generation。每个同步 resolve 在入口只捕获一次该引用,完整调用只读该引用。generation 构造在发布前读取依赖图所需的全部 manifest;失败时当前 generation 不变。发布成功只替换一个引用,执行中的调用可以继续使用它已捕获的 generation。 @@ -104,7 +106,7 @@ runtime resolution 列出它提供的包;Loader entries 组成活动插件列 ### 文件系统与运行时载体 -解析器不创建、更新或删除 fallback 软链接与代理包。runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置;其余包名把该目录当作普通祖先。构造时还会记录指向 profiles 树外真实目录的 profile 链接,包括没有自身 manifest 的目录。linked importer 保留原生祖先顺序,在匹配 manifest 对应的位置应用 peer 映射,即使那里没有物理 `node_modules`。profile 本地包元数据与 bundle 依赖展开遵循同一套 Node 查找。优先级和范围见[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)。可写 profile 状态和包管理器事务不属于解析器。 +解析器不创建、更新或删除 fallback 软链接与代理包。runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置;其余包名把该目录当作普通祖先。构造时记录目标同时位于共享 profiles 树和当前 profile 自身之外的目录链接,包括没有自身 manifest 的目标。installation 作用域包目录不参与 linked 拦截,即使更宽的 linked root 包含它们。符合条件的 linked importer 保留原生祖先顺序与逐位置的 peer 映射。优先级和范围见[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)。可写 profile 状态和包管理器事务不属于解析器。 运行时解析要求受支持的 Node Internal loader 接口。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行;打包构建从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。pkg 与 Electron 使用和普通 Node 启动相同的 runtime resolution 机制。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.i18n.yaml index 9adcca0495..8965c2b9db 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.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/architecture/2026-09-19-profile-resolution-lookup-order.md -2026-09-19-profile-resolution-lookup-order.md: cb4c677e30a8d0f104a3cacf6c97407a8ea70576 -2026-09-19-profile-resolution-lookup-order.zh.md: cf33a9b5b1d56e23c8060418d8c5c8dc076574e0 +2026-09-19-profile-resolution-lookup-order.md: 0830aac1ef00aadf56aee8179c59324230e132d5 +2026-09-19-profile-resolution-lookup-order.zh.md: 9817f2001e484208edd3755bc0f80d5a3029b4c7 diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md index cb4c677e30..0830aac1ef 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md @@ -50,6 +50,8 @@ The interception layer reads, writes, and deletes no disk links. Leftover histor When `/node_modules/` links to a real directory R outside the profiles tree, R is a linked root. R need not contain `package.json` or `node_modules`: it may be a package directory, a monorepo package, or a `src` directory. Importers below R follow Node's real ancestor chain. The recorded root decides which importers participate; it does not stop their lookup at R. +Linked interception excludes importers inside installation-scope package directories, using both their configured and real paths. A broad checkout link therefore does not take over the host's own dependency lookup. Overlapping linked roots describe one participation scope: the importer determines the ancestor chain, so neither root order nor link names choose a different peer set. + At each candidate `D/node_modules`, a matching current `D/package.json` peer declaration uses the runtime package if the table supplies that name. This occupies only that package's position, including when `D/node_modules` does not physically exist. Without both a declaration and a runtime entry, Node tries the physical candidate. A missing manifest does not stop lookup. `dependencies` and `devDependencies` do not enable interception, and a peer version range is not an additional resolver filter. The same rule applies to third-party peers such as React when the runtime table supplies them. A nearer physical candidate wins before any later ancestor peer declaration. If a selected package's `exports` rejects the request, Node's error is final. A legacy CommonJS subpath miss can continue to the next position, where that directory's peer rule applies again; the physical copy at an occupied position is not retried. Each new linked resolution reads the manifests at the positions it visits, without memoizing its selected route. An unreadable manifest supplies no peers; Node retains its native manifest diagnostics. This does not clear Node's caches or automatically reload already loaded modules. @@ -63,7 +65,7 @@ The runtime resolution is computed once at profile startup and consists of four - Installation closure: starting from the `package.json` of the currently running dsh package, a breadth-first traversal follows `dependencies` and `peerDependencies`; each edge resolves from the manifest that declares it per Node rules, and the first installed package found owns a package name. The closure holds several hundred entries, roughly half in the `@deepseek-ai/` scope and half third-party libraries. These entries apply to every profile. - Bundle-only entries: for a bundle selected by the profile that is not part of the closure, the same traversal starts from its manifest, and package names the closure already owns are not overridden. These entries apply only to profiles that select that bundle; they let the Loader import the bundle's embedded plugins by bare name from the profile root. - Local package names: package names among the profile's direct dependencies that are already installed in `$DSH_HOME/profiles//node_modules`. They already sit at ② on the ancestor chain; recording them only saves one directory probe. -- Linked roots: top-level and `@scope/*` links in `$DSH_HOME/profiles//node_modules` whose targets are directories outside the profiles tree; each records its real directory and link position. Their own manifests are optional. Missing targets, files, and directories inside the profiles tree are excluded. +- Linked roots: top-level and `@scope/*` links in `$DSH_HOME/profiles//node_modules` whose targets are directories outside both the shared profiles tree and the active profile directory; each records its link name and real directory. Their own manifests are optional. Missing targets and files are excluded. An application-owned profile outside the shared tree does not register its internal pnpm store links as external roots. The CLI derives the installation anchor from `import.meta.url`, which Node has already resolved to its real path; the Desktop Host builds it from its runtime directory, which is not a symlink. Both leave the installation root equal to its real directory, so bundle discovery and dependency traversal use the same location. During the traversal, every dependency level uses the real directory of its owning package as the lookup anchor for the next level and records the location of the manifest that declares it. After a hit, Node resolves from that declaring location and gets the same result the package's own internal imports get. Every entry records the package name, package directory, version, declaring location, and scope. Declared but uninstalled dependencies are skipped; the bundle package root itself does not become an entry. @@ -118,6 +120,7 @@ The profile links `/node_modules/my-plugin` to the plugin's real direct | Transitive dependency inside R → any package name | Participates | Each position uses its own manifest's peers; nearer physical candidates precede later peer declarations | | Link target R has no manifest → peer declared by an ancestor | Participates | Search starts at R; an earlier physical candidate wins, otherwise the ancestor peer can select the runtime package even without physical `node_modules` there | | Hoisted dependency whose real directory is outside every linked root → any package name | Does not participate | Native Node lookup from that dependency's real directory | +| Installation-scope package inside a broad linked directory → any package name | Does not participate in linked interception | Native lookup retains the host package's own dependencies | | Any importer → `require.resolve(pkg, { paths })` | Does not participate | Native Node lookup using the supplied paths | ### Part 3: Developer integration guide @@ -166,7 +169,7 @@ Two other layouts remain available: install `@deepseek-ai/dsh` in the plugin rep - Linked-root cases in the same file link a profile package to an external repository with a same-named devDependency copy in its `node_modules`. Package names cover an installation entry declared as a peer, one declared as a dependency, the repository's own third-party dependency, and an undeclared name; importers cover the repository's own files and transitive dependencies inside it. The cases assert agreement across the four resolution forms, that the devDependency copy is not read, and that the next resolution observes a rewritten `peerDependencies` declaration. - The [differential matrix](../../../../packages/boot/app-boot/tests/linked-resolution-matrix.spec.ts) constructs independent native, intercepted, and reference directories. Only the reference copy replaces eligible peer positions with links to the runtime package; Node resolves the resulting files. Single-package and pnpm monorepo cases compare entry, file, directory, import/require, `import.meta.resolve`, and explicit-path outcomes, including absent manifests, absent `node_modules`, malformed peer declarations, React, outside-scope helpers, and legacy subpath continuation. Assertions compare package selection, existing paths, error codes, and module identity; explicit paths use the pristine native reference. - Dedicated cases in the same file cover each row of Table 1: bare names and subpaths with and without `exports`, `#alias`, explicit `paths`, package self-reference, CommonJS skipping ③ and going straight to ④ when a subpath is missing after a runtime resolution hit, and the interception position of an out-of-tree profile. -- The [real CLI launch test](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts) covers src and lib launches with ordinary and npm-link layouts, plus a link to a manifest-free `src` directory whose parent declares a peer. It checks shared Tools/AgentLoop instances, native explicit-path selection of the developer's copy, real-path dependency resolution, and unchanged files and link targets. +- The [real CLI launch test](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts) covers src and lib launches with ordinary and npm-link layouts, plus a link to a manifest-free `src` directory whose parent declares a peer. It checks shared Tools/AgentLoop instances, native explicit-path selection of the developer's copy, real-path dependency resolution, and unchanged files and link targets. Source-mode CommonJS checks use fixture packages with existing JavaScript entries; real installation CommonJS entries require their build outputs. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md index cf33a9b5b1..9817f2001e 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md @@ -50,6 +50,8 @@ Node 仍然负责 `exports`、`imports`、conditions、`main`、子路径、扩 `/node_modules/<包名>` 链接到 profiles 树外的真实目录 R 时,R 是一个 linked root。R 不必包含 `package.json` 或 `node_modules`,可以是包目录、monorepo 中的单个包,也可以是 `src` 目录。R 之下的 importer 沿 Node 的真实祖先链查询。记录的根目录决定哪些 importer 参与拦截,不会把它们的查找限制在 R 内。 +linked 拦截排除 installation 作用域包目录内的 importer,同时识别配置路径和真实路径。因此,链接整个 checkout 不会接管宿主自身的依赖查询。重叠的 linked root 共同描述参与范围:祖先链由 importer 决定,root 顺序和链接名称都不会选择另一套 peer 集合。 + 在每个候选 `D/node_modules`,当前 `D/package.json` 的 peer 声明命中且运行时表提供该包名时,就使用运行时包。这只占据该包的位置,即使 `D/node_modules` 在磁盘上不存在也成立。声明和运行时条目缺少任一项,Node 就尝试物理候选。缺少 manifest 不会终止查找。`dependencies` 与 `devDependencies` 不启用拦截,peer 的版本范围也不是额外的解析筛选条件。运行时表提供 React 等第三方 peer 时,同样适用这条规则。 更近的物理候选先于后续祖先的 peer 声明。选中包的 `exports` 拒绝请求时,Node 的错误是终局。旧式 CommonJS 子路径缺失可以继续到下一位置,再应用那个目录的 peer 规则;不会重试已被占据位置上的物理副本。每次新的 linked 解析读取所访问位置的 manifest,不缓存选中的路由。不可读的 manifest 不提供 peer,Node 保留其原生 manifest 诊断。这不清除 Node 缓存,也不自动重新加载已加载的模块。 @@ -63,7 +65,7 @@ runtime resolution 在 profile 启动时一次算出,由四部分组成。 - installation 闭包:从当前运行的 dsh 包的 `package.json` 出发,沿 `dependencies` 与 `peerDependencies` 做广度优先遍历,每条边从声明它的 manifest 按 Node 规则解析,同一个包名由第一次找到的已安装包占有。闭包有数百条,约一半在 `@deepseek-ai/` 作用域,一半是第三方库。这些条目对所有 profile 生效。 - bundle-only 条目:profile 选中的、不属于闭包的 bundle,从它的 manifest 出发做同样的遍历,闭包已占有的包名不覆盖。这些条目只对选中该 bundle 的 profile 生效,用于让 Loader 从 profile 根按裸名导入 bundle 内嵌的插件。 - 本地包名:profile 直接依赖中已经安装在 `$DSH_HOME/profiles//node_modules` 的包名。它们本来就在主线 ② 上,记录下来只为免去一次目录探测。 -- linked root:`$DSH_HOME/profiles//node_modules` 顶层与 `@scope/*` 里目标为 profiles 树外目录的链接,记录真实目录与 link 位置。目录自身的 manifest 可缺省。目标缺失、为文件或位于 profiles 树内的链接都排除。 +- linked root:`$DSH_HOME/profiles//node_modules` 顶层与 `@scope/*` 里目标同时位于共享 profiles 树和当前 profile 目录之外的目录链接,记录链接名称与真实目录。目录自身的 manifest 可缺省。目标缺失或为文件的链接都排除。位于共享树外的应用自有 profile,不会把内部 pnpm store 链接登记为外部 root。 CLI 的安装锚点来自 `import.meta.url`,Node 已对它做过 realpath;Desktop Host 从其运行时目录拼出锚点,该目录不是软链接。两者的安装根目录都等于真实目录,bundle 发现与依赖遍历使用同一个位置。遍历中每一层依赖都以所属包的真实目录作为下一层的查找锚点,并记录声明它的 manifest 位置。命中后 Node 从这个声明位置解析,得到与该包自己内部 import 相同的结果。每个条目记录包名、包目录、版本、声明位置与作用域。已声明但未安装的依赖跳过;bundle 包根自身不成为条目。 @@ -118,6 +120,7 @@ ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harne | R 内传递依赖 → 任何包名 | 参与 | 每个位置使用自己的 manifest 的 peer;更近的物理候选先于后续 peer 声明 | | link 目标 R 无 manifest → 祖先声明的 peer | 参与 | 从 R 开始查;更近的物理候选优先,否则祖先 peer 可以选中运行时包,即使那里没有物理 `node_modules` | | 真实目录在所有 linked root 外的提升依赖 → 任何包名 | 不参与 | 从该依赖真实目录进行原生 Node 查询 | +| 位于宽链接目录内的 installation 作用域包 → 任何包名 | 不参与 linked 拦截 | 原生查询保留宿主包自身的依赖 | | 任意 importer → `require.resolve(pkg, { paths })` | 不参与 | 使用指定路径进行原生 Node 查询 | ### 三、开发者接入指引 @@ -166,7 +169,7 @@ manifest 的写法与 harness 自身的包相同:需要与宿主共享实例 - 同一文件内的 linked root 用例:`/node_modules` 的软链接指向树外仓库,仓库 `node_modules` 里放同名 devDependency 副本;包名取声明为 peer 的 installation 条目、声明为 dependency 的 installation 条目、仓库自己的第三方 dependency、未声明的包名,importer 取仓库自身文件与仓库内传递依赖;断言四种解析方式一致、devDependency 副本不被读到,以及改写 `peerDependencies` 后下一次解析立即按新声明进行。 - [差分矩阵](../../../../packages/boot/app-boot/tests/linked-resolution-matrix.spec.ts)构造独立的原生、拦截与参考目录。只有参考副本把合格 peer 位置替换为运行时包的链接,再让 Node 解析文件。单包与 pnpm monorepo 用例对比入口、文件、目录、import/require、`import.meta.resolve` 与显式路径的结果,覆盖 manifest 缺失、`node_modules` 缺失、错误 peer 声明、React、作用域外 helper 和旧式子路径继续查询。断言比较选包、现存路径、错误码与模块实例;显式 paths 使用未投射的原生参考。 - 同一文件内的专项用例覆盖表 1 各行:有无 `exports` 的裸包名与子路径、`#alias`、显式 `paths`、包自引用、命中 runtime resolution 后 CommonJS 子路径缺失时跳过 ③ 直接到 ④,以及树外 profile 的拦截位置。 -- [CLI 真启动测试](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts)覆盖 src 与 lib 启动、普通与 npm-link 布局,以及指向无 manifest 的 `src` 目录且其父目录声明 peer 的链接。测试检查 Tools/AgentLoop 共享实例、原生显式路径选中开发者副本、依赖沿真实路径解析,以及文件和链接目标保持不变。 +- [CLI 真启动测试](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts)覆盖 src 与 lib 启动、普通与 npm-link 布局,以及指向无 manifest 的 `src` 目录且其父目录声明 peer 的链接。测试检查 Tools/AgentLoop 共享实例、原生显式路径选中开发者副本、依赖沿真实路径解析,以及文件和链接目标保持不变。源码模式的 CommonJS 检查使用具备现存 JavaScript 入口的 fixture 包;安装中真实的 CommonJS 入口需要相应构建产物。 ## Consequences diff --git a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts index 90fb6bc7f2..9978557402 100644 --- a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts +++ b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts @@ -36,9 +36,10 @@ interface ResolutionEvidence { toolsInstance: boolean pluginToolsInstance: boolean sourceToolsInstance: boolean - profileToolsCjs: string - sourceToolsCjs: string - sourceToolsExplicitCjs: string + profileToolsCjs: string | null + sourceToolsCjs: string | null + sourceLeafCjs: string + sourceLeafExplicitCjs: string scheduler: boolean modules: string[] } @@ -70,6 +71,8 @@ export function testProfileResolution(mode: ExampleMode): void { const bridgeDir = layout === 'npm-link' ? join(root, 'dependencies', bridgeName) : installedBridge const logicalLeaf = join(bundleDir, 'node_modules', leafName) const realLeaf = join(root, 'dependencies', 'node_modules', leafName) + const sourcePackageDir = join(root, 'work', 'source-package') + const sourceDevLeaf = join(sourcePackageDir, 'node_modules', leafName) const exports = { import: './index.mjs', require: './index.cjs' } await writePackage(bundleDir, { name: bundleName, version: '1.0.0', dependencies: { [bridgeName]: '*' }, @@ -81,7 +84,9 @@ export function testProfileResolution(mode: ExampleMode): void { 'index.mjs': `export { leaf } from '${leafName}'`, 'index.cjs': `module.exports = require('${leafName}')`, }) - for (const [dir, version] of [[logicalLeaf, '1.0.0'], [realLeaf, '2.0.0']] as const) { + for (const [dir, version] of [ + [logicalLeaf, '1.0.0'], [realLeaf, '2.0.0'], [sourceDevLeaf, '0.0.0'], + ] as const) { await writePackage(dir, { name: leafName, version, exports }, { 'index.mjs': `export const leaf = { version: '${version}', url: import.meta.url }`, 'index.cjs': `exports.leaf = { version: '${version}', filename: __filename }`, @@ -137,19 +142,21 @@ export function testProfileResolution(mode: ExampleMode): void { 'index.mjs': "export default class Tools {}\nexport const TOOL_RUNTIME_SCHEDULER = Symbol()\nthrow new Error('STALE_DSH_TOOLS')", 'index.cjs': "throw new Error('STALE_DSH_TOOLS')", }) - const sourcePackageDir = join(root, 'work', 'source-package') const sourceDir = join(sourcePackageDir, 'src') + const toolsCjsExpression = mode === 'lib' ? "require.resolve('@deepseek-ai/dsh-tools')" : 'null' await mkdir(sourceDir, { recursive: true }) + // The ESM-only source hook does not map CommonJS exports to source, so its CJS probe uses a fixture-owned peer. await writePackage(sourcePackageDir, { - name: 'source-package', version: '1.0.0', peerDependencies: { '@deepseek-ai/dsh-tools': '*' }, + name: 'source-package', version: '1.0.0', peerDependencies: { '@deepseek-ai/dsh-tools': '*', [leafName]: '*' }, }, { 'src/query.mjs': [ "import { createRequire } from 'node:module'", "import Tools from '@deepseek-ai/dsh-tools'", 'export { Tools as SourceTools }', 'const require = createRequire(import.meta.url)', - "export const sourceToolsCjs = require.resolve('@deepseek-ai/dsh-tools')", - `export const sourceToolsExplicitCjs = require.resolve('@deepseek-ai/dsh-tools', { paths: [${JSON.stringify(sourceDir)}] })`, + `export const sourceToolsCjs = ${toolsCjsExpression}`, + `export const sourceLeafCjs = require.resolve('${leafName}')`, + `export const sourceLeafExplicitCjs = require.resolve('${leafName}', { paths: [${JSON.stringify(sourceDir)}] })`, ].join('\n'), }) for (const [target, link] of [ @@ -181,7 +188,7 @@ export function testProfileResolution(mode: ExampleMode): void { `import { leaf as bridgeLeaf } from '${bridgeName}'`, `import { external } from '${externalName}'`, `import { external as pluginExternal, PluginTools } from '${pluginName}'`, - `import { SourceTools, sourceToolsCjs, sourceToolsExplicitCjs } from '${sourceProbeName}/query.mjs'`, + `import { SourceTools, sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs } from '${sourceProbeName}/query.mjs'`, `import { leaf as externalLeaf } from ${JSON.stringify(pathToFileURL(join(externalLeaf, 'index.mjs')).href)}`, 'const require = createRequire(import.meta.url)', "export const inject = ['tools', 'agentLoop', 'loader']", @@ -205,8 +212,8 @@ export function testProfileResolution(mode: ExampleMode): void { ' toolsInstance: ctx.tools instanceof Tools,', ' pluginToolsInstance: ctx.tools instanceof PluginTools,', ' sourceToolsInstance: ctx.tools instanceof SourceTools,', - " profileToolsCjs: require.resolve('@deepseek-ai/dsh-tools'),", - ' sourceToolsCjs, sourceToolsExplicitCjs,', + ` profileToolsCjs: ${toolsCjsExpression},`, + ' sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs,', " scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',", ' modules: [...ctx.loader.internal.loadCache.keys()]', ' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),', @@ -227,7 +234,7 @@ export function testProfileResolution(mode: ExampleMode): void { ]), }) const packageDirs = [ - bundleDir, bridgeDir, logicalLeaf, realLeaf, pluginDir, externalDir, externalLeaf, + bundleDir, bridgeDir, logicalLeaf, realLeaf, sourceDevLeaf, pluginDir, externalDir, externalLeaf, sharedLeaf, ancestorExternal, ancestorLeaf, staleTools, sourcePackageDir, sourceDir, profileDir, ] const packageFiles = (await Promise.all(packageDirs.map(async dir => @@ -278,8 +285,12 @@ export function testProfileResolution(mode: ExampleMode): void { expect(evidence.pluginToolsInstance).toBe(true) expect(evidence.sourceToolsInstance).toBe(true) expect(evidence.sourceToolsCjs).toBe(evidence.profileToolsCjs) - expect(evidence.sourceToolsExplicitCjs).toBe(realpathSync.native(join(staleTools, 'index.cjs'))) - expect(evidence.sourceToolsCjs).not.toBe(evidence.sourceToolsExplicitCjs) + expect(evidence.profileToolsCjs).toBe(mode === 'lib' + ? realpathSync.native(join(repoRoot, 'packages/core/tools/lib/index.js')) + : null) + expect(evidence.sourceLeafCjs).toBe(evidence.cjs.filename) + expect(evidence.sourceLeafExplicitCjs).toBe(realpathSync.native(join(sourceDevLeaf, 'index.cjs'))) + expect(evidence.sourceLeafCjs).not.toBe(evidence.sourceLeafExplicitCjs) expect(evidence.scheduler).toBe(true) expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : []) for (const name of ['tools', 'agent-loop']) { diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index 4641bc607c..88df3856da 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.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 docs/user/develop/basic/publish.md -publish.md: bdc6f21ec6d3a4777cf3e0c14771c19a08ed4b5e -publish.zh.md: 43fcea22873733784f46d97bcd16bfdb297dc3ed +publish.md: b317943954972e63d0e4b741fb16d2fc19091f58 +publish.zh.md: b7864f7e7596681f48fc87590772841294d2a039 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index bdc6f21ec6..b317943954 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -104,6 +104,8 @@ A linked checkout keeps its own `node_modules`. Declare dsh packages whose insta Ordinary linked imports follow Node's ancestor order and check each directory's current peer declarations. A nearer physical package wins before a higher peer declaration. A link target can lack `package.json`; ancestor peers still apply, even without a physical `node_modules` beside that manifest. Explicit `require.resolve(..., { paths })` is always native, including paths inside a profile. These rules are shared by npm, Desktop, and source launches; they do not invalidate loaded modules or validate peer version ranges. See the [resolution rules](../../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md) for scope and file-query behavior. +Linking a broad checkout does not apply peer interception to the running installation's own package directories. Links whose targets stay inside the profile, including its pnpm store, remain profile-owned installation content rather than external linked roots. Overlapping external links do not change lookup order: each request still starts from its importer's directory. + Verify the layer without booting, then boot: ```sh diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 43fcea2287..b7864f7e75 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -104,6 +104,8 @@ dsh plugin --profile demo add ./hello-plugin 普通 linked import 遵循 Node 的祖先顺序,检查每个目录当前的 peer 声明。更近的物理包先于更高的 peer 声明。link 目标可以没有 `package.json`,祖先 peer 仍可生效,即使该 manifest 旁没有物理 `node_modules`。显式 `require.resolve(..., { paths })` 始终是原生查询,包括指向 profile 内的路径。npm、Desktop 与源码启动共用这些规则;规则不会使已加载模块失效,也不校验 peer 版本范围。作用域和文件查询行为见[解析规则](../../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md)。 +链接整个 checkout 不会对运行中安装自身的包目录应用 peer 拦截。目标仍位于 profile 内的链接(包括它的 pnpm store)属于 profile 自己安装的内容,不算外部 linked root。外部链接互相重叠也不改变查找顺序,每个请求仍从其 importer 所在目录开始。 + 先不启动、只验证该层,再启动: ```sh diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 67d3b0d201..0896d50887 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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 packages/boot/app-boot/README.md -README.md: a3c85a76c919cbedf548a35008660a0346a8e8f7 -README.zh.md: b53d8eeff682ffa6040a2a4c10a79f0b9cedb827 +README.md: e2bea1658eaff832eecef815f8d86215efbdf88b +README.zh.md: 927d88e4ca147cb7be0e2a6f839c6c94da96aef5 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index a3c85a76c9..e2bea1658e 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -115,12 +115,12 @@ This section explains how the outcomes above are realized and points at the code - **Profile launch data.** `ctx.profileContext` contains only profile locations, startup bundle names, parsed invocation overlays and the telemetry opt-out value. `readProfilePatches()` composes the supplied startup profile or reads current files at those locations; callers schedule and apply the result. - **Process-local module resolution.** The launcher installs the runtime resolution on Node's internal ESM and CommonJS resolvers before profile rows mount. Node still owns exports, conditions, subpaths, module caches, and error codes; routed ESM failures report the original importer. Explicit CommonJS `paths` always retain native lookup, including paths inside profiles. -- **Linked directories.** A profile link to an external directory admits its importers to peer-aware ancestor lookup, even without its own `package.json`. At each `D/node_modules` position, current `D/package.json` peer names present in the runtime table use the runtime package; other names use the physical candidate. A nearer physical package precedes a later peer declaration, and a peer position needs no physical `node_modules` ([rule](../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md)). +- **Linked directories.** A profile link to an external directory admits its importers to peer-aware ancestor lookup, even without its own `package.json`. At each `D/node_modules` position, current `D/package.json` peer names present in the runtime table use the runtime package; other names use the physical candidate. A nearer physical package precedes a later peer declaration, and a peer position needs no physical `node_modules`. Installation-scope package directories are excluded from linked interception; overlapping roots do not change the importer's lookup order ([rule](../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md)). - **Package metadata.** `ctx.pluginPackages.packageOf` locates the owning package without loading code or requiring an exported `package.json`; a subpath selects its package without validating that file. The installed runtime resolution owns selection even for a miss. Low-level embedders that install the service without one retain native lookup. Display metadata uses the separate entry-aware reader described above. - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution. - **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate Loader subtree when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint. - **One runtime resolution.** The installation-first and ordered-bundle breadth-first traversal produces the runtime table. Runtime resolution creates no links; runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`, and every other name sees that directory as an ordinary ancestor. Profile load removes the `.dsh-module-fallback` projections that link-backend releases wrote into a profile; pnpm-installed packages stay. External bare targets selected by package `imports` use the same package order, while Node retains mapping, conditions, and exact target resolution. A complete successor may add package names and update linked-root membership atomically within the existing package-mapping and local-name constraints; changing or removing an existing mapping, or pointing an existing linked root at another directory, requires restart. -- **Application-owned profiles.** Application-owned profiles use the same runtime resolution. Resolution does not modify their `node_modules`; pnpm owns installed packages. +- **Application-owned profiles.** Application-owned profiles use the same runtime resolution. Links within the active profile directory, including pnpm store links, are not external roots even when the profile is outside the shared profiles tree. Resolution does not modify their `node_modules`; pnpm owns installed packages. - **Owned Workers.** Worker build banners import `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` before bundled business code. Each Worker installs the structured-cloned runtime resolution in its own isolate. The bootstrap bundle has no static package imports. Source Worker entries retain their self-contained dependency closure, and third-party Workers receive no injection. - **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success. - **One rejection checkpoint.** `inactiveEntries` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. @@ -182,6 +182,7 @@ These limits describe when this boot library is a poor fit or needs special care - **Runtime resolution depends on Node internals** — supported Node versions require the native builtin-access addon and executable compatibility coverage. Only built Harness-owned Workers receive the runtime resolution bootstrap; third-party Workers and custom `vm` linkers keep native resolution. - **Relinking a profile package needs a restart** — Node caches real paths, so changing the target of a profile link or a dependency link requires a process restart. - **Linked scope follows recorded real directories** — a hoisted dependency outside every linked root uses native Node. Current peer reads do not invalidate Node caches, watch files, or validate peer version ranges. +- **Source launches use an ESM-only hook** — CommonJS requests still need the JavaScript files selected by package exports; resolution does not supply missing build outputs. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins. - **A user patch replaces the whole matched config** — an id-targeted patch does not deep-merge, so a profile override restates the bundle fields it keeps. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index b53d8eeff6..927d88e4ca 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -115,12 +115,12 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 - **Profile 启动数据。** `ctx.profileContext` 只包含 profile 位置、启动时组合包名称、已解析的调用级 overlay 与遥测退出值。`readProfilePatches()` 组合传入的启动 profile,或读取这些位置上的当前文件;调用方负责调度和应用结果。 - **进程内模块解析。** launcher 在挂载 profile 条目前,将 runtime resolution 安装到 Node 的 ESM 与 CommonJS 内部 resolver。exports、conditions、子路径、模块缓存和错误码仍由 Node 负责;路由后的 ESM 失败报告原始 importer。显式 CommonJS `paths` 始终保留原生查询,包括指向 profile 内的路径。 -- **链接目录。** profile 链接到树外目录时,其下的 importer 参与逐层 peer 查询,即使目标没有自身的 `package.json`。在每个 `D/node_modules` 位置,当前 `D/package.json` 的 peer 包名若存在于运行时表,就使用运行时包;其他包名查询物理候选。更近的物理包先于后续 peer 声明,peer 位置无需物理 `node_modules`([规则](../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md))。 +- **链接目录。** profile 链接到树外目录时,其下的 importer 参与逐层 peer 查询,即使目标没有自身的 `package.json`。在每个 `D/node_modules` 位置,当前 `D/package.json` 的 peer 包名若存在于运行时表,就使用运行时包;其他包名查询物理候选。更近的物理包先于后续 peer 声明,peer 位置无需物理 `node_modules`。installation 作用域包目录不参与 linked 拦截,重叠 root 不改变 importer 的查询顺序([规则](../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md))。 - **包元数据。** `ctx.pluginPackages.packageOf` 定位所属包,不加载代码,也不要求导出 `package.json`;子路径选择其所属包,不校验该文件。安装 runtime resolution 后,即使查询未命中也以其选包规则为准。仅安装服务而不提供 runtime resolution 的底层嵌入方保留原生查询。展示元数据使用上文另述的入口感知读取器。 - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。 - **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 Loader 子树。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。 - **唯一 runtime resolution。** 安装优先、有序 bundle 逐根 breadth-first 遍历生成运行时表。runtime 解析不创建链接;runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置,其余包名把该目录当作普通祖先。profile 加载时删除 Link 后端发布版写进 profile 的 `.dsh-module-fallback` 投影;pnpm 安装的包保留。package `imports` 选中的外部 bare target 使用相同的选包顺序,映射、conditions 和精确 target 解析仍由 Node 负责。完整后继 runtime resolution 可以在既有包映射和本地包名约束内原子增加 package name、更新 linked root 集合;修改或删除既有映射,或把既有 linked root 指向另一个目录,则要求重启。 -- **应用自有 profile。** 应用自有 profile 使用相同的 runtime resolution。解析过程不修改其 `node_modules`;已安装包由 pnpm 管理。 +- **应用自有 profile。** 应用自有 profile 使用相同的 runtime resolution。目标位于当前 profile 目录内的链接(包括 pnpm store 链接)不算外部 root,即使 profile 位于共享 profiles 树外。解析过程不修改其 `node_modules`;已安装包由 pnpm 管理。 - **自有 Worker。** Worker 构建 banner 会在业务 bundle 前导入 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。每个 Worker 在自己的 isolate 中安装结构化克隆的 runtime resolution。bootstrap bundle 不静态导入任何包。源码 Worker 入口保留自包含依赖,第三方 Worker 不接受注入。 - **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。 - **单一 rejection 检查点。** `inactiveEntries` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。 @@ -182,6 +182,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 - **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 runtime resolution bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。 - **重新链接 profile 包需要重启**——Node 缓存真实路径,因此改变 profile 链接或依赖链接的目标需要重启进程。 - **链接作用域以记录的真实目录为准**——提升后的依赖若在所有 linked root 之外,就使用原生 Node。实时读取 peer 不会使 Node 缓存失效、监视文件或校验 peer 版本范围。 +- **源码启动只安装 ESM 钩子**——CommonJS 请求仍需要 package exports 选中的 JavaScript 文件,解析器不会补出缺失的构建产物。 - **快照回放替换仅识别特定 basename**——只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境发现以启动为界**——`loadLayeredEnv` 只读取一次调用目录与 harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。 - **用户 patch 会替换匹配到的整个配置**——按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 diff --git a/packages/boot/app-boot/src/profile-resolution/resolver.ts b/packages/boot/app-boot/src/profile-resolution/resolver.ts index edbeafc597..215c82b0d0 100644 --- a/packages/boot/app-boot/src/profile-resolution/resolver.ts +++ b/packages/boot/app-boot/src/profile-resolution/resolver.ts @@ -89,12 +89,11 @@ interface LayerPositions { */ type InterceptionLayer = | LayerPositions & { readonly kind: 'profile'; readonly active: boolean } - | { readonly kind: 'linked'; readonly root: LinkedRoot } + | { readonly kind: 'linked' } interface CompiledLinkedRoot { readonly root: LinkedRoot readonly paths: readonly string[] - readonly layer: InterceptionLayer } interface ParentRoutes { @@ -112,6 +111,7 @@ interface CompiledResolution { readonly profileDir: string | undefined readonly profilePaths: readonly string[] readonly profile: readonly string[] + readonly installationPaths: readonly string[] readonly linkedRoots: readonly CompiledLinkedRoot[] readonly localPackageNames: ReadonlySet readonly esmRoutes: ResolutionRoutes @@ -175,7 +175,10 @@ function compileResolution(resolution: RuntimeResolution): CompiledResolution { profileDir: resolution.profileDir, profilePaths: prefixes(resolution.profilesDir), profile: resolution.profileDir === undefined ? [] : prefixes(resolution.profileDir), - linkedRoots: resolution.linkedRoots.map(root => ({ root, paths: prefixes(root.realPath), layer: computeLinkedLayer(root) })), + installationPaths: [...new Set(resolution.entries + .filter(entry => entry.scope === 'installation') + .flatMap(entry => prefixes(entry.packageDir)))], + linkedRoots: resolution.linkedRoots.map(root => ({ root, paths: prefixes(root.realPath) })), localPackageNames: new Set(resolution.localPackageNames), esmRoutes: new Map(), cjsRoutes: new Map(), @@ -196,22 +199,15 @@ function computeProfileLayer(dir: string, active: boolean): InterceptionLayer { } } -/** - * A linked directory admits its importers to peer-aware lookup along Node's real ancestor chain. - */ -function computeLinkedLayer(root: LinkedRoot): InterceptionLayer { - return { - kind: 'linked', root, - } -} - /** The interception layer of a module path: from its profile directory inside a profiles tree, or its linked root. */ function findInterceptionLayer(path: string, resolution: CompiledResolution): InterceptionLayer | undefined { const treeRoot = resolution.profilePaths.find(prefix => path.startsWith(prefix)) const activeProfile = resolution.profile.find(prefix => path.startsWith(prefix)) const dir = treeRoot !== undefined ? profileChild(path, treeRoot) : activeProfile?.slice(0, -1) if (dir !== undefined) return computeProfileLayer(dir, activeProfile !== undefined) - return resolution.linkedRoots.find(candidate => startsWithin(path, candidate.paths))?.layer + if (!resolution.linkedRoots.some(candidate => startsWithin(path, candidate.paths))) return undefined + if (startsWithin(path, resolution.installationPaths)) return undefined + return { kind: 'linked' } } /** Package names a directory's current manifest lists as peers; an unreadable manifest lists none. */ diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index ac0fb71734..c377b57438 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -113,15 +113,13 @@ export interface RuntimeResolutionEntry { } /** - * A profile node_modules entry linked to a directory outside the profiles tree. + * A profile node_modules entry linked to a directory outside the shared profiles tree and the active profile. * Importers below `realPath` use Node's real ancestor chain, with peer mappings read at each node_modules position. */ export interface LinkedRoot { /** Package name of the profile `node_modules` entry, including its scope. */ readonly name: string - /** The symlink or junction path under the active profile's node_modules. */ - readonly linkPath: string - /** Real directory the link resolves to; it lies outside the profiles tree and need not hold a package.json. */ + /** Real directory outside the shared profiles tree and active profile; a package.json is optional. */ readonly realPath: string } @@ -135,7 +133,7 @@ export interface RuntimeResolution { readonly localPackageNames: readonly string[] /** Installation-scope entries followed by profile-scope entries in precedence order. */ readonly entries: readonly RuntimeResolutionEntry[] - /** Active profile packages linked to directories outside the profiles tree, sorted by name. */ + /** Active profile links to external directories, sorted by name. */ readonly linkedRoots: readonly LinkedRoot[] } @@ -274,33 +272,35 @@ function symlinksUnder(modules: string): string[] { } /** - * Active profile `node_modules` entries linked to directories outside the profiles tree. - * Missing targets, files, and directories inside the profiles tree are not linked roots. + * Active profile `node_modules` entries linked outside the shared profiles tree and the active profile. + * Missing targets and files are not linked roots; invalid link chains retain Node's diagnostic. */ function linkedProfileRoots(profile: Profile, profilesDir: string): LinkedRoot[] { const modules = join(profile.dir, 'node_modules') + const links = symlinksUnder(modules) + if (links.length === 0) return [] let tree: string try { tree = realModuleDirectory(profilesDir) + sep } catch (error) { // A profiles tree that is not materialized yet holds no links. - /* v8 ignore next -- a non-ENOENT realpath failure requires a host filesystem fault */ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error tree = resolve(profilesDir) + sep } + const excludedTrees = [tree, realModuleDirectory(profile.dir) + sep] const roots: LinkedRoot[] = [] - for (const linkPath of symlinksUnder(modules)) { + for (const linkPath of links) { let realPath: string try { realPath = realModuleDirectory(linkPath) } catch (error) { // A dangling link is not a package Node can load from the profile. - /* v8 ignore next -- a non-ENOENT realpath failure requires a host filesystem fault */ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error continue } - if (realPath + sep === tree || realPath.startsWith(tree) || !statSync(realPath).isDirectory()) continue - roots.push({ name: relative(modules, linkPath).split(sep).join('/'), linkPath, realPath }) + if (excludedTrees.some(prefix => realPath + sep === prefix || realPath.startsWith(prefix)) + || !statSync(realPath).isDirectory()) continue + roots.push({ name: relative(modules, linkPath).split(sep).join('/'), realPath }) } return roots.sort((left, right) => left.name.localeCompare(right.name)) } diff --git a/packages/boot/app-boot/tests/profile-resolution.spec.ts b/packages/boot/app-boot/tests/profile-resolution.spec.ts index 86634ebc5a..7d0a78f2d3 100644 --- a/packages/boot/app-boot/tests/profile-resolution.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution.spec.ts @@ -1606,7 +1606,7 @@ describe('runtime resolution', { concurrent: false }, () => { registration.replace({ ...first, localPackageNames: ['new-local'] }) registration.replace({ ...first, localPackageNames: ['new-local'] }) expect(() => { registration.replace(first) }).toThrow(/removing local package/u) - const linked = { name: 'linked-plugin', linkPath: join(f.profile.dir, 'node_modules', 'linked-plugin'), realPath: join(f.root, 'work', 'a') } + const linked = { name: 'linked-plugin', realPath: join(f.root, 'work', 'a') } const withLocal = { ...first, localPackageNames: ['new-local'] } registration.replace({ ...withLocal, linkedRoots: [linked] }) registration.replace({ ...withLocal, linkedRoots: [] }) @@ -1705,9 +1705,9 @@ describe('runtime resolution', { concurrent: false }, () => { pkg(join(modules, 'installed'), 'installed', 8) const resolution = await resolutionOf(f) expect(resolution.linkedRoots).toEqual([ - { name: '@scope/linked', linkPath: join(modules, '@scope', 'linked'), realPath: scopedOutside }, - { name: 'bare-dir', linkPath: join(modules, 'bare-dir'), realPath: bare }, - { name: 'my-plugin', linkPath: join(modules, 'my-plugin'), realPath: outside }, + { name: '@scope/linked', realPath: scopedOutside }, + { name: 'bare-dir', realPath: bare }, + { name: 'my-plugin', realPath: outside }, ]) expect(Object.isFrozen(resolution.linkedRoots)).toBe(true) expect(Object.isFrozen(resolution.linkedRoots[0])).toBe(true) @@ -1721,6 +1721,68 @@ describe('runtime resolution', { concurrent: false }, () => { expect(unmaterialized.linkedRoots).toEqual([]) }) + it.each([ + { layout: 'directory', sharedTree: 'present' }, + { layout: 'symlink', sharedTree: 'present' }, + { layout: 'directory', sharedTree: 'missing' }, + { layout: 'symlink', sharedTree: 'missing' }, + ])( + 'excludes a $layout application-owned profile and its pnpm packages from linked roots ($sharedTree shared tree)', async ({ layout, sharedTree }) => { + const f = fixture() + const realProfile = join(f.root, 'application-profile') + const profileDir = layout === 'symlink' ? join(f.root, 'application-profile-alias') : realProfile + file(join(realProfile, 'package.json'), JSON.stringify({ name: 'application-profile', private: true })) + const local = join(realProfile, 'node_modules', '.pnpm', 'local@1.0.0', 'node_modules', 'local') + pkg(local, 'local', 2) + const outside = join(f.root, 'external-plugin') + pkg(outside, 'external-plugin', 3) + const shared = join(f.root, 'profiles', 'node_modules', 'shared-package') + pkg(shared, 'shared-package', 4) + const linkType = process.platform === 'win32' ? 'junction' : 'dir' + const links: string[] = [] + try { + if (layout === 'symlink') { + symlinkSync(realProfile, profileDir, linkType) + links.push(profileDir) + } + for (const [name, target] of [ + ['local', join(profileDir, 'node_modules', '.pnpm', 'local@1.0.0', 'node_modules', 'local')], + ['profile-self', profileDir], + ['profile-real', realProfile], + ['external-plugin', outside], + ['shared-package', shared], + ] as const) { + const link = join(profileDir, 'node_modules', name) + symlinkSync(target, link, linkType) + links.push(link) + } + if (sharedTree === 'missing') rmSync(join(f.root, 'profiles'), { recursive: true }) + const resolution = await createRuntimeResolution({ + installAnchor: f.installAnchor, + profile: { ...f.profile, dir: profileDir, patchPath: join(profileDir, 'cordis.patch.yml') }, + home: f.root, + }) + expect(resolution.linkedRoots).toEqual([{ name: 'external-plugin', realPath: outside }]) + } finally { + for (const link of links.reverse()) unlinkSync(link) + } + }, + ) + + it.each(['unmaterialized', 'empty'] as const)( + 'leaves an %s application-owned profile without linked roots', async (state) => { + const f = fixture() + const dir = join(f.root, 'application-profile') + if (state === 'empty') mkdirSync(join(dir, 'node_modules'), { recursive: true }) + const resolution = await createRuntimeResolution({ + installAnchor: f.installAnchor, + profile: { ...f.profile, dir, patchPath: join(dir, 'cordis.patch.yml') }, + home: f.root, + }) + expect(resolution.linkedRoots).toEqual([]) + }, + ) + it('excludes profile symlinks to files from linked roots', async () => { const f = fixture() const modules = join(f.profile.dir, 'node_modules') @@ -1734,6 +1796,38 @@ describe('runtime resolution', { concurrent: false }, () => { expect(resolution.linkedRoots).toEqual([]) }) + it.each(['linked target', 'profiles directory'] as const)( + 'reports a cyclic %s with the original Node error and path', async (location) => { + const f = fixture() + const home = location === 'profiles directory' ? join(f.root, 'cycle-home') : f.root + const modules = join(f.profile.dir, 'node_modules') + const outside = join(f.root, 'external-directory') + mkdirSync(outside, { recursive: true }) + mkdirSync(modules, { recursive: true }) + const cycle = location === 'profiles directory' ? join(home, 'profiles') : join(modules, 'cycle') + mkdirSync(dirname(cycle), { recursive: true }) + const linkType = process.platform === 'win32' ? 'junction' : 'dir' + const links: string[] = [] + try { + const ordinaryLink = join(modules, 'external-directory') + symlinkSync(outside, ordinaryLink, linkType) + links.push(ordinaryLink) + symlinkSync(cycle, cycle, linkType) + links.push(cycle) + const nativeError = thrownError(() => realpathSync.native(cycle)) + expect(nativeError.code).toBe('ELOOP') + expect(nativeError.path).toBe(cycle) + await expect(createRuntimeResolution({ + installAnchor: f.installAnchor, profile: f.profile, home, + })).rejects.toMatchObject({ + code: 'ELOOP', path: cycle, message: nativeError.message, + }) + } finally { + for (const link of links.reverse()) unlinkSync(link) + } + }, + ) + /** A plugin repository linked into the profile, with the manifest a plugin developer keeps for type checking. */ function linkedPluginFixture(peerDependencies: Record | undefined = { '@deepseek-ai/dsh-core': '*' }): { f: ReturnType @@ -1782,6 +1876,182 @@ describe('runtime resolution', { concurrent: false }, () => { return { f, linkedRoot, helper, writeManifest } } + it.each(['src', 'dist'] as const)( + 'keeps intermediate %s/node_modules ahead of linked-root peers and ancestor packages', async (directory) => { + const { f, linkedRoot } = linkedPluginFixture() + const intermediate = join(linkedRoot, directory) + const queries = [ + ['@deepseek-ai/dsh-core', 41], + ['@deepseek-ai/dsh-util', 42], + ['left-pad', 43], + ] as const + for (const [name, marker] of queries) pkg(join(intermediate, 'node_modules', name), name, marker) + const require = createRequire(join(intermediate, 'nested', 'entry.cjs')) + const parent = pathToFileURL(join(intermediate, 'nested', 'entry.mjs')).href + const registration = installRuntimeInterception(await resolutionOf(f)) + registrations.push(registration) + try { + for (const [name, marker] of queries) { + const selected = join(intermediate, 'node_modules', name) + expect(require.resolve(name), name).toBe(join(selected, 'index.cjs')) + expect(require(name), name).toEqual({ marker }) + expect(resolveFrom(name, parent), name).toBe(pathToFileURL(join(selected, 'index.js')).href) + expect(await importFrom(name, parent), name).toMatchObject({ marker }) + expect(registration.packageDir(name, parent), name).toBe(selected) + } + } finally { + registration.dispose() + registrations.pop() + } + }, + ) + + it.each([ + { names: 'outer-first', reverse: false }, + { names: 'outer-first', reverse: true }, + { names: 'inner-first', reverse: false }, + { names: 'inner-first', reverse: true }, + ])('resolves overlapping linked roots independently of names and order ($names, reverse=$reverse)', async ({ names, reverse }) => { + const f = fixture('inner-peer') + const installDir = dirname(f.installAnchor) + const outer = join(f.root, 'work', 'repo') + const inner = join(outer, 'packages', 'plugin') + pkg(outer, 'outer-repo', 0, {}, { 'shadowed-outer-peer': '*', 'outer-peer': '*' }) + pkg(inner, 'inner-plugin', 0, {}, { 'inner-peer': '*' }) + const queries = [ + { + name: 'inner-peer', nativeDir: join(inner, 'node_modules', 'inner-peer'), nativeMarker: 11, + selected: join(installDir, 'node_modules', 'inner-peer'), marker: 1, + }, + { + name: 'shadowed-outer-peer', nativeDir: join(inner, 'node_modules', 'shadowed-outer-peer'), nativeMarker: 12, + selected: join(inner, 'node_modules', 'shadowed-outer-peer'), marker: 12, + }, + { + name: 'outer-peer', nativeDir: join(outer, 'node_modules', 'outer-peer'), nativeMarker: 13, + selected: join(installDir, 'node_modules', 'outer-peer'), marker: 3, + }, + ] + pkg(installDir, '@deepseek-ai/dsh', 0, Object.fromEntries(queries.map(({ name }) => [name, '*']))) + for (const [index, query] of queries.entries()) { + pkg(join(installDir, 'node_modules', query.name), query.name, index + 1) + pkg(query.nativeDir, query.name, query.nativeMarker) + } + const outerName = names === 'outer-first' ? 'a-repo' : 'z-repo' + const innerName = names === 'outer-first' ? 'z-plugin' : 'a-plugin' + const modules = join(f.profile.dir, 'node_modules') + mkdirSync(modules, { recursive: true }) + const links: string[] = [] + try { + for (const [name, target] of [[outerName, outer], [innerName, inner]] as const) { + const link = join(modules, name) + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir') + links.push(link) + } + const resolution = await resolutionOf(f) + expect(resolution.linkedRoots.map(root => root.realPath)) + .toEqual(names === 'outer-first' ? [outer, inner] : [inner, outer]) + const linkedRoots = reverse ? [...resolution.linkedRoots].reverse() : resolution.linkedRoots + const require = createRequire(join(inner, 'entry.cjs')) + const parent = pathToFileURL(join(inner, 'entry.mjs')).href + for (const query of queries) expect(require.resolve(query.name)).toBe(join(query.nativeDir, 'index.cjs')) + const registration = installRuntimeInterception({ ...resolution, linkedRoots }) + registrations.push(registration) + try { + for (const { name, selected, marker } of queries) { + expect(require.resolve(name), name).toBe(join(selected, 'index.cjs')) + expect(require(name), name).toEqual({ marker }) + expect(resolveFrom(name, parent), name).toBe(pathToFileURL(join(selected, 'index.js')).href) + expect(await importFrom(name, parent), name).toMatchObject({ marker }) + expect(registration.packageDir(name, parent), name).toBe(selected) + } + } finally { + registration.dispose() + registrations.pop() + } + for (const query of queries) expect(require.resolve(query.name)).toBe(join(query.nativeDir, 'index.cjs')) + } finally { + for (const link of links.reverse()) unlinkSync(link) + } + }) + + it.each(['host', 'dependency', 'workspace-logical', 'workspace-real'] as const)( + 'keeps %s importers native inside a wide linked root', async (importer) => { + const name = '@deepseek-ai/dsh-core' + const f = fixture(name) + const installDir = dirname(f.installAnchor) + pkg(installDir, '@deepseek-ai/dsh', 0, { + [name]: '*', 'host-consumer': '*', 'workspace-consumer': '*', + }) + const hostSource = join(installDir, 'src') + const dependency = join(installDir, 'node_modules', 'host-consumer') + const workspace = join(f.root, 'workspace-consumer') + const workspaceLink = join(installDir, 'node_modules', 'workspace-consumer') + const external = join(f.root, 'external-plugin') + for (const [dir, packageName, marker] of [ + [hostSource, 'host-source', 11], + [dependency, 'host-consumer', 21], + [workspace, 'workspace-consumer', 22], + [external, 'external-plugin', 31], + ] as const) { + pkg(dir, packageName, 0, {}, { [name]: '*' }) + pkg(join(dir, 'node_modules', name), name, marker) + } + const linkType = process.platform === 'win32' ? 'junction' : 'dir' + const wideLink = join(f.profile.dir, 'node_modules', 'wide-workspace') + mkdirSync(dirname(wideLink), { recursive: true }) + const links: string[] = [] + try { + symlinkSync(workspace, workspaceLink, linkType) + links.push(workspaceLink) + symlinkSync(f.root, wideLink, linkType) + links.push(wideLink) + const importers = { + host: { dir: hostSource, marker: 11 }, + dependency: { dir: dependency, marker: 21 }, + 'workspace-logical': { dir: workspaceLink, marker: 22 }, + 'workspace-real': { dir: workspace, marker: 22 }, + } + const { dir, marker } = importers[importer] + const expectedDir = join(dir, 'node_modules', name) + const realDir = realpathSync.native(expectedDir) + const require = createRequire(join(dir, 'entry.cjs')) + const parent = pathToFileURL(join(dir, 'entry.mjs')).href + const nativeCjs = require.resolve(name) + const nativeEsm = resolveFrom(name, parent) + expect(nativeCjs).toBe(join(realDir, 'index.cjs')) + expect(nativeEsm).toBe(pathToFileURL(join(realDir, 'index.js')).href) + const resolution = await resolutionOf(f) + expect(resolution.entries.find(entry => entry.name === 'workspace-consumer')) + .toMatchObject({ scope: 'installation', packageDir: workspaceLink }) + expect(resolution.linkedRoots).toEqual([ + { name: 'wide-workspace', realPath: f.root }, + ]) + const registration = installRuntimeInterception(resolution) + registrations.push(registration) + try { + const externalRequire = createRequire(join(external, 'entry.cjs')) + const externalParent = pathToFileURL(join(external, 'entry.mjs')).href + expect(externalRequire.resolve(name)).toBe(join(f.installed, 'index.cjs')) + expect(externalRequire(name)).toEqual({ marker: 1 }) + expect(resolveFrom(name, externalParent)).toBe(pathToFileURL(join(f.installed, 'index.js')).href) + expect(await importFrom(name, externalParent)).toMatchObject({ marker: 1 }) + expect(registration.packageDir(name, externalParent)).toBe(f.installed) + expect(require.resolve(name)).toBe(nativeCjs) + expect(require(name)).toEqual({ marker }) + expect(resolveFrom(name, parent)).toBe(nativeEsm) + expect(await importFrom(name, parent)).toMatchObject({ marker }) + expect(registration.packageDir(name, parent)).toBe(expectedDir) + } finally { + registration.dispose() + registrations.pop() + } + } finally { + for (const link of links.reverse()) unlinkSync(link) + } + }, + ) + it('occupies only the peer names of a linked plugin at its own node_modules', async () => { const { f, linkedRoot, helper } = linkedPluginFixture() const registration = installRuntimeInterception(await resolutionOf(f))