gen-scoped-events.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. /**
  2. * Generate the dev-invariants scoped-event resolver map from the
  3. * repository TypeScript Program.
  4. *
  5. * A scoped event declares `this: Scoped<Base>`. Real `scopeTarget(base, key)`
  6. * calls establish the routing-key type for that base. The generator searches
  7. * every event payload parameter and one property level for exactly one type
  8. * equivalent to that key. Each generated resolver compiles against the merged
  9. * `Events` parameter tuple. Zero matches require `@dshScopeScan unsupported`;
  10. * multiple matches are ambiguous and always fail loud.
  11. *
  12. * `tsx scripts/gen-scoped-events.ts` -> write the generated source
  13. * `tsx scripts/gen-scoped-events.ts --check` -> exit 1 when it is stale
  14. */
  15. import { existsSync, readFileSync, writeFileSync } from 'node:fs'
  16. import { resolve } from 'node:path'
  17. import ts from 'typescript'
  18. import { pointer, rawJsDoc } from './jsdoc.ts'
  19. import { TypeScriptProject } from './ts-project.ts'
  20. const root = resolve(import.meta.dirname, '..')
  21. const OUT = 'packages/support/invariants/src/scoped-events.generated.ts'
  22. const SCOPE_DOC_MARKER = 'Scope-filtered dispatch'
  23. interface ScopeTargetContract {
  24. baseType: ts.Type
  25. keyType: ts.Type
  26. source: string
  27. }
  28. interface SubjectCandidate {
  29. path: string
  30. parameter: number
  31. property?: string
  32. type: ts.Type
  33. }
  34. interface ScopedEventResolver {
  35. event: string
  36. candidate: SubjectCandidate | null
  37. ownerPackage: string
  38. }
  39. interface ScopeTag {
  40. present: boolean
  41. unsupported: boolean
  42. }
  43. /** Program-backed analyzer and renderer for the generated scoped-event resolvers. */
  44. class ScopedEventGenerator {
  45. private readonly checker: ts.TypeChecker
  46. private readonly packageSources: ts.SourceFile[]
  47. private readonly scopeTargetDeclaration: ts.FunctionDeclaration
  48. private readonly scopedSymbol: ts.Symbol
  49. private readonly violations: string[] = []
  50. private readonly packageNames = new Map<string, string>()
  51. constructor(private readonly project: TypeScriptProject) {
  52. this.checker = project.checker
  53. this.packageSources = project.sourceFiles().filter((sourceFile) => {
  54. return /^packages\/[^/]+\/[^/]+\/src\/.+\.ts$/.test(project.relativePath(sourceFile))
  55. })
  56. this.scopeTargetDeclaration = this.functionDeclaration(
  57. 'packages/core/scope/src/index.ts',
  58. 'scopeTarget',
  59. )
  60. this.scopedSymbol = this.typeAliasSymbol(
  61. 'packages/core/scope/src/index.ts',
  62. 'Scoped',
  63. )
  64. }
  65. /** Render the complete generated TypeScript module or throw every contract violation. */
  66. render(): string {
  67. const contracts = this.collectScopeTargetContracts()
  68. const resolvers = this.collectScopedEventResolvers(contracts)
  69. if (this.violations.length > 0) {
  70. throw new Error(
  71. `gen-scoped-events: ${this.violations.length} scoped-event contract violation(s):\n`
  72. + this.violations.map(violation => ` - ${violation}`).join('\n'),
  73. )
  74. }
  75. const ownerImports = [...new Set(resolvers.map(resolver => resolver.ownerPackage))]
  76. .sort()
  77. .map(packageName => `import type {} from ${quote(packageName)}`)
  78. return [
  79. '/**',
  80. ' * Generated scoped-event routing-subject resolvers for dsh-invariants.',
  81. ' * Do not edit by hand; run `pnpm run gen-scoped-events`.',
  82. ' *',
  83. ' * @module @deepseek-ai/dsh-invariants/scoped-events.generated',
  84. ' */',
  85. '',
  86. "import type { Events } from 'cordis'",
  87. "import type { Scoped } from '@deepseek-ai/dsh-scope'",
  88. ...ownerImports,
  89. '',
  90. 'type ScopedEventName = {',
  91. ' [K in keyof Events]: ThisParameterType<Events[K]> extends Scoped<object> ? K : never',
  92. '}[keyof Events]',
  93. '',
  94. 'type ScopedSubjectResolver = (args: readonly unknown[]) => unknown',
  95. '',
  96. 'function adapt<K extends ScopedEventName>(',
  97. ' resolver: (args: Parameters<Events[K]>) => unknown,',
  98. '): ScopedSubjectResolver {',
  99. ' return args => resolver(args as Parameters<Events[K]>)',
  100. '}',
  101. '',
  102. 'const scopedSubjectResolvers = Object.freeze({',
  103. ...resolvers.map(({ event, candidate }) => {
  104. if (candidate === null) return ` '${event}': null,`
  105. const subject = candidate.property === undefined
  106. ? `args[${candidate.parameter}]`
  107. : `args[${candidate.parameter}].${candidate.property}`
  108. return ` '${event}': adapt<'${event}'>(args => ${subject}),`
  109. }),
  110. '} as const satisfies Readonly<Record<ScopedEventName, ScopedSubjectResolver | null>>)',
  111. '',
  112. 'const scopedSubjectResolverIndex: Readonly<Record<string, ScopedSubjectResolver | null>> = scopedSubjectResolvers',
  113. '',
  114. '/**',
  115. ' * Resolve the routing key named by one scoped event payload. A null',
  116. ' * resolver means the payload cannot expose its external routing key, so the',
  117. ' * invariant checks carrier presence only.',
  118. ' * @param event - runtime Cordis event name.',
  119. ' * @returns the generated subject resolver, null for presence-only,',
  120. ' * or undefined when the event is not scope-filtered.',
  121. ' */',
  122. 'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {',
  123. ' return scopedSubjectResolverIndex[event]',
  124. '}',
  125. '',
  126. ].join('\n')
  127. }
  128. /** Resolve one named function declaration from a known source file. */
  129. private functionDeclaration(relativePath: string, name: string): ts.FunctionDeclaration {
  130. const sourceFile = this.project.sourceFile(relativePath)
  131. const declaration = sourceFile.statements.find((statement): statement is ts.FunctionDeclaration => {
  132. return ts.isFunctionDeclaration(statement) && statement.name?.text === name
  133. })
  134. if (!declaration) throw new Error(`gen-scoped-events: cannot resolve function ${name} from ${relativePath}`)
  135. return declaration
  136. }
  137. /** Resolve one named type-alias symbol from a known source file. */
  138. private typeAliasSymbol(relativePath: string, name: string): ts.Symbol {
  139. const sourceFile = this.project.sourceFile(relativePath)
  140. const declaration = sourceFile.statements.find((statement): statement is ts.TypeAliasDeclaration => {
  141. return ts.isTypeAliasDeclaration(statement) && statement.name.text === name
  142. })
  143. const symbol = declaration && this.checker.getSymbolAtLocation(declaration.name)
  144. if (!symbol) throw new Error(`gen-scoped-events: cannot resolve type ${name} from ${relativePath}`)
  145. return symbol
  146. }
  147. /** Collect every real scopeTarget(base, key) base/key type contract. */
  148. private collectScopeTargetContracts(): ScopeTargetContract[] {
  149. const contracts: ScopeTargetContract[] = []
  150. const visit = (sourceFile: ts.SourceFile, node: ts.Node): void => {
  151. if (ts.isCallExpression(node)
  152. && this.checker.getResolvedSignature(node)?.declaration === this.scopeTargetDeclaration) {
  153. const base = node.arguments[0]
  154. const key = node.arguments[1]
  155. if (!base || !key) {
  156. const source = pointer(this.project.relativePath(sourceFile), sourceFile, node)
  157. this.violations.push(`${source} calls scopeTarget without base and key arguments`)
  158. } else {
  159. contracts.push({
  160. baseType: this.checker.getTypeAtLocation(base),
  161. keyType: this.checker.getTypeAtLocation(key),
  162. source: pointer(this.project.relativePath(sourceFile), sourceFile, node),
  163. })
  164. }
  165. }
  166. ts.forEachChild(node, (child) => { visit(sourceFile, child) })
  167. }
  168. for (const sourceFile of this.packageSources) visit(sourceFile, sourceFile)
  169. return contracts
  170. }
  171. /** Collect every Events member and derive its generated resolver. */
  172. private collectScopedEventResolvers(contracts: readonly ScopeTargetContract[]): ScopedEventResolver[] {
  173. const resolvers: ScopedEventResolver[] = []
  174. for (const sourceFile of this.packageSources) {
  175. const rel = this.project.relativePath(sourceFile)
  176. const ownerPackage = this.packageName(packageRootFor(rel))
  177. const visit = (node: ts.Node): void => {
  178. if (ts.isInterfaceDeclaration(node) && node.name.text === 'Events' && isCordisModuleInterface(node)) {
  179. for (const member of node.members) {
  180. if (!ts.isMethodSignature(member) || !ts.isStringLiteral(member.name)) continue
  181. const event = member.name.text
  182. const raw = rawJsDoc(sourceFile.text, member)
  183. const where = `event '${event}' (${pointer(rel, sourceFile, member)})`
  184. const tag = parseScopeTag(raw, where, this.violations)
  185. const thisParameter = member.parameters.find(isThisParameter)
  186. const scopedBase = thisParameter && this.scopedBaseType(thisParameter)
  187. if (!scopedBase) {
  188. if (raw.includes(SCOPE_DOC_MARKER)) {
  189. this.violations.push(
  190. `${where} documents scope-filtered dispatch but its signature has no this: Scoped<...> receiver`,
  191. )
  192. }
  193. if (tag.present) {
  194. this.violations.push(`${where} has @dshScopeScan metadata but is not a Scoped event`)
  195. }
  196. continue
  197. }
  198. if (!raw.includes(SCOPE_DOC_MARKER)) {
  199. this.violations.push(
  200. `${where} has this: Scoped<...> but its JSDoc does not explain "${SCOPE_DOC_MARKER}"`,
  201. )
  202. }
  203. const keyType = this.routingKeyType(where, scopedBase, contracts)
  204. if (!keyType) continue
  205. const candidates = this.subjectCandidates(member)
  206. .filter(candidate => this.typesEquivalent(candidate.type, keyType))
  207. if (candidates.length > 1) {
  208. this.violations.push(
  209. `${where} has multiple routing-key candidates for ${this.typeText(keyType)}: `
  210. + candidates.map(candidate => `${candidate.path}: ${this.typeText(candidate.type)}`).join(', '),
  211. )
  212. continue
  213. }
  214. if (candidates.length === 0) {
  215. if (!tag.unsupported) {
  216. const keyLabel = this.typeText(keyType)
  217. this.violations.push(
  218. `${where} exposes no parameter or one-level property equivalent to routing key type ${keyLabel}; `
  219. + 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload',
  220. )
  221. }
  222. resolvers.push({ event, candidate: null, ownerPackage })
  223. continue
  224. }
  225. if (tag.unsupported) {
  226. this.violations.push(
  227. `${where} has unnecessary @dshScopeScan unsupported; ${candidates[0]?.path} exposes the routing key`,
  228. )
  229. continue
  230. }
  231. resolvers.push({ event, candidate: candidates[0] ?? null, ownerPackage })
  232. }
  233. }
  234. ts.forEachChild(node, visit)
  235. }
  236. visit(sourceFile)
  237. }
  238. return resolvers.sort((left, right) => left.event.localeCompare(right.event))
  239. }
  240. /** Extract the Base type from one exact this: Scoped<Base> parameter. */
  241. private scopedBaseType(parameter: ts.ParameterDeclaration): ts.Type | undefined {
  242. const type = this.checker.getTypeAtLocation(parameter)
  243. if (type.aliasSymbol !== this.scopedSymbol) return undefined
  244. return type.aliasTypeArguments?.[0]
  245. }
  246. /** Resolve one unambiguous key type for a scoped carrier base. */
  247. private routingKeyType(
  248. where: string,
  249. scopedBase: ts.Type,
  250. contracts: readonly ScopeTargetContract[],
  251. ): ts.Type | undefined {
  252. const matches = contracts.filter((contract) => {
  253. return this.checker.isTypeAssignableTo(this.normalizedType(contract.baseType), this.normalizedType(scopedBase))
  254. })
  255. if (matches.length === 0) {
  256. this.violations.push(
  257. `${where} has no matching scopeTarget(base, key) call for carrier base ${this.typeText(scopedBase)}`,
  258. )
  259. return undefined
  260. }
  261. const keyTypes: ts.Type[] = []
  262. for (const match of matches) {
  263. if (!keyTypes.some(type => this.typesEquivalent(type, match.keyType))) keyTypes.push(match.keyType)
  264. }
  265. if (keyTypes.length > 1) {
  266. this.violations.push(
  267. `${where} carrier base ${this.typeText(scopedBase)} has inconsistent routing-key types: `
  268. + matches.map(match => `${this.typeText(match.keyType)} at ${match.source}`).join(', '),
  269. )
  270. return undefined
  271. }
  272. return keyTypes[0]
  273. }
  274. /** Enumerate every payload parameter and every accessible one-level property. */
  275. private subjectCandidates(member: ts.MethodSignature): SubjectCandidate[] {
  276. const candidates: SubjectCandidate[] = []
  277. let runtimeIndex = 0
  278. for (const parameter of member.parameters) {
  279. if (isThisParameter(parameter)) continue
  280. const directPath = `args[${runtimeIndex}]`
  281. const parameterType = this.checker.getTypeAtLocation(parameter)
  282. candidates.push({ path: directPath, parameter: runtimeIndex, type: parameterType })
  283. for (const property of this.checker.getPropertiesOfType(this.normalizedType(parameterType))) {
  284. const name = property.getName()
  285. if (name.startsWith('__@') || hasNonPublicDeclaration(property)) continue
  286. candidates.push({
  287. path: `${directPath}.${name}`,
  288. parameter: runtimeIndex,
  289. property: name,
  290. type: this.checker.getTypeOfSymbolAtLocation(property, parameter),
  291. })
  292. }
  293. runtimeIndex += 1
  294. }
  295. return dedupeCandidates(candidates)
  296. }
  297. /** Read and cache one workspace package name. */
  298. private packageName(packageRoot: string): string {
  299. const cached = this.packageNames.get(packageRoot)
  300. if (cached) return cached
  301. const manifest: unknown = JSON.parse(readFileSync(resolve(root, packageRoot, 'package.json'), 'utf8'))
  302. const name: unknown = typeof manifest === 'object' && manifest !== null
  303. ? Reflect.get(manifest, 'name')
  304. : undefined
  305. if (typeof name !== 'string') throw new Error(`gen-scoped-events: ${packageRoot}/package.json has no name`)
  306. this.packageNames.set(packageRoot, name)
  307. return name
  308. }
  309. /** Compare exact Program type identities after removing null and undefined. */
  310. private typesEquivalent(left: ts.Type, right: ts.Type): boolean {
  311. const normalizedLeft = this.normalizedType(left)
  312. const normalizedRight = this.normalizedType(right)
  313. if (normalizedLeft.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
  314. if (normalizedRight.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
  315. return normalizedLeft === normalizedRight
  316. }
  317. /** Remove null and undefined from a routing or candidate type. */
  318. private normalizedType(type: ts.Type): ts.Type {
  319. return this.checker.getNonNullableType(type)
  320. }
  321. /** Render a stable diagnostic type label. */
  322. private typeText(type: ts.Type): string {
  323. return this.checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation)
  324. }
  325. }
  326. /** Return whether an Events interface is inside declare module 'cordis'. */
  327. function isCordisModuleInterface(node: ts.InterfaceDeclaration): boolean {
  328. const block = node.parent
  329. const declaration = block.parent
  330. return ts.isModuleBlock(block)
  331. && ts.isModuleDeclaration(declaration)
  332. && ts.isStringLiteral(declaration.name)
  333. && declaration.name.text === 'cordis'
  334. }
  335. /** Return whether a parameter is the explicit TypeScript this receiver. */
  336. function isThisParameter(parameter: ts.ParameterDeclaration): boolean {
  337. return ts.isIdentifier(parameter.name) && parameter.name.text === 'this'
  338. }
  339. /** Parse and validate the optional @dshScopeScan unsupported tag. */
  340. function parseScopeTag(raw: string, where: string, violations: string[]): ScopeTag {
  341. const tags = raw
  342. .replace(/^\/\*\*/, '')
  343. .replace(/\*\/$/, '')
  344. .split('\n')
  345. .map(line => line.replace(/^\s*\*?\s?/, '').trim())
  346. .filter(line => line.startsWith('@dshScopeScan'))
  347. if (tags.length > 1) violations.push(`${where} has multiple @dshScopeScan tags`)
  348. if (tags.length === 0) return { present: false, unsupported: false }
  349. const unsupported = tags[0] === '@dshScopeScan unsupported'
  350. if (!unsupported) {
  351. violations.push(
  352. `${where} has invalid scoped-event scan metadata '${tags[0]}'; expected '@dshScopeScan unsupported'`,
  353. )
  354. }
  355. return { present: true, unsupported }
  356. }
  357. /** Return whether a property has a private or protected declaration. */
  358. function hasNonPublicDeclaration(symbol: ts.Symbol): boolean {
  359. return (symbol.declarations ?? []).some((declaration) => {
  360. if (!ts.canHaveModifiers(declaration)) return false
  361. return ts.getModifiers(declaration)?.some((modifier) => {
  362. return modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword
  363. }) ?? false
  364. })
  365. }
  366. /** Deduplicate candidate paths contributed by merged/intersection types. */
  367. function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandidate[] {
  368. const seen = new Set<string>()
  369. return candidates.filter((candidate) => {
  370. if (seen.has(candidate.path)) return false
  371. seen.add(candidate.path)
  372. return true
  373. })
  374. }
  375. /** Return the workspace package root owning one package source file. */
  376. function packageRootFor(relativePath: string): string {
  377. const match = /^(packages\/[^/]+\/[^/]+)\/src\//.exec(relativePath)
  378. if (!match?.[1]) throw new Error(`gen-scoped-events: cannot derive package root from ${relativePath}`)
  379. return match[1]
  380. }
  381. /** Quote a generated property key as a single-quoted TypeScript string. */
  382. function quote(value: string): string {
  383. return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
  384. }
  385. /**
  386. * Render the generated scoped-event resolver module for one repository root.
  387. * @param projectRoot - repository root carrying tsconfig.json.
  388. * @returns complete generated TypeScript source.
  389. */
  390. export function renderScopedEvents(projectRoot: string = root): string {
  391. return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render()
  392. }
  393. /** Generate or freshness-check the fixed invariants source file. */
  394. function main(): void {
  395. const content = renderScopedEvents()
  396. const output = resolve(root, OUT)
  397. if (process.argv.includes('--check')) {
  398. const committed = existsSync(output) ? readFileSync(output, 'utf8') : null
  399. if (committed === content) {
  400. console.log(`gen-scoped-events: ${OUT} is up to date.`)
  401. return
  402. }
  403. console.error(`gen-scoped-events: ${OUT} is stale. Run \`pnpm run gen-scoped-events\` and commit it.`)
  404. process.exit(1)
  405. }
  406. writeFileSync(output, content)
  407. console.log(`gen-scoped-events: wrote ${OUT}.`)
  408. }
  409. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  410. main()
  411. }