Merge branch 'master' into worktree/python-sdk-model-visible-assertions
This commit is contained in:
@@ -234,9 +234,9 @@ def verify_wheel(
|
||||
raise RuntimeError(
|
||||
f"{wheel} has distribution name {metadata.get('Name')}, expected {expected_distribution}"
|
||||
)
|
||||
if metadata.get("License-Expression") != "BSD-3-Clause":
|
||||
if metadata.get("License-Expression") != "MIT":
|
||||
raise RuntimeError(
|
||||
f"{wheel} has license expression {metadata.get('License-Expression')}, expected BSD-3-Clause"
|
||||
f"{wheel} has license expression {metadata.get('License-Expression')}, expected MIT"
|
||||
)
|
||||
expected_license_files = ["LICENSE"] if package == "sdk" else ["LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||
license_files = [Path(name).name for name in metadata.get_all("License-File") or []]
|
||||
|
||||
@@ -231,8 +231,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
if (manifest.private === true) {
|
||||
errors.push(`${label}: published Landlock package must not set "private": true`)
|
||||
}
|
||||
if (manifest.publishConfig?.access !== 'restricted') {
|
||||
errors.push(`${label}: published Landlock package must set publishConfig.access to "restricted"`)
|
||||
if (manifest.publishConfig?.access !== 'public') {
|
||||
errors.push(`${label}: published Landlock package must set publishConfig.access to "public"`)
|
||||
}
|
||||
const expectedDirectory = dir
|
||||
if (manifest.repository?.type !== 'git'
|
||||
@@ -242,13 +242,20 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
}
|
||||
} else if (releaseMemberDirectory.test(dir)) {
|
||||
// Release members state that they are publishable: npm refuses a private
|
||||
// package, the scope is published privately, and the repository field is
|
||||
// how a consumer of a private package finds its source.
|
||||
// package, and the repository field is how a consumer finds the source of
|
||||
// the package it installed.
|
||||
//
|
||||
// Access is per release sequence, not per scope: the vendored framework and
|
||||
// the Landlock packages publish publicly because outside consumers install
|
||||
// them, while the dsh family stays restricted until its own sequence goes
|
||||
// public. A mixed scope is why no publish path passes `--access` — one flag
|
||||
// cannot serve both, so each packed manifest decides
|
||||
// ([rationale](../.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.md)).
|
||||
if (manifest.private === true) {
|
||||
errors.push(`${label}: release member must not set "private": true`)
|
||||
}
|
||||
if (manifest.publishConfig?.access !== 'restricted') {
|
||||
errors.push(`${label}: release member must set publishConfig.access to "restricted"`)
|
||||
if (manifest.publishConfig?.access !== 'public') {
|
||||
errors.push(`${label}: release member must set publishConfig.access to "public"`)
|
||||
}
|
||||
if (manifest.repository?.type !== 'git'
|
||||
|| manifest.repository.url !== publishedRepositoryUrl
|
||||
|
||||
@@ -34,14 +34,20 @@ describe('CI workflow', () => {
|
||||
|| !isRecord(workflow.jobs['windows-native'])
|
||||
|| !isRecord(workflow.jobs['wine-apt-cache'])
|
||||
|| !isRecord(workflow.jobs['serial-windows'])
|
||||
|| !isRecord(workflow.jobs['node-24'])
|
||||
|| !isRecord(workflow.jobs['node-24-coverage'])
|
||||
|| !isRecord(workflow.jobs['node-24-consumers'])
|
||||
|| !isRecord(workflow.jobs['all-checks-passed'])) {
|
||||
throw new TypeError('CI workflow must define windows, windows-native, wine-apt-cache, serial-windows, and all-checks-passed jobs')
|
||||
throw new TypeError('CI workflow must define windows, windows-native, wine-apt-cache, serial-windows, node-24, node-24-coverage, node-24-consumers, and all-checks-passed jobs')
|
||||
}
|
||||
|
||||
const windows = workflow.jobs.windows
|
||||
const windowsNative = workflow.jobs['windows-native']
|
||||
const wineAptCache = workflow.jobs['wine-apt-cache']
|
||||
const serialWindows = workflow.jobs['serial-windows']
|
||||
const node24 = workflow.jobs['node-24']
|
||||
const node24Coverage = workflow.jobs['node-24-coverage']
|
||||
const node24Consumers = workflow.jobs['node-24-consumers']
|
||||
const aggregate = workflow.jobs['all-checks-passed']
|
||||
if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) {
|
||||
throw new TypeError('Windows job must define steps and the aggregate must define needs')
|
||||
@@ -57,8 +63,10 @@ describe('CI workflow', () => {
|
||||
expect(commandSteps.some(step => step.run.includes('wine-windows-gates.sh'))).toBe(true)
|
||||
|
||||
// windows-native: non-blocking native job with failover, runs windows-complete.
|
||||
// Its pool is resolved by the Windows-specific switch.
|
||||
expect(typeof windowsNative['runs-on']).toBe('string')
|
||||
expect(windowsNative['runs-on']).toContain('DSH_CI_FAILOVER')
|
||||
expect(windowsNative['runs-on']).toContain('DSH_CI_FAILOVER_WINDOWS')
|
||||
expect(windowsNative['runs-on']).not.toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(windowsNative['runs-on']).toContain('self-hosted')
|
||||
expect(windowsNative['runs-on']).toContain('dsh-win-ci')
|
||||
expect(windowsNative['runs-on']).toContain('dsh-windows-2025-16core')
|
||||
@@ -82,6 +90,19 @@ describe('CI workflow', () => {
|
||||
expect(aggregate.needs).toContain('windows')
|
||||
expect(aggregate.needs).not.toContain('windows-native')
|
||||
expect(aggregate.needs).not.toContain('serial-windows')
|
||||
|
||||
// Linux failover is a separate switch: the three required Linux workers
|
||||
// and the verdict job resolve their pool through DSH_CI_FAILOVER_LINUX,
|
||||
// never the Windows switch.
|
||||
for (const [jobName, job] of [['node-24', node24], ['node-24-coverage', node24Coverage], ['node-24-consumers', node24Consumers]] as const) {
|
||||
expect(typeof job['runs-on']).toBe('string')
|
||||
expect(job['runs-on'], `${jobName} runs-on must use the Linux failover switch`).toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(job['runs-on'], `${jobName} runs-on must not use the Windows failover switch`).not.toContain('DSH_CI_FAILOVER_WINDOWS')
|
||||
expect(job['runs-on']).toContain('vm-backup')
|
||||
}
|
||||
expect(aggregate['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(aggregate['runs-on']).not.toContain('DSH_CI_FAILOVER_WINDOWS')
|
||||
expect(aggregate['runs-on']).toContain('vm-backup')
|
||||
})
|
||||
|
||||
it('exempts push from cancellation, so one master merge does not cancel the running drill', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { dirname, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { LINK_MAP } from './gen-cordis-catalog.ts'
|
||||
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
|
||||
import { githubSlug } from './verify-md-links.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/config-catalog.md'
|
||||
@@ -766,11 +767,6 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
|
||||
return entries.sort((a, b) => a.pkg.localeCompare(b.pkg))
|
||||
}
|
||||
|
||||
/** GitHub-style anchor slug for a `## \`pkg\`` heading. */
|
||||
function slug(heading: string): string {
|
||||
return heading.toLowerCase().replace(/[^a-z0-9 -]/g, '').replace(/ /g, '-')
|
||||
}
|
||||
|
||||
/** Render the `Requires:` service-key line, or '' when the plugin injects nothing. */
|
||||
function requiresLine(inject: string[]): string {
|
||||
return inject.length ? `Requires: ${inject.map(k => `\`${k}\``).join(' · ')}` : ''
|
||||
@@ -782,7 +778,7 @@ function requiresLine(inject: string[]): string {
|
||||
function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
|
||||
const target = byName.get(ref.specifier)
|
||||
if (target?.kind === 'config' && ref.imported === target.configTypeName) {
|
||||
return `[\`${ref.alias}\`](#${slug(target.pkg)})`
|
||||
return `[\`${ref.alias}\`](#${githubSlug(target.pkg)})`
|
||||
}
|
||||
const page = LINK_MAP[ref.imported]
|
||||
if (page) return `[\`${ref.alias}\`](subsystems/${page})`
|
||||
@@ -792,7 +788,7 @@ function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
|
||||
|
||||
/** Render one configurable plugin's section. */
|
||||
function renderConfigEntry(entry: CatalogEntry, byName: Map<string, CatalogEntry>): string[] {
|
||||
const out = [`## \`${entry.pkg}\``, '']
|
||||
const out = [`<a id="${githubSlug(entry.pkg)}"></a>`, '', `## \`${entry.pkg}\``, '']
|
||||
const requires = requiresLine(entry.inject)
|
||||
if (requires) out.push(requires, '')
|
||||
out.push('```' + FENCE, ...(entry.pastes ?? []).map(p => p.text).join('\n\n').split('\n'), '```', '')
|
||||
|
||||
@@ -10,6 +10,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
import { githubSlug } from './verify-md-links.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/persistence-catalog.md'
|
||||
@@ -341,7 +342,8 @@ function typeLinks(payload: string): string {
|
||||
|
||||
/** Render one log event entry. */
|
||||
function renderEvent(e: AnnotatedLogEventEntry): string[] {
|
||||
const out = [`#### \`${e.name}\` — ${e.surface ? 'surface' : 'log-only'}`, '']
|
||||
const heading = `${e.name} — ${e.surface ? 'surface' : 'log-only'}`
|
||||
const out = [`<a id="${githubSlug(heading)}"></a>`, '', `#### \`${e.name}\` — ${e.surface ? 'surface' : 'log-only'}`, '']
|
||||
out.push('```' + FENCE, e.declaration, '```', '')
|
||||
const links = typeLinks(e.payload)
|
||||
if (links) out.push(links, '')
|
||||
|
||||
@@ -691,7 +691,7 @@ export function render(): string {
|
||||
|
||||
# Third-Party Notices
|
||||
|
||||
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
|
||||
DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
|
||||
|
||||
This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
|
||||
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread'
|
||||
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
|
||||
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
import { githubSlug } from './verify-md-links.ts'
|
||||
|
||||
/** Attachment seam marker that makes the attachments-conditional `read_image` schema harvestable. */
|
||||
class CatalogAttachmentStore extends AttachmentStore {
|
||||
@@ -704,7 +705,7 @@ export function render(catalog: ToolCatalog): string {
|
||||
'',
|
||||
]
|
||||
for (const entry of catalog) {
|
||||
lines.push(`## \`${entry.pkg}\``, '')
|
||||
lines.push(`<a id="${githubSlug(entry.pkg)}"></a>`, '', `## \`${entry.pkg}\``, '')
|
||||
for (const schema of entry.schemas) {
|
||||
// Collection validated that every harvested schema has a source.
|
||||
const source = entry.sources[schema.name] as string
|
||||
|
||||
@@ -16,6 +16,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { removeFixtureSafely, unlinkFixtureLinks } from './test-fixture-cleanup.ts'
|
||||
|
||||
const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
|
||||
const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B %P'
|
||||
@@ -40,7 +41,7 @@ interface CommandResult {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
|
||||
for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture)
|
||||
})
|
||||
|
||||
function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult {
|
||||
@@ -282,6 +283,10 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1)
|
||||
|
||||
const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
|
||||
// Windows Git follows the fixture's MOUNT_POINT junctions into their real
|
||||
// targets while removing a worktree; unlink them first so the removal
|
||||
// cannot delete the repository's scripts/ or tsx package.
|
||||
unlinkFixtureLinks(fixture.linked)
|
||||
git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked])
|
||||
expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval)
|
||||
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
|
||||
|
||||
@@ -7,7 +7,7 @@ import { basename, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts'
|
||||
import {
|
||||
addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown,
|
||||
addProjectionFrontmatter, projectedPageContent, publishableImage, resolveRepositoryRef, rewriteMarkdown,
|
||||
} from './project-doc-site.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -91,6 +91,16 @@ describe('publishableImage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRepositoryRef', () => {
|
||||
it('defaults to public master instead of a private workflow SHA', () => {
|
||||
expect(resolveRepositoryRef({ GITHUB_SHA: 'private-sha' })).toBe('master')
|
||||
})
|
||||
|
||||
it('accepts an explicit public repository ref', () => {
|
||||
expect(resolveRepositoryRef({ DOCS_REPOSITORY_REF: 'public-sha' })).toBe('public-sha')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteMarkdown', () => {
|
||||
it('maps published pages and pins unpublished source links', () => {
|
||||
const { root, pages } = fixture()
|
||||
|
||||
@@ -19,6 +19,16 @@ const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const generatedRoot = resolve(root, 'website/.generated')
|
||||
|
||||
/**
|
||||
* Resolve the public repository ref used by projected source links.
|
||||
*
|
||||
* @param environment Build environment containing an optional explicit public ref.
|
||||
* @returns The configured public ref, or `master`.
|
||||
*/
|
||||
export function resolveRepositoryRef(environment: NodeJS.ProcessEnv): string {
|
||||
return environment.DOCS_REPOSITORY_REF ?? 'master'
|
||||
}
|
||||
|
||||
interface Replacement {
|
||||
start: number
|
||||
end: number
|
||||
@@ -400,7 +410,7 @@ export function projectDocs(): void {
|
||||
const routes = new Set<string>()
|
||||
/** Projected path to the repository file that claimed it, pages and images alike. */
|
||||
const claimed = new Map<string, string>()
|
||||
const repositoryRef = process.env.GITHUB_SHA ?? 'master'
|
||||
const repositoryRef = resolveRepositoryRef(process.env)
|
||||
rmSync(generatedRoot, { recursive: true, force: true })
|
||||
|
||||
/** Reserve one projected path, refusing a second source for it. */
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('release families', () => {
|
||||
member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
|
||||
]
|
||||
|
||||
expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([
|
||||
expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([
|
||||
'@deepseek-ai/dsh-library',
|
||||
'@deepseek-ai/dsh-consumer',
|
||||
'@deepseek-ai/dsh-zebra',
|
||||
@@ -74,6 +74,92 @@ describe('release families', () => {
|
||||
expect(() => { dsh.publishOrder(members) }).toThrow(/dependency cycle/)
|
||||
})
|
||||
|
||||
it('publishes a peer before its consumer', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [
|
||||
member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { peerDependencies: { '@deepseek-ai/dsh-zebra': 'workspace:^' } }),
|
||||
member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
|
||||
]
|
||||
|
||||
// Name order alone would place the consumer first; the peer edge moves it.
|
||||
expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([
|
||||
'@deepseek-ai/dsh-zebra',
|
||||
'@deepseek-ai/dsh-consumer',
|
||||
])
|
||||
})
|
||||
|
||||
it('orders around a peer cycle rather than refusing to publish, and reports the edge it dropped', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [
|
||||
member('packages/a/left', '@deepseek-ai/dsh-left', { peerDependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }),
|
||||
member('packages/a/right', '@deepseek-ai/dsh-right', { peerDependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }),
|
||||
]
|
||||
|
||||
// Sibling packages declare each other as peers, and npm treats an unmet peer
|
||||
// as a warning, so this pair has to publish rather than fail the release.
|
||||
const plan = dsh.publishOrder(members)
|
||||
expect(plan.order.map(entry => entry.name)).toEqual([
|
||||
'@deepseek-ai/dsh-right',
|
||||
'@deepseek-ai/dsh-left',
|
||||
])
|
||||
// One of the two edges has to give, and which one it is belongs in the log.
|
||||
expect(plan.droppedPeerEdges).toEqual([
|
||||
{ consumer: '@deepseek-ai/dsh-right', peer: '@deepseek-ai/dsh-left' },
|
||||
])
|
||||
})
|
||||
|
||||
it('honours an install edge even when a peer cycle surrounds it', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [
|
||||
member('packages/a/base', '@deepseek-ai/dsh-base', { peerDependencies: { '@deepseek-ai/dsh-consumer': 'workspace:^' } }),
|
||||
member('packages/a/consumer', '@deepseek-ai/dsh-consumer', {
|
||||
dependencies: { '@deepseek-ai/dsh-base': 'workspace:^' },
|
||||
peerDependencies: { '@deepseek-ai/dsh-base': 'workspace:^' },
|
||||
}),
|
||||
]
|
||||
|
||||
// The install edge is absolute: base publishes first, and the peer edge that
|
||||
// would reverse it is the one dropped.
|
||||
const plan = dsh.publishOrder(members)
|
||||
expect(plan.order.map(entry => entry.name)).toEqual([
|
||||
'@deepseek-ai/dsh-base',
|
||||
'@deepseek-ai/dsh-consumer',
|
||||
])
|
||||
expect(plan.droppedPeerEdges).toEqual([
|
||||
{ consumer: '@deepseek-ai/dsh-base', peer: '@deepseek-ai/dsh-consumer' },
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses an order that would publish a consumer before a dependency it installs', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [
|
||||
member('packages/a/alpha', '@deepseek-ai/dsh-alpha', { peerDependencies: { '@deepseek-ai/dsh-bravo': 'workspace:^' } }),
|
||||
member('packages/a/bravo', '@deepseek-ai/dsh-bravo', { peerDependencies: { '@deepseek-ai/dsh-charlie': 'workspace:^' } }),
|
||||
member('packages/a/charlie', '@deepseek-ai/dsh-charlie', { dependencies: { '@deepseek-ai/dsh-alpha': 'workspace:^' } }),
|
||||
]
|
||||
|
||||
// A cycle of two peer edges closed by one install edge: dropping a peer edge
|
||||
// would order this, and the traversal drops the install edge instead. That
|
||||
// order would publish charlie before the alpha it installs, so it is refused
|
||||
// here rather than published.
|
||||
expect(() => { dsh.publishOrder(members) }).toThrow(/no publish order honours @deepseek-ai\/dsh-charlie -> @deepseek-ai\/dsh-alpha/)
|
||||
})
|
||||
|
||||
it('ignores devDependencies when ordering', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [
|
||||
member('packages/a/alpha', '@deepseek-ai/dsh-alpha', { devDependencies: { '@deepseek-ai/dsh-zebra': 'workspace:^' } }),
|
||||
member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
|
||||
]
|
||||
|
||||
// A dev dependency is absent from the published package, so it must not move
|
||||
// the consumer behind it.
|
||||
expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([
|
||||
'@deepseek-ai/dsh-alpha',
|
||||
'@deepseek-ai/dsh-zebra',
|
||||
])
|
||||
})
|
||||
|
||||
it('applies the harness payload policy to dsh and keeps upstream payloads for vendored packages', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const vendor = releaseFamily('vendor')
|
||||
|
||||
@@ -13,12 +13,47 @@ import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { validateTarballPayload } from '../publication-payload.ts'
|
||||
|
||||
/** Dependency sections that constrain publish order: a consumer must publish after its dependency. */
|
||||
const ORDER_SECTIONS = ['dependencies', 'optionalDependencies'] as const
|
||||
/**
|
||||
* Dependency sections a consumer must publish after, because npm resolves them
|
||||
* when the package is installed: publishing a consumer first would leave a
|
||||
* window where its own tree cannot be assembled.
|
||||
*/
|
||||
const INSTALL_SECTIONS = ['dependencies', 'optionalDependencies'] as const
|
||||
|
||||
/**
|
||||
* Peer declarations also order the publication, but they cannot constrain it.
|
||||
* npm never installs a peer on the package's behalf — an unmet peer is a
|
||||
* warning, not a resolution failure — and sibling packages legitimately declare
|
||||
* each other as peers, which makes these edges the ones that close cycles. They
|
||||
* order what they can and are dropped where they would deadlock.
|
||||
*/
|
||||
const PEER_SECTIONS = ['peerDependencies'] as const
|
||||
|
||||
/** The workspace root manifest, which is never a release member. */
|
||||
const WORKSPACE_ROOT_PACKAGE = '@deepseek-ai/dsh-root'
|
||||
|
||||
/** One peer declaration the publish order leaves unordered. */
|
||||
interface DroppedPeerEdge {
|
||||
/** Package declaring the peer. */
|
||||
readonly consumer: string
|
||||
/** The declared peer, which publishes after `consumer` or alongside it in a cycle. */
|
||||
readonly peer: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A family's publish order together with the ordering it could not honour.
|
||||
*
|
||||
* The dropped edges are part of the result rather than a detail of forming it:
|
||||
* a release drops real ordering constraints, and the operator reading the pack
|
||||
* log is the only one who can judge whether a newly dropped edge is expected.
|
||||
*/
|
||||
export interface PublishPlan {
|
||||
/** Members in publish order. */
|
||||
readonly order: readonly ReleaseMember[]
|
||||
/** Peer declarations left unordered, in the order the traversal reached them. */
|
||||
readonly droppedPeerEdges: readonly DroppedPeerEdge[]
|
||||
}
|
||||
|
||||
/** One publishable package of a release family. */
|
||||
export interface ReleaseMember {
|
||||
/** Repository-relative package directory, for example `packages/core/session`. */
|
||||
@@ -107,45 +142,122 @@ export abstract class ReleaseFamily {
|
||||
}
|
||||
|
||||
/**
|
||||
* Order members so every package publishes after the family members it depends on.
|
||||
* Order members so every package publishes after the family members it
|
||||
* depends on, which is what makes a partial publication self-consistent: an
|
||||
* interrupted run leaves a prefix whose packages never point at something
|
||||
* absent from the registry.
|
||||
*
|
||||
* Install edges are honoured absolutely — a cycle among them is a defect this
|
||||
* reports rather than works around. Peer edges order what they can and are
|
||||
* dropped where honouring one would deadlock: sibling packages declare each
|
||||
* other as peers, and npm treats an unmet peer as a warning rather than a
|
||||
* resolution failure ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
* Every dropped edge is reported, because dropping one is a decision about a
|
||||
* real release rather than an implementation detail.
|
||||
* @param members - this family's members.
|
||||
* @returns The same members in publish order; ties break by name for determinism.
|
||||
* @returns The order, ties broken by name for determinism, and the peer edges it left unordered.
|
||||
*/
|
||||
publishOrder(members: readonly ReleaseMember[]): ReleaseMember[] {
|
||||
publishOrder(members: readonly ReleaseMember[]): PublishPlan {
|
||||
const byName = new Map(members.map(member => [member.name, member]))
|
||||
const ordered: ReleaseMember[] = []
|
||||
const placed = new Set<string>()
|
||||
const visiting = new Set<string>()
|
||||
const byNameSorted = [...members].sort((left, right) => left.name.localeCompare(right.name))
|
||||
const edges = (member: ReleaseMember, sections: readonly string[]): ReleaseMember[] =>
|
||||
this.orderEdges(member, byName, sections)
|
||||
|
||||
const visit = (member: ReleaseMember, path: readonly string[]): void => {
|
||||
if (placed.has(member.name)) return
|
||||
if (visiting.has(member.name)) {
|
||||
// Install edges alone must be acyclic, and that is checked on its own graph:
|
||||
// a peer edge leading into an install edge would otherwise read as a cycle
|
||||
// where the install edges are perfectly orderable.
|
||||
const installVisiting = new Set<string>()
|
||||
const installDone = new Set<string>()
|
||||
const checkInstall = (member: ReleaseMember, path: readonly string[]): void => {
|
||||
if (installDone.has(member.name)) return
|
||||
if (installVisiting.has(member.name)) {
|
||||
throw new Error(`dependency cycle in release family ${this.id}: ${[...path, member.name].join(' -> ')}`)
|
||||
}
|
||||
visiting.add(member.name)
|
||||
for (const dependency of this.orderEdges(member, byName)) {
|
||||
visit(dependency, [...path, member.name])
|
||||
installVisiting.add(member.name)
|
||||
for (const dependency of edges(member, INSTALL_SECTIONS)) checkInstall(dependency, [...path, member.name])
|
||||
installVisiting.delete(member.name)
|
||||
installDone.add(member.name)
|
||||
}
|
||||
for (const member of byNameSorted) checkInstall(member, [])
|
||||
|
||||
// Emit the order over both kinds of edge. A node already on the stack closes
|
||||
// a cycle, and that cycle carries at least one peer edge because the install
|
||||
// edges were just proved acyclic — but the back edge that reaches the stacked
|
||||
// node is not necessarily the peer one, so the post-condition below decides
|
||||
// whether the emitted order survived.
|
||||
const ordered: ReleaseMember[] = []
|
||||
const droppedPeerEdges: DroppedPeerEdge[] = []
|
||||
const placed = new Set<string>()
|
||||
const onStack = new Set<string>()
|
||||
// Members reachable from one member through install edges. A peer edge is
|
||||
// dropped when the peer installs the member declaring it: honouring it would
|
||||
// emit a package before something it installs, and the install edge wins.
|
||||
const installClosure = (member: ReleaseMember): Set<string> => {
|
||||
const reached = new Set<string>()
|
||||
const walk = (current: ReleaseMember): void => {
|
||||
for (const dependency of edges(current, INSTALL_SECTIONS)) {
|
||||
if (reached.has(dependency.name)) continue
|
||||
reached.add(dependency.name)
|
||||
walk(dependency)
|
||||
}
|
||||
}
|
||||
visiting.delete(member.name)
|
||||
walk(member)
|
||||
return reached
|
||||
}
|
||||
const visit = (member: ReleaseMember): void => {
|
||||
if (placed.has(member.name) || onStack.has(member.name)) return
|
||||
onStack.add(member.name)
|
||||
for (const dependency of edges(member, INSTALL_SECTIONS)) visit(dependency)
|
||||
for (const peer of edges(member, PEER_SECTIONS)) {
|
||||
if (installClosure(peer).has(member.name)) {
|
||||
droppedPeerEdges.push({ consumer: member.name, peer: peer.name })
|
||||
continue
|
||||
}
|
||||
// A peer already on the stack is an ancestor, so it publishes after this
|
||||
// member rather than before it: the edge is dropped, not honoured.
|
||||
if (onStack.has(peer.name)) droppedPeerEdges.push({ consumer: member.name, peer: peer.name })
|
||||
visit(peer)
|
||||
}
|
||||
onStack.delete(member.name)
|
||||
placed.add(member.name)
|
||||
ordered.push(member)
|
||||
}
|
||||
for (const member of byNameSorted) visit(member)
|
||||
|
||||
for (const member of [...members].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
visit(member, [])
|
||||
// A cycle mixing both kinds of edge can put an install edge's target on the
|
||||
// stack, where the traversal skips it like a peer edge and emits a consumer
|
||||
// before something it installs. Nothing downstream can detect that, and it
|
||||
// would only surface as an unresolvable install for whoever consumes the
|
||||
// published packages, so the emitted order is checked against the edges it
|
||||
// exists to honour.
|
||||
const position = new Map(ordered.map((entry, index) => [entry.name, index]))
|
||||
for (const [index, member] of ordered.entries()) {
|
||||
for (const dependency of edges(member, INSTALL_SECTIONS)) {
|
||||
const dependencyIndex = position.get(dependency.name)
|
||||
if (dependencyIndex !== undefined && dependencyIndex < index) continue
|
||||
throw new Error(
|
||||
`release family ${this.id}: no publish order honours ${member.name} -> ${dependency.name};`
|
||||
+ ' a cycle mixing peer and dependency declarations reaches this dependency through a peer edge',
|
||||
)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
return { order: ordered, droppedPeerEdges }
|
||||
}
|
||||
|
||||
/**
|
||||
* The family members one member depends on at runtime.
|
||||
* The family members one member declares in the given sections.
|
||||
* @param member - the dependent member.
|
||||
* @param byName - every family member by package name.
|
||||
* @returns Dependencies inside this family, sorted by name.
|
||||
* @param sections - manifest sections to read.
|
||||
* @returns Members of this family named there, sorted by name.
|
||||
*/
|
||||
private orderEdges(member: ReleaseMember, byName: ReadonlyMap<string, ReleaseMember>): ReleaseMember[] {
|
||||
private orderEdges(
|
||||
member: ReleaseMember,
|
||||
byName: ReadonlyMap<string, ReleaseMember>,
|
||||
sections: readonly string[],
|
||||
): ReleaseMember[] {
|
||||
const edges: ReleaseMember[] = []
|
||||
for (const section of ORDER_SECTIONS) {
|
||||
for (const section of sections) {
|
||||
const dependencies = member.manifest[section]
|
||||
if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue
|
||||
for (const name of Object.keys(dependencies)) {
|
||||
|
||||
@@ -45,7 +45,7 @@ function main(): void {
|
||||
const family = releaseFamily(values.family)
|
||||
const root = process.cwd()
|
||||
const destination = resolve(root, values.out ?? DEFAULT_OUTPUT)
|
||||
const members = family.publishOrder(family.members(root))
|
||||
const members = family.publishOrder(family.members(root)).order
|
||||
family.verifyVersions(members)
|
||||
|
||||
rmSync(destination, { recursive: true, force: true })
|
||||
|
||||
@@ -38,6 +38,40 @@ export function attempt(command: string, args: readonly string[], options: RunOp
|
||||
return { status: result.status, stdout: result.stdout, stderr: result.stderr }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command, capture its output, and echo it once the command exits.
|
||||
*
|
||||
* A step that both shows what a command said and classifies its own failure
|
||||
* needs both halves: the output has to reach the workflow log, and the caller has
|
||||
* to read it to decide whether a failure is worth retrying.
|
||||
*
|
||||
* This is not live progress. `spawnSync` returns only after the child exits, so
|
||||
* nothing appears while the command runs, and the two streams are echoed one
|
||||
* after the other — all of stdout, then all of stderr — which loses their
|
||||
* interleaving. For an npm publish that matters in one visible way: `npm notice`
|
||||
* lines go to stderr while the `+ name@version` confirmation goes to stdout, so
|
||||
* the log shows the confirmation first. Live progress would need an
|
||||
* asynchronous spawn with data listeners.
|
||||
* @param command - executable name.
|
||||
* @param args - command arguments.
|
||||
* @param options - working directory and environment.
|
||||
* @returns The exit status and captured streams.
|
||||
*/
|
||||
export function attemptEchoed(command: string, args: readonly string[], options: RunOptions = {}): CommandResult {
|
||||
const result = spawnSync(command, [...args], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
encoding: 'utf8',
|
||||
// 'inherit' would leave nothing to capture, so the streams are piped and
|
||||
// echoed instead.
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
})
|
||||
if (result.error !== undefined) throw result.error
|
||||
if (result.stdout !== '') process.stdout.write(result.stdout)
|
||||
if (result.stderr !== '') process.stderr.write(result.stderr)
|
||||
return { status: result.status, stdout: result.stdout, stderr: result.stderr }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command, capture its standard output, and fail on a non-zero exit.
|
||||
* @param command - executable name.
|
||||
|
||||
@@ -15,19 +15,46 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily } from './families.ts'
|
||||
import { attempt, isEntry, run } from './process.ts'
|
||||
import { attempt, attemptEchoed, isEntry } from './process.ts'
|
||||
import { packedIdentity, readPublishOrder } from './tarball.ts'
|
||||
|
||||
/** npm access level for every package this repository publishes. */
|
||||
const ACCESS = 'restricted'
|
||||
/**
|
||||
* Registry codes that answer a write which did not settle, rather than a
|
||||
* rejection of what was sent. `E409 Failed to save packument` is the one this
|
||||
* sequence actually hits: publishing several packages in a row can outrun the
|
||||
* registry's own processing. A rejected payload (`E403` over an existing
|
||||
* version, a malformed manifest) never clears on a retry and must surface.
|
||||
*/
|
||||
const TRANSIENT_PUBLISH_CODES = ['E409', 'E429', 'E500', 'E502', 'E503', 'E504', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'] as const
|
||||
|
||||
/** How many times one tarball's publish is attempted before the run fails. */
|
||||
const PUBLISH_ATTEMPTS = 4
|
||||
|
||||
/**
|
||||
* Shortest gap between two publishes, and the first retry backoff.
|
||||
*
|
||||
* The registry needs a moment to commit a packument before the next write; back
|
||||
* to back publishes are what produce `E409`.
|
||||
*/
|
||||
const PUBLISH_SPACING_MS = 2_000
|
||||
|
||||
/** What the registry knows about one version. */
|
||||
type RegistryState =
|
||||
| { readonly kind: 'absent' }
|
||||
| { readonly kind: 'present'; readonly integrity: string }
|
||||
|
||||
/**
|
||||
* Whether a failed publish is worth another attempt.
|
||||
* @param output - combined npm output.
|
||||
* @returns True when the registry reported a write it did not commit.
|
||||
*/
|
||||
function isTransientFailure(output: string): boolean {
|
||||
return TRANSIENT_PUBLISH_CODES.some(code => output.includes(`code ${code}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* The subresource integrity string npm records for a tarball.
|
||||
* @param tarball - absolute tarball path.
|
||||
@@ -57,8 +84,47 @@ function registryState(name: string, version: string): RegistryState {
|
||||
return { kind: 'present', integrity: parsed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish one tarball, retrying a registry write that did not settle.
|
||||
*
|
||||
* Every retry re-reads the registry first, because `E409` can answer a write
|
||||
* that landed anyway: republishing a version that now exists fails permanently,
|
||||
* so the same integrity appearing under the failed attempt counts as success.
|
||||
* @param tarball - absolute tarball path.
|
||||
* @param name - package name the tarball declares.
|
||||
* @param version - package version the tarball declares.
|
||||
*/
|
||||
async function publishTarball(tarball: string, name: string, version: string): Promise<void> {
|
||||
// A prerelease version never takes the latest dist-tag.
|
||||
const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
|
||||
for (let tries = 1; tries <= PUBLISH_ATTEMPTS; tries += 1) {
|
||||
// No --access: the sequences do not share one access level, so a
|
||||
// command-line flag could not serve both and would override the manifest
|
||||
// that does. Each packed manifest decides, and
|
||||
// check-workspace-constraints holds every manifest to its sequence's level.
|
||||
const result = attemptEchoed('npm', ['publish', tarball, ...tagArgs])
|
||||
const output = `${result.stdout}${result.stderr}`
|
||||
if (result.status === 0) return
|
||||
|
||||
const settled = registryState(name, version)
|
||||
if (settled.kind === 'present' && settled.integrity === integrityOf(tarball)) {
|
||||
console.log(`release publish: ${name}@${version} landed despite a reported failure, continuing`)
|
||||
return
|
||||
}
|
||||
if (tries === PUBLISH_ATTEMPTS || !isTransientFailure(output)) {
|
||||
throw new Error(`npm publish ${name}@${version} failed:\n${output}`)
|
||||
}
|
||||
const backoff = PUBLISH_SPACING_MS * 2 ** (tries - 1)
|
||||
console.log(
|
||||
`release publish: ${name}@${version} hit a transient registry failure`
|
||||
+ ` (attempt ${String(tries)} of ${String(PUBLISH_ATTEMPTS)}), retrying in ${String(backoff)}ms`,
|
||||
)
|
||||
await sleep(backoff)
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish the family named by `--family` from the directory named by `--from`. */
|
||||
function main(): void {
|
||||
async function main(): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
options: { family: { type: 'string' }, from: { type: 'string' } },
|
||||
allowPositionals: false,
|
||||
@@ -70,9 +136,15 @@ function main(): void {
|
||||
const family = releaseFamily(values.family)
|
||||
const directory = resolve(process.cwd(), values.from)
|
||||
|
||||
// Every entry in the order settles as either published or already present, so
|
||||
// one counter answers "how far along is this run" for whoever is watching a
|
||||
// release that takes minutes per family.
|
||||
const order = readPublishOrder(directory)
|
||||
const total = String(order.length)
|
||||
let published = 0
|
||||
let skipped = 0
|
||||
for (const filename of readPublishOrder(directory)) {
|
||||
for (const [index, filename] of order.entries()) {
|
||||
const progress = `[${String(index + 1)}/${total}]`
|
||||
const tarball = join(directory, filename)
|
||||
const { name, version } = packedIdentity(tarball)
|
||||
const state = registryState(name, version)
|
||||
@@ -85,17 +157,22 @@ function main(): void {
|
||||
+ '\nBump the version, or investigate why the build is not reproducible.',
|
||||
)
|
||||
}
|
||||
console.log(`release publish: ${name}@${version} already published, skipping`)
|
||||
console.log(`release publish: ${progress} ${name}@${version} already published, skipping`)
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
// A prerelease version never takes the latest dist-tag.
|
||||
const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
|
||||
run('npm', ['publish', tarball, '--access', ACCESS, ...tagArgs])
|
||||
// Space out the writes: the gap belongs between publishes, so a run that
|
||||
// only skips does not wait at all.
|
||||
if (published > 0) await sleep(PUBLISH_SPACING_MS)
|
||||
await publishTarball(tarball, name, version)
|
||||
console.log(`release publish: ${progress} ${name}@${version} published`)
|
||||
published += 1
|
||||
}
|
||||
|
||||
console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`)
|
||||
console.log(
|
||||
`release publish: family ${family.id}, ${total} member(s),`
|
||||
+ ` ${String(published)} published, ${String(skipped)} already present`,
|
||||
)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
if (isEntry(import.meta.url)) await main()
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* Every tarball the installed tree needs comes from `--from`, so the only
|
||||
* registry traffic is for external dependencies. That matters beyond hermetic
|
||||
* verification: the harness packages declare the vendored framework as a peer,
|
||||
* and those packages live in another release sequence that this credential-free
|
||||
* job cannot fetch from a private registry — so a dsh verification passes the
|
||||
* those packages live in another release sequence, and this job must not depend
|
||||
* on the registry already carrying versions that match — one pull request may
|
||||
* bump both families before either publishes — so a dsh verification passes the
|
||||
* vendored family's pack output too, while publishing only its own
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*
|
||||
|
||||
@@ -9,7 +9,33 @@
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { isEntry } from './process.ts'
|
||||
import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts'
|
||||
import { releaseFamily, type PublishPlan, type ReleaseFamily, type ReleaseMember } from './families.ts'
|
||||
|
||||
/**
|
||||
* Print the publish order the release will follow, and the peer declarations it
|
||||
* leaves unordered.
|
||||
*
|
||||
* The order is the release's own plan: an interrupted publication leaves exactly
|
||||
* a prefix of it, so reading it is how anyone judges what a partial run left on
|
||||
* the registry, and printing it on every pull request is what makes a change to
|
||||
* the order reviewable rather than only observable during a publication.
|
||||
* @param family - the release family.
|
||||
* @param plan - the resolved order and its dropped edges.
|
||||
*/
|
||||
function reportPublishOrder(family: ReleaseFamily, plan: PublishPlan): void {
|
||||
console.log(`release verify: publish order for family ${family.id}, ${String(plan.order.length)} member(s):`)
|
||||
const width = String(plan.order.length).length
|
||||
for (const [index, member] of plan.order.entries()) {
|
||||
console.log(` ${String(index + 1).padStart(width, ' ')} ${member.name}@${member.version}`)
|
||||
}
|
||||
if (plan.droppedPeerEdges.length === 0) return
|
||||
console.log(
|
||||
`release verify: ${String(plan.droppedPeerEdges.length)} peer declaration(s) publish unordered,`
|
||||
+ ' because the peer cannot precede the package declaring it without contradicting a dependency edge'
|
||||
+ ' or its own cycle. npm treats an unmet peer as a warning, so this orders nothing and blocks nothing:',
|
||||
)
|
||||
for (const edge of plan.droppedPeerEdges) console.log(` ${edge.consumer} -> ${edge.peer}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert every member may be published: npm refuses a `private` package.
|
||||
@@ -55,6 +81,16 @@ function main(): void {
|
||||
const family = releaseFamily(values.family)
|
||||
const members = family.members(process.cwd())
|
||||
family.verifyVersions(members)
|
||||
// Resolve the publish order here, before the build: an install-edge cycle
|
||||
// makes the order unrepresentable, and that has to surface at the first gate
|
||||
// rather than when pack is already writing tarballs.
|
||||
const plan = family.publishOrder(members)
|
||||
if (plan.order.length !== members.length) {
|
||||
throw new Error(
|
||||
`release family ${family.id}: publish order covers ${String(plan.order.length)} of ${String(members.length)} members`,
|
||||
)
|
||||
}
|
||||
reportPublishOrder(family, plan)
|
||||
|
||||
const publishing = process.env.RELEASE_PUBLISH === 'true'
|
||||
if (publishing) {
|
||||
@@ -64,7 +100,11 @@ function main(): void {
|
||||
|
||||
const versions = [...new Set(members.map(member => member.version))]
|
||||
const summary = versions.length === 1 ? versions[0] : `${String(versions.length)} versions`
|
||||
console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}${publishing ? ', publish gates passed' : ''}`)
|
||||
console.log(
|
||||
`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary},`
|
||||
+ ` publish order resolved, ${String(plan.droppedPeerEdges.length)} peer declaration(s) unordered`
|
||||
+ (publishing ? ', publish gates passed' : ''),
|
||||
)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
|
||||
@@ -83,6 +83,15 @@ describe('gate graph validation', () => {
|
||||
expect(ids).toContain('public-repository-links')
|
||||
})
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
|
||||
'keeps the DSH package license policy in %s',
|
||||
(mode) => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('dsh-package-licenses')
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps native Windows coverage blocking while portability inventory remains observational', () => {
|
||||
const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
|
||||
const byId = new Map(gates.map(subject => [subject.id, subject]))
|
||||
|
||||
@@ -246,8 +246,12 @@ function ciSharedStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
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' }),
|
||||
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
|
||||
label: 'optional dependency imports',
|
||||
}),
|
||||
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
]
|
||||
}
|
||||
@@ -557,12 +561,16 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
builtPackageInvariantsGate(options.artifactNeeds),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
...artifactOptions,
|
||||
}),
|
||||
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
|
||||
label: 'optional dependency imports',
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -604,8 +612,8 @@ function docSyncLeafGates(options: {
|
||||
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
|
||||
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
|
||||
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
|
||||
pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts'], {
|
||||
label: 'documentation projection',
|
||||
pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts', 'scripts/verify-doc-site-fragments.spec.ts'], {
|
||||
label: 'documentation site checks',
|
||||
}),
|
||||
// Keep the VitePress build itself in one gate because projection rewrites website/.generated.
|
||||
pnpmScript('docs-site-build', options.docsBuildScript ?? 'docs:build', { label: 'documentation build' }),
|
||||
|
||||
File diff suppressed because one or more lines are too long
49
scripts/test-fixture-cleanup.ts
Normal file
49
scripts/test-fixture-cleanup.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Junction-safe fixture cleanup for Windows. Test fixtures junction the REAL
|
||||
* `scripts/`, `node_modules`, and tsx package directories so installer probes
|
||||
* resolve through them; Windows recursive deletion — both Node's `rmSync` and
|
||||
* Git's `worktree remove` — follows MOUNT_POINT junctions into their targets
|
||||
* and would delete the repository's own directories. POSIX `unlink`/`rm`
|
||||
* already remove symlinks without following them, so the walk is a no-op
|
||||
* there.
|
||||
*/
|
||||
|
||||
import { lstatSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Recursively unlink every symbolic link (junction) under `path`.
|
||||
* @param path - the fixture tree whose reparse points are unlinked.
|
||||
*/
|
||||
export function unlinkFixtureLinks(path: string): void {
|
||||
const visit = (entry: string): void => {
|
||||
let stat: ReturnType<typeof lstatSync>
|
||||
try {
|
||||
stat = lstatSync(entry)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
|
||||
throw error
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
if (stat.isSymbolicLink()) unlinkSync(entry)
|
||||
return
|
||||
}
|
||||
for (const child of readdirSync(entry)) visit(join(entry, child))
|
||||
}
|
||||
visit(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one fixture tree after its junctions are unlinked (see
|
||||
* {@link unlinkFixtureLinks}). Retries the removal: Windows releases child
|
||||
* process and antivirus file handles asynchronously, and an unretried
|
||||
* `rmSync` fails immediately with EPERM under load. A 10-second retry window
|
||||
* (50 attempts × 200 ms) covers the failover pool's slow handle release;
|
||||
* release is one-shot (a terminated child's handles drain, not reacquired),
|
||||
* so a bounded window suffices and never pins afterEach cleanup.
|
||||
* @param path - the fixture tree to remove.
|
||||
*/
|
||||
export function removeFixtureSafely(path: string): void {
|
||||
unlinkFixtureLinks(path)
|
||||
rmSync(path, { recursive: true, force: true, maxRetries: 50, retryDelay: 200 })
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
/** Integration coverage for automatic and explicit pairing-record conflict resolution. */
|
||||
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -15,6 +22,7 @@ import {
|
||||
renderTranslationPairingRecord,
|
||||
translationPairPaths,
|
||||
} from './translation-pairing-record.ts'
|
||||
import { removeFixtureSafely } from './test-fixture-cleanup.ts'
|
||||
|
||||
const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url))
|
||||
const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url))
|
||||
@@ -28,7 +36,7 @@ interface Fixture {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
|
||||
for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture)
|
||||
})
|
||||
|
||||
function git(fixture: Fixture, args: string[]): string {
|
||||
|
||||
@@ -11,6 +11,12 @@ interface ProjectGraph {
|
||||
options: ts.CompilerOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* A compiler face: the two aggregates a repository-wide program may seed from.
|
||||
* The root solution is never one of them.
|
||||
*/
|
||||
export type CompilerFace = 'host' | 'client'
|
||||
|
||||
/** TypeScript config host shared by repository scripts. */
|
||||
export const repositoryConfigHost: ts.ParseConfigFileHost = {
|
||||
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
|
||||
@@ -24,12 +30,12 @@ export const repositoryConfigHost: ts.ParseConfigFileHost = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the host aggregate tsconfig and flatten all referenced projects into one
|
||||
* Parse one face aggregate tsconfig and flatten all referenced projects into one
|
||||
* semantic graph. Never seed the root solution: flattening host+client into one
|
||||
* program collides the cordis Context merges.
|
||||
*/
|
||||
function loadProjectGraph(projectRoot: string): ProjectGraph {
|
||||
const rootConfigPath = resolve(projectRoot, 'tsconfig.host.json')
|
||||
function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph {
|
||||
const rootConfigPath = resolve(projectRoot, `tsconfig.${face}.json`)
|
||||
const rootConfig = parseConfig(rootConfigPath)
|
||||
const rootNames = new Set<string>()
|
||||
const visited = new Set<string>()
|
||||
@@ -81,8 +87,12 @@ export class TypeScriptProject {
|
||||
/** The checker shared by every semantic query in this project. */
|
||||
readonly checker: ts.TypeChecker
|
||||
|
||||
constructor(private readonly projectRoot: string) {
|
||||
const graph = loadProjectGraph(projectRoot)
|
||||
/**
|
||||
* @param projectRoot - repository root the program is seeded and reported from.
|
||||
* @param face - which compiler face aggregate to flatten.
|
||||
*/
|
||||
constructor(readonly projectRoot: string, face: CompilerFace = 'host') {
|
||||
const graph = loadProjectGraph(projectRoot, face)
|
||||
this.program = ts.createProgram(graph.rootNames, semanticCompilerOptions(graph.options))
|
||||
this.checker = this.program.getTypeChecker()
|
||||
}
|
||||
|
||||
@@ -351,6 +351,11 @@
|
||||
"symbol": "ToolProviderResult",
|
||||
"source": "packages/core/system-prompt/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/llm-streaming.md",
|
||||
"symbol": "ReplayEnvelope",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/llm-streaming.md",
|
||||
"symbol": "StreamChunk",
|
||||
|
||||
87
scripts/verify-doc-site-fragments.spec.ts
Normal file
87
scripts/verify-doc-site-fragments.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/** Tests for built-site fragment validation. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { inspectSiteFragments } from './verify-doc-site-fragments.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixture(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-fragments-'))
|
||||
roots.push(root)
|
||||
mkdirSync(join(root, 'guide'), { recursive: true })
|
||||
writeFileSync(join(root, 'index.html'), '<a id="home"></a><a href="/guide/start#ready">start</a>')
|
||||
writeFileSync(join(root, 'guide/start.html'), [
|
||||
'<h1 id="ready">Ready</h1>',
|
||||
'<a name="legacy"></a>',
|
||||
'<a href="#ready">same page</a>',
|
||||
'<a href="./start.html#legacy">html alias</a>',
|
||||
'<a href="../#home">root</a>',
|
||||
'<a href="https://example.com/page#missing">external</a>',
|
||||
].join(''))
|
||||
return root
|
||||
}
|
||||
|
||||
describe('inspectSiteFragments', () => {
|
||||
it('rejects a directory with no built pages', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-fragments-empty-'))
|
||||
roots.push(root)
|
||||
|
||||
expect(() => inspectSiteFragments(root)).toThrow('no HTML files found')
|
||||
})
|
||||
|
||||
it('resolves clean, encoded, and same-page routes', () => {
|
||||
const root = fixture()
|
||||
writeFileSync(
|
||||
join(root, 'guide/encoded.html'),
|
||||
'<h1 id="a b">Encoded</h1><h2 id="%">Literal</h2><a href="./encoded#a%20b">encoded</a><a href="#%">literal</a>',
|
||||
)
|
||||
|
||||
expect(inspectSiteFragments(root)).toEqual({ checked: 6, broken: [] })
|
||||
})
|
||||
|
||||
it('rejects ambiguous built routes', () => {
|
||||
const root = fixture()
|
||||
writeFileSync(join(root, 'guide.html'), '<h1 id="flat">Flat</h1>')
|
||||
writeFileSync(join(root, 'guide/index.html'), '<h1 id="index">Index</h1>')
|
||||
|
||||
expect(() => inspectSiteFragments(root)).toThrow('share route "/guide"')
|
||||
})
|
||||
|
||||
it('rejects malformed fragment hrefs', () => {
|
||||
const root = fixture()
|
||||
writeFileSync(join(root, 'guide/invalid.html'), '<a href="http://[invalid]#fragment">invalid</a>')
|
||||
|
||||
expect(() => inspectSiteFragments(root)).toThrow(
|
||||
'guide/invalid.html has invalid fragment href "http://[invalid]#fragment"',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports missing ids and missing built routes', () => {
|
||||
const root = fixture()
|
||||
writeFileSync(join(root, 'guide/broken.html'), [
|
||||
'<a href="./start#missing">id</a>',
|
||||
'<a href="./absent#missing">route</a>',
|
||||
].join(''))
|
||||
|
||||
expect(inspectSiteFragments(root).broken).toEqual([
|
||||
{
|
||||
source: 'guide/broken.html',
|
||||
href: './start#missing',
|
||||
target: 'guide/start.html',
|
||||
fragment: 'missing',
|
||||
},
|
||||
{
|
||||
source: 'guide/broken.html',
|
||||
href: './absent#missing',
|
||||
fragment: 'missing',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
157
scripts/verify-doc-site-fragments.ts
Normal file
157
scripts/verify-doc-site-fragments.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Verify fragment links against the HTML emitted by VitePress. Markdown and
|
||||
* VitePress use different heading-slug algorithms, so source-link validation
|
||||
* alone cannot prove that a published fragment exists.
|
||||
*
|
||||
* This runs as part of `docs:build` and can also run directly after a build
|
||||
* with `tsx scripts/verify-doc-site-fragments.ts`.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { JSDOM } from 'jsdom'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** One fragment reference that does not resolve in the built site. */
|
||||
export interface BrokenSiteFragment {
|
||||
/** HTML file containing the link. */
|
||||
source: string
|
||||
/** Link value as emitted by VitePress. */
|
||||
href: string
|
||||
/** Built HTML target, or `undefined` when the route was not emitted. */
|
||||
target?: string
|
||||
/** Decoded fragment id requested by the link. */
|
||||
fragment: string
|
||||
}
|
||||
|
||||
/** Result of checking every fragment-bearing anchor in a built site. */
|
||||
export interface SiteFragmentReport {
|
||||
/** Number of internal fragment references inspected. */
|
||||
checked: number
|
||||
/** References whose route or fragment id is absent. */
|
||||
broken: BrokenSiteFragment[]
|
||||
}
|
||||
|
||||
interface BuiltPage {
|
||||
file: string
|
||||
route: string
|
||||
ids: Set<string>
|
||||
document: Document
|
||||
}
|
||||
|
||||
function posixPath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
function routeFor(file: string): string {
|
||||
if (file === 'index.html') return '/'
|
||||
if (file.endsWith('/index.html')) return `/${file.slice(0, -'index.html'.length)}`
|
||||
return `/${file.slice(0, -'.html'.length)}`
|
||||
}
|
||||
|
||||
function aliasesFor(page: BuiltPage): string[] {
|
||||
if (page.route === '/') return ['/', '/index', '/index.html']
|
||||
if (page.route.endsWith('/')) {
|
||||
const stem = page.route.slice(0, -1)
|
||||
return [page.route, stem, `${stem}/index`, `${stem}/index.html`]
|
||||
}
|
||||
return [page.route, `${page.route}.html`]
|
||||
}
|
||||
|
||||
function decodedFragment(hash: string): string {
|
||||
try {
|
||||
return decodeURIComponent(hash.slice(1))
|
||||
} catch (error) {
|
||||
if (!(error instanceof URIError)) throw error
|
||||
// URIError means malformed percent encoding; preserve the literal id for comparison.
|
||||
return hash.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check fragment-bearing links in a VitePress output directory.
|
||||
*
|
||||
* @param distRoot - Directory containing generated HTML files.
|
||||
* @returns Counted internal links and every unresolved target.
|
||||
*/
|
||||
export function inspectSiteFragments(distRoot: string): SiteFragmentReport {
|
||||
const files = globSync('**/*.html', { cwd: distRoot }).map(posixPath).sort()
|
||||
if (files.length === 0) {
|
||||
throw new Error(`verify-doc-site-fragments: no HTML files found under ${distRoot}; run docs:build first.`)
|
||||
}
|
||||
const pages: BuiltPage[] = files.map((file) => {
|
||||
const document = new JSDOM(readFileSync(resolve(distRoot, file), 'utf8')).window.document
|
||||
const ids = new Set<string>()
|
||||
for (const element of document.querySelectorAll<HTMLElement>('[id]')) ids.add(element.id)
|
||||
for (const element of document.querySelectorAll<HTMLAnchorElement>('a[name]')) {
|
||||
const name = element.getAttribute('name')
|
||||
if (name !== null) ids.add(name)
|
||||
}
|
||||
return { file, route: routeFor(file), ids, document }
|
||||
})
|
||||
|
||||
const byRoute = new Map<string, BuiltPage>()
|
||||
for (const page of pages) {
|
||||
for (const alias of aliasesFor(page)) {
|
||||
const existing = byRoute.get(alias)
|
||||
if (existing !== undefined && existing !== page) {
|
||||
throw new Error(
|
||||
`verify-doc-site-fragments: built pages ${existing.file} and ${page.file} share route ${JSON.stringify(alias)}.`,
|
||||
)
|
||||
}
|
||||
byRoute.set(alias, page)
|
||||
}
|
||||
}
|
||||
|
||||
const origin = 'https://dsh-docs.invalid'
|
||||
const broken: BrokenSiteFragment[] = []
|
||||
let checked = 0
|
||||
for (const page of pages) {
|
||||
for (const anchor of page.document.querySelectorAll<HTMLAnchorElement>('a[href]')) {
|
||||
const href = anchor.getAttribute('href')
|
||||
if (href === null || !href.includes('#')) continue
|
||||
let targetUrl: URL
|
||||
try {
|
||||
targetUrl = new URL(href, `${origin}${page.route}`)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`verify-doc-site-fragments: ${page.file} has invalid fragment href ${JSON.stringify(href)}.`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (targetUrl.origin !== origin || targetUrl.hash === '') continue
|
||||
const fragment = decodedFragment(targetUrl.hash)
|
||||
if (fragment === '') continue
|
||||
checked++
|
||||
const target = byRoute.get(targetUrl.pathname)
|
||||
if (target === undefined || !target.ids.has(fragment)) {
|
||||
broken.push({
|
||||
source: page.file,
|
||||
href,
|
||||
...(target === undefined ? {} : { target: target.file }),
|
||||
fragment,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return { checked, broken }
|
||||
}
|
||||
|
||||
function main(): number {
|
||||
const distRoot = resolve(root, 'website/.dist')
|
||||
const report = inspectSiteFragments(distRoot)
|
||||
if (report.broken.length === 0) {
|
||||
console.log(`verify-doc-site-fragments: ${report.checked} internal fragment reference(s) resolve.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
console.error(`verify-doc-site-fragments: ${report.broken.length} broken fragment reference(s):`)
|
||||
for (const item of report.broken) {
|
||||
const target = item.target === undefined ? 'target route was not built' : `${item.target} has no id ${JSON.stringify(item.fragment)}`
|
||||
console.error(` ${item.source}: ${JSON.stringify(item.href)} (${target})`)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
if (import.meta.main) process.exitCode = main()
|
||||
59
scripts/verify-dsh-package-licenses.spec.ts
Normal file
59
scripts/verify-dsh-package-licenses.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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 { inspectDshPackageLicenses } from './verify-dsh-package-licenses.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeManifest(root: string, file: string, manifest: Record<string, unknown>): void {
|
||||
const path = join(root, file)
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function createWorkspace(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-package-licenses-'))
|
||||
roots.push(root)
|
||||
writeManifest(root, 'package.json', {
|
||||
name: '@deepseek-ai/dsh-root',
|
||||
license: 'MIT',
|
||||
workspaces: ['apps/*', 'packages/*/*', 'vendor/*'],
|
||||
})
|
||||
return root
|
||||
}
|
||||
|
||||
describe('DSH package license gate', () => {
|
||||
it('checks root, unhyphenated CLI, and dsh-prefixed package names while ignoring other families', () => {
|
||||
const root = createWorkspace()
|
||||
writeManifest(root, 'apps/cli/package.json', { name: '@deepseek-ai/dsh', license: 'MIT' })
|
||||
writeManifest(root, 'packages/core/agent/package.json', {
|
||||
name: '@deepseek-ai/dsh-agent',
|
||||
license: 'BSD-3-Clause',
|
||||
})
|
||||
writeManifest(root, 'vendor/cordis/package.json', {
|
||||
name: '@deepseek-ai/cordis',
|
||||
license: 'BSD-3-Clause',
|
||||
})
|
||||
|
||||
expect(inspectDshPackageLicenses(root)).toEqual({
|
||||
packageCount: 3,
|
||||
failures: [
|
||||
'packages/core/agent/package.json: @deepseek-ai/dsh-agent must declare "license": "MIT"; found "BSD-3-Clause".',
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a missing license declaration', () => {
|
||||
const root = createWorkspace()
|
||||
writeManifest(root, 'packages/core/agent/package.json', { name: '@deepseek-ai/dsh-agent' })
|
||||
|
||||
expect(inspectDshPackageLicenses(root).failures).toEqual([
|
||||
'packages/core/agent/package.json: @deepseek-ai/dsh-agent must declare "license": "MIT"; found undefined.',
|
||||
])
|
||||
})
|
||||
})
|
||||
89
scripts/verify-dsh-package-licenses.ts
Normal file
89
scripts/verify-dsh-package-licenses.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Enforce the MIT license declaration for repository-owned DSH npm packages.
|
||||
* @module scripts/verify-dsh-package-licenses
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..')
|
||||
const DSH_PACKAGE_NAME = /^@deepseek-ai\/dsh(?:-|$)/
|
||||
|
||||
/** Result of checking every DSH package reachable through the root workspace list. */
|
||||
export interface DshPackageLicenseReport {
|
||||
/** Number of DSH package manifests checked. */
|
||||
packageCount: number
|
||||
/** Repository-relative diagnostics for non-MIT declarations. */
|
||||
failures: string[]
|
||||
}
|
||||
|
||||
function readManifest(root: string, file: string): Record<string, unknown> {
|
||||
const parsed: unknown = JSON.parse(readFileSync(resolve(root, file), 'utf8'))
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`verify-dsh-package-licenses: ${file} must contain a JSON object.`)
|
||||
}
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((entry: unknown) => typeof entry === 'string')
|
||||
}
|
||||
|
||||
function workspaceManifestPaths(root: string): string[] {
|
||||
const rootManifest = readManifest(root, 'package.json')
|
||||
const workspaces = rootManifest.workspaces
|
||||
if (!isStringArray(workspaces)) {
|
||||
throw new Error('verify-dsh-package-licenses: package.json workspaces must be a string array.')
|
||||
}
|
||||
|
||||
const files = new Set(['package.json'])
|
||||
for (const pattern of workspaces) {
|
||||
for (const file of globSync(`${pattern}/package.json`, { cwd: root })) {
|
||||
files.add(file)
|
||||
}
|
||||
}
|
||||
return [...files].sort()
|
||||
}
|
||||
|
||||
function printable(value: unknown): string {
|
||||
return value === undefined ? 'undefined' : JSON.stringify(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check every DSH npm package declared by the repository workspace.
|
||||
* @param root - absolute repository root containing the workspace package.json.
|
||||
* @returns the checked package count and every non-MIT declaration.
|
||||
*/
|
||||
export function inspectDshPackageLicenses(root: string): DshPackageLicenseReport {
|
||||
let packageCount = 0
|
||||
const failures: string[] = []
|
||||
|
||||
for (const file of workspaceManifestPaths(root)) {
|
||||
const manifest = readManifest(root, file)
|
||||
const name = manifest.name
|
||||
if (typeof name !== 'string' || !DSH_PACKAGE_NAME.test(name)) continue
|
||||
|
||||
packageCount++
|
||||
if (manifest.license !== 'MIT') {
|
||||
const normalizedFile = file.split(sep).join('/')
|
||||
failures.push(
|
||||
`${normalizedFile}: ${name} must declare "license": "MIT"; found ${printable(manifest.license)}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { packageCount, failures }
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
const report = inspectDshPackageLicenses(ROOT)
|
||||
if (report.failures.length > 0) {
|
||||
process.stderr.write('verify-dsh-package-licenses: non-MIT DSH package declarations found:\n')
|
||||
for (const failure of report.failures) process.stderr.write(` ${failure}\n`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`verify-dsh-package-licenses: ${String(report.packageCount)} DSH package(s) checked; all declare MIT.\n`,
|
||||
)
|
||||
}
|
||||
}
|
||||
130
scripts/verify-optional-dependency-imports.spec.ts
Normal file
130
scripts/verify-optional-dependency-imports.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Tests for the optional-dependency load gate: which import and re-export forms
|
||||
* survive emit, and therefore load a package the installed tree may not carry.
|
||||
*
|
||||
* The expectations here match what `tsc` emits with `verbatimModuleSyntax` off:
|
||||
* `import type`, `import {}`, an inline `type` specifier, and a named binding
|
||||
* that resolves to a type all disappear; a bare import, a value binding, and a
|
||||
* star re-export remain.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { TypeScriptProject } from './ts-project.ts'
|
||||
import { collectOptionalImportViolations } from './verify-optional-dependency-imports.ts'
|
||||
|
||||
const FIXTURE: Record<string, string> = {
|
||||
'tsconfig.host.json': JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'es2022',
|
||||
module: 'esnext',
|
||||
moduleResolution: 'bundler',
|
||||
noEmit: true,
|
||||
skipLibCheck: true,
|
||||
types: [],
|
||||
paths: {
|
||||
'@f/opt': ['./packages/f/opt/src/index.ts'],
|
||||
'@f/hard': ['./packages/f/hard/src/index.ts'],
|
||||
},
|
||||
},
|
||||
include: ['packages/**/*.ts'],
|
||||
}),
|
||||
|
||||
'packages/f/opt/package.json': JSON.stringify({ name: '@f/opt', version: '0.0.1' }),
|
||||
'packages/f/opt/src/index.ts': [
|
||||
'export interface Shape { a: number }',
|
||||
'export const runtimeValue = 1',
|
||||
'',
|
||||
].join('\n'),
|
||||
|
||||
'packages/f/hard/package.json': JSON.stringify({ name: '@f/hard', version: '0.0.1' }),
|
||||
'packages/f/hard/src/index.ts': 'export const hardValue = 2\n',
|
||||
|
||||
// The consumer allows @f/opt to be absent and requires @f/hard.
|
||||
'packages/f/consumer/package.json': JSON.stringify({
|
||||
name: '@f/consumer',
|
||||
version: '0.0.1',
|
||||
dependencies: { '@f/hard': '*' },
|
||||
peerDependencies: { '@f/opt': '*' },
|
||||
peerDependenciesMeta: { '@f/opt': { optional: true } },
|
||||
}),
|
||||
|
||||
// Elided by the compiler, so each of these is allowed.
|
||||
'packages/f/consumer/src/allowed-type-only.ts': [
|
||||
"import type {} from '@f/opt'",
|
||||
'export const a = 1',
|
||||
'',
|
||||
].join('\n'),
|
||||
'packages/f/consumer/src/allowed-empty.ts': [
|
||||
"import {} from '@f/opt'",
|
||||
'export const b = 1',
|
||||
'',
|
||||
].join('\n'),
|
||||
'packages/f/consumer/src/allowed-inline-type.ts': [
|
||||
"import { type Shape } from '@f/opt'",
|
||||
'export const c: Shape = { a: 1 }',
|
||||
'',
|
||||
].join('\n'),
|
||||
'packages/f/consumer/src/allowed-type-binding.ts': [
|
||||
"import { Shape } from '@f/opt'",
|
||||
'export const d: Shape = { a: 1 }',
|
||||
'',
|
||||
].join('\n'),
|
||||
'packages/f/consumer/src/allowed-type-reexport.ts': [
|
||||
"export type { Shape } from '@f/opt'",
|
||||
'',
|
||||
].join('\n'),
|
||||
// A hard dependency may be loaded at module scope; only optional ones may not.
|
||||
'packages/f/consumer/src/allowed-hard-dependency.ts': [
|
||||
"import { hardValue } from '@f/hard'",
|
||||
'export const e = hardValue',
|
||||
'',
|
||||
].join('\n'),
|
||||
|
||||
// Kept by the compiler, so each of these loads a package that may be absent.
|
||||
'packages/f/consumer/src/rejected-bare.ts': [
|
||||
"import '@f/opt'",
|
||||
'export const f = 1',
|
||||
'',
|
||||
].join('\n'),
|
||||
'packages/f/consumer/src/rejected-value.ts': [
|
||||
"import { runtimeValue } from '@f/opt'",
|
||||
'export const g = runtimeValue',
|
||||
'',
|
||||
].join('\n'),
|
||||
'packages/f/consumer/src/rejected-star-reexport.ts': [
|
||||
"export * from '@f/opt'",
|
||||
'',
|
||||
].join('\n'),
|
||||
}
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'optional-imports-'))
|
||||
for (const [rel, content] of Object.entries(FIXTURE)) {
|
||||
mkdirSync(dirname(join(root, rel)), { recursive: true })
|
||||
writeFileSync(join(root, rel), content)
|
||||
}
|
||||
const violations = collectOptionalImportViolations(new TypeScriptProject(root))
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('optional dependency loads', () => {
|
||||
it('reports every form the compiler keeps, and nothing else', () => {
|
||||
expect(violations.map(violation => violation.split(' loads ')[0])).toEqual([
|
||||
'packages/f/consumer/src/rejected-bare.ts:1',
|
||||
'packages/f/consumer/src/rejected-star-reexport.ts:1',
|
||||
'packages/f/consumer/src/rejected-value.ts:1',
|
||||
])
|
||||
})
|
||||
|
||||
it('names the package, the declaration that made it optional, and the way out', () => {
|
||||
expect(violations[0]).toBe(
|
||||
'packages/f/consumer/src/rejected-bare.ts:1 loads @f/opt at module scope,'
|
||||
+ ' declared optional in peerDependenciesMeta; import it as a type,'
|
||||
+ ' or restructure so module scope does not need it',
|
||||
)
|
||||
})
|
||||
})
|
||||
214
scripts/verify-optional-dependency-imports.ts
Normal file
214
scripts/verify-optional-dependency-imports.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Reject a static value import of an optional dependency.
|
||||
*
|
||||
* A dependency declared in `optionalDependencies`, or as a peer carrying
|
||||
* `peerDependenciesMeta.<name>.optional`, may be absent from an installed tree —
|
||||
* that absence is what "optional" promises a consumer. A static import is
|
||||
* evaluated when the importing module loads, so one absent package turns
|
||||
* "this capability is unavailable" into a load failure for everything that
|
||||
* reaches the importing module.
|
||||
*
|
||||
* The way out, in order: import it as a type, which emits nothing and is all
|
||||
* that declaration merging needs; or restructure so nothing at module scope
|
||||
* needs the package. A dynamic `import()` only moves the failure to first use,
|
||||
* so it belongs to a caller that genuinely requires the package and handles its
|
||||
* absence — it is a last resort, not the default answer, and reaching for it is
|
||||
* a sign the dependency is not optional.
|
||||
*
|
||||
* Value-vs-type is decided against a bound Program rather than the import
|
||||
* syntax, because `verbatimModuleSyntax` is off: a named import used only in
|
||||
* type positions is elided and does not load anything. The decision is
|
||||
* deliberately conservative in one direction — a value binding the compiler
|
||||
* would elide because nothing references it in a value position is still
|
||||
* reported, and the fix it asks for (`import type`, or dropping the binding) is
|
||||
* what the published package wants regardless. Both compiler faces are scanned,
|
||||
* and only files that ship — a published package's `src` — are subject.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { TypeScriptProject, type CompilerFace } from './ts-project.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Directories whose `src` ships as a published package. */
|
||||
const PUBLISHED_SOURCE = /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+)\/src\//
|
||||
|
||||
/** How a manifest marked a dependency optional, for the violation message. */
|
||||
type OptionalKind = 'optionalDependencies' | 'peerDependenciesMeta'
|
||||
|
||||
/**
|
||||
* The package name a module specifier resolves to.
|
||||
* @param specifier - an import specifier, possibly a subpath.
|
||||
* @returns The bare package name, keeping a leading scope.
|
||||
*/
|
||||
function packageOf(specifier: string): string {
|
||||
const parts = specifier.split('/')
|
||||
return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] ?? specifier
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a manifest field as a record.
|
||||
* @param manifest - parsed manifest.
|
||||
* @param field - field name.
|
||||
* @returns The field value, or an empty record.
|
||||
*/
|
||||
function record(manifest: Record<string, unknown>, field: string): Record<string, unknown> {
|
||||
const value = manifest[field]
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* The dependencies one manifest allows to be absent.
|
||||
* @param manifest - parsed manifest.
|
||||
* @returns Each optional package name and how it was marked.
|
||||
*/
|
||||
function optionalDependencies(manifest: Record<string, unknown>): Map<string, OptionalKind> {
|
||||
const optional = new Map<string, OptionalKind>()
|
||||
for (const name of Object.keys(record(manifest, 'optionalDependencies'))) {
|
||||
optional.set(name, 'optionalDependencies')
|
||||
}
|
||||
const peers = record(manifest, 'peerDependencies')
|
||||
for (const [name, meta] of Object.entries(record(manifest, 'peerDependenciesMeta'))) {
|
||||
if (meta === null || typeof meta !== 'object') continue
|
||||
if ((meta as Record<string, unknown>).optional !== true) continue
|
||||
// A meta entry for an undeclared peer is check-workspace-constraints' business.
|
||||
if (!(name in peers)) continue
|
||||
optional.set(name, 'peerDependenciesMeta')
|
||||
}
|
||||
return optional
|
||||
}
|
||||
|
||||
/** One package directory's optional dependencies, resolved once per directory. */
|
||||
const optionalByDirectory = new Map<string, Map<string, OptionalKind>>()
|
||||
|
||||
/**
|
||||
* The optional dependencies of the package owning a source file.
|
||||
* @param projectRoot - root the relative path is resolved against.
|
||||
* @param relativePath - repository-relative path of a source file.
|
||||
* @returns That package's optional dependencies, empty when it declares none.
|
||||
*/
|
||||
function optionalFor(projectRoot: string, relativePath: string): Map<string, OptionalKind> {
|
||||
const directory = resolve(projectRoot, relativePath.slice(0, relativePath.indexOf('/src/')))
|
||||
const cached = optionalByDirectory.get(directory)
|
||||
if (cached !== undefined) return cached
|
||||
const manifestPath = resolve(directory, 'package.json')
|
||||
const parsed: unknown = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {}
|
||||
const manifest = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: {}
|
||||
const optional = optionalDependencies(manifest)
|
||||
optionalByDirectory.set(directory, optional)
|
||||
return optional
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one binding of an import or re-export names a value.
|
||||
* @param name - the local binding name node.
|
||||
* @param checker - the program's checker.
|
||||
* @returns True when the binding carries value meaning, and on an unresolved
|
||||
* symbol, so an unresolvable binding fails closed.
|
||||
*/
|
||||
function bindsValue(name: ts.Identifier | ts.StringLiteral, checker: ts.TypeChecker): boolean {
|
||||
const symbol = checker.getSymbolAtLocation(name)
|
||||
if (symbol === undefined) return true
|
||||
const target = (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol)
|
||||
return (target.flags & ts.SymbolFlags.Value) !== 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an import declaration loads its module at run time.
|
||||
* @param declaration - the import declaration.
|
||||
* @param checker - the program's checker.
|
||||
* @returns True when the emitted module keeps the import.
|
||||
*/
|
||||
function importLoadsModule(declaration: ts.ImportDeclaration, checker: ts.TypeChecker): boolean {
|
||||
const clause = declaration.importClause
|
||||
// A bare `import 'x'` is kept for its side effects.
|
||||
if (clause === undefined) return true
|
||||
// Only the type phase erases the import. `import defer` still resolves and
|
||||
// links the module, deferring evaluation alone, so an absent package fails
|
||||
// exactly as it would without the modifier.
|
||||
if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
|
||||
if (clause.name !== undefined) return true
|
||||
const bindings = clause.namedBindings
|
||||
if (bindings === undefined || ts.isNamespaceImport(bindings)) return true
|
||||
return bindings.elements.some(element => !element.isTypeOnly && bindsValue(element.name, checker))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a re-export loads its module at run time.
|
||||
* @param declaration - the export declaration, which carries a module specifier.
|
||||
* @param checker - the program's checker.
|
||||
* @returns True when the emitted module keeps the re-export.
|
||||
*/
|
||||
function exportLoadsModule(declaration: ts.ExportDeclaration, checker: ts.TypeChecker): boolean {
|
||||
if (declaration.isTypeOnly) return false
|
||||
const clause = declaration.exportClause
|
||||
// `export * from 'x'` re-exports whatever values the module has.
|
||||
if (clause === undefined || ts.isNamespaceExport(clause)) return true
|
||||
return clause.elements.some(element => !element.isTypeOnly && bindsValue(element.name, checker))
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every static value import of an optional dependency in one face.
|
||||
* @param project - a bound repository project.
|
||||
* @returns One message per violation, sorted by location.
|
||||
*/
|
||||
export function collectOptionalImportViolations(project: TypeScriptProject): string[] {
|
||||
const checker = project.checker
|
||||
const violations: string[] = []
|
||||
for (const sourceFile of project.sourceFiles()) {
|
||||
if (sourceFile.isDeclarationFile) continue
|
||||
const relativePath = project.relativePath(sourceFile)
|
||||
if (!PUBLISHED_SOURCE.test(relativePath)) continue
|
||||
const optional = optionalFor(project.projectRoot, relativePath)
|
||||
if (optional.size === 0) continue
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
const isImport = ts.isImportDeclaration(statement)
|
||||
if (!isImport && !ts.isExportDeclaration(statement)) continue
|
||||
const specifierNode = statement.moduleSpecifier
|
||||
if (specifierNode === undefined || !ts.isStringLiteral(specifierNode)) continue
|
||||
const kind = optional.get(packageOf(specifierNode.text))
|
||||
if (kind === undefined) continue
|
||||
const loads = isImport
|
||||
? importLoadsModule(statement, checker)
|
||||
: exportLoadsModule(statement, checker)
|
||||
if (!loads) continue
|
||||
const { line } = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile))
|
||||
violations.push(
|
||||
`${relativePath}:${String(line + 1)} loads ${specifierNode.text} at module scope,`
|
||||
+ ` declared optional in ${kind}; import it as a type, or restructure so module scope does not need it`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return violations.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
/** CLI entry: list every violation and exit 1, or confirm the invariant holds. */
|
||||
function main(): void {
|
||||
const faces: readonly CompilerFace[] = ['host', 'client']
|
||||
const violations = new Set<string>()
|
||||
for (const face of faces) {
|
||||
for (const violation of collectOptionalImportViolations(new TypeScriptProject(root, face))) {
|
||||
violations.add(violation)
|
||||
}
|
||||
}
|
||||
if (violations.size === 0) {
|
||||
console.log('verify-optional-dependency-imports: no optional dependency is loaded at module scope.')
|
||||
return
|
||||
}
|
||||
console.error(`verify-optional-dependency-imports: ${String(violations.size)} optional dependency load(s) at module scope:`)
|
||||
for (const violation of [...violations].sort((left, right) => left.localeCompare(right))) {
|
||||
console.error(` ${violation}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Run only when invoked as a script, not when imported by a test.
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
main()
|
||||
}
|
||||
Reference in New Issue
Block a user