package-invariants.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. /**
  2. * Package-invariant companion discovery, generation, 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 { basename, dirname, relative, resolve, sep } from 'node:path'
  8. import ts from 'typescript'
  9. /** Marker identifying baseline companions owned by this generator. */
  10. export const GENERATED_INVARIANT_MARKER = '@generated scripts/gen-package-invariants.ts'
  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. /** Render the generated ownership-only companion for a package without custom checks. */
  50. export function renderBaselineInvariant(owner: PackageInvariantOwner): string {
  51. const serviceImport = owner.packageName === '@deepseek-ai/dsh-invariants'
  52. ? './index.ts'
  53. : '@deepseek-ai/dsh-invariants'
  54. const pluginName = `${basename(owner.dir)}-invariant`
  55. return `/**
  56. * Generated invariant ownership companion for \`${owner.packageName}\`.
  57. * Replace this file with package-owned checks while preserving its registration.
  58. *
  59. * ${GENERATED_INVARIANT_MARKER}
  60. * @module ${owner.packageName}/invariant
  61. */
  62. /* jscpd:ignore-start */
  63. import type { Context } from 'cordis'
  64. import type { InvariantInstaller } from '${serviceImport}'
  65. const PACKAGE_NAME = '${owner.packageName}'
  66. /** Cordis companion plugin name. */
  67. export const name = '${pluginName}'
  68. /** Services required before the companion can register. */
  69. export const inject = ['invariants']
  70. /** Reserve this package's invariant ownership until it adds relational checks. */
  71. const install: InvariantInstaller = () => {}
  72. /**
  73. * Register this package's invariant companion.
  74. * @param ctx - Cordis context carrying the invariant service.
  75. * @returns the installed registration's disposer after setup succeeds.
  76. */
  77. export const apply = (ctx: Context): Promise<() => void> =>
  78. Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
  79. /* jscpd:ignore-end */
  80. `
  81. }
  82. /** Return all violations of the package-invariant companion contract. */
  83. export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] {
  84. const violations: PackageInvariantViolation[] = []
  85. for (const owner of packageInvariantOwners(root)) {
  86. const manifest = readManifest(resolve(root, owner.manifestPath))
  87. checkManifest(owner, manifest, violations)
  88. checkBuild(owner, root, violations)
  89. checkSource(owner, root, violations)
  90. }
  91. return violations
  92. }
  93. function readManifest(path: string): PackageManifest {
  94. return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
  95. }
  96. function addViolation(
  97. violations: PackageInvariantViolation[],
  98. path: string,
  99. message: string,
  100. ): void {
  101. violations.push({ path, message })
  102. }
  103. function checkManifest(
  104. owner: PackageInvariantOwner,
  105. manifest: PackageManifest,
  106. violations: PackageInvariantViolation[],
  107. ): void {
  108. const invariantExport = manifest.exports?.['./invariant']
  109. if (typeof invariantExport !== 'object'
  110. || invariantExport.types !== './lib/types/invariant.d.ts'
  111. || invariantExport.default !== './lib/invariant.js') {
  112. addViolation(
  113. violations,
  114. owner.manifestPath,
  115. 'exports["./invariant"] must target ./lib/types/invariant.d.ts and ./lib/invariant.js',
  116. )
  117. }
  118. if (!manifest.files?.includes('lib/invariant.js')) {
  119. addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
  120. }
  121. if (owner.packageName === '@deepseek-ai/dsh-invariants') return
  122. if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== '^0.0.1') {
  123. addViolation(
  124. violations,
  125. owner.manifestPath,
  126. '@deepseek-ai/dsh-invariants must be a ^0.0.1 peerDependency',
  127. )
  128. }
  129. if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
  130. addViolation(
  131. violations,
  132. owner.manifestPath,
  133. '@deepseek-ai/dsh-invariants must also be a workspace:^ devDependency',
  134. )
  135. }
  136. }
  137. function checkBuild(
  138. owner: PackageInvariantOwner,
  139. root: string,
  140. violations: PackageInvariantViolation[],
  141. ): void {
  142. const tsconfigPath = `${owner.dir}/tsconfig.json`
  143. const tsconfig = JSON.parse(readFileSync(resolve(root, tsconfigPath), 'utf8')) as {
  144. references?: Array<{ path?: string }>
  145. }
  146. if (owner.packageName !== '@deepseek-ai/dsh-invariants'
  147. && !tsconfig.references?.some(reference => reference.path === '../../support/invariants')) {
  148. addViolation(
  149. violations,
  150. tsconfigPath,
  151. 'TypeScript project references must include ../../support/invariants',
  152. )
  153. }
  154. const configPath = `${owner.dir}/tsdown.config.ts`
  155. if (!existsSync(resolve(root, configPath))) return
  156. const source = readFileSync(resolve(root, configPath), 'utf8')
  157. if (!source.includes('lib/types/invariant.js')) {
  158. addViolation(violations, configPath, 'package build override must bundle lib/types/invariant.js')
  159. }
  160. }
  161. function checkSource(
  162. owner: PackageInvariantOwner,
  163. root: string,
  164. violations: PackageInvariantViolation[],
  165. ): void {
  166. const absolutePath = resolve(root, owner.sourcePath)
  167. if (!existsSync(absolutePath)) {
  168. addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion')
  169. return
  170. }
  171. const sourceText = readFileSync(absolutePath, 'utf8')
  172. if (sourceText.includes(GENERATED_INVARIANT_MARKER)
  173. && sourceText !== renderBaselineInvariant(owner)) {
  174. addViolation(
  175. violations,
  176. owner.sourcePath,
  177. 'generated baseline is stale; run pnpm run gen-package-invariants',
  178. )
  179. }
  180. const sourceFile = ts.createSourceFile(
  181. absolutePath,
  182. sourceText,
  183. ts.ScriptTarget.Latest,
  184. true,
  185. ts.ScriptKind.TS,
  186. )
  187. const constants = topLevelStringConstants(sourceFile)
  188. const registrations: string[] = []
  189. const unresolved: number[] = []
  190. const visit = (node: ts.Node): void => {
  191. if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) {
  192. const argument = node.arguments[0]
  193. const packageName = argument === undefined ? undefined : stringValue(argument, constants)
  194. if (packageName === undefined) unresolved.push(sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1)
  195. else registrations.push(packageName)
  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. if (registrations.length !== 1 || registrations[0] !== owner.packageName) {
  208. addViolation(
  209. violations,
  210. owner.sourcePath,
  211. `must register exactly its own package name ${JSON.stringify(owner.packageName)}; saw ${JSON.stringify(registrations)}`,
  212. )
  213. }
  214. for (const exportedName of ['name', 'inject', 'apply']) {
  215. if (!hasNamedExport(sourceFile, exportedName)) {
  216. addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`)
  217. }
  218. }
  219. }
  220. function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap<string, string> {
  221. const constants = new Map<string, string>()
  222. for (const statement of sourceFile.statements) {
  223. if (!ts.isVariableStatement(statement)) continue
  224. for (const declaration of statement.declarationList.declarations) {
  225. if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
  226. const value = stringValue(declaration.initializer, constants)
  227. if (value !== undefined) constants.set(declaration.name.text, value)
  228. }
  229. }
  230. return constants
  231. }
  232. function stringValue(node: ts.Expression, constants: ReadonlyMap<string, string>): string | undefined {
  233. if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
  234. if (ts.isIdentifier(node)) return constants.get(node.text)
  235. return undefined
  236. }
  237. function isInvariantRegistration(expression: ts.LeftHandSideExpression): boolean {
  238. return ts.isPropertyAccessExpression(expression)
  239. && expression.name.text === 'register'
  240. && ts.isPropertyAccessExpression(expression.expression)
  241. && expression.expression.name.text === 'invariants'
  242. }
  243. function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean {
  244. return sourceFile.statements.some((statement) => {
  245. if (!ts.isVariableStatement(statement)
  246. || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) return false
  247. return statement.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name)
  248. })
  249. }
  250. /** Format violations for the command-line gate. */
  251. export function formatPackageInvariantViolation(
  252. root: string,
  253. violation: PackageInvariantViolation,
  254. ): string {
  255. const path = resolve(root, violation.path)
  256. return `${relative(root, path)}: ${violation.message}`
  257. }