verify-application-entrypoints.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. /**
  2. * Enforce dsh profiles as the only supported Node application launcher.
  3. * Vendor CLIs, build tools, test tools, and the temporary private Python
  4. * runtime carrier are explicit classifications rather than implicit holes.
  5. */
  6. import { existsSync, globSync, readFileSync } from 'node:fs'
  7. import { resolve, sep } from 'node:path'
  8. import { pathToFileURL } from 'node:url'
  9. type ManifestBin = string | Record<string, string>
  10. interface PackageManifest {
  11. readonly bin?: unknown
  12. }
  13. interface RootManifest {
  14. readonly scripts?: Record<string, unknown>
  15. }
  16. interface DemoPolicy {
  17. readonly kind: 'dsh-direct' | 'dsh-wrapper'
  18. readonly wrapper?: string
  19. }
  20. /** Public product launcher plus the private build-only WebWorker packer. */
  21. const MANIFEST_BIN_ALLOWLIST = new Map<string, ManifestBin>([
  22. ['apps/cli/package.json', { dsh: 'lib/bin.js' }],
  23. ['packages/experimental/webworker-packer/package.json', { 'dsh-pack-vfs-image': './bin.js' }],
  24. ])
  25. /** Every executable in a Node application workspace has one explicit role. */
  26. const EXECUTABLE_SOURCE_ALLOWLIST = new Map<string, string>([
  27. ['apps/cli/src/bin.ts', 'supported dsh application launcher'],
  28. ['examples/acp-agent/tests/fixtures/shell/tool-pwsh/driver.ts', 'test-only subprocess driver'],
  29. ['examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts', 'test-only subprocess driver'],
  30. ['examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts', 'test-only subprocess driver'],
  31. ['examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts', 'test-only subprocess driver'],
  32. ['examples/headless-agent/tests/fixtures/headless-driver.ts', 'test-only subprocess driver'],
  33. ['examples/headless-agent/tests/fixtures/session-telemetry-otel-driver.ts', 'test-only subprocess driver'],
  34. ['examples/headless-agent/tests/fixtures/time-context-driver.ts', 'test-only subprocess driver'],
  35. ['examples/python-sdk-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts', 'test-only subprocess driver'],
  36. ['packages/experimental/webworker-packer/bin.js', 'private build-only wrapper'],
  37. ['packages/experimental/webworker-packer/src/bin.ts', 'private build-only implementation'],
  38. ['packages/sdk/client/tests/fake-runtime.ts', 'test-only SDK runtime peer'],
  39. ['packages/sdk/python-runtime/src/packaged-bin.ts', 'temporary private Python runtime carrier'],
  40. ['packages/test-support/llm-mock-server/src/bin.ts', 'test-only model server'],
  41. ])
  42. /** Root demos are application wrappers and therefore must visibly select dsh. */
  43. const ROOT_DEMO_POLICIES = new Map<string, DemoPolicy>([
  44. ['demo:acp', { kind: 'dsh-direct' }],
  45. ['demo:code-mode', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-code-mode.mjs' }],
  46. ['demo:cordis', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-cordis.mjs' }],
  47. ])
  48. const SOURCE_PATTERNS = [
  49. '*.ts',
  50. '*.js',
  51. '*.mjs',
  52. '*.cjs',
  53. 'apps/**/*.ts',
  54. 'apps/**/*.js',
  55. 'apps/**/*.mjs',
  56. 'apps/**/*.cjs',
  57. 'examples/**/*.ts',
  58. 'examples/**/*.js',
  59. 'examples/**/*.mjs',
  60. 'examples/**/*.cjs',
  61. 'packages/**/*.ts',
  62. 'packages/**/*.js',
  63. 'packages/**/*.mjs',
  64. 'packages/**/*.cjs',
  65. ]
  66. const SOURCE_EXCLUDES = [
  67. '**/node_modules/**',
  68. '**/lib/**',
  69. '**/dist/**',
  70. '**/coverage/**',
  71. ]
  72. /** Convert a host path from glob output to the repository's slash form. */
  73. function repositoryPath(path: string): string {
  74. return path.split(sep).join('/')
  75. }
  76. /** Stable comparison for string and object npm `bin` declarations. */
  77. function normalizedBin(value: unknown): string | undefined {
  78. if (typeof value === 'string') return JSON.stringify(value)
  79. if (!isRecord(value)) return undefined
  80. const entries = Object.entries(value)
  81. if (!entries.every(([, target]) => typeof target === 'string')) return undefined
  82. return JSON.stringify(Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right))))
  83. }
  84. function manifestBinViolations(root: string): string[] {
  85. const failures: string[] = []
  86. const manifests = globSync(['apps/*/package.json', 'packages/*/*/package.json'], { cwd: root }).sort()
  87. for (const rawPath of manifests) {
  88. const path = repositoryPath(rawPath)
  89. const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
  90. if (manifest.bin === undefined) continue
  91. const expected = MANIFEST_BIN_ALLOWLIST.get(path)
  92. if (expected === undefined) {
  93. failures.push(`${path}: package bin bypasses the dsh launcher; applications use apps/cli profiles`)
  94. continue
  95. }
  96. if (normalizedBin(manifest.bin) !== normalizedBin(expected)) {
  97. failures.push(`${path}: classified bin must remain ${JSON.stringify(expected)}, got ${JSON.stringify(manifest.bin)}`)
  98. }
  99. }
  100. return failures
  101. }
  102. function executableSourceViolations(root: string): string[] {
  103. const failures: string[] = []
  104. for (const rawPath of globSync(SOURCE_PATTERNS, { cwd: root, exclude: SOURCE_EXCLUDES }).sort()) {
  105. const path = repositoryPath(rawPath)
  106. const source = readFileSync(resolve(root, path), 'utf8')
  107. if (!source.startsWith('#!')) continue
  108. if (!EXECUTABLE_SOURCE_ALLOWLIST.has(path)) {
  109. failures.push(`${path}: executable source has no application/build/test classification`)
  110. }
  111. }
  112. return failures
  113. }
  114. function referencesDshCli(source: string): boolean {
  115. return source.includes('apps/cli/src/bin.ts')
  116. }
  117. function referencesPackageEntry(source: string): boolean {
  118. return /packages\/[^/\s'"`]+\/[^/\s'"`]+\/(?:src|lib)\/[^\s'"`]+/.test(source)
  119. }
  120. function rootDemoViolations(root: string): string[] {
  121. const manifestPath = resolve(root, 'package.json')
  122. if (!existsSync(manifestPath)) return []
  123. const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as RootManifest
  124. const failures: string[] = []
  125. for (const [name, commandValue] of Object.entries(manifest.scripts ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
  126. if (!name.startsWith('demo:')) continue
  127. const command = typeof commandValue === 'string' ? commandValue : ''
  128. const policy = ROOT_DEMO_POLICIES.get(name)
  129. if (policy === undefined) {
  130. failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`)
  131. continue
  132. }
  133. if (policy.kind === 'dsh-direct') {
  134. if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application demo must launch apps/cli/src/bin.ts`)
  135. if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application demo must not launch a package entry directly`)
  136. continue
  137. }
  138. const wrapper = policy.wrapper
  139. if (wrapper === undefined || !command.includes(wrapper)) {
  140. failures.push(`package.json scripts.${name}: classified wrapper must be ${String(wrapper)}`)
  141. continue
  142. }
  143. const wrapperPath = resolve(root, wrapper)
  144. if (!existsSync(wrapperPath)) {
  145. failures.push(`${wrapper}: classified demo wrapper is missing`)
  146. continue
  147. }
  148. const source = readFileSync(wrapperPath, 'utf8')
  149. if (!referencesDshCli(source)) failures.push(`${wrapper}: application demo wrapper must launch apps/cli/src/bin.ts`)
  150. if (referencesPackageEntry(source)) failures.push(`${wrapper}: application demo wrapper must not launch a package entry directly`)
  151. }
  152. return failures
  153. }
  154. /**
  155. * Find unsupported application entrypoints below a repository root.
  156. * @param root - repository or test-fixture root.
  157. * @returns deterministic path-qualified violations.
  158. */
  159. export function applicationEntrypointViolations(root: string): string[] {
  160. return [
  161. ...manifestBinViolations(root),
  162. ...executableSourceViolations(root),
  163. ...rootDemoViolations(root),
  164. ]
  165. }
  166. function isRecord(value: unknown): value is Record<string, unknown> {
  167. return typeof value === 'object' && value !== null && !Array.isArray(value)
  168. }
  169. if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
  170. const root = resolve(import.meta.dirname, '..')
  171. const failures = applicationEntrypointViolations(root)
  172. if (failures.length > 0) {
  173. console.error('verify-application-entrypoints: unsupported launcher(s):')
  174. for (const failure of failures) console.error(` ${failure}`)
  175. process.exitCode = 1
  176. } else {
  177. console.log('verify-application-entrypoints: dsh is the only supported Node application launcher; the private Python carrier remains the temporary exception.')
  178. }
  179. }