Merge branch 'worktree/forge-pr-worktree-panel' into worktree/forge-session-list

This commit is contained in:
Yichen Jiang committed 2026-10-24 22:03:03 +08:00
commit e4fd8a87ff
32 files changed
+937 -421

No files matched your search

+24 -3
View File
@@ -159,6 +159,19 @@ class Target {
/**
* Validated CLI configuration; construction owns help and parse-error exits.
*/
/** pkg's `--compress` values, whose build cost differs by two orders of magnitude. */
const COMPRESS_TYPES = ['None', 'GZip', 'Brotli', 'Zstd'] as const
type CompressType = typeof COMPRESS_TYPES[number]
/**
* Narrow a raw `--compress` value to a supported pkg compressor.
* @param value - the command-line value.
* @returns whether pkg accepts the value.
*/
function isCompressType(value: string): value is CompressType {
return (COMPRESS_TYPES as readonly string[]).includes(value)
}
class BuildCli {
private constructor(
/** Build targets; defaults to the host platform only. */
@@ -167,6 +180,8 @@ class BuildCli {
readonly skipBuild: boolean,
/** Build through `pnpm run build:artifacts`, which omits the aggregate test and script typechecks. */
readonly buildArtifacts: boolean,
/** pkg archive compression; Brotli is the smallest and by far the slowest. */
readonly compress: CompressType,
/** Print every command and config patch instead of executing. */
readonly dryRun: boolean,
) {}
@@ -205,7 +220,11 @@ class BuildCli {
}
seen.add(key)
}
return new BuildCli(targets, values['skip-build'], values['build-artifacts'], values['dry-run'])
const compress = values.compress ?? 'Brotli'
if (!isCompressType(compress)) {
throw new Error(`build-exe-for-python-sdk: --compress must be one of ${COMPRESS_TYPES.join(' | ')}, got ${JSON.stringify(compress)}.`)
}
return new BuildCli(targets, values['skip-build'], values['build-artifacts'], compress, values['dry-run'])
}
private static parseRaw(argv: string[]) {
@@ -215,6 +234,7 @@ class BuildCli {
'targets': { type: 'string' },
'skip-build': { type: 'boolean', default: false },
'build-artifacts': { type: 'boolean', default: false },
'compress': { type: 'string' },
'dry-run': { type: 'boolean', default: false },
'help': { type: 'boolean', default: false },
},
@@ -229,10 +249,11 @@ class BuildCli {
' Default: the host platform only (on node24).',
' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).',
' --build-artifacts run `pnpm run build:artifacts` instead, omitting the aggregate test and script typechecks.',
' --compress=<type> pkg archive compression: None | GZip | Brotli | Zstd. Default: Brotli (smallest, slowest).',
' --dry-run print every command and config patch without executing.',
' --help print this help.',
'',
'Build route: @yao-pkg/pkg --sea --compress Brotli (root devDependency, pnpm-patched); see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.',
'Build route: @yao-pkg/pkg --sea with the selected --compress type (root devDependency, pnpm-patched); see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.',
`Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
].join('\n')
}
@@ -400,7 +421,7 @@ class SingleExeBuild {
this.staging,
'--sea',
'--compress',
'Brotli',
this.cli.compress,
'--targets',
target.spec,
'--output',
+25 -9
View File
@@ -14,7 +14,7 @@ const runnerPrivatePnpmDestination = /^\$\{\{ runner\.temp \}\}\/setup-pnpm-\$\{
const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}'
describe('CI workflow', () => {
it('keeps at most ten worker jobs active across PR workflows while Python runtime targets follow the SDK suite', () => {
it('keeps at most eleven worker jobs active across PR workflows while Python runtime targets follow the SDK suite', () => {
const ci = loadWorkflow('.github/workflows/ci.yml')
const ciWindows = loadWorkflow('.github/workflows/ci-windows.yml')
const ciPython = loadWorkflow('.github/workflows/ci-python.yml')
@@ -86,10 +86,12 @@ describe('CI workflow', () => {
// Python's runtime targets start only after the SDK suite; Windows starts
// immediately. Plan and wheel stages precede the two-target matrix.
const windowsActive = windowsJobs.length
// The lint lane owns its own runner, so it raises the ceiling by one; the
// self-hosted failover pool is still admitted only up to its own limit.
const beforeSdk = ordinaryJobs.length + compatibilityLimit + windowsActive + 1
const afterSdk = ordinaryJobs.length + compatibilityLimit + windowsActive + Math.max(1, targets.split(',').length)
expect(beforeSdk).toBeLessThanOrEqual(10)
expect(afterSdk).toBeLessThanOrEqual(10)
expect(beforeSdk).toBeLessThanOrEqual(11)
expect(afterSdk).toBeLessThanOrEqual(11)
})
it('keeps the union of required PR verdicts equal to the pre-split blocking set', () => {
@@ -117,6 +119,7 @@ describe('CI workflow', () => {
'node-24-bench',
'node-24-consumers',
'node-24-coverage',
'node-24-lint',
'node-compat',
'python-runtime',
'python-sdk',
@@ -371,13 +374,14 @@ describe('CI workflow', () => {
|| !isRecord(windowsWorkflow.jobs['windows-checks-passed'])
|| !isRecord(workflow.jobs['node-24'])
|| !isRecord(workflow.jobs['node-24-coverage'])
|| !isRecord(workflow.jobs['node-24-lint'])
|| !isRecord(workflow.jobs['node-24-bench'])
|| !isRecord(workflow.jobs['node-24-consumers'])
|| !isRecord(workflow.jobs['node-compat'])
|| !isRecord(workflow.jobs['all-checks-passed'])
|| !isRecord(masterWorkflow.jobs)
|| !isRecord(masterWorkflow.jobs['serial-windows'])) {
throw new TypeError('CI workflow must define node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; CI Windows must define windows-build, windows-coverage, windows-native-tests, and windows-checks-passed; ci-master must define serial-windows')
throw new TypeError('CI workflow must define node-24, node-24-coverage, node-24-lint, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; CI Windows must define windows-build, windows-coverage, windows-native-tests, and windows-checks-passed; ci-master must define serial-windows')
}
const windowsBuild = windowsWorkflow.jobs['windows-build']
@@ -670,12 +674,19 @@ describe('CI workflow', () => {
}
})
it('runs required benchmarks on standard hosted Linux independently of failover', () => {
it('runs required benchmarks on a four-vCPU Blacksmith runner and falls back to hosted Linux', () => {
const workflow = loadWorkflow('.github/workflows/ci.yml')
const benchmark = workflowJob(workflow, 'node-24-bench')
const aggregate = workflowJob(workflow, 'all-checks-passed')
expect(benchmark['runs-on']).toBe('ubuntu-24.04')
// The shared self-hosted pool is not an idle runner, so only the
// Blacksmith leg moves the benchmarks off hosted Linux.
expect(evaluateRunsOn(benchmark['runs-on'], { vars: { DSH_CI_FAILOVER_LINUX: 'blacksmith' } }))
.toBe('blacksmith-4vcpu-ubuntu-2404')
for (const mode of ['', 'selfhosted', 'unexpected']) {
expect(evaluateRunsOn(benchmark['runs-on'], { vars: { DSH_CI_FAILOVER_LINUX: mode } })).toBe('ubuntu-24.04')
}
expect(JSON.stringify(benchmark.steps)).not.toContain('DSH_CI_FAILOVER_LINUX')
expect(benchmark.if).toBe("github.event_name == 'pull_request'")
expect(benchmark.needs).toBeUndefined()
expect(benchmark['continue-on-error']).toBeUndefined()
@@ -1078,7 +1089,7 @@ describe('DeepSeek e2e workflow', () => {
if (!Array.isArray(e2e.steps)) throw new TypeError('DeepSeek e2e workflow must define steps')
const step = e2e.steps.filter(isRecord).find(candidate => candidate.name === 'E2E tests (real DeepSeek API)')
expect(step).toMatchObject({ env: { DSH_E2E_MAX_WORKERS: 4 } })
expect(step).toMatchObject({ env: { DSH_E2E_MAX_WORKERS: 6 } })
})
})
@@ -1575,8 +1586,13 @@ describe('npm release workflows', () => {
isRecord(step) && typeof step.run === 'string' ? [step.run] : [])
expect(Object.keys(workflow.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch'])
expect(commands).toContain('pnpm run verify-package-dependencies')
expect(commands).toContain('pnpm run verify-npm-install-layout')
// Both verifications run from one step so the lane pays their longer half.
const combined = commands.find(command => command.includes('verify-package-dependencies'))
expect(combined).toContain('verify-npm-install-layout')
expect(combined).toContain('wait "$dependencies_pid"')
expect(commands).not.toContain('pnpm run verify-package-dependencies')
expect(commands).not.toContain('pnpm run verify-npm-install-layout')
expect(String(dependencies['runs-on'])).toContain('blacksmith-4vcpu-ubuntu-2404')
})
})
+44
View File
@@ -0,0 +1,44 @@
/** Worker entry: collect the source package uses of one slice of workspace packages. */
import { parentPort, workerData } from 'node:worker_threads'
import { readAllSourceUses } from './source-parse-uses.ts'
import type { PackageDependencyManifest } from './verify-package-dependencies.ts'
/** One package whose `src` tree this worker walks. */
interface SourceUsesTask {
/** Position of the package in the caller's selection, used to restore that order. */
readonly index: number
/** Package directory relative to the repository root. */
readonly dir: string
/** Package name, reported back so the caller can key the result. */
readonly name: string
/** Workspace manifest of the package, which decides @types-versus-runtime usage. */
readonly manifest: PackageDependencyManifest
}
/** Work one worker receives when it starts. */
export interface SourceUsesWorkerRequest {
/** Repository root containing the workspace sources. */
readonly root: string
/** Packages this worker walks, in caller order. */
readonly tasks: readonly SourceUsesTask[]
}
/** Source uses collected for one package. */
export interface SourceUsesWorkerResult {
/** Package name of the analysed package. */
readonly name: string
/** Bare package name to the repository paths whose source references it. */
readonly uses: Map<string, string[]>
}
const request = workerData as SourceUsesWorkerRequest
const results: SourceUsesWorkerResult[] = request.tasks.map(task => ({
name: task.name,
uses: readAllSourceUses(request.root, {
dir: task.dir,
manifestPath: `${task.dir}/package.json`,
manifest: task.manifest,
name: task.name,
}),
}))
parentPort?.postMessage(results)
+25 -5
View File
@@ -10,28 +10,48 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github
env: Record<string, string>
jobs: Record<'preview', {
'runs-on': string
steps: Array<{ name?: string; uses?: string; run?: string; with?: Record<string, unknown>; env?: Record<string, string> }>
steps: Array<{ name?: string; uses?: string; run?: string; if?: string; 'continue-on-error'?: boolean; with?: Record<string, unknown>; env?: Record<string, string> }>
}>
}
const preview = workflow.jobs.preview
describe('PR preview workflow', () => {
it('keeps every PR author on the selected GitHub-hosted runner', () => {
it('keeps every PR author on the selected runner', () => {
expect(Object.keys(workflow.jobs)).toEqual(['preview'])
expect(preview['runs-on']).toBe('ubuntu-24.04')
// The required outcome is the build, so the lane runs on four vCPUs; the
// hosted label stays as the fallback when the Blacksmith pool is not chosen.
expect(preview['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404')
expect(preview['runs-on']).toContain("'ubuntu-24.04'")
expect(preview['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
expect(workflow.on).toEqual({ pull_request: { types: ['opened', 'synchronize', 'reopened'] } })
expect(workflow.permissions).toEqual({ contents: 'read', 'pull-requests': 'write' })
expect(preview.steps.find(step => step.uses === 'actions/[email protected]')?.with).toEqual({ 'persist-credentials': false })
})
it('fails the check only for build failures and warns about publication', () => {
const byName = (name: string) => preview.steps.find(step => step.name === name)
for (const name of ['Build workspace and check aggregate programs', 'Build the preview page and pack the VFS image', 'Shape the upload']) {
expect(byName(name)?.['continue-on-error'], `${name} must stay blocking`).toBeUndefined()
}
for (const name of ['Upload to Cloudflare Pages', 'Verify the protected deployment serves the image', 'Comment the preview URL']) {
expect(byName(name)?.['continue-on-error'], `${name} must warn instead of failing`).toBe(true)
}
const report = byName('Report preview publication failures')
expect(report?.if).toBe("steps.upload.outcome == 'failure' || steps.verify.outcome == 'failure' || steps.comment.outcome == 'failure'")
expect(report?.run).toContain('::warning::')
})
it('keeps the immutable complete artifact build and restore-only dependency cache', () => {
expect(workflow.env.PRIMARY_NODE_VERSION).toBe('24')
expect(workflow.env.DSH_TELEMETRY_DISABLED).toBe('1')
const commands = preview.steps.map(step => step.run)
expect(commands).toContain('pnpm install --frozen-lockfile')
expect(commands).toContain('pnpm run build:artifacts')
expect(commands).toContain('pnpm run build')
expect(commands).toContain('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview')
expect(commands.indexOf('pnpm run build:artifacts')).toBeLessThan(commands.indexOf('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview'))
expect(commands.indexOf('pnpm run build')).toBeLessThan(commands.indexOf('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview'))
// The complete build also runs the aggregate type checks the required
// consumers dropped.
expect(commands).not.toContain('pnpm run build:check')
expect(preview.steps.filter(step => step.uses?.startsWith('actions/cache'))).toHaveLength(1)
expect(preview.steps.find(step => step.uses === 'actions/cache/[email protected]')?.with).toMatchObject({
key: "${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}",
+70
View File
@@ -0,0 +1,70 @@
/** Run the benchmark suite in the two phases its measurements tolerate. */
import { resolve } from 'node:path'
import { runPnpmCommand } from './compile-referenced-projects.ts'
/**
* Cases whose wall-clock endpoints cannot absorb a sibling: they drive a
* browser page, compare animation frames or paced streams, or spawn fresh
* children and budget their startup. Corpus cases tolerate a sibling.
*/
export const SERIAL_BENCH_FILES = [
'benchmarks/long-session-browser/long-session.bench.ts',
'benchmarks/user-text/user-text.bench.client.ts',
'benchmarks/conversation-fold/conversation-fold.bench.client.ts',
'benchmarks/active-stream-reconnect/reconnect.bench.client.ts',
// Fresh-child reopen case: its endpoints budget 63 ms and 120 ms, and a
// sibling process pushed them to 76 ms and 131 ms.
'benchmarks/session-open/session-open.bench.ts',
// Request-history budgets 297 ms against a 212 ms healthy median; a sibling
// pushed it to 342 ms.
'benchmarks/agent-continuation/agent-continuation.bench.ts',
] as const
const CONFIG = ['--config', 'vitest.bench.config.ts'] as const
/**
* Run one phase of the suite.
* @param root - Repository root containing the Vitest configuration.
* @param phase - `first` clears the report and runs the CPU cases two at a time;
* `last` runs the browser cases one at a time and renders the report.
* @throws If the phase fails.
*/
export function runBenchPhase(root: string, phase: 'first' | 'last'): void {
process.env.DSH_BENCH_REPORT_PHASE = phase
try {
if (phase === 'first') {
runPnpmCommand(root, [
'exec', 'vitest', 'run', ...CONFIG,
...SERIAL_BENCH_FILES.map(file => `--exclude=${file}`),
])
return
}
runPnpmCommand(root, [
'exec', 'vitest', 'run', ...CONFIG,
'--no-file-parallelism', '--maxWorkers=1',
...SERIAL_BENCH_FILES,
])
} finally {
delete process.env.DSH_BENCH_REPORT_PHASE
}
}
/**
* Run both phases, so a first-phase failure still reports every later case.
* @param root - Repository root containing the Vitest configuration.
* @returns the process exit code: 1 when either phase failed.
*/
export function runBenchSuite(root: string): number {
let failed = false
for (const phase of ['first', 'last'] as const) {
try {
runBenchPhase(root, phase)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
failed = true
}
}
return failed ? 1 : 0
}
if (import.meta.main) process.exitCode = runBenchSuite(resolve(import.meta.dirname, '..'))
+12 -10
View File
@@ -282,7 +282,7 @@ describe('gate graph validation', () => {
'npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts',
)
expect(scripts['build:native-system']).toBe('tsx native/system/scripts/build.ts --host-addon-only')
expect(scripts['test:bench:built']).toBe('vitest run --config vitest.bench.config.ts')
expect(scripts['test:bench:built']).toBe('tsx scripts/run-bench-suite.ts')
})
it('checks all maintained repository references locally and in CI', () => {
@@ -837,10 +837,12 @@ describe('Typert contract preparation', () => {
it('reuses contracts from the validated consumer build', () => {
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
expect(subject.find(item => item.id === 'lint-and-duplication')).toMatchObject({
displayCommand: 'pnpm run check:ci:lint:contracts-ready',
args: ['/private/pnpm.cjs', 'run', 'check:ci:lint:contracts-ready'],
})
// Lint and duplication run in their own lane; the consumers graph only
// keeps the readers that consume built artifacts.
expect(subject.map(item => item.id)).not.toContain('lint-and-duplication')
expect(withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready'))).toContainEqual(expect.objectContaining({
id: 'lint',
}))
expect(subject.find(item => item.id === 'doc-typecheck')).toMatchObject({
displayCommand: 'pnpm run doc-typecheck:contracts-ready',
args: ['/private/pnpm.cjs', 'run', 'doc-typecheck:contracts-ready'],
@@ -956,7 +958,6 @@ describe('Node 24 lane ownership', () => {
'build',
'node-compat',
'publint',
'lint-and-duplication',
'snapshot',
'expected-output',
'web-snapshot',
@@ -965,13 +966,15 @@ describe('Node 24 lane ownership', () => {
'built-bin-smoke',
])
expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
expect(subject.find(item => item.id === 'build')?.env).toEqual({
DSH_BUILD_CLIENT_PROFILE: 'official',
// The consumers lane emits artifacts only: the aggregate test and script
// type checks belong to the master lanes, not to every pull request.
expect(subject.find(item => item.id === 'build')).toMatchObject({
displayCommand: 'pnpm run build:artifacts',
env: { DSH_BUILD_CLIENT_PROFILE: 'official' },
})
expect(subject.find(item => item.id === 'node-compat')?.env).toEqual({
DSH_BUILD_CLIENT_PROFILE: 'official',
})
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['build'])
for (const id of [
'snapshot',
'expected-output',
@@ -1003,7 +1006,6 @@ describe('Node 24 lane ownership', () => {
env: { DSH_SNAPSHOT: 'replay' },
after: [
'publint',
'lint-and-duplication',
'snapshot',
'expected-output',
'doc-typecheck',
+12 -6
View File
@@ -279,6 +279,17 @@ function ciBuildGate(id = 'build', options: Partial<Gate> = {}): Gate {
})
}
/**
* Emit the artifacts every artifact consumer reads, without the aggregate
* test and script type checks that the master lanes own.
*/
function ciArtifactBuildGate(id = 'build', options: Partial<Gate> = {}): Gate {
return pnpmScript(id, 'build:artifacts', {
...options,
env: { ...options.env, [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' },
})
}
function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate {
return {
id,
@@ -530,7 +541,6 @@ function ciConsumerGates(): Gate[] {
// writer starts; `after` preserves the web diagnostic even if a reader fails.
const buildArtifactReaders = [
'publint',
'lint-and-duplication',
'snapshot',
'expected-output',
'doc-typecheck',
@@ -538,16 +548,12 @@ function ciConsumerGates(): Gate[] {
'built-bin-smoke',
]
return [
ciBuildGate(),
ciArtifactBuildGate(),
pnpmScript('node-compat', 'check:node-compat', {
label: 'Node compatibility',
env: { [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' },
}),
pnpmScript('publint', 'publint', { needs: builtTree }),
pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', {
label: 'lint and duplication',
needs: builtTree,
}),
snapshotGate(builtTree),
expectedOutputGate(builtTree),
webSnapshotGate(builtTree, buildArtifactReaders),
+226
View File
@@ -0,0 +1,226 @@
/** Source-text parsing shared by the package dependency and client package gates. */
import { globSync, readFileSync } from 'node:fs'
import { isBuiltin } from 'node:module'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
import type { PackageDependencyManifest, WorkspacePackageManifest } from './verify-package-dependencies.ts'
export type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies'
export const ROOT_TYPE_PACKAGES: ReadonlySet<string> = new Set(['@types/react', '@types/react-dom'])
/**
* Report whether a specifier names a bare package rather than a path or subpath import.
* @param specifier - module specifier text.
* @returns whether the specifier resolves outside this package.
*/
function isBareSpecifier(specifier: string): boolean {
return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
}
/**
* Read the leading package segments of one bare module specifier.
* @param specifier - module specifier text that already passed {@link isBareSpecifier}.
* @returns the package name this specifier belongs to.
*/
function packageNameForUses(specifier: string): string {
const segments = specifier.split('/')
return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
}
/**
* Read the bare package name of one module specifier.
* @param specifier - module specifier text.
* @returns the package name, or undefined for paths, builtins, and non-package specifiers.
*/
export function packageNameOf(specifier: string): string | undefined {
if (isBuiltin(specifier) || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')
|| specifier.includes(':') || 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.
* @param root - Repository containing the workspace manifests.
* @returns Every workspace package, the non-experimental release packages, and the packages the policy covers.
*/
export function addUse(target: Map<string, string[]>, name: string, path: string): void {
const paths = target.get(name) ?? []
if (!paths.includes(path)) paths.push(path)
target.set(name, paths)
}
export function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
return manifest[name] ?? {}
}
export function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
manifest[name] ??= {}
return manifest[name]
}
export function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
.filter(sectionName => section(manifest, sectionName)[name] !== undefined)
}
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
const clause = node.importClause
if (clause === undefined) return true
if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
const bindings = clause.namedBindings
return clause.name !== undefined
|| bindings === undefined
|| ts.isNamespaceImport(bindings)
|| bindings.elements.length === 0
|| bindings.elements.some(element => !element.isTypeOnly)
}
function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
if (node.isTypeOnly) return false
const clause = node.exportClause
if (clause === undefined || ts.isNamespaceExport(clause)) return true
return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly)
}
export function collectSourceFileUses(
sourceFile: ts.SourceFile,
runtimeOnly: boolean,
key: 'local' | 'package' | 'specifier',
includeRootRelative = false,
): Set<string> {
const uses = new Set<string>()
const add = (specifier: ts.Expression | undefined): void => {
if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return
if (key === 'local') {
if (specifier.text.startsWith('.') || includeRootRelative && specifier.text.startsWith('/')) uses.add(specifier.text)
return
}
if (!isBareSpecifier(specifier.text)) return
uses.add(key === 'package' ? packageNameForUses(specifier.text) : specifier.text)
}
const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node)) {
if (!runtimeOnly || importCarriesRuntimeValue(node)) add(node.moduleSpecifier)
} else if (ts.isExportDeclaration(node)) {
if (!runtimeOnly || exportCarriesRuntimeValue(node)) add(node.moduleSpecifier)
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
if (!runtimeOnly || !node.isTypeOnly) add(node.moduleReference.expression)
} else if (!runtimeOnly && ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
add(node.argument.literal)
} else if (ts.isCallExpression(node)
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
add(node.arguments[0])
} else if (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
add(node.name)
} else if (key !== 'local'
&& (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) {
uses.add('react')
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return uses
}
/**
* Read browser-module declarations from workspace manifests.
* @param root - Absolute repository root.
* @returns Declarations and malformed dsh.client fields.
*/
export function collectSourcePackageUses(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, false, 'package')
}
/**
* Collect bare packages whose values one production source file reaches at runtime.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Bare package names retained by runtime imports, exports, requires, or JSX.
*/
export function collectRuntimeSourcePackageUses(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, true, 'package')
}
/**
* Collect exact bare specifiers retained by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Exact specifiers retained by runtime imports, exports, requires, or JSX.
*/
export function collectRuntimeSourceSpecifiers(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, true, 'specifier')
}
/**
* Collect relative module specifiers used to follow one source entry's local closure.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Relative imports, exports, requires, and import types.
*/
export function collectLocalSourceSpecifiers(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, false, 'local')
}
/**
* Collect local module specifiers retained by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @param includeRootRelative - Include absolute paths, such as Vite's Web-root imports.
* @returns Local imports, exports, and requires that survive compilation.
*/
export function collectRuntimeLocalSourceSpecifiers(path: string, source: string, includeRootRelative = false): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative)
}
/**
* Collect package-asset module specifiers retained by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Relative runtime imports, re-exports, and requires whose extension has a package-asset media type.
*/
/**
* Normalize a repository path to forward slashes.
* @param path - platform path inside the repository.
* @returns the same path with POSIX separators.
*/
export function normalizePath(path: string): string {
return path.split(sep).join('/')
}
export function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
const uses = new Map<string, string[]>()
for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
let runtimeUses: Set<string> | undefined
for (const specifier of collectSourcePackageUses(sourcePath, source)) {
const name = packageNameOf(specifier)
if (name === undefined) continue
const typesName = `@types/${name.replace(/^@/, '').replace('/', '__')}`
if (!ROOT_TYPE_PACKAGES.has(typesName)
&& declaredSections(pkg.manifest, name).length === 0
&& declaredSections(pkg.manifest, typesName).length > 0) {
runtimeUses ??= collectRuntimeSourcePackageUses(sourcePath, source)
if (!runtimeUses.has(name)) {
addUse(uses, typesName, displayPath)
continue
}
}
addUse(uses, name, displayPath)
}
}
return uses
}
+4 -2
View File
@@ -139,8 +139,10 @@ for (const [file, jobIds] of [['release.yml', ['dependencies', 'pack']], ['relea
it('retains the dependency and pack verification commands', () => {
const commands = job.steps.flatMap(step => step.run === undefined ? [] : [step.run])
if (jobId === 'dependencies') {
expect(commands).toContain('pnpm run verify-package-dependencies')
expect(commands).toContain('pnpm run verify-npm-install-layout')
// Both verifications share one step so the lane pays the longer half.
const combined = commands.find(command => command.includes('verify-package-dependencies'))
expect(combined).toContain('verify-npm-install-layout')
expect(combined).toContain('wait "$layout_pid"')
} else {
const family = file === 'release.yml' ? 'dsh' : 'vendor'
const output = family === 'dsh' ? 'dist/npm' : 'dist/npm-vendor'
+8 -127
View File
@@ -15,6 +15,14 @@ import {
} from '../packages/client/modules/src/assets.ts'
import { TypeScriptProject } from './ts-project.ts'
export {
collectLocalSourceSpecifiers,
collectRuntimeLocalSourceSpecifiers,
collectRuntimeSourcePackageUses,
collectRuntimeSourceSpecifiers,
collectSourcePackageUses,
} from './source-parse-uses.ts'
import { collectSourceFileUses } from './source-parse-uses.ts'
const GATE = 'verify-client-packages'
const CLIENT_MANIFEST_GLOB = 'packages/client/*/package.json'
const MANIFEST_GLOBS = ['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json']
@@ -89,62 +97,6 @@ export interface ClientDeclarations {
* @param source - Source text to inspect.
* @returns Bare package names referenced by imports, declarations, or JSX.
*/
export function collectSourcePackageUses(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, false, 'package')
}
/**
* Collect bare packages whose values one production source file reaches at runtime.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Bare package names retained by runtime imports, exports, requires, or JSX.
*/
export function collectRuntimeSourcePackageUses(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, true, 'package')
}
/**
* Collect exact bare specifiers retained by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Exact specifiers retained by runtime imports, exports, requires, or JSX.
*/
export function collectRuntimeSourceSpecifiers(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, true, 'specifier')
}
/**
* Collect relative module specifiers used to follow one source entry's local closure.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Relative imports, exports, requires, and import types.
*/
export function collectLocalSourceSpecifiers(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, false, 'local')
}
/**
* Collect local module specifiers retained by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @param includeRootRelative - Include absolute paths, such as Vite's Web-root imports.
* @returns Local imports, exports, and requires that survive compilation.
*/
export function collectRuntimeLocalSourceSpecifiers(path: string, source: string, includeRootRelative = false): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative)
}
/**
* Collect package-asset module specifiers retained by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Relative runtime imports, re-exports, and requires whose extension has a package-asset media type.
*/
export function collectSourceAssetSpecifiers(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return new Set([...collectSourceFileUses(sourceFile, true, 'local')].filter(isAssetSpecifier))
@@ -227,73 +179,6 @@ export function readStylesheetAssetReferences(
}
}
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
const clause = node.importClause
if (clause === undefined) return true
if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
const bindings = clause.namedBindings
return clause.name !== undefined
|| bindings === undefined
|| ts.isNamespaceImport(bindings)
|| bindings.elements.length === 0
|| bindings.elements.some(element => !element.isTypeOnly)
}
function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
if (node.isTypeOnly) return false
const clause = node.exportClause
if (clause === undefined || ts.isNamespaceExport(clause)) return true
return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly)
}
function collectSourceFileUses(
sourceFile: ts.SourceFile,
runtimeOnly: boolean,
key: 'local' | 'package' | 'specifier',
includeRootRelative = false,
): Set<string> {
const uses = new Set<string>()
const add = (specifier: ts.Expression | undefined): void => {
if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return
if (key === 'local') {
if (specifier.text.startsWith('.') || includeRootRelative && 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 => {
if (ts.isImportDeclaration(node)) {
if (!runtimeOnly || importCarriesRuntimeValue(node)) add(node.moduleSpecifier)
} else if (ts.isExportDeclaration(node)) {
if (!runtimeOnly || exportCarriesRuntimeValue(node)) add(node.moduleSpecifier)
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
if (!runtimeOnly || !node.isTypeOnly) add(node.moduleReference.expression)
} else if (!runtimeOnly && ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
add(node.argument.literal)
} else if (ts.isCallExpression(node)
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
add(node.arguments[0])
} else if (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
add(node.name)
} else if (key !== 'local'
&& (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) {
uses.add('react')
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return uses
}
/**
* Read browser-module declarations from workspace manifests.
* @param root - Absolute repository root.
* @returns Declarations and malformed dsh.client fields.
*/
export function readClientDeclarations(root: string): ClientDeclarations {
const malformed: string[] = []
const declarations = globSync(MANIFEST_GLOBS, { cwd: root })
@@ -853,10 +738,6 @@ function rowPackageOf(specifier: string, rows: ReadonlySet<string>): string | un
return rows.has(stripped) ? stripped : undefined
}
function isBareSpecifier(specifier: string): boolean {
return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+137 -74
View File
@@ -5,9 +5,10 @@
import { spawnSync } from 'node:child_process'
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
import { isBuiltin } from 'node:module'
import { availableParallelism } from 'node:os'
import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Worker } from 'node:worker_threads'
import ts from 'typescript'
import { WorkspaceTypertGenerator } from '../packages/typert/generator/src/workspace.ts'
import { PACKAGE_ASSET_EXPORT } from '../packages/client/modules/src/assets.ts'
@@ -17,11 +18,22 @@ import {
PACKAGE_DEPENDENCY_POLICY,
type PackageDependencyPolicy,
} from './package-dependency-policy.ts'
import type {
SourceUsesWorkerRequest,
SourceUsesWorkerResult,
} from './package-source-uses.worker.ts'
import {
ROOT_TYPE_PACKAGES,
addUse,
normalizePath,
collectRuntimeLocalSourceSpecifiers,
collectRuntimeSourcePackageUses,
collectSourcePackageUses,
} from './verify-client-packages.ts'
declaredSections,
mutableSection,
packageNameOf,
readAllSourceUses,
section,
type DependencySection,
} from './source-parse-uses.ts'
const GATE = 'verify-package-dependencies'
const CORDIS = '@deepseek-ai/cordis'
@@ -29,7 +41,6 @@ const CORDIS = '@deepseek-ai/cordis'
* Type packages declared only in the root devDependencies. Each package-local pnpm link is one more
* location that TypeScript scans when it computes module specifiers for every JSX file.
*/
const ROOT_TYPE_PACKAGES: ReadonlySet<string> = new Set(['@types/react', '@types/react-dom'])
function workspaceRange(name: string): 'workspace:*' | 'workspace:~' {
return name === '@deepseek-ai/dsh' || name.startsWith('@deepseek-ai/dsh-') ? 'workspace:*' : 'workspace:~'
}
@@ -42,9 +53,15 @@ const WORKSPACE_MANIFEST_GLOBS = [
'vendor/*/package.json',
]
type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies'
export type PackageDependencyRole = 'client-only' | 'client-host' | 'configured-host'
/** One workspace package the dependency policy covers, with its resolved role. */
export type SelectedWorkspacePackage = WorkspacePackageManifest & { readonly role: PackageDependencyRole }
/** Upper bound on source-walk workers: each one pays its own TypeScript module load. */
export const MAX_DEPENDENCY_WORKERS = 16
/** Manifest fields read and repaired by the package dependency policy. */
export interface PackageDependencyManifest {
name?: string
@@ -104,24 +121,6 @@ export interface ExpectedPackageDependency {
readonly origins: readonly string[]
}
function normalizePath(path: string): string {
return path.split(sep).join('/')
}
function packageNameOf(specifier: string): string | undefined {
if (isBuiltin(specifier) || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')
|| specifier.includes(':') || 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.
* @param root - Repository containing the workspace manifests.
* @returns Every workspace package, the non-experimental release packages, and the packages the policy covers.
*/
export function readWorkspacePackageManifests(root: string): {
all: WorkspacePackageManifest[]
release: WorkspacePackageManifest[]
@@ -211,12 +210,6 @@ export function discoverPackageDependencyScope(
}
}
function addUse(target: Map<string, string[]>, name: string, path: string): void {
const paths = target.get(name) ?? []
if (!paths.includes(path)) paths.push(path)
target.set(name, paths)
}
const NAMESPACE_RUNTIME_EXPORT = '*'
const SIDE_EFFECT_RUNTIME_EXPORT = '(side effect)'
@@ -519,31 +512,6 @@ function readHostRuntimeUses(
}
}
function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
const uses = new Map<string, string[]>()
for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
let runtimeUses: Set<string> | undefined
for (const specifier of collectSourcePackageUses(sourcePath, source)) {
const name = packageNameOf(specifier)
if (name === undefined) continue
const typesName = `@types/${name.replace(/^@/, '').replace('/', '__')}`
if (!ROOT_TYPE_PACKAGES.has(typesName)
&& declaredSections(pkg.manifest, name).length === 0
&& declaredSections(pkg.manifest, typesName).length > 0) {
runtimeUses ??= collectRuntimeSourcePackageUses(sourcePath, source)
if (!runtimeUses.has(name)) {
addUse(uses, typesName, displayPath)
continue
}
}
addUse(uses, name, displayPath)
}
}
return uses
}
/**
* Read authored and generated source usage for one already-classified package.
* @param root - Repository containing source files and face tsconfigs.
@@ -562,13 +530,14 @@ export async function readPackageDependencyFacts(
workspaceNames: ReadonlySet<string>,
policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
generatedHostSource?: string,
precomputedSourceUses?: Map<string, string[]>,
): Promise<PackageDependencyFacts> {
const inject = pkg.manifest.dsh?.client?.inject ?? []
const hostRuntime = role === 'client-only'
? { packageUses: new Map<string, string[]>(), exportUses: [] }
: readHostRuntimeUses(root, pkg, await readHostBuildEntries(root, pkg), generatedHostSource)
const servicePeers = policy.requiredServicePeers?.[pkg.name] ?? []
const allSourceUses = readAllSourceUses(root, pkg)
const allSourceUses = precomputedSourceUses ?? readAllSourceUses(root, pkg)
for (const name of servicePeers) {
if (!workspaceNames.has(name) || !allSourceUses.has(name)) {
throw new Error(`${pkg.manifestPath}: requiredServicePeers must name a referenced workspace package: ${name}`)
@@ -676,10 +645,7 @@ export async function readPackageDependencyState(
const workspaceNames = new Set(packages.all.map(pkg => pkg.name))
const discovered = discoverPackageDependencyScope(packages.policy, policy)
const generated = generatedHostSources(root, discovered.selected.filter(pkg => pkg.role !== 'client-only'))
const facts: PackageDependencyFacts[] = []
for (const pkg of discovered.selected) {
facts.push(await readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy, generated.get(pkg.name)))
}
const facts = await readPackageDependencyFactsConcurrently(root, discovered.selected, workspaceNames, policy, generated)
const selectedNames = new Set(facts.map(fact => fact.manifest.name))
return {
facts,
@@ -696,6 +662,117 @@ export async function readPackageDependencyState(
}
}
/**
* Read dependency facts for every selected package, walking package sources in parallel.
*
* Walking a package's `src` tree with the TypeScript parser is the expensive part of
* this gate and shares no state across packages, so those walks run in worker threads
* while the caller's package order is preserved for the policy checks.
* @param root - Repository containing the workspace sources and build configs.
* @param packages - Selected packages, in policy order.
* @param workspaceNames - Identities of every workspace package.
* @param policy - Reviewed scope overrides and Host export classifications.
* @param generated - Host modules the batched Typert pass emitted in memory, keyed by package name.
* @param workers - Worker count for the source walk; defaults to the host CPU count, capped at {@link MAX_DEPENDENCY_WORKERS}.
* @returns Facts in the same order as `packages`.
*/
export async function readPackageDependencyFactsConcurrently(
root: string,
packages: readonly SelectedWorkspacePackage[],
workspaceNames: ReadonlySet<string>,
policy: PackageDependencyPolicy,
generated: ReadonlyMap<string, string>,
workers = Math.min(availableParallelism(), packages.length, MAX_DEPENDENCY_WORKERS),
): Promise<PackageDependencyFacts[]> {
const sourceUses = await readSourceUses(root, packages, workers)
const facts: PackageDependencyFacts[] = []
for (const pkg of packages) {
facts.push(await readPackageDependencyFacts(
root,
pkg,
pkg.role,
workspaceNames,
policy,
generated.get(pkg.name),
sourceUses.get(pkg.dir),
))
}
return facts
}
/** Source uses per package directory, collected either in-process or by worker threads. */
async function readSourceUses(
root: string,
packages: readonly SelectedWorkspacePackage[],
workers: number,
): Promise<Map<string, Map<string, string[]>>> {
if (workers < 2 || packages.length < 2) {
return new Map(packages.map(pkg => [pkg.dir, readAllSourceUses(root, pkg)]))
}
const sizes = sliceSizes(packages.length, workers)
const tasks = packages.map((pkg, index) => ({ index, dir: pkg.dir, name: pkg.name, manifest: pkg.manifest }))
const slices: typeof tasks[] = []
let offset = 0
for (const size of sizes) {
slices.push(tasks.slice(offset, offset + size))
offset += size
}
const collected = (await Promise.all(slices.map(async slice => runSourceUsesWorker(root, slice)))).flat()
const byName = new Map(collected.map(result => [result.name, result.uses]))
return new Map(packages.map(pkg => [pkg.dir, byName.get(pkg.name) ?? new Map<string, string[]>()]))
}
/**
* Run one slice of packages in a worker thread.
* @param root - Repository containing the workspace sources.
* @param tasks - Packages this worker walks.
* @returns one result per task.
* @throws If the worker crashes or exits before reporting.
*/
function runSourceUsesWorker(root: string, tasks: readonly SourceUsesTaskForWorker[]): Promise<SourceUsesWorkerResult[]> {
const request: SourceUsesWorkerRequest = { root, tasks }
const url = new URL('./package-source-uses.worker.ts', import.meta.url)
return new Promise((resolveResults, rejectResults) => {
const worker = new Worker(url, { workerData: request })
let settled = false
worker.once('message', (results: SourceUsesWorkerResult[]) => {
settled = true
void worker.terminate()
resolveResults(results)
})
worker.once('error', (error) => {
if (settled) return
settled = true
rejectResults(error)
})
worker.once('exit', (code) => {
if (settled) return
settled = true
rejectResults(new Error(`${GATE}: source-uses worker exited with ${String(code)} before reporting`))
})
})
}
interface SourceUsesTaskForWorker {
readonly index: number
readonly dir: string
readonly name: string
readonly manifest: PackageDependencyManifest
}
/**
* Distribute a task count over a worker count as evenly as possible.
* @param tasks - Number of tasks to place.
* @param workers - Number of workers that must stay busy.
* @returns One slice size per non-empty slice.
*/
export function sliceSizes(tasks: number, workers: number): number[] {
const slices = Math.min(tasks, Math.max(1, workers))
const base = Math.floor(tasks / slices)
const remainder = tasks % slices
return Array.from({ length: slices }, (_, index) => base + (index < remainder ? 1 : 0))
}
/** Derive the required npm section for each relationship owned by the policy. */
export function expectedPackageDependencies(
facts: PackageDependencyFacts,
@@ -790,20 +867,6 @@ export function formatPeerRequiredRuntimeDependencies(state: PackageDependencySt
]
}
function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
return manifest[name] ?? {}
}
function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
manifest[name] ??= {}
return manifest[name]
}
function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
.filter(sectionName => section(manifest, sectionName)[name] !== undefined)
}
function describeSections(sections: readonly DependencySection[]): string {
return sections.length === 0 ? 'no dependency section' : sections.join(' + ')
}