1
0

verify-client-packages.ts 39 KB

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