diff --git a/docs/superpowers/specs/2026-07-18-dev-mode-type-introspection-design.md b/docs/superpowers/specs/2026-07-18-dev-mode-type-introspection-design.md new file mode 100644 index 0000000..7f7f31b --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-dev-mode-type-introspection-design.md @@ -0,0 +1,109 @@ +# Design: Dev-mode type introspection (on-disk JSDoc fallback) + +**Date:** 2026-07-18 +**Branch:** `feat/dev-mode-type-introspection` (based on `fix/expose-jsdoc-type-brace-parsing`) +**Status:** Approved + +## Problem + +`@expose()` derives argument/return types exclusively from JSDoc parsed out of +`Class.toString()` — the **runtime** class source. When a service runs from +`.ts` directly via a transpiler that strips comments (`tsx`, `ts-node` with +esbuild/swc transforms), the JSDoc is gone at runtime and every exposed method +degrades to `any` in the service description and generated clients. The type +contract silently depends on the developer's launcher. + +(Node's native `--experimental-strip-types` keeps comments; compiled +`node dist/index.js` with `removeComments: false` keeps them too — those paths +already work.) + +## Decision (user-approved) + +**Option 1a — keep the JSDoc contract; add an on-disk source fallback:** + +- **Precedence:** runtime source wins whenever it yields JSDoc for the class. + The on-disk file is consulted **only when the runtime class source contains + no JSDoc comment blocks**. No freshness checks, no env flags. +- Zero new dependencies. Production path (`ctor.toString()` → acorn) is + unchanged. +- Worst case (source not locatable / no JSDoc on disk) degrades to the current + behavior (`any`), never worse. + +Rejected alternatives: parsing TS type annotations with the `typescript` +compiler (adds a heavy runtime dependency for a dev concern); carrying types as +decorator arguments (bulletproof but taxes every method with boilerplate and +requires migrating existing services). + +## Mechanism + +### 1. Source-file capture + +`@expose()` factory executions happen at class-definition time, physically in +the defining module. Capture `new Error().stack` inside the factory, take the +first frame whose path is not rpc's own `expose` module, normalize +(`file://` URL → path), and remember it. With `tsx` / `--experimental-strip-types` +(source maps on) the frame points at the on-disk `.ts`; with compiled code it +points at the `.js` (which normally carries JSDoc anyway). + +The captured path is stored per decorated method and associated with the class +when the description is built (`buildMethodDescription`), keyed by class name. + +### 2. Fallback trigger + +`parseDescriptions(className, ctor.toString())` currently populates +`descriptions[className]` from the runtime source. After it runs, if the +parsed class yielded **no JSDoc comment metadata at all** (no method has a +comment block) and a captured source path exists and is readable, run the +fallback extraction against the file contents. + +### 3. Textual JSDoc extraction from `.ts` + +The on-disk file may be TypeScript, which acorn cannot parse — extraction is +textual, not AST: + +- Narrow the search to the class body: from `class ` to the next + top-level `class ` declaration or EOF (tolerate `export`/`default` + modifiers). +- For each exposed method name, match the **last** `/** … */` block that + precedes `methodName(` allowing decorators (`@word(...)`), modifiers + (`public|protected|private|static|async`), generics (`<...>`), and + whitespace between the block and the method name. +- Feed the block body to the existing `parseComment()` (which, after the + balanced-brace fix, handles object-literal types and braces in + descriptions). +- Any extraction miss for a method leaves that method as-is (current + behavior). + +File reads are cached per absolute path (`fs.readFileSync`, sync — decoration +is startup-time work, same as today's parsing). + +## Accepted limitations (documented, not bugs) + +- Compiled `.js` with `removeComments: true`: the stack points at the + comment-less `.js`; fallback finds no JSDoc → `any`, as today. Mapping + `.js → .ts` is out of scope. +- Bundled single-file builds without source maps may defeat frame capture → + current behavior. +- A stale process whose on-disk `.ts` has drifted can yield newer types than + the running code — inherent to reading the disk; runtime-first precedence + keeps this to the dev-only fallback path. +- Textual extraction is heuristic; pathological sources (method-name strings + inside earlier comments, etc.) may miss — degrading to `any`, never + corrupting existing parses. + +## Files + +- Modify: `src/decorators/expose.ts` — stack capture in the factory; fallback + in `buildMethodDescription`; textual extractor (exported for tests). +- Test: `test/decorators/expose.spec.ts` — simulate a comment-stripped runtime + class (`toString` override) whose on-disk source carries JSDoc; unit-test the + textual extractor against TS-syntax source (decorators, modifiers, + object-literal types); regression: runtime-JSDoc-present path must not read + the disk. + +## Verification + +- New tests RED before implementation, GREEN after. +- Full suite passes (198 tests at branch base + new ones). +- Manual sanity: types recovered for a stripped-runtime class in tests equal + the JSDoc on disk. diff --git a/src/decorators/expose.ts b/src/decorators/expose.ts index 1bbf8cd..e6ec6a6 100644 --- a/src/decorators/expose.ts +++ b/src/decorators/expose.ts @@ -21,6 +21,8 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; import { parse, type Comment, type Options } from 'acorn'; import { type ArgDescription, @@ -53,10 +55,24 @@ const RX_COMMA_SPLIT = /\s*,\s*/; const RX_MULTILINE_CLEANUP = /\*?\n +\* ?/g; const RX_DESCRIPTION = /^([\s\S]*?)@/; const RX_TAG = /(@[^@]+)/g; -const RX_TYPE = /\{([\s\S]+)\}/; const RX_LI_CLEANUP = /^\s*-\s*/; const RX_SPACE_SPLIT = / /; const RX_OPTIONAL = /^\[(.*?)\]$/; +// member head that may follow a doc block in original (possibly TypeScript) +// source: optional decorators and modifiers, then the method name right +// before its (possibly generic) argument list +const RX_SOURCE_METHOD = + /^\s*(?:@[\w$.]+(?:\([^)]*\))?\s*)*(?:(?:public|protected|private|static|override|abstract|async)\s+)*([\w$]+)\s*(?:<[^>]*>)?\s*\(/; +const RX_JSDOC_BLOCK = /\/\*\*([\s\S]*?)\*\//g; + +// path of this very module — used to skip own frames during caller capture +const OWN_PATH: string = (() => { + try { + return fileURLToPath(import.meta.url); + } catch { + return ''; + } +})(); /** * Lookup and returns a list of argument names for a given function @@ -73,6 +89,41 @@ function argumentNames(fn: (...args: any[]) => any): string[] { .filter(arg => arg); } +/** + * Extracts a leading JSDoc `{type}` from a tag definition. Braces are matched + * by depth, so object-literal types (e.g. `{{ x: number }}`) are captured whole + * and a later `}` in the description cannot extend the captured type. The type, + * if present, must be the leading token of the definition. + * + * @param {string} tagDef - tag definition text following the tag name + * @return {{ tsType: string, rest: string } | null} - captured type and the + * remaining definition (name + description), or null when there is no type + */ +function extractType(tagDef: string): { tsType: string; rest: string } | null { + const start = tagDef.indexOf('{'); + + // a type is only a type when it leads the definition; a `{` preceded by + // any non-whitespace belongs to the description, not to a type + if (start === -1 || tagDef.slice(0, start).trim() !== '') { + return null; + } + + let depth = 0; + + for (let i = start; i < tagDef.length; i++) { + if (tagDef[i] === '{') { + depth++; + } else if (tagDef[i] === '}' && --depth === 0) { + return { + tsType: tagDef.slice(start + 1, i), + rest: tagDef.slice(i + 1).trim(), + }; + } + } + + return null; // unbalanced braces — treat as having no parseable type +} + /** * Parses given multi-line comment block * @@ -105,17 +156,16 @@ function parseComment(src: string): CommentMetadata { let parts = tag.split(RX_SPACE_SPLIT); let tagName = parts.shift(); let tagDef = parts.join(' '); - let typeMatch = tagDef.match(RX_TYPE); + let typeInfo = extractType(tagDef); let tsType = '', name = '', description = '', type = ''; let isOptional = false; - if (typeMatch) { - tsType = typeMatch[1]; - tagDef = tagDef.replace(RX_TYPE, '').trim(); - parts = tagDef.split(/ /); + if (typeInfo) { + tsType = typeInfo.tsType; + parts = typeInfo.rest.split(/ /); } name = (parts.shift() || '').replace(RX_LI_CLEANUP, ''); @@ -250,6 +300,158 @@ function parseDescriptions(name: string, src: string): void { } } +/** + * Extracts method JSDoc blocks from original source text of a given class. + * + * The text may be TypeScript (type annotations, generics, modifiers, + * decorators), which acorn cannot parse — so extraction is textual: the + * search is narrowed to the class region (from the class declaration to the + * next class declaration or EOF), and each doc block is attributed to the + * method whose declaration head immediately follows it. When several blocks + * precede a method, the closest one wins, matching the runtime parser. + * + * @param {string} src - source file text (TypeScript or JavaScript) + * @param {string} className - class to extract method docs for + * @return {{ [method: string]: string }} - map of method name to raw JSDoc + * block body (delimiters excluded, as acorn reports comment values) + */ +export function parseSourceComments( + src: string, + className: string, +): { [method: string]: string } { + const comments: { [method: string]: string } = {}; + const classRx = new RegExp(`\\bclass\\s+${className}\\b`); + const classMatch = classRx.exec(src); + + if (!classMatch) { + return comments; + } + + const start = classMatch.index + classMatch[0].length; + const nextClass = /\bclass\s+[\w$]+/g; + + nextClass.lastIndex = start; + + const next = nextClass.exec(src); + const region = src.slice(start, next ? next.index : src.length); + + let match: RegExpExecArray | null; + + RX_JSDOC_BLOCK.lastIndex = 0; + + while ((match = RX_JSDOC_BLOCK.exec(region))) { + const head = region + .slice(match.index + match[0].length) + .match(RX_SOURCE_METHOD); + + if (head && head[1] !== 'constructor') { + comments[head[1]] = match[1]; + } + } + + return comments; +} + +// per-path cache of source file reads used by the dev-mode fallback; +// null marks an unreadable path so it is only attempted once +const sourceFileCache: { [path: string]: string | null } = {}; + +/** + * Reads and caches a source file, returning null when unreadable. + * + * @param {string} path - absolute source file path + * @return {string | null} + */ +function readSourceFile(path: string): string | null { + if (!(path in sourceFileCache)) { + try { + sourceFileCache[path] = readFileSync(path, 'utf8'); + } catch { + sourceFileCache[path] = null; + } + } + + return sourceFileCache[path]; +} + +/** + * Captures the file path of the code that invoked the expose() factory — + * physically the module defining the decorated class. Own and internal + * frames are skipped. Returns an empty string when no frame is usable + * (e.g. bundled builds without source maps). + * + * @return {string} + */ +function captureCallerPath(): string { + const stack: string = new Error().stack || ''; + + for (const line of stack.split('\n').slice(1)) { + const match = line.match( + /\(?((?:file:\/\/)?[^()\s]+?\.[cm]?[jt]sx?)(?::\d+){0,2}\)?$/, + ); + + if (!match) { + continue; + } + + let path = match[1]; + + if (path.startsWith('file://')) { + try { + path = fileURLToPath(path); + } catch { + continue; + } + } + + if ( + path === OWN_PATH || + path.startsWith('node:') || + path.includes('node_modules') + ) { + continue; + } + + return path; + } + + return ''; +} + +/** + * Dev-mode fallback: when the runtime class source yields no JSDoc (a + * comment-stripping transpiler like tsx/esbuild), re-extract method docs + * from the defining source file on disk. Runtime source always wins when + * it carries any JSDoc; extraction misses leave methods at their current + * (untyped) state, so behavior never degrades below the status quo. + * + * @param {string} className - class to describe + * @param {string} sourcePath - captured defining file path + */ +function applySourceFallback(className: string, sourcePath: string): void { + const hasJsdoc = Object.keys(descriptions[className] || {}).some( + key => key !== 'inherits', + ); + + if (hasJsdoc || !sourcePath) { + return; + } + + const src = readSourceFile(sourcePath); + + if (!src) { + return; + } + + const comments = parseSourceComments(src, className); + + for (const method of Object.keys(comments)) { + descriptions[className][method] = { + comment: parseComment(comments[method]), + }; + } +} + /** * Helper function to make easy descriptions parts extractions * @@ -287,17 +489,25 @@ function get( * @param {Function} ctor - class that declares the method * @param {string} methodName - exposed method name * @param {(...args: any[]) => any} fn - the method implementation + * @param {string} [sourcePath] - defining file path captured at decoration */ function buildMethodDescription( ctor: Function, methodName: string, fn: (...args: any[]) => any, + sourcePath?: string, ): void { const className: string = ctor.name; const argNames: string[] = argumentNames(fn); if (!descriptions[className]) { parseDescriptions(className, ctor.toString()); + + // dev-mode: a comment-stripping transpiler leaves no JSDoc in the + // runtime source — recover method docs from the file on disk + if (sourcePath) { + applySourceFallback(className, sourcePath); + } } const args: ArgDescription[] = get( @@ -367,6 +577,11 @@ function buildMethodDescription( * @return {(value: any, context: ClassMethodDecoratorContext) => void} */ export function expose(): any { + // the factory executes at class-definition time inside the defining + // module, so the caller frame points at the service source file — used + // as the dev-mode fallback when runtime comments are stripped + const sourcePath: string = captureCallerPath(); + // Dual-mode: standard (TC39) invocations pass a context object with a // `kind` property; legacy ones pass (target, propertyKey, descriptor). return function exposeDecorator( @@ -397,7 +612,7 @@ export function expose(): any { ? proto.constructor : this.constructor; - buildMethodDescription(ctor, methodName, value); + buildMethodDescription(ctor, methodName, value, sourcePath); }); return; @@ -409,6 +624,7 @@ export function expose(): any { target.constructor, String(context), descriptor.value, + sourcePath, ); return descriptor; diff --git a/test/decorators/expose.spec.ts b/test/decorators/expose.spec.ts index 2c7be8d..6c1b2b8 100644 --- a/test/decorators/expose.spec.ts +++ b/test/decorators/expose.spec.ts @@ -24,7 +24,7 @@ import '../mocks/index.js'; import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { expose, IMQRPCDescription } from '../../index.js'; +import { expose, parseSourceComments, IMQRPCDescription } from '../../index.js'; const description = IMQRPCDescription.serviceDescription; @@ -276,6 +276,123 @@ describe('decorators/expose()', () => { ); }); + it('should isolate the JSDoc type from braces in the description and keep object-literal types', () => { + class BraceTypeClass { + /** + * Searches items + * + * @param {string} query - filter, for example `{ id, name }` + * @param {{ x: number, y: number }} point - a point value + * @return {string} - a marker like `{ ok }` + */ + public search(query: string, point: { x: number; y: number }) { + return `${query}:${point.x}`; + } + } + + const descriptor = Object.getOwnPropertyDescriptor( + BraceTypeClass.prototype, + 'search', + ); + + expose()(BraceTypeClass.prototype, 'search', descriptor); + + const method = description.BraceTypeClass.methods.search; + + // a `}` inside the description must not extend the captured type + assert.equal(method.arguments[0].tsType, 'string'); + assert.equal(method.arguments[0].name, 'query'); + // object-literal types (nested braces) must be preserved intact + assert.equal(method.arguments[1].tsType, '{ x: number, y: number }'); + assert.equal(method.arguments[1].name, 'point'); + // a `}` inside the @return description must not corrupt the return type + assert.equal(method.returns.tsType, 'string'); + }); + + it('should extract method JSDoc from TypeScript source text', () => { + // original .ts source: type annotations, generics, visibility + // modifiers and decorators — none of which acorn can parse + const src = [ + 'export class TsSourceClass extends IMQService {', + ' /**', + ' * Authenticates a user', + ' *', + ' * @param {AuthenticateArgs} args - like `{ login, password }`', + ' * @return {Promise} - session data', + ' */', + ' @expose()', + ' public async authenticate(', + ' args: AuthenticateArgs,', + ' ): Promise {', + ' return {} as Authentication;', + ' }', + '', + ' /**', + ' * @param {{ x: number, y: number }} point - a point', + ' * @return {number}', + ' */', + ' protected calc(point: { x: number; y: number }): number {', + ' return 0;', + ' }', + '}', + 'class UnrelatedClass {', + ' /** @return {string} */', + ' public other() { return ""; }', + '}', + ].join('\n'); + + const comments = parseSourceComments(src, 'TsSourceClass'); + + assert.ok(comments.authenticate, 'authenticate doc not found'); + assert.match(comments.authenticate, /@param \{AuthenticateArgs\}/); + assert.match( + comments.authenticate, + /@return \{Promise\}/, + ); + assert.ok(comments.calc, 'calc doc not found'); + assert.match(comments.calc, /\{\{ x: number, y: number \}\}/); + // members of other classes in the same file must not leak in + assert.equal(comments.other, undefined); + }); + + it('should fall back to on-disk source when runtime comments are stripped', () => { + class DevModeStrippedClass { + /** + * Does dev things + * + * @param {string} input - input value + * @return {number} - result code + */ + public devMethod(input: string) { + return input.length; + } + } + + // simulate a comment-stripping dev transpiler (tsx/esbuild): the + // runtime class source carries no JSDoc, while this file on disk + // (located via the stack captured by the expose() factory) does + Object.defineProperty(DevModeStrippedClass, 'toString', { + value: () => + 'class DevModeStrippedClass {\n' + + ' devMethod(input) { return input.length; }\n' + + '}', + }); + + const descriptor = Object.getOwnPropertyDescriptor( + DevModeStrippedClass.prototype, + 'devMethod', + ); + + expose()(DevModeStrippedClass.prototype, 'devMethod', descriptor); + + const method = description.DevModeStrippedClass.methods.devMethod; + + assert.equal(method.description, 'Does dev things'); + assert.equal(method.arguments[0].name, 'input'); + assert.equal(method.arguments[0].tsType, 'string'); + assert.equal(method.returns.tsType, 'number'); + }); + it('should register the method via the legacy signature', () => { class LegacyExposeClass { /**