1
0

verify-client-packages.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  1. /**
  2. * Verify client package modes and the synchronous browser module-request graph.
  3. */
  4. import { globSync, readFileSync, writeFileSync } from 'node:fs'
  5. import { dirname, resolve, sep } from 'node:path'
  6. import { pathToFileURL } from 'node:url'
  7. import ts from 'typescript'
  8. import { TypeScriptProject } from './ts-project.ts'
  9. const GATE = 'verify-client-packages'
  10. const CLIENT_MANIFEST_GLOB = 'packages/client/*/package.json'
  11. const MANIFEST_GLOBS = ['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json']
  12. const CONFIG_GLOB = 'packages/*/*/tsdown.config.ts'
  13. const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts'
  14. const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts'
  15. const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts'
  16. const CORDIS = '@deepseek-ai/cordis'
  17. /** One workspace package's browser-module declaration. */
  18. export interface ClientDeclaration {
  19. readonly name: string
  20. readonly manifest: string
  21. readonly dynamic: boolean
  22. readonly external: readonly string[]
  23. readonly runtimeSourceUses: Readonly<Record<string, readonly string[]>>
  24. /** Exact runtime specifiers used to validate `dsh.client.external` declarations. */
  25. readonly runtimeSourceSpecifiers: Readonly<Record<string, readonly string[]>>
  26. /** Informational package dependencies declared by the row. */
  27. readonly inject: readonly string[]
  28. }
  29. /** One package directly under packages/client. */
  30. export interface ClientPackage extends ClientDeclaration {
  31. readonly staticLinked: boolean
  32. readonly sourceUses: Readonly<Record<string, readonly string[]>>
  33. readonly dependencies: Readonly<Record<string, string>>
  34. readonly peerDependencies: Readonly<Record<string, string>>
  35. readonly devDependencies: Readonly<Record<string, string>>
  36. }
  37. /** Complete source-plane input to the client package verifier. */
  38. export interface ClientPackageFacts {
  39. readonly packages: readonly ClientPackage[]
  40. readonly declarations: readonly ClientDeclaration[]
  41. readonly staticLinkedPackages: ReadonlySet<string>
  42. readonly platformModules: readonly string[]
  43. readonly preloadedExternals: readonly string[]
  44. readonly parserPreloadIds: readonly string[]
  45. readonly malformed: readonly string[]
  46. }
  47. /** Result of reading every workspace browser-module declaration. */
  48. export interface ClientDeclarations {
  49. readonly declarations: ClientDeclaration[]
  50. readonly malformed: string[]
  51. }
  52. /**
  53. * Collect bare packages referenced by one production source file.
  54. * @param path - File path used to select TypeScript's parser mode.
  55. * @param source - Source text to inspect.
  56. * @returns Bare package names referenced by imports, declarations, or JSX.
  57. */
  58. export function collectSourcePackageUses(path: string, source: string): Set<string> {
  59. const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
  60. return collectSourceFileUses(sourceFile, false, 'package')
  61. }
  62. /**
  63. * Collect bare packages whose values one production source file reaches at runtime.
  64. * @param path - File path used to select TypeScript's parser mode.
  65. * @param source - Source text to inspect.
  66. * @returns Bare package names retained by runtime imports, exports, requires, or JSX.
  67. */
  68. export function collectRuntimeSourcePackageUses(path: string, source: string): Set<string> {
  69. const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
  70. return collectSourceFileUses(sourceFile, true, 'package')
  71. }
  72. /**
  73. * Collect exact bare specifiers retained by one production source file.
  74. * @param path - File path used to select TypeScript's parser mode.
  75. * @param source - Source text to inspect.
  76. * @returns Exact specifiers retained by runtime imports, exports, requires, or JSX.
  77. */
  78. export function collectRuntimeSourceSpecifiers(path: string, source: string): Set<string> {
  79. const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
  80. return collectSourceFileUses(sourceFile, true, 'specifier')
  81. }
  82. /**
  83. * Collect relative module specifiers used to follow one source entry's local closure.
  84. * @param path - File path used to select TypeScript's parser mode.
  85. * @param source - Source text to inspect.
  86. * @returns Relative imports, exports, requires, and import types.
  87. */
  88. export function collectLocalSourceSpecifiers(path: string, source: string): Set<string> {
  89. const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
  90. return collectSourceFileUses(sourceFile, false, 'local')
  91. }
  92. /**
  93. * Collect local module specifiers retained by one production source file.
  94. * @param path - File path used to select TypeScript's parser mode.
  95. * @param source - Source text to inspect.
  96. * @param includeRootRelative - Include absolute paths, such as Vite's Web-root imports.
  97. * @returns Local imports, exports, and requires that survive compilation.
  98. */
  99. export function collectRuntimeLocalSourceSpecifiers(path: string, source: string, includeRootRelative = false): Set<string> {
  100. const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
  101. return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative)
  102. }
  103. function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
  104. const clause = node.importClause
  105. if (clause === undefined) return true
  106. if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
  107. const bindings = clause.namedBindings
  108. return clause.name !== undefined
  109. || bindings === undefined
  110. || ts.isNamespaceImport(bindings)
  111. || bindings.elements.length === 0
  112. || bindings.elements.some(element => !element.isTypeOnly)
  113. }
  114. function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
  115. if (node.isTypeOnly) return false
  116. const clause = node.exportClause
  117. if (clause === undefined || ts.isNamespaceExport(clause)) return true
  118. return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly)
  119. }
  120. function collectSourceFileUses(
  121. sourceFile: ts.SourceFile,
  122. runtimeOnly: boolean,
  123. key: 'local' | 'package' | 'specifier',
  124. includeRootRelative = false,
  125. ): Set<string> {
  126. const uses = new Set<string>()
  127. const add = (specifier: ts.Expression | undefined): void => {
  128. if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return
  129. if (key === 'local') {
  130. if (specifier.text.startsWith('.') || includeRootRelative && specifier.text.startsWith('/')) uses.add(specifier.text)
  131. return
  132. }
  133. if (!isBareSpecifier(specifier.text)) return
  134. uses.add(key === 'package' ? packageNameOf(specifier.text) : specifier.text)
  135. }
  136. const visit = (node: ts.Node): void => {
  137. if (ts.isImportDeclaration(node)) {
  138. if (!runtimeOnly || importCarriesRuntimeValue(node)) add(node.moduleSpecifier)
  139. } else if (ts.isExportDeclaration(node)) {
  140. if (!runtimeOnly || exportCarriesRuntimeValue(node)) add(node.moduleSpecifier)
  141. } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
  142. if (!runtimeOnly || !node.isTypeOnly) add(node.moduleReference.expression)
  143. } else if (!runtimeOnly && ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
  144. add(node.argument.literal)
  145. } else if (ts.isCallExpression(node)
  146. && (node.expression.kind === ts.SyntaxKind.ImportKeyword
  147. || ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
  148. add(node.arguments[0])
  149. } else if (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
  150. add(node.name)
  151. } else if (key !== 'local'
  152. && (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) {
  153. uses.add('react')
  154. }
  155. ts.forEachChild(node, visit)
  156. }
  157. visit(sourceFile)
  158. return uses
  159. }
  160. /**
  161. * Read browser-module declarations from workspace manifests.
  162. * @param root - Absolute repository root.
  163. * @returns Declarations and malformed dsh.client fields.
  164. */
  165. export function readClientDeclarations(root: string): ClientDeclarations {
  166. const malformed: string[] = []
  167. const declarations = globSync(MANIFEST_GLOBS, { cwd: root })
  168. .map(normalizePath)
  169. .sort()
  170. .flatMap(path => readDeclaration(root, path, malformed) ?? [])
  171. return { declarations, malformed }
  172. }
  173. /**
  174. * Return every client package policy violation.
  175. * @param facts - Package modes, manifests, source uses, and platform module lists.
  176. * @returns Stable self-contained diagnostics.
  177. */
  178. export function collectClientPackageViolations(facts: ClientPackageFacts): string[] {
  179. return [
  180. ...facts.malformed,
  181. ...collectModeViolations(facts),
  182. ...collectModuleViolations(facts),
  183. ].sort((left, right) => left.localeCompare(right))
  184. }
  185. interface ManifestDocument {
  186. readonly path: string
  187. readonly manifest: Manifest
  188. changed: boolean
  189. }
  190. /**
  191. * Repair malformed or redundant `dsh.client` declaration entries.
  192. * @param root - Absolute repository root.
  193. * @param facts - Facts used by the verification pass.
  194. * @returns Repository-relative manifests written by the fixer.
  195. */
  196. export function fixClientPackageManifests(root: string, facts: ClientPackageFacts): string[] {
  197. const documents = new Map<string, ManifestDocument>()
  198. const document = (path: string): ManifestDocument => {
  199. const cached = documents.get(path)
  200. if (cached !== undefined) return cached
  201. const loaded: ManifestDocument = {
  202. path,
  203. manifest: JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest,
  204. changed: false,
  205. }
  206. documents.set(path, loaded)
  207. return loaded
  208. }
  209. const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
  210. for (const declaration of facts.declarations.filter(entry => entry.dynamic)) {
  211. const target = document(declaration.manifest)
  212. const dsh = isRecord(target.manifest.dsh) ? target.manifest.dsh : undefined
  213. const client = isRecord(dsh?.client) ? dsh.client : undefined
  214. if (client === undefined) continue
  215. target.changed = normalizeClientArray(client, 'inject', () => false) || target.changed
  216. target.changed = normalizeClientArray(
  217. client,
  218. 'external',
  219. value => baseline.has(value) || rowPackageOf(value, new Set([declaration.name])) === declaration.name,
  220. ) || target.changed
  221. }
  222. const changed = [...documents.values()].filter(target => target.changed).sort((left, right) =>
  223. left.path.localeCompare(right.path))
  224. for (const target of changed) {
  225. writeFileSync(resolve(root, target.path), JSON.stringify(target.manifest, null, 2) + '\n')
  226. }
  227. return changed.map(target => target.path)
  228. }
  229. function normalizeClientArray(
  230. client: Record<string, unknown>,
  231. field: 'external' | 'inject',
  232. remove: (value: string) => boolean,
  233. ): boolean {
  234. const value = client[field]
  235. if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) return false
  236. const seen = new Set<string>()
  237. const normalized = value.filter((entry: string) => {
  238. if (entry === '' || seen.has(entry) || remove(entry)) return false
  239. seen.add(entry)
  240. return true
  241. })
  242. if (normalized.length === value.length && normalized.every((entry, index) => entry === value[index])) return false
  243. if (normalized.length === 0) {
  244. if (field === 'external') delete client.external
  245. else delete client.inject
  246. } else {
  247. client[field] = normalized
  248. }
  249. return true
  250. }
  251. function collectModeViolations(facts: ClientPackageFacts): string[] {
  252. const violations: string[] = []
  253. for (const pkg of facts.packages) {
  254. if (pkg.dynamic && pkg.staticLinked) {
  255. violations.push(
  256. pkg.manifest + ': ' + pkg.name + ' declares dsh.client and uses the staticLinked preset;'
  257. + ' a client package must be dynamic or statically linked, not both',
  258. )
  259. } else if (!pkg.dynamic && !pkg.staticLinked) {
  260. violations.push(
  261. pkg.manifest + ': ' + pkg.name + ' has no supported client package mode;'
  262. + ' declare dsh.client or use the staticLinked preset',
  263. )
  264. }
  265. }
  266. const workspaceNames = new Set(facts.declarations.map(entry => entry.name))
  267. for (const specifier of facts.platformModules) {
  268. const owner = packageNameOf(specifier)
  269. if (!workspaceNames.has(owner) || owner === CORDIS || facts.staticLinkedPackages.has(owner)) continue
  270. violations.push(
  271. PLATFORM_SOURCE + ': seeded workspace module ' + JSON.stringify(specifier)
  272. + ' belongs to ' + owner + ', whose build does not use the staticLinked preset',
  273. )
  274. }
  275. const rows = rowNames(facts.declarations)
  276. for (const specifier of facts.preloadedExternals) {
  277. if (rowPackageOf(specifier, rows) === undefined) {
  278. violations.push(
  279. PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier)
  280. + ' has no dynamic dsh.client row',
  281. )
  282. }
  283. if (!facts.parserPreloadIds.includes(stripClientSuffix(specifier))) {
  284. violations.push(
  285. PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier)
  286. + ' has no matching PARSER_PRELOAD_IDS row in ' + PARSER_PRELOAD_SOURCE,
  287. )
  288. }
  289. }
  290. return violations
  291. }
  292. interface ModuleEdge {
  293. readonly from: string
  294. readonly to: string
  295. readonly specifier: string
  296. }
  297. function collectModuleViolations(facts: ClientPackageFacts): string[] {
  298. const violations: string[] = []
  299. const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
  300. const rows = rowNames(facts.declarations)
  301. const byName = new Map(facts.declarations.map(entry => [entry.name, entry]))
  302. const edges: ModuleEdge[] = []
  303. for (const pkg of facts.declarations.filter(entry => entry.dynamic)) {
  304. for (const field of ['external', 'inject'] as const) {
  305. const seen = new Set<string>()
  306. for (const value of pkg[field]) {
  307. if (value === '') violations.push(pkg.manifest + ': dsh.client.' + field + ' contains an empty value')
  308. else if (seen.has(value)) {
  309. violations.push(pkg.manifest + ': dsh.client.' + field + ' lists ' + JSON.stringify(value) + ' twice')
  310. }
  311. seen.add(value)
  312. }
  313. }
  314. for (const specifier of new Set(pkg.external)) {
  315. if (specifier === '') continue
  316. if (baseline.has(specifier)) {
  317. violations.push(
  318. pkg.manifest + ': dsh.client.external repeats baseline module ' + JSON.stringify(specifier)
  319. + '; remove the explicit declaration',
  320. )
  321. continue
  322. }
  323. const supplier = rowPackageOf(specifier, rows)
  324. if (supplier === pkg.name) {
  325. violations.push(pkg.manifest + ': dsh.client.external names its own row ' + JSON.stringify(specifier))
  326. } else if (supplier !== undefined) {
  327. if (pkg.manifest.startsWith('packages/client/')) {
  328. violations.push(
  329. pkg.manifest + ': client feature package requests runtime external ' + JSON.stringify(specifier)
  330. + '; import shared types only or call an injected Cordis service',
  331. )
  332. continue
  333. }
  334. if (pkg.runtimeSourceSpecifiers[specifier] === undefined) {
  335. violations.push(
  336. pkg.manifest + ': dsh.client.external ' + JSON.stringify(specifier)
  337. + ' has no runtime import or re-export in production source; remove the stale declaration',
  338. )
  339. continue
  340. }
  341. edges.push({ from: pkg.name, to: supplier, specifier })
  342. } else {
  343. const owner = stripClientSuffix(specifier)
  344. violations.push(
  345. pkg.manifest + ': dsh.client.external ' + JSON.stringify(specifier) + ' has no supplier;'
  346. + (byName.has(owner)
  347. ? ' workspace package ' + owner
  348. + ' declares no dynamic dsh.client row and the shell does not seed this specifier'
  349. : ' no dynamic row or PLATFORM_MODULES entry answers it'),
  350. )
  351. }
  352. }
  353. }
  354. violations.push(...collectModuleCycles(edges, byName))
  355. return violations
  356. }
  357. function collectModuleCycles(
  358. edges: readonly ModuleEdge[],
  359. byName: ReadonlyMap<string, ClientDeclaration>,
  360. ): string[] {
  361. const outgoing = new Map<string, ModuleEdge[]>()
  362. for (const edge of [...edges].sort((left, right) => left.specifier.localeCompare(right.specifier))) {
  363. outgoing.set(edge.from, [...outgoing.get(edge.from) ?? [], edge])
  364. }
  365. const finished = new Set<string>()
  366. const onPath = new Set<string>()
  367. const path: ModuleEdge[] = []
  368. const reported = new Map<string, string>()
  369. const walk = (name: string): void => {
  370. onPath.add(name)
  371. for (const edge of outgoing.get(name) ?? []) {
  372. if (onPath.has(edge.to)) {
  373. const start = path.findIndex(entry => entry.from === edge.to)
  374. const cycle = start === -1 ? [edge] : [...path.slice(start), edge]
  375. const key = cycleKey(cycle)
  376. if (!reported.has(key)) reported.set(key, formatCycle(cycle, byName))
  377. } else if (!finished.has(edge.to)) {
  378. path.push(edge)
  379. walk(edge.to)
  380. path.pop()
  381. }
  382. }
  383. onPath.delete(name)
  384. finished.add(name)
  385. }
  386. for (const name of [...outgoing.keys()].sort()) {
  387. if (!finished.has(name)) walk(name)
  388. }
  389. return [...reported.values()]
  390. }
  391. function cycleKey(cycle: readonly ModuleEdge[]): string {
  392. const labels = cycle.map(edge => edge.from + ' ' + edge.specifier)
  393. const first = [...labels].sort()[0]
  394. const offset = first === undefined ? 0 : labels.indexOf(first)
  395. return [...labels.slice(offset), ...labels.slice(0, offset)].join(' -> ')
  396. }
  397. function formatCycle(
  398. cycle: readonly ModuleEdge[],
  399. byName: ReadonlyMap<string, ClientDeclaration>,
  400. ): string {
  401. const entry = cycle[0]
  402. const chain = cycle.map(edge => edge.from + ' --(' + edge.specifier + ')-->').join(' ')
  403. const manifest = entry === undefined ? 'packages/client' : byName.get(entry.from)?.manifest ?? entry.from
  404. return manifest + ': synchronous dsh.client.external cycle: ' + chain + ' ' + (entry?.from ?? '')
  405. }
  406. interface Manifest {
  407. name?: unknown
  408. dsh?: unknown
  409. dependencies?: Record<string, string>
  410. peerDependencies?: Record<string, string>
  411. devDependencies?: Record<string, string>
  412. }
  413. function readDeclaration(
  414. root: string,
  415. manifestPath: string,
  416. malformed: string[],
  417. ): ClientDeclaration | undefined {
  418. const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
  419. if (typeof manifest.name !== 'string') return undefined
  420. const dsh = isRecord(manifest.dsh) ? manifest.dsh : undefined
  421. const rawClient = dsh?.client
  422. if (rawClient === undefined) {
  423. return {
  424. name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [],
  425. runtimeSourceUses: {}, runtimeSourceSpecifiers: {},
  426. }
  427. }
  428. if (!isRecord(rawClient)) {
  429. malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object')
  430. return {
  431. name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [],
  432. runtimeSourceUses: {}, runtimeSourceSpecifiers: {},
  433. }
  434. }
  435. return {
  436. name: manifest.name,
  437. manifest: manifestPath,
  438. dynamic: true,
  439. external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed),
  440. inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed),
  441. runtimeSourceUses: {},
  442. runtimeSourceSpecifiers: {},
  443. }
  444. }
  445. function stringArray(
  446. value: unknown,
  447. packageName: string,
  448. manifestPath: string,
  449. field: string,
  450. malformed: string[],
  451. ): readonly string[] {
  452. if (value === undefined) return []
  453. if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) {
  454. malformed.push(manifestPath + ': ' + packageName + ' dsh.client.' + field + ' must be a string array')
  455. return []
  456. }
  457. return value as string[]
  458. }
  459. async function readStaticLinkedRoster(root: string): Promise<Set<string>> {
  460. const presetUrl = pathToFileURL(resolve(import.meta.dirname, '..', STATIC_PRESET_SOURCE)).href
  461. const preset = await import(presetUrl) as { isStaticLinkedConfig?: unknown }
  462. if (typeof preset.isStaticLinkedConfig !== 'function') {
  463. throw new Error(GATE + ': ' + STATIC_PRESET_SOURCE + ' exports no isStaticLinkedConfig')
  464. }
  465. const predicate = preset.isStaticLinkedConfig as (configs: readonly unknown[]) => boolean
  466. const roster = new Set<string>()
  467. for (const configPath of globSync(CONFIG_GLOB, { cwd: root }).map(normalizePath).sort()) {
  468. const loaded = await import(pathToFileURL(resolve(root, configPath)).href) as { default?: unknown }
  469. if (typeof loaded.default !== 'function') continue
  470. const configs = (loaded.default as (input: { env: Record<string, string> }) => unknown)({
  471. env: { DSH_BUILD_FACE: 'client' },
  472. })
  473. if (!Array.isArray(configs) || !predicate(configs)) continue
  474. const manifest = JSON.parse(
  475. readFileSync(resolve(root, configPath.replace(/tsdown\.config\.ts$/, 'package.json')), 'utf8'),
  476. ) as Manifest
  477. if (typeof manifest.name === 'string') roster.add(manifest.name)
  478. }
  479. return roster
  480. }
  481. function unwrapExpression(expression: ts.Expression): ts.Expression {
  482. let current = expression
  483. while (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current)) {
  484. current = current.expression
  485. }
  486. return current
  487. }
  488. function readStringLiteralArray(root: string, sourcePath: string, name: string): string[] {
  489. const path = resolve(root, sourcePath)
  490. const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, false, ts.ScriptKind.TS)
  491. const constants = new Map<string, string>()
  492. for (const statement of source.statements) {
  493. if (!ts.isVariableStatement(statement)) continue
  494. for (const declaration of statement.declarationList.declarations) {
  495. if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
  496. const initializer = unwrapExpression(declaration.initializer)
  497. if (ts.isStringLiteral(initializer)) constants.set(declaration.name.text, initializer.text)
  498. }
  499. }
  500. for (const statement of source.statements) {
  501. if (!ts.isVariableStatement(statement)) continue
  502. for (const declaration of statement.declarationList.declarations) {
  503. if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue
  504. const expression = declaration.initializer === undefined ? undefined : unwrapExpression(declaration.initializer)
  505. if (expression === undefined || !ts.isArrayLiteralExpression(expression)) {
  506. throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must be an array literal')
  507. }
  508. return expression.elements.map((element) => {
  509. const value = unwrapExpression(element)
  510. if (ts.isStringLiteral(value)) return value.text
  511. if (ts.isIdentifier(value) && constants.has(value.text)) return constants.get(value.text) as string
  512. throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must contain only string constants')
  513. })
  514. }
  515. }
  516. throw new Error(GATE + ': ' + sourcePath + ' declares no ' + name)
  517. }
  518. async function readFacts(root: string): Promise<ClientPackageFacts> {
  519. const { declarations: bareDeclarations, malformed } = readClientDeclarations(root)
  520. const staticLinkedPackages = await readStaticLinkedRoster(root)
  521. const project = new TypeScriptProject(root, 'client')
  522. const sourceFiles = project.sourceFiles()
  523. const declarations = bareDeclarations.map((declaration): ClientDeclaration => {
  524. const runtimeSourceUses = new Map<string, Set<string>>()
  525. const runtimeSourceSpecifiers = new Map<string, Set<string>>()
  526. const sourcePrefix = dirname(declaration.manifest) + '/src/'
  527. for (const sourceFile of sourceFiles) {
  528. if (sourceFile.isDeclarationFile) continue
  529. const file = project.relativePath(sourceFile)
  530. if (!file.startsWith(sourcePrefix)) continue
  531. for (const name of collectSourceFileUses(sourceFile, true, 'package')) {
  532. const locations = runtimeSourceUses.get(name) ?? new Set<string>()
  533. locations.add(file)
  534. runtimeSourceUses.set(name, locations)
  535. }
  536. for (const specifier of collectSourceFileUses(sourceFile, true, 'specifier')) {
  537. const locations = runtimeSourceSpecifiers.get(specifier) ?? new Set<string>()
  538. locations.add(file)
  539. runtimeSourceSpecifiers.set(specifier, locations)
  540. }
  541. }
  542. return {
  543. ...declaration,
  544. runtimeSourceUses: Object.fromEntries(
  545. [...runtimeSourceUses].sort(([left], [right]) => left.localeCompare(right))
  546. .map(([name, locations]) => [name, [...locations].sort()]),
  547. ),
  548. runtimeSourceSpecifiers: Object.fromEntries(
  549. [...runtimeSourceSpecifiers].sort(([left], [right]) => left.localeCompare(right))
  550. .map(([specifier, locations]) => [specifier, [...locations].sort()]),
  551. ),
  552. }
  553. })
  554. const byManifest = new Map(declarations.map(entry => [entry.manifest, entry]))
  555. const packages: ClientPackage[] = []
  556. for (const manifestPath of globSync(CLIENT_MANIFEST_GLOB, { cwd: root }).map(normalizePath).sort()) {
  557. const declaration = byManifest.get(manifestPath)
  558. if (declaration === undefined) throw new Error(GATE + ': no declaration facts for ' + manifestPath)
  559. const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
  560. if (typeof manifest.name !== 'string') throw new Error(GATE + ': ' + manifestPath + ' has no package name')
  561. const sourceUses = new Map<string, Set<string>>()
  562. const runtimeSourceUses = new Map<string, Set<string>>()
  563. const packageDirectory = dirname(manifestPath)
  564. const sourcePrefix = packageDirectory + '/src/'
  565. for (const sourceFile of sourceFiles) {
  566. if (sourceFile.isDeclarationFile) continue
  567. const file = project.relativePath(sourceFile)
  568. if (!file.startsWith(sourcePrefix)) continue
  569. for (const name of collectSourceFileUses(sourceFile, false, 'package')) {
  570. const locations = sourceUses.get(name) ?? new Set<string>()
  571. locations.add(file)
  572. sourceUses.set(name, locations)
  573. }
  574. for (const name of collectSourceFileUses(sourceFile, true, 'package')) {
  575. const locations = runtimeSourceUses.get(name) ?? new Set<string>()
  576. locations.add(file)
  577. runtimeSourceUses.set(name, locations)
  578. }
  579. }
  580. packages.push({
  581. ...declaration,
  582. staticLinked: staticLinkedPackages.has(declaration.name),
  583. sourceUses: Object.fromEntries(
  584. [...sourceUses].sort(([left], [right]) => left.localeCompare(right))
  585. .map(([name, locations]) => [name, [...locations].sort()]),
  586. ),
  587. runtimeSourceUses: Object.fromEntries(
  588. [...runtimeSourceUses].sort(([left], [right]) => left.localeCompare(right))
  589. .map(([name, locations]) => [name, [...locations].sort()]),
  590. ),
  591. dependencies: manifest.dependencies ?? {},
  592. peerDependencies: manifest.peerDependencies ?? {},
  593. devDependencies: manifest.devDependencies ?? {},
  594. })
  595. }
  596. return {
  597. packages,
  598. declarations,
  599. staticLinkedPackages,
  600. platformModules: readStringLiteralArray(root, PLATFORM_SOURCE, 'PLATFORM_MODULES'),
  601. preloadedExternals: readStringLiteralArray(root, PLATFORM_SOURCE, 'PRELOADED_CLIENT_EXTERNALS'),
  602. parserPreloadIds: readStringLiteralArray(root, PARSER_PRELOAD_SOURCE, 'PARSER_PRELOAD_IDS'),
  603. malformed,
  604. }
  605. }
  606. function packageNameOf(specifier: string): string {
  607. const segments = specifier.split('/')
  608. return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
  609. }
  610. function stripClientSuffix(specifier: string): string {
  611. return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier
  612. }
  613. function rowNames(declarations: readonly ClientDeclaration[]): Set<string> {
  614. return new Set(declarations.filter(entry => entry.dynamic).map(entry => entry.name))
  615. }
  616. function rowPackageOf(specifier: string, rows: ReadonlySet<string>): string | undefined {
  617. if (rows.has(specifier)) return specifier
  618. const stripped = stripClientSuffix(specifier)
  619. return rows.has(stripped) ? stripped : undefined
  620. }
  621. function isBareSpecifier(specifier: string): boolean {
  622. return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
  623. }
  624. function isRecord(value: unknown): value is Record<string, unknown> {
  625. return typeof value === 'object' && value !== null && !Array.isArray(value)
  626. }
  627. function normalizePath(path: string): string {
  628. return path.split(sep).join('/')
  629. }
  630. async function main(): Promise<void> {
  631. const root = resolve(import.meta.dirname, '..')
  632. let facts = await readFacts(root)
  633. if (process.argv.includes('--fix')) {
  634. const changed = fixClientPackageManifests(root, facts)
  635. console.log(
  636. changed.length === 0
  637. ? GATE + ': no mechanically fixable manifest changes.'
  638. : GATE + ': fixed ' + String(changed.length) + ' manifest(s): ' + changed.join(', '),
  639. )
  640. facts = await readFacts(root)
  641. }
  642. const violations = collectClientPackageViolations(facts)
  643. if (violations.length > 0) {
  644. console.error(GATE + ': ' + String(violations.length) + ' violation(s):')
  645. for (const violation of violations) console.error(' ' + violation)
  646. process.exit(1)
  647. }
  648. const dynamic = facts.packages.filter(pkg => pkg.dynamic).length
  649. const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0)
  650. console.log(
  651. GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, '
  652. + String(facts.packages.length - dynamic) + ' statically linked) satisfy package-mode and module-request rules; '
  653. + String(requests) + ' explicit external request(s).',
  654. )
  655. }
  656. if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
  657. await main()
  658. }