package-invariants.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. /**
  2. * Package-invariant companion discovery and structural checks.
  3. * The runtime registry stays product-independent; this gate keeps each
  4. * published companion complete without requiring synthetic empty companions.
  5. */
  6. import { existsSync, globSync, readFileSync } from 'node:fs'
  7. import { dirname, relative, resolve, sep } from 'node:path'
  8. import ts from 'typescript'
  9. import { usesFlattenedPackageDependencies } from './package-dependency-policy.ts'
  10. /** Package README sentence that records why an invariant companion is omitted. */
  11. const OMITTED_COMPANION_REASON = /No (?:(?:runtime )?invariant )?companion is published(?: because|[.:;—])\s+\S/i
  12. interface PackageManifest {
  13. name?: string
  14. dsh?: unknown
  15. exports?: Record<string, { types?: string; default?: string } | string | null | undefined>
  16. files?: string[]
  17. peerDependencies?: Record<string, string>
  18. devDependencies?: Record<string, string>
  19. }
  20. /** One package and the files participating in its invariant publication rules. */
  21. export interface PackageInvariantOwner {
  22. readonly dir: string
  23. readonly manifestPath: string
  24. readonly sourcePath: string
  25. readonly packageName: string
  26. }
  27. /** One gate violation with a repo-relative owner path. */
  28. export interface PackageInvariantViolation {
  29. readonly path: string
  30. readonly message: string
  31. }
  32. /** Discover packages that own an invariant companion. */
  33. export function packageInvariantOwners(root: string): PackageInvariantOwner[] {
  34. return packageInvariantPackages(root)
  35. .filter(owner => existsSync(resolve(root, owner.sourcePath)))
  36. }
  37. /** Discover every package under the repository package tree. */
  38. function packageInvariantPackages(root: string): PackageInvariantOwner[] {
  39. return globSync('packages/*/*/package.json', { cwd: root })
  40. .map(path => path.split(sep).join('/'))
  41. .sort()
  42. .map((manifestPath) => {
  43. const manifest = readManifest(resolve(root, manifestPath))
  44. if (manifest.name === undefined || manifest.name === '') {
  45. throw new Error(`${manifestPath}: package invariant owner must declare a package name`)
  46. }
  47. const dir = dirname(manifestPath)
  48. return {
  49. dir,
  50. manifestPath,
  51. sourcePath: `${dir}/src/invariant.ts`,
  52. packageName: manifest.name,
  53. }
  54. })
  55. }
  56. /** Return all violations of the package-invariant companion rules. */
  57. export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] {
  58. const violations: PackageInvariantViolation[] = []
  59. for (const owner of packageInvariantPackages(root)) {
  60. const manifest = readManifest(resolve(root, owner.manifestPath))
  61. const hasCompanion = existsSync(resolve(root, owner.sourcePath))
  62. checkManifest(owner, manifest, hasCompanion, violations)
  63. checkBuild(owner, root, hasCompanion, violations)
  64. if (hasCompanion) {
  65. checkSource(owner, root, violations)
  66. } else {
  67. checkOmissionReason(owner, root, violations)
  68. }
  69. }
  70. return violations
  71. }
  72. function readManifest(path: string): PackageManifest {
  73. return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
  74. }
  75. function addViolation(
  76. violations: PackageInvariantViolation[],
  77. path: string,
  78. message: string,
  79. ): void {
  80. violations.push({ path, message })
  81. }
  82. function checkManifest(
  83. owner: PackageInvariantOwner,
  84. manifest: PackageManifest,
  85. hasCompanion: boolean,
  86. violations: PackageInvariantViolation[],
  87. ): void {
  88. const invariantExport = manifest.exports?.['./invariant']
  89. if (!hasCompanion) {
  90. if (invariantExport !== undefined) {
  91. addViolation(
  92. violations,
  93. owner.manifestPath,
  94. 'exports["./invariant"] must be omitted when src/invariant.ts is absent',
  95. )
  96. }
  97. if (manifest.files?.includes('lib/invariant.js')) {
  98. addViolation(
  99. violations,
  100. owner.manifestPath,
  101. 'files must omit lib/invariant.js when src/invariant.ts is absent',
  102. )
  103. }
  104. return
  105. }
  106. if (typeof invariantExport !== 'object'
  107. || invariantExport === null
  108. || invariantExport.types !== './lib/types/invariant.d.ts'
  109. || invariantExport.default !== './lib/invariant.js') {
  110. addViolation(
  111. violations,
  112. owner.manifestPath,
  113. 'exports["./invariant"] must target ./lib/types/invariant.d.ts and ./lib/invariant.js',
  114. )
  115. }
  116. if (!manifest.files?.includes('lib/invariant.js')) {
  117. addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
  118. }
  119. if (owner.packageName === '@deepseek-ai/dsh-invariants') return
  120. const developmentOnlyInvariant = usesFlattenedPackageDependencies(
  121. owner.manifestPath,
  122. owner.packageName,
  123. manifest.dsh,
  124. )
  125. const expectedRange = 'workspace:^'
  126. const peerRange = manifest.peerDependencies?.['@deepseek-ai/dsh-invariants']
  127. if (developmentOnlyInvariant ? peerRange !== undefined : peerRange !== expectedRange) {
  128. addViolation(violations, owner.manifestPath, developmentOnlyInvariant
  129. ? '@deepseek-ai/dsh-invariants must not be a peerDependency under this package dependency policy'
  130. : '@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency')
  131. }
  132. if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== expectedRange) {
  133. addViolation(
  134. violations,
  135. owner.manifestPath,
  136. `@deepseek-ai/dsh-invariants must be a ${expectedRange} devDependency`,
  137. )
  138. }
  139. }
  140. function checkBuild(
  141. owner: PackageInvariantOwner,
  142. root: string,
  143. hasCompanion: boolean,
  144. violations: PackageInvariantViolation[],
  145. ): void {
  146. const tsconfigPath = `${owner.dir}/tsconfig.json`
  147. if (hasCompanion
  148. && owner.packageName !== '@deepseek-ai/dsh-invariants'
  149. && !projectReferencesInvariants(root, owner.dir, tsconfigPath)) {
  150. addViolation(
  151. violations,
  152. tsconfigPath,
  153. 'TypeScript project references must include ../../runtime-diagnostics/invariants',
  154. )
  155. } else if (!hasCompanion && projectReferencesInvariants(root, owner.dir, tsconfigPath)) {
  156. addViolation(
  157. violations,
  158. tsconfigPath,
  159. 'TypeScript project references must omit ../../runtime-diagnostics/invariants when src/invariant.ts is absent',
  160. )
  161. }
  162. const configPath = `${owner.dir}/tsdown.config.ts`
  163. if (!existsSync(resolve(root, configPath))) return
  164. const source = readFileSync(resolve(root, configPath), 'utf8')
  165. const bundlesCompanion = source.includes('lib/types/invariant.js')
  166. if (hasCompanion && !bundlesCompanion) {
  167. addViolation(violations, configPath, 'package build override must bundle lib/types/invariant.js')
  168. } else if (!hasCompanion && bundlesCompanion) {
  169. addViolation(
  170. violations,
  171. configPath,
  172. 'package build override must omit lib/types/invariant.js when src/invariant.ts is absent',
  173. )
  174. }
  175. }
  176. function checkOmissionReason(
  177. owner: PackageInvariantOwner,
  178. root: string,
  179. violations: PackageInvariantViolation[],
  180. ): void {
  181. const readmePath = `${owner.dir}/README.md`
  182. const absolutePath = resolve(root, readmePath)
  183. if (!existsSync(absolutePath) || !OMITTED_COMPANION_REASON.test(readFileSync(absolutePath, 'utf8'))) {
  184. addViolation(
  185. violations,
  186. readmePath,
  187. 'omitted companion requires a README "No ... companion is published" reason sentence',
  188. )
  189. }
  190. }
  191. function projectReferencesInvariants(root: string, ownerDir: string, entryPath: string): boolean {
  192. const ownerRoot = resolve(root, ownerDir)
  193. const target = resolve(root, 'packages/runtime-diagnostics/invariants')
  194. const pending = [resolve(root, entryPath)]
  195. const visited = new Set<string>()
  196. while (pending.length > 0) {
  197. const configPath = pending.pop()
  198. if (configPath === undefined) break
  199. if (visited.has(configPath)) continue
  200. visited.add(configPath)
  201. const config = JSON.parse(readFileSync(configPath, 'utf8')) as {
  202. references?: Array<{ path?: string }>
  203. }
  204. for (const reference of config.references ?? []) {
  205. if (reference.path === undefined) continue
  206. const referenced = resolve(dirname(configPath), reference.path)
  207. if (referenced === target) return true
  208. if (!referenced.startsWith(`${ownerRoot}${sep}`)) continue
  209. const childConfig = referenced.endsWith('.json') ? referenced : resolve(referenced, 'tsconfig.json')
  210. if (existsSync(childConfig)) pending.push(childConfig)
  211. }
  212. }
  213. return false
  214. }
  215. function checkSource(
  216. owner: PackageInvariantOwner,
  217. root: string,
  218. violations: PackageInvariantViolation[],
  219. ): void {
  220. const absolutePath = resolve(root, owner.sourcePath)
  221. const sourceText = readFileSync(absolutePath, 'utf8')
  222. if (sourceText.includes('@generated')) {
  223. addViolation(
  224. violations,
  225. owner.sourcePath,
  226. 'invariant companions must be hand-owned and may not carry @generated markers',
  227. )
  228. }
  229. const sourceFile = ts.createSourceFile(
  230. absolutePath,
  231. sourceText,
  232. ts.ScriptTarget.Latest,
  233. true,
  234. ts.ScriptKind.TS,
  235. )
  236. const constants = topLevelStringConstants(sourceFile)
  237. const registrations: string[] = []
  238. const unresolved: number[] = []
  239. const mismatchedInstallers: number[] = []
  240. const visit = (node: ts.Node): void => {
  241. if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) {
  242. const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1
  243. const argument = node.arguments[0]
  244. const packageName = argument === undefined ? undefined : stringValue(argument, constants)
  245. if (packageName === undefined) unresolved.push(line)
  246. else registrations.push(packageName)
  247. const installer = node.arguments[1]
  248. if (installer === undefined || !ts.isIdentifier(installer) || installer.text !== 'install') {
  249. mismatchedInstallers.push(line)
  250. }
  251. }
  252. ts.forEachChild(node, visit)
  253. }
  254. visit(sourceFile)
  255. for (const line of unresolved) {
  256. addViolation(
  257. violations,
  258. owner.sourcePath,
  259. `line ${line}: ctx.invariants.register package name must resolve to a local string constant`,
  260. )
  261. }
  262. for (const line of mismatchedInstallers) {
  263. addViolation(
  264. violations,
  265. owner.sourcePath,
  266. `line ${line}: ctx.invariants.register must use the checked local install function`,
  267. )
  268. }
  269. if (registrations.length !== 1 || registrations[0] !== owner.packageName) {
  270. addViolation(
  271. violations,
  272. owner.sourcePath,
  273. `must register exactly its own package name ${JSON.stringify(owner.packageName)}; saw ${JSON.stringify(registrations)}`,
  274. )
  275. }
  276. for (const exportedName of ['name', 'inject', 'apply']) {
  277. if (!hasNamedExport(sourceFile, exportedName)) {
  278. addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`)
  279. }
  280. }
  281. if (hasDefaultExport(sourceFile)) {
  282. addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace')
  283. }
  284. checkInstaller(owner, sourceFile, violations)
  285. }
  286. function checkInstaller(
  287. owner: PackageInvariantOwner,
  288. sourceFile: ts.SourceFile,
  289. violations: PackageInvariantViolation[],
  290. ): void {
  291. let initializer: ts.Expression | undefined
  292. for (const statement of sourceFile.statements) {
  293. if (!ts.isVariableStatement(statement)) continue
  294. for (const declaration of statement.declarationList.declarations) {
  295. if (ts.isIdentifier(declaration.name)
  296. && declaration.name.text === 'install'
  297. && declaration.initializer !== undefined) {
  298. initializer = declaration.initializer
  299. }
  300. }
  301. }
  302. const installer = initializer === undefined ? undefined : installerFunction(initializer)
  303. if (installer === undefined) {
  304. addViolation(violations, owner.sourcePath, 'must declare a local install function for package-owned checks')
  305. return
  306. }
  307. if (ts.isBlock(installer.body) && installer.body.statements.length === 0) {
  308. addViolation(
  309. violations,
  310. owner.sourcePath,
  311. 'empty install function is unnecessary; omit the companion and its publication wiring',
  312. )
  313. return
  314. }
  315. const reporter = installer.parameters[1]?.name
  316. if (reporter === undefined || !ts.isIdentifier(reporter)) {
  317. addViolation(violations, owner.sourcePath, 'install function must accept the bound failure reporter as its second parameter')
  318. return
  319. }
  320. if (!usesIdentifier(installer.body, reporter.text)) {
  321. addViolation(violations, owner.sourcePath, 'install function must use its bound failure reporter')
  322. }
  323. }
  324. function usesIdentifier(node: ts.Node, name: string): boolean {
  325. return ts.isIdentifier(node) && node.text === name
  326. || node.getChildren().some(child => usesIdentifier(child, name))
  327. }
  328. function installerFunction(
  329. initializer: ts.Expression,
  330. ): ts.ArrowFunction | ts.FunctionExpression | undefined {
  331. if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer
  332. if (ts.isCallExpression(initializer)
  333. && ts.isPropertyAccessExpression(initializer.expression)
  334. && ts.isIdentifier(initializer.expression.expression)
  335. && initializer.expression.expression.text === 'Object'
  336. && initializer.expression.name.text === 'assign') {
  337. const target = initializer.arguments[0]
  338. if (target !== undefined && (ts.isArrowFunction(target) || ts.isFunctionExpression(target))) return target
  339. }
  340. return undefined
  341. }
  342. function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap<string, string> {
  343. const constants = new Map<string, string>()
  344. for (const statement of sourceFile.statements) {
  345. if (!ts.isVariableStatement(statement)) continue
  346. for (const declaration of statement.declarationList.declarations) {
  347. if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
  348. const value = stringValue(declaration.initializer, constants)
  349. if (value !== undefined) constants.set(declaration.name.text, value)
  350. }
  351. }
  352. return constants
  353. }
  354. function stringValue(node: ts.Expression, constants: ReadonlyMap<string, string>): string | undefined {
  355. if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
  356. if (ts.isIdentifier(node)) return constants.get(node.text)
  357. return undefined
  358. }
  359. function isInvariantRegistration(expression: ts.LeftHandSideExpression): boolean {
  360. return ts.isPropertyAccessExpression(expression)
  361. && expression.name.text === 'register'
  362. && ts.isPropertyAccessExpression(expression.expression)
  363. && expression.expression.name.text === 'invariants'
  364. }
  365. function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean {
  366. return sourceFile.statements.some((statement) => {
  367. if (!ts.isVariableStatement(statement)
  368. || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) return false
  369. return statement.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name)
  370. })
  371. }
  372. function hasDefaultExport(sourceFile: ts.SourceFile): boolean {
  373. return sourceFile.statements.some((statement) => {
  374. if (ts.isExportAssignment(statement)) return true
  375. const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
  376. if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true
  377. if (!ts.isExportDeclaration(statement) || statement.exportClause === undefined) return false
  378. if (ts.isNamespaceExport(statement.exportClause)) {
  379. return statement.exportClause.name.text === 'default'
  380. }
  381. return statement.exportClause.elements.some(element => element.name.text === 'default')
  382. })
  383. }
  384. /** Format violations for the command-line gate. */
  385. export function formatPackageInvariantViolation(
  386. root: string,
  387. violation: PackageInvariantViolation,
  388. ): string {
  389. const path = resolve(root, violation.path)
  390. return `${relative(root, path)}: ${violation.message}`
  391. }