gen-scoped-events.ts 16 KB

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