feat: launch dsh source with native TypeScript
This commit is contained in:
199
apps/cli/src/tsconfig-paths-loader.ts
Normal file
199
apps/cli/src/tsconfig-paths-loader.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Node module resolve hook for the `dsh` source launcher. It projects the root
|
||||||
|
* tsconfig `paths` map into Node resolution while leaving all TypeScript syntax
|
||||||
|
* handling to Node's native transform-types runtime.
|
||||||
|
* @module @deepseek-ai/dsh/tsconfig-paths-loader
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, stat } from 'node:fs/promises'
|
||||||
|
import { dirname, extname, join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||||
|
import type { ResolveHookContext, ResolveFnOutput } from 'node:module'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
interface LoaderData {
|
||||||
|
tsconfigPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageManifest {
|
||||||
|
name?: string
|
||||||
|
dependencies?: Record<string, string>
|
||||||
|
optionalDependencies?: Record<string, string>
|
||||||
|
peerDependencies?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PathRule {
|
||||||
|
pattern: string
|
||||||
|
prefix: string
|
||||||
|
suffix: string
|
||||||
|
targets: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const
|
||||||
|
|
||||||
|
/** Resolve package imports through one parsed tsconfig paths table. */
|
||||||
|
export class TsconfigPathsResolver {
|
||||||
|
private readonly rules: readonly PathRule[]
|
||||||
|
private readonly configDirectory: string
|
||||||
|
private readonly manifests = new Map<string, Promise<PackageManifest | undefined>>()
|
||||||
|
|
||||||
|
private constructor(tsconfigPath: string, paths: ts.MapLike<string[]>) {
|
||||||
|
this.configDirectory = dirname(tsconfigPath)
|
||||||
|
this.rules = Object.entries(paths)
|
||||||
|
.map(([pattern, targets]) => {
|
||||||
|
const wildcard = pattern.indexOf('*')
|
||||||
|
return {
|
||||||
|
pattern,
|
||||||
|
prefix: wildcard === -1 ? pattern : pattern.slice(0, wildcard),
|
||||||
|
suffix: wildcard === -1 ? '' : pattern.slice(wildcard + 1),
|
||||||
|
targets,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftExact = left.pattern.includes('*') ? 0 : 1
|
||||||
|
const rightExact = right.pattern.includes('*') ? 0 : 1
|
||||||
|
return rightExact - leftExact || right.prefix.length - left.prefix.length || right.suffix.length - left.suffix.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a tsconfig including its `extends` chain.
|
||||||
|
* @param tsconfigPath Absolute tsconfig path supplying `compilerOptions.paths`.
|
||||||
|
* @returns A resolver backed by that path table.
|
||||||
|
*/
|
||||||
|
static create(tsconfigPath: string): TsconfigPathsResolver {
|
||||||
|
let unrecoverable: ts.Diagnostic | undefined
|
||||||
|
const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, {
|
||||||
|
...ts.sys,
|
||||||
|
onUnRecoverableConfigFileDiagnostic(diagnostic) { unrecoverable = diagnostic },
|
||||||
|
})
|
||||||
|
if (parsed === undefined) {
|
||||||
|
const detail = unrecoverable === undefined
|
||||||
|
? 'unknown configuration error'
|
||||||
|
: ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n')
|
||||||
|
throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`)
|
||||||
|
}
|
||||||
|
const paths = parsed.options.paths
|
||||||
|
if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`)
|
||||||
|
return new TsconfigPathsResolver(tsconfigPath, paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one bare package specifier to a source file when the importing
|
||||||
|
* package (or config-directory owner) declares that package at runtime.
|
||||||
|
* @param specifier Module specifier passed to Node.
|
||||||
|
* @param parentURL Importing file or Loader config-directory URL.
|
||||||
|
* @returns Source file URL, or `undefined` when normal Node resolution owns the request.
|
||||||
|
*/
|
||||||
|
async resolve(specifier: string, parentURL: string | undefined): Promise<string | undefined> {
|
||||||
|
const packageName = packageNameFromSpecifier(specifier)
|
||||||
|
if (packageName === undefined || parentURL === undefined || !parentURL.startsWith('file:')) return undefined
|
||||||
|
const matched = this.match(specifier)
|
||||||
|
if (matched === undefined) return undefined
|
||||||
|
const configParent = parentURL.endsWith('/')
|
||||||
|
const parentPath = fileURLToPath(parentURL)
|
||||||
|
const startDirectory = configParent ? parentPath : dirname(parentPath)
|
||||||
|
if (!await this.isDeclaredRuntimeDependency(startDirectory, packageName, configParent)) return undefined
|
||||||
|
|
||||||
|
for (const target of matched.targets) {
|
||||||
|
const substituted = target.replace('*', matched.wildcard)
|
||||||
|
const candidate = await existingSourcePath(resolve(this.configDirectory, substituted))
|
||||||
|
if (candidate !== undefined) return pathToFileURL(candidate).href
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private match(specifier: string): { targets: readonly string[]; wildcard: string } | undefined {
|
||||||
|
for (const rule of this.rules) {
|
||||||
|
if (!rule.pattern.includes('*')) {
|
||||||
|
if (specifier === rule.pattern) return { targets: rule.targets, wildcard: '' }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!specifier.startsWith(rule.prefix) || !specifier.endsWith(rule.suffix)) continue
|
||||||
|
const wildcard = specifier.slice(rule.prefix.length, specifier.length - rule.suffix.length)
|
||||||
|
return { targets: rule.targets, wildcard }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isDeclaredRuntimeDependency(
|
||||||
|
startDirectory: string,
|
||||||
|
packageName: string,
|
||||||
|
searchAncestors: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
for (let directory = startDirectory; ; directory = dirname(directory)) {
|
||||||
|
const manifest = await this.readManifest(join(directory, 'package.json'))
|
||||||
|
if (manifest !== undefined) {
|
||||||
|
if (declaresRuntimeDependency(manifest, packageName)) return true
|
||||||
|
if (!searchAncestors) return false
|
||||||
|
}
|
||||||
|
const parent = dirname(directory)
|
||||||
|
if (parent === directory) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readManifest(path: string): Promise<PackageManifest | undefined> {
|
||||||
|
let pending = this.manifests.get(path)
|
||||||
|
if (pending !== undefined) return pending
|
||||||
|
pending = readFile(path, 'utf8').then(
|
||||||
|
content => JSON.parse(content) as PackageManifest,
|
||||||
|
(error: unknown) => {
|
||||||
|
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
||||||
|
throw error
|
||||||
|
},
|
||||||
|
)
|
||||||
|
this.manifests.set(path, pending)
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolver: TsconfigPathsResolver | undefined
|
||||||
|
|
||||||
|
/** Initialize the hook worker from the source-launch preloader. */
|
||||||
|
export function initialize(data: LoaderData): void {
|
||||||
|
resolver = TsconfigPathsResolver.create(data.tsconfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve declared workspace packages to source and delegate every other request to Node. */
|
||||||
|
export async function resolveHook(
|
||||||
|
specifier: string,
|
||||||
|
context: ResolveHookContext,
|
||||||
|
nextResolve: (specifier: string, context: ResolveHookContext) => Promise<ResolveFnOutput>,
|
||||||
|
): Promise<ResolveFnOutput> {
|
||||||
|
const url = await resolver?.resolve(specifier, context.parentURL)
|
||||||
|
return url === undefined ? nextResolve(specifier, context) : { url, shortCircuit: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node customization hooks discover this exact export name.
|
||||||
|
export { resolveHook as resolve }
|
||||||
|
|
||||||
|
function packageNameFromSpecifier(specifier: string): string | undefined {
|
||||||
|
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || specifier.startsWith('file:')) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const segments = specifier.split('/')
|
||||||
|
return specifier.startsWith('@')
|
||||||
|
? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
|
||||||
|
: segments[0] || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaresRuntimeDependency(manifest: PackageManifest, packageName: string): boolean {
|
||||||
|
return manifest.name === packageName
|
||||||
|
|| packageName in (manifest.dependencies ?? {})
|
||||||
|
|| packageName in (manifest.optionalDependencies ?? {})
|
||||||
|
|| packageName in (manifest.peerDependencies ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function existingSourcePath(base: string): Promise<string | undefined> {
|
||||||
|
const candidates = extname(base) === ''
|
||||||
|
? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))]
|
||||||
|
: [base]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
if ((await stat(candidate)).isFile()) return candidate
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
15
bin/dsh
15
bin/dsh
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE with this checkout's
|
# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through Node's native
|
||||||
# tsx, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the
|
# TypeScript transform, so a symlink from anywhere (e.g. ~/.local/bin/dsh)
|
||||||
# current working tree — code changes apply on the next launch, no build step.
|
# always executes the current working tree without a build step.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# Resolve symlink chains without readlink -f (not on every macOS).
|
# Resolve symlink chains without readlink -f (not on every macOS).
|
||||||
@@ -15,7 +15,8 @@ while [ -L "$script" ]; do
|
|||||||
done
|
done
|
||||||
root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd)
|
root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd)
|
||||||
|
|
||||||
# tsx is imported by absolute path because bare `--import tsx` resolves from
|
# The preloader projects this checkout's tsconfig paths into Node resolution;
|
||||||
# the invoking cwd, which is usually outside this repository.
|
# TypeScript transformation itself remains Node-owned (no tsx/esbuild hook).
|
||||||
export TSX_TSCONFIG_PATH="$root/tsconfig.json"
|
exec node --experimental-transform-types \
|
||||||
exec node --import "$root/node_modules/tsx/dist/loader.mjs" "$root/apps/cli/src/bin.ts" "$@"
|
--import "$root/scripts/tspath-loader.ts" \
|
||||||
|
"$root/apps/cli/src/bin.ts" "$@"
|
||||||
|
|||||||
@@ -95,13 +95,13 @@
|
|||||||
"constraints": "tsx scripts/check-workspace-constraints.ts",
|
"constraints": "tsx scripts/check-workspace-constraints.ts",
|
||||||
"doc-sync": "tsx scripts/run-gates.ts doc-sync",
|
"doc-sync": "tsx scripts/run-gates.ts doc-sync",
|
||||||
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
|
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
|
||||||
"dsh": "node --import tsx apps/cli/src/bin.ts",
|
"dsh": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts",
|
||||||
"demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
|
"demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
|
||||||
"demo:tui": "node --import tsx apps/cli/src/bin.ts",
|
"demo:tui": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts",
|
||||||
"demo:code-mode": "node scripts/demo-code-mode.mjs",
|
"demo:code-mode": "node scripts/demo-code-mode.mjs",
|
||||||
"demo:cordis": "node scripts/demo-cordis.mjs",
|
"demo:cordis": "node scripts/demo-cordis.mjs",
|
||||||
"demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
|
"demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
|
||||||
"demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web",
|
"demo:web": "npm run build && npm run build:web && node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web",
|
||||||
"mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts",
|
"mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts",
|
||||||
"dev:web": "tsx scripts/dev-web.ts --poll",
|
"dev:web": "tsx scripts/dev-web.ts --poll",
|
||||||
"postinstall": "node scripts/install-lefthook.mjs"
|
"postinstall": "node scripts/install-lefthook.mjs"
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After the tree settles, reject entries with no fiber, which indicates a
|
* After the tree settles, reject entries with no fiber and name every plugin
|
||||||
* swallowed module-import failure. Disabled entries are the only valid
|
* whose module failed to resolve. Disabled entries are the only valid
|
||||||
* fiber-less state.
|
* fiber-less state.
|
||||||
* @param ctx - the settled context whose loader entries to audit.
|
* @param ctx - the settled context whose loader entries to audit.
|
||||||
* @param binName - the diagnostic prefix on the thrown error.
|
* @param binName - the diagnostic prefix on the thrown error.
|
||||||
@@ -152,7 +152,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
|||||||
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
|
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||||
if (failed.length > 0) {
|
if (failed.length > 0) {
|
||||||
const names = failed.map(entry => entry.options.name).join(', ')
|
const names = failed.map(entry => entry.options.name).join(', ')
|
||||||
throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
|
throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import { spawn } from 'node:child_process'
|
|||||||
|
|
||||||
// Each UI's node invocation matches its base demo script plus the overlay config.
|
// Each UI's node invocation matches its base demo script plus the overlay config.
|
||||||
const UIS = new Map([
|
const UIS = new Map([
|
||||||
['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/tui-agent/code-mode.cordis.yml']],
|
['tui', [
|
||||||
|
'--experimental-transform-types',
|
||||||
|
'--import',
|
||||||
|
'./scripts/tspath-loader.ts',
|
||||||
|
'apps/cli/src/bin.ts',
|
||||||
|
'--config',
|
||||||
|
'examples/tui-agent/code-mode.cordis.yml',
|
||||||
|
]],
|
||||||
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
|
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
14
scripts/tspath-loader.ts
Normal file
14
scripts/tspath-loader.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/** Register source-only tsconfig paths resolution before a TypeScript entry loads. */
|
||||||
|
|
||||||
|
import { register } from 'node:module'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const tsconfigPath = process.env.TSX_TSCONFIG_PATH === undefined
|
||||||
|
? fileURLToPath(new URL('../tsconfig.json', import.meta.url))
|
||||||
|
: resolve(process.env.TSX_TSCONFIG_PATH)
|
||||||
|
|
||||||
|
register(new URL('../apps/cli/src/tsconfig-paths-loader.ts', import.meta.url), {
|
||||||
|
parentURL: import.meta.url,
|
||||||
|
data: { tsconfigPath },
|
||||||
|
})
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Validate Cordis Loader entry metadata and example package resolution.
|
* Validate Cordis Loader entry metadata and package resolution.
|
||||||
*
|
*
|
||||||
* The Loader interpolates only a plugin entry's `config`; expression objects in
|
* The Loader interpolates only a plugin entry's `config`; expression objects in
|
||||||
* fields such as `disabled` remain truthy data and silently change composition.
|
* fields such as `disabled` remain truthy data and silently change composition.
|
||||||
* Example configs run from built packages, so every named package must resolve
|
* Example configs and the dsh Web composition resolve named plugins from their
|
||||||
* from the examples workspace and every local package must be in the root
|
* owning workspace manifests. Local example packages must also be in the root
|
||||||
* TypeScript project graph.
|
* TypeScript project graph.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ const schema = yaml.JSON_SCHEMA.extend(jsExprType)
|
|||||||
|
|
||||||
const files = cordisConfigFiles(root)
|
const files = cordisConfigFiles(root)
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
const examplePluginReferences: PluginReference[] = []
|
const pluginReferences: PluginReference[] = []
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
|
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
|
||||||
@@ -56,9 +56,10 @@ for (const file of files) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
errors.push(...validateExampleResolution())
|
errors.push(...validateExampleResolution())
|
||||||
|
errors.push(...validateAppResolution())
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
console.error('verify-cordis-config: invalid Loader metadata or example package resolution:')
|
console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:')
|
||||||
for (const error of errors) console.error(`- ${error}`)
|
for (const error of errors) console.error(`- ${error}`)
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
} else {
|
} else {
|
||||||
@@ -70,7 +71,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
|||||||
errors.push(`${file}${path}: entry must be an object`)
|
errors.push(`${file}${path}: entry must be an object`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordExamplePlugin(value, file)
|
recordPlugin(value, file)
|
||||||
validateMetadata(value, file, path)
|
validateMetadata(value, file, path)
|
||||||
if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
|
if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
|
||||||
for (let index = 0; index < value.config.length; index++) {
|
for (let index = 0; index < value.config.length; index++) {
|
||||||
@@ -84,7 +85,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
|||||||
const patch = config.patches[index]
|
const patch = config.patches[index]
|
||||||
const patchPath = `${path}.config.patches[${index}]`
|
const patchPath = `${path}.config.patches[${index}]`
|
||||||
if (!isRecord(patch)) continue
|
if (!isRecord(patch)) continue
|
||||||
recordExamplePlugin(patch, file)
|
recordPlugin(patch, file)
|
||||||
validateMetadata(patch, file, patchPath)
|
validateMetadata(patch, file, patchPath)
|
||||||
if (!isUnknownArray(patch.insert)) continue
|
if (!isUnknownArray(patch.insert)) continue
|
||||||
for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
|
for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
|
||||||
@@ -93,10 +94,8 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordExamplePlugin(entry: Record<string, unknown>, file: string): void {
|
function recordPlugin(entry: Record<string, unknown>, file: string): void {
|
||||||
if (file.startsWith('examples/') && typeof entry.name === 'string') {
|
if (typeof entry.name === 'string') pluginReferences.push({ file, name: entry.name })
|
||||||
examplePluginReferences.push({ file, name: entry.name })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateExampleResolution(): string[] {
|
function validateExampleResolution(): string[] {
|
||||||
@@ -105,25 +104,13 @@ function validateExampleResolution(): string[] {
|
|||||||
const dependencies = exampleManifest.dependencies ?? {}
|
const dependencies = exampleManifest.dependencies ?? {}
|
||||||
const localPackages = localPackageDirectories()
|
const localPackages = localPackageDirectories()
|
||||||
const rootReferences = rootProjectReferences()
|
const rootReferences = rootProjectReferences()
|
||||||
const requiredPackages = new Map<string, Set<string>>()
|
const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/'))
|
||||||
|
violations.push(...missingPluginDependencies(exampleReferences, dependencies, 'examples/package.json'))
|
||||||
for (const reference of examplePluginReferences) {
|
const requiredPackages = new Set(exampleReferences.map(reference => packageNameFromSpecifier(reference.name)))
|
||||||
const packageName = packageNameFromSpecifier(reference.name)
|
|
||||||
if (packageName === undefined) continue
|
|
||||||
const locations = requiredPackages.get(packageName) ?? new Set<string>()
|
|
||||||
locations.add(reference.file)
|
|
||||||
requiredPackages.set(packageName, locations)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [packageName, locations] of requiredPackages) {
|
|
||||||
if (!(packageName in dependencies)) {
|
|
||||||
violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const localExamplePackages = new Set([
|
const localExamplePackages = new Set([
|
||||||
...Object.keys(dependencies),
|
...Object.keys(dependencies),
|
||||||
...requiredPackages.keys(),
|
...[...requiredPackages].filter(packageName => packageName !== undefined),
|
||||||
])
|
])
|
||||||
for (const packageName of localExamplePackages) {
|
for (const packageName of localExamplePackages) {
|
||||||
const packageDirectory = localPackages.get(packageName)
|
const packageDirectory = localPackages.get(packageName)
|
||||||
@@ -135,6 +122,30 @@ function validateExampleResolution(): string[] {
|
|||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateAppResolution(): string[] {
|
||||||
|
const dependencies = readManifest('apps/cli/package.json').dependencies ?? {}
|
||||||
|
const references = pluginReferences.filter(reference => reference.file === 'apps/cli/cordis.yml')
|
||||||
|
return missingPluginDependencies(references, dependencies, 'apps/cli/package.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingPluginDependencies(
|
||||||
|
references: readonly PluginReference[],
|
||||||
|
dependencies: Readonly<Record<string, string>>,
|
||||||
|
manifestPath: string,
|
||||||
|
): string[] {
|
||||||
|
const requiredPackages = new Map<string, Set<string>>()
|
||||||
|
for (const reference of references) {
|
||||||
|
const packageName = packageNameFromSpecifier(reference.name)
|
||||||
|
if (packageName === undefined) continue
|
||||||
|
const locations = requiredPackages.get(packageName) ?? new Set<string>()
|
||||||
|
locations.add(reference.file)
|
||||||
|
requiredPackages.set(packageName, locations)
|
||||||
|
}
|
||||||
|
return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies
|
||||||
|
? []
|
||||||
|
: `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`)
|
||||||
|
}
|
||||||
|
|
||||||
function readManifest(path: string): PackageManifest {
|
function readManifest(path: string): PackageManifest {
|
||||||
return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
|
return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
|
||||||
}
|
}
|
||||||
@@ -177,7 +188,7 @@ function rootProjectReferences(): Set<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function packageNameFromSpecifier(specifier: string): string | undefined {
|
function packageNameFromSpecifier(specifier: string): string | undefined {
|
||||||
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined
|
if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) return undefined
|
||||||
const segments = specifier.split('/')
|
const segments = specifier.split('/')
|
||||||
if (specifier.startsWith('@')) {
|
if (specifier.startsWith('@')) {
|
||||||
return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
|
return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
|
||||||
|
|||||||
Reference in New Issue
Block a user