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:
commit
c4bc8b847a
1836 files changed
+37648
-15031
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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.' },
|
||||
|
||||
Reference in new issue
Block a user