From de256e8bc134a9063b61a4b9930909b407ba4eb4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:49:42 +0800 Subject: [PATCH] feat: enforce published dependency policy --- package.json | 3 + .../benchmark-next-package-dependency.spec.ts | 114 +++++ scripts/benchmark-next-package-dependency.ts | 271 ++++++++++ scripts/benchmark-npm-resolution.spec.ts | 84 +++ scripts/benchmark-npm-resolution.ts | 417 +++++++++++++++ scripts/package-dependency-policy.ts | 52 ++ scripts/package-invariants.spec.ts | 62 ++- scripts/package-invariants.ts | 23 +- scripts/run-gates.spec.ts | 11 +- scripts/run-gates.ts | 2 + scripts/verify-client-packages.spec.ts | 162 +----- scripts/verify-client-packages.ts | 314 +----------- scripts/verify-package-dependencies.spec.ts | 260 ++++++++++ scripts/verify-package-dependencies.ts | 479 ++++++++++++++++++ 14 files changed, 1804 insertions(+), 450 deletions(-) create mode 100644 scripts/benchmark-next-package-dependency.spec.ts create mode 100644 scripts/benchmark-next-package-dependency.ts create mode 100644 scripts/benchmark-npm-resolution.spec.ts create mode 100644 scripts/benchmark-npm-resolution.ts create mode 100644 scripts/package-dependency-policy.ts create mode 100644 scripts/verify-package-dependencies.spec.ts create mode 100644 scripts/verify-package-dependencies.ts diff --git a/package.json b/package.json index 9587f220d0..2ec0a09049 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "test:web:perf": "npm run build && npm run test:web:perf:built", "test:web:perf:built": "DSH_SNAPSHOT=replay vitest run --config vitest.web.perf.config.ts", "test:web:stress": "npm run build && vitest run --config vitest.web-stress.config.ts", + "benchmark:npm-resolution": "tsx scripts/benchmark-npm-resolution.ts", + "benchmark:npm-resolution:next": "tsx scripts/benchmark-next-package-dependency.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", @@ -104,6 +106,7 @@ "verify-optional-dependency-imports": "tsx scripts/verify-optional-dependency-imports.ts", "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts", + "verify-package-dependencies": "tsx scripts/verify-package-dependencies.ts", "verify-client-packages": "tsx scripts/verify-client-packages.ts", "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", diff --git a/scripts/benchmark-next-package-dependency.spec.ts b/scripts/benchmark-next-package-dependency.spec.ts new file mode 100644 index 0000000000..9248a3288e --- /dev/null +++ b/scripts/benchmark-next-package-dependency.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { + applyFactsToRegistry, + discoverBenchmarkCandidates, + parseNextPackageBenchmarkOptions, + type MutableRegistryManifest, +} from './benchmark-next-package-dependency.ts' +import type { + PackageDependencyFacts, + PackageDependencyManifest, + WorkspacePackageManifest, +} from './verify-package-dependencies.ts' +import type { RegistryIndex } from './benchmark-npm-resolution.ts' + +describe('next package benchmark options', () => { + it('parses candidate and repetition controls', () => { + expect(parseNextPackageBenchmarkOptions([ + '--', + '--candidates=@f/a,@f/b', + '--runs=2', + '--finalist-runs=4', + '--finalists=3', + '--jobs=6', + '--timeout-ms=9000', + ])).toEqual({ + candidates: ['@f/a', '@f/b'], + coarseRuns: 2, + finalistRuns: 4, + finalists: 3, + jobs: 6, + timeoutMs: 9000, + }) + }) + + it('rejects invalid positive integers', () => { + expect(() => parseNextPackageBenchmarkOptions(['--jobs=0'])).toThrow('--jobs must be a positive integer') + }) +}) + +describe('next package benchmark graph', () => { + it('applies a source-derived candidate without changing the filesystem', () => { + const manifest: PackageDependencyManifest & { version: string } = { + name: '@f/probe', + version: '1.0.0', + peerDependencies: { + '@deepseek-ai/cordis': 'workspace:^', + '@f/runtime': 'workspace:^', + '@f/types': 'workspace:^', + }, + devDependencies: { + '@deepseek-ai/cordis': 'workspace:^', + '@f/runtime': 'workspace:^', + '@f/types': 'workspace:^', + }, + } + const facts: PackageDependencyFacts = { + manifestPath: 'packages/g/probe/package.json', + role: 'configured-host', + manifest, + workspaceNames: new Set(['@deepseek-ai/cordis', '@f/probe', '@f/runtime', '@f/types']), + allSourceUses: new Map([ + ['@f/runtime', ['packages/g/probe/src/index.ts']], + ['@f/types', ['packages/g/probe/src/types.ts']], + ]), + hostRuntimeSourceUses: new Map([['@f/runtime', ['packages/g/probe/src/index.ts']]]), + clientInject: new Set(), + } + const index = new Map>([ + ['@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([ + ['@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']) + }) +}) diff --git a/scripts/benchmark-next-package-dependency.ts b/scripts/benchmark-next-package-dependency.ts new file mode 100644 index 0000000000..545045de6f --- /dev/null +++ b/scripts/benchmark-next-package-dependency.ts @@ -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 + optionalDependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record +} + +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> { + return new Map([...index].map(([name, versions]) => [ + name, + new Map([...versions].map(([version, manifest]) => [ + version, + structuredClone(manifest) as MutableRegistryManifest, + ])), + ])) +} + +function publishedSection( + values: Readonly> | undefined, + workspaceVersions: ReadonlyMap, +): Record | 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>, + facts: PackageDependencyFacts, + workspaceVersions: ReadonlyMap, +): 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 +} + +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, + releasePackages: ReadonlyMap, + policyPackages: ReadonlySet, +): string[] { + const reached = new Set() + 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 { + 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( + values: readonly T[], + jobs: number, + operation: (value: T) => Promise, +): Promise { + 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 { + 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 => { + 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 + } +} diff --git a/scripts/benchmark-npm-resolution.spec.ts b/scripts/benchmark-npm-resolution.spec.ts new file mode 100644 index 0000000000..92a6bdbfcb --- /dev/null +++ b/scripts/benchmark-npm-resolution.spec.ts @@ -0,0 +1,84 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + benchmarkNpmResolution, + buildRegistryIndex, + parseBenchmarkOptions, + publishWorkspaceRange, + type RegistryIndex, +} from './benchmark-npm-resolution.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function writeJson(root: string, path: string, value: unknown): void { + const absolute = join(root, path) + mkdirSync(dirname(absolute), { recursive: true }) + writeFileSync(absolute, `${JSON.stringify(value, null, 2)}\n`) +} + +describe('npm resolution benchmark', () => { + it('parses repeat, timeout, threshold, and ref options', () => { + expect(parseBenchmarkOptions([])).toEqual({ runs: 1, timeoutMs: 300_000 }) + expect(parseBenchmarkOptions([ + '--runs', '3', '--timeout-ms', '45000', '--max-ms', '20000', '--ref', 'master', + ])).toEqual({ runs: 3, timeoutMs: 45_000, maxMs: 20_000, ref: 'master' }) + expect(parseBenchmarkOptions(['--', '--runs', '2'])).toEqual({ runs: 2, timeoutMs: 300_000 }) + expect(() => parseBenchmarkOptions(['--runs', '0'])).toThrow('--runs must be a positive integer') + }) + + it('projects workspace protocols to published ranges', () => { + expect(publishWorkspaceRange('workspace:^', '1.2.3')).toBe('^1.2.3') + expect(publishWorkspaceRange('workspace:~', '1.2.3')).toBe('~1.2.3') + expect(publishWorkspaceRange('workspace:*', '1.2.3')).toBe('1.2.3') + expect(publishWorkspaceRange('^4.0.0', '1.2.3')).toBe('^4.0.0') + }) + + it('combines installed metadata with current publishable workspace fields', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-npm-registry-index-')) + roots.push(root) + writeJson(root, 'node_modules/.pnpm/external@2.0.0/node_modules/external/package.json', { + name: 'external', + version: '2.0.0', + dependencies: { child: '^1.0.0' }, + devDependencies: { ignored: '^1.0.0' }, + }) + writeJson(root, 'apps/cli/package.json', { + name: '@deepseek-ai/dsh', + version: '0.1.0', + dependencies: { '@deepseek-ai/dsh-child': 'workspace:^', external: '^2.0.0' }, + devDependencies: { ignored: 'workspace:^' }, + }) + writeJson(root, 'packages/core/child/package.json', { + name: '@deepseek-ai/dsh-child', + version: '0.1.0', + }) + + const index = buildRegistryIndex(root) + + expect(index.get('external')?.get('2.0.0')).toMatchObject({ dependencies: { child: '^1.0.0' } }) + expect(index.get('@deepseek-ai/dsh')?.get('0.1.0')).toEqual({ + name: '@deepseek-ai/dsh', + version: '0.1.0', + dependencies: { '@deepseek-ai/dsh-child': '^0.1.0', external: '^2.0.0' }, + }) + }) + + it('runs npm against the local registry without requesting an archive', async () => { + const index: RegistryIndex = new Map([[ + '@deepseek-ai/dsh', + new Map([['0.1.0', { name: '@deepseek-ai/dsh', version: '0.1.0' }]]), + ]]) + const result = await benchmarkNpmResolution(index, '0.1.0', 10_000) + + expect(result.durationMs).toBeGreaterThan(0) + expect(result.registryRequests).toBeGreaterThan(0) + expect(result.archiveRequests).toBe(0) + expect(result.unknownPackages).toEqual([]) + }) +}) diff --git a/scripts/benchmark-npm-resolution.ts b/scripts/benchmark-npm-resolution.ts new file mode 100644 index 0000000000..57834d4f09 --- /dev/null +++ b/scripts/benchmark-npm-resolution.ts @@ -0,0 +1,417 @@ +/** Benchmark npm's dependency-tree resolution against an all-local registry. */ + +import { execFileSync, spawn } from 'node:child_process' +import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { parseArgs } from 'node:util' + +const TARGET_PACKAGE = '@deepseek-ai/dsh' +const DEFAULT_TIMEOUT_MS = 300_000 +const WORKSPACE_MANIFEST_GLOBS = [ + 'apps/*/package.json', + 'packages/*/*/package.json', + 'vendor/*/package.json', + 'native/landlock-run/package.json', + 'native/landlock-run/packages/*/package.json', +] +const INSTALLED_MANIFEST_GLOBS = [ + 'node_modules/.pnpm/*/node_modules/*/package.json', + 'node_modules/.pnpm/*/node_modules/@*/*/package.json', +] +const PUBLISHED_FIELDS = [ + 'dependencies', + 'optionalDependencies', + 'peerDependencies', + 'peerDependenciesMeta', + 'engines', + 'os', + 'cpu', + 'bin', +] as const + +interface PackageManifest { + readonly name?: unknown + readonly version?: unknown + readonly dependencies?: Record + readonly optionalDependencies?: Record + readonly peerDependencies?: Record + readonly peerDependenciesMeta?: Record + 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> + +/** Parsed command-line options for one benchmark invocation. */ +export interface BenchmarkOptions { + readonly ref?: string + readonly runs: number + readonly timeoutMs: number + readonly maxMs?: number +} + +/** One measured npm resolution. */ +export interface BenchmarkRun { + readonly durationMs: number + readonly registryRequests: number + readonly archiveRequests: number + readonly unknownPackages: readonly string[] +} + +/** Parse one positive-integer command-line option or use its default. */ +export function parsePositiveIntegerOption(raw: string | undefined, fallback: number, name: string): number { + if (raw === undefined) return fallback + const value = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(value) || value < 1 || String(value) !== raw) { + throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`) + } + return value +} + +/** + * Parse supported benchmark arguments. + * @param args - Command-line arguments after the script path. + * @returns Validated benchmark options. + */ +export function parseBenchmarkOptions(args: readonly string[]): BenchmarkOptions { + const normalized = args[0] === '--' ? args.slice(1) : args + const { values } = parseArgs({ + args: [...normalized], + options: { + ref: { type: 'string' }, + runs: { type: 'string' }, + 'timeout-ms': { type: 'string' }, + 'max-ms': { type: 'string' }, + }, + allowPositionals: false, + }) + const maxMs = values['max-ms'] === undefined + ? undefined + : parsePositiveIntegerOption(values['max-ms'], 0, '--max-ms') + return { + runs: parsePositiveIntegerOption(values.runs, 1, '--runs'), + timeoutMs: parsePositiveIntegerOption(values['timeout-ms'], DEFAULT_TIMEOUT_MS, '--timeout-ms'), + ...(values.ref === undefined ? {} : { ref: values.ref }), + ...(maxMs === undefined ? {} : { maxMs }), + } +} + +function workspaceManifestPath(path: string): boolean { + return /^(?:apps\/[^/]+|packages\/[^/]+\/[^/]+|vendor\/[^/]+|native\/landlock-run(?:\/packages\/[^/]+)?)\/package\.json$/.test(path) +} + +function workspaceManifestPaths(root: string, ref: string | undefined): string[] { + if (ref === undefined) return globSync(WORKSPACE_MANIFEST_GLOBS, { cwd: root }).sort() + return execFileSync('git', ['ls-tree', '-r', '--name-only', ref, '--', 'apps', 'packages', 'vendor', 'native'], { + cwd: root, + encoding: 'utf8', + }).split('\n').filter(workspaceManifestPath).sort() +} + +function readGitFiles(root: string, ref: string, paths: readonly string[]): ReadonlyMap { + 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() + 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, +): RegistryVersion | undefined { + if (typeof source.name !== 'string' || typeof source.version !== 'string') return undefined + const output: Record = { 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).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>, manifest: RegistryVersion): void { + const versions = index.get(manifest.name) ?? new Map() + 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>() + 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 { + 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 { + 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 { + return new Promise((resolveClose, reject) => { + server.close((error) => { + if (error === undefined) resolveClose() + else reject(error) + }) + }) +} + +function npmExecutable(): string { + return process.platform === 'win32' ? 'npm.cmd' : 'npm' +} + +async function runNpm( + cwd: string, + registry: string, + timeoutMs: number, +): Promise<{ durationMs: number; output: string; timedOut: boolean }> { + const started = performance.now() + const child = spawn(npmExecutable(), [ + 'install', + '--package-lock-only', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--loglevel=error', + `--registry=${registry}`, + ], { + cwd, + // Windows resolves npm through a .cmd shim, which spawn() refuses + // without a shell since the CVE-2024-27980 hardening. + shell: process.platform === 'win32', + env: { + ...process.env, + npm_config_cache: join(cwd, '.npm-cache'), + npm_config_update_notifier: 'false', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let output = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { output += String(chunk) }) + child.stderr.on('data', (chunk) => { output += String(chunk) }) + const outcome = await new Promise<{ status: number | null; timedOut: boolean }>((resolveExit, reject) => { + let timeoutReached = false + const timer = setTimeout(() => { + timeoutReached = true + child.kill('SIGTERM') + }, timeoutMs) + child.once('error', reject) + child.once('exit', (status) => { + clearTimeout(timer) + resolveExit({ status, timedOut: timeoutReached }) + }) + }) + const durationMs = performance.now() - started + if (outcome.timedOut) return { durationMs, output, timedOut: true } + if (outcome.status !== 0) { + throw new Error(`npm install exited ${String(outcome.status)} after ${durationMs.toFixed(0)} ms\n${output.trim()}`) + } + return { durationMs, output, timedOut: false } +} + +/** + * Resolve the CLI install graph once without downloading package archives. + * @param index - Package metadata exposed through the local registry. + * @param targetVersion - Version of `@deepseek-ai/dsh` to install. + * @param timeoutMs - Hard wall-clock limit for the npm child process. + * @returns Timing and registry-request observations. + */ +export async function benchmarkNpmResolution( + index: RegistryIndex, + targetVersion: string, + timeoutMs: number, +): Promise { + let registryRequests = 0 + let archiveRequests = 0 + const unknownPackages = new Set() + let registry = '' + const server = createServer((request, response) => { + registryRequests++ + const pathname = new URL(request.url ?? '/', registry).pathname + if (pathname.startsWith('/tarballs/')) { + archiveRequests++ + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'package-lock-only benchmark requested an archive' })) + return + } + const name = decodeURIComponent(pathname.slice(1)) + const versions = index.get(name) + if (versions === undefined) { + unknownPackages.add(name) + response.writeHead(404, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'not_found' })) + return + } + const materialized = Object.fromEntries([...versions].map(([version, manifest]) => [version, { + ...manifest, + dist: { tarball: `${registry}tarballs/${encodeURIComponent(name)}-${version}.tgz` }, + }])) + const body = JSON.stringify({ + name, + 'dist-tags': { latest: latestVersion(versions) }, + versions: materialized, + }) + response.writeHead(200, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }) + response.end(body) + }) + const port = await listen(server) + registry = `http://127.0.0.1:${String(port)}/` + const consumer = mkdtempSync(join(tmpdir(), 'dsh-npm-resolution-')) + try { + writeFileSync(join(consumer, 'package.json'), `${JSON.stringify({ + name: 'dsh-npm-resolution-benchmark', + version: '0.0.0', + private: true, + dependencies: { [TARGET_PACKAGE]: targetVersion }, + }, null, 2)}\n`) + const result = await runNpm(consumer, registry, timeoutMs) + if (result.timedOut) throw new Error(`npm resolution exceeded ${String(timeoutMs)} ms`) + return { + durationMs: result.durationMs, + registryRequests, + archiveRequests, + unknownPackages: [...unknownPackages].sort(), + } + } finally { + await close(server) + rmSync(consumer, { recursive: true, force: true }) + } +} + +async function main(): Promise { + 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 + } +} diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts new file mode 100644 index 0000000000..1a172daafc --- /dev/null +++ b/scripts/package-dependency-policy.ts @@ -0,0 +1,52 @@ +/** Explicit exceptions and Host packages for the published dependency policy. */ + +/** Packages treated as Client/Host packages without declaring `dsh.client`. */ +const CLIENT_FACE_INCLUDE: readonly string[] = [] + +/** Packages exempted from automatic Client/Host treatment despite declaring `dsh.client`. */ +const CLIENT_FACE_EXCLUDE: readonly string[] = [ + '@deepseek-ai/dsh-api-session-controller', +] + +/** Host-only packages whose peer relays are deliberately flattened. */ +const HOST_DEPENDENCY_PACKAGES: readonly string[] = [ + '@deepseek-ai/dsh-llm', + '@deepseek-ai/dsh-session', +] + +/** Complete configurable input to package dependency classification. */ +export interface PackageDependencyPolicy { + readonly clientFaceInclude: readonly string[] + readonly clientFaceExclude: readonly string[] + readonly hostPackages: readonly string[] +} + +/** Repository dependency policy consumed by verification and benchmarking. */ +export const PACKAGE_DEPENDENCY_POLICY: PackageDependencyPolicy = { + clientFaceInclude: CLIENT_FACE_INCLUDE, + clientFaceExclude: CLIENT_FACE_EXCLUDE, + hostPackages: HOST_DEPENDENCY_PACKAGES, +} + +function isRecord(value: unknown): value is Record { + 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) +} diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 626aa59bce..d972e36816 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -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') diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 3e4e5ac757..eeafd0e9fb 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -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 files?: string[] peerDependencies?: Record @@ -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`, ) } } diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 0033a55958..f30dc4adca 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -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) => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f3052d6c25..9a71f1876e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -278,6 +278,7 @@ function ciSharedStaticGates(): Gate[] { pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }), pnpmScript('constraints', 'constraints'), + pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }), pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), @@ -667,6 +668,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { pnpmScript('rescope-vendor', 'rescope-vendor:check', { label: 'vendor rescope' }), pnpmScript('publint', 'publint', artifactOptions), pnpmScript('constraints', 'constraints'), + pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }), pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }), pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), diff --git a/scripts/verify-client-packages.spec.ts b/scripts/verify-client-packages.spec.ts index aa39b84316..05a4acf031 100644 --- a/scripts/verify-client-packages.spec.ts +++ b/scripts/verify-client-packages.spec.ts @@ -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 - devDependencies: Record - } - 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) }) }) diff --git a/scripts/verify-client-packages.ts b/scripts/verify-client-packages.ts index 294cba6328..94e70497ae 100644 --- a/scripts/verify-client-packages.ts +++ b/scripts/verify-client-packages.ts @@ -1,6 +1,5 @@ /** - * Verify client package modes, npm dependency sections, and the synchronous - * browser module-request graph. + * Verify client package modes and the synchronous browser module-request graph. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -17,8 +16,6 @@ const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts' const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts' const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts' const CORDIS = '@deepseek-ai/cordis' -const DSH_PREFIX = '@deepseek-ai/dsh-' -const CLIENT_WEB = '@deepseek-ai/dsh-client-web' /** One workspace package's browser-module declaration. */ export interface ClientDeclaration { @@ -92,6 +89,17 @@ export function collectRuntimeSourceSpecifiers(path: string, source: string): Se return collectSourceFileUses(sourceFile, true, 'specifier') } +/** + * Collect relative module specifiers used to follow one source entry's local closure. + * @param path - File path used to select TypeScript's parser mode. + * @param source - Source text to inspect. + * @returns Relative imports, exports, requires, and import types. + */ +export function collectLocalSourceSpecifiers(path: string, source: string): Set { + const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true) + return collectSourceFileUses(sourceFile, false, 'local') +} + function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean { const clause = node.importClause if (clause === undefined) return true @@ -114,12 +122,17 @@ function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean { function collectSourceFileUses( sourceFile: ts.SourceFile, runtimeOnly: boolean, - key: 'package' | 'specifier', + key: 'local' | 'package' | 'specifier', ): Set { const uses = new Set() const add = (specifier: ts.Expression | undefined): void => { - if (specifier === undefined || !ts.isStringLiteral(specifier) || !isBareSpecifier(specifier.text)) return + if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return + if (key === 'local') { + if (specifier.text.startsWith('.')) uses.add(specifier.text) + return + } + if (!isBareSpecifier(specifier.text)) return uses.add(key === 'package' ? packageNameOf(specifier.text) : specifier.text) } const visit = (node: ts.Node): void => { @@ -135,9 +148,10 @@ function collectSourceFileUses( && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === 'require')) { add(node.arguments[0]) - } else if (!runtimeOnly && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { + } else if (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { add(node.name) - } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) { + } else if (key !== 'local' + && (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) { uses.add('react') } ts.forEachChild(node, visit) @@ -170,7 +184,6 @@ export function collectClientPackageViolations(facts: ClientPackageFacts): strin return [ ...facts.malformed, ...collectModeViolations(facts), - ...collectDependencyViolations(facts), ...collectModuleViolations(facts), ].sort((left, right) => left.localeCompare(right)) } @@ -181,10 +194,8 @@ interface ManifestDocument { changed: boolean } -type DependencySection = 'dependencies' | 'peerDependencies' | 'devDependencies' - /** - * Repair manifest declarations whose intended result follows uniquely from the policy. + * Repair malformed or redundant `dsh.client` declaration entries. * @param root - Absolute repository root. * @param facts - Facts used by the verification pass. * @returns Repository-relative manifests written by the fixer. @@ -217,53 +228,6 @@ export function fixClientPackageManifests(root: string, facts: ClientPackageFact ) || target.changed } - const staticInputs = new Set([ - ...facts.staticLinkedPackages, - ...facts.platformModules.map(packageNameOf), - ]) - staticInputs.delete(CORDIS) - const inferredRanges = dependencyRangeCandidates(root) - for (const pkg of facts.packages) { - const target = document(pkg.manifest) - const expected = expectedSections(pkg, staticInputs) - for (const [name, rule] of expected) { - const range = preferredRange(target.manifest, name, rule.kind, inferredRanges) - if (range === undefined) continue - target.changed = rule.kind === 'dependency' - ? ensureDependencyOnly(target.manifest, name, range) || target.changed - : rule.kind === 'dev' - ? ensureDevOnly(target.manifest, name, range) || target.changed - : ensurePeerDev(target.manifest, name, range) || target.changed - } - - if (pkg.dynamic) { - const productionNames = new Set([ - ...Object.keys(section(target.manifest, 'dependencies')), - ...Object.keys(section(target.manifest, 'peerDependencies')), - ]) - for (const name of productionNames) { - if (expected.has(name)) continue - const range = preferredRange( - target.manifest, - name, - staticInputs.has(name) ? 'dev' : 'peer-dev', - inferredRanges, - ) - if (range === undefined) continue - if (staticInputs.has(name)) { - target.changed = ensureDevOnly(target.manifest, name, range) || target.changed - } else if (section(target.manifest, 'dependencies')[name] !== undefined && isInternalDsh(name)) { - target.changed = ensurePeerDev(target.manifest, name, range) || target.changed - } - } - } - - for (const [name, range] of Object.entries(section(target.manifest, 'peerDependencies'))) { - target.changed = setDependency(target.manifest, 'devDependencies', name, range) || target.changed - } - target.changed = deleteEmptySections(target.manifest) || target.changed - } - const changed = [...documents.values()].filter(target => target.changed).sort((left, right) => left.path.localeCompare(right.path)) for (const target of changed) { @@ -295,102 +259,6 @@ function normalizeClientArray( return true } -function ensureDevOnly(manifest: Manifest, name: string, range: string): boolean { - let changed = deleteDependency(manifest, 'dependencies', name) - changed = deleteDependency(manifest, 'peerDependencies', name) || changed - return setDependency(manifest, 'devDependencies', name, range) || changed -} - -function ensureDependencyOnly(manifest: Manifest, name: string, range: string): boolean { - let changed = deleteDependency(manifest, 'peerDependencies', name) - changed = deleteDependency(manifest, 'devDependencies', name) || changed - return setDependency(manifest, 'dependencies', name, range) || changed -} - -function ensurePeerDev(manifest: Manifest, name: string, range: string): boolean { - let changed = deleteDependency(manifest, 'dependencies', name) - changed = setDependency(manifest, 'peerDependencies', name, range) || changed - return setDependency(manifest, 'devDependencies', name, range) || changed -} - -function setDependency(manifest: Manifest, field: DependencySection, name: string, range: string): boolean { - const dependencies = mutableSection(manifest, field) - if (dependencies[name] === range) return false - dependencies[name] = range - return true -} - -function deleteDependency(manifest: Manifest, field: DependencySection, name: string): boolean { - const dependencies = section(manifest, field) - if (dependencies[name] === undefined) return false - manifest[field] = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name)) - return true -} - -function deleteEmptySections(manifest: Manifest): boolean { - let changed = false - for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) { - if (manifest[field] === undefined || Object.keys(section(manifest, field)).length > 0) continue - if (field === 'dependencies') delete manifest.dependencies - else if (field === 'peerDependencies') delete manifest.peerDependencies - else delete manifest.devDependencies - changed = true - } - return changed -} - -function preferredRange( - manifest: Manifest, - name: string, - kind: ExpectedRule['kind'], - inferred: ReadonlyMap>, -): string | 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> { - const candidates = new Map>() - 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() - ranges.add(range) - candidates.set(name, ranges) - } - } - } - return candidates -} - -function section(manifest: Manifest, field: DependencySection): Record { - return manifest[field] ?? {} -} - -function mutableSection(manifest: Manifest, field: DependencySection): Record { - const value = manifest[field] - if (value !== undefined) return value - const created: Record = {} - manifest[field] = created - return created -} - function collectModeViolations(facts: ClientPackageFacts): string[] { const violations: string[] = [] for (const pkg of facts.packages) { @@ -435,114 +303,6 @@ function collectModeViolations(facts: ClientPackageFacts): string[] { return violations } -interface ExpectedRule { - readonly kind: 'dependency' | 'dev' | 'peer-dev' - readonly origins: Set -} - -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): Map { - const expected = new Map([ - [CORDIS, { kind: 'peer-dev', origins: new Set(['client package baseline']) }], - ]) - if (!pkg.dynamic) { - if (pkg.name === CLIENT_WEB) return expected - for (const [name, locations] of Object.entries(pkg.runtimeSourceUses)) { - if (name === pkg.name || name === CORDIS || isInternalDsh(name)) continue - expected.set(name, { kind: 'dependency', origins: new Set(locations) }) - } - return expected - } - - const add = (name: string, origin: string): void => { - if (name === pkg.name) return - const kind = staticInputs.has(name) ? 'dev' : isInternalDsh(name) ? 'peer-dev' : undefined - if (kind === undefined) return - const current = expected.get(name) - if (current !== undefined) current.origins.add(origin) - else expected.set(name, { kind, origins: new Set([origin]) }) - } - for (const [name, locations] of Object.entries(pkg.sourceUses)) { - for (const location of locations) add(name, location) - } - for (const name of pkg.inject) add(name, 'dsh.client.inject') - return expected -} - interface ModuleEdge { readonly from: string readonly to: string @@ -895,32 +655,6 @@ function rowPackageOf(specifier: string, rows: ReadonlySet): string | 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 { - const sorted = [...origins].sort() - const [first, second, ...rest] = sorted - if (first === undefined) return 'production use' - if (second === undefined) return first - return rest.length === 0 ? first + ', ' + second : first + ', ' + second + ', and ' + String(rest.length) + ' more' -} - -function isInternalDsh(name: string): boolean { - return name === CORDIS || name.startsWith(DSH_PREFIX) -} - function isBareSpecifier(specifier: string): boolean { return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#') } @@ -956,7 +690,7 @@ async function main(): Promise { 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).', ) } diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts new file mode 100644 index 0000000000..253a5ea7c3 --- /dev/null +++ b/scripts/verify-package-dependencies.spec.ts @@ -0,0 +1,260 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + PACKAGE_DEPENDENCY_POLICY, + type PackageDependencyPolicy, +} from './package-dependency-policy.ts' +import { + collectPackageDependencyViolations, + discoverPackageDependencyScope, + fixPackageDependencies, + formatManagedRuntimeDependencies, + readPackageDependencyFacts, + repairPackageDependencyManifest, + type PackageDependencyFacts, + type PackageDependencyManifest, + type WorkspacePackageManifest, +} from './verify-package-dependencies.ts' + +const CORDIS = '@deepseek-ai/cordis' +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function pkg( + name: string, + manifestPath: string, + manifest: Partial = {}, +): WorkspacePackageManifest { + return { + name, + manifestPath, + dir: dirname(manifestPath), + manifest: { name, ...manifest }, + } +} + +function policy(fields: Partial = {}): PackageDependencyPolicy { + return { + clientFaceInclude: [], + clientFaceExclude: [], + hostPackages: [], + ...fields, + } +} + +function facts(manifest: PackageDependencyManifest): PackageDependencyFacts { + return { + manifestPath: 'packages/core/probe/package.json', + role: 'configured-host', + manifest, + workspaceNames: new Set([ + CORDIS, + '@deepseek-ai/dsh-runtime', + '@deepseek-ai/dsh-types', + '@deepseek-ai/dsh-stale', + '@deepseek-ai/schemastery', + ]), + allSourceUses: new Map([ + ['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']], + ['@deepseek-ai/dsh-types', ['packages/core/probe/src/types.ts']], + ]), + hostRuntimeSourceUses: new Map([ + ['@deepseek-ai/dsh-runtime', ['packages/core/probe/src/index.ts']], + ]), + clientInject: new Set(), + } +} + +describe('package dependency scope', () => { + it('keeps the measured Host relay roster explicit', () => { + expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([ + '@deepseek-ai/dsh-api-session-controller', + ]) + expect(PACKAGE_DEPENDENCY_POLICY.hostPackages).toEqual([ + '@deepseek-ai/dsh-llm', + '@deepseek-ai/dsh-session', + ]) + }) + + it('discovers the Client directory, dsh.client declarations, and configured Host packages', () => { + const packages = [ + pkg('@f/static', 'packages/client/static/package.json'), + pkg('@f/dynamic-client', 'packages/client/dynamic/package.json', { dsh: { client: {} } }), + pkg('@f/dual', 'packages/api/dual/package.json', { dsh: { client: {} } }), + pkg('@f/export-only', 'packages/api/export-only/package.json', { exports: { './client': './lib/client.js' } }), + pkg('@f/forced-client', 'packages/api/forced/package.json'), + pkg('@f/excluded', 'packages/api/excluded/package.json', { dsh: { client: {} } }), + pkg('@f/host', 'packages/core/host/package.json'), + ] + + const found = discoverPackageDependencyScope(packages, policy({ + clientFaceInclude: ['@f/forced-client'], + clientFaceExclude: ['@f/excluded'], + hostPackages: ['@f/host'], + })) + + expect(found.violations).toEqual([]) + expect(found.selected.map(item => [item.name, item.role])).toEqual([ + ['@f/dual', 'client-host'], + ['@f/forced-client', 'client-host'], + ['@f/dynamic-client', 'client-host'], + ['@f/static', 'client-host'], + ['@f/host', 'configured-host'], + ]) + }) + + it('rejects stale, redundant, overlapping, and unknown configuration', () => { + const packages = [ + pkg('@f/client', 'packages/client/client/package.json'), + pkg('@f/dual', 'packages/api/dual/package.json', { dsh: { client: {} } }), + pkg('@f/host', 'packages/core/host/package.json'), + ] + const found = discoverPackageDependencyScope(packages, policy({ + clientFaceInclude: ['@f/dual', '@f/missing', '@f/host'], + clientFaceExclude: ['@f/client', '@f/host', '@f/missing'], + hostPackages: ['@f/dual'], + })) + + expect(found.violations).toEqual(expect.arrayContaining([ + expect.stringContaining('clientFaceInclude redundantly names automatically discovered package @f/dual'), + expect.stringContaining('@f/host appears in both clientFaceInclude and clientFaceExclude'), + expect.stringContaining('clientFaceExclude cannot exempt packages/client package @f/client'), + expect.stringContaining('clientFaceExclude names @f/host, which declares no dsh.client entry'), + expect.stringContaining('hostPackages redundantly names Client-faced package @f/dual'), + expect.stringContaining('unknown release package @f/missing'), + ])) + }) +}) + +describe('face-aware source classification', () => { + it('counts Host values as dependencies and Client values as development inputs', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-package-faces-')) + roots.push(root) + const subject = pkg('@f/dual', 'packages/g/dual/package.json', { + dsh: { client: { inject: ['@f/injected'] } }, + }) + const files = { + 'packages/g/dual/src/index.ts': [ + "import { value } from '@f/runtime'", + "import type { Shared } from '@f/types'", + "export { nested } from './nested.ts'", + ].join('\n'), + 'packages/g/dual/src/nested.ts': "export { nested } from '@f/nested'", + 'packages/g/dual/src/client/index.ts': "import { browser } from '@f/browser'", + } + for (const [path, source] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), source) + } + + const found = readPackageDependencyFacts(root, subject, 'client-host', new Set([ + CORDIS, '@f/runtime', '@f/types', '@f/nested', '@f/browser', '@f/injected', + ])) + + expect([...found.hostRuntimeSourceUses.keys()].sort()).toEqual(['@f/nested', '@f/runtime']) + expect([...found.allSourceUses.keys()].sort()).toEqual(['@f/browser', '@f/nested', '@f/runtime', '@f/types']) + }) +}) + +describe('dependency sections', () => { + it('accepts Host dependencies, development-only inputs, and shared Cordis', () => { + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-probe', + dependencies: { + '@deepseek-ai/dsh-runtime': 'workspace:^', + '@deepseek-ai/schemastery': 'workspace:^', + external: '^1.0.0', + }, + devDependencies: { + '@deepseek-ai/dsh-types': 'workspace:^', + [CORDIS]: 'workspace:^', + }, + peerDependencies: { [CORDIS]: 'workspace:^' }, + } + expect(collectPackageDependencyViolations({ + facts: [facts(manifest)], packages: [], policyViolations: [], workspaceNames: facts(manifest).workspaceNames, + })).toEqual([]) + }) + + it('lists managed Host runtime dependencies for fix review', () => { + const subject = facts({ name: '@deepseek-ai/dsh-probe' }) + expect(formatManagedRuntimeDependencies({ + facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames, + })).toEqual([ + 'verify-package-dependencies: 1 managed Host runtime dependency edge(s) remain in dependencies across 1 package(s):', + ' @deepseek-ai/dsh-probe: @deepseek-ai/dsh-runtime', + ]) + }) + + it('reports wrong sections, workspace ranges, and stale peer metadata', () => { + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-probe', + dependencies: { '@deepseek-ai/dsh-types': 'workspace:*' }, + devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' }, + peerDependencies: { [CORDIS]: 'workspace:*', '@deepseek-ai/dsh-runtime': 'workspace:^' }, + peerDependenciesMeta: { '@deepseek-ai/dsh-missing': { optional: true } }, + } + const state = { + facts: [facts(manifest)], packages: [], policyViolations: [], workspaceNames: facts(manifest).workspaceNames, + } + const violations = collectPackageDependencyViolations(state) + expect(violations).toEqual(expect.arrayContaining([ + expect.stringContaining('@deepseek-ai/dsh-runtime'), + expect.stringContaining('@deepseek-ai/dsh-types'), + expect.stringContaining(`${CORDIS} must be matching peerDependencies + devDependencies`), + expect.stringContaining('dependencies.@deepseek-ai/dsh-types must use workspace:^'), + expect.stringContaining('peerDependenciesMeta.@deepseek-ai/dsh-missing has no matching'), + ])) + }) + + it('repairs owned relationships without changing unrelated dependencies', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-package-dependencies-')) + roots.push(root) + const manifestPath = 'package.json' + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-probe', + dependencies: { '@deepseek-ai/schemastery': 'workspace:*', external: '^1.0.0' }, + devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' }, + peerDependencies: { + [CORDIS]: 'workspace:^', + '@deepseek-ai/dsh-runtime': 'workspace:^', + '@deepseek-ai/dsh-stale': 'workspace:^', + }, + peerDependenciesMeta: { '@deepseek-ai/dsh-stale': { optional: true } }, + } + writeFileSync(join(root, manifestPath), `${JSON.stringify(manifest, null, 2)}\n`) + const subject = { ...facts(manifest), manifestPath } + const state = { facts: [subject], packages: [], policyViolations: [], workspaceNames: subject.workspaceNames } + + expect(fixPackageDependencies(root, state)).toEqual([manifestPath]) + const fixed = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as PackageDependencyManifest + expect(fixed.dependencies).toEqual({ + '@deepseek-ai/schemastery': 'workspace:^', + external: '^1.0.0', + '@deepseek-ai/dsh-runtime': 'workspace:^', + }) + expect(fixed.devDependencies).toEqual({ + [CORDIS]: 'workspace:^', + '@deepseek-ai/dsh-types': 'workspace:^', + '@deepseek-ai/dsh-stale': 'workspace:^', + }) + expect(fixed.peerDependencies).toEqual({ [CORDIS]: 'workspace:^' }) + expect(fixed.peerDependenciesMeta).toBeUndefined() + }) + + it('repairs an in-memory manifest for benchmark simulation', () => { + const manifest: PackageDependencyManifest = { + name: '@deepseek-ai/dsh-probe', + peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' }, + devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-runtime': 'workspace:^' }, + } + repairPackageDependencyManifest(facts(manifest)) + expect(manifest.dependencies).toEqual({ '@deepseek-ai/dsh-runtime': 'workspace:^' }) + expect(manifest.peerDependencies).toEqual({ [CORDIS]: 'workspace:^' }) + }) +}) diff --git a/scripts/verify-package-dependencies.ts b/scripts/verify-package-dependencies.ts new file mode 100644 index 0000000000..fda6184864 --- /dev/null +++ b/scripts/verify-package-dependencies.ts @@ -0,0 +1,479 @@ +/** Verify and repair npm dependency sections from published Client and Host faces. */ + +import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path' +import { + hasClientDeclaration, + PACKAGE_DEPENDENCY_POLICY, + type PackageDependencyPolicy, +} from './package-dependency-policy.ts' +import { + collectLocalSourceSpecifiers, + collectRuntimeSourcePackageUses, + collectSourcePackageUses, +} from './verify-client-packages.ts' + +const GATE = 'verify-package-dependencies' +const CORDIS = '@deepseek-ai/cordis' +const WORKSPACE_RANGE = 'workspace:^' +const RELEASE_MANIFEST_GLOB = 'packages/!(experimental)/*/package.json' +const WORKSPACE_MANIFEST_GLOBS = [ + 'apps/*/package.json', + 'packages/*/*/package.json', + 'vendor/*/package.json', +] + +type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies' +export type PackageDependencyRole = 'client-host' | 'configured-host' + +/** Manifest fields read and repaired by the package dependency policy. */ +export interface PackageDependencyManifest { + name?: string + version?: string + exports?: unknown + dependencies?: Record + devDependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record + 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 + readonly allSourceUses: ReadonlyMap + readonly hostRuntimeSourceUses: ReadonlyMap + readonly clientInject: ReadonlySet +} + +/** 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 +} + +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() + const duplicated = new Set() + 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; 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 = [] + for (const pkg of packages) { + const clientDirectory = pkg.manifestPath.startsWith('packages/client/') + const clientHost = clientDirectory + || ((hasClientDeclaration(pkg.manifest.dsh) || include.has(pkg.name)) && !exclude.has(pkg.name)) + const configuredHost = host.has(pkg.name) + if (configuredHost && clientHost) { + violations.push(`hostPackages redundantly names Client-faced package ${pkg.name}`) + } + const role = clientHost ? 'client-host' : configuredHost ? 'configured-host' : undefined + if (role !== undefined) selected.push({ ...pkg, role }) + } + return { + selected: selected.sort((left, right) => left.manifestPath.localeCompare(right.manifestPath)), + violations: [...new Set(violations)].sort(), + } +} + +function addUse(target: Map, name: string, path: string): void { + const paths = target.get(name) ?? [] + if (!paths.includes(path)) paths.push(path) + target.set(name, paths) +} + +function resolveLocal(importer: string, specifier: string): string | undefined { + const raw = resolve(dirname(importer), specifier) + const candidates = extname(raw) === '' + ? [`${raw}.ts`, `${raw}.tsx`, `${raw}.mts`, `${raw}.cts`, join(raw, 'index.ts'), join(raw, 'index.tsx')] + : [raw, raw.replace(/\.js$/, '.ts'), raw.replace(/\.jsx$/, '.tsx'), raw.replace(/\.mjs$/, '.mts'), raw.replace(/\.cjs$/, '.cts')] + return candidates.find(candidate => existsSync(candidate)) +} + +function readHostRuntimeUses(root: string, pkg: WorkspacePackageManifest): Map { + const uses = new Map() + const seen = new Set() + const visit = (path: string): void => { + const normalized = normalize(path) + if (seen.has(normalized) || !existsSync(normalized)) return + seen.add(normalized) + const source = readFileSync(normalized, 'utf8') + const displayPath = normalizePath(relative(root, normalized)) + for (const name of collectRuntimeSourcePackageUses(normalized, source)) addUse(uses, name, displayPath) + for (const specifier of collectLocalSourceSpecifiers(normalized, source)) { + const target = resolveLocal(normalized, specifier) + if (target !== undefined) visit(target) + } + } + visit(resolve(root, pkg.dir, 'src/index.ts')) + return uses +} + +function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map { + const uses = new Map() + 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, +): PackageDependencyFacts { + const inject = pkg.manifest.dsh?.client?.inject ?? [] + return { + manifestPath: pkg.manifestPath, + role, + manifest: pkg.manifest, + workspaceNames, + allSourceUses: readAllSourceUses(root, pkg), + hostRuntimeSourceUses: readHostRuntimeUses(root, pkg), + clientInject: new Set(inject.map(packageNameOf).filter(name => name !== undefined)), + } +} + +/** Read every package covered by the current dependency policy. */ +export function readPackageDependencyState( + root: string, + policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY, +): PackageDependencyState { + const packages = readWorkspacePackageManifests(root) + const workspaceNames = new Set(packages.all.map(pkg => pkg.name)) + const discovered = discoverPackageDependencyScope(packages.release, policy) + return { + facts: discovered.selected.map(pkg => readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames)), + packages: packages.release, + policyViolations: discovered.violations, + workspaceNames, + } +} + +/** Derive the required npm section for each relationship owned by the policy. */ +export function expectedPackageDependencies( + facts: PackageDependencyFacts, +): ReadonlyMap { + const expected = new Map }>() + const add = (name: string, sectionName: ExpectedPackageDependency['section'], origin: string): void => { + if (name === facts.manifest.name || name === CORDIS) return + const current = expected.get(name) + const section = current?.section === 'dependencies' || sectionName === 'dependencies' + ? 'dependencies' + : 'devDependencies' + expected.set(name, { section, origins: new Set([...(current?.origins ?? []), origin]) }) + } + + expected.set(CORDIS, { section: 'peer-dev', origins: new Set(['shared Cordis runtime']) }) + for (const [name, paths] of facts.allSourceUses) { + if (!facts.workspaceNames.has(name)) continue + for (const path of paths) add(name, 'devDependencies', path) + } + for (const name of facts.clientInject) { + if (facts.workspaceNames.has(name)) add(name, 'devDependencies', 'dsh.client.inject') + } + for (const name of Object.keys(facts.manifest.peerDependencies ?? {})) { + if (name !== CORDIS) add(name, 'devDependencies', 'existing non-Cordis peer') + } + for (const [name, paths] of facts.hostRuntimeSourceUses) { + if (!facts.workspaceNames.has(name) && facts.manifest.peerDependencies?.[name] === undefined) continue + for (const path of paths) add(name, 'dependencies', path) + } + return new Map([...expected].map(([name, rule]) => [name, { + section: rule.section, + origins: [...rule.origins].sort(), + }])) +} + +/** Format the managed Host runtime edges that remain ordinary dependencies. */ +export function formatManagedRuntimeDependencies(state: PackageDependencyState): string[] { + const rows = state.facts.flatMap((facts) => { + const dependencies = [...expectedPackageDependencies(facts)] + .filter(([, rule]) => rule.section === 'dependencies') + .map(([name]) => name) + .sort() + if (dependencies.length === 0) return [] + return [{ + name: facts.manifest.name ?? facts.manifestPath, + dependencies, + }] + }).sort((left, right) => left.name.localeCompare(right.name)) + const edges = rows.reduce((total, row) => total + row.dependencies.length, 0) + return [ + `${GATE}: ${String(edges)} managed Host runtime dependency edge(s) remain in dependencies across ${String(rows.length)} package(s):`, + ...rows.map(row => ` ${row.name}: ${row.dependencies.join(', ')}`), + ] +} + +function section(manifest: PackageDependencyManifest, name: DependencySection): Record { + return manifest[name] ?? {} +} + +function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record { + manifest[name] ??= {} + return manifest[name] +} + +function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] { + return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) + .filter(sectionName => section(manifest, sectionName)[name] !== undefined) +} + +function describeSections(sections: readonly DependencySection[]): string { + return sections.length === 0 ? 'no dependency section' : sections.join(' + ') +} + +/** Return all manifest and policy violations in stable order. */ +export function collectPackageDependencyViolations(state: PackageDependencyState): string[] { + const violations = [...state.policyViolations] + for (const facts of state.facts) { + for (const [name, rule] of expectedPackageDependencies(facts)) { + const actual = declaredSections(facts.manifest, name) + if (rule.section === 'peer-dev') { + if (actual.length === 2 + && actual.includes('peerDependencies') + && actual.includes('devDependencies') + && section(facts.manifest, 'peerDependencies')[name] === WORKSPACE_RANGE + && section(facts.manifest, 'devDependencies')[name] === WORKSPACE_RANGE + && facts.manifest.peerDependenciesMeta?.[name] === undefined) continue + violations.push( + `${facts.manifestPath}: ${name} must be matching peerDependencies + devDependencies at ${WORKSPACE_RANGE}; found ${describeSections(actual)}`, + ) + continue + } + const expectedSection = rule.section + const range = section(facts.manifest, expectedSection)[name] + if (actual.length === 1 + && actual[0] === expectedSection + && (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE)) continue + violations.push( + `${facts.manifestPath}: ${name} (${rule.origins.join(', ')}) must be ${expectedSection}-only` + + (facts.workspaceNames.has(name) ? ` at ${WORKSPACE_RANGE}` : '') + + `; found ${describeSections(actual)}`, + ) + } + for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) { + for (const [name, range] of Object.entries(section(facts.manifest, sectionName))) { + if (!facts.workspaceNames.has(name) || range === WORKSPACE_RANGE) continue + violations.push(`${facts.manifestPath}: ${sectionName}.${name} must use ${WORKSPACE_RANGE}, found ${range}`) + } + } + for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) { + if (facts.manifest.peerDependencies?.[name] === undefined) { + violations.push(`${facts.manifestPath}: peerDependenciesMeta.${name} has no matching peerDependencies entry`) + } + } + } + return [...new Set(violations)].sort() +} + +function deleteDependency( + manifest: PackageDependencyManifest, + sectionName: DependencySection, + name: string, +): void { + const dependencies = manifest[sectionName] + if (dependencies?.[name] === undefined) return + const retained = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name)) + if (Object.keys(retained).length > 0) { + manifest[sectionName] = retained + return + } + switch (sectionName) { + case 'dependencies': delete manifest.dependencies; break + case 'devDependencies': delete manifest.devDependencies; break + case 'optionalDependencies': delete manifest.optionalDependencies; break + case 'peerDependencies': delete manifest.peerDependencies; break + } +} + +function deletePeerMeta(manifest: PackageDependencyManifest, name: string): void { + if (manifest.peerDependenciesMeta?.[name] === undefined) return + const retained = Object.fromEntries(Object.entries(manifest.peerDependenciesMeta) + .filter(([key]) => key !== name)) + if (Object.keys(retained).length > 0) manifest.peerDependenciesMeta = retained + else delete manifest.peerDependenciesMeta +} + +function preferredRange( + facts: PackageDependencyFacts, + name: string, + target: ExpectedPackageDependency['section'], +): string | undefined { + if (facts.workspaceNames.has(name)) return WORKSPACE_RANGE + const order: readonly DependencySection[] = target === 'dependencies' + ? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] + : ['devDependencies', 'peerDependencies', 'dependencies', 'optionalDependencies'] + return order.map(sectionName => section(facts.manifest, sectionName)[name]).find(value => value !== undefined) +} + +/** Apply the dependency policy to one in-memory manifest. */ +export function repairPackageDependencyManifest(facts: PackageDependencyFacts): void { + for (const [name, rule] of expectedPackageDependencies(facts)) { + if (rule.section === 'peer-dev') { + for (const sectionName of ['dependencies', 'optionalDependencies'] as const) { + deleteDependency(facts.manifest, sectionName, name) + } + mutableSection(facts.manifest, 'peerDependencies')[name] = WORKSPACE_RANGE + mutableSection(facts.manifest, 'devDependencies')[name] = WORKSPACE_RANGE + deletePeerMeta(facts.manifest, name) + continue + } + const range = preferredRange(facts, name, rule.section) + if (range === undefined) continue + for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) { + if (sectionName !== rule.section) deleteDependency(facts.manifest, sectionName, name) + } + mutableSection(facts.manifest, rule.section)[name] = range + deletePeerMeta(facts.manifest, name) + } + for (const name of Object.keys(facts.manifest.peerDependenciesMeta ?? {})) { + if (facts.manifest.peerDependencies?.[name] === undefined) deletePeerMeta(facts.manifest, name) + } + for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const) { + for (const name of Object.keys(section(facts.manifest, sectionName))) { + if (facts.workspaceNames.has(name)) mutableSection(facts.manifest, sectionName)[name] = WORKSPACE_RANGE + } + } +} + +/** Repair every covered manifest and return repository-relative changed paths. */ +export function fixPackageDependencies(root: string, state: PackageDependencyState): string[] { + if (state.policyViolations.length > 0) return [] + const changed: string[] = [] + for (const facts of state.facts) { + const before = `${JSON.stringify(facts.manifest, null, 2)}\n` + repairPackageDependencyManifest(facts) + const after = `${JSON.stringify(facts.manifest, null, 2)}\n` + if (after === before) continue + writeFileSync(resolve(root, facts.manifestPath), after) + changed.push(facts.manifestPath) + } + return changed.sort() +} + +function main(): void { + const root = resolve(import.meta.dirname, '..') + let state = readPackageDependencyState(root) + const fix = process.argv.includes('--fix') + if (fix) { + const changed = fixPackageDependencies(root, state) + console.log(`${GATE}: fixed ${String(changed.length)} manifest(s).`) + state = readPackageDependencyState(root) + } + const violations = collectPackageDependencyViolations(state) + if (violations.length > 0) { + console.error(`${GATE}: ${String(violations.length)} violation(s):`) + for (const violation of violations) console.error(` ${violation}`) + process.exitCode = 1 + return + } + const roles = Object.groupBy(state.facts, fact => fact.role) + console.log( + `${GATE}: ${String(state.facts.length)} package(s) match the published dependency policy` + + ` (${String(roles['client-host']?.length ?? 0)} Client/Host,` + + ` ${String(roles['configured-host']?.length ?? 0)} configured Host).`, + ) + if (fix) { + for (const line of formatManagedRuntimeDependencies(state)) console.log(line) + } +} + +if (import.meta.main) main()