Skip to content

Commit 255c741

Browse files
authored
Fix/schema add jsdoc descriptions (#1762)
* schema-generator: propagate JSDoc descriptions to JSON Schema - Port behavior from old js-runtime transformer (branch fix/ast-add-descriptions-to-schema): - Attach property descriptions from JSDoc (merging/conflict note for intersections) - Attach index signature descriptions to additionalProperties - Attach root-level descriptions when present on the type symbol - Add doc-utils with robust extraction from declarations and symbols - Add fixture: descriptions-basic to verify behavior No changes to existing fixtures; behavior is additive only when JSDoc exists. * schema-generator: remove stray _doc file from commit * schema-generator: add full JSDoc description parity and fixtures - Property docs across all types (primitives, arrays, unions, wrappers) - Root-level descriptions from interface/type alias docs - Intersection conflicts: keep first, add , warn - Inheritance (extends) preserves property and root docs - Index signatures: string/number → additionalProperties.description; conflict comment - Preserve multiline/markdown text - Improve array detection to avoid misclassifying map-like types - Add comprehensive fixtures (no auto-gen): descriptions-basic, -index, -index-ambiguous, -intersection-same, -intersection-conflict, -extends, -union-literals, -array-doc, -wrappers, -recursive * schema-generator: add parity fixtures and alias-root doc handling - Add fixtures: descriptions-root-alias, -record-alias, -optional-union, -root-with-tags, -nested (Doc/ChildNode) - Improve root description extraction to also consider underlying symbol when the root is a type alias (use target doc if alias has none) * schema-generator: switch conflict logging to getLogger and add multiline property doc fixture; remove try/catch swallowing; fix logger optional-level assignment for exactOptionalPropertyTypes * add back warning for intersection property merge * fix issue with not recognizing typescript's NonPrimitive generic object type in objectformatter; return basic permissive schema in that case * use isRecord to improve code and reduce 'as any' casts * address code review simplification suggestions
1 parent 109a374 commit 255c741

38 files changed

+704
-10
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import ts from "typescript";
2+
3+
/**
4+
* Extract plain-text JSDoc from a symbol. Filters out tag lines starting with
5+
* '@'. Returns undefined when no useful text is present.
6+
*/
7+
export function getSymbolDoc(
8+
symbol: ts.Symbol | undefined,
9+
checker: ts.TypeChecker,
10+
): string | undefined {
11+
if (!symbol) return undefined;
12+
const parts = symbol.getDocumentationComment(checker);
13+
// @ts-ignore - displayPartsToString exists on the TS namespace
14+
const text = ts.displayPartsToString(parts) || "";
15+
if (!text) return undefined;
16+
const lines = text.split(/\r?\n/).filter((l) => !l.trim().startsWith("@"));
17+
const cleaned = lines.join("\n").trim();
18+
return cleaned || undefined;
19+
}
20+
21+
/**
22+
* Extract JSDoc comments from a declaration node (if available), filtering out
23+
* lines starting with '@'. Returns all distinct comment texts.
24+
*/
25+
export function getDeclDocs(decl: ts.Declaration): string[] {
26+
const docs: string[] = [];
27+
const jsDocs = (decl as any).jsDoc as Array<ts.JSDoc> | undefined;
28+
if (jsDocs && jsDocs.length > 0) {
29+
for (const d of jsDocs) {
30+
const comment = (d as any).comment as unknown;
31+
let text = "";
32+
if (typeof comment === "string") {
33+
text = comment;
34+
} else if (Array.isArray(comment)) {
35+
text = comment
36+
.map((c) => (typeof c === "string" ? c : (c as any).text ?? ""))
37+
.join("");
38+
}
39+
if (!text) continue;
40+
const lines = String(text).split(/\r?\n/).filter((l) =>
41+
!l.trim().startsWith("@")
42+
);
43+
const cleaned = lines.join("\n").trim();
44+
if (cleaned && !docs.includes(cleaned)) docs.push(cleaned);
45+
}
46+
}
47+
return docs;
48+
}
49+
50+
/**
51+
* Extract merged doc from symbol declarations and the symbol itself, preferring
52+
* declaration-attached comments from non-declaration files. Returns the first
53+
* doc text (if any) and the set of all distinct docs discovered.
54+
*/
55+
export function extractDocFromSymbolAndDecls(
56+
symbol: ts.Symbol | undefined,
57+
checker: ts.TypeChecker,
58+
): { text?: string; all: string[] } {
59+
const all: string[] = [];
60+
if (symbol) {
61+
const decls = symbol.declarations ?? [];
62+
for (const decl of decls) {
63+
const sf = decl.getSourceFile();
64+
if (!sf.isDeclarationFile) {
65+
for (const s of getDeclDocs(decl)) if (!all.includes(s)) all.push(s);
66+
}
67+
}
68+
}
69+
70+
// Only include symbol-level docs if there is a non-declaration-file declaration
71+
const hasUserDecl = (symbol?.declarations ?? []).some((d) =>
72+
!d.getSourceFile().isDeclarationFile
73+
);
74+
if (hasUserDecl) {
75+
const symText = getSymbolDoc(symbol, checker);
76+
if (symText && !all.includes(symText)) all.push(symText);
77+
}
78+
79+
const result: { text?: string; all: string[] } = { all };
80+
if (all[0]) result.text = all[0];
81+
return result;
82+
}

packages/schema-generator/src/formatters/intersection-formatter.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import type {
55
TypeFormatter,
66
} from "../interface.ts";
77
import type { SchemaGenerator } from "../schema-generator.ts";
8+
import { getLogger } from "@commontools/utils/logger";
9+
import { isRecord } from "@commontools/utils/types";
10+
11+
const logger = getLogger("schema-generator.intersection");
812

913
export class IntersectionFormatter implements TypeFormatter {
1014
constructor(private schemaGenerator: SchemaGenerator) {}
@@ -88,14 +92,36 @@ export class IntersectionFormatter implements TypeFormatter {
8892
// Merge properties from this part
8993
if (schema.properties) {
9094
for (const [key, value] of Object.entries(schema.properties)) {
91-
if (mergedProps[key] && mergedProps[key] !== value) {
92-
// Property conflict - could improve this with more sophisticated merging
93-
console.warn(
94-
`Intersection property conflict for key '${key}' - using first definition`,
95+
const existing = mergedProps[key];
96+
if (existing) {
97+
// If both are object schemas, check description conflicts
98+
if (isRecord(existing) && isRecord(value)) {
99+
const aDesc = typeof existing.description === "string"
100+
? (existing.description as string)
101+
: undefined;
102+
const bDesc = typeof value.description === "string"
103+
? (value.description as string)
104+
: undefined;
105+
if (aDesc && bDesc && aDesc !== bDesc) {
106+
const priorComment = typeof existing.$comment === "string"
107+
? (existing.$comment as string)
108+
: undefined;
109+
(existing as Record<string, unknown>).$comment =
110+
priorComment ??
111+
"Conflicting docs across intersection constituents; using first";
112+
logger.warn(() =>
113+
`Intersection doc conflict for '${key}'; using first`
114+
);
115+
}
116+
}
117+
// Prefer the first definition by default; emit debug for visibility
118+
logger.debug(() =>
119+
`Intersection kept first definition for '${key}'`
95120
);
96-
} else {
97-
mergedProps[key] = value;
121+
// Keep existing
122+
continue;
98123
}
124+
mergedProps[key] = value as SchemaDefinition;
99125
}
100126
}
101127

packages/schema-generator/src/formatters/object-formatter.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import type {
66
} from "../interface.ts";
77
import { safeGetPropertyType } from "../type-utils.ts";
88
import type { SchemaGenerator } from "../schema-generator.ts";
9+
import { extractDocFromSymbolAndDecls, getDeclDocs } from "../doc-utils.ts";
10+
import { getLogger } from "@commontools/utils/logger";
11+
import { isRecord } from "@commontools/utils/types";
12+
13+
const logger = getLogger("schema-generator.object", {
14+
enabled: true,
15+
level: "warn",
16+
});
917

1018
/**
1119
* Formatter for object types (interfaces, type literals, etc.)
@@ -15,12 +23,23 @@ export class ObjectFormatter implements TypeFormatter {
1523

1624
supportsType(type: ts.Type, context: GenerationContext): boolean {
1725
// Handle object types (interfaces, type literals, classes)
18-
return (type.flags & ts.TypeFlags.Object) !== 0;
26+
const flags = type.flags;
27+
if ((flags & ts.TypeFlags.Object) !== 0) return true;
28+
// Also claim the exact TypeScript `object` type via string check.
29+
return context.typeChecker.typeToString(type) === "object";
1930
}
2031

2132
formatType(type: ts.Type, context: GenerationContext): SchemaDefinition {
2233
const checker = context.typeChecker;
2334

35+
// If this is the TS `object` type (unknown object shape), emit a permissive
36+
// object schema instead of attempting to enumerate properties.
37+
// This avoids false "no formatter" errors for unions containing `object`.
38+
const typeName = checker.typeToString(type);
39+
if (typeName === "object") {
40+
return { type: "object", additionalProperties: true };
41+
}
42+
2443
// Special-case Date to a string with date-time format (match old behavior)
2544
if (type.symbol?.name === "Date" && type.symbol?.valueDeclaration) {
2645
// Check if this is the built-in Date type (not a user-defined type named "Date")
@@ -75,10 +94,73 @@ export class ObjectFormatter implements TypeFormatter {
7594
context,
7695
propTypeNode,
7796
);
97+
// Attach property description from JSDoc (if any)
98+
const { text, all } = extractDocFromSymbolAndDecls(prop, checker);
99+
if (text && isRecord(generated)) {
100+
const conflicts = all.filter((s) => s && s !== text);
101+
(generated as Record<string, unknown>).description = text;
102+
if (conflicts.length > 0) {
103+
const comment = typeof generated.$comment === "string"
104+
? (generated.$comment as string)
105+
: undefined;
106+
(generated as Record<string, unknown>).$comment = comment
107+
? comment
108+
: "Conflicting docs across declarations; using first";
109+
// Warning only
110+
logger.warn(() =>
111+
`JSDoc conflict for property '${propName}'; using first doc`
112+
);
113+
}
114+
}
78115
properties[propName] = generated;
79116
}
80117

81118
const schema: SchemaDefinition = { type: "object", properties };
119+
120+
// Handle string/number index signatures → additionalProperties with description
121+
const stringIndex = checker.getIndexTypeOfType(type, ts.IndexKind.String);
122+
const numberIndex = checker.getIndexTypeOfType(type, ts.IndexKind.Number);
123+
const chosenIndex = stringIndex ?? numberIndex;
124+
if (chosenIndex) {
125+
const apSchema = this.schemaGenerator.formatChildType(
126+
chosenIndex,
127+
context,
128+
undefined,
129+
);
130+
// Attempt to read JSDoc from index signature declarations
131+
const sym = type.getSymbol?.();
132+
const foundDocs: string[] = [];
133+
if (sym) {
134+
for (const decl of sym.declarations ?? []) {
135+
if (ts.isInterfaceDeclaration(decl) || ts.isTypeLiteralNode(decl)) {
136+
for (const member of decl.members) {
137+
if (ts.isIndexSignatureDeclaration(member)) {
138+
const docs = getDeclDocs(member);
139+
for (const d of docs) {
140+
if (!foundDocs.includes(d)) foundDocs.push(d);
141+
}
142+
}
143+
}
144+
}
145+
}
146+
}
147+
if (foundDocs.length > 0 && isRecord(apSchema)) {
148+
(apSchema as Record<string, unknown>).description = foundDocs[0]!;
149+
if (foundDocs.length > 1) {
150+
const comment = typeof apSchema.$comment === "string"
151+
? (apSchema.$comment as string)
152+
: undefined;
153+
(apSchema as Record<string, unknown>).$comment = comment
154+
? comment
155+
: "Conflicting docs for index signatures; using first";
156+
logger.warn(() =>
157+
"JSDoc conflict for index signatures; using first doc"
158+
);
159+
}
160+
}
161+
(schema as Record<string, unknown>).additionalProperties =
162+
apSchema as SchemaDefinition;
163+
}
82164
if (required.length > 0) schema.required = required;
83165

84166
return schema;

packages/schema-generator/src/schema-generator.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
safeGetIndexTypeOfType,
1818
safeGetTypeOfSymbolAtLocation,
1919
} from "./type-utils.ts";
20+
import { extractDocFromSymbolAndDecls } from "./doc-utils.ts";
21+
import { isRecord } from "@commontools/utils/types";
2022

2123
/**
2224
* Main schema generator that uses a chain of formatters
@@ -61,7 +63,10 @@ export class SchemaGenerator implements ISchemaGenerator {
6163
};
6264

6365
// Generate the root schema
64-
const rootSchema = this.formatType(type, context, true);
66+
let rootSchema = this.formatType(type, context, true);
67+
68+
// Attach root-level description from JSDoc if available
69+
rootSchema = this.attachRootDescription(rootSchema, type, context);
6570

6671
// Build final schema with definitions if needed
6772
return this.buildFinalSchema(rootSchema, type, context, typeNode);
@@ -342,4 +347,45 @@ export class SchemaGenerator implements ISchemaGenerator {
342347
if (checker) visit(type);
343348
return { types: cycles, names: cycleNames };
344349
}
350+
351+
/**
352+
* Attach a root-level description from JSDoc on the type symbol when
353+
* available and when the root schema is an object that does not already have
354+
* a description.
355+
*/
356+
private attachRootDescription(
357+
schema: SchemaDefinition,
358+
type: ts.Type,
359+
context: GenerationContext,
360+
): SchemaDefinition {
361+
if (typeof schema !== "object") return schema;
362+
363+
// Consider both alias symbol (for type aliases) and the underlying
364+
// symbol (for interfaces/classes). Prefer alias doc when present; fall
365+
// back to underlying.
366+
const aliasSym = type.aliasSymbol;
367+
const directSym = type.getSymbol?.() || (type as any).symbol;
368+
369+
const pickDoc = (sym?: ts.Symbol): string | undefined => {
370+
if (!sym) return undefined;
371+
const hasUserDecl = (sym.declarations ?? []).some((d) =>
372+
!d.getSourceFile().isDeclarationFile
373+
);
374+
if (!hasUserDecl) return undefined;
375+
const { text } = extractDocFromSymbolAndDecls(
376+
sym,
377+
context.typeChecker,
378+
);
379+
return text;
380+
};
381+
382+
const aliasDoc = pickDoc(aliasSym);
383+
const directDoc = pickDoc(directSym);
384+
const chosen = aliasDoc ?? directDoc;
385+
386+
if (chosen && isRecord(schema) && !("description" in schema)) {
387+
(schema as Record<string, unknown>).description = chosen;
388+
}
389+
return schema;
390+
}
345391
}

packages/schema-generator/src/type-utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,25 @@ export function getArrayElementInfo(
271271
if (elementType) return { elementType };
272272
}
273273

274+
// If the type also has a string index signature, prefer treating it as an
275+
// object map (not an array). This avoids misclassifying dictionary types
276+
// like `{ [k: string]: T; [n: number]: T }` as arrays.
277+
const stringIndex = safeGetIndexTypeOfType(
278+
checker,
279+
type,
280+
ts.IndexKind.String,
281+
"array/map disambiguation string index",
282+
);
283+
const numberIndex = safeGetIndexTypeOfType(
284+
checker,
285+
type,
286+
ts.IndexKind.Number,
287+
"array/map disambiguation number index",
288+
);
289+
if (stringIndex && numberIndex) {
290+
return undefined;
291+
}
292+
274293
// Use numeric index type as fallback (for tuples/array-like objects)
275294
const elementType = safeGetIndexTypeOfType(
276295
checker,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"properties": {
3+
"tags": {
4+
"description": "tags of the item",
5+
"items": {
6+
"type": "string"
7+
},
8+
"type": "array"
9+
}
10+
},
11+
"required": [
12+
"tags"
13+
],
14+
"type": "object"
15+
}
16+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
interface SchemaRoot {
2+
/** tags of the item */
3+
tags: string[];
4+
}
5+
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"properties": {
3+
"name": {
4+
"description": "Name doc",
5+
"type": "string"
6+
}
7+
},
8+
"required": [
9+
"name"
10+
],
11+
"description": "Root doc",
12+
"type": "object"
13+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** Root doc */
2+
interface SchemaRoot {
3+
/** Name doc */
4+
name: string;
5+
}

0 commit comments

Comments
 (0)