@cosmicdrift/kumiko-guards 0.398.0 → 0.400.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/_lib/agent-docs-api-nodes.ts +107 -0
- package/src/_lib/agent-docs-api-render.ts +257 -0
- package/src/_lib/agent-docs-api.ts +285 -0
- package/src/_lib/agent-docs-feature-imports.ts +175 -0
- package/src/_lib/agent-docs-features.ts +196 -0
- package/src/_lib/agent-docs-module-walk.ts +103 -0
- package/src/_lib/agent-docs-shared.ts +76 -0
- package/src/_lib/agent-docs.ts +19 -205
- package/src/guard-agent-docs-drift.ts +2 -1
- package/src/guard-screen-conventions.ts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.400.0",
|
|
4
4
|
"description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"kumiko-pre-push": "./src/pre-push.sh"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
39
|
+
"@cosmicdrift/kumiko-types": "0.400.0",
|
|
40
40
|
"ts-morph": "^28.0.0",
|
|
41
41
|
"zod": "^4.6.7"
|
|
42
42
|
},
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"LICENSE"
|
|
53
53
|
],
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
55
|
+
"@cosmicdrift/kumiko-framework": "0.400.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependenciesMeta": {
|
|
58
58
|
"@cosmicdrift/kumiko-framework": {
|
|
@@ -60,6 +60,6 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
63
|
+
"@cosmicdrift/kumiko-framework": "0.400.0"
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ArrowFunction,
|
|
3
|
+
type ClassDeclaration,
|
|
4
|
+
type EnumDeclaration,
|
|
5
|
+
type FunctionDeclaration,
|
|
6
|
+
type FunctionExpression,
|
|
7
|
+
type InterfaceDeclaration,
|
|
8
|
+
type JSDoc,
|
|
9
|
+
Node,
|
|
10
|
+
SyntaxKind,
|
|
11
|
+
type TypeAliasDeclaration,
|
|
12
|
+
type VariableDeclaration,
|
|
13
|
+
} from "ts-morph";
|
|
14
|
+
|
|
15
|
+
export type ApiTypeDeclaration =
|
|
16
|
+
| InterfaceDeclaration
|
|
17
|
+
| TypeAliasDeclaration
|
|
18
|
+
| EnumDeclaration
|
|
19
|
+
| ClassDeclaration;
|
|
20
|
+
|
|
21
|
+
export function isApiTypeDeclaration(node: Node): node is ApiTypeDeclaration {
|
|
22
|
+
return (
|
|
23
|
+
Node.isInterfaceDeclaration(node) ||
|
|
24
|
+
Node.isTypeAliasDeclaration(node) ||
|
|
25
|
+
Node.isEnumDeclaration(node) ||
|
|
26
|
+
Node.isClassDeclaration(node)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function jsDocsOf(node: Node): readonly JSDoc[] {
|
|
31
|
+
if (Node.isVariableDeclaration(node)) return node.getVariableStatement()?.getJsDocs() ?? [];
|
|
32
|
+
return Node.isJSDocable(node) ? node.getJsDocs() : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isInternalApi(node: Node): boolean {
|
|
36
|
+
return jsDocsOf(node).some((doc) => doc.getTags().some((tag) => tag.getTagName() === "internal"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isPublicClassMember(member: Node): boolean {
|
|
40
|
+
if (isInternalApi(member)) return false;
|
|
41
|
+
if (!Node.isModifierable(member)) return true;
|
|
42
|
+
if (
|
|
43
|
+
member.hasModifier(SyntaxKind.PrivateKeyword) ||
|
|
44
|
+
member.hasModifier(SyntaxKind.ProtectedKeyword)
|
|
45
|
+
)
|
|
46
|
+
return false;
|
|
47
|
+
return (
|
|
48
|
+
!(Node.isPropertyDeclaration(member) || Node.isMethodDeclaration(member)) ||
|
|
49
|
+
!member.getName().startsWith("#")
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function presentNodes(node: Node | undefined): Node[] {
|
|
54
|
+
return node === undefined ? [] : [node];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isFunctionLike(node: Node | undefined): node is ArrowFunction | FunctionExpression {
|
|
58
|
+
return Node.isArrowFunction(node) || Node.isFunctionExpression(node);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function variableTypeRoots(node: VariableDeclaration): Node[] {
|
|
62
|
+
const typeNode = node.getTypeNode();
|
|
63
|
+
if (typeNode !== undefined) return [typeNode];
|
|
64
|
+
const initializer = node.getInitializer();
|
|
65
|
+
if (!isFunctionLike(initializer)) return [];
|
|
66
|
+
return [...initializer.getParameters(), ...presentNodes(initializer.getReturnTypeNode())];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function functionTypeRoots(node: FunctionDeclaration): Node[] {
|
|
70
|
+
const overloads = node.getOverloads();
|
|
71
|
+
return (overloads.length > 0 ? overloads : [node]).flatMap((signature) => [
|
|
72
|
+
...signature.getParameters(),
|
|
73
|
+
...presentNodes(signature.getReturnTypeNode()),
|
|
74
|
+
...signature.getTypeParameters(),
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function classTypeRoots(node: ClassDeclaration): Node[] {
|
|
79
|
+
const roots: Node[] = presentNodes(node.getExtends());
|
|
80
|
+
for (const member of node.getMembers()) {
|
|
81
|
+
if (!isPublicClassMember(member)) continue;
|
|
82
|
+
if (Node.isPropertyDeclaration(member)) roots.push(...presentNodes(member.getTypeNode()));
|
|
83
|
+
if (Node.isMethodDeclaration(member) || Node.isConstructorDeclaration(member)) {
|
|
84
|
+
roots.push(...member.getParameters(), ...presentNodes(member.getReturnTypeNode()));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return roots;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function typeRoots(node: Node): Node[] {
|
|
91
|
+
if (Node.isVariableDeclaration(node)) return variableTypeRoots(node);
|
|
92
|
+
if (Node.isFunctionDeclaration(node)) return functionTypeRoots(node);
|
|
93
|
+
if (Node.isClassDeclaration(node)) return classTypeRoots(node);
|
|
94
|
+
return isApiTypeDeclaration(node) ? [node] : [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Type references reachable from the rendered text: parameter, return, property
|
|
98
|
+
// and heritage types. Bodies and initializers are not walked.
|
|
99
|
+
export function referencedTypeNodes(node: Node): readonly Node[] {
|
|
100
|
+
const roots = typeRoots(node);
|
|
101
|
+
return roots.flatMap((root) => [
|
|
102
|
+
...(Node.isTypeReference(root) || Node.isExpressionWithTypeArguments(root) ? [root] : []),
|
|
103
|
+
...root.getDescendantsOfKind(SyntaxKind.TypeReference),
|
|
104
|
+
...root.getDescendantsOfKind(SyntaxKind.ExpressionWithTypeArguments),
|
|
105
|
+
...root.getDescendantsOfKind(SyntaxKind.TypeQuery),
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ClassDeclaration,
|
|
3
|
+
type EnumDeclaration,
|
|
4
|
+
type FunctionDeclaration,
|
|
5
|
+
type InterfaceDeclaration,
|
|
6
|
+
Node,
|
|
7
|
+
type ParameterDeclaration,
|
|
8
|
+
type TypeAliasDeclaration,
|
|
9
|
+
type TypeElementTypes,
|
|
10
|
+
type TypeNode,
|
|
11
|
+
type TypeParameterDeclaration,
|
|
12
|
+
ts,
|
|
13
|
+
type VariableDeclaration,
|
|
14
|
+
} from "ts-morph";
|
|
15
|
+
import { isInternalApi, isPublicClassMember, jsDocsOf } from "./agent-docs-api-nodes";
|
|
16
|
+
import { firstSentence } from "./agent-docs-shared";
|
|
17
|
+
|
|
18
|
+
const INDENT = " ";
|
|
19
|
+
const MAX_INLINE_TYPE_CHARS = 240;
|
|
20
|
+
const MAX_INFERRED_TYPE_CHARS = 120;
|
|
21
|
+
const MAX_NESTED_LITERAL_DEPTH = 3;
|
|
22
|
+
const IMPORT_QUALIFIER_RE = /import\("[^"]*"\)\./g;
|
|
23
|
+
// Readonly modifiers decide nothing for code that passes options in, and they
|
|
24
|
+
// are a tenth of the output.
|
|
25
|
+
const READONLY_MODIFIER_RE = /\breadonly /g;
|
|
26
|
+
|
|
27
|
+
function collapse(text: string): string {
|
|
28
|
+
return text
|
|
29
|
+
.replace(IMPORT_QUALIFIER_RE, "")
|
|
30
|
+
.replace(READONLY_MODIFIER_RE, "")
|
|
31
|
+
.replace(/\s+/g, " ")
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const commentFreePrinter = ts.createPrinter({ removeComments: true });
|
|
36
|
+
|
|
37
|
+
// Source text keeps line comments, which would swallow the rest of a collapsed
|
|
38
|
+
// one-line signature; the printer re-emits the node without them.
|
|
39
|
+
function nodeText(node: Node): string {
|
|
40
|
+
const printed = commentFreePrinter.printNode(
|
|
41
|
+
ts.EmitHint.Unspecified,
|
|
42
|
+
node.compilerNode,
|
|
43
|
+
node.getSourceFile().compilerNode,
|
|
44
|
+
);
|
|
45
|
+
return collapse(printed);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function capped(text: string, max: number): string {
|
|
49
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function docSuffix(node: Node): string {
|
|
53
|
+
const description = jsDocsOf(node).at(-1)?.getDescription().trim() ?? "";
|
|
54
|
+
return description === "" ? "" : ` // ${firstSentence(description)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function typeParametersText(params: readonly TypeParameterDeclaration[]): string {
|
|
58
|
+
return params.length === 0 ? "" : `<${params.map((p) => nodeText(p)).join(", ")}>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Destructuring patterns and their defaults are implementation detail; the
|
|
62
|
+
// parameter type already lists the properties.
|
|
63
|
+
function parameterName(param: ParameterDeclaration): string {
|
|
64
|
+
const nameNode = param.getNameNode();
|
|
65
|
+
if (Node.isObjectBindingPattern(nameNode)) return "props";
|
|
66
|
+
if (Node.isArrayBindingPattern(nameNode)) return "args";
|
|
67
|
+
return nameNode.getText();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parameterText(param: ParameterDeclaration): string {
|
|
71
|
+
const rest = param.isRestParameter() ? "..." : "";
|
|
72
|
+
const optional = param.hasQuestionToken() || param.hasInitializer() ? "?" : "";
|
|
73
|
+
const typeNode = param.getTypeNode();
|
|
74
|
+
const typeText = typeNode === undefined ? "" : `: ${nodeText(typeNode)}`;
|
|
75
|
+
return `${rest}${parameterName(param)}${optional}${typeText}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type SignatureLike = {
|
|
79
|
+
getTypeParameters(): TypeParameterDeclaration[];
|
|
80
|
+
getParameters(): ParameterDeclaration[];
|
|
81
|
+
getReturnTypeNode(): TypeNode | undefined;
|
|
82
|
+
getReturnType(): { getText(enclosing?: Node): string };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function signatureText(signature: SignatureLike & Node): string {
|
|
86
|
+
const params = signature.getParameters().map(parameterText).join(", ");
|
|
87
|
+
const declared = signature.getReturnTypeNode();
|
|
88
|
+
const returnText =
|
|
89
|
+
declared !== undefined
|
|
90
|
+
? nodeText(declared)
|
|
91
|
+
: inferredText(signature.getReturnType(), signature);
|
|
92
|
+
return `${typeParametersText(signature.getTypeParameters())}(${params}): ${returnText}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function typeText(typeNode: TypeNode | undefined, depth: number, indent: string): string {
|
|
96
|
+
if (typeNode === undefined) return "unknown";
|
|
97
|
+
if (Node.isTypeLiteral(typeNode) && depth < MAX_NESTED_LITERAL_DEPTH) {
|
|
98
|
+
const members = typeNode.getMembers().filter((member) => !isInternalApi(member));
|
|
99
|
+
if (members.length === 0) return "{}";
|
|
100
|
+
const lines = members.flatMap((member) => memberLines(member, depth + 1, `${indent}${INDENT}`));
|
|
101
|
+
return `{\n${lines.join("\n")}\n${indent}}`;
|
|
102
|
+
}
|
|
103
|
+
return capped(nodeText(typeNode), MAX_INLINE_TYPE_CHARS);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function memberName(member: TypeElementTypes): string {
|
|
107
|
+
if (Node.isPropertySignature(member) || Node.isMethodSignature(member)) {
|
|
108
|
+
const optional = member.hasQuestionToken() ? "?" : "";
|
|
109
|
+
return `${nodeText(member.getNameNode())}${optional}`;
|
|
110
|
+
}
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function memberLines(member: TypeElementTypes, depth: number, indent: string): string[] {
|
|
115
|
+
const doc = docSuffix(member);
|
|
116
|
+
if (Node.isPropertySignature(member)) {
|
|
117
|
+
const text = typeText(member.getTypeNode(), depth, indent);
|
|
118
|
+
return [`${indent}${memberName(member)}: ${text};${doc}`];
|
|
119
|
+
}
|
|
120
|
+
if (Node.isMethodSignature(member)) {
|
|
121
|
+
return [`${indent}${memberName(member)}${signatureText(member)};${doc}`];
|
|
122
|
+
}
|
|
123
|
+
return [`${indent}${capped(nodeText(member).replace(/;$/, ""), MAX_INLINE_TYPE_CHARS)};${doc}`];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function typeMembersBlock(members: readonly TypeElementTypes[]): string[] {
|
|
127
|
+
return members
|
|
128
|
+
.filter((member) => !isInternalApi(member))
|
|
129
|
+
.flatMap((member) => memberLines(member, 1, INDENT));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderInterface(node: InterfaceDeclaration): string[] {
|
|
133
|
+
const heritage = node.getExtends().map((clause) => nodeText(clause));
|
|
134
|
+
const extendsText = heritage.length === 0 ? "" : ` extends ${heritage.join(", ")}`;
|
|
135
|
+
const head = `interface ${node.getName()}${typeParametersText(node.getTypeParameters())}${extendsText}`;
|
|
136
|
+
const members = typeMembersBlock(node.getMembers());
|
|
137
|
+
if (members.length === 0) return [`${head} {}${docSuffix(node)}`];
|
|
138
|
+
return [`${head} {${docSuffix(node)}`, ...members, "}"];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function literalParts(typeNode: TypeNode): { named: string[]; literals: TypeNode[] } {
|
|
142
|
+
const parts = Node.isIntersectionTypeNode(typeNode) ? typeNode.getTypeNodes() : [typeNode];
|
|
143
|
+
return {
|
|
144
|
+
named: parts.filter((part) => !Node.isTypeLiteral(part)).map((part) => nodeText(part)),
|
|
145
|
+
literals: parts.filter((part) => Node.isTypeLiteral(part)),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function renderTypeAlias(node: TypeAliasDeclaration): string[] {
|
|
150
|
+
const head = `type ${node.getName()}${typeParametersText(node.getTypeParameters())} =`;
|
|
151
|
+
const typeNode = node.getTypeNodeOrThrow();
|
|
152
|
+
const doc = docSuffix(node);
|
|
153
|
+
if (Node.isUnionTypeNode(typeNode) && typeNode.getTypeNodes().some(Node.isTypeLiteral)) {
|
|
154
|
+
const variants = typeNode.getTypeNodes().map((variant) => {
|
|
155
|
+
const literal = typeText(variant, 1, INDENT);
|
|
156
|
+
return `${INDENT}| ${Node.isTypeLiteral(variant) ? literal : nodeText(variant)}`;
|
|
157
|
+
});
|
|
158
|
+
return [`${head}${doc}`, ...variants, ";"];
|
|
159
|
+
}
|
|
160
|
+
const { named, literals } = literalParts(typeNode);
|
|
161
|
+
if (literals.length === 0) {
|
|
162
|
+
return [`${head} ${capped(nodeText(typeNode), MAX_INLINE_TYPE_CHARS * 2)};${doc}`];
|
|
163
|
+
}
|
|
164
|
+
const members = literals.flatMap((literal) =>
|
|
165
|
+
Node.isTypeLiteral(literal) ? typeMembersBlock(literal.getMembers()) : [],
|
|
166
|
+
);
|
|
167
|
+
const prefix = named.length === 0 ? "" : `${named.join(" & ")} & `;
|
|
168
|
+
return [`${head} ${prefix}{${doc}`, ...members, "};"];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderEnum(node: EnumDeclaration): string[] {
|
|
172
|
+
const members = node.getMembers().map((member) => nodeText(member));
|
|
173
|
+
return [`enum ${node.getName()} { ${members.join(", ")} }${docSuffix(node)}`];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderClass(node: ClassDeclaration): string[] {
|
|
177
|
+
const base = node.getExtends();
|
|
178
|
+
const extendsText = base === undefined ? "" : ` extends ${nodeText(base)}`;
|
|
179
|
+
const head = `class ${node.getName() ?? "default"}${typeParametersText(node.getTypeParameters())}${extendsText}`;
|
|
180
|
+
const lines: string[] = [];
|
|
181
|
+
for (const ctor of node.getConstructors()) {
|
|
182
|
+
if (isPublicClassMember(ctor)) {
|
|
183
|
+
lines.push(`${INDENT}constructor(${ctor.getParameters().map(parameterText).join(", ")});`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const member of node.getMembers()) {
|
|
187
|
+
if (!isPublicClassMember(member)) continue;
|
|
188
|
+
const isStatic = Node.isStaticable(member) && member.isStatic() ? "static " : "";
|
|
189
|
+
if (Node.isPropertyDeclaration(member)) {
|
|
190
|
+
const text = typeText(member.getTypeNode(), 1, INDENT);
|
|
191
|
+
lines.push(`${INDENT}${isStatic}${member.getName()}: ${text};${docSuffix(member)}`);
|
|
192
|
+
} else if (Node.isMethodDeclaration(member)) {
|
|
193
|
+
lines.push(
|
|
194
|
+
`${INDENT}${isStatic}${member.getName()}${signatureText(member)};${docSuffix(member)}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (lines.length === 0) return [`${head} {}${docSuffix(node)}`];
|
|
199
|
+
return [`${head} {${docSuffix(node)}`, ...lines, "}"];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderFunction(name: string, node: FunctionDeclaration): string[] {
|
|
203
|
+
const overloads = node.getOverloads();
|
|
204
|
+
const signatures = overloads.length > 0 ? overloads : [node];
|
|
205
|
+
return signatures.map(
|
|
206
|
+
(signature) => `function ${name}${signatureText(signature)};${docSuffix(signature)}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function renderVariable(name: string, node: VariableDeclaration): string[] {
|
|
211
|
+
const doc = docSuffix(node);
|
|
212
|
+
const typeNode = node.getTypeNode();
|
|
213
|
+
if (typeNode !== undefined) return [`const ${name}: ${typeText(typeNode, 1, "")};${doc}`];
|
|
214
|
+
const initializer = node.getInitializer();
|
|
215
|
+
if (
|
|
216
|
+
initializer !== undefined &&
|
|
217
|
+
(Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer))
|
|
218
|
+
) {
|
|
219
|
+
return [`function ${name}${signatureText(initializer)};${doc}`];
|
|
220
|
+
}
|
|
221
|
+
const inferred = collapse(node.getType().getText(node));
|
|
222
|
+
if (inferred.length <= MAX_INFERRED_TYPE_CHARS) return [`const ${name}: ${inferred};${doc}`];
|
|
223
|
+
const keys = objectLiteralKeys(initializer);
|
|
224
|
+
return [`const ${name}${keys === undefined ? "" : `: { ${keys.join("; ")} }`};${doc}`];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function objectLiteralKeys(initializer: Node | undefined): string[] | undefined {
|
|
228
|
+
let expression = initializer;
|
|
229
|
+
while (Node.isAsExpression(expression) || Node.isSatisfiesExpression(expression)) {
|
|
230
|
+
expression = expression.getExpression();
|
|
231
|
+
}
|
|
232
|
+
if (!Node.isObjectLiteralExpression(expression)) return undefined;
|
|
233
|
+
return expression
|
|
234
|
+
.getProperties()
|
|
235
|
+
.flatMap((property) =>
|
|
236
|
+
Node.isPropertyAssignment(property) ||
|
|
237
|
+
Node.isShorthandPropertyAssignment(property) ||
|
|
238
|
+
Node.isMethodDeclaration(property)
|
|
239
|
+
? [property.getName()]
|
|
240
|
+
: [],
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function renderApiDeclaration(name: string, node: Node): string[] | undefined {
|
|
245
|
+
if (Node.isFunctionDeclaration(node)) return renderFunction(name, node);
|
|
246
|
+
if (Node.isVariableDeclaration(node)) return renderVariable(name, node);
|
|
247
|
+
if (Node.isInterfaceDeclaration(node)) return renderInterface(node);
|
|
248
|
+
if (Node.isTypeAliasDeclaration(node)) return renderTypeAlias(node);
|
|
249
|
+
if (Node.isEnumDeclaration(node)) return renderEnum(node);
|
|
250
|
+
if (Node.isClassDeclaration(node)) return renderClass(node);
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function inferredText(type: { getText(enclosing?: Node): string }, enclosing: Node): string {
|
|
255
|
+
const text = collapse(type.getText(enclosing));
|
|
256
|
+
return text.length <= MAX_INFERRED_TYPE_CHARS ? text : "…";
|
|
257
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { Node, type Project, type SourceFile } from "ts-morph";
|
|
4
|
+
import {
|
|
5
|
+
type ApiTypeDeclaration,
|
|
6
|
+
isApiTypeDeclaration,
|
|
7
|
+
isInternalApi,
|
|
8
|
+
referencedTypeNodes,
|
|
9
|
+
} from "./agent-docs-api-nodes";
|
|
10
|
+
import { renderApiDeclaration } from "./agent-docs-api-render";
|
|
11
|
+
import { compareCodepoint, finishFile, isRecord, parseJsonFile, table } from "./agent-docs-shared";
|
|
12
|
+
|
|
13
|
+
type ApiEntry = { readonly packageDir: string; readonly subpath: string };
|
|
14
|
+
|
|
15
|
+
const API_ENTRIES: readonly ApiEntry[] = [
|
|
16
|
+
{ packageDir: "packages/framework", subpath: "./engine" },
|
|
17
|
+
{ packageDir: "packages/framework", subpath: "./errors" },
|
|
18
|
+
{ packageDir: "packages/framework", subpath: "./ui-types" },
|
|
19
|
+
{ packageDir: "packages/framework", subpath: "./db" },
|
|
20
|
+
{ packageDir: "packages/framework", subpath: "./event-store" },
|
|
21
|
+
{ packageDir: "packages/framework", subpath: "./seeding" },
|
|
22
|
+
{ packageDir: "packages/renderer", subpath: "./web" },
|
|
23
|
+
{ packageDir: "packages/testing", subpath: "./stack" },
|
|
24
|
+
{ packageDir: "packages/testing", subpath: "./helpers" },
|
|
25
|
+
{ packageDir: "packages/dev-server", subpath: "./testing" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Bundled-feature types stay out of the closure; features.md covers them.
|
|
29
|
+
const TYPES_PACKAGE_DIR = "packages/types";
|
|
30
|
+
const PACKAGE_SCOPE_PREFIX = "@cosmicdrift/kumiko-";
|
|
31
|
+
const TYPES_FILE = "api/types.md";
|
|
32
|
+
|
|
33
|
+
type ResolvedEntry = {
|
|
34
|
+
readonly importPath: string;
|
|
35
|
+
readonly file: string;
|
|
36
|
+
readonly sourceRel: string;
|
|
37
|
+
readonly sourceFile: SourceFile;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function resolveEntry(repoRoot: string, project: Project, entry: ApiEntry): ResolvedEntry {
|
|
41
|
+
const packageJsonRel = `${entry.packageDir}/package.json`;
|
|
42
|
+
const parsed = parseJsonFile(
|
|
43
|
+
packageJsonRel,
|
|
44
|
+
readFileSync(join(repoRoot, packageJsonRel), "utf-8"),
|
|
45
|
+
);
|
|
46
|
+
const name = isRecord(parsed) ? parsed["name"] : undefined;
|
|
47
|
+
const exportsField = isRecord(parsed) ? parsed["exports"] : undefined;
|
|
48
|
+
const target = isRecord(exportsField) ? exportsField[entry.subpath] : undefined;
|
|
49
|
+
const typesPath = isRecord(target) ? target["types"] : undefined;
|
|
50
|
+
if (typeof name !== "string" || typeof typesPath !== "string") {
|
|
51
|
+
throw new Error(`${packageJsonRel}: needs a name and exports["${entry.subpath}"].types`);
|
|
52
|
+
}
|
|
53
|
+
const sourceRel = join(entry.packageDir, typesPath).split(sep).join("/");
|
|
54
|
+
const slug = `${name.replace(PACKAGE_SCOPE_PREFIX, "")}-${entry.subpath.replace(/^\.\//, "").replace(/\//g, "-")}`;
|
|
55
|
+
return {
|
|
56
|
+
importPath: `${name}/${entry.subpath.replace(/^\.\//, "")}`,
|
|
57
|
+
file: `api/${slug}.md`,
|
|
58
|
+
sourceRel,
|
|
59
|
+
sourceFile: project.addSourceFileAtPath(join(repoRoot, sourceRel)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// An unresolved `export … from` silently drops its names from
|
|
64
|
+
// getExportedDeclarations(), which would shrink the docs without a signal.
|
|
65
|
+
function assertReExportsResolve(entryFiles: readonly SourceFile[]): void {
|
|
66
|
+
const pending = [...entryFiles];
|
|
67
|
+
const visited = new Set<SourceFile>(entryFiles);
|
|
68
|
+
for (let sourceFile = pending.pop(); sourceFile !== undefined; sourceFile = pending.pop()) {
|
|
69
|
+
for (const declaration of sourceFile.getExportDeclarations()) {
|
|
70
|
+
const specifier = declaration.getModuleSpecifierValue();
|
|
71
|
+
if (specifier === undefined) continue;
|
|
72
|
+
const target = declaration.getModuleSpecifierSourceFile();
|
|
73
|
+
if (target === undefined) {
|
|
74
|
+
throw new Error(`${sourceFile.getFilePath()}: cannot resolve re-export "${specifier}"`);
|
|
75
|
+
}
|
|
76
|
+
if (!visited.has(target)) {
|
|
77
|
+
visited.add(target);
|
|
78
|
+
pending.push(target);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The tmp dirs of tests and symlinked checkouts reach the same file under two
|
|
85
|
+
// paths; comparing real paths keeps the closure filter and module headers stable.
|
|
86
|
+
// ts-morph serves the TypeScript lib files from memory, so they have no real path.
|
|
87
|
+
function realFilePath(file: SourceFile): string {
|
|
88
|
+
const path = file.getFilePath();
|
|
89
|
+
return existsSync(path) ? realpathSync(path) : path;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type ExportGroup = "Functions" | "Constants" | "Classes" | "Types";
|
|
93
|
+
const EXPORT_GROUP_ORDER: readonly ExportGroup[] = ["Functions", "Constants", "Classes", "Types"];
|
|
94
|
+
|
|
95
|
+
function exportGroupOf(node: Node): ExportGroup | undefined {
|
|
96
|
+
if (Node.isFunctionDeclaration(node)) return "Functions";
|
|
97
|
+
if (Node.isVariableDeclaration(node)) {
|
|
98
|
+
const initializer = node.getInitializer();
|
|
99
|
+
const isFunction =
|
|
100
|
+
node.getTypeNode() === undefined &&
|
|
101
|
+
initializer !== undefined &&
|
|
102
|
+
(Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer));
|
|
103
|
+
return isFunction ? "Functions" : "Constants";
|
|
104
|
+
}
|
|
105
|
+
if (Node.isEnumDeclaration(node)) return "Constants";
|
|
106
|
+
if (Node.isClassDeclaration(node)) return "Classes";
|
|
107
|
+
if (Node.isInterfaceDeclaration(node) || Node.isTypeAliasDeclaration(node)) return "Types";
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type ExportedApi = { readonly name: string; readonly node: Node; readonly group: ExportGroup };
|
|
112
|
+
|
|
113
|
+
function publicExportsOf(sourceFile: SourceFile): readonly ExportedApi[] {
|
|
114
|
+
const found: ExportedApi[] = [];
|
|
115
|
+
for (const [name, declarations] of sourceFile.getExportedDeclarations()) {
|
|
116
|
+
for (const node of declarations) {
|
|
117
|
+
const group = exportGroupOf(node);
|
|
118
|
+
if (group !== undefined && !isInternalApi(node)) found.push({ name, node, group });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return found.sort((left, right) => compareCodepoint(left.name, right.name));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function codeBlock(lines: readonly string[]): string {
|
|
125
|
+
return `\`\`\`ts\n${lines.join("\n")}\n\`\`\``;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isInsideInternalMember(node: Node): boolean {
|
|
129
|
+
return node
|
|
130
|
+
.getAncestors()
|
|
131
|
+
.some((ancestor) => !isApiTypeDeclaration(ancestor) && isInternalApi(ancestor));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function referencedDeclarations(
|
|
135
|
+
node: Node,
|
|
136
|
+
isRepoSource: (file: SourceFile) => boolean,
|
|
137
|
+
): ApiTypeDeclaration[] {
|
|
138
|
+
const found: ApiTypeDeclaration[] = [];
|
|
139
|
+
for (const reference of referencedTypeNodes(node)) {
|
|
140
|
+
if (isInsideInternalMember(reference)) continue;
|
|
141
|
+
const nameNode = Node.isTypeReference(reference)
|
|
142
|
+
? reference.getTypeName()
|
|
143
|
+
: Node.isExpressionWithTypeArguments(reference)
|
|
144
|
+
? reference.getExpression()
|
|
145
|
+
: undefined;
|
|
146
|
+
const symbol = nameNode?.getSymbol();
|
|
147
|
+
const target = symbol?.getAliasedSymbol() ?? symbol;
|
|
148
|
+
for (const declaration of target?.getDeclarations() ?? []) {
|
|
149
|
+
if (
|
|
150
|
+
isApiTypeDeclaration(declaration) &&
|
|
151
|
+
!isInternalApi(declaration) &&
|
|
152
|
+
isRepoSource(declaration.getSourceFile())
|
|
153
|
+
) {
|
|
154
|
+
found.push(declaration);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function declarationName(node: ApiTypeDeclaration): string {
|
|
162
|
+
return node.getName() ?? "default";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function renderApiDocs(repoRoot: string, project: Project): ReadonlyMap<string, string> {
|
|
166
|
+
const realRoot = realpathSync(repoRoot);
|
|
167
|
+
const closureRoots = [
|
|
168
|
+
...new Set([...API_ENTRIES.map((entry) => entry.packageDir), TYPES_PACKAGE_DIR]),
|
|
169
|
+
].map((packageDir) => `${realRoot}/${packageDir}/`);
|
|
170
|
+
const isRepoSource = (file: SourceFile): boolean => {
|
|
171
|
+
const path = realFilePath(file);
|
|
172
|
+
return closureRoots.some((root) => path.startsWith(root)) && !path.includes("/node_modules/");
|
|
173
|
+
};
|
|
174
|
+
const entries = API_ENTRIES.map((entry) => resolveEntry(repoRoot, project, entry));
|
|
175
|
+
assertReExportsResolve(entries.map((entry) => entry.sourceFile));
|
|
176
|
+
|
|
177
|
+
const documentedIn = new Map<Node, string>();
|
|
178
|
+
const exportsByEntry = entries.map((entry) => publicExportsOf(entry.sourceFile));
|
|
179
|
+
entries.forEach((entry, index) => {
|
|
180
|
+
for (const exported of exportsByEntry[index] ?? []) {
|
|
181
|
+
if (!documentedIn.has(exported.node)) documentedIn.set(exported.node, entry.file);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const files = new Map<string, string>();
|
|
186
|
+
const pending: Node[] = [];
|
|
187
|
+
entries.forEach((entry, index) => {
|
|
188
|
+
const grouped = new Map<ExportGroup, string[]>(EXPORT_GROUP_ORDER.map((group) => [group, []]));
|
|
189
|
+
const elsewhere = new Map<string, string[]>();
|
|
190
|
+
for (const exported of exportsByEntry[index] ?? []) {
|
|
191
|
+
const owner = documentedIn.get(exported.node);
|
|
192
|
+
if (owner !== entry.file) {
|
|
193
|
+
const names = elsewhere.get(owner ?? "") ?? [];
|
|
194
|
+
names.push(`\`${exported.name}\``);
|
|
195
|
+
elsewhere.set(owner ?? "", names);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
grouped
|
|
199
|
+
.get(exported.group)
|
|
200
|
+
?.push(...(renderApiDeclaration(exported.name, exported.node) ?? []));
|
|
201
|
+
pending.push(exported.node);
|
|
202
|
+
}
|
|
203
|
+
const sections = EXPORT_GROUP_ORDER.flatMap((group) => {
|
|
204
|
+
const lines = grouped.get(group) ?? [];
|
|
205
|
+
return lines.length === 0 ? [] : [`## ${group}\n\n${codeBlock(lines)}`];
|
|
206
|
+
});
|
|
207
|
+
for (const [owner, names] of [...elsewhere].sort(([a], [b]) => compareCodepoint(a, b))) {
|
|
208
|
+
sections.push(`Also exported here, documented in \`${owner}\`: ${names.join(", ")}`);
|
|
209
|
+
}
|
|
210
|
+
const body = `# ${entry.importPath}\n\nImport from \`${entry.importPath}\`. Types used here but not exported by any entry point are in \`${TYPES_FILE}\`.\n\n${sections.join("\n\n")}`;
|
|
211
|
+
files.set(entry.file, finishFile(entry.sourceRel, body));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const referenced = new Set<ApiTypeDeclaration>();
|
|
215
|
+
while (pending.length > 0) {
|
|
216
|
+
const node = pending.pop();
|
|
217
|
+
if (node === undefined) break;
|
|
218
|
+
for (const declaration of referencedDeclarations(node, isRepoSource)) {
|
|
219
|
+
if (documentedIn.has(declaration) || referenced.has(declaration)) continue;
|
|
220
|
+
referenced.add(declaration);
|
|
221
|
+
pending.push(declaration);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
files.set(TYPES_FILE, renderReferencedTypes(repoRoot, referenced));
|
|
225
|
+
files.set(
|
|
226
|
+
"api.md",
|
|
227
|
+
renderApiIndex(
|
|
228
|
+
entries,
|
|
229
|
+
exportsByEntry.map((list) => list.length),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
return files;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderReferencedTypes(
|
|
236
|
+
repoRoot: string,
|
|
237
|
+
referenced: ReadonlySet<ApiTypeDeclaration>,
|
|
238
|
+
): string {
|
|
239
|
+
const root = realpathSync(repoRoot);
|
|
240
|
+
const byModule = new Map<string, ApiTypeDeclaration[]>();
|
|
241
|
+
for (const declaration of referenced) {
|
|
242
|
+
const moduleRel = relative(root, realFilePath(declaration.getSourceFile()))
|
|
243
|
+
.split(sep)
|
|
244
|
+
.join("/");
|
|
245
|
+
const list = byModule.get(moduleRel) ?? [];
|
|
246
|
+
list.push(declaration);
|
|
247
|
+
byModule.set(moduleRel, list);
|
|
248
|
+
}
|
|
249
|
+
const sections = [...byModule]
|
|
250
|
+
.sort(([left], [right]) => compareCodepoint(left, right))
|
|
251
|
+
.map(([moduleRel, declarations]) => {
|
|
252
|
+
const lines = declarations
|
|
253
|
+
.sort((left, right) => compareCodepoint(declarationName(left), declarationName(right)))
|
|
254
|
+
.flatMap(
|
|
255
|
+
(declaration) => renderApiDeclaration(declarationName(declaration), declaration) ?? [],
|
|
256
|
+
);
|
|
257
|
+
return `## ${moduleRel}\n\n${codeBlock(lines)}`;
|
|
258
|
+
});
|
|
259
|
+
const intro =
|
|
260
|
+
"Types that appear in the signatures of the entry points but are not exported by any of them. Use them to read option shapes; import only what an entry point exports.";
|
|
261
|
+
return finishFile(
|
|
262
|
+
"the API entry points",
|
|
263
|
+
`# Referenced types\n\n${intro}\n\n${sections.join("\n\n")}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function renderApiIndex(
|
|
268
|
+
entries: readonly ResolvedEntry[],
|
|
269
|
+
exportCounts: readonly number[],
|
|
270
|
+
): string {
|
|
271
|
+
const rows = entries.map((entry, index) => [
|
|
272
|
+
`\`${entry.importPath}\``,
|
|
273
|
+
`\`${entry.file}\``,
|
|
274
|
+
String(exportCounts[index] ?? 0),
|
|
275
|
+
]);
|
|
276
|
+
const intro = [
|
|
277
|
+
"Public signatures of the entry points an app imports, generated from their sources. Read these instead of `dist/*.d.ts`.",
|
|
278
|
+
"",
|
|
279
|
+
"Format: one line per function or constant, object and options types as property lists (`name?: Type;`), and the first sentence of the doc comment after `//`. Bodies and `@internal` declarations are left out. A type that an entry point uses but does not export is listed in `api/types.md`.",
|
|
280
|
+
].join("\n");
|
|
281
|
+
return finishFile(
|
|
282
|
+
"package.json exports of the listed entry points",
|
|
283
|
+
`# API reference\n\n${intro}\n\n${table(["Import path", "File", "Exports"], rows)}`,
|
|
284
|
+
);
|
|
285
|
+
}
|