| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429 |
- /**
- * AST helpers for the client slot surface: the `SlotMap` declaration merges
- * that type every slot, and the `slots.register` call sites that say who
- * already occupies one. Both readings are lexical (no type-checker program):
- * the client catalog generator consumes them, and the same scan doubles as its
- * own exhaustiveness backstop because it reads every source file rather than a
- * reachable-export closure.
- */
- import { globSync, readFileSync } from 'node:fs'
- import { dirname, join, resolve, sep } from 'node:path'
- import ts from 'typescript'
- /** The module whose `SlotMap` / standard-kit interfaces every slot owner merges into. */
- const SLOTS_MODULE = '@deepseek-ai/dsh-client-ui-slots'
- /** Cheap textual prefilter for a slot-contract merge, quote-style agnostic. */
- const MERGE_HEAD = /declare module ['"]@deepseek-ai\/dsh-client-ui-slots['"]/
- /** Cheap textual prefilter for a registration call site. */
- const REGISTER_HEAD = /\.register\(/
- /** One `SlotMap` member: the slot's contract as its owning package declares it. */
- export interface SlotDeclaration {
- /** SlotMap key, e.g. `settings.section`. */
- key: string
- /** Cardinality literal (`single` / `list` / `keyed` / `chain`), or '' when not a literal. */
- kind: string
- /** Data-scope literal (`root` / `session` / `session-maybe`), or '' when not a literal. */
- scope: string
- /** Type name of the owner-supplied props share, absent when the slot declares none. */
- ownerType?: string
- /** Source text of the `keyProps` member (keyed slots), absent otherwise. */
- keyProps?: string
- /** Source text of the `hookContext` member, absent otherwise. */
- hookContext?: string
- /** Type name of the slot-level inject face, absent when the slot declares none. */
- injectType?: string
- /** The member's JSDoc with container indentation removed, '' when undocumented. */
- jsDoc: string
- /** Workspace package that declares the contract. */
- package: string
- /** Source pointer `packages/…/file.ts:line`. */
- source: string
- }
- /** One `slots.register({ name, … }, Component)` call site. */
- export interface SlotRegistration {
- /** Target SlotMap key the entry contributes into. */
- key: string
- /** Workspace package that registers the entry. */
- package: string
- /** Component argument as written (identifier, or a trimmed expression). */
- component: string
- /** `id` literal of a list entry, absent otherwise. */
- id?: string
- /** `key` literal of a keyed entry, absent otherwise. */
- entryKey?: string
- /** SlotMap keys this registration declares as children (they exist while it is mounted). */
- children: string[]
- /** Source pointer `packages/…/file.ts:line`. */
- source: string
- }
- /** One exported type declaration, retained with its JSDoc for catalog projection. */
- export interface TypeDeclaration {
- /** Declared name. */
- name: string
- /** Full declaration text INCLUDING its JSDoc (member docs are the teaching text). */
- text: string
- /** Source pointer `packages/…/file.ts:line`. */
- source: string
- }
- /** One scanned source file with the artifacts the catalog reads from it. */
- export interface ScannedFile {
- /** Repo-relative, `/`-normalized path. */
- rel: string
- /** Workspace package name that owns the file. */
- package: string
- /** Parsed source file. */
- sf: ts.SourceFile
- }
- /**
- * Parse every file matching `patterns`, keeping the ones that carry a slot
- * contract merge or a registration call. Files without either are skipped so
- * the scan stays cheap over the whole workspace.
- * @param scanRoot - repository root the patterns resolve against.
- * @param patterns - glob(s) selecting the TypeScript/TSX files to scan.
- * @returns one entry per interesting file, in path order.
- */
- export function scanSlotFiles(scanRoot: string, patterns: readonly string[]): ScannedFile[] {
- const out: ScannedFile[] = []
- const names = new Map<string, string>()
- const rels = [...new Set(globSync(patterns as string[], { cwd: scanRoot })
- .map(path => path.split(sep).join('/')))].sort()
- for (const rel of rels) {
- const abs = resolve(scanRoot, rel)
- const text = readFileSync(abs, 'utf8')
- if (!MERGE_HEAD.test(text) && !REGISTER_HEAD.test(text)) continue
- out.push({
- rel,
- package: packageNameOf(scanRoot, rel, names),
- sf: ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, scriptKindOf(rel)),
- })
- }
- return out
- }
- /**
- * Index every exported type declaration of the scanned packages, keeping JSDoc.
- * The catalog resolves owner-props and inject-face shapes through this index
- * instead of a type-checker program: the declaration text with its member
- * documentation IS the teaching material a registrant needs.
- * @param scanRoot - repository root the patterns resolve against.
- * @param patterns - glob(s) selecting the TypeScript/TSX files to index.
- * @returns name → declaration, with names declared more than once dropped as ambiguous.
- */
- export function indexExportedTypes(scanRoot: string, patterns: readonly string[]): Map<string, TypeDeclaration> {
- const index = new Map<string, TypeDeclaration>()
- const ambiguous = new Set<string>()
- const rels = [...new Set(globSync(patterns as string[], { cwd: scanRoot })
- .map(path => path.split(sep).join('/')))].sort()
- for (const rel of rels) {
- const abs = resolve(scanRoot, rel)
- const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true, scriptKindOf(rel))
- for (const statement of sf.statements) {
- if (!ts.isInterfaceDeclaration(statement) && !ts.isTypeAliasDeclaration(statement)) continue
- if (!statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
- const name = statement.name.text
- if (index.has(name)) {
- ambiguous.add(name)
- continue
- }
- index.set(name, {
- name,
- text: declarationText(statement, sf),
- source: `${rel}:${String(lineOf(sf, statement))}`,
- })
- }
- }
- for (const name of ambiguous) index.delete(name)
- return index
- }
- /**
- * Read every `SlotMap` member declared in one scanned file.
- * @param file - a file returned by {@link scanSlotFiles}.
- * @returns the declared slots, in source order.
- */
- export function slotDeclarations(file: ScannedFile): SlotDeclaration[] {
- const out: SlotDeclaration[] = []
- for (const body of slotModuleBodies(file.sf)) {
- for (const statement of body.statements) {
- if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'SlotMap') continue
- for (const member of statement.members) {
- if (!ts.isPropertySignature(member) || member.type === undefined) continue
- const key = ts.isStringLiteral(member.name) || ts.isIdentifier(member.name)
- ? member.name.text
- : member.name.getText(file.sf)
- const entry = ts.isTypeLiteralNode(member.type) ? member.type : undefined
- const ownerType = memberTypeText(entry, 'owner', file.sf)
- const keyProps = memberTypeText(entry, 'keyProps', file.sf)
- const hookContext = memberTypeText(entry, 'hookContext', file.sf)
- const injectType = memberTypeText(entry, 'inject', file.sf)
- out.push({
- key,
- kind: literalMember(entry, 'kind'),
- scope: literalMember(entry, 'scope'),
- ...ownerType === undefined ? {} : { ownerType },
- ...keyProps === undefined ? {} : { keyProps },
- ...hookContext === undefined ? {} : { hookContext },
- ...injectType === undefined ? {} : { injectType },
- jsDoc: jsDocOf(member, file.sf),
- package: file.package,
- source: `${file.rel}:${String(lineOf(file.sf, member))}`,
- })
- }
- }
- }
- return out
- }
- /**
- * Read every registration call site in one scanned file: which slot it
- * occupies, with which component and cell identity, and which child slots it
- * declares. A call whose `name` is not a string literal is skipped — the
- * shipped composition always names its target literally, and a computed name
- * carries no catalog fact.
- * @param file - a file returned by {@link scanSlotFiles}.
- * @returns the registrations, in source order.
- */
- export function slotRegistrations(file: ScannedFile): SlotRegistration[] {
- const out: SlotRegistration[] = []
- const visit = (node: ts.Node): void => {
- if (ts.isCallExpression(node)
- && ts.isPropertyAccessExpression(node.expression)
- && node.expression.name.text === 'register'
- && isSlotsReceiver(node.expression.expression, file.sf)
- && node.arguments.length >= 1) {
- const options = node.arguments[0]
- if (options !== undefined && ts.isObjectLiteralExpression(options)) {
- const key = stringProperty(options, 'name')
- if (key !== undefined) {
- const id = stringProperty(options, 'id')
- const entryKey = stringProperty(options, 'key')
- out.push({
- key,
- package: file.package,
- component: componentText(node.arguments[1], file.sf),
- ...id === undefined ? {} : { id },
- ...entryKey === undefined ? {} : { entryKey },
- children: childKeys(options),
- source: `${file.rel}:${String(lineOf(file.sf, node))}`,
- })
- }
- }
- }
- ts.forEachChild(node, visit)
- }
- visit(file.sf)
- return out
- }
- /**
- * Read one standard-kit interface's members from the scanned files: the props
- * a slot component receives for free from the framework at a given scope.
- * @param files - scanned files to search.
- * @param interfaceName - `GlobalStandardProps`, `SessionStandardProps`, or `SessionMaybeStandardProps`.
- * @returns `member: type` texts in declaration order, merged across declaring files.
- */
- export function standardKitMembers(files: readonly ScannedFile[], interfaceName: string): string[] {
- const out: string[] = []
- for (const file of files) {
- for (const body of slotModuleBodies(file.sf)) {
- for (const statement of body.statements) {
- if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== interfaceName) continue
- for (const member of statement.members) {
- if (!ts.isPropertySignature(member)) continue
- const type = member.type === undefined ? 'unknown' : member.type.getText(file.sf)
- out.push(`${member.name.getText(file.sf)}${member.questionToken === undefined ? '' : '?'}: ${collapse(type)}`)
- }
- }
- }
- }
- return out
- }
- /**
- * Names in the type index that seed texts mention, word-bounded — ONE level, not
- * a transitive closure. The catalog expands an owner-props contract exactly one
- * step: the owner interface carries the interaction protocol in its own member
- * documentation, while the shapes its fields reference belong to the subsystems
- * that own them and would otherwise drag the entire session model into a single
- * slot's report.
- * @param seeds - declaration or signature texts to search.
- * @param index - the type index from {@link indexExportedTypes}.
- * @returns the mentioned names, sorted.
- */
- export function referencedTypeNames(
- seeds: readonly string[],
- index: ReadonlyMap<string, TypeDeclaration>,
- ): string[] {
- const found: string[] = []
- for (const name of index.keys()) {
- const pattern = new RegExp(`\\b${name}\\b`)
- if (seeds.some(text => pattern.test(text))) found.push(name)
- }
- return found.sort()
- }
- /**
- * Resolve declarations by name, dropping names the index does not hold.
- * @param names - type names to resolve.
- * @param index - the type index from {@link indexExportedTypes}.
- * @returns the resolved declarations, sorted by name.
- */
- export function declaredTypes(
- names: readonly string[],
- index: ReadonlyMap<string, TypeDeclaration>,
- ): TypeDeclaration[] {
- return [...names]
- .flatMap(name => index.get(name) ?? [])
- .sort((left, right) => left.name.localeCompare(right.name))
- }
- /** Every slot-contract module block in one file, in source order. */
- function slotModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
- const bodies: ts.ModuleBlock[] = []
- for (const statement of sf.statements) {
- if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name)) continue
- if (statement.name.text !== SLOTS_MODULE) continue
- if (statement.body !== undefined && ts.isModuleBlock(statement.body)) bodies.push(statement.body)
- }
- return bodies
- }
- /**
- * Whether a `X.register(...)` receiver is the slots service. Every other
- * registry in the repo (`ctx.tools`, `ctx.commands`, `ctx.settings`, …) also
- * takes an options object with a `name`, so the receiver is what separates a
- * slot occupancy fact from an unrelated registration.
- */
- function isSlotsReceiver(receiver: ts.Expression, sf: ts.SourceFile): boolean {
- const text = receiver.getText(sf)
- return text === 'slots' || text.endsWith('.slots')
- }
- /** The workspace package name owning a repo-relative file, memoized per package root. */
- function packageNameOf(scanRoot: string, rel: string, cache: Map<string, string>): string {
- let dir = dirname(resolve(scanRoot, rel))
- while (dir.length > scanRoot.length) {
- const cached = cache.get(dir)
- if (cached !== undefined) return cached
- try {
- const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: unknown }
- if (typeof manifest.name === 'string') {
- cache.set(dir, manifest.name)
- return manifest.name
- }
- } catch {
- // No manifest at this level: keep walking up to the owning package root.
- }
- dir = dirname(dir)
- }
- return '(unknown package)'
- }
- /** TSX must parse as TSX; a `.ts` file with JSX-looking generics must not. */
- function scriptKindOf(rel: string): ts.ScriptKind {
- return rel.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
- }
- /** 1-based line of a node's first character. */
- function lineOf(sf: ts.SourceFile, node: ts.Node): number {
- return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1
- }
- /** Declaration text including leading JSDoc, with container indentation removed. */
- function declarationText(statement: ts.Node, sf: ts.SourceFile): string {
- return dedent(sf.text.slice(statement.getStart(sf, true), statement.getEnd()))
- }
- /** One member's JSDoc comment text, '' when the member has none. */
- function jsDocOf(member: ts.Node, sf: ts.SourceFile): string {
- // getStart(includeJsDoc) brackets exactly the doc comment: with it the range
- // opens at `/**`, without it at the member itself.
- const withDoc = member.getStart(sf, true)
- const withoutDoc = member.getStart(sf, false)
- if (withDoc >= withoutDoc) return ''
- return dedent(sf.text.slice(withDoc, withoutDoc).trimEnd())
- }
- /** Strip the shared leading indentation of a multi-line source slice. */
- function dedent(text: string): string {
- const lines = text.split('\n')
- const indents = lines.slice(1).filter(line => line.trim() !== '')
- .map(line => (/^\s*/.exec(line) as RegExpExecArray)[0].length)
- const shared = indents.length === 0 ? 0 : Math.min(...indents)
- return [lines[0] ?? '', ...lines.slice(1).map(line => line.slice(shared))].join('\n').trimEnd()
- }
- /** Collapse a type text to one line so catalog rows stay one row. */
- function collapse(text: string): string {
- return text.replace(/\s+/g, ' ').trim()
- }
- /** A type-literal member's string-literal type text, '' when absent or computed. */
- function literalMember(entry: ts.TypeLiteralNode | undefined, name: string): string {
- const member = namedMember(entry, name)
- if (member?.type === undefined) return ''
- return ts.isLiteralTypeNode(member.type) && ts.isStringLiteral(member.type.literal)
- ? member.type.literal.text
- : ''
- }
- /** A type-literal member's type text on one line, absent when the member is. */
- function memberTypeText(
- entry: ts.TypeLiteralNode | undefined,
- name: string,
- sf: ts.SourceFile,
- ): string | undefined {
- const member = namedMember(entry, name)
- return member?.type === undefined ? undefined : collapse(member.type.getText(sf))
- }
- /** One named property signature of a type literal. */
- function namedMember(entry: ts.TypeLiteralNode | undefined, name: string): ts.PropertySignature | undefined {
- if (entry === undefined) return undefined
- for (const member of entry.members) {
- if (ts.isPropertySignature(member) && memberName(member.name) === name) return member
- }
- return undefined
- }
- /** A property name's text, quotes removed. */
- function memberName(name: ts.PropertyName): string {
- return ts.isStringLiteral(name) || ts.isIdentifier(name) ? name.text : name.getText()
- }
- /** One string-literal property of an options object literal. */
- function stringProperty(options: ts.ObjectLiteralExpression, name: string): string | undefined {
- for (const property of options.properties) {
- if (!ts.isPropertyAssignment(property)) continue
- if (memberName(property.name) !== name) continue
- if (ts.isStringLiteral(property.initializer)) return property.initializer.text
- }
- return undefined
- }
- /** The SlotMap keys a registration's `children` table declares. */
- function childKeys(options: ts.ObjectLiteralExpression): string[] {
- for (const property of options.properties) {
- if (!ts.isPropertyAssignment(property)) continue
- if (memberName(property.name) !== 'children') continue
- if (!ts.isObjectLiteralExpression(property.initializer)) return []
- return property.initializer.properties
- .flatMap(child => (child.name === undefined ? [] : [memberName(child.name)]))
- }
- return []
- }
- /** The component argument as written; a non-identifier expression is collapsed. */
- function componentText(argument: ts.Expression | undefined, sf: ts.SourceFile): string {
- if (argument === undefined) return '(none)'
- const text = collapse(argument.getText(sf))
- return text.length > 60 ? `${text.slice(0, 57)}…` : text
- }
|