gen-dependency-catalog.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. /** Generate the README npx dependency catalog from a recorded public npm resolution. */
  2. import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
  3. import { mkdtemp, rm } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { dirname, join, resolve } from 'node:path'
  6. import { parseArgs } from 'node:util'
  7. import { runCommandWithTimeout } from './benchmark-npm-resolution.ts'
  8. const ROOT = resolve(import.meta.dirname, '..')
  9. const PACKAGE = '@deepseek-ai/dsh'
  10. const ENTRY = `node_modules/${PACKAGE}`
  11. const REGISTRY = 'https://registry.npmjs.org/'
  12. const LOCK = 'scripts/dependency-catalog/package-lock.json'
  13. const METADATA = 'scripts/dependency-catalog/resolution.json'
  14. const OUT = 'docs/dependency-catalog.json'
  15. /** npm lockfile fields used to describe an installed package or optional candidate. */
  16. interface PackageEntry {
  17. name?: string
  18. version: string
  19. dependencies?: Record<string, string>
  20. optionalDependencies?: Record<string, string>
  21. dev?: boolean
  22. optional?: boolean
  23. peer?: boolean
  24. os?: string[]
  25. cpu?: string[]
  26. libc?: string[]
  27. }
  28. /** One package location in npm's resolved tree; duplicate versions retain their locations. */
  29. export interface DependencyRow {
  30. readonly location: string
  31. readonly name: string
  32. readonly version: string
  33. readonly direct: boolean
  34. readonly optional: boolean
  35. readonly peer: boolean
  36. readonly os?: readonly string[]
  37. readonly cpu?: readonly string[]
  38. readonly libc?: readonly string[]
  39. }
  40. interface ResolutionMetadata {
  41. capturedAt: string
  42. npm: string
  43. node: string
  44. platform: string
  45. arch: string
  46. registry: string
  47. installStrategy: string
  48. }
  49. function record(value: unknown, label: string): Record<string, unknown> {
  50. if (typeof value !== 'object' || value === null || Array.isArray(value)) {
  51. throw new Error(`dependency-catalog: ${label} must be an object`)
  52. }
  53. return value as Record<string, unknown>
  54. }
  55. function string(value: unknown, label: string): string {
  56. if (typeof value !== 'string' || value.length === 0 || /[\r\n]/.test(value)) {
  57. throw new Error(`dependency-catalog: ${label} must be a nonempty single-line value`)
  58. }
  59. return value
  60. }
  61. function parseEntry(value: unknown, location: string): PackageEntry {
  62. const raw = record(value, location)
  63. const entry: PackageEntry = { version: string(raw['version'], `${location} version`) }
  64. if (raw['name'] !== undefined) entry.name = string(raw['name'], `${location} name`)
  65. if (raw['link'] === true) throw new Error(`dependency-catalog: ${location} is a local link`)
  66. const source = new URL(string(raw['resolved'], `${location} resolved`))
  67. if (source.origin !== new URL(REGISTRY).origin || source.username !== '' || source.password !== '') {
  68. throw new Error(`dependency-catalog: ${location} is not resolved from the public npm registry`)
  69. }
  70. for (const key of ['dev', 'optional', 'peer'] as const) {
  71. if (raw[key] === undefined) continue
  72. if (typeof raw[key] !== 'boolean') throw new Error(`dependency-catalog: ${location} ${key} must be boolean`)
  73. entry[key] = raw[key]
  74. }
  75. for (const key of ['dependencies', 'optionalDependencies'] as const) {
  76. if (raw[key] === undefined) continue
  77. entry[key] = Object.fromEntries(Object.entries(record(raw[key], `${location} ${key}`))
  78. .map(([name, spec]) => [string(name, 'dependency name'), string(spec, `${location} ${name}`)]))
  79. }
  80. for (const key of ['os', 'cpu', 'libc'] as const) {
  81. const values = raw[key]
  82. if (values === undefined) continue
  83. if (!Array.isArray(values)) throw new Error(`dependency-catalog: ${location} ${key} must be an array`)
  84. entry[key] = values.map(value => string(value, `${location} ${key}`))
  85. }
  86. return entry
  87. }
  88. /**
  89. * Read npm's resolved production tree without substituting workspace or development dependencies.
  90. * @param input - Parsed npm lockfile v3 from a consumer with only dsh as its dependency.
  91. * @returns The CLI version and sorted package locations, excluding the synthetic consumer and CLI itself.
  92. */
  93. export function collectDependencies(input: unknown): { version: string; rows: DependencyRow[] } {
  94. const lock = record(input, 'lockfile')
  95. if (lock['lockfileVersion'] !== 3) throw new Error('dependency-catalog: expected npm lockfileVersion 3')
  96. const packages = record(lock['packages'], 'packages')
  97. const consumer = record(packages[''], 'consumer')
  98. const requested = record(consumer['dependencies'], 'consumer dependencies')
  99. if (Object.keys(requested).length !== 1 || requested[PACKAGE] !== 'latest') {
  100. throw new Error(`dependency-catalog: consumer must request only ${PACKAGE}@latest`)
  101. }
  102. const entries = new Map(Object.entries(packages).filter(([location]) => location !== '')
  103. .map(([location, entry]) => [location, parseEntry(entry, location)]))
  104. const cli = entries.get(ENTRY)
  105. if (cli === undefined || cli.dev) throw new Error(`dependency-catalog: missing production ${PACKAGE}`)
  106. const direct = new Set<string>()
  107. for (const name of Object.keys({ ...cli.dependencies, ...cli.optionalDependencies })) {
  108. const location = [`${ENTRY}/node_modules/${name}`, `node_modules/${name}`]
  109. .find(candidate => entries.has(candidate))
  110. if (location === undefined) {
  111. if (cli.optionalDependencies?.[name] !== undefined) continue
  112. throw new Error(`dependency-catalog: missing direct dependency ${name}`)
  113. }
  114. direct.add(location)
  115. }
  116. const rows: DependencyRow[] = []
  117. for (const [location, entry] of entries) {
  118. if (location === ENTRY || entry.dev) continue
  119. const name = location.split('node_modules/').at(-1)
  120. if (name === undefined || !/^(@[^/]+\/)?[^/]+$/.test(name)) {
  121. throw new Error(`dependency-catalog: invalid npm package location ${location}`)
  122. }
  123. rows.push({
  124. location,
  125. name: entry.name ?? name,
  126. version: entry.version,
  127. direct: direct.has(location),
  128. optional: entry.optional === true,
  129. peer: entry.peer === true,
  130. ...(entry.os === undefined ? {} : { os: entry.os }),
  131. ...(entry.cpu === undefined ? {} : { cpu: entry.cpu }),
  132. ...(entry.libc === undefined ? {} : { libc: entry.libc }),
  133. })
  134. }
  135. if (rows.length === 0) throw new Error('dependency-catalog: empty dependency tree')
  136. rows.sort((a, b) => a.name.localeCompare(b.name, 'en')
  137. || a.version.localeCompare(b.version, 'en') || a.location.localeCompare(b.location, 'en'))
  138. return { version: cli.version, rows }
  139. }
  140. function readMetadata(scanRoot: string): ResolutionMetadata {
  141. const value = record(JSON.parse(readFileSync(resolve(scanRoot, METADATA), 'utf8')), 'resolution metadata')
  142. if (value['registry'] !== REGISTRY || value['installStrategy'] !== 'hoisted') {
  143. throw new Error('dependency-catalog: expected the public npm registry and hoisted install strategy')
  144. }
  145. return {
  146. capturedAt: string(value['capturedAt'], 'capturedAt'),
  147. npm: string(value['npm'], 'npm'),
  148. node: string(value['node'], 'node'),
  149. platform: string(value['platform'], 'platform'),
  150. arch: string(value['arch'], 'arch'),
  151. registry: value['registry'],
  152. installStrategy: value['installStrategy'],
  153. }
  154. }
  155. /**
  156. * Combine identical package versions while retaining each installation's npm flags and platform constraints.
  157. * @param rows - Resolved package locations, excluding the consumer and CLI.
  158. * @returns One sorted record per package name and version, with sorted installation locations.
  159. */
  160. export function deduplicateDependencies(rows: readonly DependencyRow[]): {
  161. name: string
  162. version: string
  163. installations: Omit<DependencyRow, 'name' | 'version'>[]
  164. }[] {
  165. const packages = new Map<string, {
  166. name: string
  167. version: string
  168. installations: Omit<DependencyRow, 'name' | 'version'>[]
  169. }>()
  170. for (const { name, version, ...installation } of rows) {
  171. const key = JSON.stringify([name, version])
  172. const dependency = packages.get(key) ?? { name, version, installations: [] }
  173. dependency.installations.push(installation)
  174. packages.set(key, dependency)
  175. }
  176. const sorted = [...packages.values()].sort((a, b) => a.name.localeCompare(b.name, 'en')
  177. || a.version.localeCompare(b.version, 'en'))
  178. for (const dependency of sorted) {
  179. dependency.installations.sort((a, b) => a.location.localeCompare(b.location, 'en'))
  180. }
  181. return sorted
  182. }
  183. function renderCatalog(lock: unknown, metadata: ResolutionMetadata): string {
  184. const { version, rows } = collectDependencies(lock)
  185. return `${JSON.stringify({
  186. package: PACKAGE,
  187. version,
  188. requested: `${PACKAGE}@latest`,
  189. resolution: metadata,
  190. dependencies: deduplicateDependencies(rows),
  191. }, null, 2)}\n`
  192. }
  193. /**
  194. * Compute the JSON catalog without network access or writes.
  195. * @param scanRoot - Repository root containing the recorded npm resolution.
  196. * @returns Deterministic JSON with one trailing newline.
  197. */
  198. export function computeDependencyCatalog(scanRoot: string = ROOT): string {
  199. return renderCatalog(JSON.parse(readFileSync(resolve(scanRoot, LOCK), 'utf8')), readMetadata(scanRoot))
  200. }
  201. /**
  202. * Check the generated JSON against the recorded npm resolution.
  203. * @param scanRoot - Repository root containing the resolution and catalog.
  204. * @returns Whether the catalog exists and matches its inputs byte for byte.
  205. */
  206. export function isDependencyCatalogCurrent(scanRoot: string = ROOT): boolean {
  207. return existsSync(resolve(scanRoot, OUT))
  208. && readFileSync(resolve(scanRoot, OUT), 'utf8') === computeDependencyCatalog(scanRoot)
  209. }
  210. /**
  211. * Isolate npm configuration and cache from the maintainer and invoking package manager.
  212. * @param temporary - Private consumer directory owned by the caller.
  213. * @param inherited - Environment whose non-npm settings remain available to the child.
  214. * @returns Complete child environment, including isolated user/global configuration and cache paths.
  215. */
  216. export function createNpmResolutionEnvironment(
  217. temporary: string,
  218. inherited: NodeJS.ProcessEnv = process.env,
  219. ): NodeJS.ProcessEnv {
  220. const userConfig = join(temporary, '.npmrc-user')
  221. const globalConfig = join(temporary, '.npmrc-global')
  222. writeFileSync(userConfig, '')
  223. writeFileSync(globalConfig, '')
  224. writeFileSync(join(temporary, '.npmrc'), `registry=${REGISTRY}\n@deepseek-ai:registry=${REGISTRY}\ninstall-strategy=hoisted\n`)
  225. return {
  226. ...Object.fromEntries(Object.entries(inherited).filter(([name]) => !name.toLowerCase().startsWith('npm_config_'))),
  227. npm_config_userconfig: userConfig,
  228. npm_config_globalconfig: globalConfig,
  229. npm_config_cache: join(temporary, '.npm-cache'),
  230. npm_config_update_notifier: 'false',
  231. }
  232. }
  233. async function runNpm(args: readonly string[], cwd: string, env: NodeJS.ProcessEnv): Promise<string> {
  234. const result = await runCommandWithTimeout(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, {
  235. cwd, env, timeoutMs: 300_000,
  236. })
  237. if (result.timedOut || result.status !== 0) {
  238. throw new Error(`dependency-catalog: npm failed (timedOut=${result.timedOut}, status=${String(result.status)}): ${result.output}`)
  239. }
  240. return result.output.trim()
  241. }
  242. async function refreshResolution(): Promise<void> {
  243. const temporary = await mkdtemp(join(tmpdir(), 'dsh-dependency-catalog-'))
  244. try {
  245. writeFileSync(join(temporary, 'package.json'), `${JSON.stringify({
  246. name: 'dsh-dependency-catalog', version: '0.0.0', private: true, dependencies: { [PACKAGE]: 'latest' },
  247. }, null, 2)}\n`)
  248. const environment = createNpmResolutionEnvironment(temporary)
  249. const npm = await runNpm(['--version'], temporary, environment)
  250. await runNpm([
  251. 'install', '--package-lock-only', '--lockfile-version=3', '--ignore-scripts', '--no-audit', '--no-fund',
  252. '--include=prod', '--include=optional', '--include=peer', '--legacy-peer-deps=false',
  253. '--install-strategy=hoisted', '--loglevel=error', `--registry=${REGISTRY}`,
  254. ], temporary, environment)
  255. const lock = readFileSync(join(temporary, 'package-lock.json'), 'utf8')
  256. const metadata: ResolutionMetadata = {
  257. capturedAt: new Date().toISOString(), npm, node: process.versions.node,
  258. platform: process.platform, arch: process.arch, registry: REGISTRY, installStrategy: 'hoisted',
  259. }
  260. const catalog = renderCatalog(JSON.parse(lock), metadata)
  261. mkdirSync(resolve(ROOT, dirname(LOCK)), { recursive: true })
  262. writeFileSync(resolve(ROOT, LOCK), lock)
  263. writeFileSync(resolve(ROOT, METADATA), `${JSON.stringify(metadata, null, 2)}\n`)
  264. writeFileSync(resolve(ROOT, OUT), catalog)
  265. } finally {
  266. await rm(temporary, { recursive: true, force: true, maxRetries: 3 })
  267. }
  268. }
  269. async function main(): Promise<void> {
  270. const { values } = parseArgs({ options: { check: { type: 'boolean' }, refresh: { type: 'boolean' } } })
  271. if (values.check && values.refresh) throw new Error('dependency-catalog: --check cannot refresh the registry record')
  272. if (values.refresh) {
  273. await refreshResolution()
  274. } else if (values.check) {
  275. if (!isDependencyCatalogCurrent()) {
  276. throw new Error(`dependency-catalog: stale ${OUT}; run pnpm run gen-dependency-catalog`)
  277. }
  278. console.log('dependency-catalog: JSON matches the recorded npm resolution')
  279. return
  280. } else {
  281. writeFileSync(resolve(ROOT, OUT), computeDependencyCatalog())
  282. }
  283. console.log(`dependency-catalog: generated ${OUT}`)
  284. }
  285. if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()