feat: enforce published dependency policy
This commit is contained in:
parent
ee309c0794
commit
de256e8bc1
14 changed files with 1804 additions and 450 deletions
|
|
@ -50,6 +50,8 @@
|
|||
"test:web:perf": "npm run build && npm run test:web:perf:built",
|
||||
"test:web:perf:built": "DSH_SNAPSHOT=replay vitest run --config vitest.web.perf.config.ts",
|
||||
"test:web:stress": "npm run build && vitest run --config vitest.web-stress.config.ts",
|
||||
"benchmark:npm-resolution": "tsx scripts/benchmark-npm-resolution.ts",
|
||||
"benchmark:npm-resolution:next": "tsx scripts/benchmark-next-package-dependency.ts",
|
||||
"test:gui": "vitest run packages/client packages/host",
|
||||
"check:all": "tsx scripts/run-gates.ts check-all",
|
||||
"check:ci": "tsx scripts/run-gates.ts ci-primary",
|
||||
|
|
@ -104,6 +106,7 @@
|
|||
"verify-optional-dependency-imports": "tsx scripts/verify-optional-dependency-imports.ts",
|
||||
"verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
|
||||
"verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts",
|
||||
"verify-package-dependencies": "tsx scripts/verify-package-dependencies.ts",
|
||||
"verify-client-packages": "tsx scripts/verify-client-packages.ts",
|
||||
"verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts",
|
||||
"verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
|
||||
|
|
|
|||
114
scripts/benchmark-next-package-dependency.spec.ts
Normal file
114
scripts/benchmark-next-package-dependency.spec.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyFactsToRegistry,
|
||||
discoverBenchmarkCandidates,
|
||||
parseNextPackageBenchmarkOptions,
|
||||
type MutableRegistryManifest,
|
||||
} from './benchmark-next-package-dependency.ts'
|
||||
import type {
|
||||
PackageDependencyFacts,
|
||||
PackageDependencyManifest,
|
||||
WorkspacePackageManifest,
|
||||
} from './verify-package-dependencies.ts'
|
||||
import type { RegistryIndex } from './benchmark-npm-resolution.ts'
|
||||
|
||||
describe('next package benchmark options', () => {
|
||||
it('parses candidate and repetition controls', () => {
|
||||
expect(parseNextPackageBenchmarkOptions([
|
||||
'--',
|
||||
'--candidates=@f/a,@f/b',
|
||||
'--runs=2',
|
||||
'--finalist-runs=4',
|
||||
'--finalists=3',
|
||||
'--jobs=6',
|
||||
'--timeout-ms=9000',
|
||||
])).toEqual({
|
||||
candidates: ['@f/a', '@f/b'],
|
||||
coarseRuns: 2,
|
||||
finalistRuns: 4,
|
||||
finalists: 3,
|
||||
jobs: 6,
|
||||
timeoutMs: 9000,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid positive integers', () => {
|
||||
expect(() => parseNextPackageBenchmarkOptions(['--jobs=0'])).toThrow('--jobs must be a positive integer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('next package benchmark graph', () => {
|
||||
it('applies a source-derived candidate without changing the filesystem', () => {
|
||||
const manifest: PackageDependencyManifest & { version: string } = {
|
||||
name: '@f/probe',
|
||||
version: '1.0.0',
|
||||
peerDependencies: {
|
||||
'@deepseek-ai/cordis': 'workspace:^',
|
||||
'@f/runtime': 'workspace:^',
|
||||
'@f/types': 'workspace:^',
|
||||
},
|
||||
devDependencies: {
|
||||
'@deepseek-ai/cordis': 'workspace:^',
|
||||
'@f/runtime': 'workspace:^',
|
||||
'@f/types': 'workspace:^',
|
||||
},
|
||||
}
|
||||
const facts: PackageDependencyFacts = {
|
||||
manifestPath: 'packages/g/probe/package.json',
|
||||
role: 'configured-host',
|
||||
manifest,
|
||||
workspaceNames: new Set(['@deepseek-ai/cordis', '@f/probe', '@f/runtime', '@f/types']),
|
||||
allSourceUses: new Map([
|
||||
['@f/runtime', ['packages/g/probe/src/index.ts']],
|
||||
['@f/types', ['packages/g/probe/src/types.ts']],
|
||||
]),
|
||||
hostRuntimeSourceUses: new Map([['@f/runtime', ['packages/g/probe/src/index.ts']]]),
|
||||
clientInject: new Set(),
|
||||
}
|
||||
const index = new Map<string, Map<string, MutableRegistryManifest>>([
|
||||
['@f/probe', new Map([['1.0.0', structuredClone(manifest) as MutableRegistryManifest]])],
|
||||
])
|
||||
applyFactsToRegistry(index, facts, new Map([
|
||||
['@deepseek-ai/cordis', '4.0.1'],
|
||||
['@f/probe', '1.0.0'],
|
||||
['@f/runtime', '2.0.0'],
|
||||
['@f/types', '3.0.0'],
|
||||
]))
|
||||
|
||||
expect(index.get('@f/probe')?.get('1.0.0')).toMatchObject({
|
||||
dependencies: { '@f/runtime': '^2.0.0' },
|
||||
peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' },
|
||||
})
|
||||
expect(index.get('@f/probe')?.get('1.0.0')?.dependencies).not.toHaveProperty('@f/types')
|
||||
})
|
||||
|
||||
it('finds reachable unconfigured packages with non-Cordis peers', () => {
|
||||
const index = new Map([
|
||||
['@deepseek-ai/dsh', new Map([['1.0.0', {
|
||||
name: '@deepseek-ai/dsh', version: '1.0.0', dependencies: { '@f/a': '^1.0.0', '@f/b': '^1.0.0' },
|
||||
}]])],
|
||||
['@f/a', new Map([['1.0.0', {
|
||||
name: '@f/a', version: '1.0.0', peerDependencies: { '@f/runtime': '^1.0.0' },
|
||||
}]])],
|
||||
['@f/b', new Map([['1.0.0', {
|
||||
name: '@f/b', version: '1.0.0', peerDependencies: { '@deepseek-ai/cordis': '^4.0.0' },
|
||||
}]])],
|
||||
['@f/runtime', new Map([['1.0.0', { name: '@f/runtime', version: '1.0.0' }]])],
|
||||
]) as RegistryIndex
|
||||
const release = new Map<string, WorkspacePackageManifest>([
|
||||
['@f/a', {
|
||||
name: '@f/a', dir: 'packages/g/a', manifestPath: 'packages/g/a/package.json', manifest: { name: '@f/a' },
|
||||
}],
|
||||
['@f/b', {
|
||||
name: '@f/b', dir: 'packages/g/b', manifestPath: 'packages/g/b/package.json', manifest: { name: '@f/b' },
|
||||
}],
|
||||
])
|
||||
|
||||
expect(discoverBenchmarkCandidates(
|
||||
index,
|
||||
new Map([['@deepseek-ai/dsh', '1.0.0'], ['@f/a', '1.0.0'], ['@f/b', '1.0.0']]),
|
||||
release,
|
||||
new Set(),
|
||||
)).toEqual(['@f/a'])
|
||||
})
|
||||
})
|
||||
271
scripts/benchmark-next-package-dependency.ts
Normal file
271
scripts/benchmark-next-package-dependency.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
/** Benchmark which additional Host package most reduces npm peer resolution. */
|
||||
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import {
|
||||
benchmarkNpmResolution,
|
||||
buildRegistryIndex,
|
||||
parsePositiveIntegerOption,
|
||||
publishWorkspaceRange,
|
||||
type RegistryIndex,
|
||||
} from './benchmark-npm-resolution.ts'
|
||||
import {
|
||||
readPackageDependencyFacts,
|
||||
readPackageDependencyState,
|
||||
readWorkspacePackageManifests,
|
||||
repairPackageDependencyManifest,
|
||||
type PackageDependencyFacts,
|
||||
type WorkspacePackageManifest,
|
||||
} from './verify-package-dependencies.ts'
|
||||
|
||||
const TARGET_PACKAGE = '@deepseek-ai/dsh'
|
||||
const CORDIS = '@deepseek-ai/cordis'
|
||||
|
||||
interface Options {
|
||||
readonly candidates?: readonly string[]
|
||||
readonly coarseRuns: number
|
||||
readonly finalistRuns: number
|
||||
readonly finalists: number
|
||||
readonly jobs: number
|
||||
readonly timeoutMs: number
|
||||
}
|
||||
|
||||
export interface MutableRegistryManifest {
|
||||
name: string
|
||||
version: string
|
||||
dependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>
|
||||
}
|
||||
|
||||
interface Measurement {
|
||||
readonly package: string
|
||||
readonly seconds: readonly number[]
|
||||
readonly medianSeconds: number
|
||||
}
|
||||
|
||||
/** Parse benchmark selection and repetition options. */
|
||||
export function parseNextPackageBenchmarkOptions(args: readonly string[]): Options {
|
||||
const normalized = args[0] === '--' ? args.slice(1) : args
|
||||
const { values } = parseArgs({
|
||||
args: [...normalized],
|
||||
options: {
|
||||
candidates: { type: 'string' },
|
||||
runs: { type: 'string' },
|
||||
'finalist-runs': { type: 'string' },
|
||||
finalists: { type: 'string' },
|
||||
jobs: { type: 'string' },
|
||||
'timeout-ms': { type: 'string' },
|
||||
},
|
||||
allowPositionals: false,
|
||||
})
|
||||
return {
|
||||
...(values.candidates === undefined
|
||||
? {}
|
||||
: { candidates: values.candidates.split(',').filter(Boolean) }),
|
||||
coarseRuns: parsePositiveIntegerOption(values.runs, 1, '--runs'),
|
||||
finalistRuns: parsePositiveIntegerOption(values['finalist-runs'], 3, '--finalist-runs'),
|
||||
finalists: parsePositiveIntegerOption(values.finalists, 5, '--finalists'),
|
||||
jobs: parsePositiveIntegerOption(values.jobs, Math.min(8, availableParallelism()), '--jobs'),
|
||||
timeoutMs: parsePositiveIntegerOption(values['timeout-ms'], 120_000, '--timeout-ms'),
|
||||
}
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0
|
||||
? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
|
||||
: sorted[middle] ?? 0
|
||||
}
|
||||
|
||||
function cloneIndex(index: RegistryIndex): Map<string, Map<string, MutableRegistryManifest>> {
|
||||
return new Map([...index].map(([name, versions]) => [
|
||||
name,
|
||||
new Map([...versions].map(([version, manifest]) => [
|
||||
version,
|
||||
structuredClone(manifest) as MutableRegistryManifest,
|
||||
])),
|
||||
]))
|
||||
}
|
||||
|
||||
function publishedSection(
|
||||
values: Readonly<Record<string, string>> | undefined,
|
||||
workspaceVersions: ReadonlyMap<string, string>,
|
||||
): Record<string, string> | undefined {
|
||||
if (values === undefined) return undefined
|
||||
return Object.fromEntries(Object.entries(values).map(([name, range]) => {
|
||||
const version = workspaceVersions.get(name)
|
||||
return [name, version === undefined ? range : publishWorkspaceRange(range, version)]
|
||||
}))
|
||||
}
|
||||
|
||||
/** Apply one source-derived policy result to an in-memory registry manifest. */
|
||||
export function applyFactsToRegistry(
|
||||
index: Map<string, Map<string, MutableRegistryManifest>>,
|
||||
facts: PackageDependencyFacts,
|
||||
workspaceVersions: ReadonlyMap<string, string>,
|
||||
): void {
|
||||
const source = structuredClone(facts.manifest)
|
||||
repairPackageDependencyManifest({ ...facts, manifest: source })
|
||||
const version = workspaceVersions.get(source.name ?? '')
|
||||
const target = version === undefined ? undefined : index.get(source.name ?? '')?.get(version)
|
||||
if (target === undefined) throw new Error(`local registry has no ${source.name ?? 'unnamed package'}@${version ?? 'unknown'}`)
|
||||
for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies'] as const) {
|
||||
const values = publishedSection(source[field], workspaceVersions)
|
||||
if (values !== undefined) target[field] = values
|
||||
else if (field === 'dependencies') delete target.dependencies
|
||||
else if (field === 'optionalDependencies') delete target.optionalDependencies
|
||||
else delete target.peerDependencies
|
||||
}
|
||||
if (source.peerDependenciesMeta === undefined) delete target.peerDependenciesMeta
|
||||
else target.peerDependenciesMeta = structuredClone(source.peerDependenciesMeta) as Record<string, { optional?: boolean }>
|
||||
}
|
||||
|
||||
function currentVersion(pkg: WorkspacePackageManifest): string {
|
||||
const version = pkg.manifest.version
|
||||
if (typeof version !== 'string') throw new Error(`${pkg.manifestPath}: missing package version`)
|
||||
return version
|
||||
}
|
||||
|
||||
/** Find reachable Host candidates whose published manifests still carry non-Cordis peers. */
|
||||
export function discoverBenchmarkCandidates(
|
||||
index: RegistryIndex,
|
||||
workspaceVersions: ReadonlyMap<string, string>,
|
||||
releasePackages: ReadonlyMap<string, WorkspacePackageManifest>,
|
||||
policyPackages: ReadonlySet<string>,
|
||||
): string[] {
|
||||
const reached = new Set<string>()
|
||||
const queue = [TARGET_PACKAGE]
|
||||
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
||||
const name = queue[cursor]
|
||||
if (name === undefined || reached.has(name)) continue
|
||||
const version = workspaceVersions.get(name)
|
||||
const manifest = version === undefined ? undefined : index.get(name)?.get(version)
|
||||
if (manifest === undefined) continue
|
||||
reached.add(name)
|
||||
const installed = {
|
||||
...manifest.dependencies,
|
||||
...manifest.optionalDependencies,
|
||||
...Object.fromEntries(Object.entries(manifest.peerDependencies ?? {})
|
||||
.filter(([peer]) => (manifest.peerDependenciesMeta?.[peer] as { optional?: boolean } | undefined)?.optional !== true)),
|
||||
}
|
||||
for (const dependency of Object.keys(installed).sort()) {
|
||||
if (!reached.has(dependency)) queue.push(dependency)
|
||||
}
|
||||
}
|
||||
return [...reached].filter((name) => {
|
||||
if (policyPackages.has(name) || !releasePackages.has(name)) return false
|
||||
const version = workspaceVersions.get(name)
|
||||
const manifest = version === undefined ? undefined : index.get(name)?.get(version)
|
||||
return Object.keys(manifest?.peerDependencies ?? {}).some(peer => peer !== CORDIS)
|
||||
}).sort()
|
||||
}
|
||||
|
||||
async function measure(
|
||||
index: RegistryIndex,
|
||||
targetVersion: string,
|
||||
runs: number,
|
||||
timeoutMs: number,
|
||||
): Promise<number[]> {
|
||||
const seconds: number[] = []
|
||||
for (let run = 0; run < runs; run += 1) {
|
||||
const result = await benchmarkNpmResolution(index, targetVersion, timeoutMs)
|
||||
if (result.archiveRequests > 0) throw new Error('metadata-only benchmark requested package archives')
|
||||
seconds.push(Number((result.durationMs / 1000).toFixed(2)))
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
async function mapConcurrent<T, R>(
|
||||
values: readonly T[],
|
||||
jobs: number,
|
||||
operation: (value: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results: R[] = []
|
||||
let next = 0
|
||||
await Promise.all(Array.from({ length: Math.min(jobs, values.length) }, async () => {
|
||||
while (next < values.length) {
|
||||
const index = next
|
||||
next += 1
|
||||
const value = values[index]
|
||||
if (value === undefined) return
|
||||
results[index] = await operation(value)
|
||||
}
|
||||
}))
|
||||
return results
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseNextPackageBenchmarkOptions(process.argv.slice(2))
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const packages = readWorkspacePackageManifests(root)
|
||||
const workspaceVersions = new Map(packages.all.map(pkg => [pkg.name, currentVersion(pkg)]))
|
||||
const releaseByName = new Map(packages.release.map(pkg => [pkg.name, pkg]))
|
||||
const state = readPackageDependencyState(root)
|
||||
if (state.policyViolations.length > 0) throw new Error(state.policyViolations.join('\n'))
|
||||
const base = cloneIndex(buildRegistryIndex(root))
|
||||
for (const facts of state.facts) applyFactsToRegistry(base, facts, workspaceVersions)
|
||||
const targetVersion = workspaceVersions.get(TARGET_PACKAGE)
|
||||
if (targetVersion === undefined) throw new Error(`workspace has no ${TARGET_PACKAGE}`)
|
||||
const policyNames = new Set(state.facts.map(facts => facts.manifest.name).filter(name => name !== undefined))
|
||||
const discovered = discoverBenchmarkCandidates(base, workspaceVersions, releaseByName, policyNames)
|
||||
const candidates = options.candidates ?? discovered
|
||||
for (const name of candidates) {
|
||||
if (!discovered.includes(name)) throw new Error(`${name} is not a reachable unconfigured Host candidate`)
|
||||
}
|
||||
const candidateFacts = new Map(candidates.map((name) => {
|
||||
const pkg = releaseByName.get(name)
|
||||
if (pkg === undefined) throw new Error(`release set has no ${name}`)
|
||||
return [name, readPackageDependencyFacts(root, pkg, 'configured-host', state.workspaceNames)]
|
||||
}))
|
||||
|
||||
const baselineSeconds = await measure(base, targetVersion, options.finalistRuns, options.timeoutMs)
|
||||
const baseline = median(baselineSeconds)
|
||||
console.log(JSON.stringify({ type: 'baseline', seconds: baselineSeconds, medianSeconds: baseline }))
|
||||
|
||||
const coarse = await mapConcurrent(candidates, options.jobs, async (name): Promise<Measurement> => {
|
||||
const index = cloneIndex(base)
|
||||
const facts = candidateFacts.get(name)
|
||||
if (facts === undefined) throw new Error(`missing source facts for ${name}`)
|
||||
applyFactsToRegistry(index, facts, workspaceVersions)
|
||||
const seconds = await measure(index, targetVersion, options.coarseRuns, options.timeoutMs)
|
||||
const result = { package: name, seconds, medianSeconds: median(seconds) }
|
||||
console.log(JSON.stringify({ type: 'coarse', ...result }))
|
||||
return result
|
||||
})
|
||||
const finalists = coarse.sort((left, right) => left.medianSeconds - right.medianSeconds)
|
||||
.slice(0, options.finalists)
|
||||
const measured: Measurement[] = []
|
||||
for (const finalist of finalists) {
|
||||
const index = cloneIndex(base)
|
||||
const facts = candidateFacts.get(finalist.package)
|
||||
if (facts === undefined) throw new Error(`missing source facts for ${finalist.package}`)
|
||||
applyFactsToRegistry(index, facts, workspaceVersions)
|
||||
const seconds = await measure(index, targetVersion, options.finalistRuns, options.timeoutMs)
|
||||
measured.push({ package: finalist.package, seconds, medianSeconds: median(seconds) })
|
||||
}
|
||||
const ranking = measured.sort((left, right) => left.medianSeconds - right.medianSeconds)
|
||||
.map(result => ({
|
||||
...result,
|
||||
gainSeconds: Number((baseline - result.medianSeconds).toFixed(2)),
|
||||
}))
|
||||
console.log(JSON.stringify({
|
||||
type: 'result',
|
||||
baselineSeconds,
|
||||
baselineMedianSeconds: baseline,
|
||||
candidateCount: candidates.length,
|
||||
ranking,
|
||||
}, null, 2))
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
await main()
|
||||
} catch (error) {
|
||||
console.error(`benchmark-next-package-dependency: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
84
scripts/benchmark-npm-resolution.spec.ts
Normal file
84
scripts/benchmark-npm-resolution.spec.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
benchmarkNpmResolution,
|
||||
buildRegistryIndex,
|
||||
parseBenchmarkOptions,
|
||||
publishWorkspaceRange,
|
||||
type RegistryIndex,
|
||||
} from './benchmark-npm-resolution.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeJson(root: string, path: string, value: unknown): void {
|
||||
const absolute = join(root, path)
|
||||
mkdirSync(dirname(absolute), { recursive: true })
|
||||
writeFileSync(absolute, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
describe('npm resolution benchmark', () => {
|
||||
it('parses repeat, timeout, threshold, and ref options', () => {
|
||||
expect(parseBenchmarkOptions([])).toEqual({ runs: 1, timeoutMs: 300_000 })
|
||||
expect(parseBenchmarkOptions([
|
||||
'--runs', '3', '--timeout-ms', '45000', '--max-ms', '20000', '--ref', 'master',
|
||||
])).toEqual({ runs: 3, timeoutMs: 45_000, maxMs: 20_000, ref: 'master' })
|
||||
expect(parseBenchmarkOptions(['--', '--runs', '2'])).toEqual({ runs: 2, timeoutMs: 300_000 })
|
||||
expect(() => parseBenchmarkOptions(['--runs', '0'])).toThrow('--runs must be a positive integer')
|
||||
})
|
||||
|
||||
it('projects workspace protocols to published ranges', () => {
|
||||
expect(publishWorkspaceRange('workspace:^', '1.2.3')).toBe('^1.2.3')
|
||||
expect(publishWorkspaceRange('workspace:~', '1.2.3')).toBe('~1.2.3')
|
||||
expect(publishWorkspaceRange('workspace:*', '1.2.3')).toBe('1.2.3')
|
||||
expect(publishWorkspaceRange('^4.0.0', '1.2.3')).toBe('^4.0.0')
|
||||
})
|
||||
|
||||
it('combines installed metadata with current publishable workspace fields', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-npm-registry-index-'))
|
||||
roots.push(root)
|
||||
writeJson(root, 'node_modules/.pnpm/external@2.0.0/node_modules/external/package.json', {
|
||||
name: 'external',
|
||||
version: '2.0.0',
|
||||
dependencies: { child: '^1.0.0' },
|
||||
devDependencies: { ignored: '^1.0.0' },
|
||||
})
|
||||
writeJson(root, 'apps/cli/package.json', {
|
||||
name: '@deepseek-ai/dsh',
|
||||
version: '0.1.0',
|
||||
dependencies: { '@deepseek-ai/dsh-child': 'workspace:^', external: '^2.0.0' },
|
||||
devDependencies: { ignored: 'workspace:^' },
|
||||
})
|
||||
writeJson(root, 'packages/core/child/package.json', {
|
||||
name: '@deepseek-ai/dsh-child',
|
||||
version: '0.1.0',
|
||||
})
|
||||
|
||||
const index = buildRegistryIndex(root)
|
||||
|
||||
expect(index.get('external')?.get('2.0.0')).toMatchObject({ dependencies: { child: '^1.0.0' } })
|
||||
expect(index.get('@deepseek-ai/dsh')?.get('0.1.0')).toEqual({
|
||||
name: '@deepseek-ai/dsh',
|
||||
version: '0.1.0',
|
||||
dependencies: { '@deepseek-ai/dsh-child': '^0.1.0', external: '^2.0.0' },
|
||||
})
|
||||
})
|
||||
|
||||
it('runs npm against the local registry without requesting an archive', async () => {
|
||||
const index: RegistryIndex = new Map([[
|
||||
'@deepseek-ai/dsh',
|
||||
new Map([['0.1.0', { name: '@deepseek-ai/dsh', version: '0.1.0' }]]),
|
||||
]])
|
||||
const result = await benchmarkNpmResolution(index, '0.1.0', 10_000)
|
||||
|
||||
expect(result.durationMs).toBeGreaterThan(0)
|
||||
expect(result.registryRequests).toBeGreaterThan(0)
|
||||
expect(result.archiveRequests).toBe(0)
|
||||
expect(result.unknownPackages).toEqual([])
|
||||
})
|
||||
})
|
||||
417
scripts/benchmark-npm-resolution.ts
Normal file
417
scripts/benchmark-npm-resolution.ts
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
/** Benchmark npm's dependency-tree resolution against an all-local registry. */
|
||||
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
const TARGET_PACKAGE = '@deepseek-ai/dsh'
|
||||
const DEFAULT_TIMEOUT_MS = 300_000
|
||||
const WORKSPACE_MANIFEST_GLOBS = [
|
||||
'apps/*/package.json',
|
||||
'packages/*/*/package.json',
|
||||
'vendor/*/package.json',
|
||||
'native/landlock-run/package.json',
|
||||
'native/landlock-run/packages/*/package.json',
|
||||
]
|
||||
const INSTALLED_MANIFEST_GLOBS = [
|
||||
'node_modules/.pnpm/*/node_modules/*/package.json',
|
||||
'node_modules/.pnpm/*/node_modules/@*/*/package.json',
|
||||
]
|
||||
const PUBLISHED_FIELDS = [
|
||||
'dependencies',
|
||||
'optionalDependencies',
|
||||
'peerDependencies',
|
||||
'peerDependenciesMeta',
|
||||
'engines',
|
||||
'os',
|
||||
'cpu',
|
||||
'bin',
|
||||
] as const
|
||||
|
||||
interface PackageManifest {
|
||||
readonly name?: unknown
|
||||
readonly version?: unknown
|
||||
readonly dependencies?: Record<string, string>
|
||||
readonly optionalDependencies?: Record<string, string>
|
||||
readonly peerDependencies?: Record<string, string>
|
||||
readonly peerDependenciesMeta?: Record<string, unknown>
|
||||
readonly engines?: unknown
|
||||
readonly os?: unknown
|
||||
readonly cpu?: unknown
|
||||
readonly bin?: unknown
|
||||
}
|
||||
|
||||
interface RegistryVersion extends PackageManifest {
|
||||
readonly name: string
|
||||
readonly version: string
|
||||
}
|
||||
|
||||
/** Package versions served by the local benchmark registry. */
|
||||
export type RegistryIndex = ReadonlyMap<string, ReadonlyMap<string, RegistryVersion>>
|
||||
|
||||
/** Parsed command-line options for one benchmark invocation. */
|
||||
export interface BenchmarkOptions {
|
||||
readonly ref?: string
|
||||
readonly runs: number
|
||||
readonly timeoutMs: number
|
||||
readonly maxMs?: number
|
||||
}
|
||||
|
||||
/** One measured npm resolution. */
|
||||
export interface BenchmarkRun {
|
||||
readonly durationMs: number
|
||||
readonly registryRequests: number
|
||||
readonly archiveRequests: number
|
||||
readonly unknownPackages: readonly string[]
|
||||
}
|
||||
|
||||
/** Parse one positive-integer command-line option or use its default. */
|
||||
export function parsePositiveIntegerOption(raw: string | undefined, fallback: number, name: string): number {
|
||||
if (raw === undefined) return fallback
|
||||
const value = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(value) || value < 1 || String(value) !== raw) {
|
||||
throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse supported benchmark arguments.
|
||||
* @param args - Command-line arguments after the script path.
|
||||
* @returns Validated benchmark options.
|
||||
*/
|
||||
export function parseBenchmarkOptions(args: readonly string[]): BenchmarkOptions {
|
||||
const normalized = args[0] === '--' ? args.slice(1) : args
|
||||
const { values } = parseArgs({
|
||||
args: [...normalized],
|
||||
options: {
|
||||
ref: { type: 'string' },
|
||||
runs: { type: 'string' },
|
||||
'timeout-ms': { type: 'string' },
|
||||
'max-ms': { type: 'string' },
|
||||
},
|
||||
allowPositionals: false,
|
||||
})
|
||||
const maxMs = values['max-ms'] === undefined
|
||||
? undefined
|
||||
: parsePositiveIntegerOption(values['max-ms'], 0, '--max-ms')
|
||||
return {
|
||||
runs: parsePositiveIntegerOption(values.runs, 1, '--runs'),
|
||||
timeoutMs: parsePositiveIntegerOption(values['timeout-ms'], DEFAULT_TIMEOUT_MS, '--timeout-ms'),
|
||||
...(values.ref === undefined ? {} : { ref: values.ref }),
|
||||
...(maxMs === undefined ? {} : { maxMs }),
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceManifestPath(path: string): boolean {
|
||||
return /^(?:apps\/[^/]+|packages\/[^/]+\/[^/]+|vendor\/[^/]+|native\/landlock-run(?:\/packages\/[^/]+)?)\/package\.json$/.test(path)
|
||||
}
|
||||
|
||||
function workspaceManifestPaths(root: string, ref: string | undefined): string[] {
|
||||
if (ref === undefined) return globSync(WORKSPACE_MANIFEST_GLOBS, { cwd: root }).sort()
|
||||
return execFileSync('git', ['ls-tree', '-r', '--name-only', ref, '--', 'apps', 'packages', 'vendor', 'native'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
}).split('\n').filter(workspaceManifestPath).sort()
|
||||
}
|
||||
|
||||
function readGitFiles(root: string, ref: string, paths: readonly string[]): ReadonlyMap<string, string> {
|
||||
const output = execFileSync('git', ['cat-file', '--batch'], {
|
||||
cwd: root,
|
||||
input: paths.map(path => `${ref}:${path}\n`).join(''),
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
})
|
||||
const contents = new Map<string, string>()
|
||||
let offset = 0
|
||||
for (const path of paths) {
|
||||
const headerEnd = output.indexOf(0x0a, offset)
|
||||
if (headerEnd < 0) throw new Error(`git cat-file returned no header for ${ref}:${path}`)
|
||||
const header = output.subarray(offset, headerEnd).toString('utf8')
|
||||
if (header.endsWith(' missing')) throw new Error(`git ref ${ref} has no ${path}`)
|
||||
const size = Number.parseInt(header.split(' ')[2] ?? '', 10)
|
||||
if (!Number.isSafeInteger(size) || size < 0) {
|
||||
throw new Error(`git cat-file returned an invalid size for ${ref}:${path}`)
|
||||
}
|
||||
const contentStart = headerEnd + 1
|
||||
const contentEnd = contentStart + size
|
||||
if (output[contentEnd] !== 0x0a) throw new Error(`git cat-file truncated ${ref}:${path}`)
|
||||
contents.set(path, output.subarray(contentStart, contentEnd).toString('utf8'))
|
||||
offset = contentEnd + 1
|
||||
}
|
||||
return contents
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a workspace protocol range to the range published by pnpm pack.
|
||||
* @param range - Dependency range from a workspace manifest.
|
||||
* @param targetVersion - Current version of the referenced workspace package.
|
||||
* @returns The registry-facing semver range.
|
||||
*/
|
||||
export function publishWorkspaceRange(range: string, targetVersion: string): string {
|
||||
if (range === 'workspace:*') return targetVersion
|
||||
if (range === 'workspace:^') return `^${targetVersion}`
|
||||
if (range === 'workspace:~') return `~${targetVersion}`
|
||||
if (range.startsWith('workspace:')) return range.slice('workspace:'.length)
|
||||
return range
|
||||
}
|
||||
|
||||
function copyPublishedManifest(
|
||||
source: PackageManifest,
|
||||
workspaceVersions: ReadonlyMap<string, string>,
|
||||
): RegistryVersion | undefined {
|
||||
if (typeof source.name !== 'string' || typeof source.version !== 'string') return undefined
|
||||
const output: Record<string, unknown> = { name: source.name, version: source.version }
|
||||
for (const field of PUBLISHED_FIELDS) {
|
||||
const value = source[field]
|
||||
if (value === undefined) continue
|
||||
if (field === 'dependencies' || field === 'optionalDependencies' || field === 'peerDependencies') {
|
||||
output[field] = Object.fromEntries(Object.entries(value as Record<string, string>).map(([name, range]) => {
|
||||
const targetVersion = workspaceVersions.get(name)
|
||||
return [name, targetVersion === undefined ? range : publishWorkspaceRange(range, targetVersion)]
|
||||
}))
|
||||
} else {
|
||||
output[field] = structuredClone(value)
|
||||
}
|
||||
}
|
||||
return output as unknown as RegistryVersion
|
||||
}
|
||||
|
||||
function addManifest(index: Map<string, Map<string, RegistryVersion>>, manifest: RegistryVersion): void {
|
||||
const versions = index.get(manifest.name) ?? new Map<string, RegistryVersion>()
|
||||
versions.set(manifest.version, manifest)
|
||||
index.set(manifest.name, versions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build registry metadata from installed external packages and workspace manifests.
|
||||
* @param root - Repository root containing the pnpm virtual store.
|
||||
* @param ref - Optional Git ref used instead of working-tree workspace manifests.
|
||||
* @returns Package metadata served by the benchmark registry.
|
||||
*/
|
||||
export function buildRegistryIndex(root: string, ref?: string): RegistryIndex {
|
||||
const index = new Map<string, Map<string, RegistryVersion>>()
|
||||
for (const path of globSync(INSTALLED_MANIFEST_GLOBS, { cwd: root }).sort()) {
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
|
||||
const copied = copyPublishedManifest(manifest, new Map())
|
||||
if (copied !== undefined) addManifest(index, copied)
|
||||
}
|
||||
|
||||
const paths = workspaceManifestPaths(root, ref)
|
||||
const refContents = ref === undefined ? undefined : readGitFiles(root, ref, paths)
|
||||
const workspace = paths.map(path =>
|
||||
JSON.parse(refContents?.get(path) ?? readFileSync(resolve(root, path), 'utf8')) as PackageManifest)
|
||||
const workspaceVersions = new Map(workspace.flatMap(manifest =>
|
||||
typeof manifest.name === 'string' && typeof manifest.version === 'string'
|
||||
? [[manifest.name, manifest.version] as const]
|
||||
: []))
|
||||
for (const manifest of workspace) {
|
||||
const copied = copyPublishedManifest(manifest, workspaceVersions)
|
||||
if (copied !== undefined) addManifest(index, copied)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function latestVersion(versions: ReadonlyMap<string, RegistryVersion>): string {
|
||||
const sorted = [...versions.keys()].sort((left, right) => left.localeCompare(right, 'en', { numeric: true }))
|
||||
const latest = sorted.at(-1)
|
||||
if (latest === undefined) throw new Error('local registry package has no versions')
|
||||
return latest
|
||||
}
|
||||
|
||||
function listen(server: Server): Promise<number> {
|
||||
return new Promise((resolveListen, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') {
|
||||
reject(new Error('local registry did not expose a TCP port'))
|
||||
return
|
||||
}
|
||||
resolveListen(address.port)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function close(server: Server): Promise<void> {
|
||||
return new Promise((resolveClose, reject) => {
|
||||
server.close((error) => {
|
||||
if (error === undefined) resolveClose()
|
||||
else reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function npmExecutable(): string {
|
||||
return process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
}
|
||||
|
||||
async function runNpm(
|
||||
cwd: string,
|
||||
registry: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ durationMs: number; output: string; timedOut: boolean }> {
|
||||
const started = performance.now()
|
||||
const child = spawn(npmExecutable(), [
|
||||
'install',
|
||||
'--package-lock-only',
|
||||
'--ignore-scripts',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--loglevel=error',
|
||||
`--registry=${registry}`,
|
||||
], {
|
||||
cwd,
|
||||
// Windows resolves npm through a .cmd shim, which spawn() refuses
|
||||
// without a shell since the CVE-2024-27980 hardening.
|
||||
shell: process.platform === 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_cache: join(cwd, '.npm-cache'),
|
||||
npm_config_update_notifier: 'false',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let output = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk) => { output += String(chunk) })
|
||||
child.stderr.on('data', (chunk) => { output += String(chunk) })
|
||||
const outcome = await new Promise<{ status: number | null; timedOut: boolean }>((resolveExit, reject) => {
|
||||
let timeoutReached = false
|
||||
const timer = setTimeout(() => {
|
||||
timeoutReached = true
|
||||
child.kill('SIGTERM')
|
||||
}, timeoutMs)
|
||||
child.once('error', reject)
|
||||
child.once('exit', (status) => {
|
||||
clearTimeout(timer)
|
||||
resolveExit({ status, timedOut: timeoutReached })
|
||||
})
|
||||
})
|
||||
const durationMs = performance.now() - started
|
||||
if (outcome.timedOut) return { durationMs, output, timedOut: true }
|
||||
if (outcome.status !== 0) {
|
||||
throw new Error(`npm install exited ${String(outcome.status)} after ${durationMs.toFixed(0)} ms\n${output.trim()}`)
|
||||
}
|
||||
return { durationMs, output, timedOut: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the CLI install graph once without downloading package archives.
|
||||
* @param index - Package metadata exposed through the local registry.
|
||||
* @param targetVersion - Version of `@deepseek-ai/dsh` to install.
|
||||
* @param timeoutMs - Hard wall-clock limit for the npm child process.
|
||||
* @returns Timing and registry-request observations.
|
||||
*/
|
||||
export async function benchmarkNpmResolution(
|
||||
index: RegistryIndex,
|
||||
targetVersion: string,
|
||||
timeoutMs: number,
|
||||
): Promise<BenchmarkRun> {
|
||||
let registryRequests = 0
|
||||
let archiveRequests = 0
|
||||
const unknownPackages = new Set<string>()
|
||||
let registry = ''
|
||||
const server = createServer((request, response) => {
|
||||
registryRequests++
|
||||
const pathname = new URL(request.url ?? '/', registry).pathname
|
||||
if (pathname.startsWith('/tarballs/')) {
|
||||
archiveRequests++
|
||||
response.writeHead(500, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: 'package-lock-only benchmark requested an archive' }))
|
||||
return
|
||||
}
|
||||
const name = decodeURIComponent(pathname.slice(1))
|
||||
const versions = index.get(name)
|
||||
if (versions === undefined) {
|
||||
unknownPackages.add(name)
|
||||
response.writeHead(404, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: 'not_found' }))
|
||||
return
|
||||
}
|
||||
const materialized = Object.fromEntries([...versions].map(([version, manifest]) => [version, {
|
||||
...manifest,
|
||||
dist: { tarball: `${registry}tarballs/${encodeURIComponent(name)}-${version}.tgz` },
|
||||
}]))
|
||||
const body = JSON.stringify({
|
||||
name,
|
||||
'dist-tags': { latest: latestVersion(versions) },
|
||||
versions: materialized,
|
||||
})
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(body),
|
||||
})
|
||||
response.end(body)
|
||||
})
|
||||
const port = await listen(server)
|
||||
registry = `http://127.0.0.1:${String(port)}/`
|
||||
const consumer = mkdtempSync(join(tmpdir(), 'dsh-npm-resolution-'))
|
||||
try {
|
||||
writeFileSync(join(consumer, 'package.json'), `${JSON.stringify({
|
||||
name: 'dsh-npm-resolution-benchmark',
|
||||
version: '0.0.0',
|
||||
private: true,
|
||||
dependencies: { [TARGET_PACKAGE]: targetVersion },
|
||||
}, null, 2)}\n`)
|
||||
const result = await runNpm(consumer, registry, timeoutMs)
|
||||
if (result.timedOut) throw new Error(`npm resolution exceeded ${String(timeoutMs)} ms`)
|
||||
return {
|
||||
durationMs: result.durationMs,
|
||||
registryRequests,
|
||||
archiveRequests,
|
||||
unknownPackages: [...unknownPackages].sort(),
|
||||
}
|
||||
} finally {
|
||||
await close(server)
|
||||
rmSync(consumer, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseBenchmarkOptions(process.argv.slice(2))
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const started = performance.now()
|
||||
const index = buildRegistryIndex(root, options.ref)
|
||||
const targetVersions = index.get(TARGET_PACKAGE)
|
||||
if (targetVersions === undefined) throw new Error(`local registry contains no ${TARGET_PACKAGE}`)
|
||||
const targetVersion = latestVersion(targetVersions)
|
||||
const npmVersion = execFileSync(npmExecutable(), ['--version'], { encoding: 'utf8' }).trim()
|
||||
console.log(
|
||||
`benchmark-npm-resolution: npm ${npmVersion}, ${options.ref === undefined ? 'working tree' : options.ref}, `
|
||||
+ `${String(index.size)} package name(s), setup ${(performance.now() - started).toFixed(0)} ms.`,
|
||||
)
|
||||
const durations: number[] = []
|
||||
for (let run = 1; run <= options.runs; run++) {
|
||||
const result = await benchmarkNpmResolution(index, targetVersion, options.timeoutMs)
|
||||
durations.push(result.durationMs)
|
||||
console.log(
|
||||
`benchmark-npm-resolution: run ${String(run)}/${String(options.runs)} resolved ${TARGET_PACKAGE}@${targetVersion}`
|
||||
+ ` in ${(result.durationMs / 1000).toFixed(2)} s with ${String(result.registryRequests)} metadata request(s)`
|
||||
+ ` and ${String(result.unknownPackages.length)} local 404 package name(s).`,
|
||||
)
|
||||
if (result.archiveRequests > 0) throw new Error('npm requested package archives during the metadata-only benchmark')
|
||||
}
|
||||
const minimum = Math.min(...durations)
|
||||
const maximum = Math.max(...durations)
|
||||
console.log(
|
||||
`benchmark-npm-resolution: ${String(options.runs)} run(s), min ${(minimum / 1000).toFixed(2)} s, max ${(maximum / 1000).toFixed(2)} s.`,
|
||||
)
|
||||
if (options.maxMs !== undefined && maximum > options.maxMs) {
|
||||
throw new Error(`npm resolution exceeded --max-ms=${String(options.maxMs)} (max ${maximum.toFixed(0)} ms)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
await main()
|
||||
} catch (error) {
|
||||
console.error(`benchmark-npm-resolution: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
52
scripts/package-dependency-policy.ts
Normal file
52
scripts/package-dependency-policy.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/** Explicit exceptions and Host packages for the published dependency policy. */
|
||||
|
||||
/** Packages treated as Client/Host packages without declaring `dsh.client`. */
|
||||
const CLIENT_FACE_INCLUDE: readonly string[] = []
|
||||
|
||||
/** Packages exempted from automatic Client/Host treatment despite declaring `dsh.client`. */
|
||||
const CLIENT_FACE_EXCLUDE: readonly string[] = [
|
||||
'@deepseek-ai/dsh-api-session-controller',
|
||||
]
|
||||
|
||||
/** Host-only packages whose peer relays are deliberately flattened. */
|
||||
const HOST_DEPENDENCY_PACKAGES: readonly string[] = [
|
||||
'@deepseek-ai/dsh-llm',
|
||||
'@deepseek-ai/dsh-session',
|
||||
]
|
||||
|
||||
/** Complete configurable input to package dependency classification. */
|
||||
export interface PackageDependencyPolicy {
|
||||
readonly clientFaceInclude: readonly string[]
|
||||
readonly clientFaceExclude: readonly string[]
|
||||
readonly hostPackages: readonly string[]
|
||||
}
|
||||
|
||||
/** Repository dependency policy consumed by verification and benchmarking. */
|
||||
export const PACKAGE_DEPENDENCY_POLICY: PackageDependencyPolicy = {
|
||||
clientFaceInclude: CLIENT_FACE_INCLUDE,
|
||||
clientFaceExclude: CLIENT_FACE_EXCLUDE,
|
||||
hostPackages: HOST_DEPENDENCY_PACKAGES,
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Whether a package manifest declares a dynamically loaded Client entry. */
|
||||
export function hasClientDeclaration(dshField: unknown): boolean {
|
||||
return isRecord(dshField) && Object.hasOwn(dshField, 'client')
|
||||
}
|
||||
|
||||
/** Whether the repository policy flattens one package's non-Cordis peers. */
|
||||
export function usesFlattenedPackageDependencies(
|
||||
manifestPath: string,
|
||||
packageName: string,
|
||||
dshField: unknown,
|
||||
policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
|
||||
): boolean {
|
||||
if (!manifestPath.startsWith('packages/') || manifestPath.startsWith('packages/experimental/')) return false
|
||||
if (policy.hostPackages.includes(packageName)) return true
|
||||
if (manifestPath.startsWith('packages/client/')) return true
|
||||
const included = hasClientDeclaration(dshField) || policy.clientFaceInclude.includes(packageName)
|
||||
return included && !policy.clientFaceExclude.includes(packageName)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|||
import {
|
||||
collectPackageInvariantViolations,
|
||||
} from './package-invariants.ts'
|
||||
import { usesFlattenedPackageDependencies } from './package-dependency-policy.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
|
|
@ -28,7 +29,10 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo
|
|||
|
||||
function fixture(options: {
|
||||
packageName?: string
|
||||
packageDirectory?: string
|
||||
source?: string
|
||||
clientDeclaration?: boolean
|
||||
clientExport?: boolean
|
||||
invariantExport?: boolean
|
||||
invariantDependency?: boolean
|
||||
invariantReference?: boolean
|
||||
|
|
@ -36,19 +40,34 @@ function fixture(options: {
|
|||
} = {}): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-package-invariants-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'packages/core/probe')
|
||||
const packageDirectory = options.packageDirectory ?? 'packages/core/probe'
|
||||
const dir = join(root, packageDirectory)
|
||||
mkdirSync(join(dir, 'src'), { recursive: true })
|
||||
const packageName = options.packageName ?? '@deepseek-ai/dsh-probe'
|
||||
const manifest = {
|
||||
name: packageName,
|
||||
exports: options.invariantExport === false ? {} : {
|
||||
const exports = options.invariantExport === false ? {} : {
|
||||
'./invariant': {
|
||||
types: './lib/types/invariant.d.ts',
|
||||
default: './lib/invariant.js',
|
||||
},
|
||||
...(options.clientExport === true ? {
|
||||
'./client': {
|
||||
types: './lib/types/client/index.d.ts',
|
||||
default: './lib/client.js',
|
||||
},
|
||||
} : {}),
|
||||
}
|
||||
const dsh = options.clientDeclaration === true ? { client: {} } : undefined
|
||||
const developmentOnlyInvariant = usesFlattenedPackageDependencies(
|
||||
`${packageDirectory}/package.json`,
|
||||
packageName,
|
||||
dsh,
|
||||
)
|
||||
const manifest = {
|
||||
name: packageName,
|
||||
...(dsh === undefined ? {} : { dsh }),
|
||||
exports,
|
||||
files: ['lib/index.js', 'lib/invariant.js'],
|
||||
peerDependencies: options.invariantDependency === false ? {} : {
|
||||
peerDependencies: options.invariantDependency === false || developmentOnlyInvariant ? {} : {
|
||||
'@deepseek-ai/dsh-invariants': 'workspace:^',
|
||||
},
|
||||
devDependencies: options.invariantDependency === false ? {} : {
|
||||
|
|
@ -72,6 +91,33 @@ describe('package invariant gate', () => {
|
|||
expect(collectPackageInvariantViolations(fixture())).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts development-only invariants for configured Host dependencies', () => {
|
||||
expect(collectPackageInvariantViolations(fixture({ packageName: '@deepseek-ai/dsh-llm' }))).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts development-only invariants for client packages', () => {
|
||||
expect(collectPackageInvariantViolations(fixture({
|
||||
packageName: '@deepseek-ai/dsh-client-probe',
|
||||
packageDirectory: 'packages/client/probe',
|
||||
}))).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts development-only invariants for packages with a dsh.client entry', () => {
|
||||
expect(collectPackageInvariantViolations(fixture({ clientDeclaration: true, clientExport: true }))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps invariant peers for packages that only export a Client API', () => {
|
||||
expect(collectPackageInvariantViolations(fixture({ clientExport: true }))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps invariant peers for experimental packages with a dsh.client entry', () => {
|
||||
expect(collectPackageInvariantViolations(fixture({
|
||||
packageDirectory: 'packages/experimental/probe',
|
||||
clientDeclaration: true,
|
||||
clientExport: true,
|
||||
}))).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts an invariant reference owned by a package-local leaf project', () => {
|
||||
const root = fixture({ invariantReference: false })
|
||||
const dir = join(root, 'packages/core/probe')
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@
|
|||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { dirname, relative, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { usesFlattenedPackageDependencies } from './package-dependency-policy.ts'
|
||||
|
||||
/** Required explanation marker for an intentionally empty installer. */
|
||||
const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:'
|
||||
|
||||
interface PackageManifest {
|
||||
name?: string
|
||||
dsh?: unknown
|
||||
exports?: Record<string, { types?: string; default?: string } | string | undefined>
|
||||
files?: string[]
|
||||
peerDependencies?: Record<string, string>
|
||||
|
|
@ -96,18 +98,23 @@ function checkManifest(
|
|||
addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
|
||||
}
|
||||
if (owner.packageName === '@deepseek-ai/dsh-invariants') return
|
||||
if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
|
||||
addViolation(
|
||||
violations,
|
||||
const developmentOnlyInvariant = usesFlattenedPackageDependencies(
|
||||
owner.manifestPath,
|
||||
'@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency',
|
||||
owner.packageName,
|
||||
manifest.dsh,
|
||||
)
|
||||
const expectedRange = 'workspace:^'
|
||||
const peerRange = manifest.peerDependencies?.['@deepseek-ai/dsh-invariants']
|
||||
if (developmentOnlyInvariant ? peerRange !== undefined : peerRange !== expectedRange) {
|
||||
addViolation(violations, owner.manifestPath, developmentOnlyInvariant
|
||||
? '@deepseek-ai/dsh-invariants must not be a peerDependency under this package dependency policy'
|
||||
: '@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency')
|
||||
}
|
||||
if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
|
||||
if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== expectedRange) {
|
||||
addViolation(
|
||||
violations,
|
||||
owner.manifestPath,
|
||||
'@deepseek-ai/dsh-invariants must also be a workspace:^ devDependency',
|
||||
`@deepseek-ai/dsh-invariants must be a ${expectedRange} devDependency`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ describe('gate graph validation', () => {
|
|||
const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
|
||||
|
||||
expect(ids).toEqual([
|
||||
'rescope-vendor', 'publint', 'constraints', 'application-entrypoints',
|
||||
'rescope-vendor', 'publint', 'constraints', 'package-dependencies', 'application-entrypoints',
|
||||
'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
|
||||
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'cordis-config',
|
||||
'runtime-closure', 'vendored-links',
|
||||
|
|
@ -141,6 +141,15 @@ describe('gate graph validation', () => {
|
|||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
||||
'keeps package dependency enforcement in %s',
|
||||
(mode) => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('package-dependencies')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
|
||||
'keeps the client dependency policy in %s',
|
||||
(mode) => {
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ function ciSharedStaticGates(): Gate[] {
|
|||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }),
|
||||
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
|
|
@ -667,6 +668,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
|||
pnpmScript('rescope-vendor', 'rescope-vendor:check', { label: 'vendor rescope' }),
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }),
|
||||
pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }),
|
||||
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/** Tests for client package modes, dependency sections, and module requests. */
|
||||
/** Tests for client package modes and module requests. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
|
@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'
|
|||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectClientPackageViolations,
|
||||
collectLocalSourceSpecifiers,
|
||||
collectRuntimeSourcePackageUses,
|
||||
collectRuntimeSourceSpecifiers,
|
||||
collectSourcePackageUses,
|
||||
|
|
@ -106,6 +107,19 @@ describe('source package uses', () => {
|
|||
'@deepseek-ai/dsh-b/remote',
|
||||
'react',
|
||||
])
|
||||
expect([...collectLocalSourceSpecifiers('feature.ts', [
|
||||
"import type { A } from './types.ts'",
|
||||
"export { value } from './value.ts'",
|
||||
"const load = () => import('./lazy.ts')",
|
||||
"const legacy = require('./legacy.ts')",
|
||||
"declare module './augmentation.ts' {}",
|
||||
"import '@deepseek-ai/dsh-a'",
|
||||
].join('\n'))].sort()).toEqual([
|
||||
'./lazy.ts',
|
||||
'./legacy.ts',
|
||||
'./types.ts',
|
||||
'./value.ts',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -152,109 +166,6 @@ describe('package modes', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('dependency sections', () => {
|
||||
it('accepts dynamic peer plus dev relationships, static dev inputs, and private dependencies', () => {
|
||||
const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
|
||||
const conversation = pkg('conversation', {
|
||||
inject: ['@deepseek-ai/dsh-client-feature'],
|
||||
sourceUses: {
|
||||
'@deepseek-ai/dsh-agent': ['packages/client/conversation/src/index.ts'],
|
||||
'@deepseek-ai/dsh-client-ui-slots': ['packages/client/conversation/src/client/slots.ts'],
|
||||
react: ['packages/client/conversation/src/client/view.tsx'],
|
||||
},
|
||||
dependencies: { immer: '^10.1.1' },
|
||||
peerDependencies: {
|
||||
[CORDIS]: 'workspace:^',
|
||||
'@deepseek-ai/dsh-agent': 'workspace:^',
|
||||
'@deepseek-ai/dsh-client-feature': 'workspace:^',
|
||||
},
|
||||
devDependencies: {
|
||||
[CORDIS]: 'workspace:^',
|
||||
'@deepseek-ai/dsh-agent': 'workspace:^',
|
||||
'@deepseek-ai/dsh-client-feature': 'workspace:^',
|
||||
'@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
|
||||
react: '^18.2.0',
|
||||
},
|
||||
})
|
||||
expect(collectClientPackageViolations(facts([slots, conversation], {
|
||||
platformModules: ['react', slots.name],
|
||||
}))).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects internal dependencies, static peers, and mismatched peer development ranges', () => {
|
||||
const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
|
||||
const subject = pkg('feature', {
|
||||
sourceUses: {
|
||||
'@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
|
||||
[slots.name]: ['packages/client/feature/src/view.tsx'],
|
||||
},
|
||||
dependencies: { '@deepseek-ai/dsh-agent': 'workspace:^' },
|
||||
peerDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:^' },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:*' },
|
||||
})
|
||||
const found = collectClientPackageViolations(facts([slots, subject]))
|
||||
expect(found).toHaveLength(2)
|
||||
expect(found.join('\n')).toContain('peer-installed DSH relationship')
|
||||
expect(found.join('\n')).toContain('static client input')
|
||||
})
|
||||
|
||||
it('requires every peer to have the same development range', () => {
|
||||
const subject = pkg('feature', {
|
||||
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/cordis-plugin-loader': 'workspace:^' },
|
||||
})
|
||||
expect(collectClientPackageViolations(facts([subject]))).toEqual([
|
||||
'packages/client/feature/package.json: peerDependencies.@deepseek-ai/cordis-plugin-loader'
|
||||
+ ' is workspace:^, so devDependencies.@deepseek-ai/cordis-plugin-loader must use the same range;'
|
||||
+ ' found no declaration',
|
||||
])
|
||||
})
|
||||
|
||||
it('requires statically linked third-party runtime imports in dependencies', () => {
|
||||
const primitives = pkg('ui-primitives', {
|
||||
dynamic: false,
|
||||
staticLinked: true,
|
||||
runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' },
|
||||
})
|
||||
const found = collectClientPackageViolations(facts([primitives]))
|
||||
expect(found).toHaveLength(1)
|
||||
expect(found[0]).toContain('runtime import retained by a statically linked artifact')
|
||||
expect(found[0]).toContain('declare it only in dependencies')
|
||||
|
||||
const valid = { ...primitives, dependencies: { shiki: '^4.3.1' }, devDependencies: { [CORDIS]: 'workspace:^' } }
|
||||
expect(collectClientPackageViolations(facts([valid]))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the web shell runtime inputs development-only', () => {
|
||||
const web = pkg('web', {
|
||||
dynamic: false,
|
||||
staticLinked: true,
|
||||
runtimeSourceUses: {
|
||||
'@deepseek-ai/cordis-plugin-loader': ['packages/client/web/src/boot.ts'],
|
||||
react: ['packages/client/web/src/seed.ts'],
|
||||
},
|
||||
devDependencies: {
|
||||
[CORDIS]: 'workspace:^',
|
||||
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
|
||||
react: '^18.2.0',
|
||||
},
|
||||
})
|
||||
expect(collectClientPackageViolations(facts([web]))).toEqual([])
|
||||
})
|
||||
|
||||
it('allows npm dependency cycles', () => {
|
||||
const a = pkg('a', {
|
||||
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
|
||||
})
|
||||
const b = pkg('b', {
|
||||
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
|
||||
})
|
||||
expect(collectClientPackageViolations(facts([a, b]))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('module requests', () => {
|
||||
it('rejects runtime requests from one client feature package to another dynamic row', () => {
|
||||
const ui = declaration('ui', {
|
||||
|
|
@ -378,7 +289,7 @@ describe('manifest declarations', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('fixes unambiguous dependency sections and declaration entries', () => {
|
||||
it('fixes malformed declaration entries without changing dependency sections', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'client-packages-fix-'))
|
||||
roots.push(root)
|
||||
const subject = pkg('feature', {
|
||||
|
|
@ -426,43 +337,8 @@ describe('manifest declarations', () => {
|
|||
external: ['@deepseek-ai/dsh-missing'],
|
||||
inject: ['@deepseek-ai/dsh-agent'],
|
||||
})
|
||||
expect(fixed.dependencies).toBeUndefined()
|
||||
expect(fixed.peerDependencies).toEqual({
|
||||
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
|
||||
[CORDIS]: 'workspace:^',
|
||||
'@deepseek-ai/dsh-agent': 'workspace:*',
|
||||
})
|
||||
expect(fixed.devDependencies).toEqual({
|
||||
'@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
|
||||
[CORDIS]: 'workspace:^',
|
||||
'@deepseek-ai/dsh-agent': 'workspace:*',
|
||||
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
|
||||
})
|
||||
})
|
||||
|
||||
it('fixes a statically linked runtime import into dependencies', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'client-packages-static-fix-'))
|
||||
roots.push(root)
|
||||
const subject = pkg('ui-primitives', {
|
||||
dynamic: false,
|
||||
staticLinked: true,
|
||||
runtimeSourceUses: { shiki: ['packages/client/ui-primitives/src/highlight.ts'] },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', shiki: '^4.3.1' },
|
||||
})
|
||||
mkdirSync(dirname(join(root, subject.manifest)), { recursive: true })
|
||||
writeFileSync(join(root, subject.manifest), JSON.stringify({
|
||||
name: subject.name,
|
||||
peerDependencies: subject.peerDependencies,
|
||||
devDependencies: subject.devDependencies,
|
||||
}))
|
||||
writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true }))
|
||||
|
||||
expect(fixClientPackageManifests(root, facts([subject]))).toEqual([subject.manifest])
|
||||
const fixed = JSON.parse(readFileSync(join(root, subject.manifest), 'utf8')) as {
|
||||
dependencies: Record<string, string>
|
||||
devDependencies: Record<string, string>
|
||||
}
|
||||
expect(fixed.dependencies).toEqual({ shiki: '^4.3.1' })
|
||||
expect(fixed.devDependencies).toEqual({ [CORDIS]: 'workspace:^' })
|
||||
expect(fixed.dependencies).toEqual(subject.dependencies)
|
||||
expect(fixed.peerDependencies).toEqual(subject.peerDependencies)
|
||||
expect(fixed.devDependencies).toEqual(subject.devDependencies)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
/**
|
||||
* Verify client package modes, npm dependency sections, and the synchronous
|
||||
* browser module-request graph.
|
||||
* Verify client package modes and the synchronous browser module-request graph.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
|
|
@ -17,8 +16,6 @@ const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts'
|
|||
const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts'
|
||||
const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts'
|
||||
const CORDIS = '@deepseek-ai/cordis'
|
||||
const DSH_PREFIX = '@deepseek-ai/dsh-'
|
||||
const CLIENT_WEB = '@deepseek-ai/dsh-client-web'
|
||||
|
||||
/** One workspace package's browser-module declaration. */
|
||||
export interface ClientDeclaration {
|
||||
|
|
@ -92,6 +89,17 @@ export function collectRuntimeSourceSpecifiers(path: string, source: string): Se
|
|||
return collectSourceFileUses(sourceFile, true, 'specifier')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect relative module specifiers used to follow one source entry's local closure.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Relative imports, exports, requires, and import types.
|
||||
*/
|
||||
export function collectLocalSourceSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, false, 'local')
|
||||
}
|
||||
|
||||
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
|
||||
const clause = node.importClause
|
||||
if (clause === undefined) return true
|
||||
|
|
@ -114,12 +122,17 @@ function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
|
|||
function collectSourceFileUses(
|
||||
sourceFile: ts.SourceFile,
|
||||
runtimeOnly: boolean,
|
||||
key: 'package' | 'specifier',
|
||||
key: 'local' | 'package' | 'specifier',
|
||||
): Set<string> {
|
||||
const uses = new Set<string>()
|
||||
|
||||
const add = (specifier: ts.Expression | undefined): void => {
|
||||
if (specifier === undefined || !ts.isStringLiteral(specifier) || !isBareSpecifier(specifier.text)) return
|
||||
if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return
|
||||
if (key === 'local') {
|
||||
if (specifier.text.startsWith('.')) uses.add(specifier.text)
|
||||
return
|
||||
}
|
||||
if (!isBareSpecifier(specifier.text)) return
|
||||
uses.add(key === 'package' ? packageNameOf(specifier.text) : specifier.text)
|
||||
}
|
||||
const visit = (node: ts.Node): void => {
|
||||
|
|
@ -135,9 +148,10 @@ function collectSourceFileUses(
|
|||
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
|
||||
add(node.arguments[0])
|
||||
} else if (!runtimeOnly && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
|
||||
} else if (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
|
||||
add(node.name)
|
||||
} else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
|
||||
} else if (key !== 'local'
|
||||
&& (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) {
|
||||
uses.add('react')
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
|
|
@ -170,7 +184,6 @@ export function collectClientPackageViolations(facts: ClientPackageFacts): strin
|
|||
return [
|
||||
...facts.malformed,
|
||||
...collectModeViolations(facts),
|
||||
...collectDependencyViolations(facts),
|
||||
...collectModuleViolations(facts),
|
||||
].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
|
@ -181,10 +194,8 @@ interface ManifestDocument {
|
|||
changed: boolean
|
||||
}
|
||||
|
||||
type DependencySection = 'dependencies' | 'peerDependencies' | 'devDependencies'
|
||||
|
||||
/**
|
||||
* Repair manifest declarations whose intended result follows uniquely from the policy.
|
||||
* Repair malformed or redundant `dsh.client` declaration entries.
|
||||
* @param root - Absolute repository root.
|
||||
* @param facts - Facts used by the verification pass.
|
||||
* @returns Repository-relative manifests written by the fixer.
|
||||
|
|
@ -217,53 +228,6 @@ export function fixClientPackageManifests(root: string, facts: ClientPackageFact
|
|||
) || target.changed
|
||||
}
|
||||
|
||||
const staticInputs = new Set([
|
||||
...facts.staticLinkedPackages,
|
||||
...facts.platformModules.map(packageNameOf),
|
||||
])
|
||||
staticInputs.delete(CORDIS)
|
||||
const inferredRanges = dependencyRangeCandidates(root)
|
||||
for (const pkg of facts.packages) {
|
||||
const target = document(pkg.manifest)
|
||||
const expected = expectedSections(pkg, staticInputs)
|
||||
for (const [name, rule] of expected) {
|
||||
const range = preferredRange(target.manifest, name, rule.kind, inferredRanges)
|
||||
if (range === undefined) continue
|
||||
target.changed = rule.kind === 'dependency'
|
||||
? ensureDependencyOnly(target.manifest, name, range) || target.changed
|
||||
: rule.kind === 'dev'
|
||||
? ensureDevOnly(target.manifest, name, range) || target.changed
|
||||
: ensurePeerDev(target.manifest, name, range) || target.changed
|
||||
}
|
||||
|
||||
if (pkg.dynamic) {
|
||||
const productionNames = new Set([
|
||||
...Object.keys(section(target.manifest, 'dependencies')),
|
||||
...Object.keys(section(target.manifest, 'peerDependencies')),
|
||||
])
|
||||
for (const name of productionNames) {
|
||||
if (expected.has(name)) continue
|
||||
const range = preferredRange(
|
||||
target.manifest,
|
||||
name,
|
||||
staticInputs.has(name) ? 'dev' : 'peer-dev',
|
||||
inferredRanges,
|
||||
)
|
||||
if (range === undefined) continue
|
||||
if (staticInputs.has(name)) {
|
||||
target.changed = ensureDevOnly(target.manifest, name, range) || target.changed
|
||||
} else if (section(target.manifest, 'dependencies')[name] !== undefined && isInternalDsh(name)) {
|
||||
target.changed = ensurePeerDev(target.manifest, name, range) || target.changed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, range] of Object.entries(section(target.manifest, 'peerDependencies'))) {
|
||||
target.changed = setDependency(target.manifest, 'devDependencies', name, range) || target.changed
|
||||
}
|
||||
target.changed = deleteEmptySections(target.manifest) || target.changed
|
||||
}
|
||||
|
||||
const changed = [...documents.values()].filter(target => target.changed).sort((left, right) =>
|
||||
left.path.localeCompare(right.path))
|
||||
for (const target of changed) {
|
||||
|
|
@ -295,102 +259,6 @@ function normalizeClientArray(
|
|||
return true
|
||||
}
|
||||
|
||||
function ensureDevOnly(manifest: Manifest, name: string, range: string): boolean {
|
||||
let changed = deleteDependency(manifest, 'dependencies', name)
|
||||
changed = deleteDependency(manifest, 'peerDependencies', name) || changed
|
||||
return setDependency(manifest, 'devDependencies', name, range) || changed
|
||||
}
|
||||
|
||||
function ensureDependencyOnly(manifest: Manifest, name: string, range: string): boolean {
|
||||
let changed = deleteDependency(manifest, 'peerDependencies', name)
|
||||
changed = deleteDependency(manifest, 'devDependencies', name) || changed
|
||||
return setDependency(manifest, 'dependencies', name, range) || changed
|
||||
}
|
||||
|
||||
function ensurePeerDev(manifest: Manifest, name: string, range: string): boolean {
|
||||
let changed = deleteDependency(manifest, 'dependencies', name)
|
||||
changed = setDependency(manifest, 'peerDependencies', name, range) || changed
|
||||
return setDependency(manifest, 'devDependencies', name, range) || changed
|
||||
}
|
||||
|
||||
function setDependency(manifest: Manifest, field: DependencySection, name: string, range: string): boolean {
|
||||
const dependencies = mutableSection(manifest, field)
|
||||
if (dependencies[name] === range) return false
|
||||
dependencies[name] = range
|
||||
return true
|
||||
}
|
||||
|
||||
function deleteDependency(manifest: Manifest, field: DependencySection, name: string): boolean {
|
||||
const dependencies = section(manifest, field)
|
||||
if (dependencies[name] === undefined) return false
|
||||
manifest[field] = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
|
||||
return true
|
||||
}
|
||||
|
||||
function deleteEmptySections(manifest: Manifest): boolean {
|
||||
let changed = false
|
||||
for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
|
||||
if (manifest[field] === undefined || Object.keys(section(manifest, field)).length > 0) continue
|
||||
if (field === 'dependencies') delete manifest.dependencies
|
||||
else if (field === 'peerDependencies') delete manifest.peerDependencies
|
||||
else delete manifest.devDependencies
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
function preferredRange(
|
||||
manifest: Manifest,
|
||||
name: string,
|
||||
kind: ExpectedRule['kind'],
|
||||
inferred: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
): string | undefined {
|
||||
const order: readonly DependencySection[] = kind === 'dependency'
|
||||
? ['dependencies', 'devDependencies', 'peerDependencies']
|
||||
: kind === 'dev'
|
||||
? ['devDependencies', 'peerDependencies', 'dependencies']
|
||||
: ['peerDependencies', 'devDependencies', 'dependencies']
|
||||
for (const field of order) {
|
||||
const range = section(manifest, field)[name]
|
||||
if (range !== undefined) return range
|
||||
}
|
||||
if (isInternalDsh(name)) return 'workspace:^'
|
||||
const candidates = inferred.get(name)
|
||||
return candidates?.size === 1 ? [...candidates][0] : undefined
|
||||
}
|
||||
|
||||
function dependencyRangeCandidates(root: string): Map<string, Set<string>> {
|
||||
const candidates = new Map<string, Set<string>>()
|
||||
const paths = globSync([
|
||||
'package.json',
|
||||
...MANIFEST_GLOBS,
|
||||
'website/package.json',
|
||||
], { cwd: root }).map(normalizePath)
|
||||
for (const path of new Set(paths)) {
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest
|
||||
for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
|
||||
for (const [name, range] of Object.entries(section(manifest, field))) {
|
||||
const ranges = candidates.get(name) ?? new Set<string>()
|
||||
ranges.add(range)
|
||||
candidates.set(name, ranges)
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function section(manifest: Manifest, field: DependencySection): Record<string, string> {
|
||||
return manifest[field] ?? {}
|
||||
}
|
||||
|
||||
function mutableSection(manifest: Manifest, field: DependencySection): Record<string, string> {
|
||||
const value = manifest[field]
|
||||
if (value !== undefined) return value
|
||||
const created: Record<string, string> = {}
|
||||
manifest[field] = created
|
||||
return created
|
||||
}
|
||||
|
||||
function collectModeViolations(facts: ClientPackageFacts): string[] {
|
||||
const violations: string[] = []
|
||||
for (const pkg of facts.packages) {
|
||||
|
|
@ -435,114 +303,6 @@ function collectModeViolations(facts: ClientPackageFacts): string[] {
|
|||
return violations
|
||||
}
|
||||
|
||||
interface ExpectedRule {
|
||||
readonly kind: 'dependency' | 'dev' | 'peer-dev'
|
||||
readonly origins: Set<string>
|
||||
}
|
||||
|
||||
function collectDependencyViolations(facts: ClientPackageFacts): string[] {
|
||||
const violations: string[] = []
|
||||
const staticInputs = new Set([
|
||||
...facts.staticLinkedPackages,
|
||||
...facts.platformModules.map(packageNameOf),
|
||||
])
|
||||
staticInputs.delete(CORDIS)
|
||||
|
||||
for (const pkg of [...facts.packages].sort((left, right) => left.manifest.localeCompare(right.manifest))) {
|
||||
const expected = expectedSections(pkg, staticInputs)
|
||||
for (const [name, rule] of [...expected].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
const actual = declaredSections(pkg, name)
|
||||
if (rule.kind === 'dependency') {
|
||||
if (actual.length === 1 && actual[0] === 'dependencies') continue
|
||||
violations.push(
|
||||
pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a runtime import'
|
||||
+ ' retained by a statically linked artifact; declare it only in dependencies, found '
|
||||
+ describeSections(actual),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (rule.kind === 'dev') {
|
||||
if (actual.length === 1 && actual[0] === 'devDependencies') continue
|
||||
violations.push(
|
||||
pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a static client input;'
|
||||
+ ' declare it only in devDependencies, found ' + describeSections(actual),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const peerRange = pkg.peerDependencies[name]
|
||||
const devRange = pkg.devDependencies[name]
|
||||
if (actual.length === 2
|
||||
&& actual.includes('peerDependencies')
|
||||
&& actual.includes('devDependencies')
|
||||
&& peerRange === devRange) continue
|
||||
violations.push(
|
||||
pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ')'
|
||||
+ ' is a peer-installed DSH relationship; declare it in peerDependencies and devDependencies'
|
||||
+ ' with matching ranges, not dependencies; found ' + describeSections(actual)
|
||||
+ describeRangeMismatch(peerRange, devRange),
|
||||
)
|
||||
}
|
||||
|
||||
for (const [name, peerRange] of Object.entries(pkg.peerDependencies).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
if (expected.has(name)) continue
|
||||
const devRange = pkg.devDependencies[name]
|
||||
if (devRange === peerRange) continue
|
||||
violations.push(
|
||||
pkg.manifest + ': peerDependencies.' + name + ' is ' + peerRange + ', so devDependencies.' + name
|
||||
+ ' must use the same range; found ' + (devRange ?? 'no declaration'),
|
||||
)
|
||||
}
|
||||
|
||||
if (!pkg.dynamic) continue
|
||||
for (const section of ['dependencies', 'peerDependencies'] as const) {
|
||||
for (const name of Object.keys(pkg[section]).sort()) {
|
||||
if (expected.has(name)) continue
|
||||
if (staticInputs.has(name)) {
|
||||
violations.push(
|
||||
pkg.manifest + ': dynamic package declares static input ' + name + ' in ' + section + ';'
|
||||
+ ' move it to devDependencies or delete the stale declaration',
|
||||
)
|
||||
} else if (section === 'dependencies' && isInternalDsh(name)) {
|
||||
violations.push(
|
||||
pkg.manifest + ': dynamic package declares ' + name + ' in dependencies;'
|
||||
+ ' dynamic DSH relationships are peer plus dev, and static client inputs are dev-only',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
function expectedSections(pkg: ClientPackage, staticInputs: ReadonlySet<string>): Map<string, ExpectedRule> {
|
||||
const expected = new Map<string, ExpectedRule>([
|
||||
[CORDIS, { kind: 'peer-dev', origins: new Set(['client package baseline']) }],
|
||||
])
|
||||
if (!pkg.dynamic) {
|
||||
if (pkg.name === CLIENT_WEB) return expected
|
||||
for (const [name, locations] of Object.entries(pkg.runtimeSourceUses)) {
|
||||
if (name === pkg.name || name === CORDIS || isInternalDsh(name)) continue
|
||||
expected.set(name, { kind: 'dependency', origins: new Set(locations) })
|
||||
}
|
||||
return expected
|
||||
}
|
||||
|
||||
const add = (name: string, origin: string): void => {
|
||||
if (name === pkg.name) return
|
||||
const kind = staticInputs.has(name) ? 'dev' : isInternalDsh(name) ? 'peer-dev' : undefined
|
||||
if (kind === undefined) return
|
||||
const current = expected.get(name)
|
||||
if (current !== undefined) current.origins.add(origin)
|
||||
else expected.set(name, { kind, origins: new Set([origin]) })
|
||||
}
|
||||
for (const [name, locations] of Object.entries(pkg.sourceUses)) {
|
||||
for (const location of locations) add(name, location)
|
||||
}
|
||||
for (const name of pkg.inject) add(name, 'dsh.client.inject')
|
||||
return expected
|
||||
}
|
||||
|
||||
interface ModuleEdge {
|
||||
readonly from: string
|
||||
readonly to: string
|
||||
|
|
@ -895,32 +655,6 @@ function rowPackageOf(specifier: string, rows: ReadonlySet<string>): string | un
|
|||
return rows.has(stripped) ? stripped : undefined
|
||||
}
|
||||
|
||||
function declaredSections(pkg: ClientPackage, name: string): string[] {
|
||||
return (['dependencies', 'peerDependencies', 'devDependencies'] as const)
|
||||
.filter(section => pkg[section][name] !== undefined)
|
||||
}
|
||||
|
||||
function describeSections(sections: readonly string[]): string {
|
||||
return sections.length === 0 ? 'no dependency declaration' : sections.join(' + ')
|
||||
}
|
||||
|
||||
function describeRangeMismatch(peer: string | undefined, dev: string | undefined): string {
|
||||
if (peer === undefined || dev === undefined || peer === dev) return ''
|
||||
return ' (peer ' + peer + ', dev ' + dev + ')'
|
||||
}
|
||||
|
||||
function describeOrigins(origins: ReadonlySet<string>): string {
|
||||
const sorted = [...origins].sort()
|
||||
const [first, second, ...rest] = sorted
|
||||
if (first === undefined) return 'production use'
|
||||
if (second === undefined) return first
|
||||
return rest.length === 0 ? first + ', ' + second : first + ', ' + second + ', and ' + String(rest.length) + ' more'
|
||||
}
|
||||
|
||||
function isInternalDsh(name: string): boolean {
|
||||
return name === CORDIS || name.startsWith(DSH_PREFIX)
|
||||
}
|
||||
|
||||
function isBareSpecifier(specifier: string): boolean {
|
||||
return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
|
||||
}
|
||||
|
|
@ -956,7 +690,7 @@ async function main(): Promise<void> {
|
|||
const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0)
|
||||
console.log(
|
||||
GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, '
|
||||
+ String(facts.packages.length - dynamic) + ' statically linked) satisfy dependency and module-request rules; '
|
||||
+ String(facts.packages.length - dynamic) + ' statically linked) satisfy package-mode and module-request rules; '
|
||||
+ String(requests) + ' explicit external request(s).',
|
||||
)
|
||||
}
|
||||
|
|
|
|||
260
scripts/verify-package-dependencies.spec.ts
Normal file
260
scripts/verify-package-dependencies.spec.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PACKAGE_DEPENDENCY_POLICY,
|
||||
type PackageDependencyPolicy,
|
||||
} from './package-dependency-policy.ts'
|
||||
import {
|
||||
collectPackageDependencyViolations,
|
||||
discoverPackageDependencyScope,
|
||||
fixPackageDependencies,
|
||||
formatManagedRuntimeDependencies,
|
||||
readPackageDependencyFacts,
|
||||
repairPackageDependencyManifest,
|
||||
type PackageDependencyFacts,
|
||||
type PackageDependencyManifest,
|
||||
type WorkspacePackageManifest,
|
||||
} from './verify-package-dependencies.ts'
|
||||
|
||||
const CORDIS = '@deepseek-ai/cordis'
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function pkg(
|
||||
name: string,
|
||||
manifestPath: string,
|
||||
manifest: Partial<PackageDependencyManifest> = {},
|
||||
): WorkspacePackageManifest {
|
||||
return {
|
||||
name,
|
||||
manifestPath,
|
||||
dir: dirname(manifestPath),
|
||||
manifest: { name, ...manifest },
|
||||
}
|
||||
}
|
||||
|
||||
function policy(fields: Partial<PackageDependencyPolicy> = {}): PackageDependencyPolicy {
|
||||
return {
|
||||
clientFaceInclude: [],
|
||||
clientFaceExclude: [],
|
||||
hostPackages: [],
|
||||
...fields,
|
||||
}
|
||||
}
|
||||
|
||||
function facts(manifest: PackageDependencyManifest): PackageDependencyFacts {
|
||||
return {
|
||||
manifestPath: 'packages/core/probe/package.json',
|
||||
role: 'configured-host',
|
||||
manifest,
|
||||
workspaceNames: new Set([
|
||||
CORDIS,
|
||||
'@deepseek-ai/dsh-runtime',
|
||||
'@deepseek-ai/dsh-types',
|
||||
'@deepseek-ai/dsh-stale',
|
||||
'@deepseek-ai/schemastery',
|
||||
]),
|
||||
allSourceUses: new Map([
|
||||
['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']],
|
||||
['@deepseek-ai/dsh-types', ['packages/core/probe/src/types.ts']],
|
||||
]),
|
||||
hostRuntimeSourceUses: new Map([
|
||||
['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']],
|
||||
]),
|
||||
clientInject: new Set(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('package dependency scope', () => {
|
||||
it('keeps the measured Host relay roster explicit', () => {
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([
|
||||
'@deepseek-ai/dsh-api-session-controller',
|
||||
])
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.hostPackages).toEqual([
|
||||
'@deepseek-ai/dsh-llm',
|
||||
'@deepseek-ai/dsh-session',
|
||||
])
|
||||
})
|
||||
|
||||
it('discovers the Client directory, dsh.client declarations, and configured Host packages', () => {
|
||||
const packages = [
|
||||
pkg('@f/static', 'packages/client/static/package.json'),
|
||||
pkg('@f/dynamic-client', 'packages/client/dynamic/package.json', { dsh: { client: {} } }),
|
||||
pkg('@f/dual', 'packages/api/dual/package.json', { dsh: { client: {} } }),
|
||||
pkg('@f/export-only', 'packages/api/export-only/package.json', { exports: { './client': './lib/client.js' } }),
|
||||
pkg('@f/forced-client', 'packages/api/forced/package.json'),
|
||||
pkg('@f/excluded', 'packages/api/excluded/package.json', { dsh: { client: {} } }),
|
||||
pkg('@f/host', 'packages/core/host/package.json'),
|
||||
]
|
||||
|
||||
const found = discoverPackageDependencyScope(packages, policy({
|
||||
clientFaceInclude: ['@f/forced-client'],
|
||||
clientFaceExclude: ['@f/excluded'],
|
||||
hostPackages: ['@f/host'],
|
||||
}))
|
||||
|
||||
expect(found.violations).toEqual([])
|
||||
expect(found.selected.map(item => [item.name, item.role])).toEqual([
|
||||
['@f/dual', 'client-host'],
|
||||
['@f/forced-client', 'client-host'],
|
||||
['@f/dynamic-client', 'client-host'],
|
||||
['@f/static', 'client-host'],
|
||||
['@f/host', 'configured-host'],
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects stale, redundant, overlapping, and unknown configuration', () => {
|
||||
const packages = [
|
||||
pkg('@f/client', 'packages/client/client/package.json'),
|
||||
pkg('@f/dual', 'packages/api/dual/package.json', { dsh: { client: {} } }),
|
||||
pkg('@f/host', 'packages/core/host/package.json'),
|
||||
]
|
||||
const found = discoverPackageDependencyScope(packages, policy({
|
||||
clientFaceInclude: ['@f/dual', '@f/missing', '@f/host'],
|
||||
clientFaceExclude: ['@f/client', '@f/host', '@f/missing'],
|
||||
hostPackages: ['@f/dual'],
|
||||
}))
|
||||
|
||||
expect(found.violations).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('clientFaceInclude redundantly names automatically discovered package @f/dual'),
|
||||
expect.stringContaining('@f/host appears in both clientFaceInclude and clientFaceExclude'),
|
||||
expect.stringContaining('clientFaceExclude cannot exempt packages/client package @f/client'),
|
||||
expect.stringContaining('clientFaceExclude names @f/host, which declares no dsh.client entry'),
|
||||
expect.stringContaining('hostPackages redundantly names Client-faced package @f/dual'),
|
||||
expect.stringContaining('unknown release package @f/missing'),
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('face-aware source classification', () => {
|
||||
it('counts Host values as dependencies and Client values as development inputs', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-package-faces-'))
|
||||
roots.push(root)
|
||||
const subject = pkg('@f/dual', 'packages/g/dual/package.json', {
|
||||
dsh: { client: { inject: ['@f/injected'] } },
|
||||
})
|
||||
const files = {
|
||||
'packages/g/dual/src/index.ts': [
|
||||
"import { value } from '@f/runtime'",
|
||||
"import type { Shared } from '@f/types'",
|
||||
"export { nested } from './nested.ts'",
|
||||
].join('\n'),
|
||||
'packages/g/dual/src/nested.ts': "export { nested } from '@f/nested'",
|
||||
'packages/g/dual/src/client/index.ts': "import { browser } from '@f/browser'",
|
||||
}
|
||||
for (const [path, source] of Object.entries(files)) {
|
||||
mkdirSync(dirname(join(root, path)), { recursive: true })
|
||||
writeFileSync(join(root, path), source)
|
||||
}
|
||||
|
||||
const found = readPackageDependencyFacts(root, subject, 'client-host', new Set([
|
||||
CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/browser', '@f/injected',
|
||||
]))
|
||||
|
||||
expect([...found.hostRuntimeSourceUses.keys()].sort()).toEqual(['@f/nested', '@f/runtime'])
|
||||
expect([...found.allSourceUses.keys()].sort()).toEqual(['@f/browser', '@f/nested', '@f/runtime', '@f/types'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dependency sections', () => {
|
||||
it('accepts Host dependencies, development-only inputs, and shared Cordis', () => {
|
||||
const manifest: PackageDependencyManifest = {
|
||||
name: '@deepseek-ai/dsh-probe',
|
||||
dependencies: {
|
||||
'@deepseek-ai/dsh-runtime': 'workspace:^',
|
||||
'@deepseek-ai/schemastery': 'workspace:^',
|
||||
external: '^1.0.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'@deepseek-ai/dsh-types': 'workspace:^',
|
||||
[CORDIS]: 'workspace:^',
|
||||
},
|
||||
peerDependencies: { [CORDIS]: 'workspace:^' },
|
||||
}
|
||||
expect(collectPackageDependencyViolations({
|
||||
facts: [facts(manifest)], packages: [], policyViolations: [], workspaceNames: facts(manifest).workspaceNames,
|
||||
})).toEqual([])
|
||||
})
|
||||
|
||||
it('lists managed Host runtime dependencies for fix review', () => {
|
||||
const subject = facts({ name: '@deepseek-ai/dsh-probe' })
|
||||
expect(formatManagedRuntimeDependencies({
|
||||
facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames,
|
||||
})).toEqual([
|
||||
'verify-package-dependencies: 1 managed Host runtime dependency edge(s) remain in dependencies across 1 package(s):',
|
||||
' @deepseek-ai/dsh-probe: @deepseek-ai/dsh-runtime',
|
||||
])
|
||||
})
|
||||
|
||||
it('reports wrong sections, workspace ranges, and stale peer metadata', () => {
|
||||
const manifest: PackageDependencyManifest = {
|
||||
name: '@deepseek-ai/dsh-probe',
|
||||
dependencies: { '@deepseek-ai/dsh-types': 'workspace:*' },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' },
|
||||
peerDependencies: { [CORDIS]: 'workspace:*', '@deepseek-ai/dsh-runtime': 'workspace:^' },
|
||||
peerDependenciesMeta: { '@deepseek-ai/dsh-missing': { optional: true } },
|
||||
}
|
||||
const state = {
|
||||
facts: [facts(manifest)], packages: [], policyViolations: [], workspaceNames: facts(manifest).workspaceNames,
|
||||
}
|
||||
const violations = collectPackageDependencyViolations(state)
|
||||
expect(violations).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('@deepseek-ai/dsh-runtime'),
|
||||
expect.stringContaining('@deepseek-ai/dsh-types'),
|
||||
expect.stringContaining(`${CORDIS} must be matching peerDependencies + devDependencies`),
|
||||
expect.stringContaining('dependencies.@deepseek-ai/dsh-types must use workspace:^'),
|
||||
expect.stringContaining('peerDependenciesMeta.@deepseek-ai/dsh-missing has no matching'),
|
||||
]))
|
||||
})
|
||||
|
||||
it('repairs owned relationships without changing unrelated dependencies', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-package-dependencies-'))
|
||||
roots.push(root)
|
||||
const manifestPath = 'package.json'
|
||||
const manifest: PackageDependencyManifest = {
|
||||
name: '@deepseek-ai/dsh-probe',
|
||||
dependencies: { '@deepseek-ai/schemastery': 'workspace:*', external: '^1.0.0' },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' },
|
||||
peerDependencies: {
|
||||
[CORDIS]: 'workspace:^',
|
||||
'@deepseek-ai/dsh-runtime': 'workspace:^',
|
||||
'@deepseek-ai/dsh-stale': 'workspace:^',
|
||||
},
|
||||
peerDependenciesMeta: { '@deepseek-ai/dsh-stale': { optional: true } },
|
||||
}
|
||||
writeFileSync(join(root, manifestPath), `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
const subject = { ...facts(manifest), manifestPath }
|
||||
const state = { facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames }
|
||||
|
||||
expect(fixPackageDependencies(root, state)).toEqual([manifestPath])
|
||||
const fixed = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as PackageDependencyManifest
|
||||
expect(fixed.dependencies).toEqual({
|
||||
'@deepseek-ai/schemastery': 'workspace:^',
|
||||
external: '^1.0.0',
|
||||
'@deepseek-ai/dsh-runtime': 'workspace:^',
|
||||
})
|
||||
expect(fixed.devDependencies).toEqual({
|
||||
[CORDIS]: 'workspace:^',
|
||||
'@deepseek-ai/dsh-types': 'workspace:^',
|
||||
'@deepseek-ai/dsh-stale': 'workspace:^',
|
||||
})
|
||||
expect(fixed.peerDependencies).toEqual({ [CORDIS]: 'workspace:^' })
|
||||
expect(fixed.peerDependenciesMeta).toBeUndefined()
|
||||
})
|
||||
|
||||
it('repairs an in-memory manifest for benchmark simulation', () => {
|
||||
const manifest: PackageDependencyManifest = {
|
||||
name: '@deepseek-ai/dsh-probe',
|
||||
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' },
|
||||
}
|
||||
repairPackageDependencyManifest(facts(manifest))
|
||||
expect(manifest.dependencies).toEqual({ '@deepseek-ai/dsh-runtime': 'workspace:^' })
|
||||
expect(manifest.peerDependencies).toEqual({ [CORDIS]: 'workspace:^' })
|
||||
})
|
||||
})
|
||||
479
scripts/verify-package-dependencies.ts
Normal file
479
scripts/verify-package-dependencies.ts
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
/** Verify and repair npm dependency sections from published Client and Host faces. */
|
||||
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path'
|
||||
import {
|
||||
hasClientDeclaration,
|
||||
PACKAGE_DEPENDENCY_POLICY,
|
||||
type PackageDependencyPolicy,
|
||||
} from './package-dependency-policy.ts'
|
||||
import {
|
||||
collectLocalSourceSpecifiers,
|
||||
collectRuntimeSourcePackageUses,
|
||||
collectSourcePackageUses,
|
||||
} from './verify-client-packages.ts'
|
||||
|
||||
const GATE = 'verify-package-dependencies'
|
||||
const CORDIS = '@deepseek-ai/cordis'
|
||||
const WORKSPACE_RANGE = 'workspace:^'
|
||||
const RELEASE_MANIFEST_GLOB = 'packages/!(experimental)/*/package.json'
|
||||
const WORKSPACE_MANIFEST_GLOBS = [
|
||||
'apps/*/package.json',
|
||||
'packages/*/*/package.json',
|
||||
'vendor/*/package.json',
|
||||
]
|
||||
|
||||
type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies'
|
||||
export type PackageDependencyRole = 'client-host' | 'configured-host'
|
||||
|
||||
/** Manifest fields read and repaired by the package dependency policy. */
|
||||
export interface PackageDependencyManifest {
|
||||
name?: string
|
||||
version?: string
|
||||
exports?: unknown
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
peerDependenciesMeta?: Record<string, unknown>
|
||||
dsh?: { client?: { inject?: string[] } }
|
||||
}
|
||||
|
||||
/** One workspace package and its source location. */
|
||||
export interface WorkspacePackageManifest {
|
||||
readonly dir: string
|
||||
readonly manifestPath: string
|
||||
readonly manifest: PackageDependencyManifest
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** Source and manifest facts for one package covered by the policy. */
|
||||
export interface PackageDependencyFacts {
|
||||
readonly manifestPath: string
|
||||
readonly role: PackageDependencyRole
|
||||
readonly manifest: PackageDependencyManifest
|
||||
readonly workspaceNames: ReadonlySet<string>
|
||||
readonly allSourceUses: ReadonlyMap<string, readonly string[]>
|
||||
readonly hostRuntimeSourceUses: ReadonlyMap<string, readonly string[]>
|
||||
readonly clientInject: ReadonlySet<string>
|
||||
}
|
||||
|
||||
/** Complete policy input read from the repository. */
|
||||
export interface PackageDependencyState {
|
||||
readonly facts: readonly PackageDependencyFacts[]
|
||||
readonly packages: readonly WorkspacePackageManifest[]
|
||||
readonly policyViolations: readonly string[]
|
||||
readonly workspaceNames: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export interface ExpectedPackageDependency {
|
||||
readonly section: 'dependencies' | 'devDependencies' | 'peer-dev'
|
||||
readonly origins: readonly string[]
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
function packageNameOf(specifier: string): string | undefined {
|
||||
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#') || specifier.includes(':')) {
|
||||
return undefined
|
||||
}
|
||||
const parts = specifier.split('/')
|
||||
return specifier.startsWith('@') ? parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined : parts[0]
|
||||
}
|
||||
|
||||
/** Read package manifests used for scope discovery and workspace-name checks. */
|
||||
export function readWorkspacePackageManifests(root: string): {
|
||||
all: WorkspacePackageManifest[]
|
||||
release: WorkspacePackageManifest[]
|
||||
} {
|
||||
const read = (manifestPath: string): WorkspacePackageManifest => {
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as PackageDependencyManifest
|
||||
if (typeof manifest.name !== 'string') throw new Error(`${manifestPath}: missing package name`)
|
||||
return {
|
||||
dir: dirname(manifestPath),
|
||||
manifestPath,
|
||||
manifest,
|
||||
name: manifest.name,
|
||||
}
|
||||
}
|
||||
const all = globSync(WORKSPACE_MANIFEST_GLOBS, { cwd: root }).map(normalizePath).sort().map(read)
|
||||
const releasePaths = new Set(globSync(RELEASE_MANIFEST_GLOB, { cwd: root }).map(normalizePath))
|
||||
return { all, release: all.filter(pkg => releasePaths.has(pkg.manifestPath)) }
|
||||
}
|
||||
|
||||
function duplicates(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const duplicated = new Set<string>()
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) duplicated.add(value)
|
||||
seen.add(value)
|
||||
}
|
||||
return [...duplicated].sort()
|
||||
}
|
||||
|
||||
/** Discover Client faces and configured Host packages, validating explicit overrides. */
|
||||
export function discoverPackageDependencyScope(
|
||||
packages: readonly WorkspacePackageManifest[],
|
||||
policy: PackageDependencyPolicy,
|
||||
): { selected: Array<WorkspacePackageManifest & { role: PackageDependencyRole }>; violations: string[] } {
|
||||
const violations: string[] = []
|
||||
const byName = new Map(packages.map(pkg => [pkg.name, pkg]))
|
||||
const include = new Set(policy.clientFaceInclude)
|
||||
const exclude = new Set(policy.clientFaceExclude)
|
||||
const host = new Set(policy.hostPackages)
|
||||
|
||||
for (const [field, values] of [
|
||||
['clientFaceInclude', policy.clientFaceInclude],
|
||||
['clientFaceExclude', policy.clientFaceExclude],
|
||||
['hostPackages', policy.hostPackages],
|
||||
] as const) {
|
||||
for (const name of duplicates(values)) violations.push(`${field} lists ${name} more than once`)
|
||||
for (const name of values) {
|
||||
if (!byName.has(name)) violations.push(`${field} names unknown release package ${name}`)
|
||||
}
|
||||
}
|
||||
for (const name of include) {
|
||||
if (exclude.has(name)) violations.push(`${name} appears in both clientFaceInclude and clientFaceExclude`)
|
||||
const pkg = byName.get(name)
|
||||
if (pkg !== undefined
|
||||
&& (pkg.manifestPath.startsWith('packages/client/') || hasClientDeclaration(pkg.manifest.dsh))) {
|
||||
violations.push(`clientFaceInclude redundantly names automatically discovered package ${name}`)
|
||||
}
|
||||
}
|
||||
for (const name of exclude) {
|
||||
const pkg = byName.get(name)
|
||||
if (pkg !== undefined && pkg.manifestPath.startsWith('packages/client/')) {
|
||||
violations.push(`clientFaceExclude cannot exempt packages/client package ${name}`)
|
||||
} else if (pkg !== undefined && !hasClientDeclaration(pkg.manifest.dsh)) {
|
||||
violations.push(`clientFaceExclude names ${name}, which declares no dsh.client entry`)
|
||||
}
|
||||
}
|
||||
|
||||
const selected: Array<WorkspacePackageManifest & { role: PackageDependencyRole }> = []
|
||||
for (const pkg of packages) {
|
||||
const clientDirectory = pkg.manifestPath.startsWith('packages/client/')
|
||||
const clientHost = clientDirectory
|
||||
|| ((hasClientDeclaration(pkg.manifest.dsh) || include.has(pkg.name)) && !exclude.has(pkg.name))
|
||||
const configuredHost = host.has(pkg.name)
|
||||
if (configuredHost && clientHost) {
|
||||
violations.push(`hostPackages redundantly names Client-faced package ${pkg.name}`)
|
||||
}
|
||||
const role = clientHost ? 'client-host' : configuredHost ? 'configured-host' : undefined
|
||||
if (role !== undefined) selected.push({ ...pkg, role })
|
||||
}
|
||||
return {
|
||||
selected: selected.sort((left, right) => left.manifestPath.localeCompare(right.manifestPath)),
|
||||
violations: [...new Set(violations)].sort(),
|
||||
}
|
||||
}
|
||||
|
||||
function addUse(target: Map<string, string[]>, name: string, path: string): void {
|
||||
const paths = target.get(name) ?? []
|
||||
if (!paths.includes(path)) paths.push(path)
|
||||
target.set(name, paths)
|
||||
}
|
||||
|
||||
function resolveLocal(importer: string, specifier: string): string | undefined {
|
||||
const raw = resolve(dirname(importer), specifier)
|
||||
const candidates = extname(raw) === ''
|
||||
? [`${raw}.ts`, `${raw}.tsx`, `${raw}.mts`, `${raw}.cts`, join(raw, 'index.ts'), join(raw, 'index.tsx')]
|
||||
: [raw, raw.replace(/\.js$/, '.ts'), raw.replace(/\.jsx$/, '.tsx'), raw.replace(/\.mjs$/, '.mts'), raw.replace(/\.cjs$/, '.cts')]
|
||||
return candidates.find(candidate => existsSync(candidate))
|
||||
}
|
||||
|
||||
function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
|
||||
const uses = new Map<string, string[]>()
|
||||
const seen = new Set<string>()
|
||||
const visit = (path: string): void => {
|
||||
const normalized = normalize(path)
|
||||
if (seen.has(normalized) || !existsSync(normalized)) return
|
||||
seen.add(normalized)
|
||||
const source = readFileSync(normalized, 'utf8')
|
||||
const displayPath = normalizePath(relative(root, normalized))
|
||||
for (const name of collectRuntimeSourcePackageUses(normalized, source)) addUse(uses, name, displayPath)
|
||||
for (const specifier of collectLocalSourceSpecifiers(normalized, source)) {
|
||||
const target = resolveLocal(normalized, specifier)
|
||||
if (target !== undefined) visit(target)
|
||||
}
|
||||
}
|
||||
visit(resolve(root, pkg.dir, 'src/index.ts'))
|
||||
return uses
|
||||
}
|
||||
|
||||
function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
|
||||
const uses = new Map<string, string[]>()
|
||||
for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
|
||||
const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
|
||||
const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
|
||||
for (const name of collectSourcePackageUses(sourcePath, source)) addUse(uses, name, displayPath)
|
||||
}
|
||||
return uses
|
||||
}
|
||||
|
||||
/** Read source usage for one already-classified package. */
|
||||
export function readPackageDependencyFacts(
|
||||
root: string,
|
||||
pkg: WorkspacePackageManifest,
|
||||
role: PackageDependencyRole,
|
||||
workspaceNames: ReadonlySet<string>,
|
||||
): PackageDependencyFacts {
|
||||
const inject = pkg.manifest.dsh?.client?.inject ?? []
|
||||
return {
|
||||
manifestPath: pkg.manifestPath,
|
||||
role,
|
||||
manifest: pkg.manifest,
|
||||
workspaceNames,
|
||||
allSourceUses: readAllSourceUses(root, pkg),
|
||||
hostRuntimeSourceUses: readHostRuntimeUses(root, pkg),
|
||||
clientInject: new Set(inject.map(packageNameOf).filter(name => name !== undefined)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Read every package covered by the current dependency policy. */
|
||||
export function readPackageDependencyState(
|
||||
root: string,
|
||||
policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
|
||||
): PackageDependencyState {
|
||||
const packages = readWorkspacePackageManifests(root)
|
||||
const workspaceNames = new Set(packages.all.map(pkg => pkg.name))
|
||||
const discovered = discoverPackageDependencyScope(packages.release, policy)
|
||||
return {
|
||||
facts: discovered.selected.map(pkg => readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames)),
|
||||
packages: packages.release,
|
||||
policyViolations: discovered.violations,
|
||||
workspaceNames,
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive the required npm section for each relationship owned by the policy. */
|
||||
export function expectedPackageDependencies(
|
||||
facts: PackageDependencyFacts,
|
||||
): ReadonlyMap<string, ExpectedPackageDependency> {
|
||||
const expected = new Map<string, { section: ExpectedPackageDependency['section']; origins: Set<string> }>()
|
||||
const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => {
|
||||
if (name === facts.manifest.name || name === CORDIS) return
|
||||
const current = expected.get(name)
|
||||
const section = current?.section === 'dependencies' || sectionName === 'dependencies'
|
||||
? 'dependencies'
|
||||
: 'devDependencies'
|
||||
expected.set(name, { section, origins: new Set([...(current?.origins ?? []), origin]) })
|
||||
}
|
||||
|
||||
expected.set(CORDIS, { section: 'peer-dev', origins: new Set(['shared Cordis runtime']) })
|
||||
for (const [name, paths] of facts.allSourceUses) {
|
||||
if (!facts.workspaceNames.has(name)) continue
|
||||
for (const path of paths) add(name, 'devDependencies', path)
|
||||
}
|
||||
for (const name of facts.clientInject) {
|
||||
if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject')
|
||||
}
|
||||
for (const name of Object.keys(facts.manifest.peerDependencies ?? {})) {
|
||||
if (name !== CORDIS) add(name, 'devDependencies', 'existing non-Cordis peer')
|
||||
}
|
||||
for (const [name, paths] of facts.hostRuntimeSourceUses) {
|
||||
if (!facts.workspaceNames.has(name) && facts.manifest.peerDependencies?.[name] === undefined) continue
|
||||
for (const path of paths) add(name, 'dependencies', path)
|
||||
}
|
||||
return new Map([...expected].map(([name, rule]) => [name, {
|
||||
section: rule.section,
|
||||
origins: [...rule.origins].sort(),
|
||||
}]))
|
||||
}
|
||||
|
||||
/** Format the managed Host runtime edges that remain ordinary dependencies. */
|
||||
export function formatManagedRuntimeDependencies(state: PackageDependencyState): string[] {
|
||||
const rows = state.facts.flatMap((facts) => {
|
||||
const dependencies = [...expectedPackageDependencies(facts)]
|
||||
.filter(([, rule]) => rule.section === 'dependencies')
|
||||
.map(([name]) => name)
|
||||
.sort()
|
||||
if (dependencies.length === 0) return []
|
||||
return [{
|
||||
name: facts.manifest.name ?? facts.manifestPath,
|
||||
dependencies,
|
||||
}]
|
||||
}).sort((left, right) => left.name.localeCompare(right.name))
|
||||
const edges = rows.reduce((total, row) => total + row.dependencies.length, 0)
|
||||
return [
|
||||
`${GATE}: ${String(edges)} managed Host runtime dependency edge(s) remain in dependencies across ${String(rows.length)} package(s):`,
|
||||
...rows.map(row => ` ${row.name}: ${row.dependencies.join(', ')}`),
|
||||
]
|
||||
}
|
||||
|
||||
function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
|
||||
return manifest[name] ?? {}
|
||||
}
|
||||
|
||||
function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
|
||||
manifest[name] ??= {}
|
||||
return manifest[name]
|
||||
}
|
||||
|
||||
function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
|
||||
return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
|
||||
.filter(sectionName => section(manifest, sectionName)[name] !== undefined)
|
||||
}
|
||||
|
||||
function describeSections(sections: readonly DependencySection[]): string {
|
||||
return sections.length === 0 ? 'no dependency section' : sections.join(' + ')
|
||||
}
|
||||
|
||||
/** Return all manifest and policy violations in stable order. */
|
||||
export function collectPackageDependencyViolations(state: PackageDependencyState): string[] {
|
||||
const violations = [...state.policyViolations]
|
||||
for (const facts of state.facts) {
|
||||
for (const [name, rule] of expectedPackageDependencies(facts)) {
|
||||
const actual = declaredSections(facts.manifest, name)
|
||||
if (rule.section === 'peer-dev') {
|
||||
if (actual.length === 2
|
||||
&& actual.includes('peerDependencies')
|
||||
&& actual.includes('devDependencies')
|
||||
&& section(facts.manifest, 'peerDependencies')[name] === WORKSPACE_RANGE
|
||||
&& section(facts.manifest, 'devDependencies')[name] === WORKSPACE_RANGE
|
||||
&& facts.manifest.peerDependenciesMeta?.[name] === undefined) continue
|
||||
violations.push(
|
||||
`${facts.manifestPath}: ${name} must be matching peerDependencies + devDependencies at ${WORKSPACE_RANGE}; found ${describeSections(actual)}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const expectedSection = rule.section
|
||||
const range = section(facts.manifest, expectedSection)[name]
|
||||
if (actual.length === 1
|
||||
&& actual[0] === expectedSection
|
||||
&& (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE)) continue
|
||||
violations.push(
|
||||
`${facts.manifestPath}: ${name} (${rule.origins.join(', ')}) must be ${expectedSection}-only`
|
||||
+ (facts.workspaceNames.has(name) ? ` at ${WORKSPACE_RANGE}` : '')
|
||||
+ `; found ${describeSections(actual)}`,
|
||||
)
|
||||
}
|
||||
for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
|
||||
for (const [name, range] of Object.entries(section(facts.manifest, sectionName))) {
|
||||
if (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE) continue
|
||||
violations.push(`${facts.manifestPath}: ${sectionName}.${name} must use ${WORKSPACE_RANGE}, found ${range}`)
|
||||
}
|
||||
}
|
||||
for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) {
|
||||
if (facts.manifest.peerDependencies?.[name] === undefined) {
|
||||
violations.push(`${facts.manifestPath}: peerDependenciesMeta.${name} has no matching peerDependencies entry`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(violations)].sort()
|
||||
}
|
||||
|
||||
function deleteDependency(
|
||||
manifest: PackageDependencyManifest,
|
||||
sectionName: DependencySection,
|
||||
name: string,
|
||||
): void {
|
||||
const dependencies = manifest[sectionName]
|
||||
if (dependencies?.[name] === undefined) return
|
||||
const retained = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
|
||||
if (Object.keys(retained).length > 0) {
|
||||
manifest[sectionName] = retained
|
||||
return
|
||||
}
|
||||
switch (sectionName) {
|
||||
case 'dependencies': delete manifest.dependencies; break
|
||||
case 'devDependencies': delete manifest.devDependencies; break
|
||||
case 'optionalDependencies': delete manifest.optionalDependencies; break
|
||||
case 'peerDependencies': delete manifest.peerDependencies; break
|
||||
}
|
||||
}
|
||||
|
||||
function deletePeerMeta(manifest: PackageDependencyManifest, name: string): void {
|
||||
if (manifest.peerDependenciesMeta?.[name] === undefined) return
|
||||
const retained = Object.fromEntries(Object.entries(manifest.peerDependenciesMeta)
|
||||
.filter(([key]) => key !== name))
|
||||
if (Object.keys(retained).length > 0) manifest.peerDependenciesMeta = retained
|
||||
else delete manifest.peerDependenciesMeta
|
||||
}
|
||||
|
||||
function preferredRange(
|
||||
facts: PackageDependencyFacts,
|
||||
name: string,
|
||||
target: ExpectedPackageDependency['section'],
|
||||
): string | undefined {
|
||||
if (facts.workspaceNames.has(name)) return WORKSPACE_RANGE
|
||||
const order: readonly DependencySection[] = target === 'dependencies'
|
||||
? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
||||
: ['devDependencies', 'peerDependencies', 'dependencies', 'optionalDependencies']
|
||||
return order.map(sectionName => section(facts.manifest, sectionName)[name]).find(value => value !== undefined)
|
||||
}
|
||||
|
||||
/** Apply the dependency policy to one in-memory manifest. */
|
||||
export function repairPackageDependencyManifest(facts: PackageDependencyFacts): void {
|
||||
for (const [name, rule] of expectedPackageDependencies(facts)) {
|
||||
if (rule.section === 'peer-dev') {
|
||||
for (const sectionName of ['dependencies', 'optionalDependencies'] as const) {
|
||||
deleteDependency(facts.manifest, sectionName, name)
|
||||
}
|
||||
mutableSection(facts.manifest, 'peerDependencies')[name] = WORKSPACE_RANGE
|
||||
mutableSection(facts.manifest, 'devDependencies')[name] = WORKSPACE_RANGE
|
||||
deletePeerMeta(facts.manifest, name)
|
||||
continue
|
||||
}
|
||||
const range = preferredRange(facts, name, rule.section)
|
||||
if (range === undefined) continue
|
||||
for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
|
||||
if (sectionName !== rule.section) deleteDependency(facts.manifest, sectionName, name)
|
||||
}
|
||||
mutableSection(facts.manifest, rule.section)[name] = range
|
||||
deletePeerMeta(facts.manifest, name)
|
||||
}
|
||||
for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) {
|
||||
if (facts.manifest.peerDependencies?.[name] === undefined) deletePeerMeta(facts.manifest, name)
|
||||
}
|
||||
for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) {
|
||||
for (const name of Object.keys(section(facts.manifest, sectionName))) {
|
||||
if (facts.workspaceNames.has(name)) mutableSection(facts.manifest, sectionName)[name] = WORKSPACE_RANGE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Repair every covered manifest and return repository-relative changed paths. */
|
||||
export function fixPackageDependencies(root: string, state: PackageDependencyState): string[] {
|
||||
if (state.policyViolations.length > 0) return []
|
||||
const changed: string[] = []
|
||||
for (const facts of state.facts) {
|
||||
const before = `${JSON.stringify(facts.manifest, null, 2)}\n`
|
||||
repairPackageDependencyManifest(facts)
|
||||
const after = `${JSON.stringify(facts.manifest, null, 2)}\n`
|
||||
if (after === before) continue
|
||||
writeFileSync(resolve(root, facts.manifestPath), after)
|
||||
changed.push(facts.manifestPath)
|
||||
}
|
||||
return changed.sort()
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
let state = readPackageDependencyState(root)
|
||||
const fix = process.argv.includes('--fix')
|
||||
if (fix) {
|
||||
const changed = fixPackageDependencies(root, state)
|
||||
console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`)
|
||||
state = readPackageDependencyState(root)
|
||||
}
|
||||
const violations = collectPackageDependencyViolations(state)
|
||||
if (violations.length > 0) {
|
||||
console.error(`${GATE}: ${String(violations.length)} violation(s):`)
|
||||
for (const violation of violations) console.error(` ${violation}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const roles = Object.groupBy(state.facts, fact => fact.role)
|
||||
console.log(
|
||||
`${GATE}: ${String(state.facts.length)} package(s) match the published dependency policy`
|
||||
+ ` (${String(roles['client-host']?.length ?? 0)} Client/Host,`
|
||||
+ ` ${String(roles['configured-host']?.length ?? 0)} configured Host).`,
|
||||
)
|
||||
if (fix) {
|
||||
for (const line of formatManagedRuntimeDependencies(state)) console.log(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) main()
|
||||
Loading…
Add table
Reference in a new issue