Merge branch 'master' into worktree/python-sdk-model-visible-assertions

This commit is contained in:
Yichen Jiang
2026-08-17 17:01:52 +08:00
committed by GitHub
995 changed files with 10894 additions and 69959 deletions

View File

@@ -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 []]

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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'), '```', '')

View File

@@ -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, '')

View File

@@ -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.

View File

@@ -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

View File

@@ -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')

View File

@@ -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()

View File

@@ -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. */

View File

@@ -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')

View File

@@ -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)) {

View File

@@ -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 })

View File

@@ -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.

View File

@@ -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()

View File

@@ -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)).
*

View File

@@ -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()

View File

@@ -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]))

View File

@@ -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

View 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 })
}

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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",

View 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',
},
])
})
})

View 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()

View 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.',
])
})
})

View 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`,
)
}
}

View 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',
)
})
})

View 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()
}