@cosmicdrift/kumiko-guards 0.397.0 → 0.399.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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type FunctionDeclaration,
|
|
5
|
+
Node,
|
|
6
|
+
type Project,
|
|
7
|
+
type SourceFile,
|
|
8
|
+
SyntaxKind,
|
|
9
|
+
} from "ts-morph";
|
|
10
|
+
import { exportedFunctions, importedVariable } from "./agent-docs-module-walk";
|
|
11
|
+
import { compareCodepoint, isRecord, parseJsonFile } from "./agent-docs-shared";
|
|
12
|
+
|
|
13
|
+
const PACKAGES_DIR = "packages";
|
|
14
|
+
const FEATURE_FACTORY_RE = /^create\w*Feature$/;
|
|
15
|
+
const SOURCE_FILE_RE = /\.tsx?$/;
|
|
16
|
+
const TEST_PATH_RE = /(__tests__|\.test\.tsx?$|\.d\.ts$)/;
|
|
17
|
+
|
|
18
|
+
export type FeatureImport =
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: "resolved";
|
|
21
|
+
readonly importPath: string;
|
|
22
|
+
readonly exportNames: readonly string[];
|
|
23
|
+
readonly call?: string;
|
|
24
|
+
}
|
|
25
|
+
| { readonly kind: "unresolved" };
|
|
26
|
+
|
|
27
|
+
type FeatureDefinitionSite = {
|
|
28
|
+
readonly packageDir: string;
|
|
29
|
+
readonly srcSegment: string;
|
|
30
|
+
readonly factoryName: string | undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function sourceFilesUnder(dir: string): string[] {
|
|
34
|
+
if (!existsSync(dir)) return [];
|
|
35
|
+
return readdirSync(dir, { recursive: true, encoding: "utf-8" })
|
|
36
|
+
.filter((rel) => SOURCE_FILE_RE.test(rel) && !TEST_PATH_RE.test(rel))
|
|
37
|
+
.map((rel) => join(dir, rel));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stringValue(project: Project, node: Node | undefined): string | undefined {
|
|
41
|
+
if (node === undefined) return undefined;
|
|
42
|
+
if (Node.isStringLiteral(node)) return node.getLiteralValue();
|
|
43
|
+
if (Node.isAsExpression(node)) return stringValue(project, node.getExpression());
|
|
44
|
+
if (!Node.isIdentifier(node)) return undefined;
|
|
45
|
+
const file = node.getSourceFile();
|
|
46
|
+
const name = node.getText();
|
|
47
|
+
const declaration = file.getVariableDeclaration(name) ?? importedVariable(project, file, name);
|
|
48
|
+
return stringValue(project, declaration?.getInitializer());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isTopLevelVariable(node: Node): boolean {
|
|
52
|
+
return (
|
|
53
|
+
Node.isVariableDeclaration(node) && Node.isSourceFile(node.getVariableStatement()?.getParent())
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The factory is the enclosing function, or a module-level constant; a local
|
|
58
|
+
// `const feature = defineFeature(…)` inside a factory is not one.
|
|
59
|
+
function enclosingFactoryName(node: Node): string | undefined {
|
|
60
|
+
const owner =
|
|
61
|
+
node.getFirstAncestor(Node.isFunctionDeclaration) ?? node.getFirstAncestor(isTopLevelVariable);
|
|
62
|
+
return Node.isFunctionDeclaration(owner) || Node.isVariableDeclaration(owner)
|
|
63
|
+
? owner.getName()
|
|
64
|
+
: undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Where each feature name is passed to defineFeature in a package source. A
|
|
68
|
+
// name defined only outside packages/ (a sample app) has no import path.
|
|
69
|
+
function featureDefinitionSites(
|
|
70
|
+
repoRoot: string,
|
|
71
|
+
project: Project,
|
|
72
|
+
): ReadonlyMap<string, FeatureDefinitionSite[]> {
|
|
73
|
+
const sites = new Map<string, FeatureDefinitionSite[]>();
|
|
74
|
+
for (const packageName of readdirSync(join(repoRoot, PACKAGES_DIR)).sort(compareCodepoint)) {
|
|
75
|
+
const packageDir = `${PACKAGES_DIR}/${packageName}`;
|
|
76
|
+
const srcDir = join(repoRoot, packageDir, "src");
|
|
77
|
+
for (const path of sourceFilesUnder(srcDir)) {
|
|
78
|
+
if (!readFileSync(path, "utf-8").includes("defineFeature(")) continue;
|
|
79
|
+
const calls = project
|
|
80
|
+
.addSourceFileAtPath(path)
|
|
81
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
82
|
+
.filter((call) => call.getExpression().getText() === "defineFeature");
|
|
83
|
+
for (const call of calls) {
|
|
84
|
+
const name = stringValue(project, call.getArguments()[0]);
|
|
85
|
+
if (name === undefined) continue;
|
|
86
|
+
const srcSegment = relative(srcDir, path).split(sep)[0] ?? "";
|
|
87
|
+
const list = sites.get(name) ?? [];
|
|
88
|
+
list.push({ packageDir, srcSegment, factoryName: enclosingFactoryName(call) });
|
|
89
|
+
sites.set(name, list);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return sites;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type ExportEntry = { readonly importPath: string; readonly file: SourceFile };
|
|
97
|
+
|
|
98
|
+
function exportEntries(repoRoot: string, project: Project, site: FeatureDefinitionSite) {
|
|
99
|
+
const rel = `${site.packageDir}/package.json`;
|
|
100
|
+
const parsed = parseJsonFile(rel, readFileSync(join(repoRoot, rel), "utf-8"));
|
|
101
|
+
const name = isRecord(parsed) ? parsed["name"] : undefined;
|
|
102
|
+
const exportsField = isRecord(parsed) ? parsed["exports"] : undefined;
|
|
103
|
+
if (typeof name !== "string" || !isRecord(exportsField)) return [];
|
|
104
|
+
const entries: ExportEntry[] = [];
|
|
105
|
+
for (const subpath of [`./${site.srcSegment.replace(SOURCE_FILE_RE, "")}`, "."]) {
|
|
106
|
+
const target = exportsField[subpath];
|
|
107
|
+
const path = isRecord(target) ? target["types"] : target;
|
|
108
|
+
if (typeof path !== "string" || !SOURCE_FILE_RE.test(path)) continue;
|
|
109
|
+
entries.push({
|
|
110
|
+
importPath: subpath === "." ? name : `${name}/${subpath.slice(2)}`,
|
|
111
|
+
file: project.addSourceFileAtPath(join(repoRoot, site.packageDir, path)),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return entries;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// `options.<flag> === true ? <factory>() : …` inside an exported factory means
|
|
118
|
+
// that factory with `{ <flag>: true }` builds the feature.
|
|
119
|
+
function flagCall(name: string, fn: FunctionDeclaration, factoryName: string): string | undefined {
|
|
120
|
+
const body = (fn.getImplementation() ?? fn).getBody();
|
|
121
|
+
for (const conditional of body?.getDescendantsOfKind(SyntaxKind.ConditionalExpression) ?? []) {
|
|
122
|
+
const whenTrue = conditional.getWhenTrue();
|
|
123
|
+
const condition = conditional.getCondition();
|
|
124
|
+
if (!Node.isCallExpression(whenTrue) || whenTrue.getExpression().getText() !== factoryName) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!Node.isBinaryExpression(condition) || condition.getRight().getText() !== "true") continue;
|
|
128
|
+
const flag = condition.getLeft();
|
|
129
|
+
if (Node.isPropertyAccessExpression(flag)) return `${name}({ ${flag.getName()}: true })`;
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveAt(
|
|
135
|
+
project: Project,
|
|
136
|
+
entry: ExportEntry,
|
|
137
|
+
factoryName: string | undefined,
|
|
138
|
+
): FeatureImport | undefined {
|
|
139
|
+
const functions = exportedFunctions(project, entry.file);
|
|
140
|
+
const importPath = entry.importPath;
|
|
141
|
+
if (factoryName !== undefined && functions.has(factoryName)) {
|
|
142
|
+
return { kind: "resolved", importPath, exportNames: [factoryName] };
|
|
143
|
+
}
|
|
144
|
+
const factories = [...functions.keys()]
|
|
145
|
+
.filter((name) => FEATURE_FACTORY_RE.test(name))
|
|
146
|
+
.sort(compareCodepoint);
|
|
147
|
+
for (const name of factories) {
|
|
148
|
+
const fn = functions.get(name);
|
|
149
|
+
const call =
|
|
150
|
+
fn === undefined || factoryName === undefined ? undefined : flagCall(name, fn, factoryName);
|
|
151
|
+
if (call !== undefined) return { kind: "resolved", importPath, exportNames: [name], call };
|
|
152
|
+
}
|
|
153
|
+
return factories.length === 0
|
|
154
|
+
? undefined
|
|
155
|
+
: { kind: "resolved", importPath, exportNames: factories };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// undefined: the feature is not defined in a package, so an app cannot import it.
|
|
159
|
+
export function createFeatureImportResolver(
|
|
160
|
+
repoRoot: string,
|
|
161
|
+
project: Project,
|
|
162
|
+
): (featureName: string) => FeatureImport | undefined {
|
|
163
|
+
const sites = featureDefinitionSites(repoRoot, project);
|
|
164
|
+
return (featureName) => {
|
|
165
|
+
const candidates = sites.get(featureName);
|
|
166
|
+
if (candidates === undefined) return undefined;
|
|
167
|
+
for (const site of candidates) {
|
|
168
|
+
for (const entry of exportEntries(repoRoot, project, site)) {
|
|
169
|
+
const resolved = resolveAt(project, entry, site.factoryName);
|
|
170
|
+
if (resolved !== undefined) return resolved;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { kind: "unresolved" };
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Project } from "ts-morph";
|
|
4
|
+
import { createFeatureImportResolver, type FeatureImport } from "./agent-docs-feature-imports";
|
|
5
|
+
import {
|
|
6
|
+
codeList,
|
|
7
|
+
compareCodepoint,
|
|
8
|
+
finishFile,
|
|
9
|
+
firstSentence,
|
|
10
|
+
isRecord,
|
|
11
|
+
parseJsonFile,
|
|
12
|
+
table,
|
|
13
|
+
} from "./agent-docs-shared";
|
|
14
|
+
|
|
15
|
+
const FEATURE_MANIFEST_REL = "samples/apps/use-all-bundled/feature-manifest.json";
|
|
16
|
+
|
|
17
|
+
type ManifestConfigKeyDoc = {
|
|
18
|
+
readonly key: string;
|
|
19
|
+
readonly type: string;
|
|
20
|
+
readonly scope: string;
|
|
21
|
+
readonly default: string | number | boolean | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type ManifestSecretDoc = {
|
|
25
|
+
readonly qualifiedName: string;
|
|
26
|
+
readonly scope: string;
|
|
27
|
+
readonly label: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type ManifestFeatureDoc = {
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly description: string | null;
|
|
33
|
+
readonly category: string | null;
|
|
34
|
+
readonly requires: readonly string[];
|
|
35
|
+
readonly optionalRequires: readonly string[];
|
|
36
|
+
readonly exposesApis: readonly string[];
|
|
37
|
+
readonly usesApis: readonly string[];
|
|
38
|
+
readonly configKeys: readonly ManifestConfigKeyDoc[];
|
|
39
|
+
readonly secrets: readonly ManifestSecretDoc[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function isStringArray(value: unknown): value is readonly string[] {
|
|
43
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isNullableString(value: unknown): value is string | null {
|
|
47
|
+
return value === null || typeof value === "string";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isConfigKeyDoc(value: unknown): value is ManifestConfigKeyDoc {
|
|
51
|
+
if (!isRecord(value)) return false;
|
|
52
|
+
const fallback = value["default"];
|
|
53
|
+
return (
|
|
54
|
+
typeof value["key"] === "string" &&
|
|
55
|
+
typeof value["type"] === "string" &&
|
|
56
|
+
typeof value["scope"] === "string" &&
|
|
57
|
+
(fallback === null ||
|
|
58
|
+
typeof fallback === "string" ||
|
|
59
|
+
typeof fallback === "number" ||
|
|
60
|
+
typeof fallback === "boolean")
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isSecretDoc(value: unknown): value is ManifestSecretDoc {
|
|
65
|
+
return (
|
|
66
|
+
isRecord(value) &&
|
|
67
|
+
typeof value["qualifiedName"] === "string" &&
|
|
68
|
+
typeof value["scope"] === "string" &&
|
|
69
|
+
isNullableString(value["label"])
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseFeatureDoc(value: unknown): ManifestFeatureDoc | undefined {
|
|
74
|
+
if (!isRecord(value) || typeof value["name"] !== "string") return undefined;
|
|
75
|
+
const description = value["description"];
|
|
76
|
+
const configKeys = value["configKeys"];
|
|
77
|
+
const secrets = value["secrets"];
|
|
78
|
+
const uiHints = value["uiHints"];
|
|
79
|
+
const category = isRecord(uiHints) ? uiHints["category"] : undefined;
|
|
80
|
+
if (
|
|
81
|
+
!isNullableString(description) ||
|
|
82
|
+
!isStringArray(value["requires"]) ||
|
|
83
|
+
!isStringArray(value["optionalRequires"]) ||
|
|
84
|
+
!isStringArray(value["exposesApis"]) ||
|
|
85
|
+
!isStringArray(value["usesApis"]) ||
|
|
86
|
+
!Array.isArray(configKeys) ||
|
|
87
|
+
!configKeys.every(isConfigKeyDoc) ||
|
|
88
|
+
!Array.isArray(secrets) ||
|
|
89
|
+
!secrets.every(isSecretDoc)
|
|
90
|
+
) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
name: value["name"],
|
|
95
|
+
description,
|
|
96
|
+
category: typeof category === "string" ? category : null,
|
|
97
|
+
requires: value["requires"],
|
|
98
|
+
optionalRequires: value["optionalRequires"],
|
|
99
|
+
exposesApis: value["exposesApis"],
|
|
100
|
+
usesApis: value["usesApis"],
|
|
101
|
+
configKeys,
|
|
102
|
+
secrets,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readFeatureManifest(repoRoot: string): readonly ManifestFeatureDoc[] {
|
|
107
|
+
const raw = readFileSync(join(repoRoot, FEATURE_MANIFEST_REL), "utf-8");
|
|
108
|
+
const parsed = parseJsonFile(FEATURE_MANIFEST_REL, raw);
|
|
109
|
+
const features = isRecord(parsed) ? parsed["features"] : undefined;
|
|
110
|
+
if (!Array.isArray(features)) {
|
|
111
|
+
throw new Error(`${FEATURE_MANIFEST_REL}: "features" must be an array`);
|
|
112
|
+
}
|
|
113
|
+
const docs = features.map((entry, index) => {
|
|
114
|
+
const feature = parseFeatureDoc(entry);
|
|
115
|
+
if (feature === undefined) {
|
|
116
|
+
throw new Error(`${FEATURE_MANIFEST_REL}: features[${index}] has an unexpected shape`);
|
|
117
|
+
}
|
|
118
|
+
return feature;
|
|
119
|
+
});
|
|
120
|
+
return docs.sort((left, right) => compareCodepoint(left.name, right.name));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderFeatureSection(feature: ManifestFeatureDoc): string {
|
|
124
|
+
const parts = [`## ${feature.name}`];
|
|
125
|
+
if (feature.description !== null) parts.push(feature.description.replace(/\s+/g, " ").trim());
|
|
126
|
+
if (feature.category !== null) parts.push(`Category: ${feature.category}`);
|
|
127
|
+
if (feature.requires.length > 0) parts.push(`Requires: ${codeList(feature.requires)}`);
|
|
128
|
+
if (feature.optionalRequires.length > 0) {
|
|
129
|
+
parts.push(`Optional requires: ${codeList(feature.optionalRequires)}`);
|
|
130
|
+
}
|
|
131
|
+
if (feature.configKeys.length > 0) {
|
|
132
|
+
const rows = feature.configKeys.map((configKey) => [
|
|
133
|
+
`\`${configKey.key}\``,
|
|
134
|
+
configKey.type,
|
|
135
|
+
configKey.scope,
|
|
136
|
+
configKey.default === null ? "" : `\`${String(configKey.default)}\``,
|
|
137
|
+
]);
|
|
138
|
+
parts.push(`Config keys:\n\n${table(["Key", "Type", "Scope", "Default"], rows)}`);
|
|
139
|
+
}
|
|
140
|
+
if (feature.secrets.length > 0) {
|
|
141
|
+
const rows = feature.secrets.map((secret) => [
|
|
142
|
+
`\`${secret.qualifiedName}\``,
|
|
143
|
+
secret.scope,
|
|
144
|
+
secret.label ?? "",
|
|
145
|
+
]);
|
|
146
|
+
parts.push(`Secrets:\n\n${table(["Secret", "Scope", "Label"], rows)}`);
|
|
147
|
+
}
|
|
148
|
+
if (feature.exposesApis.length > 0) parts.push(`Exposes APIs: ${codeList(feature.exposesApis)}`);
|
|
149
|
+
if (feature.usesApis.length > 0) parts.push(`Uses APIs: ${codeList(feature.usesApis)}`);
|
|
150
|
+
return parts.join("\n\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type FeatureDocs = {
|
|
154
|
+
readonly features: string;
|
|
155
|
+
readonly featuresIndex: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
function importLine(found: FeatureImport): string {
|
|
159
|
+
if (found.kind === "unresolved") return "Import: no factory found in the package exports";
|
|
160
|
+
const statement = `\`import { ${found.exportNames.join(", ")} } from "${found.importPath}"\``;
|
|
161
|
+
return found.call === undefined ? statement : `${statement}, call \`${found.call}\``;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderIndexEntry(feature: ManifestFeatureDoc, found: FeatureImport): string {
|
|
165
|
+
const purpose = feature.description === null ? "" : ` ${firstSentence(feature.description)}`;
|
|
166
|
+
const details = [importLine(found)];
|
|
167
|
+
if (feature.requires.length > 0) details.push(`requires ${codeList(feature.requires)}`);
|
|
168
|
+
const unset = feature.configKeys.filter((configKey) => configKey.default === null);
|
|
169
|
+
if (unset.length > 0) {
|
|
170
|
+
details.push(`config without default ${codeList(unset.map((configKey) => configKey.key))}`);
|
|
171
|
+
}
|
|
172
|
+
if (feature.secrets.length > 0) {
|
|
173
|
+
details.push(`secrets ${codeList(feature.secrets.map((secret) => secret.qualifiedName))}`);
|
|
174
|
+
}
|
|
175
|
+
return `- \`${feature.name}\`:${purpose}\n ${details.join("; ")}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const INDEX_INTRO =
|
|
179
|
+
"One entry per feature a package ships: purpose, import of its factory, required features, config keys without a default and secrets. Factory options: `api/` or the feature's types. Details: `features.md`.";
|
|
180
|
+
|
|
181
|
+
export function renderFeatureDocs(repoRoot: string, project: Project): FeatureDocs {
|
|
182
|
+
const manifest = readFeatureManifest(repoRoot);
|
|
183
|
+
const sections = manifest.map(renderFeatureSection);
|
|
184
|
+
const resolveImport = createFeatureImportResolver(repoRoot, project);
|
|
185
|
+
const entries = manifest.flatMap((feature) => {
|
|
186
|
+
const found = resolveImport(feature.name);
|
|
187
|
+
return found === undefined ? [] : [renderIndexEntry(feature, found)];
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
features: finishFile(FEATURE_MANIFEST_REL, `# Bundled features\n\n${sections.join("\n\n")}`),
|
|
191
|
+
featuresIndex: finishFile(
|
|
192
|
+
FEATURE_MANIFEST_REL,
|
|
193
|
+
`# Bundled features index\n\n${INDEX_INTRO}\n\n${entries.join("\n")}`,
|
|
194
|
+
),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
ExportDeclaration,
|
|
5
|
+
FunctionDeclaration,
|
|
6
|
+
ImportDeclaration,
|
|
7
|
+
Project,
|
|
8
|
+
SourceFile,
|
|
9
|
+
VariableDeclaration,
|
|
10
|
+
} from "ts-morph";
|
|
11
|
+
|
|
12
|
+
// Syntactic module walk over relative specifiers. Asking ts-morph to resolve a
|
|
13
|
+
// specifier rebuilds the program after every added file, which made one
|
|
14
|
+
// generator run take seconds per package; package specifiers stay unresolved.
|
|
15
|
+
export function relativeModuleFile(
|
|
16
|
+
project: Project,
|
|
17
|
+
declaration: ImportDeclaration | ExportDeclaration,
|
|
18
|
+
): SourceFile | undefined {
|
|
19
|
+
const specifier = declaration.getModuleSpecifierValue();
|
|
20
|
+
if (specifier === undefined || !specifier.startsWith(".")) return undefined;
|
|
21
|
+
const base = join(dirname(declaration.getSourceFile().getFilePath()), specifier);
|
|
22
|
+
const withoutJs = base.replace(/\.js$/, "");
|
|
23
|
+
const candidates = [base, `${withoutJs}.ts`, `${withoutJs}.tsx`, join(base, "index.ts")];
|
|
24
|
+
const path = candidates.find((candidate) => /\.tsx?$/.test(candidate) && existsSync(candidate));
|
|
25
|
+
if (path === undefined) return undefined;
|
|
26
|
+
return project.getSourceFile(path) ?? project.addSourceFileAtPath(path);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function reExportTargets(
|
|
30
|
+
project: Project,
|
|
31
|
+
file: SourceFile,
|
|
32
|
+
name: string,
|
|
33
|
+
): { target: SourceFile; name: string }[] {
|
|
34
|
+
const targets: { target: SourceFile; name: string }[] = [];
|
|
35
|
+
for (const declaration of file.getExportDeclarations()) {
|
|
36
|
+
const target = relativeModuleFile(project, declaration);
|
|
37
|
+
if (declaration.isTypeOnly() || target === undefined) continue;
|
|
38
|
+
const named = declaration.getNamedExports();
|
|
39
|
+
if (named.length === 0) targets.push({ target, name });
|
|
40
|
+
for (const spec of named) {
|
|
41
|
+
const exported = (spec.getAliasNode() ?? spec.getNameNode()).getText();
|
|
42
|
+
if (exported === name) targets.push({ target, name: spec.getName() });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return targets;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function exportedVariable(
|
|
49
|
+
project: Project,
|
|
50
|
+
file: SourceFile,
|
|
51
|
+
name: string,
|
|
52
|
+
seen = new Set<SourceFile>(),
|
|
53
|
+
): VariableDeclaration | undefined {
|
|
54
|
+
seen.add(file);
|
|
55
|
+
const local = file.getVariableDeclaration(name);
|
|
56
|
+
if (local !== undefined) return local;
|
|
57
|
+
for (const hop of reExportTargets(project, file, name)) {
|
|
58
|
+
if (seen.has(hop.target)) continue;
|
|
59
|
+
const found = exportedVariable(project, hop.target, hop.name, seen);
|
|
60
|
+
if (found !== undefined) return found;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function importedVariable(
|
|
66
|
+
project: Project,
|
|
67
|
+
file: SourceFile,
|
|
68
|
+
name: string,
|
|
69
|
+
): VariableDeclaration | undefined {
|
|
70
|
+
const declaration = file
|
|
71
|
+
.getImportDeclarations()
|
|
72
|
+
.find((candidate) => candidate.getNamedImports().some((spec) => spec.getName() === name));
|
|
73
|
+
const spec = declaration?.getNamedImports().find((candidate) => candidate.getName() === name);
|
|
74
|
+
const target = declaration === undefined ? undefined : relativeModuleFile(project, declaration);
|
|
75
|
+
if (spec === undefined || target === undefined) return undefined;
|
|
76
|
+
const original = spec.getNameNode().getText();
|
|
77
|
+
return exportedVariable(project, target, original);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function exportedFunctions(
|
|
81
|
+
project: Project,
|
|
82
|
+
file: SourceFile,
|
|
83
|
+
seen = new Set<SourceFile>(),
|
|
84
|
+
): Map<string, FunctionDeclaration> {
|
|
85
|
+
seen.add(file);
|
|
86
|
+
const found = new Map<string, FunctionDeclaration>();
|
|
87
|
+
for (const fn of file.getFunctions()) {
|
|
88
|
+
const name = fn.getName();
|
|
89
|
+
if (fn.isExported() && name !== undefined) found.set(name, fn);
|
|
90
|
+
}
|
|
91
|
+
for (const declaration of file.getExportDeclarations()) {
|
|
92
|
+
const target = relativeModuleFile(project, declaration);
|
|
93
|
+
if (declaration.isTypeOnly() || target === undefined || seen.has(target)) continue;
|
|
94
|
+
const targetFunctions = exportedFunctions(project, target, seen);
|
|
95
|
+
const named = declaration.getNamedExports();
|
|
96
|
+
if (named.length === 0) for (const [name, fn] of targetFunctions) found.set(name, fn);
|
|
97
|
+
for (const spec of named) {
|
|
98
|
+
const fn = targetFunctions.get(spec.getName());
|
|
99
|
+
if (fn !== undefined) found.set((spec.getAliasNode() ?? spec.getNameNode()).getText(), fn);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Project, ts } from "ts-morph";
|
|
4
|
+
|
|
5
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseJsonFile(relPath: string, raw: string): unknown {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(raw);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
throw new Error(`${relPath}: invalid JSON`, { cause: error });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function compareCodepoint(left: string, right: string): number {
|
|
18
|
+
if (left < right) return -1;
|
|
19
|
+
return left > right ? 1 : 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function generatedHeader(source: string): string {
|
|
23
|
+
return `<!-- Generated by \`bun run gen:agent-docs\` from ${source}. Do not edit. -->`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function finishFile(source: string, body: string): string {
|
|
27
|
+
const normalized = body.replace(/\r\n/g, "\n").trim();
|
|
28
|
+
return `${generatedHeader(source)}\n\n${normalized}\n`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function escapeCell(text: string): string {
|
|
32
|
+
return text.replace(/\s+/g, " ").trim().replace(/\|/g, "\\|");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function table(headers: readonly string[], rows: readonly (readonly string[])[]): string {
|
|
36
|
+
const lines = [
|
|
37
|
+
`| ${headers.join(" | ")} |`,
|
|
38
|
+
`| ${headers.map(() => "---").join(" | ")} |`,
|
|
39
|
+
...rows.map((row) => `| ${row.map(escapeCell).join(" | ")} |`),
|
|
40
|
+
];
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function codeList(values: readonly string[]): string {
|
|
45
|
+
return values.map((value) => `\`${value}\``).join(", ");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const FIRST_SENTENCE_RE = /^(.+?(?<!\be\.g|\bi\.e)[.!?])(?:\s|$)/;
|
|
49
|
+
|
|
50
|
+
export function firstSentence(text: string): string {
|
|
51
|
+
const flattened = text.replace(/\s+/g, " ").trim();
|
|
52
|
+
return FIRST_SENTENCE_RE.exec(flattened)?.[1] ?? flattened;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const FALLBACK_COMPILER_OPTIONS: ts.CompilerOptions = {
|
|
56
|
+
module: ts.ModuleKind.ESNext,
|
|
57
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
58
|
+
target: ts.ScriptTarget.ES2022,
|
|
59
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
60
|
+
allowImportingTsExtensions: true,
|
|
61
|
+
noEmit: true,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// The repo tsconfig carries the workspace `paths`, so re-exports from sibling
|
|
65
|
+
// packages resolve to their sources instead of a published dist.
|
|
66
|
+
export function createAgentDocsProject(repoRoot: string): Project {
|
|
67
|
+
const tsConfigFilePath = join(repoRoot, "tsconfig.json");
|
|
68
|
+
if (!existsSync(tsConfigFilePath)) {
|
|
69
|
+
return new Project({ compilerOptions: FALLBACK_COMPILER_OPTIONS });
|
|
70
|
+
}
|
|
71
|
+
return new Project({
|
|
72
|
+
tsConfigFilePath,
|
|
73
|
+
skipAddingFilesFromTsConfig: true,
|
|
74
|
+
compilerOptions: { jsx: ts.JsxEmit.ReactJSX, noEmit: true },
|
|
75
|
+
});
|
|
76
|
+
}
|