@defold-typescript/library-types 0.20.7 → 0.21.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.
@@ -0,0 +1,303 @@
1
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { emitLibraryDeclarations } from "./emit-library-dts";
4
+ import { lowerLibraryModel } from "./lower-api-doc";
5
+ import { buildFidelityReport, type FidelityReport } from "./luals-fidelity";
6
+ import { type LibraryModel, mergeLibraryModels, parseLualsSource } from "./parse-luals";
7
+
8
+ /**
9
+ * The LuaLS ingestion front-end pins its source per-entry: a druid-style library
10
+ * ships no `.d.ts`, only inline LuaLS `---@` annotations, and each such library
11
+ * lives in its own repo at its own tag. So every target carries its own
12
+ * `repo`/`ref`, unlike the ts-defold front-end's single shared `source`.
13
+ */
14
+ export interface LualsTarget {
15
+ repo: string;
16
+ ref: string;
17
+ sourceGlobs: string[];
18
+ moduleId: string;
19
+ namespace: string;
20
+ typeRenames: Record<string, string>;
21
+ ignore: string[];
22
+ // SPDX-style license id, surfaced by the docs-site provenance block. Optional
23
+ // in the config; the docs-site defaults an absent value to "".
24
+ license?: string;
25
+ }
26
+
27
+ export interface LualsTargets {
28
+ targets: LualsTarget[];
29
+ }
30
+
31
+ const REQUIRED_FIELDS = ["repo", "ref", "sourceGlobs", "moduleId", "namespace"] as const;
32
+
33
+ /**
34
+ * Read `luals-targets.json`, validate every required field per entry, and fill
35
+ * optional defaults (`typeRenames` → `{}`, `ignore` → `[]`). Throws on the first
36
+ * missing field naming both the field and the offending entry (its `moduleId`,
37
+ * or its index when `moduleId` itself is absent) — the loud-fail discipline the
38
+ * ts-defold `regenerate` uses for unmapped references. No network.
39
+ */
40
+ export function readLualsTargets(packageRoot: string): LualsTarget[] {
41
+ const parsed = JSON.parse(readFileSync(join(packageRoot, "luals-targets.json"), "utf8")) as {
42
+ targets: Partial<LualsTarget>[];
43
+ };
44
+ return parsed.targets.map((entry, index) => {
45
+ const label = typeof entry.moduleId === "string" ? entry.moduleId : `index ${index}`;
46
+ for (const field of REQUIRED_FIELDS) {
47
+ if (entry[field] === undefined) {
48
+ throw new Error(`luals-targets.json: entry ${label} is missing required field "${field}".`);
49
+ }
50
+ }
51
+ return {
52
+ repo: entry.repo as string,
53
+ ref: entry.ref as string,
54
+ sourceGlobs: entry.sourceGlobs as string[],
55
+ moduleId: entry.moduleId as string,
56
+ namespace: entry.namespace as string,
57
+ typeRenames: entry.typeRenames ?? {},
58
+ ignore: entry.ignore ?? [],
59
+ ...(entry.license !== undefined ? { license: entry.license } : {}),
60
+ };
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Compile a glob to an anchored RegExp — mirrors `globToRegex` in
66
+ * `packages/cli/src/build-output.ts` rather than importing across packages
67
+ * (library-types must not depend on the cli package). A `**` path segment spans
68
+ * any number of segments, a bare `**` spans the rest, `*` a non-slash run, `?`
69
+ * one non-slash.
70
+ */
71
+ function globToRegex(pattern: string): RegExp {
72
+ let out = "";
73
+ for (let i = 0; i < pattern.length; i++) {
74
+ const c = pattern[i];
75
+ if (c === "*") {
76
+ if (pattern[i + 1] === "*") {
77
+ i++;
78
+ if (pattern[i + 1] === "/") {
79
+ i++;
80
+ out += "(?:[^/]+/)*";
81
+ } else {
82
+ out += ".*";
83
+ }
84
+ } else {
85
+ out += "[^/]*";
86
+ }
87
+ } else if (c === "?") {
88
+ out += "[^/]";
89
+ } else {
90
+ out += (c as string).replace(/[.+^$(){}|[\]\\]/g, "\\$&");
91
+ }
92
+ }
93
+ return new RegExp(`^${out}$`);
94
+ }
95
+
96
+ /**
97
+ * A path is selected iff at least one `sourceGlob` matches and no `ignore` glob
98
+ * matches. Returns the sorted, deduped subset — the fixture set the vendor step
99
+ * snapshots.
100
+ */
101
+ export function selectLualsSources(
102
+ paths: string[],
103
+ target: { sourceGlobs: string[]; ignore: string[] },
104
+ ): string[] {
105
+ const includes = target.sourceGlobs.map(globToRegex);
106
+ const excludes = target.ignore.map(globToRegex);
107
+ const selected = paths.filter(
108
+ (p) => includes.some((re) => re.test(p)) && !excludes.some((re) => re.test(p)),
109
+ );
110
+ return [...new Set(selected)].sort();
111
+ }
112
+
113
+ /** Enumerate the pinned tree of a LuaLS library repo at its ref. Network seam. */
114
+ export type ListLualsTree = (repo: string, ref: string) => Promise<string[]>;
115
+
116
+ /** Fetch the raw text at a URL. Network seam — mirrors `sync-library-types.ts`. */
117
+ export type FetchText = (url: string) => Promise<string>;
118
+
119
+ /** Recursively enumerate a fixture directory's entries. Filesystem seam. */
120
+ export type ReadFixtureDir = (root: string) => string[];
121
+
122
+ /**
123
+ * A GitHub repo URL reduced to the bare `<owner>/<repo>` slug used to address
124
+ * raw content. Mirrors `repoSlug` in the ts-defold front-end.
125
+ */
126
+ function repoSlug(repo: string): string {
127
+ return repo
128
+ .replace(/^https:\/\/github\.com\//, "")
129
+ .replace(/\.git$/, "")
130
+ .replace(/\/$/, "");
131
+ }
132
+
133
+ function rawUrl(target: LualsTarget, path: string): string {
134
+ return `https://raw.githubusercontent.com/${repoSlug(target.repo)}/${target.ref}/${path}`;
135
+ }
136
+
137
+ /**
138
+ * List the pinned tree, select the matching sources, fetch each via raw-content
139
+ * URL, and write it under `fixtures/luals/<namespace>/<relpath>` preserving tree
140
+ * shape. Snapshot only — no codemod. The `listTree`/`fetchText` seams keep the
141
+ * pass offline-testable; only the CLI `--fetch` arm wires the real network.
142
+ */
143
+ export async function fetchLualsFixtures(
144
+ packageRoot: string,
145
+ target: LualsTarget,
146
+ seams: { listTree: ListLualsTree; fetchText: FetchText },
147
+ ): Promise<void> {
148
+ const paths = selectLualsSources(await seams.listTree(target.repo, target.ref), target);
149
+ for (const path of paths) {
150
+ const text = await seams.fetchText(rawUrl(target, path));
151
+ const dest = join(packageRoot, "fixtures/luals", target.namespace, path);
152
+ mkdirSync(dirname(dest), { recursive: true });
153
+ writeFileSync(dest, text);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Parse and merge a target's committed fixtures, then build its fidelity report.
159
+ * Reads only `fixtures/luals/<namespace>/**` from disk — zero network — so both
160
+ * the `--fidelity` CLI arm and its round-trip test drive the exact same path and
161
+ * agree byte-for-byte. Fixture files are read in sorted order for determinism,
162
+ * mirroring the parse snapshot.
163
+ *
164
+ * Interfaces and aliases come from the full merge — the module's own signatures
165
+ * reference cross-file classes — but module functions are scoped to the module's
166
+ * own `.lua` file (`moduleId` dotted to a path, e.g. `druid.druid` →
167
+ * `druid/druid.lua`). Merging every file's free functions would export every
168
+ * library file's functions from the one module. A `moduleId` with no matching
169
+ * fixture is a loud misconfiguration, not a silently empty surface.
170
+ */
171
+ export function buildTargetModel(
172
+ packageRoot: string,
173
+ target: LualsTarget,
174
+ seams: { readDir?: ReadFixtureDir } = {},
175
+ ): LibraryModel {
176
+ const readDir = seams.readDir ?? ((r) => readdirSync(r, { recursive: true }).map(String));
177
+ const root = join(packageRoot, "fixtures/luals", target.namespace);
178
+ const files = readDir(root)
179
+ .map((entry) => entry.replace(/\\/g, "/"))
180
+ .filter((entry) => entry.endsWith(".lua"))
181
+ .sort();
182
+ const ownFile = `${target.moduleId.replace(/\./g, "/")}.lua`;
183
+ if (!files.includes(ownFile)) {
184
+ throw new Error(
185
+ `buildTargetModel: module "${target.moduleId}" expects fixture "${ownFile}" under fixtures/luals/${target.namespace}, but it is not among the ${files.length} fixture .lua files.`,
186
+ );
187
+ }
188
+ const parsed = new Map<string, LibraryModel>();
189
+ for (const rel of files) parsed.set(rel, parseLualsSource(readFileSync(join(root, rel), "utf8")));
190
+ const merged = mergeLibraryModels([...parsed.values()]);
191
+ return { ...merged, moduleFunctions: parsed.get(ownFile)?.moduleFunctions ?? [] };
192
+ }
193
+
194
+ export function buildTargetFidelity(packageRoot: string, target: LualsTarget): FidelityReport {
195
+ return buildFidelityReport(
196
+ target.namespace,
197
+ buildTargetModel(packageRoot, target),
198
+ target.typeRenames,
199
+ );
200
+ }
201
+
202
+ /**
203
+ * A druid-style corpus member: a LuaLS-sourced pure-Lua library, distinct from
204
+ * the ts-defold hand-written modules. Standalone registry — the docs-site and
205
+ * CLI wirings belong to later slices, not this one.
206
+ */
207
+ export interface LualsCorpusEntry {
208
+ moduleId: string;
209
+ namespace: string;
210
+ classification: "pure-lua";
211
+ source: "luals";
212
+ }
213
+
214
+ export function lualsCorpusTargets(packageRoot: string): LualsCorpusEntry[] {
215
+ return readLualsTargets(packageRoot).map((target) => ({
216
+ moduleId: target.moduleId,
217
+ namespace: target.namespace,
218
+ classification: "pure-lua",
219
+ source: "luals",
220
+ }));
221
+ }
222
+
223
+ interface GithubTreeResponse {
224
+ tree?: { path: string }[];
225
+ }
226
+
227
+ const githubHeaders = (): Record<string, string> => {
228
+ const token = process.env.GITHUB_TOKEN;
229
+ return token ? { Authorization: `Bearer ${token}` } : {};
230
+ };
231
+
232
+ const defaultListTree: ListLualsTree = async (repo, ref) => {
233
+ const url = `https://api.github.com/repos/${repoSlug(repo)}/git/trees/${ref}?recursive=1`;
234
+ const res = await fetch(url, { headers: githubHeaders() });
235
+ if (!res.ok) {
236
+ throw new Error(`git-trees fetch failed: ${url} -> ${res.status} ${res.statusText}`);
237
+ }
238
+ const body = (await res.json()) as GithubTreeResponse;
239
+ return (body.tree ?? []).map((e) => e.path);
240
+ };
241
+
242
+ const defaultFetchText: FetchText = async (url) => {
243
+ const res = await fetch(url);
244
+ if (!res.ok) {
245
+ throw new Error(`fetch failed: ${url} -> ${res.status} ${res.statusText}`);
246
+ }
247
+ return res.text();
248
+ };
249
+
250
+ if (import.meta.main) {
251
+ const root = join(import.meta.dir, "..");
252
+ const argv = process.argv.slice(2);
253
+ if (argv.includes("--fetch")) {
254
+ const targets = readLualsTargets(root);
255
+ for (const target of targets) {
256
+ await fetchLualsFixtures(root, target, {
257
+ listTree: defaultListTree,
258
+ fetchText: defaultFetchText,
259
+ });
260
+ console.log(`snapshotted ${target.moduleId} from ${repoSlug(target.repo)}@${target.ref}`);
261
+ }
262
+ }
263
+ if (argv.includes("--fidelity")) {
264
+ const targets = readLualsTargets(root);
265
+ for (const target of targets) {
266
+ const report = buildTargetFidelity(root, target);
267
+ const dest = join(root, "fidelity", `${target.namespace}.json`);
268
+ mkdirSync(dirname(dest), { recursive: true });
269
+ writeFileSync(dest, `${JSON.stringify(report, null, 2)}\n`);
270
+ console.log(
271
+ `${target.moduleId}: coverage ${(report.coverage * 100).toFixed(1)}% (${report.unknownFallbacks} unknown, ${report.undocumentedMembers} undocumented)`,
272
+ );
273
+ }
274
+ }
275
+ if (argv.includes("--emit")) {
276
+ const targets = readLualsTargets(root);
277
+ for (const target of targets) {
278
+ const model = buildTargetModel(root, target);
279
+ const declarations = emitLibraryDeclarations(model, {
280
+ moduleId: target.moduleId,
281
+ typeRenames: target.typeRenames,
282
+ });
283
+ const dest = join(root, "generated", `${target.namespace}.d.ts`);
284
+ mkdirSync(dirname(dest), { recursive: true });
285
+ writeFileSync(dest, declarations);
286
+ console.log(`emitted ${target.moduleId} -> generated/${target.namespace}.d.ts`);
287
+ }
288
+ }
289
+ if (argv.includes("--api-doc")) {
290
+ const targets = readLualsTargets(root);
291
+ for (const target of targets) {
292
+ const model = buildTargetModel(root, target);
293
+ const lowered = lowerLibraryModel(model, {
294
+ namespace: target.namespace,
295
+ typeRenames: target.typeRenames,
296
+ });
297
+ const dest = join(root, "api-doc", `${target.namespace}.json`);
298
+ mkdirSync(dirname(dest), { recursive: true });
299
+ writeFileSync(dest, `${JSON.stringify(lowered, null, 2)}\n`);
300
+ console.log(`lowered ${target.moduleId} -> api-doc/${target.namespace}.json`);
301
+ }
302
+ }
303
+ }