Merge branch 'worktree/jobs-api-review-3052' into worktree/jobs-api-review-3127

# Conflicts:
#	packages/client/ui-jobs/src/client/JobListAction.tsx
#	packages/shell/bash-local/tests/executor.spec.ts
This commit is contained in:
Yichen Jiang committed 2026-09-19 10:40:27 +08:00
commit c4bc8b847a
1836 files changed
+37648 -15031

No files matched your search

+27
View File
@@ -33,6 +33,33 @@ describe('archived Agent Notes', () => {
expect(validateArchiveArtifacts(fixture())).toEqual([])
})
it.each(['# Agent Note:示例', '# Historical decision'])('preserves historical formatting: %s', (title) => {
const artifacts = fixture()
const base = '2026-07-26-example'
const source = Buffer.from(`${title}\nStatus: implemented\nArchived: 2026-07-26\n[中文版本](${base}.zh.md)\n`)
artifacts.set(`process/${base}.md`, source)
const zh = artifacts.get(`process/${base}.zh.md`)!
artifacts.set(`process/${base}.i18n.yaml`, Buffer.from(`${base}.md: ${gitBlobHash(source)}\n${base}.zh.md: ${gitBlobHash(zh)}\n`))
expect(validateArchiveArtifacts(artifacts)).toEqual([])
})
it.each([
['Status: proposed\nArchived: 2026-07-26', /requires `Status: implemented`/],
['Status: implemented\n\nArchived: 2026-07-26', /immediately after the status/],
['Status: implemented\nArchived: 2026-02-30', /valid date/],
['Status: implemented\nArchived: 2026-07-25', /predates the note filename/],
['Status: implemented\nArchived: 2026-07-27', /English and Chinese archive dates differ/],
['Archived: 2026-07-26\nStatus: implemented\nArchived: 2026-07-27', /English and Chinese archive dates differ/],
])('rejects invalid archive metadata: %s', (metadata, error) => {
const artifacts = fixture()
const base = '2026-07-26-example'
const source = Buffer.from(`# Historical decision\n${metadata}\n`)
artifacts.set(`process/${base}.md`, source)
const zh = artifacts.get(`process/${base}.zh.md`)!
artifacts.set(`process/${base}.i18n.yaml`, Buffer.from(`${base}.md: ${gitBlobHash(source)}\n${base}.zh.md: ${gitBlobHash(zh)}\n`))
expect(validateArchiveArtifacts(artifacts).join('\n')).toMatch(error)
})
it('rejects incomplete triplets and invalid archive headers', () => {
const artifacts = fixture()
artifacts.delete('process/2026-07-26-example.i18n.yaml')
+11 -18
View File
@@ -94,27 +94,21 @@ function pairMeta(content: string): Map<string, string> | undefined {
return entries
}
function validateHeader(path: string, content: Buffer, sourceBase: string, chinese: boolean): string[] {
function validateMetadata(path: string, content: Buffer, sourceBase: string): { errors: string[]; date: string | undefined } {
const errors: string[] = []
const lines = content.toString('utf8').split('\n')
if (!/^# Agent Note: \S/.test(lines[0] ?? '')) errors.push(`${path}: line 1 must be \`# Agent Note: <title>\``)
if (lines[1] !== '') errors.push(`${path}: line 2 must be blank`)
if (lines[2] !== 'Status: implemented') errors.push(`${path}: line 3 must be \`Status: implemented\``)
const archived = /^Archived: (\d{4}-\d{2}-\d{2})$/.exec(lines[3] ?? '')?.[1]
if (archived === undefined || !validDate(archived)) {
errors.push(`${path}: line 4 must be \`Archived: YYYY-MM-DD\` with a valid date`)
const status = lines.findIndex(line => line.startsWith('Status:'))
if (lines[status] !== 'Status: implemented') errors.push(`${path}: requires \`Status: implemented\``)
const archived = /^Archived: (\d{4}-\d{2}-\d{2})$/.exec(lines[status + 1] ?? '')?.[1]
if (status < 0 || archived === undefined || !validDate(archived)) {
errors.push(`${path}: requires \`Archived: YYYY-MM-DD\` with a valid date immediately after the status`)
} else if (archived < sourceBase.slice(0, 10)) {
errors.push(`${path}: archive date ${archived} predates the note filename`)
}
if (lines[4] !== '') errors.push(`${path}: line 5 must be blank`)
const switcher = chinese
? `[English](${sourceBase}.md) | 中文`
: `English | [中文](${sourceBase}.zh.md)`
if (lines[5] !== switcher) errors.push(`${path}: line 6 must be ${JSON.stringify(switcher)}`)
return errors
return { errors, date: archived }
}
/** Validate the closed kind tree, implemented/archive headers, and complete bilingual triplets. */
/** Validate the closed kind tree, implemented/archive metadata, and complete bilingual triplets. */
export function validateArchiveArtifacts(artifacts: ReadonlyMap<string, Buffer>): string[] {
const errors: string[] = []
const triplets = new Map<string, Triplet>()
@@ -151,10 +145,9 @@ export function validateArchiveArtifacts(artifacts: ReadonlyMap<string, Buffer>)
continue
}
const sourceBase = basename(key)
errors.push(...validateHeader(sourcePath, source, sourceBase, false))
errors.push(...validateHeader(zhPath, zh, sourceBase, true))
const sourceDate = /^Archived: (\d{4}-\d{2}-\d{2})$/m.exec(source.toString('utf8'))?.[1]
const zhDate = /^Archived: (\d{4}-\d{2}-\d{2})$/m.exec(zh.toString('utf8'))?.[1]
const { errors: sourceErrors, date: sourceDate } = validateMetadata(sourcePath, source, sourceBase)
const { errors: zhErrors, date: zhDate } = validateMetadata(zhPath, zh, sourceBase)
errors.push(...sourceErrors, ...zhErrors)
if (sourceDate !== undefined && zhDate !== undefined && sourceDate !== zhDate) {
errors.push(`${key}: English and Chinese archive dates differ (${sourceDate} vs ${zhDate})`)
}
+38 -18
View File
@@ -155,7 +155,6 @@ describe('CI workflow', () => {
|| !isRecord(workflow.jobs['windows-build'])
|| !isRecord(workflow.jobs['windows-coverage'])
|| !isRecord(workflow.jobs['windows-native-tests'])
|| !isRecord(workflow.jobs['windows-observational'])
|| !isRecord(workflow.jobs['node-24'])
|| !isRecord(workflow.jobs['node-24-coverage'])
|| !isRecord(workflow.jobs['node-24-bench'])
@@ -164,13 +163,12 @@ describe('CI workflow', () => {
|| !isRecord(workflow.jobs['all-checks-passed'])
|| !isRecord(masterWorkflow.jobs)
|| !isRecord(masterWorkflow.jobs['serial-windows'])) {
throw new TypeError('CI workflow must define windows-build, windows-coverage, windows-native-tests, windows-observational, node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; ci-master must define serial-windows')
throw new TypeError('CI workflow must define windows-build, windows-coverage, windows-native-tests, node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; ci-master must define serial-windows')
}
const windowsBuild = workflow.jobs['windows-build']
const windowsCoverage = workflow.jobs['windows-coverage']
const windowsNativeTests = workflow.jobs['windows-native-tests']
const windowsObservational = workflow.jobs['windows-observational']
const serialWindows = masterWorkflow.jobs['serial-windows']
const node24 = workflow.jobs['node-24']
const node24Coverage = workflow.jobs['node-24-coverage']
@@ -182,32 +180,34 @@ describe('CI workflow', () => {
throw new TypeError('CI aggregate must define needs')
}
// The split native jobs all resolve their pool through the Windows switch.
for (const [jobName, job] of [['windows-build', windowsBuild], ['windows-coverage', windowsCoverage], ['windows-native-tests', windowsNativeTests], ['windows-observational', windowsObservational]] as const) {
for (const [jobName, job] of [['windows-build', windowsBuild], ['windows-coverage', windowsCoverage], ['windows-native-tests', windowsNativeTests]] as const) {
expect(typeof job['runs-on']).toBe('string')
expect(job['runs-on'], `${jobName} runs-on must use the Windows failover switch`).toContain('DSH_CI_FAILOVER_WINDOWS')
expect(job['runs-on'], `${jobName} runs-on must not use the Linux failover switch`).not.toContain('DSH_CI_FAILOVER_LINUX')
expect(job['runs-on']).toContain('self-hosted')
expect(job['runs-on']).toContain('dsh-win-ci')
expect(job['runs-on']).toContain('dsh-windows-2025-16core')
expect(job['runs-on']).toContain('blacksmith-16vcpu-windows-2025')
const cores = jobName === 'windows-native-tests' ? 2 : 16
expect(evaluateRunsOn(job['runs-on'], { vars: { DSH_CI_FAILOVER_WINDOWS: 'blacksmith' } }))
.toBe(`blacksmith-${cores}vcpu-windows-2025`)
expect(job.if).toBe("github.event_name == 'pull_request'")
}
// windows-build runs the blocking build/site pair.
expect(windowsBuild.name).toBe('windows node 24 / build')
expect(windowsBuild.name).toBe('windows node 24 / build & observational')
const buildSteps = windowsBuild.steps as unknown[]
const buildCommands = buildSteps.filter((step): step is Record<string, unknown> & { run: string } => (
isRecord(step) && typeof step.run === 'string'
))
expect(buildCommands.map(step => step.run)).toContain('pnpm run check:ci:windows-blocking')
// The four native Windows installs branch on the workspace filesystem:
// Native Windows installs branch on the workspace filesystem:
// clone (ReFS block clone) only on ReFS, plain install elsewhere. This
// keeps the TS6231 store-path leak (see the Windows ReFS store note) out
// of the self-hosted pool without forcing clone onto hosted NTFS, which
// rejects copy-on-write. The branch must stay, or a hosted fallback would
// fail installs with ERR_PNPM_LINKING_FAILED.
for (const [jobName, job] of [['windows-build', windowsBuild], ['windows-coverage', windowsCoverage], ['windows-native-tests', windowsNativeTests], ['windows-observational', windowsObservational]] as const) {
for (const [jobName, job] of [['windows-build', windowsBuild], ['windows-coverage', windowsCoverage], ['windows-native-tests', windowsNativeTests]] as const) {
const steps = job.steps as unknown[]
const install = steps.find((step): step is Record<string, unknown> & { run: string } => (
isRecord(step) && step.name === 'Install (immutable)' && typeof step.run === 'string'
@@ -230,9 +230,8 @@ describe('CI workflow', () => {
expect(install!.run).not.toContain('$cloneFlag')
}
// windows-coverage uses the lower 4-partition profile.
expect(windowsCoverage.name).toBe('windows node 24 / coverage')
expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '4' })
expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: "${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }}" })
const coverageSteps = windowsCoverage.steps as unknown[]
const coverageCommands = coverageSteps.filter((step): step is Record<string, unknown> & { run: string } => (
isRecord(step) && typeof step.run === 'string'
@@ -259,9 +258,30 @@ describe('CI workflow', () => {
expect(nativeTestCommand).toContain('tool-pwsh/tests/loader.spec.ts')
expect(nativeTestCommand).toContain('workflow-ptc.spec.ts')
// windows-observational is non-blocking.
expect(windowsObservational.name).toBe('windows node 24 / observational')
expect(windowsObservational['continue-on-error']).toBe(true)
expect(workflow.jobs['windows-observational']).toBeUndefined()
expect(windowsBuild['continue-on-error']).not.toBe(true)
const observational = buildCommands.find(step => step.id === 'observational')
expect(observational).toMatchObject({
run: 'pnpm run check:ci:windows-observational-ready',
shell: 'pwsh',
'continue-on-error': true,
'timeout-minutes': 15,
env: {
DSH_GATE_FAIL_FAST: '',
DSH_PUBLINT_CONCURRENCY: "${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}",
},
})
expect(observational?.if).toBeUndefined()
expect(buildCommands.findIndex(step => step.id === 'observational'))
.toBeGreaterThan(buildCommands.findIndex(step => step.run === 'pnpm run check:ci:windows-blocking'))
const report = buildCommands.find(step => step.name === 'Report Windows observational failures')
expect(report).toMatchObject({
'continue-on-error': true,
if: "steps.observational.outcome == 'failure'",
shell: 'pwsh',
})
expect(report?.run).toContain('::warning::')
expect(report?.run).toContain('Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append')
// serial-windows: master-only standby, self-hosted, non-blocking, lives in ci-master.
expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
@@ -329,7 +349,8 @@ describe('CI workflow', () => {
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(job['runs-on']).toContain('blacksmith-16vcpu-ubuntu-2404')
expect(evaluateRunsOn(job['runs-on'], { vars: { DSH_CI_FAILOVER_LINUX: 'blacksmith' } }))
.toBe(`blacksmith-${jobName === 'node-24' ? 8 : 16}vcpu-ubuntu-2404`)
}
expect(aggregate['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
expect(aggregate['runs-on']).not.toContain('DSH_CI_FAILOVER_WINDOWS')
@@ -383,8 +404,7 @@ describe('CI workflow', () => {
// The observational lane stays complete: it is continue-on-error by design
// and exists to collect as much Windows-native evidence per run as
// possible, so the first failure must not truncate the rest.
expect(windowsObservational.env).toBeDefined()
expect(windowsObservational.env).not.toMatchObject({ DSH_GATE_FAIL_FAST: '1' })
expect(observational?.env).toMatchObject({ DSH_GATE_FAIL_FAST: '' })
})
it('gates standalone keyless blacksmith jobs and benchmark tiers on the failover variables', () => {
@@ -608,8 +628,8 @@ describe('Runtime and LLM e2e Blacksmith routing', () => {
const workflow = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml')
const build = workflowJob(workflow, 'build')
for (const [target, runner, variable, blacksmith] of [
['node24-linux-x64', 'ubuntu-latest', 'DSH_CI_FAILOVER_LINUX', 'blacksmith-16vcpu-ubuntu-2404'],
['node24-win-x64', 'windows-2025', 'DSH_CI_FAILOVER_WINDOWS', 'blacksmith-16vcpu-windows-2025'],
['node24-linux-x64', 'ubuntu-latest', 'DSH_CI_FAILOVER_LINUX', 'blacksmith-2vcpu-ubuntu-2404'],
['node24-win-x64', 'windows-2025', 'DSH_CI_FAILOVER_WINDOWS', 'blacksmith-2vcpu-windows-2025'],
['node24-linux-arm64', 'ubuntu-24.04-arm', 'DSH_CI_FAILOVER_LINUX', 'ubuntu-24.04-arm'],
['node24-macos-arm64', 'macos-latest', 'DSH_CI_FAILOVER_LINUX', 'macos-latest'],
['node24-macos-x64', 'macos-15-intel', 'DSH_CI_FAILOVER_LINUX', 'macos-15-intel'],
+3
View File
@@ -142,6 +142,9 @@ describe('client bundle purity gate', () => {
expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/)
expect(resolveId('@deepseek-ai/dsh-host-open-in-app/shared')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-host-open-in-app')).toThrow(/purity/)
expect(resolveId('@deepseek-ai/dsh-plugin-manager/registry')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-plugin-manager')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-plugin-manager/registry/internal')).toThrow(/purity/)
})
it('admits only the pure spill notice entry, not its Host policy', () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"AGENTS.md": 1950,
"docs/AGENTS.md": 1320,
"docs/architecture.md": 2400,
"docs/architecture.md": 2410,
"docs/cordis-primer.md": 600,
"docs/defensive-patterns.md": 550,
"docs/testing.md": 1350,
+3
View File
@@ -176,6 +176,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API',
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-chat/README.md owns the API',
commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API',
feedbackUi: 'client-side feedback dialog service — packages/client/ui-message-feedback/README.md owns the API',
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API',
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the API',
locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the API',
@@ -277,6 +278,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
PluginChange: 'boot.md',
PluginInstallLogChunk: 'boot.md',
PluginInstallProgress: 'boot.md',
PluginRegistries: 'boot.md',
InspectOptions: 'boot.md',
BrowserUseProviderName: 'browser-use.md',
ComputerUseProviderName: 'computer-use.md',
RenderedDocumentBytes: 'office-to-pdf.md',
-1
View File
@@ -49,7 +49,6 @@ describe('release families', () => {
.filter(member => member.directory.startsWith('packages/experimental/'))
.map(member => member.name)).toEqual([
'@deepseek-ai/dsh-experimental-agent-team-profile',
'@deepseek-ai/dsh-experimental-agent-team-web-profile',
'@deepseek-ai/dsh-experimental-agent-team',
'@deepseek-ai/dsh-experimental-auto-review',
'@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp',
+124 -10
View File
@@ -1,7 +1,8 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi, type MockInstance } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import {
cliGateOptions,
ciWorkerEnvironment,
collectDescendants,
defaultConcurrency,
formatGateResultReason,
@@ -14,6 +15,10 @@ import {
type GateResult,
} from './run-gates.ts'
// Graph fixtures select their own browser pool instead of inheriting the CI host's pool.
beforeEach(() => vi.stubEnv('DSH_WEB_SNAPSHOT_WORKERS', undefined))
afterEach(() => vi.unstubAllEnvs())
/**
* Capture output a gate streams through runGate's streamOutput path.
* @returns the accumulated chunks and the stdout spy to restore in finally.
@@ -135,6 +140,59 @@ function withEnv<T>(name: string, value: string | undefined, action: () => T): T
}
}
describe('CI worker allocation', () => {
it.each([1, 2, 4, 8, 16, 64])('shares a %i CPU coverage budget without multiplying pools', (cpus) => {
const env = ciWorkerEnvironment('ci-coverage', {}, cpus)
const exempt = Math.max(1, Math.floor(cpus / 3))
const instrumented = Number(env.DSH_COVERAGE_PARTITIONS ?? 1)
expect(Number(env.DSH_COVERAGE_MAX_WORKERS)).toBe(cpus)
expect(instrumented + exempt).toBe(Math.max(2, cpus))
expect(defaultConcurrency('ci-coverage', 3, cpus).workers).toBe(Math.min(3, cpus))
if (cpus <= 2) expect(env.DSH_COVERAGE_PARTITIONS).toBeUndefined()
})
it('bounds overlapping readers and lets the isolated browser pool use the runner', () => {
const env = ciWorkerEnvironment('ci-consumers', {}, 16)
expect(env).toMatchObject({
DSH_OXLINT_THREADS: '8',
DSH_PUBLINT_CONCURRENCY: '8',
DSH_SNAPSHOT_MAX_WORKERS: '1',
DSH_SNAPSHOT_MAX_CONCURRENCY: '8',
DSH_WEB_SNAPSHOT_WORKERS: '16',
})
})
it('preserves serial reference overrides on large hosts', () => {
const inherited = {
DSH_GATE_CONCURRENCY: '1',
DSH_COVERAGE_MAX_WORKERS: '1',
DSH_OXLINT_THREADS: '1',
DSH_PUBLINT_CONCURRENCY: '1',
DSH_SNAPSHOT_MAX_CONCURRENCY: '1',
DSH_WEB_SNAPSHOT_WORKERS: '1',
}
const additions = ciWorkerEnvironment('ci-primary', inherited, 64)
expect(additions).toEqual({ DSH_SNAPSHOT_MAX_WORKERS: '1' })
expect(inherited.DSH_COVERAGE_MAX_WORKERS).toBe('1')
})
it('honors an explicit coverage budget and partition override', () => {
expect(ciWorkerEnvironment('ci-coverage', { DSH_COVERAGE_MAX_WORKERS: '6' }, 16))
.toHaveProperty('DSH_COVERAGE_PARTITIONS', '4')
expect(ciWorkerEnvironment('ci-coverage', { DSH_COVERAGE_PARTITIONS: '3' }, 16))
.not.toHaveProperty('DSH_COVERAGE_PARTITIONS')
})
it.each(['0', '-1', 'NaN', '2.5'])('rejects invalid worker budget %s', (raw) => {
expect(() => ciWorkerEnvironment('ci-coverage', { DSH_COVERAGE_MAX_WORKERS: raw }, 16))
.toThrow('DSH_COVERAGE_MAX_WORKERS must be a positive integer')
})
it('keeps local documentation defaults unchanged', () => {
expect(ciWorkerEnvironment('doc-sync', {}, 64)).toEqual({})
})
})
describe('gate graph validation', () => {
it.each([
'ci-primary',
@@ -148,7 +206,7 @@ describe('gate graph validation', () => {
'ci-consumers',
'ci-windows-blocking',
'ci-windows-complete',
'ci-windows-observational',
'ci-windows-observational-ready',
'node-compat',
'check-all',
'hygiene',
@@ -260,9 +318,14 @@ describe('gate graph validation', () => {
expect(ids).toEqual([
'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'package-dependencies', 'application-entrypoints',
'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config',
'runtime-closure',
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher',
'cordis-config', 'runtime-closure',
])
})
it('caps the local hygiene aggregate at four workers', () => {
const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({
workers: 4,
source: '8 available CPU(s), hygiene cap 4',
@@ -344,6 +407,15 @@ describe('gate graph validation', () => {
},
)
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
'keeps browser app-route resolution enforcement in %s',
(mode) => {
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
expect(ids).toContain('client-route-resolution')
},
)
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
'keeps application entrypoint enforcement in %s',
(mode) => {
@@ -355,8 +427,8 @@ describe('gate graph validation', () => {
it('keeps native Windows coverage blocking and behind the complete build', () => {
const complete = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational'))
.filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build')
const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational-ready'))
.filter(gate => gate.id !== 'docs-site-build')
const byId = new Map(complete.map(subject => [subject.id, subject]))
expect(byId.get('coverage')?.allowFailure).not.toBe(true)
@@ -375,12 +447,12 @@ describe('gate graph validation', () => {
'coverage',
'coverage-exempt-heavy',
]))
expect(completeGate?.needs).toEqual(gate.needs)
expect((completeGate?.needs ?? []).filter(id => id !== 'build')).toEqual(gate.needs ?? [])
}
})
it('runs the Windows built-bin smoke after other observational gates settle', () => {
const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational'))
const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational-ready'))
const builtBin = observational.find(gate => gate.id === 'built-bin-smoke')
expect(builtBin?.after).toEqual(
@@ -393,6 +465,48 @@ describe('gate graph validation', () => {
expect(completeBuiltBin?.after).not.toContain('docs-site-build')
})
it('reuses the Windows build without dropping diagnostics or rebuilding the workspace', () => {
const ready = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational-ready'))
const complete = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
scripts: Record<string, string>
}
expect(scripts['check:ci:windows-observational-ready']).toBe('tsx scripts/run-gates.ts ci-windows-observational-ready')
expect(scripts).not.toHaveProperty('check:ci:windows-observational')
const completeOnly = new Set(['build', 'windows-site', 'native-system', 'coverage', 'coverage-exempt-heavy'])
const shared = complete.filter(gate => !completeOnly.has(gate.id))
expect(ready.map(gate => gate.id).sort()).toEqual([...shared.map(gate => gate.id), 'docs-site-build'].sort())
expect(ready.some(gate => gate.id === 'build')).toBe(false)
expect(ready.find(gate => gate.id === 'docs-site-build')?.displayCommand).toBe('pnpm run docs:build:mpa')
for (const diagnostic of shared) {
expect(ready.find(gate => gate.id === diagnostic.id)).toMatchObject({
command: diagnostic.command,
args: diagnostic.args,
})
expect(ready.find(gate => gate.id === diagnostic.id)?.env).toEqual(diagnostic.env)
}
expect(complete.find(gate => gate.id === 'build')).toBeDefined()
expect(complete.find(gate => gate.id === 'coverage')).toBeDefined()
})
it('runs built Windows smoke after failed diagnostics settle on an existing build', async () => {
const ready = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational-ready'))
const settled = new Set<string>()
const results = await runGates(ready, 8, async (subject) => {
if (subject.id === 'built-bin-smoke') {
expect(settled.size).toBe(ready.length - 1)
expect(settled.has('doc-graphs')).toBe(true)
}
settled.add(subject.id)
return resultFor(subject, subject.id === 'doc-graphs' ? 'failed' : 'passed')
})
expect(results.filter(result => result.status === 'failed').map(result => result.gate.id)).toEqual(['doc-graphs'])
expect(results.find(result => result.gate.id === 'built-bin-smoke')?.status).toBe('passed')
expect(results.some(result => result.status === 'skipped')).toBe(false)
})
it('applies one configured test, polling, and hook timeout to both coverage gates', () => {
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () =>
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
@@ -601,8 +715,8 @@ describe('Node 24 lane ownership', () => {
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
workers: 11,
source: 'ci-consumers gate count',
workers: 4,
source: '4 available CPU(s)',
})
expect(subject.map(item => item.id)).toEqual([
'build',
+65 -19
View File
@@ -33,7 +33,7 @@ export type Mode =
| 'ci-consumers'
| 'ci-windows-blocking'
| 'ci-windows-complete'
| 'ci-windows-observational'
| 'ci-windows-observational-ready'
| 'node-compat'
| 'check-all'
| 'hygiene'
@@ -102,6 +102,9 @@ if (import.meta.main) {
async function main(args: string[]): Promise<number> {
const mode = parseMode(args[0])
const workerEnv = ciWorkerEnvironment(mode, process.env)
Object.assign(process.env, workerEnv)
if (Object.keys(workerEnv).length > 0) console.log(`run-gates: worker settings ${JSON.stringify(workerEnv)}`)
const gates = gatesForMode(mode)
const concurrencyDefault = defaultConcurrency(mode, gates.length)
const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY
@@ -144,7 +147,7 @@ function parseMode(raw: string | undefined): Mode {
case 'ci-consumers':
case 'ci-windows-blocking':
case 'ci-windows-complete':
case 'ci-windows-observational':
case 'ci-windows-observational-ready':
case 'node-compat':
case 'check-all':
case 'hygiene':
@@ -153,7 +156,7 @@ function parseMode(raw: string | undefined): Mode {
return raw
default:
throw new Error(
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint-contracts-ready | ci-coverage | ci-bench | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | hygiene | doc-sync | doc-quick, got ${JSON.stringify(raw)}.`,
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint-contracts-ready | ci-coverage | ci-bench | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational-ready | node-compat | check-all | hygiene | doc-sync | doc-quick, got ${JSON.stringify(raw)}.`,
)
}
}
@@ -170,7 +173,6 @@ export function defaultConcurrency(
total: number,
available = availableParallelism(),
): ConcurrencyDefault {
if (selectedMode === 'ci-consumers') return { workers: total, source: 'ci-consumers gate count' }
// Local modes cap workers: several doc gates each build a full ts.Program,
// so an uncapped default on a large host trades wall clock for memory blowups.
const localCap = selectedMode === 'check-all'
@@ -186,6 +188,41 @@ export function defaultConcurrency(
}
}
/**
* Fill CI worker defaults from the CPU allocation without replacing explicit overrides.
* Coverage shares one budget; artifact readers share CPUs with sibling commands.
* @param mode - aggregate selected by the caller.
* @param env - inherited worker overrides, including serial reference settings.
* @param available - CPUs available to this runner process.
* @returns environment additions for this aggregate and its children.
*/
export function ciWorkerEnvironment(
mode: Mode,
env: NodeJS.ProcessEnv,
available = availableParallelism(),
): Record<string, string> {
if (!mode.startsWith('ci-')) return {}
const additions: Record<string, string> = {}
const setDefault = (name: string, value: number): void => {
if (env[name] === undefined || env[name] === '') additions[name] = String(value)
}
const shared = Math.max(1, Math.floor(available / 2))
setDefault('DSH_OXLINT_THREADS', shared)
setDefault('DSH_PUBLINT_CONCURRENCY', shared)
setDefault('DSH_SNAPSHOT_MAX_WORKERS', 1)
setDefault('DSH_SNAPSHOT_MAX_CONCURRENCY', shared)
setDefault('DSH_WEB_SNAPSHOT_WORKERS', env.DSH_GATE_CONCURRENCY === '1' ? 1 : available)
setDefault('DSH_COVERAGE_MAX_WORKERS', available)
const coverageBudget = env.DSH_COVERAGE_MAX_WORKERS || String(available)
const total = Number(coverageBudget)
if (!Number.isSafeInteger(total) || total < 1) {
throw new Error(`run-gates: DSH_COVERAGE_MAX_WORKERS must be a positive integer, got ${JSON.stringify(coverageBudget)}.`)
}
const instrumented = Math.max(1, total - Math.max(1, Math.floor(total / 3)))
if (instrumented > 1) setDefault(COVERAGE_PARTITIONS_ENV, instrumented)
return additions
}
function concurrencyFromEnv(name: string, fallback: number): number {
const raw = process.env[name]
if (raw === undefined || raw === '') return fallback
@@ -256,8 +293,15 @@ export function gatesForMode(selected: Mode): Gate[] {
return ciWindowsBlockingGates()
case 'ci-windows-complete':
return ciWindowsCompleteGates()
case 'ci-windows-observational':
case 'ci-windows-observational-ready':
// The caller owns the successful workspace build; all diagnostics remain.
return ciWindowsObservationalGates()
.filter(gate => gate.id !== 'build')
.map(gate => ({
...gate,
...gate.needs === undefined ? {} : { needs: gate.needs.filter(id => id !== 'build') },
...gate.after === undefined ? {} : { after: gate.after.filter(id => id !== 'build') },
}))
case 'node-compat':
return nodeCompatGates()
case 'check-all':
@@ -304,14 +348,21 @@ function ciSharedStaticGates(): Gate[] {
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' }),
...sharedHygieneGates(),
pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
]
}
function sharedHygieneGates(): Gate[] {
return [
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
label: 'optional dependency imports',
}),
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
pnpmScript('client-route-resolution', 'verify-client-route-resolution', { label: 'client route resolution' }),
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
]
}
@@ -498,8 +549,8 @@ function webSnapshotGate(needs: string[], after?: string[]): Gate {
const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS
if (workerRaw !== undefined && workerRaw !== '') {
const workers = Number.parseInt(workerRaw, 10)
if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) {
throw new Error(`run-gates: DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`)
if (!Number.isSafeInteger(workers) || workers < 1 || String(workers) !== workerRaw) {
throw new Error(`run-gates: DSH_WEB_SNAPSHOT_WORKERS must be a positive integer, got ${JSON.stringify(workerRaw)}.`)
}
return pnpmScript('web-snapshot', 'test:web:ci', {
label: 'web browser snapshot',
@@ -593,10 +644,9 @@ function lintGate(options: { needs?: string[] } = {}): Gate {
// under v8 instrumentation while contributing nothing the thresholds need
// (membership rules in scripts/coverage-exempt.ts).
//
// DSH_COVERAGE_MAX_WORKERS is the ordinary lane's worker budget, so the two
// parallel gates split it instead of each claiming it whole. When
// DSH_COVERAGE_PARTITIONS is set, its single-worker processes replace the
// instrumented share while this budget still sizes the exempt gate. The exempt
// DSH_COVERAGE_MAX_WORKERS is shared between the two parallel gates. CI
// defaults its partition count to the instrumented share; an explicit
// DSH_COVERAGE_PARTITIONS overrides that share independently. The exempt
// gate's wall clock is dominated by its longest single file, so it takes the
// small share. A budget of 1 gives each gate 1 worker; lanes that need a strict
// total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1,
@@ -711,12 +761,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
label: 'node-next types',
...artifactOptions,
}),
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
label: 'optional dependency imports',
}),
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
...sharedHygieneGates(),
]
}
@@ -779,6 +824,7 @@ function docSyncLeafGates(options: {
pnpmExec('docs-site-projection', [
'vitest', 'run', 'scripts/project-doc-site.spec.ts', 'scripts/verify-doc-site-fragments.spec.ts',
'website/tests/mermaid-viewer.spec.ts',
'website/tests/code-groups.spec.ts',
'website/tests/page-markdown-actions.spec.ts', 'website/tests/raw-markdown.spec.ts',
], {
label: 'documentation site checks',
+2 -2
View File
@@ -7,8 +7,8 @@ const serialFiles = [
]
const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS
const workers = Number.parseInt(workerRaw ?? '', 10)
if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) {
throw new Error(`DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`)
if (!Number.isSafeInteger(workers) || workers < 1 || String(workers) !== workerRaw) {
throw new Error(`DSH_WEB_SNAPSHOT_WORKERS must be a positive integer, got ${JSON.stringify(workerRaw)}.`)
}
const invocation = pnpmInvocation(['exec', 'vitest', 'run', '--config', 'vitest.web.config.ts'])
let serialStatus = 0
+40 -17
View File
@@ -29,31 +29,54 @@ export const repositoryConfigHost: ts.ParseConfigFileHost = {
},
}
/**
* 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, face: CompilerFace): ProjectGraph {
const rootConfigPath = resolve(projectRoot, `tsconfig.${face}.json`)
const rootConfig = parseConfig(rootConfigPath)
const rootNames = new Set<string>()
const visited = new Set<string>()
/** One compiler face aggregate: its own config and every project it compiles. */
export interface FaceConfigs {
/** The aggregate's `tsconfig.<face>.json`. */
readonly root: ts.ParsedCommandLine
/** Every config the face compiles, its own aggregate included, keyed by resolved config path. */
readonly byPath: ReadonlyMap<string, ts.ParsedCommandLine>
}
const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => {
if (visited.has(configPath)) return
visited.add(configPath)
for (const fileName of parsed.fileNames) rootNames.add(fileName)
/**
* Parse one compiler face aggregate and every project it references. Never seed
* the root solution: the two faces are separate graphs, and flattening
* host+client into one program collides the cordis Context merges.
* @param projectRoot - repository root holding `tsconfig.<face>.json`.
* @param face - which aggregate to parse.
* @returns the aggregate's root config and every config it compiles.
*/
export function faceConfigs(projectRoot: string, face: CompilerFace): FaceConfigs {
const rootPath = resolve(projectRoot, `tsconfig.${face}.json`)
const root = parseConfig(rootPath)
const byPath = new Map<string, ts.ParsedCommandLine>([[rootPath, root]])
const collect = (parsed: ts.ParsedCommandLine): void => {
for (const reference of parsed.projectReferences ?? []) {
const referencePath = ts.resolveProjectReferencePath(reference)
collect(referencePath, parseConfig(referencePath))
if (byPath.has(referencePath)) continue
const referenced = parseConfig(referencePath)
byPath.set(referencePath, referenced)
collect(referenced)
}
}
collect(rootConfigPath, rootConfig)
collect(root)
return { root, byPath }
}
/**
* Flatten one compiler face aggregate into one semantic graph.
* @param projectRoot - repository root holding `tsconfig.<face>.json`.
* @param face - which aggregate to flatten.
* @returns every root name the face compiles, with the aggregate's options.
*/
function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph {
const { root, byPath } = faceConfigs(projectRoot, face)
const rootNames = new Set<string>()
for (const parsed of byPath.values()) {
for (const fileName of parsed.fileNames) rootNames.add(fileName)
}
return {
rootNames: [...rootNames],
options: rootConfig.options,
options: root.options,
}
}
@@ -32,6 +32,7 @@ const MANIFEST_BIN_ALLOWLIST = new Map<string, ManifestBin>([
/** Every JavaScript executable in an application or packaging workspace has one explicit role. */
const EXECUTABLE_SOURCE_ALLOWLIST = new Map<string, string>([
['apps/cli/src/bin.ts', 'supported dsh application launcher'],
['apps/desktop/scripts/logged-notarytool.mjs', 'build-only notarization logging wrapper'],
['packages/context/time-context/tests/fixtures/driver.ts', 'test-only subprocess driver'],
['packages/experimental/webworker-packer/bin.js', 'build-only wrapper'],
['packages/experimental/webworker-packer/src/bin.ts', 'build-only implementation'],
@@ -0,0 +1,199 @@
/** Regression coverage for the browser app-route guard. */
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { describe, expect, it } from 'vitest'
import { browserFaceSources, findRouteResolutionViolations, type RouteGateFace } from './verify-client-route-resolution.ts'
function patterns(file: string, source: string, face?: RouteGateFace): string[] {
return findRouteResolutionViolations(file, source, face).map(violation => violation.pattern)
}
function rules(file: string, source: string, face?: RouteGateFace): string[] {
return findRouteResolutionViolations(file, source, face).map(violation => violation.rule)
}
const CLIENT_FILE = 'packages/client/ui-example/src/client/View.tsx'
const PRODUCER_FILE = 'packages/client/modules/src/index.ts'
describe('browser app-route guard', () => {
it('accepts document-relative request targets and routes that are not targets', () => {
expect(patterns(CLIENT_FILE, `
export const PRESENT_OPEN_PATH = '/api/present.open'
const CHANNEL = '/api'
export async function load(id: string) {
const bundle = 'plugins/local.js'
const icon = \`open-in-app/icon/\${id}\`
const file = new URL(\`api/file?path=\${id}\`, document.baseURI)
await customFetch('api/session.export?sessionId=s1')
await connection.rpc.call('/api', 'goals/create', {})
webserver.register({ path: '/plugins' })
const identity = window.location.hostname
const foreign = 'https://cdn.example/app.js'
const asset = '/assets/app.js'
return [bundle, icon, file.href, CHANNEL, identity, foreign, asset]
}
`)).toEqual([])
})
it('rejects every request target that binds a route to one mount', () => {
expect(findRouteResolutionViolations(CLIENT_FILE, `
export async function load(id: string) {
const origin = window.location.origin
await fetch('/api/remote.mux')
await fetch(\`/plugins/\${id}/client.js\`)
await fetch(\`\${origin}/api/file?path=\${id}\`)
await this.fetcher('/api/custom')
new EventSource('/plugins/events')
new WebSocket('wss://harness.example/api/remote.mux')
document.querySelector('script').src = '//harness.example/api/file'
return id
}
`).map(violation => [violation.line, violation.rule])).toEqual([
[4, 'request-target'], [5, 'request-target'], [6, 'request-target'], [7, 'request-target'],
[8, 'request-target'], [9, 'request-target'], [10, 'request-target'],
])
})
it('rejects a root-absolute app route in a JSX resource attribute', () => {
expect(findRouteResolutionViolations(CLIENT_FILE, `
export function View({ id }: { id: string }) {
return <div>
<img src="/api/file?path=x" />
<img src={\`\${window.location.origin}/api/file?path=\${id}\`} />
<img src={\`/plugins/\${id}/preview.png\`} />
<a href="open-in-app/apps">apps</a>
<a href="https://cdn.example/app.js">cdn</a>
</div>
}
`).map(violation => violation.line)).toEqual([4, 5, 6])
})
it('rejects a shared host route key used without stripping its leading slash', () => {
const source = `
import { PRESENT_OPEN_PATH, EVENTS_ENDPOINT } from '../presented.ts'
await fetch(PRESENT_OPEN_PATH)
await fetch(\`\${EVENTS_ENDPOINT}?since=1\`)
await fetch(PRESENT_OPEN_PATH.slice(1))
`
expect(findRouteResolutionViolations(CLIENT_FILE, source).map(violation => violation.line)).toEqual([3, 4])
expect(rules(CLIENT_FILE, source)).toEqual(['host-route-key', 'host-route-key'])
})
it('rejects a relative app route resolved against a location read', () => {
expect(findRouteResolutionViolations(CLIENT_FILE, `
const url = new URL('api/session.export?sessionId=s1', window.location.origin)
const ok = new URL('api/session.export?sessionId=s1', document.baseURI)
const mapped = new URL(input, globalThis.location.origin)
return [url, ok, mapped]
`).map(violation => [violation.line, violation.rule])).toEqual([[2, 'location-base']])
})
it('leaves identity and transport reads of location alone', () => {
expect(patterns(CLIENT_FILE, `
const trusted = window.location.hostname === 'localhost'
const requested = new URL(location.href).searchParams.get('source')
const { origin } = document.location
return [trusted, requested, origin]
`)).toEqual([])
})
})
describe('browser-reference producers', () => {
it('rejects a root-absolute app route stamped into a field the browser resolves', () => {
expect(findRouteResolutionViolations(PRODUCER_FILE, `
const bootstrap = { phase: 'bootstrap', url: \`/plugins/??\${resources}&rev=\${rev}\`, rev, entries }
const row = { id, url: 'plugins/boot.js', initialUrl: '/plugins/all.js', rev }
const map = { src: 'https://harness.example/plugins/??a/client.js.map&rev=r' }
const link = { href: '/open-in-app/apps' }
const script = { src: '//harness.example/plugins/a.js' }
const key = \`/plugins/\${comboSearch(ids, rev)}\`
return [bootstrap, row, map, link, script, key]
`, 'reference-producer').map(violation => [violation.line, violation.rule])).toEqual(
[2, 3, 4, 5, 6].map(line => [line, 'reference-producer']),
)
})
it('leaves internal route keys and generated source names alone', () => {
expect(patterns(PRODUCER_FILE, `
const PLUGIN_ROUTE = '/plugins'
const key = \`\${PLUGIN_ROUTE}/\${comboSearch(ids, rev)}\`
const section = { map: { sources: [\`/plugins/\${record.entry.id}/client.js\`] } }
return [key, section]
`, 'reference-producer')).toEqual([])
})
})
/**
* Write one fixture project: its config, its source files, and its declared
* libraries. The `src` tree the discovery anchors on always exists.
*/
function writeProject(root: string, name: string, options: {
lib: readonly string[]
/** Project-relative source paths to write. */
sources: readonly string[]
/** Config `include` patterns; defaults to the `src` tree. */
include?: readonly string[]
}): void {
const dir = join(root, name)
mkdirSync(join(dir, 'src'), { recursive: true })
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
compilerOptions: { lib: options.lib, noEmit: true },
include: [...options.include ?? ['src']],
}))
for (const source of options.sources) {
mkdirSync(dirname(join(dir, source)), { recursive: true })
writeFileSync(join(dir, source), 'export {}\n')
}
}
/** Write the two face aggregates of one fixture root over the named projects. */
function writeFaceAggregates(root: string, client: readonly string[], host: readonly string[]): void {
const references = (names: readonly string[]): { path: string }[] => names.map(name => ({ path: `./${name}` }))
writeFileSync(join(root, 'tsconfig.client.json'), JSON.stringify({ files: [], references: references(client) }))
writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({ files: [], references: references(host) }))
}
describe('browser face discovery', () => {
it('takes every DOM client project, and only its src/client half when the Host aggregate also compiles it', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-route-face-'))
try {
writeProject(root, 'packages/client/browser-package', { lib: ['ES2024', 'DOM'], sources: ['src/view.ts', 'src/globals.d.ts'] })
// Compiles in both aggregates and keeps its browser code in plain `src`.
writeProject(root, 'packages/client/shared-package', { lib: ['ES2024', 'DOM'], sources: ['src/both-faces.ts'] })
// Compiles in both aggregates but has a `src/client` browser half, so its
// plain `src` (the node half) is out of scope.
writeProject(root, 'packages/client/dual-package', { lib: ['ES2024', 'DOM'], sources: ['src/client/View.tsx', 'src/node.ts'] })
writeProject(root, 'packages/client/host-package', { lib: ['ES2024'], sources: ['src/host.ts'] })
writeFaceAggregates(root, [
'packages/client/browser-package', 'packages/client/shared-package',
'packages/client/dual-package', 'packages/client/host-package',
], ['packages/client/shared-package', 'packages/client/dual-package', 'packages/client/host-package'])
expect(browserFaceSources(root)).toEqual([
'packages/client/browser-package/src/view.ts',
'packages/client/dual-package/src/client/View.tsx',
'packages/client/shared-package/src/both-faces.ts',
])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('fails loud when a DOM client project compiles nothing from its source tree', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-route-face-moved-'))
try {
// The DOM face still has a `src` directory, but its config now compiles a
// tree the discovery does not know: scanning less must not pass quietly.
writeProject(root, 'browser-package', { lib: ['DOM'], sources: ['lib/moved.ts'], include: ['lib'] })
writeProject(root, 'host-package', { lib: ['ES2024'], sources: ['src/host.ts'] })
writeFaceAggregates(root, ['browser-package'], ['host-package'])
expect(() => browserFaceSources(root)).toThrow(/contributes no source file/u)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
+339
View File
@@ -0,0 +1,339 @@
/**
* Browser-face route gate: an app-owned route reachable from browser code is
* document-relative. Only request targets are governed: a root-absolute
* (`/api/x`), protocol-relative (`//host/api/x`) or absolute
* (`https://host/api/x`) app route used to address a resource binds the bundle
* to one mount, and a relative app route resolved against a `location` read
* rebuilds the base the served document already provides. A request target is
* the first argument of a request constructor, of a `fetch`/`fetcher` callee,
* or of a dynamic import; an assigned resource property (`script.src = ...`);
* or a JSX `src`/`href` attribute.
*
* Route keys stay absolute pathnames — the RPC channel key, the path a route is
* registered under, and the shared `*_PATH`/`*_ENDPOINT` constants carrying
* them — so a literal that is not a request target is out of scope. A browser
* half addresses such a key by stripping its leading slash, which the shared-key
* rule below requires.
*
* The static producers of browser references are checked in the same pass: a
* field the browser resolves against its document (`url`, `src`, `href`)
* must not be stamped with a root-absolute app route. Internal route keys and
* response-table keys stay absolute and are not references. This rule reads
* inline literal fragments only, so a composed reference
* (`comboReference(...)`, `artifact.url.slice(1)`) is not traced:
* `packages/client/modules/tests/node-half.client.spec.ts` owns the regression
* signal for composed graph rows, batch descriptors, and the source-map trailer.
*
* Rationale: .agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md.
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
import { dirname, relative, resolve, sep } from 'node:path'
import ts from 'typescript'
import { faceConfigs } from './ts-project.ts'
const root = resolve(import.meta.dirname, '..')
const GATE = 'verify-client-route-resolution'
/** App-owned route prefixes; a foreign path (`/assets/x`) is not governed here. */
const APP_ROUTE_PREFIX = '(?:api|plugins|open-in-app)(?:/|$)'
/** A root-absolute or protocol-relative app route literal. */
const ROOT_ABSOLUTE_ROUTE = new RegExp(`^/{1,2}${APP_ROUTE_PREFIX}`)
/** An absolute or protocol-relative app route literal on any origin or scheme. */
const ABSOLUTE_ROUTE = new RegExp(`^(?:[A-Za-z][A-Za-z\\d+.-]*:)?//[^/]+/${APP_ROUTE_PREFIX}`)
/** A relative app route reference (`api/x`, `./plugins/x`). */
const RELATIVE_APP_ROUTE = new RegExp(`^(?:\\./)*${APP_ROUTE_PREFIX}`)
/** A shared host route key: the absolute pathname a route is registered under. */
const HOST_ROUTE_KEY = /(?:^|_)(?:PATH|ENDPOINT)$/
/** Request constructors whose first argument addresses a resource. */
const REQUEST_CONSTRUCTORS: Record<string, true> = { Request: true, EventSource: true, WebSocket: true, URL: true }
/** Assigned properties and JSX attributes that address a resource. */
const REQUEST_PROPERTIES: Record<string, true> = { href: true, src: true }
/** Fields a browser resolves against its own document. */
const REFERENCE_FIELDS: Record<string, true> = { url: true, src: true, href: true, initialUrl: true }
/**
* Static producer of browser references: the node half composes the boot graph
* the browser resolves against its document. No request-target position in
* browser code sees those values, so its reference fields are checked directly.
*/
const REFERENCE_PRODUCERS = [
'packages/client/modules/src/index.ts',
]
/** Rule that produced a violation, for diagnostics and for the gate's own coverage. */
export type RouteResolutionRule = 'request-target' | 'location-base' | 'host-route-key' | 'reference-producer'
/** What a scanned file is: browser face, or a static producer of browser references. */
export type RouteGateFace = 'browser' | 'reference-producer'
const HINT: Record<RouteResolutionRule, string> = {
'request-target': 'write the route document-relative (api/x)',
'location-base': 'resolve the route against document.baseURI, not against a location read',
'host-route-key': 'strip the leading slash before the browser uses this key (KEY.slice(1))',
'reference-producer': 'emit the browser reference document-relative (plugins/x)',
}
/** One app-owned route reached from a place that binds it to one mount. */
export interface RouteResolutionViolation {
/** One-based source column. */
column: number
/** Repository-relative source path. */
file: string
/** One-based source line. */
line: number
/** The offending expression, as written. */
pattern: string
/** Rule that recognized the route. */
rule: RouteResolutionRule
}
/** A location expression: `location`, optionally behind its owning global. */
const LOCATION_EXPRESSION = /^(?:(?:window|self|globalThis|document|top|parent|frames)\.)?(?:defaultView\.)?location$/
/** Source text of a location expression, without whitespace. */
function locationText(node: ts.Expression): string | undefined {
const text = node.getText().replace(/\s+/g, '')
return LOCATION_EXPRESSION.test(text) ? text : undefined
}
/** Whether an expression rebuilds a base from the page location anywhere inside it. */
function readsLocation(node: ts.Node): boolean {
if (ts.isExpression(node) && locationText(node) !== undefined) return true
return node.forEachChild(readsLocation) === true
}
/** Host route keys an expression references without stripping their leading slash. */
function unstrippedHostRouteKeys(node: ts.Node): ts.Identifier[] {
if (ts.isIdentifier(node)) {
if (!HOST_ROUTE_KEY.test(node.text)) return []
const derived = ts.isPropertyAccessExpression(node.parent) && node.parent.name.text === 'slice'
return derived ? [] : [node]
}
const keys: ts.Identifier[] = []
node.forEachChild((child) => { keys.push(...unstrippedHostRouteKeys(child)) })
return keys
}
/** Callee name of a call or construction, for constructors and `fetch`-shaped carriers. */
function calleeName(expression: ts.Expression): string | undefined {
if (ts.isIdentifier(expression)) return expression.text
return ts.isPropertyAccessExpression(expression) ? expression.name.text : undefined
}
/**
* The request target one node carries, when it addresses a resource.
* @param node - any node visited in the tree.
* @returns the target expression, or undefined when the node is not a target.
*/
function requestTargetOf(node: ts.Node): ts.Expression | undefined {
if (ts.isCallExpression(node)) {
const name = calleeName(node.expression)
if ((name !== undefined && /fetch(er)?$/iu.test(name)) || node.expression.kind === ts.SyntaxKind.ImportKeyword) return node.arguments[0]
return undefined
}
if (ts.isNewExpression(node)) {
const name = calleeName(node.expression)
return name !== undefined && REQUEST_CONSTRUCTORS[name] === true ? node.arguments?.[0] : undefined
}
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
&& ts.isPropertyAccessExpression(node.left) && REQUEST_PROPERTIES[node.left.name.text] === true) {
return node.right
}
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && REQUEST_PROPERTIES[node.name.text] === true) {
const { initializer } = node
if (initializer === undefined) return undefined
return ts.isJsxExpression(initializer) ? initializer.expression : initializer
}
return undefined
}
/** One literal fragment of a composed value, with the node that carries it. */
interface LiteralFragment { node: ts.Node; text: string }
/**
* Literal fragments of a value composed of literals, `+` concatenation,
* templates, and conditionals. Template fragments are the spans between
* substitutions, so an interpolated prefix (`${origin}/api/x`) is visible.
* @param node - the expression to decompose.
* @returns fragments in source order.
*/
function literalFragments(node: ts.Expression): LiteralFragment[] {
if (ts.isParenthesizedExpression(node)) return literalFragments(node.expression)
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
return [...literalFragments(node.left), ...literalFragments(node.right)]
}
if (ts.isConditionalExpression(node)) {
return [...literalFragments(node.whenTrue), ...literalFragments(node.whenFalse)]
}
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [{ node, text: node.text }]
if (ts.isTemplateExpression(node)) {
// Every fragment reports the template itself: an interpolated prefix makes
// the offending span start mid-expression, and one template is one route.
return [
{ node, text: node.head.text },
...node.templateSpans.map(span => ({ node, text: span.literal.text })),
]
}
return []
}
/**
* Find app-owned routes that bind one source file to one mount.
* @param file - repository-relative path used in diagnostics.
* @param sourceText - TypeScript or TSX source.
* @param face - `browser` for request targets, `reference-producer` for the
* fields a static host emits into the browser.
* @returns violations in source order.
*/
export function findRouteResolutionViolations(
file: string,
sourceText: string,
face: RouteGateFace = 'browser',
): RouteResolutionViolation[] {
const source = ts.createSourceFile(
file,
sourceText,
ts.ScriptTarget.Latest,
true,
file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
)
const violations = new Map<number, RouteResolutionViolation>()
const report = (node: ts.Node, rule: RouteResolutionRule): void => {
const start = node.getStart(source)
if (violations.has(start)) return
const { character, line } = source.getLineAndCharacterOfPosition(start)
violations.set(start, { column: character + 1, file, line: line + 1, pattern: node.getText(source), rule })
}
/** A request target's own literals must be document-relative. */
const checkTarget = (target: ts.Expression): void => {
for (const fragment of literalFragments(target)) {
if (ROOT_ABSOLUTE_ROUTE.test(fragment.text) || ABSOLUTE_ROUTE.test(fragment.text)) {
report(fragment.node, 'request-target')
}
}
// A shared route key used as-is: the browser half strips the leading slash.
for (const key of unstrippedHostRouteKeys(target)) report(key, 'host-route-key')
}
/** A relative app route belongs to the document base, not to a rebuilt origin. */
const checkUrlBase = (node: ts.NewExpression): void => {
const [first, second] = node.arguments ?? []
if (first === undefined || second === undefined || !readsLocation(second)) return
const literal = literalFragments(first)
if (literal.some(fragment => RELATIVE_APP_ROUTE.test(fragment.text))) report(first, 'location-base')
}
/** A field the browser resolves against its document carries a reference. */
const checkReferenceField = (value: ts.Expression): void => {
for (const fragment of literalFragments(value)) {
if (ROOT_ABSOLUTE_ROUTE.test(fragment.text) || ABSOLUTE_ROUTE.test(fragment.text)) {
report(fragment.node, 'reference-producer')
}
}
}
const visit = (node: ts.Node): void => {
if (face === 'browser') {
const target = requestTargetOf(node)
if (target !== undefined) checkTarget(target)
if (ts.isNewExpression(node) && calleeName(node.expression) === 'URL') checkUrlBase(node)
} else if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name)
&& REFERENCE_FIELDS[node.name.text] === true && ts.isExpression(node.initializer)) {
checkReferenceField(node.initializer)
}
ts.forEachChild(node, visit)
}
visit(source)
return [...violations.values()].sort((left, right) => left.line - right.line || left.column - right.column)
}
/**
* Source files of every browser-side TypeScript project in the Client aggregate.
*
* A browser half belongs to `src/client`; a package that keeps its whole UI in
* plain `src` is found through the project itself, which carries the Client
* compiler shape (DOM libraries). A config the Host aggregate also compiles has
* a host half rather than a browser-only surface, so its plain `src/` is
* scanned only when it has no `src/client` half; that half arrives below. Every
* such face must contribute at least one source file: a face that contributes
* none means the aggregate's layout moved out from under this discovery, which
* must fail loud rather than scan less.
* @param projectRoot - repository root to discover from.
* @returns sorted repository-relative source paths.
*/
export function browserFaceSources(projectRoot: string = root): string[] {
const sources = new Set<string>()
const add = (file: string): void => {
const normalized = file.replaceAll('\\', '/')
if (normalized.endsWith('.d.ts')) return
sources.add(normalized)
}
const hostConfigs = faceConfigs(projectRoot, 'host').byPath
for (const [configPath, parsed] of faceConfigs(projectRoot, 'client').byPath) {
if (parsed.options.lib?.includes('lib.dom.d.ts') !== true) continue
const configRoot = dirname(configPath).replaceAll('\\', '/')
const sourceRoot = `${configRoot}/src/`
// An aggregate or app shell (the root Client program) carries the compiler
// shape but no `src/` tree of its own; its browser sources arrive through
// the references and `src/client` discovery below.
if (!existsSync(sourceRoot)) continue
// A package the Host aggregate also compiles has a host half; when its
// browser half is `src/client`, plain `src/` may hold the node half, and
// `src/client` alone is scanned below. Without that half, all of `src/` is
// the browser surface.
if (hostConfigs.has(configPath) && existsSync(`${configRoot}/src/client`)) continue
const faceSources = parsed.fileNames.filter((file) => {
const normalized = file.replaceAll('\\', '/')
return normalized.startsWith(sourceRoot) && /\.tsx?$/.test(normalized)
})
if (faceSources.length === 0) {
throw new Error(
`${GATE}: browser face ${relative(projectRoot, configPath).split(sep).join('/')} has DOM libraries but contributes no source file; discovery is broken.`,
)
}
for (const file of faceSources) add(relative(projectRoot, file).split(sep).join('/'))
}
for (const file of globSync('packages/*/*/src/client/**/*.{ts,tsx}', { cwd: projectRoot })) {
add(relative(projectRoot, resolve(projectRoot, file)).split(sep).join('/'))
}
return [...sources].sort()
}
/** Scan one file for one face; a producer that disappeared must fail loud. */
function scan(file: string, face: RouteGateFace): RouteResolutionViolation[] {
const path = resolve(root, file)
if (face === 'reference-producer' && !existsSync(path)) {
throw new Error(`${GATE}: browser-reference producer ${file} is gone; discovery is broken.`)
}
return findRouteResolutionViolations(file, readFileSync(path, 'utf8'), face)
}
function main(): void {
const files = browserFaceSources()
const violations = [
...files.flatMap(file => scan(file, 'browser')),
...REFERENCE_PRODUCERS.flatMap(file => scan(file, 'reference-producer')),
]
if (violations.length > 0) {
console.error(`${GATE}: ${violations.length} app-owned route(s) that are not document-relative:`)
for (const violation of violations) {
console.error(
` ${violation.file}:${violation.line}:${violation.column} ${violation.pattern} — ${HINT[violation.rule]}`,
)
}
process.exitCode = 1
return
}
console.log(
`${GATE}: ${files.length} browser source file(s) and ${REFERENCE_PRODUCERS.length} browser-reference producer(s) keep app-owned routes document-relative.`,
)
}
if (import.meta.filename === resolve(process.argv[1] ?? '')) main()
@@ -94,7 +94,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/file-upload': { kind: 'none', reason: 'Browser-side request-body transport; registers nothing model-facing.' },
'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' },
'packages/experimental/agent-team-web-profile': { kind: 'indirect', reason: 'The Web layer delegates model composition to the Host-side Agent Teams profile.' },
'packages/experimental/client-ui-agent-team': { kind: 'none', reason: 'The browser projection and task controls register no model-facing input.' },
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/ui-dockkit': { kind: 'none', reason: 'Browser-side docking layout engine and components; registers nothing model-facing.' },