1
0

package-invariants.ts 14 KB

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