package-invariants.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. /**
  2. * Package-invariant companion discovery and structural checks.
  3. * The runtime registry stays product-independent; this gate makes ownership
  4. * exhaustive across packages without centralizing package checks.
  5. */
  6. import { existsSync, globSync, readFileSync } from 'node:fs'
  7. import { dirname, relative, resolve, sep } from 'node:path'
  8. import ts from 'typescript'
  9. /** Required explanation marker for an intentionally empty installer. */
  10. const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:'
  11. interface PackageManifest {
  12. name?: string
  13. exports?: Record<string, { types?: string; default?: string } | string | undefined>
  14. files?: string[]
  15. peerDependencies?: Record<string, string>
  16. devDependencies?: Record<string, string>
  17. }
  18. /** One package and the files participating in its invariant publication contract. */
  19. export interface PackageInvariantOwner {
  20. readonly dir: string
  21. readonly manifestPath: string
  22. readonly sourcePath: string
  23. readonly packageName: string
  24. }
  25. /** One gate violation with a repo-relative owner path. */
  26. export interface PackageInvariantViolation {
  27. readonly path: string
  28. readonly message: string
  29. }
  30. /** Discover every package under the repository package tree. */
  31. export function packageInvariantOwners(root: string): PackageInvariantOwner[] {
  32. return globSync('packages/*/*/package.json', { cwd: root })
  33. .map(path => path.split(sep).join('/'))
  34. .sort()
  35. .map((manifestPath) => {
  36. const manifest = readManifest(resolve(root, manifestPath))
  37. if (manifest.name === undefined || manifest.name === '') {
  38. throw new Error(`${manifestPath}: package invariant owner must declare a package name`)
  39. }
  40. const dir = dirname(manifestPath)
  41. return {
  42. dir,
  43. manifestPath,
  44. sourcePath: `${dir}/src/invariant.ts`,
  45. packageName: manifest.name,
  46. }
  47. })
  48. }
  49. /** Return all violations of the package-invariant companion contract. */
  50. export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] {
  51. const violations: PackageInvariantViolation[] = []
  52. for (const owner of packageInvariantOwners(root)) {
  53. const manifest = readManifest(resolve(root, owner.manifestPath))
  54. checkManifest(owner, manifest, violations)
  55. checkBuild(owner, root, violations)
  56. checkSource(owner, root, violations)
  57. }
  58. return violations
  59. }
  60. function readManifest(path: string): PackageManifest {
  61. return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
  62. }
  63. function addViolation(
  64. violations: PackageInvariantViolation[],
  65. path: string,
  66. message: string,
  67. ): void {
  68. violations.push({ path, message })
  69. }
  70. function checkManifest(
  71. owner: PackageInvariantOwner,
  72. manifest: PackageManifest,
  73. violations: PackageInvariantViolation[],
  74. ): void {
  75. const invariantExport = manifest.exports?.['./invariant']
  76. if (typeof invariantExport !== 'object'
  77. || invariantExport.types !== './lib/types/invariant.d.ts'
  78. || invariantExport.default !== './lib/invariant.js') {
  79. addViolation(
  80. violations,
  81. owner.manifestPath,
  82. 'exports["./invariant"] must target ./lib/types/invariant.d.ts and ./lib/invariant.js',
  83. )
  84. }
  85. if (!manifest.files?.includes('lib/invariant.js')) {
  86. addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
  87. }
  88. if (owner.packageName === '@deepseek-ai/dsh-invariants') return
  89. if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== '^0.0.1') {
  90. addViolation(
  91. violations,
  92. owner.manifestPath,
  93. '@deepseek-ai/dsh-invariants must be a ^0.0.1 peerDependency',
  94. )
  95. }
  96. if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
  97. addViolation(
  98. violations,
  99. owner.manifestPath,
  100. '@deepseek-ai/dsh-invariants must also be a workspace:^ devDependency',
  101. )
  102. }
  103. }
  104. function checkBuild(
  105. owner: PackageInvariantOwner,
  106. root: string,
  107. violations: PackageInvariantViolation[],
  108. ): void {
  109. const tsconfigPath = `${owner.dir}/tsconfig.json`
  110. const tsconfig = JSON.parse(readFileSync(resolve(root, tsconfigPath), 'utf8')) as {
  111. references?: Array<{ path?: string }>
  112. }
  113. if (owner.packageName !== '@deepseek-ai/dsh-invariants'
  114. && !tsconfig.references?.some(reference => reference.path === '../../support/invariants')) {
  115. addViolation(
  116. violations,
  117. tsconfigPath,
  118. 'TypeScript project references must include ../../support/invariants',
  119. )
  120. }
  121. const configPath = `${owner.dir}/tsdown.config.ts`
  122. if (!existsSync(resolve(root, configPath))) return
  123. const source = readFileSync(resolve(root, configPath), 'utf8')
  124. if (!source.includes('lib/types/invariant.js')) {
  125. addViolation(violations, configPath, 'package build override must bundle lib/types/invariant.js')
  126. }
  127. }
  128. function checkSource(
  129. owner: PackageInvariantOwner,
  130. root: string,
  131. violations: PackageInvariantViolation[],
  132. ): void {
  133. const absolutePath = resolve(root, owner.sourcePath)
  134. if (!existsSync(absolutePath)) {
  135. addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion')
  136. return
  137. }
  138. const sourceText = readFileSync(absolutePath, 'utf8')
  139. if (sourceText.includes('@generated')) {
  140. addViolation(
  141. violations,
  142. owner.sourcePath,
  143. 'invariant companions must be hand-owned and may not carry @generated markers',
  144. )
  145. }
  146. const sourceFile = ts.createSourceFile(
  147. absolutePath,
  148. sourceText,
  149. ts.ScriptTarget.Latest,
  150. true,
  151. ts.ScriptKind.TS,
  152. )
  153. const constants = topLevelStringConstants(sourceFile)
  154. const registrations: string[] = []
  155. const unresolved: number[] = []
  156. const mismatchedInstallers: number[] = []
  157. const visit = (node: ts.Node): void => {
  158. if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) {
  159. const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1
  160. const argument = node.arguments[0]
  161. const packageName = argument === undefined ? undefined : stringValue(argument, constants)
  162. if (packageName === undefined) unresolved.push(line)
  163. else registrations.push(packageName)
  164. const installer = node.arguments[1]
  165. if (installer === undefined || !ts.isIdentifier(installer) || installer.text !== 'install') {
  166. mismatchedInstallers.push(line)
  167. }
  168. }
  169. ts.forEachChild(node, visit)
  170. }
  171. visit(sourceFile)
  172. for (const line of unresolved) {
  173. addViolation(
  174. violations,
  175. owner.sourcePath,
  176. `line ${line}: ctx.invariants.register package name must resolve to a local string constant`,
  177. )
  178. }
  179. for (const line of mismatchedInstallers) {
  180. addViolation(
  181. violations,
  182. owner.sourcePath,
  183. `line ${line}: ctx.invariants.register must use the checked local install function`,
  184. )
  185. }
  186. if (registrations.length !== 1 || registrations[0] !== owner.packageName) {
  187. addViolation(
  188. violations,
  189. owner.sourcePath,
  190. `must register exactly its own package name ${JSON.stringify(owner.packageName)}; saw ${JSON.stringify(registrations)}`,
  191. )
  192. }
  193. for (const exportedName of ['name', 'inject', 'apply']) {
  194. if (!hasNamedExport(sourceFile, exportedName)) {
  195. addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`)
  196. }
  197. }
  198. if (hasDefaultExport(sourceFile)) {
  199. addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace')
  200. }
  201. checkInstaller(owner, sourceFile, sourceText, violations)
  202. }
  203. function checkInstaller(
  204. owner: PackageInvariantOwner,
  205. sourceFile: ts.SourceFile,
  206. sourceText: string,
  207. violations: PackageInvariantViolation[],
  208. ): void {
  209. let initializer: ts.Expression | undefined
  210. let declarationStatement: ts.VariableStatement | undefined
  211. for (const statement of sourceFile.statements) {
  212. if (!ts.isVariableStatement(statement)) continue
  213. for (const declaration of statement.declarationList.declarations) {
  214. if (ts.isIdentifier(declaration.name)
  215. && declaration.name.text === 'install'
  216. && declaration.initializer !== undefined) {
  217. initializer = declaration.initializer
  218. declarationStatement = statement
  219. }
  220. }
  221. }
  222. const installer = initializer === undefined ? undefined : installerFunction(initializer)
  223. if (installer === undefined) {
  224. addViolation(violations, owner.sourcePath, 'must declare a local install function for package-owned checks')
  225. return
  226. }
  227. if (ts.isBlock(installer.body) && installer.body.statements.length === 0) {
  228. const declarationText = declarationStatement === undefined
  229. ? ''
  230. : sourceText.slice(declarationStatement.getFullStart(), declarationStatement.getEnd())
  231. if (!declarationText.includes(NO_RUNTIME_INVARIANT_MARKER)) {
  232. addViolation(
  233. violations,
  234. owner.sourcePath,
  235. `empty install function must explain why with a "${NO_RUNTIME_INVARIANT_MARKER}" comment`,
  236. )
  237. }
  238. return
  239. }
  240. const reporter = installer.parameters[1]?.name
  241. if (reporter === undefined || !ts.isIdentifier(reporter)) {
  242. addViolation(violations, owner.sourcePath, 'install function must accept the bound failure reporter as its second parameter')
  243. return
  244. }
  245. if (!usesIdentifier(installer.body, reporter.text)) {
  246. addViolation(violations, owner.sourcePath, 'install function must use its bound failure reporter')
  247. }
  248. }
  249. function usesIdentifier(node: ts.Node, name: string): boolean {
  250. return ts.isIdentifier(node) && node.text === name
  251. || node.getChildren().some(child => usesIdentifier(child, name))
  252. }
  253. function installerFunction(
  254. initializer: ts.Expression,
  255. ): ts.ArrowFunction | ts.FunctionExpression | undefined {
  256. if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer
  257. if (ts.isCallExpression(initializer)
  258. && ts.isPropertyAccessExpression(initializer.expression)
  259. && ts.isIdentifier(initializer.expression.expression)
  260. && initializer.expression.expression.text === 'Object'
  261. && initializer.expression.name.text === 'assign') {
  262. const target = initializer.arguments[0]
  263. if (target !== undefined && (ts.isArrowFunction(target) || ts.isFunctionExpression(target))) return target
  264. }
  265. return undefined
  266. }
  267. function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap<string, string> {
  268. const constants = new Map<string, string>()
  269. for (const statement of sourceFile.statements) {
  270. if (!ts.isVariableStatement(statement)) continue
  271. for (const declaration of statement.declarationList.declarations) {
  272. if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
  273. const value = stringValue(declaration.initializer, constants)
  274. if (value !== undefined) constants.set(declaration.name.text, value)
  275. }
  276. }
  277. return constants
  278. }
  279. function stringValue(node: ts.Expression, constants: ReadonlyMap<string, string>): string | undefined {
  280. if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
  281. if (ts.isIdentifier(node)) return constants.get(node.text)
  282. return undefined
  283. }
  284. function isInvariantRegistration(expression: ts.LeftHandSideExpression): boolean {
  285. return ts.isPropertyAccessExpression(expression)
  286. && expression.name.text === 'register'
  287. && ts.isPropertyAccessExpression(expression.expression)
  288. && expression.expression.name.text === 'invariants'
  289. }
  290. function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean {
  291. return sourceFile.statements.some((statement) => {
  292. if (!ts.isVariableStatement(statement)
  293. || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) return false
  294. return statement.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name)
  295. })
  296. }
  297. function hasDefaultExport(sourceFile: ts.SourceFile): boolean {
  298. return sourceFile.statements.some((statement) => {
  299. if (ts.isExportAssignment(statement)) return true
  300. const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
  301. if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true
  302. if (!ts.isExportDeclaration(statement) || statement.exportClause === undefined) return false
  303. if (ts.isNamespaceExport(statement.exportClause)) {
  304. return statement.exportClause.name.text === 'default'
  305. }
  306. return statement.exportClause.elements.some(element => element.name.text === 'default')
  307. })
  308. }
  309. /** Format violations for the command-line gate. */
  310. export function formatPackageInvariantViolation(
  311. root: string,
  312. violation: PackageInvariantViolation,
  313. ): string {
  314. const path = resolve(root, violation.path)
  315. return `${relative(root, path)}: ${violation.message}`
  316. }