verify-client-packages.ts 32 KB

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