mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge pull request #6207 from deepseek-harness/feat/cordis-loader-resolver
feat(cordis): sync project-relative Loader resolution
This commit is contained in:
commit
1fd099f7e2
7 files changed
+213
-7
No files matched your search
@@ -242,8 +242,11 @@ export default defineConfig({
|
||||
// through its own package.json exports from the importer's directory, and
|
||||
// CSS still rides Vite's pipeline because the client build preset emits it
|
||||
// beside the bundle. Plugin packages never enter this graph; they arrive as
|
||||
// runtime bundles through the client module system. The remaining alias
|
||||
// browserizes the vendored Cordis Loader's only Node import.
|
||||
// runtime bundles through the client module system. The node:module alias
|
||||
// supports Loader initialization; its filesystem resolver's other Node
|
||||
// imports must stay default imports for Vite's browser-external stubs.
|
||||
// Browser boot supplies loader.internal before importing plugin entries,
|
||||
// so the filesystem resolver is never invoked in the browser.
|
||||
alias: [
|
||||
{ find: /^node:module$/, replacement: src('./src/node-module-stub.ts') },
|
||||
],
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
@@ -23,6 +23,9 @@ import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import * as ClaudeCodeMods from '../src/index.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
// Keep workspace imports in Vitest's source graph even when the config has a package scope.
|
||||
vi.mock('@deepseek-ai/cordis-plugin-loader/src/resolve.ts', () => ({ createResolve: () => undefined }))
|
||||
|
||||
const FIXTURES = resolve(import.meta.dirname, 'fixtures')
|
||||
|
||||
let ctx: Context | undefined
|
||||
@@ -37,6 +40,7 @@ afterEach(async () => {
|
||||
|
||||
it('loads from cordis.yml, counts the model\'s tool calls, and answers /tally through the composed command registry', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-cc-mods-composition-'))
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({ type: 'module' }))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-llm', LlmRuntime],
|
||||
@@ -66,8 +70,6 @@ it('loads from cordis.yml, counts the model\'s tool calls, and answers /tally th
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
// Without a Node internal loader the Loader imports bare names through this
|
||||
// test runner's module graph, which resolves workspace packages to `src`.
|
||||
context.loader.internal = undefined
|
||||
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
|
||||
await context.loader.await()
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Loader resolves configuration imports in their project when Node internals are unavailable. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { existsSync } from 'node:fs'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { createResolve } from '../vendor/loader/src/resolve.ts'
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
const mkdir = vi.fn(actual.mkdir)
|
||||
return { ...actual, mkdir, default: { ...actual, mkdir } }
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
const root = await fs.mkdtemp(join(tmpdir(), 'dsh-cordis-resolve-'))
|
||||
onTestFinished(() => fs.rm(root, { recursive: true, force: true }))
|
||||
await fs.writeFile(join(root, 'package.json'), JSON.stringify({
|
||||
name: 'fixture-app', type: 'module',
|
||||
imports: { '#local': './local.js' }, exports: { './sub': './sub.js' },
|
||||
}))
|
||||
await fs.writeFile(join(root, 'local.js'), 'export const tag = "subpath-import"\n')
|
||||
await fs.writeFile(join(root, 'sub.js'), 'export const tag = "self-reference"\n')
|
||||
const dep = join(root, 'node_modules', 'fixture-dep')
|
||||
await fs.mkdir(dep, { recursive: true })
|
||||
await fs.writeFile(join(dep, 'package.json'), JSON.stringify({
|
||||
name: 'fixture-dep', type: 'module',
|
||||
exports: { '.': { import: './index.js' }, './sub': { import: './sub.js' } },
|
||||
}))
|
||||
await fs.writeFile(join(dep, 'index.js'), 'export const tag = "bare-dep"\n')
|
||||
await fs.writeFile(join(dep, 'sub.js'), 'export const tag = "dependency-subpath"\n')
|
||||
await fs.mkdir(join(root, 'src'))
|
||||
await fs.writeFile(join(root, 'src', 'plugin.js'), 'export const tag = "relative"\n')
|
||||
return { root, baseUrl: pathToFileURL(join(root, 'src', 'cordis.yml')).href }
|
||||
}
|
||||
|
||||
async function treeFor(baseUrl: string) {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(Loader, { baseUrl })
|
||||
ctx.loader.internal = undefined
|
||||
return ctx.loader.root.tree
|
||||
}
|
||||
|
||||
describe('Loader project resolution', () => {
|
||||
it('loads import-only dependencies, subpaths and package mappings from the config project', async () => {
|
||||
const { root, baseUrl } = await fixture()
|
||||
const tree = await treeFor(baseUrl)
|
||||
for (const [specifier, tag] of [
|
||||
['fixture-dep', 'bare-dep'], ['fixture-dep/sub', 'dependency-subpath'],
|
||||
['#local', 'subpath-import'], ['fixture-app/sub', 'self-reference'],
|
||||
] as const) {
|
||||
expect(await tree.import(specifier)).toMatchObject({ tag })
|
||||
}
|
||||
expect(existsSync(join(root, '.cordis', 'resolve.mjs'))).toBe(true)
|
||||
expect(existsSync(join(root, 'src', '.cordis'))).toBe(false)
|
||||
expect(await fs.readFile(join(root, '.cordis', '.gitignore'), 'utf8')).toContain('*')
|
||||
await expect(tree.import('fixture-dep/missing')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('keeps project scopes independent and shares concurrent resolution within one scope', async () => {
|
||||
const first = await fixture()
|
||||
const second = await fixture()
|
||||
const [a, b, c] = await Promise.all([
|
||||
createResolve(first.baseUrl),
|
||||
createResolve(pathToFileURL(join(first.root, 'cordis.yml')).href),
|
||||
createResolve(second.baseUrl),
|
||||
])
|
||||
expect(a).toBeTypeOf('function')
|
||||
expect(a).toBe(b)
|
||||
expect(a?.('fixture-dep')).not.toBe(c?.('fixture-dep'))
|
||||
})
|
||||
|
||||
it('imports relative, URL and builtin specifiers without changing their identity', async () => {
|
||||
const { root, baseUrl } = await fixture()
|
||||
const tree = await treeFor(baseUrl)
|
||||
expect(await tree.import('./plugin.js')).toMatchObject({ tag: 'relative' })
|
||||
expect(existsSync(join(root, '.cordis'))).toBe(false)
|
||||
expect(await tree.import(pathToFileURL(join(root, 'local.js')).href)).toMatchObject({ tag: 'subpath-import' })
|
||||
expect(await tree.import('node:path')).toMatchObject({ join })
|
||||
await expect(tree.import('./missing.js')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('retains native import when helper creation is denied', async () => {
|
||||
const { root, baseUrl } = await fixture()
|
||||
const tree = await treeFor(baseUrl)
|
||||
const denied = vi.mocked(fs.mkdir).mockRejectedValueOnce(Object.assign(new Error('read-only project'), { code: 'EACCES' }))
|
||||
onTestFinished(() => { denied.mockReset() })
|
||||
expect(await createResolve(baseUrl)).toBeUndefined()
|
||||
expect(await tree.import('node:path')).toMatchObject({ join })
|
||||
await expect(tree.import('fixture-dep')).rejects.toThrow()
|
||||
expect(existsSync(join(root, '.cordis'))).toBe(false)
|
||||
})
|
||||
|
||||
it('retains native import without a file-backed package scope', async () => {
|
||||
expect(await createResolve(undefined)).toBeUndefined()
|
||||
expect(await createResolve('https://example.com/cordis.yml')).toBeUndefined()
|
||||
const tree = await treeFor('https://example.com/cordis.yml')
|
||||
expect(await tree.import('node:path')).toMatchObject({ join })
|
||||
})
|
||||
})
|
||||
Vendored
+10
-1
@@ -24,6 +24,8 @@ Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspac
|
||||
| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
|
||||
| `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
|
||||
|
||||
**Partial Loader sync:** the package baseline remains 1.0.0-rc.5. `loader/src/resolve.ts` and the bare-specifier fallback in `loader/src/config/tree.ts` use the resolver implementation from Cordis `v4.0.0-rc.11` (Loader 1.0.0-rc.8). The [resolver adaptations](#resolver-adaptations) below record the local differences from that newer source.
|
||||
|
||||
Third-party dependencies of the vendored packages stay on npm: `@standard-schema/spec`, `js-yaml`, `chokidar`, `picomatch`, `@babel/code-frame`, `supports-color`, `node-addon-require-builtin`.
|
||||
|
||||
Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordisjs/utils`, `@cordisjs/element`, `@cordisjs/unyaml` (dev-time YAML import hook only).
|
||||
@@ -50,7 +52,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.
|
||||
17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).
|
||||
18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`.
|
||||
19. **`loader/src/internal.ts` runtime shape detection**: `ModuleLoader.fromInternal()` classifies the internal loader by which module-job API it owns — `getOrCreateModuleJob` for v2, `getModuleJobForImport` for v1 — instead of by Node major version. Upstream tags every major `>= 24` as v2, but the v2 interface arrived in Node 24.12.0, so 24.0–24.11.1 report major 24 while still carrying the v1 loader; consumers then called `resolveSync` with reversed parameters and every call threw. `dsh web` served an empty client graph (`__DSH_BOOT__.entries: []`) and HMR partial reload resolved no entry URL, both behind swallowed or warn-level errors. Arity cannot discriminate the two shapes, because each reports `resolveSync.length === 2`. A loader owning neither API is left unclassified rather than guessed, so consumers take their documented no-internals path. Covered on the `node-compat` Node version matrix, which pins 24.9 for the mistagged range.
|
||||
19. **`loader/src/internal.ts` runtime API detection (upstream in Cordis rc.11)**: classification by available module-job APIs already matches rc.11. It replaces the Node-major check in the pinned Loader rc.5 baseline; no additional detection patch is required when syncing to rc.11. Covered by the Node compatibility matrix.
|
||||
|
||||
20. **`loader/src/config/entry.ts` fiber identity**: stores the original fiber from the registry result’s context instead of its PromiseLike wrapper. Configuration updates and service notifications therefore mutate the same lifecycle state; updating a provider and consumer together cannot strand the consumer in `PENDING`. Covered by `packages/boot/hmr/tests/modules.spec.ts` and the built profile reload regression in `apps/cli/tests/built-bin.e2e.ts`.
|
||||
|
||||
@@ -62,6 +64,13 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
|
||||
24. **`timer/locale/{en,zh}.json` display metadata**: local English and Chinese titles and descriptions for the Settings plugin inventory. `timer/package.json` exports and publishes these locale resources.
|
||||
|
||||
### Resolver adaptations
|
||||
|
||||
Relative to the resolver source in Cordis `v4.0.0-rc.11`:
|
||||
|
||||
- **Web build imports:** `loader/src/resolve.ts` uses default imports for Node builtin modules to support the Web shell's browser externalization. The shell supplies its own module loader.
|
||||
- **Package-scope cache keys:** `loader/src/resolve.ts` normalizes directory paths with `path.resolve()` before finding the package scope. A root config and a nested config in the same package therefore share the same cached resolver promise and helper-file write. Covered by `scripts/cordis-loader-resolve.spec.ts`.
|
||||
|
||||
## Sync procedure
|
||||
|
||||
To update a vendored package from upstream:
|
||||
|
||||
Vendored
+10
@@ -48,6 +48,16 @@ root.loader.update(id, { config: { enabled: false } })
|
||||
|
||||
For file-backed trees, use `@cordisjs/plugin-include`.
|
||||
|
||||
## Module resolution
|
||||
|
||||
Without Node module internals, Loader resolves bare specifiers, package imports and self-references in the nearest package scope containing the configuration file. Relative specifiers use the configuration URL directly, and `cordis:` builtins bypass filesystem resolution. Other specifiers, including `node:` builtins and absolute URLs, initialize an ignored `.cordis/resolve.mjs` helper in that scope to preserve native ESM import conditions.
|
||||
|
||||
If no scope exists or writing the helper files fails, Loader uses its native import fallback. This fallback emits no diagnostic of its own; project-only dependencies can remain unavailable, and the resulting import error can name the Loader module rather than the configuration project. Failure to import a written helper propagates to the caller without this fallback.
|
||||
|
||||
Initialization results are cached for the lifetime of the resolver module, including failed writes and rejected helper imports. Fixing the filesystem does not retry initialization for an already cached scope. Concurrent initialization through filesystem aliases of the same package can contend for the temporary helper files and cause a write failure.
|
||||
|
||||
Node module internals are classified by their available APIs, including the v1 API used by Node 24.0–24.11.
|
||||
|
||||
## Volatile configuration
|
||||
|
||||
`Entry.update` compares raw entry options strictly; when only `config` changed on an active fiber in an unchanged context, the pure `equalExceptVolatile` function in `config/diff.ts` compares the raw configs using Schemastery metadata. It ignores volatile fields at fixed object paths without executing expressions, validators or `internal/config` hooks. Object nodes use their declared defaults for null or absent values, including objects without volatile fields; equivalent defaults skip updates. Other ordinary values, parent expressions and unknown fields retain raw comparison. Recursive schema backedges use raw comparison. Entry passes the actual plugin schema; a non-Schemastery schema compares raw. Context changes, other option changes and pending fibers use ordinary updates. A volatile-only change retains the raw Fiber config for later activation, then `_commitVolatile` parses it through the fiber's `internal/config` hook and schema, commits the new values into the running references when every ordinary effective value still matches, and emits `loader/volatile-update` to the owning fiber with the changed paths. A changed ordinary effective value uses the ordinary remount with a debug log naming the entry; because strict comparison treats class instances other than URL, Date and RegExp by identity, an ordinary field whose transform builds a fresh instance on every parse always takes that remount. An invalid candidate is logged and leaves the running references unchanged. A throwing `loader/volatile-update` listener is logged and does not fail the update; the values are already committed. Group/Include retain child dispatch, and programmatic Fiber updates preserve saving and `noSave`; `simplify` is called with its schema receiver so saved config contains plain values.
|
||||
Vendored
+4
-1
@@ -2,6 +2,7 @@ import { composeError, Context } from '@deepseek-ai/cordis'
|
||||
import { isNonNullable, type Dict } from '@deepseek-ai/cosmokit'
|
||||
import { Entry, type EntryOptions } from './entry.ts'
|
||||
import { EntryGroup } from './group.ts'
|
||||
import { createResolve } from '../resolve.ts'
|
||||
|
||||
/** Mutable tree of loader entries. Persistence is supplied by subclasses. */
|
||||
export abstract class EntryTree {
|
||||
@@ -123,7 +124,9 @@ export abstract class EntryTree {
|
||||
} else if (name.startsWith('.')) {
|
||||
return await import(/* @vite-ignore */new URL(name, this.ctx.baseUrl).href)
|
||||
} else {
|
||||
return await import(/* @vite-ignore */name)
|
||||
const resolve = await createResolve(this.ctx.baseUrl)
|
||||
const url = resolve ? resolve(name) : name
|
||||
return await import(/* @vite-ignore */ url)
|
||||
}
|
||||
}, getOuterStack)
|
||||
}
|
||||
|
||||
Vendored
+75
@@ -0,0 +1,75 @@
|
||||
import fs from 'node:fs'
|
||||
import fsPromises from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import url from 'node:url'
|
||||
import type { Dict } from '@deepseek-ai/cosmokit'
|
||||
|
||||
/** Resolve a specifier to its URL in one configuration project. */
|
||||
export type Resolve = (specifier: string) => string
|
||||
|
||||
// Order matters: the directory ignores itself before the helper appears in it.
|
||||
const HELPER_FILES = {
|
||||
'.gitignore': '# created by cordis automatically\n*\n',
|
||||
// The one-argument form of `import.meta.resolve` anchors on this file, so
|
||||
// resolution happens in the project scope. A resolve call also survives a
|
||||
// bundling host, which transforms modules inside its root and redirects the
|
||||
// `import()` in them to its own resolver.
|
||||
'resolve.mjs': 'export default (specifier) => import.meta.resolve(specifier)\n',
|
||||
}
|
||||
|
||||
const cache: Dict<Promise<Resolve | undefined>> = Object.create(null)
|
||||
|
||||
/** Nearest ancestor of `dir` that owns a `package.json`, if any. */
|
||||
function findScope(dir: string) {
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) return
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
function resolveScope(baseUrl: string | undefined) {
|
||||
if (!baseUrl) return
|
||||
let dir: string
|
||||
try {
|
||||
dir = url.fileURLToPath(new URL('.', baseUrl))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
return findScope(path.resolve(dir))
|
||||
}
|
||||
|
||||
async function write(filename: string, content: string) {
|
||||
// Another process may be writing the same path; `rename` is atomic, so a
|
||||
// concurrent reader only ever observes the complete helper.
|
||||
const temp = `${filename}.${process.pid}`
|
||||
await fsPromises.writeFile(temp, content)
|
||||
await fsPromises.rename(temp, filename)
|
||||
}
|
||||
|
||||
async function _createResolve(scope: string): Promise<Resolve | undefined> {
|
||||
const dir = path.join(scope, '.cordis')
|
||||
try {
|
||||
await fsPromises.mkdir(dir, { recursive: true })
|
||||
for (const [name, content] of Object.entries(HELPER_FILES)) {
|
||||
await write(path.join(dir, name), content)
|
||||
}
|
||||
} catch {
|
||||
// Read-only filesystems are legitimate; the caller falls back to a native `import()`.
|
||||
return
|
||||
}
|
||||
const helperUrl = url.pathToFileURL(path.join(dir, 'resolve.mjs')).href
|
||||
return (await import(/* @vite-ignore */ helperUrl)).default
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or reuse a native ESM resolver anchored in the config project's package scope.
|
||||
* @param baseUrl Config file URL; absent, non-file or unscoped URLs cannot supply a resolver.
|
||||
* @returns The project resolver, or undefined when no package scope exists or its helper cannot be written.
|
||||
*/
|
||||
export async function createResolve(baseUrl: string | undefined) {
|
||||
const scope = resolveScope(baseUrl)
|
||||
if (!scope) return
|
||||
return await (cache[scope] ??= _createResolve(scope))
|
||||
}
|
||||
Reference in new issue
Block a user