slot-walk.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. /**
  2. * AST helpers for the client slot surface: the `SlotMap` declaration merges
  3. * that type every slot, and the `slots.register` call sites that say who
  4. * already occupies one. Both readings are lexical (no type-checker program):
  5. * the client catalog generator consumes them, and the same scan doubles as its
  6. * own exhaustiveness backstop because it reads every source file rather than a
  7. * reachable-export closure.
  8. */
  9. import { globSync, readFileSync } from 'node:fs'
  10. import { dirname, join, resolve, sep } from 'node:path'
  11. import ts from 'typescript'
  12. /** The module whose `SlotMap` / standard-kit interfaces every slot owner merges into. */
  13. const SLOTS_MODULE = '@deepseek-ai/dsh-client-ui-slots'
  14. /** Cheap textual prefilter for a slot-contract merge, quote-style agnostic. */
  15. const MERGE_HEAD = /declare module ['"]@deepseek-ai\/dsh-client-ui-slots['"]/
  16. /** Cheap textual prefilter for a registration call site. */
  17. const REGISTER_HEAD = /\.register\(/
  18. /** One `SlotMap` member: the slot's contract as its owning package declares it. */
  19. export interface SlotDeclaration {
  20. /** SlotMap key, e.g. `settings.section`. */
  21. key: string
  22. /** Cardinality literal (`single` / `list` / `keyed` / `chain`), or '' when not a literal. */
  23. kind: string
  24. /** Data-scope literal (`root` / `session` / `session-maybe`), or '' when not a literal. */
  25. scope: string
  26. /** Type name of the owner-supplied props share, absent when the slot declares none. */
  27. ownerType?: string
  28. /** Source text of the `keyProps` member (keyed slots), absent otherwise. */
  29. keyProps?: string
  30. /** Source text of the `hookContext` member, absent otherwise. */
  31. hookContext?: string
  32. /** Type name of the slot-level inject face, absent when the slot declares none. */
  33. injectType?: string
  34. /** The member's JSDoc with container indentation removed, '' when undocumented. */
  35. jsDoc: string
  36. /** Workspace package that declares the contract. */
  37. package: string
  38. /** Source pointer `packages/…/file.ts:line`. */
  39. source: string
  40. }
  41. /** One `slots.register({ name, … }, Component)` call site. */
  42. export interface SlotRegistration {
  43. /** Target SlotMap key the entry contributes into. */
  44. key: string
  45. /** Workspace package that registers the entry. */
  46. package: string
  47. /** Component argument as written (identifier, or a trimmed expression). */
  48. component: string
  49. /** `id` literal of a list entry, absent otherwise. */
  50. id?: string
  51. /** `key` literal of a keyed entry, absent otherwise. */
  52. entryKey?: string
  53. /** SlotMap keys this registration declares as children (they exist while it is mounted). */
  54. children: string[]
  55. /** Source pointer `packages/…/file.ts:line`. */
  56. source: string
  57. }
  58. /** One exported type declaration, retained with its JSDoc for catalog projection. */
  59. export interface TypeDeclaration {
  60. /** Declared name. */
  61. name: string
  62. /** Full declaration text INCLUDING its JSDoc (member docs are the teaching text). */
  63. text: string
  64. /** Source pointer `packages/…/file.ts:line`. */
  65. source: string
  66. }
  67. /** One scanned source file with the artifacts the catalog reads from it. */
  68. export interface ScannedFile {
  69. /** Repo-relative, `/`-normalized path. */
  70. rel: string
  71. /** Workspace package name that owns the file. */
  72. package: string
  73. /** Parsed source file. */
  74. sf: ts.SourceFile
  75. }
  76. /**
  77. * Parse every file matching `patterns`, keeping the ones that carry a slot
  78. * contract merge or a registration call. Files without either are skipped so
  79. * the scan stays cheap over the whole workspace.
  80. * @param scanRoot - repository root the patterns resolve against.
  81. * @param patterns - glob(s) selecting the TypeScript/TSX files to scan.
  82. * @returns one entry per interesting file, in path order.
  83. */
  84. export function scanSlotFiles(scanRoot: string, patterns: readonly string[]): ScannedFile[] {
  85. const out: ScannedFile[] = []
  86. const names = new Map<string, string>()
  87. const rels = [...new Set(globSync(patterns as string[], { cwd: scanRoot })
  88. .map(path => path.split(sep).join('/')))].sort()
  89. for (const rel of rels) {
  90. const abs = resolve(scanRoot, rel)
  91. const text = readFileSync(abs, 'utf8')
  92. if (!MERGE_HEAD.test(text) && !REGISTER_HEAD.test(text)) continue
  93. out.push({
  94. rel,
  95. package: packageNameOf(scanRoot, rel, names),
  96. sf: ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, scriptKindOf(rel)),
  97. })
  98. }
  99. return out
  100. }
  101. /**
  102. * Index every exported type declaration of the scanned packages, keeping JSDoc.
  103. * The catalog resolves owner-props and inject-face shapes through this index
  104. * instead of a type-checker program: the declaration text with its member
  105. * documentation IS the teaching material a registrant needs.
  106. * @param scanRoot - repository root the patterns resolve against.
  107. * @param patterns - glob(s) selecting the TypeScript/TSX files to index.
  108. * @returns name → declaration, with names declared more than once dropped as ambiguous.
  109. */
  110. export function indexExportedTypes(scanRoot: string, patterns: readonly string[]): Map<string, TypeDeclaration> {
  111. const index = new Map<string, TypeDeclaration>()
  112. const ambiguous = new Set<string>()
  113. const rels = [...new Set(globSync(patterns as string[], { cwd: scanRoot })
  114. .map(path => path.split(sep).join('/')))].sort()
  115. for (const rel of rels) {
  116. const abs = resolve(scanRoot, rel)
  117. const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true, scriptKindOf(rel))
  118. for (const statement of sf.statements) {
  119. if (!ts.isInterfaceDeclaration(statement) && !ts.isTypeAliasDeclaration(statement)) continue
  120. if (!statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
  121. const name = statement.name.text
  122. if (index.has(name)) {
  123. ambiguous.add(name)
  124. continue
  125. }
  126. index.set(name, {
  127. name,
  128. text: declarationText(statement, sf),
  129. source: `${rel}:${String(lineOf(sf, statement))}`,
  130. })
  131. }
  132. }
  133. for (const name of ambiguous) index.delete(name)
  134. return index
  135. }
  136. /**
  137. * Read every `SlotMap` member declared in one scanned file.
  138. * @param file - a file returned by {@link scanSlotFiles}.
  139. * @returns the declared slots, in source order.
  140. */
  141. export function slotDeclarations(file: ScannedFile): SlotDeclaration[] {
  142. const out: SlotDeclaration[] = []
  143. for (const body of slotModuleBodies(file.sf)) {
  144. for (const statement of body.statements) {
  145. if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'SlotMap') continue
  146. for (const member of statement.members) {
  147. if (!ts.isPropertySignature(member) || member.type === undefined) continue
  148. const key = ts.isStringLiteral(member.name) || ts.isIdentifier(member.name)
  149. ? member.name.text
  150. : member.name.getText(file.sf)
  151. const entry = ts.isTypeLiteralNode(member.type) ? member.type : undefined
  152. const ownerType = memberTypeText(entry, 'owner', file.sf)
  153. const keyProps = memberTypeText(entry, 'keyProps', file.sf)
  154. const hookContext = memberTypeText(entry, 'hookContext', file.sf)
  155. const injectType = memberTypeText(entry, 'inject', file.sf)
  156. out.push({
  157. key,
  158. kind: literalMember(entry, 'kind'),
  159. scope: literalMember(entry, 'scope'),
  160. ...ownerType === undefined ? {} : { ownerType },
  161. ...keyProps === undefined ? {} : { keyProps },
  162. ...hookContext === undefined ? {} : { hookContext },
  163. ...injectType === undefined ? {} : { injectType },
  164. jsDoc: jsDocOf(member, file.sf),
  165. package: file.package,
  166. source: `${file.rel}:${String(lineOf(file.sf, member))}`,
  167. })
  168. }
  169. }
  170. }
  171. return out
  172. }
  173. /**
  174. * Read every registration call site in one scanned file: which slot it
  175. * occupies, with which component and cell identity, and which child slots it
  176. * declares. A call whose `name` is not a string literal is skipped — the
  177. * shipped composition always names its target literally, and a computed name
  178. * carries no catalog fact.
  179. * @param file - a file returned by {@link scanSlotFiles}.
  180. * @returns the registrations, in source order.
  181. */
  182. export function slotRegistrations(file: ScannedFile): SlotRegistration[] {
  183. const out: SlotRegistration[] = []
  184. const visit = (node: ts.Node): void => {
  185. if (ts.isCallExpression(node)
  186. && ts.isPropertyAccessExpression(node.expression)
  187. && node.expression.name.text === 'register'
  188. && isSlotsReceiver(node.expression.expression, file.sf)
  189. && node.arguments.length >= 1) {
  190. const options = node.arguments[0]
  191. if (options !== undefined && ts.isObjectLiteralExpression(options)) {
  192. const key = stringProperty(options, 'name')
  193. if (key !== undefined) {
  194. const id = stringProperty(options, 'id')
  195. const entryKey = stringProperty(options, 'key')
  196. out.push({
  197. key,
  198. package: file.package,
  199. component: componentText(node.arguments[1], file.sf),
  200. ...id === undefined ? {} : { id },
  201. ...entryKey === undefined ? {} : { entryKey },
  202. children: childKeys(options),
  203. source: `${file.rel}:${String(lineOf(file.sf, node))}`,
  204. })
  205. }
  206. }
  207. }
  208. ts.forEachChild(node, visit)
  209. }
  210. visit(file.sf)
  211. return out
  212. }
  213. /**
  214. * Read one standard-kit interface's members from the scanned files: the props
  215. * a slot component receives for free from the framework at a given scope.
  216. * @param files - scanned files to search.
  217. * @param interfaceName - `GlobalStandardProps`, `SessionStandardProps`, or `SessionMaybeStandardProps`.
  218. * @returns `member: type` texts in declaration order, merged across declaring files.
  219. */
  220. export function standardKitMembers(files: readonly ScannedFile[], interfaceName: string): string[] {
  221. const out: string[] = []
  222. for (const file of files) {
  223. for (const body of slotModuleBodies(file.sf)) {
  224. for (const statement of body.statements) {
  225. if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== interfaceName) continue
  226. for (const member of statement.members) {
  227. if (!ts.isPropertySignature(member)) continue
  228. const type = member.type === undefined ? 'unknown' : member.type.getText(file.sf)
  229. out.push(`${member.name.getText(file.sf)}${member.questionToken === undefined ? '' : '?'}: ${collapse(type)}`)
  230. }
  231. }
  232. }
  233. }
  234. return out
  235. }
  236. /**
  237. * Names in the type index that seed texts mention, word-bounded — ONE level, not
  238. * a transitive closure. The catalog expands an owner-props contract exactly one
  239. * step: the owner interface carries the interaction protocol in its own member
  240. * documentation, while the shapes its fields reference belong to the subsystems
  241. * that own them and would otherwise drag the entire session model into a single
  242. * slot's report.
  243. * @param seeds - declaration or signature texts to search.
  244. * @param index - the type index from {@link indexExportedTypes}.
  245. * @returns the mentioned names, sorted.
  246. */
  247. export function referencedTypeNames(
  248. seeds: readonly string[],
  249. index: ReadonlyMap<string, TypeDeclaration>,
  250. ): string[] {
  251. const found: string[] = []
  252. for (const name of index.keys()) {
  253. const pattern = new RegExp(`\\b${name}\\b`)
  254. if (seeds.some(text => pattern.test(text))) found.push(name)
  255. }
  256. return found.sort()
  257. }
  258. /**
  259. * Resolve declarations by name, dropping names the index does not hold.
  260. * @param names - type names to resolve.
  261. * @param index - the type index from {@link indexExportedTypes}.
  262. * @returns the resolved declarations, sorted by name.
  263. */
  264. export function declaredTypes(
  265. names: readonly string[],
  266. index: ReadonlyMap<string, TypeDeclaration>,
  267. ): TypeDeclaration[] {
  268. return [...names]
  269. .flatMap(name => index.get(name) ?? [])
  270. .sort((left, right) => left.name.localeCompare(right.name))
  271. }
  272. /** Every slot-contract module block in one file, in source order. */
  273. function slotModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
  274. const bodies: ts.ModuleBlock[] = []
  275. for (const statement of sf.statements) {
  276. if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name)) continue
  277. if (statement.name.text !== SLOTS_MODULE) continue
  278. if (statement.body !== undefined && ts.isModuleBlock(statement.body)) bodies.push(statement.body)
  279. }
  280. return bodies
  281. }
  282. /**
  283. * Whether a `X.register(...)` receiver is the slots service. Every other
  284. * registry in the repo (`ctx.tools`, `ctx.commands`, `ctx.settings`, …) also
  285. * takes an options object with a `name`, so the receiver is what separates a
  286. * slot occupancy fact from an unrelated registration.
  287. */
  288. function isSlotsReceiver(receiver: ts.Expression, sf: ts.SourceFile): boolean {
  289. const text = receiver.getText(sf)
  290. return text === 'slots' || text.endsWith('.slots')
  291. }
  292. /** The workspace package name owning a repo-relative file, memoized per package root. */
  293. function packageNameOf(scanRoot: string, rel: string, cache: Map<string, string>): string {
  294. let dir = dirname(resolve(scanRoot, rel))
  295. while (dir.length > scanRoot.length) {
  296. const cached = cache.get(dir)
  297. if (cached !== undefined) return cached
  298. try {
  299. const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: unknown }
  300. if (typeof manifest.name === 'string') {
  301. cache.set(dir, manifest.name)
  302. return manifest.name
  303. }
  304. } catch {
  305. // No manifest at this level: keep walking up to the owning package root.
  306. }
  307. dir = dirname(dir)
  308. }
  309. return '(unknown package)'
  310. }
  311. /** TSX must parse as TSX; a `.ts` file with JSX-looking generics must not. */
  312. function scriptKindOf(rel: string): ts.ScriptKind {
  313. return rel.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
  314. }
  315. /** 1-based line of a node's first character. */
  316. function lineOf(sf: ts.SourceFile, node: ts.Node): number {
  317. return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1
  318. }
  319. /** Declaration text including leading JSDoc, with container indentation removed. */
  320. function declarationText(statement: ts.Node, sf: ts.SourceFile): string {
  321. return dedent(sf.text.slice(statement.getStart(sf, true), statement.getEnd()))
  322. }
  323. /** One member's JSDoc comment text, '' when the member has none. */
  324. function jsDocOf(member: ts.Node, sf: ts.SourceFile): string {
  325. // getStart(includeJsDoc) brackets exactly the doc comment: with it the range
  326. // opens at `/**`, without it at the member itself.
  327. const withDoc = member.getStart(sf, true)
  328. const withoutDoc = member.getStart(sf, false)
  329. if (withDoc >= withoutDoc) return ''
  330. return dedent(sf.text.slice(withDoc, withoutDoc).trimEnd())
  331. }
  332. /** Strip the shared leading indentation of a multi-line source slice. */
  333. function dedent(text: string): string {
  334. const lines = text.split('\n')
  335. const indents = lines.slice(1).filter(line => line.trim() !== '')
  336. .map(line => (/^\s*/.exec(line) as RegExpExecArray)[0].length)
  337. const shared = indents.length === 0 ? 0 : Math.min(...indents)
  338. return [lines[0] ?? '', ...lines.slice(1).map(line => line.slice(shared))].join('\n').trimEnd()
  339. }
  340. /** Collapse a type text to one line so catalog rows stay one row. */
  341. function collapse(text: string): string {
  342. return text.replace(/\s+/g, ' ').trim()
  343. }
  344. /** A type-literal member's string-literal type text, '' when absent or computed. */
  345. function literalMember(entry: ts.TypeLiteralNode | undefined, name: string): string {
  346. const member = namedMember(entry, name)
  347. if (member?.type === undefined) return ''
  348. return ts.isLiteralTypeNode(member.type) && ts.isStringLiteral(member.type.literal)
  349. ? member.type.literal.text
  350. : ''
  351. }
  352. /** A type-literal member's type text on one line, absent when the member is. */
  353. function memberTypeText(
  354. entry: ts.TypeLiteralNode | undefined,
  355. name: string,
  356. sf: ts.SourceFile,
  357. ): string | undefined {
  358. const member = namedMember(entry, name)
  359. return member?.type === undefined ? undefined : collapse(member.type.getText(sf))
  360. }
  361. /** One named property signature of a type literal. */
  362. function namedMember(entry: ts.TypeLiteralNode | undefined, name: string): ts.PropertySignature | undefined {
  363. if (entry === undefined) return undefined
  364. for (const member of entry.members) {
  365. if (ts.isPropertySignature(member) && memberName(member.name) === name) return member
  366. }
  367. return undefined
  368. }
  369. /** A property name's text, quotes removed. */
  370. function memberName(name: ts.PropertyName): string {
  371. return ts.isStringLiteral(name) || ts.isIdentifier(name) ? name.text : name.getText()
  372. }
  373. /** One string-literal property of an options object literal. */
  374. function stringProperty(options: ts.ObjectLiteralExpression, name: string): string | undefined {
  375. for (const property of options.properties) {
  376. if (!ts.isPropertyAssignment(property)) continue
  377. if (memberName(property.name) !== name) continue
  378. if (ts.isStringLiteral(property.initializer)) return property.initializer.text
  379. }
  380. return undefined
  381. }
  382. /** The SlotMap keys a registration's `children` table declares. */
  383. function childKeys(options: ts.ObjectLiteralExpression): string[] {
  384. for (const property of options.properties) {
  385. if (!ts.isPropertyAssignment(property)) continue
  386. if (memberName(property.name) !== 'children') continue
  387. if (!ts.isObjectLiteralExpression(property.initializer)) return []
  388. return property.initializer.properties
  389. .flatMap(child => (child.name === undefined ? [] : [memberName(child.name)]))
  390. }
  391. return []
  392. }
  393. /** The component argument as written; a non-identifier expression is collapsed. */
  394. function componentText(argument: ts.Expression | undefined, sf: ts.SourceFile): string {
  395. if (argument === undefined) return '(none)'
  396. const text = collapse(argument.getText(sf))
  397. return text.length > 60 ? `${text.slice(0, 57)}…` : text
  398. }