verify-client-packages.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914
  1. /**
  2. * Verify client package modes, npm dependency sections, and the synchronous
  3. * browser module-request graph.
  4. */
  5. import { globSync, readFileSync, writeFileSync } from 'node:fs'
  6. import { dirname, resolve, sep } from 'node:path'
  7. import { pathToFileURL } from 'node:url'
  8. import ts from 'typescript'
  9. import { TypeScriptProject } from './ts-project.ts'
  10. const GATE = 'verify-client-packages'
  11. const CLIENT_MANIFEST_GLOB = 'packages/client/*/package.json'
  12. const MANIFEST_GLOBS = ['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json']
  13. const CONFIG_GLOB = 'packages/*/*/tsdown.config.ts'
  14. const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts'
  15. const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts'
  16. const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts'
  17. const CORDIS = '@deepseek-ai/cordis'
  18. const DSH_PREFIX = '@deepseek-ai/dsh-'
  19. const CLIENT_WEB = '@deepseek-ai/dsh-client-web'
  20. /** One workspace package's browser-module declaration. */
  21. export interface ClientDeclaration {
  22. /** npm package name. */
  23. readonly name: string
  24. /** Repository-relative package manifest. */
  25. readonly manifest: string
  26. /** Whether the manifest declares a dynamic dsh.client row. */
  27. readonly dynamic: boolean
  28. /** Exact module-table specifiers requested by the row. */
  29. readonly external: readonly string[]
  30. /** Informational package dependencies declared by the row. */
  31. readonly inject: readonly string[]
  32. }
  33. /** One package directly under packages/client. */
  34. export interface ClientPackage extends ClientDeclaration {
  35. /** Whether its build config uses the staticLinked preset. */
  36. readonly staticLinked: boolean
  37. /** Production source locations grouped by imported package name. */
  38. readonly sourceUses: Readonly<Record<string, readonly string[]>>
  39. /** Production source locations grouped by runtime-imported package name. */
  40. readonly runtimeSourceUses: Readonly<Record<string, readonly string[]>>
  41. /** Installed implementation dependencies. */
  42. readonly dependencies: Readonly<Record<string, string>>
  43. /** Consumer-supplied dependencies. */
  44. readonly peerDependencies: Readonly<Record<string, string>>
  45. /** Dependencies available while developing the package. */
  46. readonly devDependencies: Readonly<Record<string, string>>
  47. }
  48. /** Complete source-plane input to the client package verifier. */
  49. export interface ClientPackageFacts {
  50. /** Packages directly under packages/client. */
  51. readonly packages: readonly ClientPackage[]
  52. /** Every workspace package, including packages without a browser row. */
  53. readonly declarations: readonly ClientDeclaration[]
  54. /** Packages whose build config uses the staticLinked preset. */
  55. readonly staticLinkedPackages: ReadonlySet<string>
  56. /** Specifiers the web shell seeds into the module table. */
  57. readonly platformModules: readonly string[]
  58. /** Dynamic factories the HTML parser loads before shell boot. */
  59. readonly preloadedExternals: readonly string[]
  60. /** Package rows whose bundles the HTML parser executes before shell boot. */
  61. readonly parserPreloadIds: readonly string[]
  62. /** Manifest field errors found while reading declarations. */
  63. readonly malformed: readonly string[]
  64. }
  65. /** Result of reading every workspace browser-module declaration. */
  66. export interface ClientDeclarations {
  67. /** One declaration record per named workspace manifest. */
  68. readonly declarations: ClientDeclaration[]
  69. /** Manifest field errors that prevent a reliable declaration. */
  70. readonly malformed: string[]
  71. }
  72. /**
  73. * Collect bare packages referenced 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 Bare package names referenced by imports, declarations, or JSX.
  77. */
  78. export function collectSourcePackageUses(path: string, source: string): Set<string> {
  79. const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
  80. return collectSourceFilePackageUses(sourceFile, false)
  81. }
  82. /**
  83. * Collect bare packages whose values one production source file reaches at runtime.
  84. * @param path - File path used to select TypeScript's parser mode.
  85. * @param source - Source text to inspect.
  86. * @returns Bare package names retained by runtime imports, exports, requires, or JSX.
  87. */
  88. export function collectRuntimeSourcePackageUses(path: string, source: string): Set<string> {
  89. const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
  90. return collectSourceFilePackageUses(sourceFile, true)
  91. }
  92. function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
  93. const clause = node.importClause
  94. if (clause === undefined) return true
  95. if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
  96. const bindings = clause.namedBindings
  97. return clause.name !== undefined
  98. || bindings === undefined
  99. || ts.isNamespaceImport(bindings)
  100. || bindings.elements.length === 0
  101. || bindings.elements.some(element => !element.isTypeOnly)
  102. }
  103. function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
  104. if (node.isTypeOnly) return false
  105. const clause = node.exportClause
  106. if (clause === undefined || ts.isNamespaceExport(clause)) return true
  107. return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly)
  108. }
  109. function collectSourceFilePackageUses(sourceFile: ts.SourceFile, runtimeOnly: boolean): Set<string> {
  110. const uses = new Set<string>()
  111. const add = (specifier: ts.Expression | undefined): void => {
  112. if (specifier === undefined || !ts.isStringLiteral(specifier) || !isBareSpecifier(specifier.text)) return
  113. uses.add(packageNameOf(specifier.text))
  114. }
  115. const visit = (node: ts.Node): void => {
  116. if (ts.isImportDeclaration(node)) {
  117. if (!runtimeOnly || importCarriesRuntimeValue(node)) add(node.moduleSpecifier)
  118. } else if (ts.isExportDeclaration(node)) {
  119. if (!runtimeOnly || exportCarriesRuntimeValue(node)) add(node.moduleSpecifier)
  120. } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
  121. if (!runtimeOnly || !node.isTypeOnly) add(node.moduleReference.expression)
  122. } else if (!runtimeOnly && ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
  123. add(node.argument.literal)
  124. } else if (ts.isCallExpression(node)
  125. && (node.expression.kind === ts.SyntaxKind.ImportKeyword
  126. || ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
  127. add(node.arguments[0])
  128. } else if (!runtimeOnly && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
  129. add(node.name)
  130. } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
  131. uses.add('react')
  132. }
  133. ts.forEachChild(node, visit)
  134. }
  135. visit(sourceFile)
  136. return uses
  137. }
  138. /**
  139. * Read browser-module declarations from workspace manifests.
  140. * @param root - Absolute repository root.
  141. * @returns Declarations and malformed dsh.client fields.
  142. */
  143. export function readClientDeclarations(root: string): ClientDeclarations {
  144. const malformed: string[] = []
  145. const declarations = globSync(MANIFEST_GLOBS, { cwd: root })
  146. .map(normalizePath)
  147. .sort()
  148. .flatMap(path => readDeclaration(root, path, malformed) ?? [])
  149. return { declarations, malformed }
  150. }
  151. /**
  152. * Return every client package policy violation.
  153. * @param facts - Package modes, manifests, source uses, and platform module lists.
  154. * @returns Stable self-contained diagnostics.
  155. */
  156. export function collectClientPackageViolations(facts: ClientPackageFacts): string[] {
  157. return [
  158. ...facts.malformed,
  159. ...collectModeViolations(facts),
  160. ...collectDependencyViolations(facts),
  161. ...collectModuleViolations(facts),
  162. ].sort((left, right) => left.localeCompare(right))
  163. }
  164. interface ManifestDocument {
  165. readonly path: string
  166. readonly manifest: Manifest
  167. changed: boolean
  168. }
  169. type DependencySection = 'dependencies' | 'peerDependencies' | 'devDependencies'
  170. /**
  171. * Repair manifest declarations whose intended result follows uniquely from the policy.
  172. * @param root - Absolute repository root.
  173. * @param facts - Facts used by the verification pass.
  174. * @returns Repository-relative manifests written by the fixer.
  175. */
  176. export function fixClientPackageManifests(root: string, facts: ClientPackageFacts): string[] {
  177. const documents = new Map<string, ManifestDocument>()
  178. const document = (path: string): ManifestDocument => {
  179. const cached = documents.get(path)
  180. if (cached !== undefined) return cached
  181. const loaded: ManifestDocument = {
  182. path,
  183. manifest: JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest,
  184. changed: false,
  185. }
  186. documents.set(path, loaded)
  187. return loaded
  188. }
  189. const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
  190. for (const declaration of facts.declarations.filter(entry => entry.dynamic)) {
  191. const target = document(declaration.manifest)
  192. const dsh = isRecord(target.manifest.dsh) ? target.manifest.dsh : undefined
  193. const client = isRecord(dsh?.client) ? dsh.client : undefined
  194. if (client === undefined) continue
  195. target.changed = normalizeClientArray(client, 'inject', () => false) || target.changed
  196. target.changed = normalizeClientArray(
  197. client,
  198. 'external',
  199. value => baseline.has(value) || rowPackageOf(value, new Set([declaration.name])) === declaration.name,
  200. ) || target.changed
  201. }
  202. const staticInputs = new Set([
  203. ...facts.staticLinkedPackages,
  204. ...facts.platformModules.map(packageNameOf),
  205. ])
  206. staticInputs.delete(CORDIS)
  207. const inferredRanges = dependencyRangeCandidates(root)
  208. for (const pkg of facts.packages) {
  209. const target = document(pkg.manifest)
  210. const expected = expectedSections(pkg, staticInputs)
  211. for (const [name, rule] of expected) {
  212. const range = preferredRange(target.manifest, name, rule.kind, inferredRanges)
  213. if (range === undefined) continue
  214. target.changed = rule.kind === 'dependency'
  215. ? ensureDependencyOnly(target.manifest, name, range) || target.changed
  216. : rule.kind === 'dev'
  217. ? ensureDevOnly(target.manifest, name, range) || target.changed
  218. : ensurePeerDev(target.manifest, name, range) || target.changed
  219. }
  220. if (pkg.dynamic) {
  221. const productionNames = new Set([
  222. ...Object.keys(section(target.manifest, 'dependencies')),
  223. ...Object.keys(section(target.manifest, 'peerDependencies')),
  224. ])
  225. for (const name of productionNames) {
  226. if (expected.has(name)) continue
  227. const range = preferredRange(
  228. target.manifest,
  229. name,
  230. staticInputs.has(name) ? 'dev' : 'peer-dev',
  231. inferredRanges,
  232. )
  233. if (range === undefined) continue
  234. if (staticInputs.has(name)) {
  235. target.changed = ensureDevOnly(target.manifest, name, range) || target.changed
  236. } else if (section(target.manifest, 'dependencies')[name] !== undefined && isInternalDsh(name)) {
  237. target.changed = ensurePeerDev(target.manifest, name, range) || target.changed
  238. }
  239. }
  240. }
  241. for (const [name, range] of Object.entries(section(target.manifest, 'peerDependencies'))) {
  242. target.changed = setDependency(target.manifest, 'devDependencies', name, range) || target.changed
  243. }
  244. target.changed = deleteEmptySections(target.manifest) || target.changed
  245. }
  246. const changed = [...documents.values()].filter(target => target.changed).sort((left, right) =>
  247. left.path.localeCompare(right.path))
  248. for (const target of changed) {
  249. writeFileSync(resolve(root, target.path), JSON.stringify(target.manifest, null, 2) + '\n')
  250. }
  251. return changed.map(target => target.path)
  252. }
  253. function normalizeClientArray(
  254. client: Record<string, unknown>,
  255. field: 'external' | 'inject',
  256. remove: (value: string) => boolean,
  257. ): boolean {
  258. const value = client[field]
  259. if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) return false
  260. const seen = new Set<string>()
  261. const normalized = value.filter((entry: string) => {
  262. if (entry === '' || seen.has(entry) || remove(entry)) return false
  263. seen.add(entry)
  264. return true
  265. })
  266. if (normalized.length === value.length && normalized.every((entry, index) => entry === value[index])) return false
  267. if (normalized.length === 0) {
  268. if (field === 'external') delete client.external
  269. else delete client.inject
  270. } else {
  271. client[field] = normalized
  272. }
  273. return true
  274. }
  275. function ensureDevOnly(manifest: Manifest, name: string, range: string): boolean {
  276. let changed = deleteDependency(manifest, 'dependencies', name)
  277. changed = deleteDependency(manifest, 'peerDependencies', name) || changed
  278. return setDependency(manifest, 'devDependencies', name, range) || changed
  279. }
  280. function ensureDependencyOnly(manifest: Manifest, name: string, range: string): boolean {
  281. let changed = deleteDependency(manifest, 'peerDependencies', name)
  282. changed = deleteDependency(manifest, 'devDependencies', name) || changed
  283. return setDependency(manifest, 'dependencies', name, range) || changed
  284. }
  285. function ensurePeerDev(manifest: Manifest, name: string, range: string): boolean {
  286. let changed = deleteDependency(manifest, 'dependencies', name)
  287. changed = setDependency(manifest, 'peerDependencies', name, range) || changed
  288. return setDependency(manifest, 'devDependencies', name, range) || changed
  289. }
  290. function setDependency(manifest: Manifest, field: DependencySection, name: string, range: string): boolean {
  291. const dependencies = mutableSection(manifest, field)
  292. if (dependencies[name] === range) return false
  293. dependencies[name] = range
  294. return true
  295. }
  296. function deleteDependency(manifest: Manifest, field: DependencySection, name: string): boolean {
  297. const dependencies = section(manifest, field)
  298. if (dependencies[name] === undefined) return false
  299. manifest[field] = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
  300. return true
  301. }
  302. function deleteEmptySections(manifest: Manifest): boolean {
  303. let changed = false
  304. for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
  305. if (manifest[field] === undefined || Object.keys(section(manifest, field)).length > 0) continue
  306. if (field === 'dependencies') delete manifest.dependencies
  307. else if (field === 'peerDependencies') delete manifest.peerDependencies
  308. else delete manifest.devDependencies
  309. changed = true
  310. }
  311. return changed
  312. }
  313. function preferredRange(
  314. manifest: Manifest,
  315. name: string,
  316. kind: ExpectedRule['kind'],
  317. inferred: ReadonlyMap<string, ReadonlySet<string>>,
  318. ): string | undefined {
  319. const order: readonly DependencySection[] = kind === 'dependency'
  320. ? ['dependencies', 'devDependencies', 'peerDependencies']
  321. : kind === 'dev'
  322. ? ['devDependencies', 'peerDependencies', 'dependencies']
  323. : ['peerDependencies', 'devDependencies', 'dependencies']
  324. for (const field of order) {
  325. const range = section(manifest, field)[name]
  326. if (range !== undefined) return range
  327. }
  328. if (isInternalDsh(name)) return 'workspace:^'
  329. const candidates = inferred.get(name)
  330. return candidates?.size === 1 ? [...candidates][0] : undefined
  331. }
  332. function dependencyRangeCandidates(root: string): Map<string, Set<string>> {
  333. const candidates = new Map<string, Set<string>>()
  334. const paths = globSync([
  335. 'package.json',
  336. ...MANIFEST_GLOBS,
  337. 'website/package.json',
  338. ], { cwd: root }).map(normalizePath)
  339. for (const path of new Set(paths)) {
  340. const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest
  341. for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
  342. for (const [name, range] of Object.entries(section(manifest, field))) {
  343. const ranges = candidates.get(name) ?? new Set<string>()
  344. ranges.add(range)
  345. candidates.set(name, ranges)
  346. }
  347. }
  348. }
  349. return candidates
  350. }
  351. function section(manifest: Manifest, field: DependencySection): Record<string, string> {
  352. return manifest[field] ?? {}
  353. }
  354. function mutableSection(manifest: Manifest, field: DependencySection): Record<string, string> {
  355. const value = manifest[field]
  356. if (value !== undefined) return value
  357. const created: Record<string, string> = {}
  358. manifest[field] = created
  359. return created
  360. }
  361. function collectModeViolations(facts: ClientPackageFacts): string[] {
  362. const violations: string[] = []
  363. for (const pkg of facts.packages) {
  364. if (pkg.dynamic && pkg.staticLinked) {
  365. violations.push(
  366. pkg.manifest + ': ' + pkg.name + ' declares dsh.client and uses the staticLinked preset;'
  367. + ' a client package must be dynamic or statically linked, not both',
  368. )
  369. } else if (!pkg.dynamic && !pkg.staticLinked) {
  370. violations.push(
  371. pkg.manifest + ': ' + pkg.name + ' has no supported client package mode;'
  372. + ' declare dsh.client or use the staticLinked preset',
  373. )
  374. }
  375. }
  376. const workspaceNames = new Set(facts.declarations.map(entry => entry.name))
  377. for (const specifier of facts.platformModules) {
  378. const owner = packageNameOf(specifier)
  379. if (!workspaceNames.has(owner) || owner === CORDIS || facts.staticLinkedPackages.has(owner)) continue
  380. violations.push(
  381. PLATFORM_SOURCE + ': seeded workspace module ' + JSON.stringify(specifier)
  382. + ' belongs to ' + owner + ', whose build does not use the staticLinked preset',
  383. )
  384. }
  385. const rows = rowNames(facts.declarations)
  386. for (const specifier of facts.preloadedExternals) {
  387. if (rowPackageOf(specifier, rows) === undefined) {
  388. violations.push(
  389. PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier)
  390. + ' has no dynamic dsh.client row',
  391. )
  392. }
  393. if (!facts.parserPreloadIds.includes(stripClientSuffix(specifier))) {
  394. violations.push(
  395. PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier)
  396. + ' has no matching PARSER_PRELOAD_IDS row in ' + PARSER_PRELOAD_SOURCE,
  397. )
  398. }
  399. }
  400. return violations
  401. }
  402. interface ExpectedRule {
  403. readonly kind: 'dependency' | 'dev' | 'peer-dev'
  404. readonly origins: Set<string>
  405. }
  406. function collectDependencyViolations(facts: ClientPackageFacts): string[] {
  407. const violations: string[] = []
  408. const staticInputs = new Set([
  409. ...facts.staticLinkedPackages,
  410. ...facts.platformModules.map(packageNameOf),
  411. ])
  412. staticInputs.delete(CORDIS)
  413. for (const pkg of [...facts.packages].sort((left, right) => left.manifest.localeCompare(right.manifest))) {
  414. const expected = expectedSections(pkg, staticInputs)
  415. for (const [name, rule] of [...expected].sort(([left], [right]) => left.localeCompare(right))) {
  416. const actual = declaredSections(pkg, name)
  417. if (rule.kind === 'dependency') {
  418. if (actual.length === 1 && actual[0] === 'dependencies') continue
  419. violations.push(
  420. pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a runtime import'
  421. + ' retained by a statically linked artifact; declare it only in dependencies, found '
  422. + describeSections(actual),
  423. )
  424. continue
  425. }
  426. if (rule.kind === 'dev') {
  427. if (actual.length === 1 && actual[0] === 'devDependencies') continue
  428. violations.push(
  429. pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a static client input;'
  430. + ' declare it only in devDependencies, found ' + describeSections(actual),
  431. )
  432. continue
  433. }
  434. const peerRange = pkg.peerDependencies[name]
  435. const devRange = pkg.devDependencies[name]
  436. if (actual.length === 2
  437. && actual.includes('peerDependencies')
  438. && actual.includes('devDependencies')
  439. && peerRange === devRange) continue
  440. violations.push(
  441. pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ')'
  442. + ' is a peer-installed DSH relationship; declare it in peerDependencies and devDependencies'
  443. + ' with matching ranges, not dependencies; found ' + describeSections(actual)
  444. + describeRangeMismatch(peerRange, devRange),
  445. )
  446. }
  447. for (const [name, peerRange] of Object.entries(pkg.peerDependencies).sort(([left], [right]) => left.localeCompare(right))) {
  448. if (expected.has(name)) continue
  449. const devRange = pkg.devDependencies[name]
  450. if (devRange === peerRange) continue
  451. violations.push(
  452. pkg.manifest + ': peerDependencies.' + name + ' is ' + peerRange + ', so devDependencies.' + name
  453. + ' must use the same range; found ' + (devRange ?? 'no declaration'),
  454. )
  455. }
  456. if (!pkg.dynamic) continue
  457. for (const section of ['dependencies', 'peerDependencies'] as const) {
  458. for (const name of Object.keys(pkg[section]).sort()) {
  459. if (expected.has(name)) continue
  460. if (staticInputs.has(name)) {
  461. violations.push(
  462. pkg.manifest + ': dynamic package declares static input ' + name + ' in ' + section + ';'
  463. + ' move it to devDependencies or delete the stale declaration',
  464. )
  465. } else if (section === 'dependencies' && isInternalDsh(name)) {
  466. violations.push(
  467. pkg.manifest + ': dynamic package declares ' + name + ' in dependencies;'
  468. + ' dynamic DSH relationships are peer plus dev, and static client inputs are dev-only',
  469. )
  470. }
  471. }
  472. }
  473. }
  474. return violations
  475. }
  476. function expectedSections(pkg: ClientPackage, staticInputs: ReadonlySet<string>): Map<string, ExpectedRule> {
  477. const expected = new Map<string, ExpectedRule>([
  478. [CORDIS, { kind: 'peer-dev', origins: new Set(['client package baseline']) }],
  479. ])
  480. if (!pkg.dynamic) {
  481. if (pkg.name === CLIENT_WEB) return expected
  482. for (const [name, locations] of Object.entries(pkg.runtimeSourceUses)) {
  483. if (name === pkg.name || name === CORDIS || isInternalDsh(name)) continue
  484. expected.set(name, { kind: 'dependency', origins: new Set(locations) })
  485. }
  486. return expected
  487. }
  488. const add = (name: string, origin: string): void => {
  489. if (name === pkg.name) return
  490. const kind = staticInputs.has(name) ? 'dev' : isInternalDsh(name) ? 'peer-dev' : undefined
  491. if (kind === undefined) return
  492. const current = expected.get(name)
  493. if (current !== undefined) current.origins.add(origin)
  494. else expected.set(name, { kind, origins: new Set([origin]) })
  495. }
  496. for (const [name, locations] of Object.entries(pkg.sourceUses)) {
  497. for (const location of locations) add(name, location)
  498. }
  499. for (const name of pkg.inject) add(name, 'dsh.client.inject')
  500. return expected
  501. }
  502. interface ModuleEdge {
  503. readonly from: string
  504. readonly to: string
  505. readonly specifier: string
  506. }
  507. function collectModuleViolations(facts: ClientPackageFacts): string[] {
  508. const violations: string[] = []
  509. const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
  510. const rows = rowNames(facts.declarations)
  511. const byName = new Map(facts.declarations.map(entry => [entry.name, entry]))
  512. const edges: ModuleEdge[] = []
  513. for (const pkg of facts.declarations.filter(entry => entry.dynamic)) {
  514. for (const field of ['external', 'inject'] as const) {
  515. const seen = new Set<string>()
  516. for (const value of pkg[field]) {
  517. if (value === '') violations.push(pkg.manifest + ': dsh.client.' + field + ' contains an empty value')
  518. else if (seen.has(value)) {
  519. violations.push(pkg.manifest + ': dsh.client.' + field + ' lists ' + JSON.stringify(value) + ' twice')
  520. }
  521. seen.add(value)
  522. }
  523. }
  524. for (const specifier of new Set(pkg.external)) {
  525. if (specifier === '') continue
  526. if (baseline.has(specifier)) {
  527. violations.push(
  528. pkg.manifest + ': dsh.client.external repeats baseline module ' + JSON.stringify(specifier)
  529. + '; remove the explicit declaration',
  530. )
  531. continue
  532. }
  533. const supplier = rowPackageOf(specifier, rows)
  534. if (supplier === pkg.name) {
  535. violations.push(pkg.manifest + ': dsh.client.external names its own row ' + JSON.stringify(specifier))
  536. } else if (supplier !== undefined) {
  537. edges.push({ from: pkg.name, to: supplier, specifier })
  538. } else {
  539. const owner = stripClientSuffix(specifier)
  540. violations.push(
  541. pkg.manifest + ': dsh.client.external ' + JSON.stringify(specifier) + ' has no supplier;'
  542. + (byName.has(owner)
  543. ? ' workspace package ' + owner
  544. + ' declares no dynamic dsh.client row and the shell does not seed this specifier'
  545. : ' no dynamic row or PLATFORM_MODULES entry answers it'),
  546. )
  547. }
  548. }
  549. }
  550. violations.push(...collectModuleCycles(edges, byName))
  551. return violations
  552. }
  553. function collectModuleCycles(
  554. edges: readonly ModuleEdge[],
  555. byName: ReadonlyMap<string, ClientDeclaration>,
  556. ): string[] {
  557. const outgoing = new Map<string, ModuleEdge[]>()
  558. for (const edge of [...edges].sort((left, right) => left.specifier.localeCompare(right.specifier))) {
  559. outgoing.set(edge.from, [...outgoing.get(edge.from) ?? [], edge])
  560. }
  561. const finished = new Set<string>()
  562. const onPath = new Set<string>()
  563. const path: ModuleEdge[] = []
  564. const reported = new Map<string, string>()
  565. const walk = (name: string): void => {
  566. onPath.add(name)
  567. for (const edge of outgoing.get(name) ?? []) {
  568. if (onPath.has(edge.to)) {
  569. const start = path.findIndex(entry => entry.from === edge.to)
  570. const cycle = start === -1 ? [edge] : [...path.slice(start), edge]
  571. const key = cycleKey(cycle)
  572. if (!reported.has(key)) reported.set(key, formatCycle(cycle, byName))
  573. } else if (!finished.has(edge.to)) {
  574. path.push(edge)
  575. walk(edge.to)
  576. path.pop()
  577. }
  578. }
  579. onPath.delete(name)
  580. finished.add(name)
  581. }
  582. for (const name of [...outgoing.keys()].sort()) {
  583. if (!finished.has(name)) walk(name)
  584. }
  585. return [...reported.values()]
  586. }
  587. function cycleKey(cycle: readonly ModuleEdge[]): string {
  588. const labels = cycle.map(edge => edge.from + ' ' + edge.specifier)
  589. const first = [...labels].sort()[0]
  590. const offset = first === undefined ? 0 : labels.indexOf(first)
  591. return [...labels.slice(offset), ...labels.slice(0, offset)].join(' -> ')
  592. }
  593. function formatCycle(
  594. cycle: readonly ModuleEdge[],
  595. byName: ReadonlyMap<string, ClientDeclaration>,
  596. ): string {
  597. const entry = cycle[0]
  598. const chain = cycle.map(edge => edge.from + ' --(' + edge.specifier + ')-->').join(' ')
  599. const manifest = entry === undefined ? 'packages/client' : byName.get(entry.from)?.manifest ?? entry.from
  600. return manifest + ': synchronous dsh.client.external cycle: ' + chain + ' ' + (entry?.from ?? '')
  601. }
  602. interface Manifest {
  603. name?: unknown
  604. dsh?: unknown
  605. dependencies?: Record<string, string>
  606. peerDependencies?: Record<string, string>
  607. devDependencies?: Record<string, string>
  608. }
  609. function readDeclaration(
  610. root: string,
  611. manifestPath: string,
  612. malformed: string[],
  613. ): ClientDeclaration | undefined {
  614. const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
  615. if (typeof manifest.name !== 'string') return undefined
  616. const dsh = isRecord(manifest.dsh) ? manifest.dsh : undefined
  617. const rawClient = dsh?.client
  618. if (rawClient === undefined) {
  619. return { name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [] }
  620. }
  621. if (!isRecord(rawClient)) {
  622. malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object')
  623. return { name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [] }
  624. }
  625. return {
  626. name: manifest.name,
  627. manifest: manifestPath,
  628. dynamic: true,
  629. external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed),
  630. inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed),
  631. }
  632. }
  633. function stringArray(
  634. value: unknown,
  635. packageName: string,
  636. manifestPath: string,
  637. field: string,
  638. malformed: string[],
  639. ): readonly string[] {
  640. if (value === undefined) return []
  641. if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) {
  642. malformed.push(manifestPath + ': ' + packageName + ' dsh.client.' + field + ' must be a string array')
  643. return []
  644. }
  645. return value as string[]
  646. }
  647. async function readStaticLinkedRoster(root: string): Promise<Set<string>> {
  648. const presetUrl = pathToFileURL(resolve(import.meta.dirname, '..', STATIC_PRESET_SOURCE)).href
  649. const preset = await import(presetUrl) as { isStaticLinkedConfig?: unknown }
  650. if (typeof preset.isStaticLinkedConfig !== 'function') {
  651. throw new Error(GATE + ': ' + STATIC_PRESET_SOURCE + ' exports no isStaticLinkedConfig')
  652. }
  653. const predicate = preset.isStaticLinkedConfig as (configs: readonly unknown[]) => boolean
  654. const roster = new Set<string>()
  655. for (const configPath of globSync(CONFIG_GLOB, { cwd: root }).map(normalizePath).sort()) {
  656. const loaded = await import(pathToFileURL(resolve(root, configPath)).href) as { default?: unknown }
  657. if (typeof loaded.default !== 'function') continue
  658. const configs = (loaded.default as (input: { env: Record<string, string> }) => unknown)({
  659. env: { DSH_BUILD_FACE: 'client' },
  660. })
  661. if (!Array.isArray(configs) || !predicate(configs)) continue
  662. const manifest = JSON.parse(
  663. readFileSync(resolve(root, configPath.replace(/tsdown\.config\.ts$/, 'package.json')), 'utf8'),
  664. ) as Manifest
  665. if (typeof manifest.name === 'string') roster.add(manifest.name)
  666. }
  667. return roster
  668. }
  669. function unwrapExpression(expression: ts.Expression): ts.Expression {
  670. let current = expression
  671. while (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current)) {
  672. current = current.expression
  673. }
  674. return current
  675. }
  676. function readStringLiteralArray(root: string, sourcePath: string, name: string): string[] {
  677. const path = resolve(root, sourcePath)
  678. const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, false, ts.ScriptKind.TS)
  679. const constants = new Map<string, string>()
  680. for (const statement of source.statements) {
  681. if (!ts.isVariableStatement(statement)) continue
  682. for (const declaration of statement.declarationList.declarations) {
  683. if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
  684. const initializer = unwrapExpression(declaration.initializer)
  685. if (ts.isStringLiteral(initializer)) constants.set(declaration.name.text, initializer.text)
  686. }
  687. }
  688. for (const statement of source.statements) {
  689. if (!ts.isVariableStatement(statement)) continue
  690. for (const declaration of statement.declarationList.declarations) {
  691. if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue
  692. const expression = declaration.initializer === undefined ? undefined : unwrapExpression(declaration.initializer)
  693. if (expression === undefined || !ts.isArrayLiteralExpression(expression)) {
  694. throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must be an array literal')
  695. }
  696. return expression.elements.map((element) => {
  697. const value = unwrapExpression(element)
  698. if (ts.isStringLiteral(value)) return value.text
  699. if (ts.isIdentifier(value) && constants.has(value.text)) return constants.get(value.text) as string
  700. throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must contain only string constants')
  701. })
  702. }
  703. }
  704. throw new Error(GATE + ': ' + sourcePath + ' declares no ' + name)
  705. }
  706. async function readFacts(root: string): Promise<ClientPackageFacts> {
  707. const { declarations, malformed } = readClientDeclarations(root)
  708. const byManifest = new Map(declarations.map(entry => [entry.manifest, entry]))
  709. const staticLinkedPackages = await readStaticLinkedRoster(root)
  710. const project = new TypeScriptProject(root, 'client')
  711. const packages: ClientPackage[] = []
  712. for (const manifestPath of globSync(CLIENT_MANIFEST_GLOB, { cwd: root }).map(normalizePath).sort()) {
  713. const declaration = byManifest.get(manifestPath)
  714. if (declaration === undefined) throw new Error(GATE + ': no declaration facts for ' + manifestPath)
  715. const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
  716. if (typeof manifest.name !== 'string') throw new Error(GATE + ': ' + manifestPath + ' has no package name')
  717. const sourceUses = new Map<string, Set<string>>()
  718. const runtimeSourceUses = new Map<string, Set<string>>()
  719. const packageDirectory = dirname(manifestPath)
  720. const sourcePrefix = packageDirectory + '/src/'
  721. for (const sourceFile of project.sourceFiles()) {
  722. if (sourceFile.isDeclarationFile) continue
  723. const file = project.relativePath(sourceFile)
  724. if (!file.startsWith(sourcePrefix)) continue
  725. for (const name of collectSourceFilePackageUses(sourceFile, false)) {
  726. const locations = sourceUses.get(name) ?? new Set<string>()
  727. locations.add(file)
  728. sourceUses.set(name, locations)
  729. }
  730. for (const name of collectSourceFilePackageUses(sourceFile, true)) {
  731. const locations = runtimeSourceUses.get(name) ?? new Set<string>()
  732. locations.add(file)
  733. runtimeSourceUses.set(name, locations)
  734. }
  735. }
  736. packages.push({
  737. ...declaration,
  738. staticLinked: staticLinkedPackages.has(declaration.name),
  739. sourceUses: Object.fromEntries(
  740. [...sourceUses].sort(([left], [right]) => left.localeCompare(right))
  741. .map(([name, locations]) => [name, [...locations].sort()]),
  742. ),
  743. runtimeSourceUses: Object.fromEntries(
  744. [...runtimeSourceUses].sort(([left], [right]) => left.localeCompare(right))
  745. .map(([name, locations]) => [name, [...locations].sort()]),
  746. ),
  747. dependencies: manifest.dependencies ?? {},
  748. peerDependencies: manifest.peerDependencies ?? {},
  749. devDependencies: manifest.devDependencies ?? {},
  750. })
  751. }
  752. return {
  753. packages,
  754. declarations,
  755. staticLinkedPackages,
  756. platformModules: readStringLiteralArray(root, PLATFORM_SOURCE, 'PLATFORM_MODULES'),
  757. preloadedExternals: readStringLiteralArray(root, PLATFORM_SOURCE, 'PRELOADED_CLIENT_EXTERNALS'),
  758. parserPreloadIds: readStringLiteralArray(root, PARSER_PRELOAD_SOURCE, 'PARSER_PRELOAD_IDS'),
  759. malformed,
  760. }
  761. }
  762. function packageNameOf(specifier: string): string {
  763. const segments = specifier.split('/')
  764. return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
  765. }
  766. function stripClientSuffix(specifier: string): string {
  767. return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier
  768. }
  769. function rowNames(declarations: readonly ClientDeclaration[]): Set<string> {
  770. return new Set(declarations.filter(entry => entry.dynamic).map(entry => entry.name))
  771. }
  772. function rowPackageOf(specifier: string, rows: ReadonlySet<string>): string | undefined {
  773. if (rows.has(specifier)) return specifier
  774. const stripped = stripClientSuffix(specifier)
  775. return rows.has(stripped) ? stripped : undefined
  776. }
  777. function declaredSections(pkg: ClientPackage, name: string): string[] {
  778. return (['dependencies', 'peerDependencies', 'devDependencies'] as const)
  779. .filter(section => pkg[section][name] !== undefined)
  780. }
  781. function describeSections(sections: readonly string[]): string {
  782. return sections.length === 0 ? 'no dependency declaration' : sections.join(' + ')
  783. }
  784. function describeRangeMismatch(peer: string | undefined, dev: string | undefined): string {
  785. if (peer === undefined || dev === undefined || peer === dev) return ''
  786. return ' (peer ' + peer + ', dev ' + dev + ')'
  787. }
  788. function describeOrigins(origins: ReadonlySet<string>): string {
  789. const sorted = [...origins].sort()
  790. const [first, second, ...rest] = sorted
  791. if (first === undefined) return 'production use'
  792. if (second === undefined) return first
  793. return rest.length === 0 ? first + ', ' + second : first + ', ' + second + ', and ' + String(rest.length) + ' more'
  794. }
  795. function isInternalDsh(name: string): boolean {
  796. return name === CORDIS || name.startsWith(DSH_PREFIX)
  797. }
  798. function isBareSpecifier(specifier: string): boolean {
  799. return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
  800. }
  801. function isRecord(value: unknown): value is Record<string, unknown> {
  802. return typeof value === 'object' && value !== null && !Array.isArray(value)
  803. }
  804. function normalizePath(path: string): string {
  805. return path.split(sep).join('/')
  806. }
  807. async function main(): Promise<void> {
  808. const root = resolve(import.meta.dirname, '..')
  809. let facts = await readFacts(root)
  810. if (process.argv.includes('--fix')) {
  811. const changed = fixClientPackageManifests(root, facts)
  812. console.log(
  813. changed.length === 0
  814. ? GATE + ': no mechanically fixable manifest changes.'
  815. : GATE + ': fixed ' + String(changed.length) + ' manifest(s): ' + changed.join(', '),
  816. )
  817. facts = await readFacts(root)
  818. }
  819. const violations = collectClientPackageViolations(facts)
  820. if (violations.length > 0) {
  821. console.error(GATE + ': ' + String(violations.length) + ' violation(s):')
  822. for (const violation of violations) console.error(' ' + violation)
  823. process.exit(1)
  824. }
  825. const dynamic = facts.packages.filter(pkg => pkg.dynamic).length
  826. const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0)
  827. console.log(
  828. GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, '
  829. + String(facts.packages.length - dynamic) + ' statically linked) satisfy dependency and module-request rules; '
  830. + String(requests) + ' explicit external request(s).',
  831. )
  832. }
  833. if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
  834. await main()
  835. }