Merge remote-tracking branch 'origin/master' into feat/py-types-code-mode
This commit is contained in:
42
scripts/dev-web.spec.ts
Normal file
42
scripts/dev-web.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { TsdownBundle } from 'tsdown'
|
||||
import { watchClientPlugins } from './dev-web.ts'
|
||||
|
||||
it('rebuilds a client-plugin bundle after its source changes', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-'))
|
||||
let bundles: TsdownBundle[] = []
|
||||
try {
|
||||
await symlink(join(import.meta.dirname, '..', 'node_modules'), join(root, 'node_modules'), 'dir')
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({ name: '@dsh-test/dev-web-watch', private: true, type: 'module' }))
|
||||
await writeFile(join(root, 'tsdown.config.ts'), `
|
||||
import { defineConfig } from 'tsdown'
|
||||
export default defineConfig({
|
||||
entry: { client: 'src.ts' }, outDir: 'lib', format: 'cjs', platform: 'browser', dts: false, clean: false,
|
||||
outputOptions: { entryFileNames: 'client.js' },
|
||||
})
|
||||
`)
|
||||
const sourcePath = join(root, 'src.ts')
|
||||
const bundlePath = join(root, 'lib/client.js')
|
||||
await writeFile(sourcePath, 'export const version = "watch-v1"\n')
|
||||
bundles = await watchClientPlugins(root, ['.'], 50)
|
||||
await expect.poll(async () => {
|
||||
try {
|
||||
return (await readFile(bundlePath, 'utf8')).includes('watch-v1')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1_000))
|
||||
await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`)
|
||||
await expect.poll(async () => (await readFile(bundlePath, 'utf8')).includes('watch-v2-'), {
|
||||
timeout: 10_000,
|
||||
}).toBe(true)
|
||||
} finally {
|
||||
for (const bundle of bundles) await bundle[Symbol.asyncDispose]()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 20_000)
|
||||
@@ -18,9 +18,10 @@
|
||||
* keys under each package's file config, and no package config defines it).
|
||||
*/
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'tsdown'
|
||||
import type { TsdownBundle } from 'tsdown'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
@@ -29,46 +30,64 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
* whose package.json carries `dshClient` with platform "web" is a client
|
||||
* plugin bundle emitter. Scanned once at startup — a package added while
|
||||
* watching means restarting this script.
|
||||
* @param root - repository root containing the grouped package directories.
|
||||
* @returns workspace-relative plugin package directories.
|
||||
*/
|
||||
function discoverPluginDirs(): string[] {
|
||||
export function discoverPluginDirs(root = repoRoot): string[] {
|
||||
const dirs: string[] = []
|
||||
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: repoRoot }).sort()) {
|
||||
const manifest = JSON.parse(readFileSync(join(repoRoot, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
|
||||
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
|
||||
const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
|
||||
if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
const PLUGIN_DIRS = discoverPluginDirs()
|
||||
if (PLUGIN_DIRS.length === 0) {
|
||||
console.error('dev-web: no dshClient (platform "web") packages found under packages/')
|
||||
process.exit(1)
|
||||
/**
|
||||
* Start the tsdown watch build used by `pnpm run dev:web`.
|
||||
* @param root - repository or fixture root passed to tsdown.
|
||||
* @param pluginDirs - workspace-relative package directories to watch.
|
||||
* @param pollInterval - optional source-watcher polling interval in milliseconds.
|
||||
* @returns live bundles whose async disposers stop every watcher.
|
||||
*/
|
||||
export async function watchClientPlugins(
|
||||
root: string,
|
||||
pluginDirs: readonly string[],
|
||||
pollInterval?: number,
|
||||
): Promise<TsdownBundle[]> {
|
||||
return build({
|
||||
cwd: root,
|
||||
workspace: [...pluginDirs],
|
||||
watch: true,
|
||||
...pollInterval !== undefined
|
||||
? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
|
||||
: {},
|
||||
})
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
|
||||
if (args.some(a => a !== pollArg)) {
|
||||
console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
|
||||
process.exit(1)
|
||||
}
|
||||
const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
|
||||
if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
|
||||
console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
const invokedPath = process.argv[1]
|
||||
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
|
||||
if (isMain) {
|
||||
const pluginDirs = discoverPluginDirs()
|
||||
if (pluginDirs.length === 0) {
|
||||
console.error('dev-web: no dshClient (platform "web") packages found under packages/')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await build({
|
||||
cwd: repoRoot,
|
||||
workspace: PLUGIN_DIRS,
|
||||
watch: true,
|
||||
// Rolldown watch options ride through inputOptions (tsdown has no watcher
|
||||
// tuning of its own); polling is opt-in for network mounts without inotify.
|
||||
...pollInterval !== undefined
|
||||
? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
|
||||
: {},
|
||||
})
|
||||
console.log(
|
||||
`dev-web: watching ${String(PLUGIN_DIRS.length)} dshClient plugin packages`
|
||||
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${PLUGIN_DIRS.join('\n ')}`,
|
||||
)
|
||||
const args = process.argv.slice(2)
|
||||
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
|
||||
if (args.some(a => a !== pollArg)) {
|
||||
console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
|
||||
process.exit(1)
|
||||
}
|
||||
const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
|
||||
if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
|
||||
console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await watchClientPlugins(repoRoot, pluginDirs, pollInterval)
|
||||
console.log(
|
||||
`dev-web: watching ${String(pluginDirs.length)} dshClient plugin packages`
|
||||
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -303,7 +303,8 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
|
||||
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
|
||||
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:22' },
|
||||
{ name: 'hmr/config-update-failed', summary: 'A watched config-file refresh failed.', source: 'vendor/hmr/src/index.ts:29' },
|
||||
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
|
||||
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
|
||||
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
|
||||
|
||||
72
scripts/verify-vendored-links.ts
Normal file
72
scripts/verify-vendored-links.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Verify that pnpm-lock.yaml resolves every vendored package name to its
|
||||
* workspace `link:` — never a registry copy. `linkWorkspacePackages: true`
|
||||
* (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the
|
||||
* pinned vendored sources; a registry copy of the same name coexisting with
|
||||
* the vendored one silently forks the framework layer (vendor/README.md).
|
||||
*/
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
async function vendoredNames(): Promise<Set<string>> {
|
||||
const names = new Set<string>()
|
||||
for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
let manifest: { name?: string }
|
||||
try {
|
||||
manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string }
|
||||
} catch {
|
||||
continue // not a package directory (e.g. vendor/README.md siblings)
|
||||
}
|
||||
if (manifest.name !== undefined) names.add(manifest.name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
interface Lockfile {
|
||||
importers?: Record<string, Record<string, unknown>>
|
||||
packages?: Record<string, unknown>
|
||||
snapshots?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const names = await vendoredNames()
|
||||
if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/')
|
||||
const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile
|
||||
|
||||
const violations: string[] = []
|
||||
|
||||
// Importer resolutions: every dependency entry naming a vendored package must
|
||||
// resolve to a link:, or the build silently uses a registry copy.
|
||||
for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) {
|
||||
for (const [section, dependencies] of Object.entries(sections)) {
|
||||
if (typeof dependencies !== 'object' || dependencies === null) continue
|
||||
for (const [dependency, entry] of Object.entries(dependencies as Record<string, { version?: string }>)) {
|
||||
if (!names.has(dependency)) continue
|
||||
const version = entry.version ?? ''
|
||||
if (!version.startsWith('link:')) {
|
||||
violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Package/snapshot keys: a registry copy materializes as a `<name>@<version>`
|
||||
// key; vendored names must never appear there at all.
|
||||
for (const section of ['packages', 'snapshots'] as const) {
|
||||
for (const key of Object.keys(lockfile[section] ?? {})) {
|
||||
const atIndex = key.lastIndexOf('@')
|
||||
if (atIndex <= 0) continue
|
||||
const packageName = key.slice(0, atIndex)
|
||||
if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`)
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`)
|
||||
for (const violation of violations) console.error(` - ${violation}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`)
|
||||
Reference in New Issue
Block a user