mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
Merge remote-tracking branch 'origin/master' into xtr/durable-inbox-recovery
# Conflicts: # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/config-catalog.zh.md # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # packages/api/session-controller/src/control.ts # packages/context/agent-instructions/tests/agent-instructions.spec.ts # packages/test-support/agent-loop-testkit/package.json
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
# AGENTS.md — Repository scripts
|
||||
|
||||
Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer. Source-ownership gates use syntax-aware discovery, guard against an empty or narrowed corpus, and test every admitted/excluded form that changes their detection boundary.
|
||||
|
||||
Script specs run in forked workers beside the rest of the suite and beside the other gate processes in their job, so own every port, temporary path, and child process a spec acquires. A spec that passes only when it runs alone is a defect in the spec; [the testing policy](../docs/testing.md#how-specs-execute) states the execution model and [dsh-ci-test-reliability](../.agents/skills/dsh-ci-test-reliability/SKILL.md) owns the rules.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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']]]),
|
||||
hostRuntimeExportUses: [{
|
||||
packageName: '@f/runtime',
|
||||
specifier: '@f/runtime',
|
||||
exportName: 'runtimeValue',
|
||||
sourcePath: 'packages/g/probe/src/index.ts',
|
||||
line: 1,
|
||||
column: 10,
|
||||
sourceLine: "import { runtimeValue } from '@f/runtime'",
|
||||
}],
|
||||
peerRequiredHostDependencies: new Set(),
|
||||
configurationOnlyDevDependencies: new Set(),
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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 {
|
||||
benchmarkNpmResolution,
|
||||
buildRegistryIndex,
|
||||
parseBenchmarkOptions,
|
||||
publishWorkspaceRange,
|
||||
resolveNpmPackageLock,
|
||||
runCommandWithTimeout,
|
||||
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`)
|
||||
}
|
||||
|
||||
function processCanExecute(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false
|
||||
throw error
|
||||
}
|
||||
if (process.platform !== 'linux') return true
|
||||
try {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
||||
const state = stat.slice(stat.lastIndexOf(')') + 2).split(/\s+/, 1)[0]
|
||||
return !/^[ZXx]$/.test(state ?? '')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
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([])
|
||||
})
|
||||
|
||||
it('returns npm placement for two aliased package versions without requesting archives', async () => {
|
||||
const index: RegistryIndex = new Map([[
|
||||
'@deepseek-ai/dsh',
|
||||
new Map([
|
||||
['0.1.0', { name: '@deepseek-ai/dsh', version: '0.1.0' }],
|
||||
['0.2.0', { name: '@deepseek-ai/dsh', version: '0.2.0' }],
|
||||
]),
|
||||
]])
|
||||
|
||||
const result = await resolveNpmPackageLock(index, {
|
||||
'@deepseek-ai/dsh': '0.2.0',
|
||||
'dsh-previous': 'npm:@deepseek-ai/dsh@0.1.0',
|
||||
}, 10_000)
|
||||
|
||||
expect(result.archiveRequests).toBe(0)
|
||||
expect(result.packageLock.packages['node_modules/@deepseek-ai/dsh']?.version).toBe('0.2.0')
|
||||
expect(result.packageLock.packages['node_modules/dsh-previous']).toMatchObject({
|
||||
name: '@deepseek-ai/dsh',
|
||||
version: '0.1.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('isolates peer resolution from inherited npm configuration', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-hostile-npm-config-'))
|
||||
roots.push(root)
|
||||
const userConfig = join(root, 'user.npmrc')
|
||||
writeFileSync(userConfig, '@deepseek-ai:registry=http://127.0.0.1:1/\nlegacy-peer-deps=true\nomit=peer\n')
|
||||
const previous = {
|
||||
userConfig: process.env.npm_config_userconfig,
|
||||
legacyPeerDeps: process.env.npm_config_legacy_peer_deps,
|
||||
omit: process.env.npm_config_omit,
|
||||
}
|
||||
process.env.npm_config_userconfig = userConfig
|
||||
process.env.npm_config_legacy_peer_deps = 'true'
|
||||
process.env.npm_config_omit = 'peer'
|
||||
try {
|
||||
const index: RegistryIndex = new Map([
|
||||
['@deepseek-ai/dsh', new Map([['0.1.0', {
|
||||
name: '@deepseek-ai/dsh',
|
||||
version: '0.1.0',
|
||||
peerDependencies: { '@deepseek-ai/dsh-peer': '1.0.0' },
|
||||
}]])],
|
||||
['@deepseek-ai/dsh-peer', new Map([['1.0.0', {
|
||||
name: '@deepseek-ai/dsh-peer',
|
||||
version: '1.0.0',
|
||||
}]])],
|
||||
])
|
||||
|
||||
const result = await resolveNpmPackageLock(index, { '@deepseek-ai/dsh': '0.1.0' }, 10_000)
|
||||
|
||||
expect(result.archiveRequests).toBe(0)
|
||||
expect(result.packageLock.packages['node_modules/@deepseek-ai/dsh-peer']?.version).toBe('1.0.0')
|
||||
} finally {
|
||||
if (previous.userConfig === undefined) delete process.env.npm_config_userconfig
|
||||
else process.env.npm_config_userconfig = previous.userConfig
|
||||
if (previous.legacyPeerDeps === undefined) delete process.env.npm_config_legacy_peer_deps
|
||||
else process.env.npm_config_legacy_peer_deps = previous.legacyPeerDeps
|
||||
if (previous.omit === undefined) delete process.env.npm_config_omit
|
||||
else process.env.npm_config_omit = previous.omit
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('force-kills a timed-out process tree', async () => {
|
||||
const source = [
|
||||
"const { spawn } = require('node:child_process')",
|
||||
"process.on('SIGTERM', () => {})",
|
||||
'const child = spawn(process.execPath, [\'-e\', "process.on(\'SIGTERM\', () => {}); setInterval(() => {}, 1000)"], { stdio: \'ignore\' })',
|
||||
'console.log(child.pid)',
|
||||
'setInterval(() => {}, 1000)',
|
||||
].join(';')
|
||||
let descendantPid: number | undefined
|
||||
try {
|
||||
const result = await runCommandWithTimeout(process.execPath, ['-e', source], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
timeoutMs: 1_000,
|
||||
terminationGraceMs: 100,
|
||||
})
|
||||
const reportedPid = Number.parseInt(result.output.trim(), 10)
|
||||
if (!Number.isSafeInteger(reportedPid)) throw new Error(`child reported invalid pid ${result.output.trim()}`)
|
||||
descendantPid = reportedPid
|
||||
|
||||
expect(result.timedOut).toBe(true)
|
||||
expect(result.signal).toBe('SIGKILL')
|
||||
await expect.poll(() => processCanExecute(reportedPid), { timeout: 5_000 }).toBe(false)
|
||||
} finally {
|
||||
if (descendantPid !== undefined && Number.isSafeInteger(descendantPid)) {
|
||||
try {
|
||||
process.kill(descendantPid, 'SIGKILL')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,551 @@
|
||||
/** Benchmark npm's dependency-tree resolution against an all-local registry. */
|
||||
|
||||
import { execFileSync, spawn, spawnSync, type ChildProcess } 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 TERMINATION_GRACE_MS = 1_000
|
||||
const FORCED_EXIT_TIMEOUT_MS = 5_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[]
|
||||
}
|
||||
|
||||
/** Published-package fields retained in npm's package-lock layout. */
|
||||
export interface NpmLockPackage {
|
||||
readonly name?: string
|
||||
readonly version?: string
|
||||
readonly dependencies?: Readonly<Record<string, string>>
|
||||
readonly optionalDependencies?: Readonly<Record<string, string>>
|
||||
readonly peerDependencies?: Readonly<Record<string, string>>
|
||||
readonly peerDependenciesMeta?: Readonly<Record<string, { readonly optional?: boolean }>>
|
||||
}
|
||||
|
||||
/** The installed paths selected by npm without materializing package archives. */
|
||||
export interface NpmPackageLock {
|
||||
readonly lockfileVersion: number
|
||||
readonly packages: Readonly<Record<string, NpmLockPackage>>
|
||||
}
|
||||
|
||||
/** npm resolution observations together with its computed install layout. */
|
||||
export interface NpmPackageLockResolution extends BenchmarkRun {
|
||||
readonly packageLock: NpmPackageLock
|
||||
}
|
||||
|
||||
/** 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'
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolveDelay => setTimeout(resolveDelay, ms))
|
||||
}
|
||||
|
||||
function signalProcessTree(child: ChildProcess, signal: 'SIGTERM' | 'SIGKILL'): void {
|
||||
if (child.pid === undefined) {
|
||||
child.kill(signal)
|
||||
return
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
const force = signal === 'SIGKILL' ? ['/F'] : []
|
||||
const result = spawnSync('taskkill', ['/PID', String(child.pid), '/T', ...force], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.error !== undefined) throw result.error
|
||||
if (result.status !== 0 && child.exitCode === null && child.signalCode === null) child.kill(signal)
|
||||
return
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, signal)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one command with bounded process-tree termination after its deadline.
|
||||
* @param command - Executable path or name.
|
||||
* @param args - Arguments passed without shell interpolation on POSIX.
|
||||
* @param options - Working directory, environment, timeout, and termination grace.
|
||||
* @returns Exit facts, captured output, duration, and whether timeout handling began.
|
||||
*/
|
||||
export async function runCommandWithTimeout(
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
options: {
|
||||
readonly cwd: string
|
||||
readonly env: NodeJS.ProcessEnv
|
||||
readonly timeoutMs: number
|
||||
readonly terminationGraceMs?: number
|
||||
},
|
||||
): Promise<{ status: number | null; signal: NodeJS.Signals | null; durationMs: number; output: string; timedOut: boolean }> {
|
||||
const started = performance.now()
|
||||
const child = spawn(command, [...args], {
|
||||
cwd: options.cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
env: options.env,
|
||||
shell: process.platform === 'win32',
|
||||
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 exited = new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolveExit, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (status, signal) => { resolveExit({ status, signal }) })
|
||||
})
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
try {
|
||||
const first = await Promise.race([
|
||||
exited.then(outcome => ({ type: 'exit' as const, outcome })),
|
||||
new Promise<{ type: 'timeout' }>((resolveTimeout) => {
|
||||
timeout = setTimeout(() => { resolveTimeout({ type: 'timeout' }) }, options.timeoutMs)
|
||||
}),
|
||||
])
|
||||
if (first.type === 'exit') {
|
||||
return { ...first.outcome, durationMs: performance.now() - started, output, timedOut: false }
|
||||
}
|
||||
|
||||
signalProcessTree(child, 'SIGTERM')
|
||||
await delay(options.terminationGraceMs ?? TERMINATION_GRACE_MS)
|
||||
signalProcessTree(child, 'SIGKILL')
|
||||
const forced = await Promise.race([
|
||||
exited,
|
||||
delay(FORCED_EXIT_TIMEOUT_MS).then(() => undefined),
|
||||
])
|
||||
if (forced === undefined) throw new Error('timed-out process tree did not exit after SIGKILL')
|
||||
return { ...forced, durationMs: performance.now() - started, output, timedOut: true }
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function readNpmPackageLock(path: string): NpmPackageLock {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('npm produced an invalid package-lock.json')
|
||||
}
|
||||
const { lockfileVersion, packages } = parsed as Record<string, unknown>
|
||||
if (!Number.isSafeInteger(lockfileVersion) || packages === null
|
||||
|| typeof packages !== 'object' || Array.isArray(packages)) {
|
||||
throw new Error('npm produced an invalid package-lock.json')
|
||||
}
|
||||
return parsed as NpmPackageLock
|
||||
}
|
||||
|
||||
async function runNpm(
|
||||
cwd: string,
|
||||
registry: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ durationMs: number; output: string; timedOut: boolean }> {
|
||||
const npmrc = join(cwd, '.npmrc')
|
||||
const globalNpmrc = join(cwd, '.npmrc-global')
|
||||
writeFileSync(npmrc, `registry=${registry}\n@deepseek-ai:registry=${registry}\n`)
|
||||
writeFileSync(globalNpmrc, '')
|
||||
const inheritedEnvironment = Object.fromEntries(Object.entries(process.env)
|
||||
.filter(([name]) => !name.toLowerCase().startsWith('npm_config_')))
|
||||
const result = await runCommandWithTimeout(npmExecutable(), [
|
||||
'install',
|
||||
'--package-lock-only',
|
||||
'--ignore-scripts',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--loglevel=error',
|
||||
'--include=peer',
|
||||
'--install-strategy=hoisted',
|
||||
'--legacy-peer-deps=false',
|
||||
`--registry=${registry}`,
|
||||
], {
|
||||
cwd,
|
||||
env: {
|
||||
...inheritedEnvironment,
|
||||
npm_config_cache: join(cwd, '.npm-cache'),
|
||||
npm_config_globalconfig: globalNpmrc,
|
||||
npm_config_userconfig: npmrc,
|
||||
npm_config_update_notifier: 'false',
|
||||
},
|
||||
timeoutMs,
|
||||
})
|
||||
if (result.timedOut) return result
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`npm install exited ${String(result.status)} after ${result.durationMs.toFixed(0)} ms\n${result.output.trim()}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask npm to compute an install layout without downloading package archives.
|
||||
* @param index - Package metadata exposed through the local registry.
|
||||
* @param dependencies - Root dependencies whose install layout npm computes.
|
||||
* @param timeoutMs - Hard wall-clock limit for the npm child process.
|
||||
* @returns The package lock plus timing and registry-request observations.
|
||||
*/
|
||||
export async function resolveNpmPackageLock(
|
||||
index: RegistryIndex,
|
||||
dependencies: Readonly<Record<string, string>>,
|
||||
timeoutMs: number,
|
||||
): Promise<NpmPackageLockResolution> {
|
||||
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,
|
||||
}, 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(),
|
||||
packageLock: readNpmPackageLock(join(consumer, 'package-lock.json')),
|
||||
}
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await close(server)
|
||||
rmSync(consumer, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const result = await resolveNpmPackageLock(index, { [TARGET_PACKAGE]: targetVersion }, timeoutMs)
|
||||
return {
|
||||
durationMs: result.durationMs,
|
||||
registryRequests: result.registryRequests,
|
||||
archiveRequests: result.archiveRequests,
|
||||
unknownPackages: result.unknownPackages,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,35 @@ describe('CI workflow', () => {
|
||||
))
|
||||
expect(buildCommands.map(step => step.run)).toContain('pnpm run check:ci:windows-blocking')
|
||||
|
||||
// The four native Windows installs branch on the workspace filesystem:
|
||||
// clone (ReFS block clone) only on ReFS, plain install elsewhere. This
|
||||
// keeps the TS6231 store-path leak (see the Windows ReFS store note) out
|
||||
// of the self-hosted pool without forcing clone onto hosted NTFS, which
|
||||
// rejects copy-on-write. The branch must stay, or a hosted fallback would
|
||||
// fail installs with ERR_PNPM_LINKING_FAILED.
|
||||
for (const [jobName, job] of [['windows-build', windowsBuild], ['windows-coverage', windowsCoverage], ['windows-native-tests', windowsNativeTests], ['windows-observational', windowsObservational]] as const) {
|
||||
const steps = job.steps as unknown[]
|
||||
const install = steps.find((step): step is Record<string, unknown> & { run: string } => (
|
||||
isRecord(step) && step.name === 'Install (immutable)' && typeof step.run === 'string'
|
||||
))
|
||||
expect(install, `${jobName} must define the filesystem-branched install`).toBeDefined()
|
||||
expect(install!.run).toContain("$fs -eq 'ReFS'")
|
||||
expect(install!.run).toContain('--package-import-method=clone')
|
||||
expect(install!.run).toContain('corepack pnpm install')
|
||||
// The else branch must keep the plain hosted install as a distinct line
|
||||
// (not the corepack clone line, which contains the same substring);
|
||||
// dropping it or making both branches clone would force clone onto
|
||||
// NTFS, which rejects copy-on-write (ERR_PNPM_LINKING_FAILED). The
|
||||
// YAML folded block keeps the first statement on line 1 and folds the
|
||||
// rest with leading two-space indents.
|
||||
const installLines = install!.run.split('\n').map(line => line.trim())
|
||||
expect(installLines).toContain('} else {')
|
||||
expect(installLines.some(line => line === 'pnpm install --frozen-lockfile'), `${jobName} else branch must keep the plain hosted install`).toBe(true)
|
||||
// The ReFS branch must not use the interpolated empty-flag form, which
|
||||
// passes a stray "" positional argument to pnpm.
|
||||
expect(install!.run).not.toContain('$cloneFlag')
|
||||
}
|
||||
|
||||
// windows-coverage uses the lower 4-partition profile.
|
||||
expect(windowsCoverage.name).toBe('windows node 24 / coverage')
|
||||
expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '4' })
|
||||
@@ -150,6 +179,26 @@ describe('CI workflow', () => {
|
||||
expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
|
||||
expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows'])
|
||||
expect(serialWindows.name).toBe('serial / windows (self-hosted standby)')
|
||||
// Its store must share the ReFS workspace volume for clone; the install
|
||||
// must carry the same filesystem branch as the PR jobs.
|
||||
const serialSteps = serialWindows.steps as unknown[]
|
||||
const serialStore = serialSteps.find((step): step is Record<string, unknown> & { run: string } => (
|
||||
isRecord(step) && step.name === 'Configure persistent pnpm store' && typeof step.run === 'string'
|
||||
))
|
||||
expect(serialStore).toBeDefined()
|
||||
expect(serialStore!.run).toContain('F:\\.pnpm-store')
|
||||
const serialInstall = serialSteps.find((step): step is Record<string, unknown> & { run: string } => (
|
||||
isRecord(step) && step.name === 'Install (immutable)' && typeof step.run === 'string'
|
||||
))
|
||||
expect(serialInstall).toBeDefined()
|
||||
expect(serialInstall!.run).toContain("$fs -eq 'ReFS'")
|
||||
expect(serialInstall!.run).toContain('--package-import-method=clone')
|
||||
expect(serialInstall!.run).toContain('corepack pnpm install')
|
||||
// Distinct else-branch line, as for the PR jobs: the corepack clone line
|
||||
// contains the plain-install substring too.
|
||||
expect(serialInstall!.run.split('\n').map(line => line.trim())).toContain('} else {')
|
||||
expect(serialInstall!.run.split('\n').map(line => line.trim())).toContain('pnpm install --frozen-lockfile')
|
||||
expect(serialInstall!.run).not.toContain('$cloneFlag')
|
||||
|
||||
// Aggregate: Wine and the required split native jobs are needed;
|
||||
// windows-coverage is temporarily non-blocking while Windows ACP
|
||||
@@ -603,7 +652,7 @@ describe('npm release workflows', () => {
|
||||
for (const file of ['release.yml', 'release-vendor.yml']) {
|
||||
const workflow = loadWorkflow(`.github/workflows/${file}`)
|
||||
if (!isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`)
|
||||
expect(Object.keys(workflow.jobs).sort()).toEqual(['pack'])
|
||||
expect(Object.keys(workflow.jobs).sort()).toEqual(file === 'release.yml' ? ['dependencies', 'pack'] : ['pack'])
|
||||
}
|
||||
|
||||
// publication is workflow_dispatch-only (never a PR check) and keeps the
|
||||
@@ -618,6 +667,20 @@ describe('npm release workflows', () => {
|
||||
expect(publish.concurrency).toMatchObject({ group: 'Release-publish' })
|
||||
}
|
||||
})
|
||||
|
||||
it('runs dependency policy and npm layout checks in the DSH release workflow', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/release.yml')
|
||||
const dependencies = workflowJob(workflow, 'dependencies')
|
||||
if (!isRecord(workflow.on) || !Array.isArray(dependencies.steps)) {
|
||||
throw new TypeError('DSH release workflow must define triggers and dependency steps')
|
||||
}
|
||||
const commands = dependencies.steps.flatMap(step =>
|
||||
isRecord(step) && typeof step.run === 'string' ? [step.run] : [])
|
||||
|
||||
expect(Object.keys(workflow.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch'])
|
||||
expect(commands).toContain('pnpm run verify-package-dependencies')
|
||||
expect(commands).toContain('pnpm run verify-npm-install-layout')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Documentation site publication', () => {
|
||||
|
||||
@@ -91,9 +91,11 @@ describe('client bundle purity gate', () => {
|
||||
expect(() => resolveId('@deepseek-ai/dsh-client-web-react/store')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('lets inline-safe wire layers inline', () => {
|
||||
it('lets inline-safe libraries inline', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-deque')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-util-values')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull()
|
||||
expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/)
|
||||
|
||||
@@ -90,10 +90,11 @@ describe('coverage partition count', () => {
|
||||
})
|
||||
|
||||
describe('coverage partition timeout', () => {
|
||||
it('applies one configured timeout to tests and polling', () => {
|
||||
it('applies one configured timeout to tests, polling, and hooks', () => {
|
||||
expect(coverageTestTimeoutArgs('30000')).toEqual([
|
||||
'--testTimeout=30000',
|
||||
'--expect.poll.timeout=30000',
|
||||
'--hookTimeout=30000',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export const COVERAGE_PARTITIONS_ENV = 'DSH_COVERAGE_PARTITIONS'
|
||||
/** Internal marker that suppresses reports and thresholds inside a partition process. */
|
||||
export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE'
|
||||
|
||||
/** Environment variable overriding instrumented test and polling timeouts. */
|
||||
/** Environment variable overriding instrumented test, polling, and hook timeouts. */
|
||||
export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS'
|
||||
|
||||
/** One child command owned by the coverage coordinator. */
|
||||
@@ -76,14 +76,23 @@ export function parseCoveragePartitionCount(raw: string | undefined): number | u
|
||||
return parsed
|
||||
}
|
||||
|
||||
/** Resolve the paired Vitest timeout arguments used by coverage partitions. */
|
||||
/**
|
||||
* Resolve the paired Vitest timeout arguments used by coverage partitions.
|
||||
* `--hookTimeout` travels with the test budget because setup and teardown pay
|
||||
* the same host contention the raised test budget accounts for: fixtures that
|
||||
* await child exit or retry Windows handle release spend that cost in
|
||||
* `afterEach`, where Vitest's separate 10 s default would otherwise fail a
|
||||
* suite whose cases all passed.
|
||||
* @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults.
|
||||
* @returns the Vitest arguments applying that budget, empty when unset.
|
||||
*/
|
||||
export function coverageTestTimeoutArgs(raw: string | undefined): string[] {
|
||||
if (raw === undefined || raw === '') return []
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
|
||||
throw new Error(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`]
|
||||
return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`, `--hookTimeout=${raw}`]
|
||||
}
|
||||
|
||||
/** Remove pnpm's package-script separator before forwarding Vitest arguments. */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"docs/architecture.md": 2400,
|
||||
"docs/cordis-primer.md": 600,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 1150,
|
||||
"packages/AGENTS.md": 675,
|
||||
"docs/testing.md": 1300,
|
||||
"packages/AGENTS.md": 750,
|
||||
"packages/README.md": 994
|
||||
}
|
||||
|
||||
@@ -76,13 +76,16 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
|
||||
'packages/typert/generator': 'Build-time generator run outside any agent runtime.',
|
||||
'packages/typert/protocol': 'Compiler-independent protocol declarations.',
|
||||
'packages/util/atomic-write': 'Zero-dependency filesystem write utility.',
|
||||
'packages/util/brand': 'Type-only branding primitive erased at compile time.',
|
||||
'packages/util/brand': 'Stateless nominal-string and canonical-key constructors.',
|
||||
'packages/util/crypto': 'Zero-dependency identifier minting utility.',
|
||||
'packages/util/deque': 'Zero-dependency circular deque utility.',
|
||||
'packages/util/home-paths': 'Zero-dependency harness-home path resolver.',
|
||||
'packages/util/launch-environment': 'Zero-dependency environment resolver.',
|
||||
'packages/util/native-command': 'Host-side subprocess runner utility.',
|
||||
'packages/util/output-retention': 'Zero-dependency retention utility.',
|
||||
'packages/util/time': 'Zero-dependency time-zone canonicalization utility.',
|
||||
'packages/util/timeout': 'Zero-dependency timeout utility.',
|
||||
'packages/util/values': 'Stateless lossless-JSON and immutable-value helpers.',
|
||||
'packages/util/workspace-path': 'Zero-dependency Workspace path formatter.',
|
||||
}
|
||||
|
||||
|
||||
@@ -494,7 +494,9 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SubagentStartRequest: 'subagent.md',
|
||||
AssembleContext: 'system-prompt.md',
|
||||
PromptContext: 'system-prompt.md',
|
||||
PromptContextOrderName: 'system-prompt.md',
|
||||
PromptSection: 'system-prompt.md',
|
||||
PromptSectionOrderName: 'system-prompt.md',
|
||||
SystemPrompt: 'system-prompt.md',
|
||||
ToolProviderResult: 'system-prompt.md',
|
||||
JobDoneListener: 'jobs.md',
|
||||
@@ -534,7 +536,9 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ToolRestriction: 'tools.md',
|
||||
ToolSchema: 'tools.md',
|
||||
SettingsNamespace: 'settings.md',
|
||||
SettingsNamespaceInput: 'settings.md',
|
||||
SettingsRegisterOptions: 'settings.md',
|
||||
SettingsSectionHooks: 'settings.md',
|
||||
SettingsScope: 'settings.md',
|
||||
SettingsDescriptor: 'settings.md',
|
||||
SettingsDescribeValue: 'settings.md',
|
||||
@@ -657,6 +661,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md',
|
||||
AgentPresetRoster: 'path-free preset roster is owned by packages/preset/agent-presets/README.md',
|
||||
AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-presets/README.md',
|
||||
AgentPresetComposition: 'flattened composition rows are owned by packages/preset/agent-presets/README.md',
|
||||
PresetMetadata: 'preset display text is owned by packages/preset/agent-presets/README.md',
|
||||
BashEnvContributor: 'service-local extension type is owned by packages/shell/tool-bash/src/index.ts',
|
||||
BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts',
|
||||
|
||||
+106
-48
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* Generate `docs/module-graph.md` from in-repo `peerDependencies`, the canonical
|
||||
* runtime edges. The deterministic output groups packages by directory and
|
||||
* renders both Mermaid and a dependency table; `--check` verifies freshness.
|
||||
*/
|
||||
/** Generate the paired shared-instance package graph from workspace peer dependencies. */
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
collectPackageGraph,
|
||||
escapeMermaidLabel as escLabel,
|
||||
graphNodeId as nodeId,
|
||||
type PackageGraphNode,
|
||||
} from './package-graph.ts'
|
||||
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
|
||||
import { renderTranslationPairingRecord, translationPairPaths } from './translation-pairing-record.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/module-graph.md'
|
||||
const SOURCE = 'docs/module-graph.md'
|
||||
const PATHS = translationPairPaths(SOURCE)
|
||||
type Pkg = PackageGraphNode
|
||||
type Locale = 'en' | 'zh'
|
||||
|
||||
const GROUP_ORDER = [
|
||||
'util',
|
||||
@@ -46,42 +46,55 @@ function packageLink(pkg: Pkg): string {
|
||||
return `[\`${pkg.short}\`](../${pkg.rel})`
|
||||
}
|
||||
|
||||
/** Render the full docs/module-graph.md content (pure, deterministic). */
|
||||
function render(pkgs: Pkg[]): string {
|
||||
/**
|
||||
* Render one locale of the complete deterministic package graph.
|
||||
* @param pkgs - Dependency-first package nodes.
|
||||
* @param locale - Output document language.
|
||||
* @returns Complete generated Markdown.
|
||||
*/
|
||||
export function renderModuleGraph(pkgs: readonly Pkg[], locale: Locale): string {
|
||||
const edges: string[] = []
|
||||
for (const p of pkgs) {
|
||||
for (const d of p.deps) edges.push(` ${nodeId('pkg', p.short)} --> ${nodeId('pkg', d)}`)
|
||||
for (const pkg of pkgs) {
|
||||
for (const dependency of pkg.deps) edges.push(` ${nodeId('pkg', pkg.short)} --> ${nodeId('pkg', dependency)}`)
|
||||
}
|
||||
const byShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
|
||||
const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((a, b) => {
|
||||
const ia = GROUP_ORDER.indexOf(a)
|
||||
const ib = GROUP_ORDER.indexOf(b)
|
||||
const na = ia === -1 ? Number.MAX_SAFE_INTEGER : ia
|
||||
const nb = ib === -1 ? Number.MAX_SAFE_INTEGER : ib
|
||||
return na - nb || a.localeCompare(b)
|
||||
const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((left, right) => {
|
||||
const leftIndex = GROUP_ORDER.indexOf(left)
|
||||
const rightIndex = GROUP_ORDER.indexOf(right)
|
||||
const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex
|
||||
const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex
|
||||
return normalizedLeft - normalizedRight || left.localeCompare(right)
|
||||
})
|
||||
const groupBlocks: string[] = []
|
||||
for (const group of groups) {
|
||||
groupBlocks.push(` subgraph ${nodeId('group', group)}["packages/${escLabel(group)}"]`)
|
||||
for (const pkg of pkgs.filter(p => p.group === group).sort((a, b) => a.short.localeCompare(b.short))) {
|
||||
for (const pkg of pkgs.filter(candidate => candidate.group === group)
|
||||
.sort((left, right) => left.short.localeCompare(right.short))) {
|
||||
groupBlocks.push(` ${nodeId('pkg', pkg.short)}["${escLabel(pkg.short)}"]`)
|
||||
}
|
||||
groupBlocks.push(' end')
|
||||
}
|
||||
const rows = pkgs.map((p) => {
|
||||
const deps = p.deps.length ? p.deps.map((d) => {
|
||||
const dep = byShort.get(d)
|
||||
return dep ? packageLink(dep) : `\`${d}\``
|
||||
}).join(', ') : '—'
|
||||
return `| ${packageLink(p)} | \`${p.group}\` | ${deps} |`
|
||||
const rows = pkgs.map((pkg) => {
|
||||
const dependencies = pkg.deps.length > 0
|
||||
? pkg.deps.map((dependency) => {
|
||||
const target = byShort.get(dependency)
|
||||
return target ? packageLink(target) : `\`${dependency}\``
|
||||
}).join(', ')
|
||||
: '—'
|
||||
return `| ${packageLink(pkg)} | \`${pkg.group}\` | ${dependencies} |`
|
||||
})
|
||||
const chinese = locale === 'zh'
|
||||
return [
|
||||
'<!-- Generated by scripts/gen-module-graph.ts — do not edit by hand.',
|
||||
' Run `pnpm run gen-module-graph` to regenerate. -->',
|
||||
chinese
|
||||
? '<!-- 由 scripts/gen-module-graph.ts 生成——请勿手工编辑。\n 运行 `pnpm run gen-module-graph` 重新生成。 -->'
|
||||
: '<!-- Generated by scripts/gen-module-graph.ts — do not edit by hand.\n Run `pnpm run gen-module-graph` to regenerate. -->',
|
||||
'',
|
||||
'# Module dependency graph',
|
||||
chinese ? '# 共享实例依赖关系图' : '# Shared-instance dependency graph',
|
||||
'',
|
||||
'Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package\'s `peerDependencies` (the canonical runtime-dependency signal) and grouped by the `packages/<group>/<pkg>` hierarchy. An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.',
|
||||
...(chinese ? ['[English](module-graph.md) | 中文', ''] : []),
|
||||
chinese
|
||||
? '`@deepseek-ai/dsh-*` harness 包之间的 peer 依赖关系。peer 表示消费端需要提供共享实例,不包括普通运行时 dependency 或仅开发期关系。该图按 `packages/<group>/<pkg>` 层级分组;边 `a --> b` 表示包 `a` peer 依赖包 `b`。名称中的 `@deepseek-ai/dsh-` 前缀已移除。'
|
||||
: 'Peer dependencies among the `@deepseek-ai/dsh-*` harness packages. A peer means the consumer requires a shared instance; ordinary runtime dependencies and development-only relationships are not shown. The graph is grouped by the `packages/<group>/<pkg>` hierarchy. An edge `a --> b` means package `a` has package `b` as a peer. Names omit the `@deepseek-ai/dsh-` prefix.',
|
||||
'',
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
@@ -89,31 +102,76 @@ function render(pkgs: Pkg[]): string {
|
||||
...edges,
|
||||
'```',
|
||||
'',
|
||||
'| Package | Group | Depends on |',
|
||||
chinese ? '| 包 | 分组 | Peer 依赖 |' : '| Package | Group | Peer dependencies |',
|
||||
'| --- | --- | --- |',
|
||||
...rows,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const content = render(collectPackageGraph(root, GROUP_ORDER, 'gen-module-graph'))
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(resolve(root, OUT), 'utf8')
|
||||
} catch {
|
||||
// A missing artifact is the expected read failure. Any read failure has the
|
||||
// same remedy here—regenerate—so it is reported as stale below.
|
||||
committed = null
|
||||
}
|
||||
if (committed === content) {
|
||||
console.log(`gen-module-graph: ${OUT} is up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-module-graph: ${OUT} is stale. Run \`pnpm run gen-module-graph\` and commit ${OUT}.`)
|
||||
process.exit(1)
|
||||
/**
|
||||
* Compute both localized graph documents from the current workspace manifests.
|
||||
* @param scanRoot - Repository root containing packages and documentation.
|
||||
* @returns Repository-relative output paths and exact generated content.
|
||||
*/
|
||||
export function computeModuleGraphOutputs(scanRoot: string = root): ReadonlyMap<string, string> {
|
||||
const packages = collectPackageGraph(scanRoot, GROUP_ORDER, 'gen-module-graph')
|
||||
return new Map([
|
||||
[PATHS.source, renderModuleGraph(packages, 'en')],
|
||||
[PATHS.zh, renderModuleGraph(packages, 'zh')],
|
||||
])
|
||||
}
|
||||
|
||||
writeFileSync(resolve(root, OUT), content)
|
||||
console.log(`gen-module-graph: wrote ${OUT}.`)
|
||||
/**
|
||||
* Write both graph documents and their recovery record.
|
||||
* @param scanRoot - Repository root containing packages and documentation.
|
||||
* @returns Repository-relative paths whose content changed.
|
||||
*/
|
||||
export function writeModuleGraph(scanRoot: string = root): string[] {
|
||||
const outputs = computeModuleGraphOutputs(scanRoot)
|
||||
const changed: string[] = []
|
||||
for (const [path, content] of outputs) {
|
||||
const destination = resolve(scanRoot, path)
|
||||
if (existsSync(destination) && readFileSync(destination, 'utf8') === content) continue
|
||||
writeFileSync(destination, content)
|
||||
changed.push(path)
|
||||
}
|
||||
const source = Buffer.from(outputs.get(PATHS.source) ?? '')
|
||||
const zh = Buffer.from(outputs.get(PATHS.zh) ?? '')
|
||||
const record = renderTranslationPairingRecord(PATHS, {
|
||||
sourceHash: storeGitBlob(scanRoot, source),
|
||||
zhHash: storeGitBlob(scanRoot, zh),
|
||||
})
|
||||
const recordPath = resolve(scanRoot, PATHS.meta)
|
||||
if (!existsSync(recordPath) || readFileSync(recordPath, 'utf8') !== record) {
|
||||
writeFileSync(recordPath, record)
|
||||
changed.push(PATHS.meta)
|
||||
}
|
||||
return changed.sort()
|
||||
}
|
||||
|
||||
/** CLI entry: regenerate by default, or verify all paired outputs with `--check`. @returns Nothing. */
|
||||
export function main(): void {
|
||||
const outputs = computeModuleGraphOutputs(root)
|
||||
const record = renderTranslationPairingRecord(PATHS, {
|
||||
sourceHash: gitBlobHash(Buffer.from(outputs.get(PATHS.source) ?? '')),
|
||||
zhHash: gitBlobHash(Buffer.from(outputs.get(PATHS.zh) ?? '')),
|
||||
})
|
||||
const expected = new Map([...outputs, [PATHS.meta, record]])
|
||||
if (process.argv.includes('--check')) {
|
||||
const stale = [...expected].filter(([path, content]) => (
|
||||
!existsSync(resolve(root, path)) || readFileSync(resolve(root, path), 'utf8') !== content
|
||||
)).map(([path]) => path)
|
||||
if (stale.length === 0) {
|
||||
console.log(`gen-module-graph: ${expected.size} artifact(s) are up to date.`)
|
||||
return
|
||||
}
|
||||
console.error(`gen-module-graph: stale — ${stale.join(', ')}. Run \`pnpm run gen-module-graph\` and commit the result.`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const changed = writeModuleGraph(root)
|
||||
console.log(`gen-module-graph: ${expected.size} artifact(s) computed, ${String(changed.length)} written.`)
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) main()
|
||||
|
||||
@@ -368,7 +368,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'',
|
||||
'## Event envelope',
|
||||
'',
|
||||
@@ -394,7 +394,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
|
||||
/**
|
||||
* Render the runtime known-vocabulary module: every event type the packages in
|
||||
* this repo can write, as a generated `ReadonlySet` the read path checks
|
||||
* before reconstructing a stored session.
|
||||
* unknown-type refusal against (`SessionEvent.ignorable` contract).
|
||||
*/
|
||||
export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string {
|
||||
const names = [...new Set(events.map(e => e.name))].sort()
|
||||
@@ -409,12 +409,16 @@ export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string
|
||||
'/**',
|
||||
' * Every `SessionEventMap` member declared in this repository — the event',
|
||||
' * vocabulary this build understands. The persistence read path refuses to',
|
||||
' * interpret a log containing a type outside this set: such a log was likely',
|
||||
' * written by a newer harness, and silently skipping the event could',
|
||||
' * reconstruct a wrong session.',
|
||||
' * interpret a log containing a type outside this set unless the event',
|
||||
' * carries the envelope\'s `ignorable` marker (see `SessionEvent.ignorable`',
|
||||
' * in `./types.ts`): such a log was likely written by a newer harness, and',
|
||||
' * silently skipping a required event would reconstruct a wrong session.',
|
||||
' * Downstream (out-of-repo) plugin events are outside this list by',
|
||||
' * construction; a registration surface for them is deferred until such a',
|
||||
' * consumer exists.',
|
||||
' * construction. The persisted `SessionEvent.ignorable` marker is the',
|
||||
' * compatibility mechanism; event-name registration was rejected because',
|
||||
' * it does not classify omission safety and would make reads',
|
||||
' * composition-dependent. The rationale is in',
|
||||
' * `.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md`.',
|
||||
' */',
|
||||
'export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([',
|
||||
...names.map(name => ` '${name}',`),
|
||||
|
||||
@@ -23,9 +23,6 @@ const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B
|
||||
const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url))
|
||||
const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/package.json')))
|
||||
const fixtures: string[] = []
|
||||
// Multi-worktree cases spawn several Git and Node subprocesses; native Windows
|
||||
// coverage concurrency can delay them without changing installer behavior.
|
||||
const MULTI_PROCESS_TEST_TIMEOUT_MS = 30_000
|
||||
|
||||
interface Fixture {
|
||||
container: string
|
||||
@@ -211,7 +208,15 @@ function runInstaller(
|
||||
})
|
||||
}
|
||||
|
||||
describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
|
||||
// Every case builds scratch worktrees and drives them through spawned Git and
|
||||
// Node subprocesses, so the suite is bound by process creation rather than by
|
||||
// its assertions. The value matches DSH_COVERAGE_TEST_TIMEOUT_MS, which the
|
||||
// Windows coverage lane passes as --testTimeout: a describe value overrides that
|
||||
// flag rather than yielding to it, so a smaller one here lowers what the lane
|
||||
// grants every case in this file, none of which carries an allowance of its own.
|
||||
// Rationale and the paired hook budget are in
|
||||
// .agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md.
|
||||
describe('worktree-local Lefthook installer', { timeout: 90_000 }, () => {
|
||||
for (const [label, extraEnv] of [
|
||||
['CI', { CI: 'true' }],
|
||||
['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
|
||||
@@ -290,7 +295,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
|
||||
git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked])
|
||||
expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval)
|
||||
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
|
||||
}, MULTI_PROCESS_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('replaces the owned hook path Git copies into a newly added worktree', async () => {
|
||||
const fixture = createFixture()
|
||||
@@ -315,7 +320,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
|
||||
'# config=late-linked-worktree-config',
|
||||
)
|
||||
expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBefore)
|
||||
}, MULTI_PROCESS_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('serializes concurrent installs and keeps repeated output stable', async () => {
|
||||
const fixture = createFixture()
|
||||
@@ -336,7 +341,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
|
||||
expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook)
|
||||
expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false)
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
|
||||
}, MULTI_PROCESS_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('waits for a concurrent installer to finish publishing its lock record', async () => {
|
||||
const fixture = createFixture()
|
||||
@@ -374,7 +379,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
|
||||
expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain(
|
||||
JSON.stringify(movedHooks),
|
||||
)
|
||||
}, MULTI_PROCESS_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => {
|
||||
const fixture = createFixture()
|
||||
@@ -415,7 +420,7 @@ describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => {
|
||||
expect(result.stderr).toContain('non-regular or multiply linked hook entry')
|
||||
expect(readFileSync(externalHook, 'utf8')).toBe(externalContent)
|
||||
}
|
||||
}, MULTI_PROCESS_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('restores the marker-backed stale hook path when relocation reinstall fails', async () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/** 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',
|
||||
'@deepseek-ai/dsh-api-workspace-controller',
|
||||
]
|
||||
|
||||
/** Host-only packages whose peer relays are deliberately flattened. */
|
||||
const HOST_DEPENDENCY_PACKAGES: readonly string[] = [
|
||||
'@deepseek-ai/dsh-llm',
|
||||
'@deepseek-ai/dsh-session',
|
||||
]
|
||||
|
||||
/** Development-only package relationships not represented by source imports. */
|
||||
const CONFIGURATION_ONLY_DEV_DEPENDENCIES = {
|
||||
'@deepseek-ai/dsh-client-locale': ['@deepseek-ai/dsh-api-remotes'],
|
||||
'@deepseek-ai/dsh-client-ui-conversation': [
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
'@deepseek-ai/dsh-client-ui-workspace',
|
||||
],
|
||||
'@deepseek-ai/dsh-client-ui-model-selection': ['@deepseek-ai/dsh-client-ui-input-trigger'],
|
||||
'@deepseek-ai/dsh-client-ui-sidebar': ['@deepseek-ai/dsh-client-ui-workspace'],
|
||||
'@deepseek-ai/dsh-client-ui-subagent': ['@deepseek-ai/dsh-client-ui-input-trigger'],
|
||||
'@deepseek-ai/dsh-client-ui-theme': ['@deepseek-ai/dsh-api-remotes'],
|
||||
'@deepseek-ai/dsh-client-ui-tool': ['@deepseek-ai/dsh-api-remotes'],
|
||||
} as const satisfies Readonly<Record<string, readonly string[]>>
|
||||
|
||||
/** Workspace packages whose complete runtime surface is safe across duplicate installations. */
|
||||
const DUPLICATE_SAFE_PACKAGES: readonly string[] = [
|
||||
'@deepseek-ai/dsh-brand',
|
||||
'@deepseek-ai/dsh-typert-protocol',
|
||||
'@deepseek-ai/dsh-util-crypto',
|
||||
'@deepseek-ai/dsh-util-values',
|
||||
]
|
||||
|
||||
/**
|
||||
* Runtime exports whose values remain valid when npm installs another package copy.
|
||||
*/
|
||||
const SAFE_HOST_DEPENDENCY_EXPORTS = {
|
||||
'@deepseek-ai/dsh-credentials': ['credentialKey'],
|
||||
'@deepseek-ai/dsh-deque': ['Deque'],
|
||||
'@deepseek-ai/dsh-llm': ['callConfigEquals'],
|
||||
'@deepseek-ai/dsh-timeout': ['MAX_TIMER_DELAY_MS'],
|
||||
'@deepseek-ai/schemastery': ['default'],
|
||||
} as const satisfies HostDependencyExports
|
||||
|
||||
/** Runtime exports that require every consumer to resolve the provider's shared peer instance. */
|
||||
const PEER_REQUIRED_HOST_EXPORTS = {
|
||||
'@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'],
|
||||
} as const satisfies HostDependencyExports
|
||||
|
||||
/** Exact import specifier to reviewed runtime exports. */
|
||||
type HostDependencyExports = Readonly<Record<string, readonly string[]>>
|
||||
|
||||
/** Complete configurable input to package dependency classification. */
|
||||
export interface PackageDependencyPolicy {
|
||||
readonly clientFaceInclude: readonly string[]
|
||||
readonly clientFaceExclude: readonly string[]
|
||||
readonly hostPackages: readonly string[]
|
||||
readonly configurationOnlyDevDependencies: Readonly<Record<string, readonly string[]>>
|
||||
readonly duplicateSafePackages?: readonly string[]
|
||||
readonly safeHostDependencyExports: HostDependencyExports
|
||||
readonly peerRequiredHostExports: HostDependencyExports
|
||||
}
|
||||
|
||||
/** 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,
|
||||
configurationOnlyDevDependencies: CONFIGURATION_ONLY_DEV_DEPENDENCIES,
|
||||
duplicateSafePackages: DUPLICATE_SAFE_PACKAGES,
|
||||
safeHostDependencyExports: SAFE_HOST_DEPENDENCY_EXPORTS,
|
||||
peerRequiredHostExports: PEER_REQUIRED_HOST_EXPORTS,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { renderModuleGraph } from './gen-module-graph.ts'
|
||||
import { collectPackageGraph } from './package-graph.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -49,3 +50,23 @@ describe('collectPackageGraph', () => {
|
||||
.toThrow('fixture: @deepseek-ai/dsh-consumer references missing in-repo peer @deepseek-ai/dsh-missing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderModuleGraph', () => {
|
||||
it('renders the same peer edge in both generated languages', () => {
|
||||
const packages = [
|
||||
{ short: 'provider', name: '@deepseek-ai/dsh-provider', group: 'core', rel: 'packages/core/provider', deps: [] },
|
||||
{ short: 'consumer', name: '@deepseek-ai/dsh-consumer', group: 'core', rel: 'packages/core/consumer', deps: ['provider'] },
|
||||
]
|
||||
|
||||
const english = renderModuleGraph(packages, 'en')
|
||||
const chinese = renderModuleGraph(packages, 'zh')
|
||||
|
||||
expect(english).toContain('# Shared-instance dependency graph')
|
||||
expect(chinese).toContain('# 共享实例依赖关系图')
|
||||
expect(chinese).toContain('[English](module-graph.md) | 中文')
|
||||
for (const output of [english, chinese]) {
|
||||
expect(output).toContain('pkg_consumer --> pkg_provider')
|
||||
expect(output).toContain('| [`consumer`](../packages/core/consumer) | `core` | [`provider`](../packages/core/provider) |')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 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,
|
||||
exports: options.invariantExport === false ? {} : {
|
||||
'./invariant': {
|
||||
types: './lib/types/invariant.d.ts',
|
||||
default: './lib/invariant.js',
|
||||
},
|
||||
},
|
||||
...(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,
|
||||
owner.manifestPath,
|
||||
'@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency',
|
||||
)
|
||||
const developmentOnlyInvariant = usesFlattenedPackageDependencies(
|
||||
owner.manifestPath,
|
||||
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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,22 @@ describe('release families', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['0.0.2-alpha.1', '0.0.2-canary.1', '0.0.2-rc.1'])(
|
||||
'accepts the explicit dsh prerelease version %s',
|
||||
(version) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-release-prerelease-'))
|
||||
roots.push(root)
|
||||
write(join(root, 'package.json'), '{"version":"0.0.1"}\n')
|
||||
|
||||
const dsh = releaseFamily('dsh')
|
||||
const published = member('packages/core/published', '@deepseek-ai/dsh-published')
|
||||
const plan = planShared(dsh, root, [published], version)
|
||||
|
||||
expect(plan.version).toBe(version)
|
||||
expect(plan.planned[1]?.tag).toBe(`dsh-v${version}`)
|
||||
},
|
||||
)
|
||||
|
||||
it('names one tag for the whole dsh family and one per vendored package', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const vendor = releaseFamily('vendor')
|
||||
@@ -81,6 +97,18 @@ describe('release families', () => {
|
||||
expect(vendor.tagFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v4.0.0-rc.7')
|
||||
})
|
||||
|
||||
it('assigns alpha and canary dist-tags only to dsh releases', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const vendor = releaseFamily('vendor')
|
||||
|
||||
expect(dsh.distTagForVersion('0.0.2-alpha.1')).toBe('alpha')
|
||||
expect(dsh.distTagForVersion('0.0.2-canary.1')).toBe('canary')
|
||||
expect(dsh.distTagForVersion('0.0.2-rc.1')).toBe('next')
|
||||
expect(dsh.distTagForVersion('0.0.2')).toBeUndefined()
|
||||
expect(vendor.distTagForVersion('4.0.1-alpha.1')).toBe('next')
|
||||
expect(vendor.distTagForVersion('4.0.1-canary.1')).toBe('next')
|
||||
})
|
||||
|
||||
it('rejects a family whose members disagree on the shared version', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [member('apps/cli', '@deepseek-ai/dsh'), { ...member('apps/web', '@deepseek-ai/dsh-web-frontend'), version: '0.0.2' }]
|
||||
@@ -277,6 +305,12 @@ describe('vendored version baseline', () => {
|
||||
})
|
||||
|
||||
describe('version precedence', () => {
|
||||
it('orders alpha, canary, and release-candidate versions by semver precedence', () => {
|
||||
expect(compareVersions('4.0.1-alpha.1', '4.0.1-canary.1')).toBeLessThan(0)
|
||||
expect(compareVersions('4.0.1-canary.1', '4.0.1-rc.1')).toBeLessThan(0)
|
||||
expect(compareVersions('4.0.1-rc.1', '4.0.1')).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('ranks a release above the prerelease it follows', () => {
|
||||
// git --sort=v:refname disagrees, placing 4.0.1-rc.1 above 4.0.1, which is
|
||||
// why the newest published version is chosen here rather than by git.
|
||||
|
||||
@@ -285,6 +285,15 @@ export abstract class ReleaseFamily {
|
||||
*/
|
||||
abstract tagPrefixFor(member: ReleaseMember): string
|
||||
|
||||
/**
|
||||
* The npm dist-tag assigned while publishing a version.
|
||||
* @param version - package version from the packed manifest.
|
||||
* @returns `next` for a prerelease, or undefined so npm uses `latest`.
|
||||
*/
|
||||
distTagForVersion(version: string): string | undefined {
|
||||
return version.includes('-') ? 'next' : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The tag a member publishes from.
|
||||
* @param member - the member being published.
|
||||
@@ -339,6 +348,14 @@ class DshFamily extends ReleaseFamily {
|
||||
return this.tagPrefix
|
||||
}
|
||||
|
||||
override distTagForVersion(version: string): string | undefined {
|
||||
const separator = version.indexOf('-')
|
||||
if (separator === -1) return undefined
|
||||
const [channel] = version.slice(separator + 1).split('.')
|
||||
if (channel === 'alpha' || channel === 'canary') return channel
|
||||
return 'next'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject source and declaration-map members, the repository's publication policy.
|
||||
* @param member - the packed member.
|
||||
|
||||
@@ -93,10 +93,15 @@ function registryState(name: string, version: string): RegistryState {
|
||||
* @param tarball - absolute tarball path.
|
||||
* @param name - package name the tarball declares.
|
||||
* @param version - package version the tarball declares.
|
||||
* @param distTag - explicit npm dist-tag, or undefined for npm's `latest` default.
|
||||
*/
|
||||
async function publishTarball(tarball: string, name: string, version: string): Promise<void> {
|
||||
// A prerelease version never takes the latest dist-tag.
|
||||
const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
|
||||
async function publishTarball(
|
||||
tarball: string,
|
||||
name: string,
|
||||
version: string,
|
||||
distTag: string | undefined,
|
||||
): Promise<void> {
|
||||
const tagArgs = distTag === undefined ? [] : ['--tag', distTag]
|
||||
for (let tries = 1; tries <= PUBLISH_ATTEMPTS; tries += 1) {
|
||||
// No --access: the sequences do not share one access level, so a
|
||||
// command-line flag could not serve both and would override the manifest
|
||||
@@ -164,7 +169,7 @@ async function main(): Promise<void> {
|
||||
// Space out the writes: the gap belongs between publishes, so a run that
|
||||
// only skips does not wait at all.
|
||||
if (published > 0) await sleep(PUBLISH_SPACING_MS)
|
||||
await publishTarball(tarball, name, version)
|
||||
await publishTarball(tarball, name, version, family.distTagForVersion(version))
|
||||
console.log(`release publish: ${progress} ${name}@${version} published`)
|
||||
published += 1
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
@@ -208,7 +217,7 @@ describe('gate graph validation', () => {
|
||||
expect(completeBuiltBin?.after).not.toContain('docs-site-build')
|
||||
})
|
||||
|
||||
it('applies one configured test and polling timeout to both coverage gates', () => {
|
||||
it('applies one configured test, polling, and hook timeout to both coverage gates', () => {
|
||||
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () =>
|
||||
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
|
||||
|
||||
@@ -216,6 +225,7 @@ describe('gate graph validation', () => {
|
||||
expect(gates.find(subject => subject.id === id)?.args).toEqual(expect.arrayContaining([
|
||||
'--testTimeout=15000',
|
||||
'--expect.poll.timeout=15000',
|
||||
'--hookTimeout=15000',
|
||||
]))
|
||||
}
|
||||
})
|
||||
@@ -226,7 +236,7 @@ describe('gate graph validation', () => {
|
||||
|
||||
for (const id of ['coverage', 'coverage-exempt-heavy']) {
|
||||
expect(gates.find(subject => subject.id === id)?.args).not.toEqual(expect.arrayContaining([
|
||||
expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout)=/),
|
||||
expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/),
|
||||
]))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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' }),
|
||||
@@ -569,7 +570,7 @@ function lintGate(options: { needs?: string[] } = {}): Gate {
|
||||
// small share. A budget of 1 gives each gate 1 worker; lanes that need a strict
|
||||
// total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1,
|
||||
// which keeps the gates from overlapping at all.
|
||||
// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test and expect.poll
|
||||
// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test, expect.poll, and hook
|
||||
// defaults together for instrumented lanes whose scheduling overhead exceeds
|
||||
// those defaults. Explicit fixture timeouts remain authoritative.
|
||||
function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } {
|
||||
@@ -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' }),
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const httpMock = vi.hoisted(() => ({ createServer: vi.fn() }))
|
||||
|
||||
vi.mock('node:http', () => ({ createServer: httpMock.createServer }))
|
||||
|
||||
// Snapshot plugins are plain runtime JavaScript loaded by cordis.yml.
|
||||
// @ts-expect-error The fixture intentionally has no declaration artifact.
|
||||
import * as searchFixtureModule from '../snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs'
|
||||
// @ts-expect-error The fixture intentionally has no declaration artifact.
|
||||
import * as loopbackFixtureModule from '../snapshots/session/loopback-fixture-server.mjs'
|
||||
|
||||
const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages'
|
||||
|
||||
interface FixturePlugin {
|
||||
readonly name: string
|
||||
readonly inject?: readonly string[]
|
||||
apply(ctx: Context): Promise<void>
|
||||
}
|
||||
|
||||
interface LoopbackFixtureOptions {
|
||||
readonly label: string
|
||||
readonly onCleanup: () => void
|
||||
readonly onListening: (address: { port: number }) => void
|
||||
readonly requestListener: () => void
|
||||
}
|
||||
|
||||
const searchFixture = searchFixtureModule as unknown as FixturePlugin
|
||||
const typedLoopbackFixtureModule = loopbackFixtureModule as unknown as {
|
||||
readonly applyLoopbackServerEffect: (ctx: Context, options: LoopbackFixtureOptions) => Promise<void>
|
||||
}
|
||||
const { applyLoopbackServerEffect } = typedLoopbackFixtureModule
|
||||
const nativeFetch = globalThis.fetch
|
||||
|
||||
class FixtureServer extends EventEmitter {
|
||||
readonly started = Promise.withResolvers<undefined>()
|
||||
listening = false
|
||||
closed = false
|
||||
connectionsClosed = false
|
||||
unreferenced = false
|
||||
private listenCallback: (() => void) | undefined
|
||||
private port = 0
|
||||
|
||||
listen(_port: number, _host: string, callback: () => void): this {
|
||||
this.listenCallback = callback
|
||||
this.started.resolve(undefined)
|
||||
return this
|
||||
}
|
||||
|
||||
finishListening(port = 54321): void {
|
||||
this.port = port
|
||||
this.listening = true
|
||||
this.listenCallback?.()
|
||||
}
|
||||
|
||||
address(): { address: string; family: string; port: number } | null {
|
||||
return this.listening ? { address: '127.0.0.1', family: 'IPv4', port: this.port } : null
|
||||
}
|
||||
|
||||
unref(): this {
|
||||
this.unreferenced = true
|
||||
return this
|
||||
}
|
||||
|
||||
close(callback: (error?: Error) => void): this {
|
||||
this.listening = false
|
||||
this.closed = true
|
||||
callback()
|
||||
return this
|
||||
}
|
||||
|
||||
closeAllConnections(): void {
|
||||
this.connectionsClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
function nextServer(): FixtureServer {
|
||||
const server = new FixtureServer()
|
||||
httpMock.createServer.mockReturnValueOnce(server)
|
||||
return server
|
||||
}
|
||||
|
||||
function captureErrors(ctx: Context): unknown[] {
|
||||
const errors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
|
||||
return errors
|
||||
}
|
||||
|
||||
async function disposeWhileStarting(fiber: { dispose(): Promise<unknown> }, server: FixtureServer): Promise<void> {
|
||||
await server.started.promise
|
||||
const disposal = fiber.dispose()
|
||||
const settled = vi.fn()
|
||||
void disposal.then(settled)
|
||||
await Promise.resolve()
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
server.finishListening()
|
||||
await disposal
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = nativeFetch
|
||||
httpMock.createServer.mockReset()
|
||||
})
|
||||
|
||||
describe('snapshot HTTP fixture lifecycle', () => {
|
||||
it('joins search listener setup and cleanup when disposal wins the startup race', async () => {
|
||||
const server = nextServer()
|
||||
const ctx = new Context()
|
||||
const errors = captureErrors(ctx)
|
||||
const fiber = ctx.plugin(searchFixture)
|
||||
await disposeWhileStarting(fiber, server)
|
||||
|
||||
expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true })
|
||||
expect(globalThis.fetch).toBe(nativeFetch)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
it('runs owner cleanup and closes the listener when disposal wins the startup race', async () => {
|
||||
const server = nextServer()
|
||||
const ctx = new Context()
|
||||
const errors = captureErrors(ctx)
|
||||
const onCleanup = vi.fn()
|
||||
const onListening = vi.fn()
|
||||
const fiber = ctx.plugin({
|
||||
name: 'loopback-fixture-lifecycle-test',
|
||||
apply: testCtx => applyLoopbackServerEffect(testCtx, {
|
||||
label: 'loopback-fixture-lifecycle-test',
|
||||
onCleanup,
|
||||
onListening,
|
||||
requestListener: () => {},
|
||||
}),
|
||||
})
|
||||
await disposeWhileStarting(fiber, server)
|
||||
|
||||
expect(server).toMatchObject({ closed: true, connectionsClosed: true, unreferenced: true })
|
||||
expect(onListening).toHaveBeenCalledWith(expect.objectContaining({ port: 54321 }))
|
||||
expect(onCleanup).toHaveBeenCalledOnce()
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
it('maps every fetch input form and rejects another path on the recorded authority', async () => {
|
||||
const server = nextServer()
|
||||
const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => new Response('{}'))
|
||||
globalThis.fetch = fetchMock
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(searchFixture)
|
||||
await server.started.promise
|
||||
server.finishListening(54322)
|
||||
await fiber
|
||||
|
||||
try {
|
||||
await globalThis.fetch(RECORDED_ENDPOINT)
|
||||
expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/anthropic/v1/messages')
|
||||
|
||||
await globalThis.fetch(new URL(RECORDED_ENDPOINT))
|
||||
expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/anthropic/v1/messages')
|
||||
|
||||
const request = new Request(RECORDED_ENDPOINT, { method: 'POST', headers: { 'x-fixture': 'request' } })
|
||||
await globalThis.fetch(request)
|
||||
const mappedRequest = fetchMock.mock.calls.at(-1)?.[0]
|
||||
expect(mappedRequest).toBeInstanceOf(Request)
|
||||
if (!(mappedRequest instanceof Request)) throw new TypeError('mapped fetch input must be a Request')
|
||||
expect(mappedRequest.url).toBe('http://127.0.0.1:54322/anthropic/v1/messages')
|
||||
expect(mappedRequest.method).toBe('POST')
|
||||
expect(mappedRequest.headers.get('x-fixture')).toBe('request')
|
||||
|
||||
const unrelated = new URL('https://example.test/')
|
||||
await globalThis.fetch(unrelated)
|
||||
expect(fetchMock.mock.calls.at(-1)?.[0]).toBe(unrelated)
|
||||
|
||||
await expect(globalThis.fetch('http://127.0.0.1:43118/unexpected'))
|
||||
.rejects.toThrow('web-search-error-fixture: unexpected URL for recorded authority')
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
}
|
||||
|
||||
expect(globalThis.fetch).toBe(fetchMock)
|
||||
expect(server.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a later fetch wrapper while still closing the listener and reporting the ownership error', async () => {
|
||||
const server = nextServer()
|
||||
const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => new Response('{}'))
|
||||
globalThis.fetch = fetchMock
|
||||
const ctx = new Context()
|
||||
const errors = captureErrors(ctx)
|
||||
const fiber = ctx.plugin(searchFixture)
|
||||
await server.started.promise
|
||||
server.finishListening()
|
||||
await fiber
|
||||
|
||||
const fixtureFetch = globalThis.fetch
|
||||
const laterFetch = vi.fn((input: string | URL | Request, init?: RequestInit) => fixtureFetch(input, init))
|
||||
globalThis.fetch = laterFetch
|
||||
await fiber.dispose()
|
||||
|
||||
expect(globalThis.fetch).toBe(laterFetch)
|
||||
expect(server).toMatchObject({ closed: true, connectionsClosed: true })
|
||||
expect(errors.map(String).join('\n')).toContain('web-search-error-fixture: global fetch owner changed before cleanup')
|
||||
})
|
||||
})
|
||||
@@ -261,7 +261,15 @@ function expectMergedPair(fixture: Fixture): void {
|
||||
)
|
||||
}
|
||||
|
||||
describe('translation pairing merge composition', { timeout: 15_000 }, () => {
|
||||
// Every case in this suite drives real `git` invocations against a scratch
|
||||
// repository, so it is bound by process creation rather than by its assertions.
|
||||
// The value matches DSH_COVERAGE_TEST_TIMEOUT_MS, which the Windows coverage
|
||||
// lane passes as --testTimeout: a describe value overrides that flag rather than
|
||||
// yielding to it, so a smaller one here lowers what the lane grants every case
|
||||
// in this file, none of which carries an allowance of its own. Measurements and
|
||||
// the rejected alternatives are in
|
||||
// .agents/notes/implemented/testing/2026-08-27-translation-pairing-merge-budget.md.
|
||||
describe('translation pairing merge composition', { timeout: 90_000 }, () => {
|
||||
it('rejects a pairing-record path outside the repository', () => {
|
||||
const fixture = createFixture(false)
|
||||
|
||||
|
||||
@@ -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,28 @@ 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')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect relative module specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Relative imports, exports, and requires that survive compilation.
|
||||
*/
|
||||
export function collectRuntimeLocalSourceSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'local')
|
||||
}
|
||||
|
||||
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
|
||||
const clause = node.importClause
|
||||
if (clause === undefined) return true
|
||||
@@ -114,12 +133,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 +159,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 +195,6 @@ export function collectClientPackageViolations(facts: ClientPackageFacts): strin
|
||||
return [
|
||||
...facts.malformed,
|
||||
...collectModeViolations(facts),
|
||||
...collectDependencyViolations(facts),
|
||||
...collectModuleViolations(facts),
|
||||
].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
@@ -181,10 +205,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 +239,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 +270,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 +314,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 +666,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 +701,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).',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NpmPackageLock, RegistryIndex } from './benchmark-npm-resolution.ts'
|
||||
import {
|
||||
assertDualDshInstallLayout,
|
||||
buildDualDshRegistry,
|
||||
} from './verify-npm-install-layout.ts'
|
||||
|
||||
function validLayout(): NpmPackageLock {
|
||||
return {
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'': { dependencies: { '@deepseek-ai/dsh': '0.2.0', 'dsh-previous': 'npm:@deepseek-ai/dsh@0.1.0' } },
|
||||
'node_modules/@deepseek-ai/cordis': { version: '4.0.1' },
|
||||
'node_modules/@deepseek-ai/dsh': {
|
||||
version: '0.2.0',
|
||||
dependencies: { '@deepseek-ai/dsh-child': '^0.2.0' },
|
||||
peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' },
|
||||
},
|
||||
'node_modules/@deepseek-ai/dsh-child': {
|
||||
version: '0.2.0',
|
||||
dependencies: { '@deepseek-ai/dsh-leaf': '^0.2.0' },
|
||||
},
|
||||
'node_modules/@deepseek-ai/dsh-leaf': { version: '0.2.0' },
|
||||
'node_modules/dsh-previous': {
|
||||
name: '@deepseek-ai/dsh',
|
||||
version: '0.1.0',
|
||||
dependencies: { '@deepseek-ai/dsh-child': '^0.1.0' },
|
||||
peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' },
|
||||
},
|
||||
'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-child': {
|
||||
version: '0.1.0',
|
||||
dependencies: { '@deepseek-ai/dsh-leaf': '^0.1.0' },
|
||||
},
|
||||
'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-leaf': { version: '0.1.0' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('npm install layout verifier', () => {
|
||||
it('creates two incompatible versions of every DSH package', () => {
|
||||
const index: RegistryIndex = new Map([
|
||||
['@deepseek-ai/dsh', new Map([['0.1.1-rc.2', {
|
||||
name: '@deepseek-ai/dsh',
|
||||
version: '0.1.1-rc.2',
|
||||
dependencies: { '@deepseek-ai/dsh-child': '^0.1.1-rc.2' },
|
||||
peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' },
|
||||
}]])],
|
||||
['@deepseek-ai/dsh-child', new Map([['0.1.1-rc.2', {
|
||||
name: '@deepseek-ai/dsh-child',
|
||||
version: '0.1.1-rc.2',
|
||||
}]])],
|
||||
['@deepseek-ai/cordis', new Map([['4.0.1', {
|
||||
name: '@deepseek-ai/cordis',
|
||||
version: '4.0.1',
|
||||
}]])],
|
||||
])
|
||||
|
||||
const dual = buildDualDshRegistry(index, '0.1.1-rc.2')
|
||||
|
||||
expect([...dual.get('@deepseek-ai/dsh')?.keys() ?? []]).toEqual(['0.1.0', '0.2.0'])
|
||||
expect(dual.get('@deepseek-ai/dsh')?.get('0.1.0')).toMatchObject({
|
||||
version: '0.1.0',
|
||||
dependencies: { '@deepseek-ai/dsh-child': '^0.1.0' },
|
||||
peerDependencies: { '@deepseek-ai/cordis': '^4.0.1' },
|
||||
})
|
||||
expect(dual.get('@deepseek-ai/dsh')?.get('0.2.0')).toMatchObject({
|
||||
version: '0.2.0',
|
||||
dependencies: { '@deepseek-ai/dsh-child': '^0.2.0' },
|
||||
})
|
||||
expect(dual.get('@deepseek-ai/cordis')).toBe(index.get('@deepseek-ai/cordis'))
|
||||
})
|
||||
|
||||
it('accepts isolated DSH releases with one shared Cordis installation', () => {
|
||||
expect(assertDualDshInstallLayout(validLayout())).toEqual({
|
||||
dshPackagesPerVersion: 3,
|
||||
checkedDshEdges: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an internal edge that crosses release versions', () => {
|
||||
const layout = validLayout()
|
||||
const packages = { ...layout.packages }
|
||||
Reflect.deleteProperty(packages, 'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-leaf')
|
||||
|
||||
expect(() => assertDualDshInstallLayout({ ...layout, packages })).toThrow(
|
||||
'node_modules/dsh-previous/node_modules/@deepseek-ai/dsh-child: dependencies '
|
||||
+ '@deepseek-ai/dsh-leaf resolves to node_modules/@deepseek-ai/dsh-leaf@0.2.0, expected 0.1.0',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a second Cordis installation', () => {
|
||||
const layout = validLayout()
|
||||
const packages = {
|
||||
...layout.packages,
|
||||
'node_modules/dsh-previous/node_modules/@deepseek-ai/cordis': { version: '4.0.1' },
|
||||
}
|
||||
|
||||
expect(() => assertDualDshInstallLayout({ ...layout, packages })).toThrow(
|
||||
'expected one shared @deepseek-ai/cordis',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
/** Verify npm's physical package placement for two incompatible DSH releases. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { posix, resolve } from 'node:path'
|
||||
import {
|
||||
buildRegistryIndex,
|
||||
resolveNpmPackageLock,
|
||||
type NpmLockPackage,
|
||||
type NpmPackageLock,
|
||||
type RegistryIndex,
|
||||
} from './benchmark-npm-resolution.ts'
|
||||
|
||||
const DSH_PACKAGE = '@deepseek-ai/dsh'
|
||||
const CORDIS_PACKAGE = '@deepseek-ai/cordis'
|
||||
const NESTED_DSH_ALIAS = 'dsh-previous'
|
||||
const NESTED_DSH_PATH = `node_modules/${NESTED_DSH_ALIAS}`
|
||||
const DEPENDENCY_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const
|
||||
const TIMEOUT_MS = 300_000
|
||||
|
||||
/** Synthetic incompatible versions used to expose cross-release placement errors. */
|
||||
export const SYNTHETIC_DSH_VERSIONS = ['0.1.0', '0.2.0'] as const
|
||||
|
||||
interface MutableRegistryManifest {
|
||||
name: string
|
||||
version: string
|
||||
dependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Summary of a verified two-release npm layout. */
|
||||
export interface DshInstallLayoutSummary {
|
||||
readonly dshPackagesPerVersion: number
|
||||
readonly checkedDshEdges: number
|
||||
}
|
||||
|
||||
function isDshPackage(name: string): boolean {
|
||||
return name === DSH_PACKAGE || name.startsWith(`${DSH_PACKAGE}-`)
|
||||
}
|
||||
|
||||
function cloneForVersion(manifest: object, version: string): MutableRegistryManifest {
|
||||
const cloned = structuredClone(manifest) as MutableRegistryManifest
|
||||
cloned.version = version
|
||||
for (const field of DEPENDENCY_FIELDS) {
|
||||
const dependencies = cloned[field]
|
||||
if (dependencies === undefined) continue
|
||||
for (const name of Object.keys(dependencies)) {
|
||||
if (isDshPackage(name)) dependencies[name] = `^${version}`
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the working release with two incompatible, internally consistent DSH releases.
|
||||
* @param index - Registry metadata containing the working release.
|
||||
* @param sourceVersion - Workspace version copied into each synthetic release.
|
||||
* @returns Registry metadata containing both synthetic DSH releases and unchanged external packages.
|
||||
*/
|
||||
export function buildDualDshRegistry(index: RegistryIndex, sourceVersion: string): RegistryIndex {
|
||||
const output = new Map(index)
|
||||
let dshPackages = 0
|
||||
for (const [name, versions] of index) {
|
||||
if (!isDshPackage(name)) {
|
||||
output.set(name, versions)
|
||||
continue
|
||||
}
|
||||
const source = versions.get(sourceVersion)
|
||||
if (source === undefined) throw new Error(`${name} has no workspace version ${sourceVersion}`)
|
||||
dshPackages++
|
||||
output.set(name, new Map(SYNTHETIC_DSH_VERSIONS.map(version => [
|
||||
version,
|
||||
cloneForVersion(source, version),
|
||||
])))
|
||||
}
|
||||
if (dshPackages === 0) throw new Error('registry contains no DSH packages')
|
||||
return output
|
||||
}
|
||||
|
||||
function packageNameAtPath(path: string, manifest: NpmLockPackage): string | undefined {
|
||||
if (manifest.name !== undefined) return manifest.name
|
||||
const marker = 'node_modules/'
|
||||
const markerIndex = path.lastIndexOf(marker)
|
||||
if (markerIndex < 0) return undefined
|
||||
const segments = path.slice(markerIndex + marker.length).split('/')
|
||||
if (segments[0]?.startsWith('@')) {
|
||||
return segments[1] === undefined ? undefined : `${segments[0]}/${segments[1]}`
|
||||
}
|
||||
return segments[0]
|
||||
}
|
||||
|
||||
function resolvePackagePath(
|
||||
packages: Readonly<Record<string, NpmLockPackage>>,
|
||||
sourcePath: string,
|
||||
dependency: string,
|
||||
): string | undefined {
|
||||
let directory = sourcePath
|
||||
while (directory !== '.') {
|
||||
const candidate = posix.join(directory, 'node_modules', dependency)
|
||||
if (packages[candidate] !== undefined) return candidate
|
||||
directory = posix.dirname(directory)
|
||||
}
|
||||
const rootCandidate = posix.join('node_modules', dependency)
|
||||
return packages[rootCandidate] === undefined ? undefined : rootCandidate
|
||||
}
|
||||
|
||||
function setDifference(left: ReadonlySet<string>, right: ReadonlySet<string>): string[] {
|
||||
return [...left].filter(value => !right.has(value)).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that npm isolates both DSH releases while sharing the Cordis runtime.
|
||||
* @param packageLock - Metadata-only package lock produced by npm.
|
||||
* @returns Counts for the verified DSH packages and dependency edges.
|
||||
*/
|
||||
export function assertDualDshInstallLayout(packageLock: NpmPackageLock): DshInstallLayoutSummary {
|
||||
const [nestedVersion, rootVersion] = SYNTHETIC_DSH_VERSIONS
|
||||
const errors: string[] = []
|
||||
const namesByVersion = new Map<string, Set<string>>([
|
||||
[nestedVersion, new Set()],
|
||||
[rootVersion, new Set()],
|
||||
])
|
||||
const installed = Object.entries(packageLock.packages)
|
||||
let checkedDshEdges = 0
|
||||
|
||||
for (const [path, manifest] of installed) {
|
||||
const name = packageNameAtPath(path, manifest)
|
||||
if (name === undefined || !isDshPackage(name)) continue
|
||||
const version = manifest.version
|
||||
if (version !== nestedVersion && version !== rootVersion) {
|
||||
errors.push(`${path}: expected DSH version ${nestedVersion} or ${rootVersion}, got ${String(version)}`)
|
||||
continue
|
||||
}
|
||||
namesByVersion.get(version)?.add(name)
|
||||
const expectedPath = version === rootVersion
|
||||
? `node_modules/${name}`
|
||||
: name === DSH_PACKAGE
|
||||
? NESTED_DSH_PATH
|
||||
: `${NESTED_DSH_PATH}/node_modules/${name}`
|
||||
if (path !== expectedPath) {
|
||||
errors.push(`${path}: expected ${name}@${version} at ${expectedPath}`)
|
||||
}
|
||||
|
||||
for (const field of DEPENDENCY_FIELDS) {
|
||||
for (const dependency of Object.keys(manifest[field] ?? {})) {
|
||||
if (!isDshPackage(dependency)) continue
|
||||
const targetPath = resolvePackagePath(packageLock.packages, path, dependency)
|
||||
const optionalPeer = field === 'peerDependencies'
|
||||
&& manifest.peerDependenciesMeta?.[dependency]?.optional === true
|
||||
if (targetPath === undefined) {
|
||||
if (field === 'optionalDependencies' || optionalPeer) continue
|
||||
errors.push(`${path}: ${field} ${dependency} does not resolve`)
|
||||
continue
|
||||
}
|
||||
checkedDshEdges++
|
||||
const targetVersion = packageLock.packages[targetPath]?.version
|
||||
if (targetVersion !== version) {
|
||||
errors.push(
|
||||
`${path}: ${field} ${dependency} resolves to ${targetPath}@${String(targetVersion)}, expected ${version}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nestedNames = namesByVersion.get(nestedVersion) ?? new Set<string>()
|
||||
const rootNames = namesByVersion.get(rootVersion) ?? new Set<string>()
|
||||
if (!nestedNames.has(DSH_PACKAGE)) errors.push(`${NESTED_DSH_PATH}: missing ${DSH_PACKAGE}@${nestedVersion}`)
|
||||
if (!rootNames.has(DSH_PACKAGE)) errors.push(`node_modules/${DSH_PACKAGE}: missing ${DSH_PACKAGE}@${rootVersion}`)
|
||||
const onlyNested = setDifference(nestedNames, rootNames)
|
||||
const onlyRoot = setDifference(rootNames, nestedNames)
|
||||
if (onlyNested.length > 0) errors.push(`only ${nestedVersion} contains: ${onlyNested.join(', ')}`)
|
||||
if (onlyRoot.length > 0) errors.push(`only ${rootVersion} contains: ${onlyRoot.join(', ')}`)
|
||||
|
||||
const cordisPaths = installed.flatMap(([path, manifest]) =>
|
||||
packageNameAtPath(path, manifest) === CORDIS_PACKAGE ? [path] : [])
|
||||
if (cordisPaths.length !== 1 || cordisPaths[0] !== `node_modules/${CORDIS_PACKAGE}`) {
|
||||
errors.push(`expected one shared ${CORDIS_PACKAGE} at node_modules/${CORDIS_PACKAGE}, got ${cordisPaths.join(', ')}`)
|
||||
}
|
||||
|
||||
if (errors.length > 0) throw new Error(`invalid npm install layout:\n${errors.map(error => ` - ${error}`).join('\n')}`)
|
||||
return { dshPackagesPerVersion: rootNames.size, checkedDshEdges }
|
||||
}
|
||||
|
||||
function workspaceVersion(root: string): string {
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, 'apps/cli/package.json'), 'utf8')) as { version?: unknown }
|
||||
if (typeof manifest.version !== 'string') throw new Error('apps/cli/package.json has no string version')
|
||||
return manifest.version
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const index = buildDualDshRegistry(buildRegistryIndex(root), workspaceVersion(root))
|
||||
const [nestedVersion, rootVersion] = SYNTHETIC_DSH_VERSIONS
|
||||
const result = await resolveNpmPackageLock(index, {
|
||||
[DSH_PACKAGE]: rootVersion,
|
||||
[NESTED_DSH_ALIAS]: `npm:${DSH_PACKAGE}@${nestedVersion}`,
|
||||
}, TIMEOUT_MS)
|
||||
if (result.archiveRequests !== 0) throw new Error(`npm requested ${String(result.archiveRequests)} package archive(s)`)
|
||||
const summary = assertDualDshInstallLayout(result.packageLock)
|
||||
console.log(
|
||||
`verify-npm-install-layout: ${String(summary.dshPackagesPerVersion)} DSH package(s) per release and `
|
||||
+ `${String(summary.checkedDshEdges)} internal edge(s) verified in ${(result.durationMs / 1000).toFixed(2)} s; `
|
||||
+ `both releases share one Cordis installation; ${String(result.unknownPackages.length)} unavailable optional `
|
||||
+ 'package name(s) ignored by npm.',
|
||||
)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
await main()
|
||||
} catch (error) {
|
||||
console.error(`verify-npm-install-layout: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
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 {
|
||||
collectHostDependencyExportPolicyViolations,
|
||||
collectPackageDependencyViolations,
|
||||
collectRuntimeSourceExportUses,
|
||||
discoverPackageDependencyScope,
|
||||
fixPackageDependencies,
|
||||
formatManagedRuntimeDependencies,
|
||||
formatPeerRequiredRuntimeDependencies,
|
||||
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: [],
|
||||
configurationOnlyDevDependencies: {},
|
||||
safeHostDependencyExports: {},
|
||||
peerRequiredHostExports: {},
|
||||
...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']],
|
||||
]),
|
||||
hostRuntimeExportUses: [{
|
||||
packageName: '@deepseek-ai/dsh-runtime',
|
||||
specifier: '@deepseek-ai/dsh-runtime',
|
||||
exportName: 'runtimeValue',
|
||||
sourcePath: 'packages/core/probe/src/index.ts',
|
||||
line: 1,
|
||||
column: 10,
|
||||
sourceLine: "import { runtimeValue } from '@deepseek-ai/dsh-runtime'",
|
||||
}],
|
||||
peerRequiredHostDependencies: new Set(),
|
||||
configurationOnlyDevDependencies: new Set(),
|
||||
clientInject: new Set(),
|
||||
}
|
||||
}
|
||||
|
||||
function hostRuntimeFixture(): {
|
||||
provider: WorkspacePackageManifest
|
||||
workspaceNames: Set<string>
|
||||
consumerFacts: PackageDependencyFacts
|
||||
} {
|
||||
const consumer = pkg('@f/consumer', 'packages/core/consumer/package.json')
|
||||
const provider = pkg('@f/provider', 'packages/core/provider/package.json')
|
||||
const sourcePath = 'packages/core/consumer/src/index.ts'
|
||||
const specifier = `${provider.name}/api`
|
||||
const workspaceNames = new Set([CORDIS, consumer.name, provider.name])
|
||||
const consumerFacts: PackageDependencyFacts = {
|
||||
manifestPath: consumer.manifestPath,
|
||||
role: 'configured-host',
|
||||
manifest: consumer.manifest,
|
||||
workspaceNames,
|
||||
allSourceUses: new Map(),
|
||||
hostRuntimeSourceUses: new Map([[provider.name, [sourcePath]]]),
|
||||
hostRuntimeExportUses: [{
|
||||
packageName: provider.name,
|
||||
specifier,
|
||||
exportName: 'safeValue',
|
||||
sourcePath,
|
||||
line: 1,
|
||||
column: 10,
|
||||
sourceLine: `import { safeValue } from '${specifier}'`,
|
||||
}],
|
||||
peerRequiredHostDependencies: new Set(),
|
||||
configurationOnlyDevDependencies: new Set(),
|
||||
clientInject: new Set(),
|
||||
}
|
||||
return { provider, workspaceNames, consumerFacts }
|
||||
}
|
||||
|
||||
describe('package dependency scope', () => {
|
||||
it('keeps the measured Host relay roster explicit', () => {
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([
|
||||
'@deepseek-ai/dsh-api-session-controller',
|
||||
'@deepseek-ai/dsh-api-workspace-controller',
|
||||
])
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.hostPackages).toEqual([
|
||||
'@deepseek-ai/dsh-llm',
|
||||
'@deepseek-ai/dsh-session',
|
||||
])
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.configurationOnlyDevDependencies).toEqual({
|
||||
'@deepseek-ai/dsh-client-locale': ['@deepseek-ai/dsh-api-remotes'],
|
||||
'@deepseek-ai/dsh-client-ui-conversation': [
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
'@deepseek-ai/dsh-client-ui-workspace',
|
||||
],
|
||||
'@deepseek-ai/dsh-client-ui-model-selection': ['@deepseek-ai/dsh-client-ui-input-trigger'],
|
||||
'@deepseek-ai/dsh-client-ui-sidebar': ['@deepseek-ai/dsh-client-ui-workspace'],
|
||||
'@deepseek-ai/dsh-client-ui-subagent': ['@deepseek-ai/dsh-client-ui-input-trigger'],
|
||||
'@deepseek-ai/dsh-client-ui-theme': ['@deepseek-ai/dsh-api-remotes'],
|
||||
'@deepseek-ai/dsh-client-ui-tool': ['@deepseek-ai/dsh-api-remotes'],
|
||||
})
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.duplicateSafePackages).toEqual([
|
||||
'@deepseek-ai/dsh-brand',
|
||||
'@deepseek-ai/dsh-typert-protocol',
|
||||
'@deepseek-ai/dsh-util-crypto',
|
||||
'@deepseek-ai/dsh-util-values',
|
||||
])
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-deque']).toEqual(['Deque'])
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/schemastery']).toEqual(['default'])
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-session/types']).toBeUndefined()
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-typert-protocol']).toBeUndefined()
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-scope']).toEqual([
|
||||
'carrierKeyOf', 'scopeOf', 'scopeTarget',
|
||||
])
|
||||
expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-typert-protocol']).toBeUndefined()
|
||||
})
|
||||
|
||||
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-only'],
|
||||
['@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'),
|
||||
]))
|
||||
})
|
||||
|
||||
it('rejects stale, duplicate, and unbounded safe Host export entries', () => {
|
||||
const { provider, workspaceNames, consumerFacts } = hostRuntimeFixture()
|
||||
|
||||
expect(collectHostDependencyExportPolicyViolations(
|
||||
[consumerFacts],
|
||||
workspaceNames,
|
||||
{
|
||||
safeHostDependencyExports: {
|
||||
[`${provider.name}/api`]: ['safeValue', 'safeValue', '*', 'staleValue'],
|
||||
},
|
||||
peerRequiredHostExports: {
|
||||
[`${provider.name}/api`]: ['safeValue'],
|
||||
},
|
||||
},
|
||||
)).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('export safeValue more than once'),
|
||||
expect.stringContaining('cannot classify unbounded'),
|
||||
expect.stringContaining('unused @f/provider/api export staleValue'),
|
||||
expect.stringContaining('appears in both Host export classifications'),
|
||||
]))
|
||||
})
|
||||
|
||||
it('applies a duplicate-safe package classification to its subpaths', () => {
|
||||
const { provider, workspaceNames, consumerFacts } = hostRuntimeFixture()
|
||||
|
||||
expect(collectHostDependencyExportPolicyViolations(
|
||||
[consumerFacts],
|
||||
workspaceNames,
|
||||
{
|
||||
duplicateSafePackages: [provider.name],
|
||||
safeHostDependencyExports: {},
|
||||
peerRequiredHostExports: {},
|
||||
},
|
||||
)).toEqual([])
|
||||
expect(collectHostDependencyExportPolicyViolations(
|
||||
[consumerFacts],
|
||||
workspaceNames,
|
||||
{
|
||||
duplicateSafePackages: [provider.name],
|
||||
safeHostDependencyExports: { [`${provider.name}/api`]: ['safeValue'] },
|
||||
peerRequiredHostExports: {},
|
||||
},
|
||||
)).toContain(`safeHostDependencyExports redundantly classifies duplicate-install-safe package ${provider.name}/api`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('face-aware source classification', () => {
|
||||
it('fails when a managed Host package has no Host entry', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-package-missing-host-'))
|
||||
roots.push(root)
|
||||
const subject = pkg('@f/host', 'packages/g/host/package.json')
|
||||
|
||||
expect(() => readPackageDependencyFacts(root, subject, 'configured-host', new Set([subject.name])))
|
||||
.toThrow('packages/g/host/package.json: Host runtime entry packages/g/host/src/index.ts does not exist')
|
||||
})
|
||||
|
||||
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'",
|
||||
"import type { Hidden } from './types.ts'",
|
||||
"export { nested } from './nested.ts'",
|
||||
].join('\n'),
|
||||
'packages/g/dual/src/nested.ts': "export { nested } from '@f/nested'",
|
||||
'packages/g/dual/src/types.ts': "import { hidden } from '@f/hidden'; export type Hidden = typeof hidden",
|
||||
'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/hidden', '@f/browser', '@f/injected',
|
||||
]), policy({
|
||||
configurationOnlyDevDependencies: { '@f/dual': ['@f/injected'] },
|
||||
}))
|
||||
|
||||
expect([...found.hostRuntimeSourceUses.keys()].sort()).toEqual(['@f/nested', '@f/runtime'])
|
||||
expect([...found.configurationOnlyDevDependencies]).toEqual(['@f/injected'])
|
||||
expect(found.hostRuntimeExportUses).toEqual([
|
||||
{
|
||||
packageName: '@f/nested',
|
||||
specifier: '@f/nested',
|
||||
exportName: 'nested',
|
||||
sourcePath: 'packages/g/dual/src/nested.ts',
|
||||
line: 1,
|
||||
column: 10,
|
||||
sourceLine: "export { nested } from '@f/nested'",
|
||||
},
|
||||
{
|
||||
packageName: '@f/runtime',
|
||||
specifier: '@f/runtime',
|
||||
exportName: 'value',
|
||||
sourcePath: 'packages/g/dual/src/index.ts',
|
||||
line: 1,
|
||||
column: 10,
|
||||
sourceLine: "import { value } from '@f/runtime'",
|
||||
},
|
||||
])
|
||||
expect([...found.allSourceUses.keys()].sort()).toEqual([
|
||||
'@f/browser', '@f/hidden', '@f/nested', '@f/runtime', '@f/types',
|
||||
])
|
||||
})
|
||||
|
||||
it('identifies exact runtime exports without treating type imports as values', () => {
|
||||
const source = [
|
||||
"import defaultValue, { value as local, type Kind } from '@f/root'",
|
||||
"import * as namespace from '@f/namespace'",
|
||||
"import '@f/effect'",
|
||||
"import type { TypeOnly } from '@f/types'",
|
||||
"export { source as renamed, type SourceType } from '@f/reexport'",
|
||||
"export * from '@f/star'",
|
||||
"void import('@f/dynamic')",
|
||||
"void require('@f/required')",
|
||||
'void defaultValue; void local; void namespace',
|
||||
].join('\n')
|
||||
const uses = collectRuntimeSourceExportUses('probe.ts', source)
|
||||
expect(uses.map(({ specifier, exportName }) => ({ specifier, exportName }))).toEqual([
|
||||
{ specifier: '@f/dynamic', exportName: '*' },
|
||||
{ specifier: '@f/effect', exportName: '(side effect)' },
|
||||
{ specifier: '@f/namespace', exportName: '*' },
|
||||
{ specifier: '@f/reexport', exportName: 'source' },
|
||||
{ specifier: '@f/required', exportName: '*' },
|
||||
{ specifier: '@f/root', exportName: 'default' },
|
||||
{ specifier: '@f/root', exportName: 'value' },
|
||||
{ specifier: '@f/star', exportName: '*' },
|
||||
])
|
||||
expect(uses.find(use => use.specifier === '@f/root' && use.exportName === 'value')).toMatchObject({
|
||||
line: 1,
|
||||
column: 24,
|
||||
sourceLine: "import defaultValue, { value as local, type Kind } from '@f/root'",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('dependency sections', () => {
|
||||
it('does not leak repository configuration into captured dependency facts', () => {
|
||||
const manifest: PackageDependencyManifest = {
|
||||
name: '@deepseek-ai/dsh-client-locale',
|
||||
dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^' },
|
||||
peerDependencies: { [CORDIS]: 'workspace:^' },
|
||||
}
|
||||
const base = facts(manifest)
|
||||
const subject: PackageDependencyFacts = {
|
||||
...base,
|
||||
workspaceNames: new Set([...base.workspaceNames, '@deepseek-ai/dsh-api-remotes']),
|
||||
}
|
||||
|
||||
expect(collectPackageDependencyViolations({
|
||||
facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames,
|
||||
})).toEqual([])
|
||||
})
|
||||
|
||||
it('requires non-workspace Host runtime imports in dependencies', () => {
|
||||
const manifest: PackageDependencyManifest = {
|
||||
name: '@deepseek-ai/dsh-probe',
|
||||
dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^', external: '^1.0.0' },
|
||||
peerDependencies: { [CORDIS]: 'workspace:^' },
|
||||
}
|
||||
const subject: PackageDependencyFacts = {
|
||||
...facts(manifest),
|
||||
hostRuntimeSourceUses: new Map([
|
||||
['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']],
|
||||
['external', ['packages/core/probe/src/index.ts']],
|
||||
]),
|
||||
}
|
||||
const state = {
|
||||
facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames,
|
||||
}
|
||||
|
||||
expect(collectPackageDependencyViolations(state)).toContain(
|
||||
'packages/core/probe/package.json: external (packages/core/probe/src/index.ts) '
|
||||
+ 'must be dependencies-only; found devDependencies',
|
||||
)
|
||||
repairPackageDependencyManifest(subject)
|
||||
expect(manifest.dependencies?.external).toBe('^1.0.0')
|
||||
expect(manifest.devDependencies?.external).toBeUndefined()
|
||||
|
||||
delete manifest.dependencies?.external
|
||||
expect(collectPackageDependencyViolations(state)).toContain(
|
||||
'packages/core/probe/package.json: external (packages/core/probe/src/index.ts) '
|
||||
+ 'must be dependencies-only; found no dependency section',
|
||||
)
|
||||
})
|
||||
|
||||
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 edge(s) remain in dependencies across 1 package(s):',
|
||||
' @deepseek-ai/dsh-probe -> @deepseek-ai/dsh-runtime: @deepseek-ai/dsh-runtime#runtimeValue',
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an unapproved Host runtime export without rewriting its dependency section', () => {
|
||||
const manifest: PackageDependencyManifest = {
|
||||
name: '@deepseek-ai/dsh-probe',
|
||||
dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^' },
|
||||
peerDependencies: { [CORDIS]: 'workspace:^' },
|
||||
}
|
||||
const subject = facts(manifest)
|
||||
const safetyViolations = collectHostDependencyExportPolicyViolations(
|
||||
[subject],
|
||||
subject.workspaceNames,
|
||||
{ safeHostDependencyExports: {}, peerRequiredHostExports: {} },
|
||||
)
|
||||
const state = {
|
||||
facts: [subject], packages: [], policyViolations: safetyViolations, workspaceNames: subject.workspaceNames,
|
||||
}
|
||||
|
||||
expect(safetyViolations).toEqual([
|
||||
'packages/core/probe/src/index.ts:1:10: @deepseek-ai/dsh-runtime#runtimeValue is not classified as '
|
||||
+ 'safe or peer-required — import { runtimeValue } from \'@deepseek-ai/dsh-runtime\'',
|
||||
])
|
||||
expect(fixPackageDependencies('/unused', state)).toEqual([])
|
||||
expect(manifest.dependencies).toEqual({ '@deepseek-ai/dsh-runtime': 'workspace:^' })
|
||||
})
|
||||
|
||||
it('keeps an edge as a peer when one imported export requires shared identity', () => {
|
||||
const manifest: PackageDependencyManifest = {
|
||||
name: '@deepseek-ai/dsh-probe',
|
||||
dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' },
|
||||
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-types': 'workspace:^' },
|
||||
peerDependencies: { [CORDIS]: 'workspace:^' },
|
||||
}
|
||||
const subject: PackageDependencyFacts = {
|
||||
...facts(manifest),
|
||||
peerRequiredHostDependencies: new Set(['@deepseek-ai/dsh-runtime']),
|
||||
}
|
||||
expect(collectHostDependencyExportPolicyViolations(
|
||||
[subject],
|
||||
subject.workspaceNames,
|
||||
{
|
||||
safeHostDependencyExports: {},
|
||||
peerRequiredHostExports: {
|
||||
'@deepseek-ai/dsh-runtime': ['runtimeValue'],
|
||||
},
|
||||
},
|
||||
)).toEqual([])
|
||||
|
||||
repairPackageDependencyManifest(subject)
|
||||
expect(manifest.dependencies).toBeUndefined()
|
||||
expect(manifest.peerDependencies).toMatchObject({
|
||||
[CORDIS]: 'workspace:^',
|
||||
'@deepseek-ai/dsh-runtime': 'workspace:^',
|
||||
})
|
||||
expect(manifest.devDependencies).toMatchObject({
|
||||
[CORDIS]: 'workspace:^',
|
||||
'@deepseek-ai/dsh-runtime': 'workspace:^',
|
||||
})
|
||||
expect(formatPeerRequiredRuntimeDependencies({
|
||||
facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames,
|
||||
})).toEqual([
|
||||
'verify-package-dependencies: 1 Host runtime edge(s) remain in peerDependencies because their exports require shared identity across 1 package(s):',
|
||||
' @deepseek-ai/dsh-probe -> @deepseek-ai/dsh-runtime: @deepseek-ai/dsh-runtime#runtimeValue',
|
||||
])
|
||||
})
|
||||
|
||||
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:^' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,746 @@
|
||||
/** Verify and repair npm dependency sections from published Client and Host faces. */
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { writeModuleGraph } from './gen-module-graph.ts'
|
||||
import {
|
||||
hasClientDeclaration,
|
||||
PACKAGE_DEPENDENCY_POLICY,
|
||||
type PackageDependencyPolicy,
|
||||
} from './package-dependency-policy.ts'
|
||||
import {
|
||||
collectRuntimeLocalSourceSpecifiers,
|
||||
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-only' | '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 hostRuntimeExportUses: readonly HostRuntimeExportUse[]
|
||||
readonly peerRequiredHostDependencies: ReadonlySet<string>
|
||||
readonly configurationOnlyDevDependencies: ReadonlySet<string>
|
||||
readonly clientInject: ReadonlySet<string>
|
||||
}
|
||||
|
||||
/** One runtime export reached from a package's Host source closure. */
|
||||
export interface HostRuntimeExportUse {
|
||||
readonly packageName: string
|
||||
readonly specifier: string
|
||||
readonly exportName: string
|
||||
readonly sourcePath: string
|
||||
readonly line: number
|
||||
readonly column: number
|
||||
readonly sourceLine: 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 = (hasClientDeclaration(pkg.manifest.dsh) || include.has(pkg.name)) && !exclude.has(pkg.name)
|
||||
const clientOnly = clientDirectory && !clientHost
|
||||
const configuredHost = host.has(pkg.name)
|
||||
if (configuredHost && (clientHost || clientOnly)) {
|
||||
violations.push(`hostPackages redundantly names Client-faced package ${pkg.name}`)
|
||||
}
|
||||
const role = clientHost ? 'client-host' : clientOnly ? 'client-only' : 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)
|
||||
}
|
||||
|
||||
const NAMESPACE_RUNTIME_EXPORT = '*'
|
||||
const SIDE_EFFECT_RUNTIME_EXPORT = '(side effect)'
|
||||
|
||||
interface RuntimeSourceExportUse {
|
||||
readonly specifier: string
|
||||
readonly exportName: string
|
||||
readonly line: number
|
||||
readonly column: number
|
||||
readonly sourceLine: string
|
||||
}
|
||||
|
||||
/** Collect exact runtime exports imported or re-exported by one source file. */
|
||||
export function collectRuntimeSourceExportUses(path: string, source: string): RuntimeSourceExportUse[] {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
const uses = new Map<string, RuntimeSourceExportUse>()
|
||||
const sourceLines = source.split(/\r?\n/u)
|
||||
const record = (specifier: string, exportName: string, locationNode: ts.Node): void => {
|
||||
const key = `${specifier}\0${exportName}`
|
||||
if (uses.has(key)) return
|
||||
const position = sourceFile.getLineAndCharacterOfPosition(locationNode.getStart(sourceFile))
|
||||
uses.set(key, {
|
||||
specifier,
|
||||
exportName,
|
||||
line: position.line + 1,
|
||||
column: position.character + 1,
|
||||
sourceLine: sourceLines[position.line]?.trim() ?? '',
|
||||
})
|
||||
}
|
||||
const add = (
|
||||
specifierNode: ts.Expression | undefined,
|
||||
exportName: string,
|
||||
locationNode: ts.Node = specifierNode ?? sourceFile,
|
||||
): void => {
|
||||
if (specifierNode === undefined || !ts.isStringLiteralLike(specifierNode)) return
|
||||
if (packageNameOf(specifierNode.text) === undefined) return
|
||||
record(specifierNode.text, exportName, locationNode)
|
||||
}
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
const clause = node.importClause
|
||||
if (clause === undefined) {
|
||||
add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
|
||||
} else if (clause.phaseModifier !== ts.SyntaxKind.TypeKeyword) {
|
||||
if (clause.name !== undefined) add(node.moduleSpecifier, 'default', clause.name)
|
||||
const bindings = clause.namedBindings
|
||||
if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
|
||||
add(node.moduleSpecifier, NAMESPACE_RUNTIME_EXPORT, bindings.name)
|
||||
} else if (bindings !== undefined && bindings.elements.length === 0) {
|
||||
add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
|
||||
} else if (bindings !== undefined) {
|
||||
for (const element of bindings.elements) {
|
||||
const imported = element.propertyName ?? element.name
|
||||
if (!element.isTypeOnly) add(node.moduleSpecifier, imported.text, imported)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ts.isExportDeclaration(node) && !node.isTypeOnly) {
|
||||
const clause = node.exportClause
|
||||
if (clause === undefined || ts.isNamespaceExport(clause)) {
|
||||
add(node.moduleSpecifier, NAMESPACE_RUNTIME_EXPORT)
|
||||
} else if (clause.elements.length === 0) {
|
||||
add(node.moduleSpecifier, SIDE_EFFECT_RUNTIME_EXPORT)
|
||||
} else {
|
||||
for (const element of clause.elements) {
|
||||
const imported = element.propertyName ?? element.name
|
||||
if (!element.isTypeOnly) add(node.moduleSpecifier, imported.text, imported)
|
||||
}
|
||||
}
|
||||
} else if (ts.isImportEqualsDeclaration(node)
|
||||
&& !node.isTypeOnly
|
||||
&& ts.isExternalModuleReference(node.moduleReference)) {
|
||||
add(node.moduleReference.expression, NAMESPACE_RUNTIME_EXPORT, node.name)
|
||||
} else if (ts.isCallExpression(node)
|
||||
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
|
||||
add(node.arguments[0], NAMESPACE_RUNTIME_EXPORT)
|
||||
} else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
|
||||
record('react/jsx-runtime', NAMESPACE_RUNTIME_EXPORT, node)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(sourceFile)
|
||||
return [...uses.values()].sort((left, right) =>
|
||||
left.specifier.localeCompare(right.specifier)
|
||||
|| left.exportName.localeCompare(right.exportName)
|
||||
|| left.line - right.line
|
||||
|| left.column - right.column)
|
||||
}
|
||||
|
||||
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): {
|
||||
packageUses: Map<string, string[]>
|
||||
exportUses: HostRuntimeExportUse[]
|
||||
} {
|
||||
const packageUses = new Map<string, string[]>()
|
||||
const exportUses = new Map<string, HostRuntimeExportUse>()
|
||||
const seen = new Set<string>()
|
||||
const visit = (path: string): void => {
|
||||
const normalized = normalize(path)
|
||||
if (seen.has(normalized)) return
|
||||
seen.add(normalized)
|
||||
const source = readFileSync(normalized, 'utf8')
|
||||
const displayPath = normalizePath(relative(root, normalized))
|
||||
for (const use of collectRuntimeSourceExportUses(normalized, source)) {
|
||||
const name = packageNameOf(use.specifier)
|
||||
if (name === undefined) continue
|
||||
addUse(packageUses, name, displayPath)
|
||||
const fact = { packageName: name, ...use, sourcePath: displayPath }
|
||||
exportUses.set(`${use.specifier}\0${use.exportName}\0${displayPath}\0${String(use.line)}\0${String(use.column)}`, fact)
|
||||
}
|
||||
for (const specifier of collectRuntimeLocalSourceSpecifiers(normalized, source)) {
|
||||
const target = resolveLocal(normalized, specifier)
|
||||
if (target !== undefined) visit(target)
|
||||
}
|
||||
}
|
||||
const entry = resolve(root, pkg.dir, 'src/index.ts')
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error(`${pkg.manifestPath}: Host runtime entry ${normalizePath(relative(root, entry))} does not exist`)
|
||||
}
|
||||
visit(entry)
|
||||
return {
|
||||
packageUses,
|
||||
exportUses: [...exportUses.values()].sort((left, right) =>
|
||||
left.packageName.localeCompare(right.packageName)
|
||||
|| left.specifier.localeCompare(right.specifier)
|
||||
|| left.exportName.localeCompare(right.exportName)
|
||||
|| left.sourcePath.localeCompare(right.sourcePath)
|
||||
|| left.line - right.line
|
||||
|| left.column - right.column),
|
||||
}
|
||||
}
|
||||
|
||||
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>,
|
||||
policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
|
||||
): PackageDependencyFacts {
|
||||
const inject = pkg.manifest.dsh?.client?.inject ?? []
|
||||
const hostRuntime = role === 'client-only'
|
||||
? { packageUses: new Map<string, string[]>(), exportUses: [] }
|
||||
: readHostRuntimeUses(root, pkg)
|
||||
return {
|
||||
manifestPath: pkg.manifestPath,
|
||||
role,
|
||||
manifest: pkg.manifest,
|
||||
workspaceNames,
|
||||
allSourceUses: readAllSourceUses(root, pkg),
|
||||
hostRuntimeSourceUses: hostRuntime.packageUses,
|
||||
hostRuntimeExportUses: hostRuntime.exportUses,
|
||||
peerRequiredHostDependencies: new Set(hostRuntime.exportUses
|
||||
.filter(use => policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true)
|
||||
.map(use => use.packageName)),
|
||||
configurationOnlyDevDependencies: new Set(
|
||||
policy.configurationOnlyDevDependencies[pkg.manifest.name ?? ''] ?? [],
|
||||
),
|
||||
clientInject: new Set(inject.map(packageNameOf).filter(name => name !== undefined)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate reviewed Host export classifications against current source facts. */
|
||||
export function collectHostDependencyExportPolicyViolations(
|
||||
facts: readonly PackageDependencyFacts[],
|
||||
workspaceNames: ReadonlySet<string>,
|
||||
policy: Pick<PackageDependencyPolicy, 'duplicateSafePackages' | 'peerRequiredHostExports' | 'safeHostDependencyExports'>,
|
||||
): string[] {
|
||||
const violations: string[] = []
|
||||
const allRuntimeUses = facts.flatMap(fact => fact.hostRuntimeExportUses)
|
||||
const duplicateSafePackages = new Set(policy.duplicateSafePackages ?? [])
|
||||
for (const packageName of duplicates(policy.duplicateSafePackages ?? [])) {
|
||||
violations.push(`duplicateSafePackages lists ${packageName} more than once`)
|
||||
}
|
||||
for (const packageName of duplicateSafePackages) {
|
||||
if (!workspaceNames.has(packageName)) {
|
||||
violations.push(`duplicateSafePackages names unknown workspace package ${packageName}`)
|
||||
}
|
||||
}
|
||||
const classifications = [
|
||||
['safeHostDependencyExports', policy.safeHostDependencyExports],
|
||||
['peerRequiredHostExports', policy.peerRequiredHostExports],
|
||||
] as const
|
||||
for (const [field, entries] of classifications) {
|
||||
for (const [specifier, exportNames] of Object.entries(entries)) {
|
||||
const provider = packageNameOf(specifier)
|
||||
if (provider === undefined || !workspaceNames.has(provider)) {
|
||||
violations.push(`${field} specifier ${specifier} is not a workspace package`)
|
||||
} else if (duplicateSafePackages.has(provider)) {
|
||||
violations.push(`${field} redundantly classifies duplicate-install-safe package ${specifier}`)
|
||||
}
|
||||
if (exportNames.length === 0) {
|
||||
violations.push(`${field} lists no exports for ${specifier}`)
|
||||
}
|
||||
for (const exportName of duplicates(exportNames)) {
|
||||
violations.push(`${field} lists ${specifier} export ${exportName} more than once`)
|
||||
}
|
||||
for (const exportName of exportNames) {
|
||||
if (exportName === '' || exportName === NAMESPACE_RUNTIME_EXPORT || exportName === SIDE_EFFECT_RUNTIME_EXPORT) {
|
||||
violations.push(`${field} cannot classify unbounded ${specifier} export ${exportName}`)
|
||||
continue
|
||||
}
|
||||
if (!allRuntimeUses.some(use => use.specifier === specifier && use.exportName === exportName)) {
|
||||
violations.push(`${field} lists unused ${specifier} export ${exportName}`)
|
||||
}
|
||||
if (field === 'safeHostDependencyExports'
|
||||
&& policy.peerRequiredHostExports[specifier]?.includes(exportName) === true) {
|
||||
violations.push(`${specifier} export ${exportName} appears in both Host export classifications`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const fact of facts) {
|
||||
for (const use of fact.hostRuntimeExportUses) {
|
||||
if (use.packageName === fact.manifest.name || use.packageName === CORDIS) continue
|
||||
if (!workspaceNames.has(use.packageName)) continue
|
||||
if (duplicateSafePackages.has(use.packageName)) continue
|
||||
if (policy.safeHostDependencyExports[use.specifier]?.includes(use.exportName) === true) continue
|
||||
if (policy.peerRequiredHostExports[use.specifier]?.includes(use.exportName) === true) continue
|
||||
violations.push(
|
||||
`${use.sourcePath}:${String(use.line)}:${String(use.column)}: `
|
||||
+ `${use.specifier}#${use.exportName} is not classified as safe or peer-required — ${use.sourceLine}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return violations.sort()
|
||||
}
|
||||
|
||||
/** 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)
|
||||
const facts = discovered.selected.map(pkg =>
|
||||
readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy))
|
||||
const selectedNames = new Set(facts.map(fact => fact.manifest.name))
|
||||
return {
|
||||
facts,
|
||||
packages: packages.release,
|
||||
policyViolations: [
|
||||
...discovered.violations,
|
||||
...collectHostDependencyExportPolicyViolations(facts, workspaceNames, policy),
|
||||
...Object.keys(policy.configurationOnlyDevDependencies)
|
||||
.filter(name => !selectedNames.has(name))
|
||||
.map(name => `configurationOnlyDevDependencies names unmanaged package ${name}`),
|
||||
].sort(),
|
||||
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 === 'peer-dev' || sectionName === 'peer-dev'
|
||||
? 'peer-dev'
|
||||
: 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 facts.configurationOnlyDevDependencies) {
|
||||
if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'configured development-only relationship')
|
||||
}
|
||||
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) {
|
||||
const expectedSection = facts.workspaceNames.has(name) && facts.peerRequiredHostDependencies.has(name)
|
||||
? 'peer-dev'
|
||||
: 'dependencies'
|
||||
for (const path of paths) add(name, expectedSection, path)
|
||||
}
|
||||
return new Map([...expected].map(([name, rule]) => [name, {
|
||||
section: rule.section,
|
||||
origins: [...rule.origins].sort(),
|
||||
}]))
|
||||
}
|
||||
|
||||
interface ManagedRuntimeEdge {
|
||||
readonly consumer: string
|
||||
readonly dependency: string
|
||||
readonly exports: readonly string[]
|
||||
}
|
||||
|
||||
function managedRuntimeEdges(
|
||||
state: PackageDependencyState,
|
||||
expectedSection: 'dependencies' | 'peer-dev',
|
||||
): ManagedRuntimeEdge[] {
|
||||
return state.facts.flatMap(facts => [...expectedPackageDependencies(facts)]
|
||||
.filter(([name, rule]) => name !== CORDIS && rule.section === expectedSection)
|
||||
.map(([dependency]) => ({
|
||||
consumer: facts.manifest.name ?? facts.manifestPath,
|
||||
dependency,
|
||||
exports: [...new Set(facts.hostRuntimeExportUses
|
||||
.filter(use => use.packageName === dependency)
|
||||
.map(use => `${use.specifier}#${use.exportName}`))].sort(),
|
||||
})))
|
||||
.sort((left, right) =>
|
||||
left.consumer.localeCompare(right.consumer) || left.dependency.localeCompare(right.dependency))
|
||||
}
|
||||
|
||||
/** Format Host runtime edges whose reviewed exports permit ordinary dependencies. */
|
||||
export function formatManagedRuntimeDependencies(state: PackageDependencyState): string[] {
|
||||
const rows = managedRuntimeEdges(state, 'dependencies')
|
||||
const packages = new Set(rows.map(row => row.consumer)).size
|
||||
return [
|
||||
`${GATE}: ${String(rows.length)} managed Host runtime edge(s) remain in dependencies across ${String(packages)} package(s):`,
|
||||
...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.join(', ')}`),
|
||||
]
|
||||
}
|
||||
|
||||
/** Format Host runtime edges retained as peers by their imported export classification. */
|
||||
export function formatPeerRequiredRuntimeDependencies(state: PackageDependencyState): string[] {
|
||||
const rows = managedRuntimeEdges(state, 'peer-dev')
|
||||
const packages = new Set(rows.map(row => row.consumer)).size
|
||||
return [
|
||||
`${GATE}: ${String(rows.length)} Host runtime edge(s) remain in peerDependencies because their exports require shared identity across ${String(packages)} package(s):`,
|
||||
...rows.map(row => ` ${row.consumer} -> ${row.dependency}: ${row.exports.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]
|
||||
if (violations.length > 0) return [...new Set(violations)].sort()
|
||||
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 refreshPnpmLockfile(root: string): void {
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'],
|
||||
{ cwd: root, shell: process.platform === 'win32', stdio: 'inherit' },
|
||||
)
|
||||
if (result.error !== undefined) throw new Error(`could not refresh pnpm-lock.yaml: ${result.error.message}`)
|
||||
if (result.status !== 0) throw new Error(`pnpm lockfile refresh exited with status ${String(result.status)}`)
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
let state = readPackageDependencyState(root)
|
||||
const fix = process.argv.includes('--fix')
|
||||
if (fix) {
|
||||
if (state.policyViolations.length > 0) {
|
||||
console.error(`${GATE}: --fix skipped because dependency policy review failed.`)
|
||||
} else {
|
||||
const changed = fixPackageDependencies(root, state)
|
||||
console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`)
|
||||
refreshPnpmLockfile(root)
|
||||
const graphChanges = writeModuleGraph(root)
|
||||
console.log(
|
||||
`${GATE}: refreshed pnpm-lock.yaml and wrote ${String(graphChanges.length)} module-graph artifact(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-only']?.length ?? 0)} Client-only,`
|
||||
+ ` ${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)
|
||||
for (const line of formatPeerRequiredRuntimeDependencies(state)) console.log(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) main()
|
||||
@@ -16,7 +16,7 @@ const CANONICAL = '## Known Limitations and Deferred Work'
|
||||
|
||||
/** Packages audited as having no limitations section, keyed by repo-relative directory. */
|
||||
const NO_LIMITATIONS: Readonly<Record<string, string>> = {
|
||||
'packages/util/brand': 'Type-only nominal-branding primitive with no runtime behavior or deferred work.',
|
||||
'packages/util/brand': 'Stateless nominal-string and canonical-key helpers have no deferred work.',
|
||||
}
|
||||
|
||||
/** A heading that reads as a limitations section — canonical or drifted. */
|
||||
|
||||
@@ -31,10 +31,11 @@ interface SentenceContract {
|
||||
*/
|
||||
const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
|
||||
'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.',
|
||||
'packages/util/brand': 'The package is a type-only primitive erased at compile time.',
|
||||
'packages/util/brand': 'The package only constructs plain string values and registers nothing model-facing.',
|
||||
'packages/util/home-paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',
|
||||
'packages/util/launch-environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.',
|
||||
'packages/util/workspace-path': 'The package only formats Workspace paths for browser UI; it never constructs model input.',
|
||||
'packages/util/values': 'The package only validates, snapshots, compares, freezes, or rejects caller-owned values; consumers own every model-facing use.',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/code-runtime/code-runtime-python': { kind: 'indirect', reason: 'The CPython subprocess backend delegates model rendering to PTC mode in dsh-tools.' },
|
||||
'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' },
|
||||
'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' },
|
||||
'packages/util/deque': { kind: 'none', reason: 'In-process collection primitive; registers nothing model-facing.' },
|
||||
'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' },
|
||||
'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
|
||||
'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' },
|
||||
'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' },
|
||||
|
||||
Reference in New Issue
Block a user