Merge Cordis Core and resolver prerequisites for Loader upgrade

This commit is contained in:
Shigma committed 2026-10-14 18:03:01 +08:00
commit f265038f11
5 files changed
+197 -2

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
/** Loader resolves configuration imports in their project when Node internals are unavailable. */
import { Context } from '@deepseek-ai/cordis'
import Loader, { ModuleLoader } 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 })
})
it('uses the running Node loader signature selected by its module-job API', () => {
const loader = ModuleLoader.fromInternal()
expect(loader).toBeDefined()
const resolved = loader!.version === 'v2'
? loader!.resolveSync(import.meta.url, { specifier: 'node:path', attributes: {} })
: loader!.resolveSync('node:path', import.meta.url, {})
expect(resolved.url).toBe('node:path')
})
})
+1 -1
View File
@@ -50,7 +50,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 resolver slice from Cordis v4.0.0-rc.11**: `loader/src/resolve.ts` and the no-internals branch of `EntryTree.import()` use upstream project-relative ESM resolution, preserving import conditions, package imports and self-references. A helper under the nearest package scope supplies `import.meta.resolve`; absent scopes and helper-write failures retain the native import fallback. `loader/src/internal.ts` matches upstream runtime API detection, retiring the local Node-major workaround. Node builtin imports use default namespaces so the Web shell can bundle Loader with its existing browser externalization; the shell supplies its own module loader. The local resolver normalizes trailing directory separators before caching a scope, so concurrent requests from a root config and a nested config share one helper write. The Loader manifest remains at 1.0.0-rc.5 until the remaining package changes are reconciled. Covered by `scripts/cordis-loader-resolve.spec.ts` and the Node compatibility matrix.
20. **Loader fiber identity**: rc.11 Core canonicalizes `update()` and `restart()` through `ctx.fiber`, retiring the local unwrapping workaround. `Entry.fiber` retains the registry wrapper; volatile commits explicitly use the canonical fiber. HMR retains its existing replacement algorithm.
+4
View File
@@ -48,6 +48,10 @@ 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. It writes an ignored `.cordis/resolve.mjs` helper in that scope to preserve native ESM import conditions. Relative specifiers use the configuration URL directly. If no scope exists or helper creation fails, Loader uses its native import fallback; project-only dependencies can then remain unavailable. 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.
+4 -1
View File
@@ -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)
}
+75
View File
@@ -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))
}