@defold-typescript/library-types 0.20.6 → 0.20.8

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,383 @@
1
+ /**
2
+ * A line-oriented reader for the LuaLS `---@` annotation dialect that druid-style
3
+ * pure-Lua libraries ship in place of a `.d.ts`. It populates a `LibraryModel`, a
4
+ * richer OOP shape than the flat `ApiModule` (`packages/types/src/api-doc.ts`):
5
+ * interfaces with methods/fields/generics/extends, aliases, and free module
6
+ * functions. Naming mirrors the flat model where it fits (`types: string[]`,
7
+ * `brief`, `isOptional`, `doc`) so the two read alike.
8
+ *
9
+ * Scope is parse-only: every LuaLS type expression is preserved as a raw token
10
+ * string, verbatim (`integer`, `string?`, `fun(self):number`, `table<K,V>`,
11
+ * `"a" | "b"`). Mapping those tokens to TypeScript is the next goal; this reader
12
+ * never rewrites, splits, or normalizes a type toward TS.
13
+ */
14
+
15
+ export interface LibraryModel {
16
+ interfaces: LibraryInterface[];
17
+ aliases: LibraryAlias[];
18
+ moduleFunctions: LibraryMethod[];
19
+ }
20
+
21
+ export interface LibraryInterface {
22
+ name: string;
23
+ extends?: string;
24
+ generics: LibraryGeneric[];
25
+ fields: LibraryField[];
26
+ methods: LibraryMethod[];
27
+ brief: string;
28
+ }
29
+
30
+ export interface LibraryMethod {
31
+ name: string;
32
+ brief: string;
33
+ generics: LibraryGeneric[];
34
+ params: LibraryParam[];
35
+ returns: LibraryParam[];
36
+ }
37
+
38
+ export interface LibraryParam {
39
+ name: string;
40
+ types: string[];
41
+ doc: string;
42
+ isOptional: boolean;
43
+ isVararg: boolean;
44
+ }
45
+
46
+ export type LibraryFieldVisibility = "public" | "protected" | "private" | "package";
47
+
48
+ export interface LibraryField {
49
+ name: string;
50
+ types: string[];
51
+ doc: string;
52
+ isOptional: boolean;
53
+ visibility?: LibraryFieldVisibility;
54
+ }
55
+
56
+ export interface LibraryGeneric {
57
+ name: string;
58
+ constraint?: string;
59
+ }
60
+
61
+ export interface LibraryAlias {
62
+ name: string;
63
+ types: string[];
64
+ doc: string;
65
+ }
66
+
67
+ interface Pending {
68
+ doc: string[];
69
+ params: LibraryParam[];
70
+ returns: LibraryParam[];
71
+ generics: LibraryGeneric[];
72
+ }
73
+
74
+ const emptyPending = (): Pending => ({ doc: [], params: [], returns: [], generics: [] });
75
+
76
+ /**
77
+ * Read a single raw type token from the head of `rest`, honoring bracket depth so
78
+ * an inner space (`table<string, any>`, `fun(a, b): c`) does not end the token. The
79
+ * token ends at the first top-level whitespace. Returns the token and the trailing
80
+ * remainder (the human description). Never rewrites the token toward TS.
81
+ */
82
+ function readTypeToken(rest: string): { type: string; rest: string } {
83
+ let depth = 0;
84
+ let i = 0;
85
+ for (; i < rest.length; i++) {
86
+ const c = rest[i];
87
+ if (c === "<" || c === "(" || c === "[" || c === "{") depth++;
88
+ else if (c === ">" || c === ")" || c === "]" || c === "}") depth = Math.max(0, depth - 1);
89
+ else if ((c === " " || c === "\t") && depth === 0) break;
90
+ }
91
+ return { type: rest.slice(0, i), rest: rest.slice(i).trim() };
92
+ }
93
+
94
+ /** A bare lowercase identifier — the shape druid uses for an optional `@return` name. */
95
+ const RETURN_NAME = /^[a-z_][A-Za-z0-9_]*$/;
96
+
97
+ function parseParam(rest: string): LibraryParam {
98
+ const spaceAt = rest.search(/\s/);
99
+ const rawName = spaceAt === -1 ? rest : rest.slice(0, spaceAt);
100
+ const afterName = spaceAt === -1 ? "" : rest.slice(spaceAt).trim();
101
+ const isVararg = rawName === "...";
102
+ const isOptional = !isVararg && rawName.endsWith("?");
103
+ const name = isOptional ? rawName.slice(0, -1) : rawName;
104
+ const { type, rest: doc } = readTypeToken(afterName);
105
+ return { name, types: type ? [type] : [], doc, isOptional, isVararg };
106
+ }
107
+
108
+ function parseReturn(rest: string): LibraryParam {
109
+ const { type, rest: afterType } = readTypeToken(rest);
110
+ const spaceAt = afterType.search(/\s/);
111
+ const head = spaceAt === -1 ? afterType : afterType.slice(0, spaceAt);
112
+ let name = "";
113
+ let doc = afterType;
114
+ if (head && RETURN_NAME.test(head)) {
115
+ name = head;
116
+ doc = spaceAt === -1 ? "" : afterType.slice(spaceAt).trim();
117
+ }
118
+ return { name, types: type ? [type] : [], doc, isOptional: false, isVararg: false };
119
+ }
120
+
121
+ const VISIBILITY_KEYWORDS = new Set<LibraryFieldVisibility>([
122
+ "public",
123
+ "protected",
124
+ "private",
125
+ "package",
126
+ ]);
127
+
128
+ function parseField(rest: string): LibraryField {
129
+ // LuaLS grammar is `---@field [scope] <name> <type> [description]`. Strip a leading
130
+ // visibility keyword only when a further token follows it — a lone `---@field private`
131
+ // is a field literally named `private`, matching LuaLS's own resolution.
132
+ let body = rest;
133
+ let visibility: LibraryFieldVisibility | undefined;
134
+ const firstSpace = body.search(/\s/);
135
+ if (firstSpace !== -1) {
136
+ const first = body.slice(0, firstSpace);
137
+ if (VISIBILITY_KEYWORDS.has(first as LibraryFieldVisibility)) {
138
+ visibility = first as LibraryFieldVisibility;
139
+ body = body.slice(firstSpace).trim();
140
+ }
141
+ }
142
+ const spaceAt = body.search(/\s/);
143
+ const rawName = spaceAt === -1 ? body : body.slice(0, spaceAt);
144
+ const afterName = spaceAt === -1 ? "" : body.slice(spaceAt).trim();
145
+ const isOptional = rawName.endsWith("?");
146
+ const name = isOptional ? rawName.slice(0, -1) : rawName;
147
+ const { type, rest: doc } = readTypeToken(afterName);
148
+ return {
149
+ name,
150
+ types: type ? [type] : [],
151
+ doc,
152
+ isOptional,
153
+ ...(visibility ? { visibility } : {}),
154
+ };
155
+ }
156
+
157
+ function parseVararg(rest: string): LibraryParam {
158
+ const { type, rest: doc } = readTypeToken(rest);
159
+ return { name: "...", types: type ? [type] : [], doc, isOptional: false, isVararg: true };
160
+ }
161
+
162
+ function parseGenerics(rest: string): LibraryGeneric[] {
163
+ return rest
164
+ .split(",")
165
+ .map((part) => part.trim())
166
+ .filter((part) => part.length > 0)
167
+ .map((part) => {
168
+ const colon = part.indexOf(":");
169
+ if (colon === -1) return { name: part.trim() };
170
+ return { name: part.slice(0, colon).trim(), constraint: part.slice(colon + 1).trim() };
171
+ });
172
+ }
173
+
174
+ /** Parse a `@class Name[ : parent]` head. The parent is kept as a single raw token. */
175
+ function parseClassHead(rest: string): { name: string; extends?: string } {
176
+ const colon = rest.indexOf(":");
177
+ if (colon === -1) return { name: rest.trim() };
178
+ const parent = rest.slice(colon + 1).trim();
179
+ return { name: rest.slice(0, colon).trim(), ...(parent ? { extends: parent } : {}) };
180
+ }
181
+
182
+ interface FunctionDecl {
183
+ kind: "method" | "module";
184
+ receiver?: string;
185
+ name: string;
186
+ }
187
+
188
+ const FUNCTION_FORMS: { re: RegExp; kind: "method" | "module"; recv?: number; name: number }[] = [
189
+ { re: /^function\s+([A-Za-z_][\w.]*):([A-Za-z_]\w*)\s*\(/, kind: "method", recv: 1, name: 2 },
190
+ { re: /^function\s+([A-Za-z_][\w.]*)\.([A-Za-z_]\w*)\s*\(/, kind: "module", name: 2 },
191
+ { re: /^(?:local\s+)?function\s+([A-Za-z_]\w*)\s*\(/, kind: "module", name: 1 },
192
+ { re: /^([A-Za-z_][\w.]*):([A-Za-z_]\w*)\s*=\s*function\b/, kind: "method", recv: 1, name: 2 },
193
+ { re: /^([A-Za-z_][\w.]*)\.([A-Za-z_]\w*)\s*=\s*function\b/, kind: "module", name: 2 },
194
+ { re: /^([A-Za-z_]\w*)\s*=\s*function\b/, kind: "module", name: 1 },
195
+ ];
196
+
197
+ function parseFunctionDecl(line: string): FunctionDecl | null {
198
+ for (const form of FUNCTION_FORMS) {
199
+ const m = form.re.exec(line);
200
+ if (!m) continue;
201
+ const name = m[form.name] ?? "";
202
+ if (form.kind === "method") {
203
+ const receiver = form.recv ? m[form.recv] : undefined;
204
+ return { kind: "method", name, ...(receiver ? { receiver } : {}) };
205
+ }
206
+ return { kind: "module", name };
207
+ }
208
+ return null;
209
+ }
210
+
211
+ const LOCAL_ASSIGN = /^local\s+([A-Za-z_]\w*)\s*=/;
212
+
213
+ /**
214
+ * Scan one LuaLS-annotated source into a `LibraryModel`. Only column-0 lines are
215
+ * recognized (module- and class-level declarations and their leading `---@` block);
216
+ * indented lines — in-body closures, `---@cast`/`---@type` narrowing — are opaque,
217
+ * so they neither create declarations nor pollute the pending block. Output order
218
+ * follows source order, making the result stable across repeated runs.
219
+ */
220
+ export function parseLualsSource(source: string): LibraryModel {
221
+ const interfaces: LibraryInterface[] = [];
222
+ const byName = new Map<string, LibraryInterface>();
223
+ const aliases: LibraryAlias[] = [];
224
+ const moduleFunctions: LibraryMethod[] = [];
225
+ const receiverBinding = new Map<string, string>();
226
+
227
+ let pending = emptyPending();
228
+ let openClass: LibraryInterface | null = null;
229
+ let lastOpenedClass: string | null = null;
230
+
231
+ const ensureInterface = (name: string): LibraryInterface => {
232
+ const existing = byName.get(name);
233
+ if (existing) return existing;
234
+ const created: LibraryInterface = {
235
+ name,
236
+ generics: [],
237
+ fields: [],
238
+ methods: [],
239
+ brief: "",
240
+ };
241
+ byName.set(name, created);
242
+ interfaces.push(created);
243
+ return created;
244
+ };
245
+
246
+ const methodFromPending = (name: string): LibraryMethod => ({
247
+ name,
248
+ brief: pending.doc.join("\n"),
249
+ generics: pending.generics,
250
+ params: pending.params,
251
+ returns: pending.returns,
252
+ });
253
+
254
+ for (const raw of source.split("\n")) {
255
+ // Column-0 discipline: a line with leading whitespace is opaque to the scanner.
256
+ if (/^\s/.test(raw) || raw.length === 0) continue;
257
+
258
+ if (raw.startsWith("---@")) {
259
+ const tagMatch = /^---@([a-zA-Z]+)\s*(.*)$/.exec(raw);
260
+ if (!tagMatch) continue;
261
+ const tag = tagMatch[1];
262
+ const rest = (tagMatch[2] ?? "").trim();
263
+ switch (tag) {
264
+ case "class": {
265
+ const head = parseClassHead(rest);
266
+ const iface = ensureInterface(head.name);
267
+ if (head.extends) iface.extends = head.extends;
268
+ if (pending.doc.length > 0 && iface.brief === "") iface.brief = pending.doc.join("\n");
269
+ if (pending.generics.length > 0) iface.generics = pending.generics;
270
+ openClass = iface;
271
+ lastOpenedClass = head.name;
272
+ pending = emptyPending();
273
+ break;
274
+ }
275
+ case "field": {
276
+ if (openClass) openClass.fields.push(parseField(rest));
277
+ break;
278
+ }
279
+ case "param": {
280
+ pending.params.push(parseParam(rest));
281
+ break;
282
+ }
283
+ case "vararg": {
284
+ pending.params.push(parseVararg(rest));
285
+ break;
286
+ }
287
+ case "return": {
288
+ pending.returns.push(parseReturn(rest));
289
+ break;
290
+ }
291
+ case "generic": {
292
+ pending.generics.push(...parseGenerics(rest));
293
+ break;
294
+ }
295
+ case "alias": {
296
+ const spaceAt = rest.search(/\s/);
297
+ const name = spaceAt === -1 ? rest : rest.slice(0, spaceAt);
298
+ const expr = spaceAt === -1 ? "" : rest.slice(spaceAt).trim();
299
+ aliases.push({ name, types: expr ? [expr] : [], doc: pending.doc.join("\n") });
300
+ pending = emptyPending();
301
+ break;
302
+ }
303
+ default:
304
+ // @private, @protected, @cast, @type, @diagnostic, @overload, ... — outside
305
+ // the Druid subset; recognized as a tag and skipped, never treated as doc.
306
+ break;
307
+ }
308
+ continue;
309
+ }
310
+
311
+ if (raw.startsWith("---")) {
312
+ pending.doc.push(raw.slice(3).trim());
313
+ continue;
314
+ }
315
+
316
+ const decl = parseFunctionDecl(raw);
317
+ if (decl) {
318
+ if (decl.kind === "method") {
319
+ const target = decl.receiver ? (receiverBinding.get(decl.receiver) ?? decl.receiver) : "";
320
+ ensureInterface(target).methods.push(methodFromPending(decl.name));
321
+ } else {
322
+ moduleFunctions.push(methodFromPending(decl.name));
323
+ }
324
+ pending = emptyPending();
325
+ openClass = null;
326
+ continue;
327
+ }
328
+
329
+ const localAssign = LOCAL_ASSIGN.exec(raw);
330
+ if (localAssign) {
331
+ const variable = localAssign[1];
332
+ if (variable && lastOpenedClass) receiverBinding.set(variable, lastOpenedClass);
333
+ lastOpenedClass = null;
334
+ openClass = null;
335
+ pending = emptyPending();
336
+ }
337
+ }
338
+
339
+ return { interfaces, aliases, moduleFunctions };
340
+ }
341
+
342
+ /**
343
+ * Fold several parsed models into one, merging interfaces by name (concatenating
344
+ * fields and methods, keeping the first non-empty `extends`/`brief`/`generics`) and
345
+ * concatenating aliases and module functions in argument order. Deterministic given
346
+ * a stable input order — the snapshot feeds it the fixture files sorted by path.
347
+ */
348
+ export function mergeLibraryModels(models: LibraryModel[]): LibraryModel {
349
+ const interfaces: LibraryInterface[] = [];
350
+ const byName = new Map<string, LibraryInterface>();
351
+ const aliases: LibraryAlias[] = [];
352
+ const moduleFunctions: LibraryMethod[] = [];
353
+
354
+ for (const model of models) {
355
+ for (const iface of model.interfaces) {
356
+ const existing = byName.get(iface.name);
357
+ if (!existing) {
358
+ const copy: LibraryInterface = {
359
+ name: iface.name,
360
+ ...(iface.extends ? { extends: iface.extends } : {}),
361
+ generics: [...iface.generics],
362
+ fields: [...iface.fields],
363
+ methods: [...iface.methods],
364
+ brief: iface.brief,
365
+ };
366
+ byName.set(iface.name, copy);
367
+ interfaces.push(copy);
368
+ continue;
369
+ }
370
+ existing.fields.push(...iface.fields);
371
+ existing.methods.push(...iface.methods);
372
+ if (!existing.extends && iface.extends) existing.extends = iface.extends;
373
+ if (existing.brief === "" && iface.brief !== "") existing.brief = iface.brief;
374
+ if (existing.generics.length === 0 && iface.generics.length > 0) {
375
+ existing.generics = [...iface.generics];
376
+ }
377
+ }
378
+ aliases.push(...model.aliases);
379
+ moduleFunctions.push(...model.moduleFunctions);
380
+ }
381
+
382
+ return { interfaces, aliases, moduleFunctions };
383
+ }
@@ -0,0 +1,240 @@
1
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { buildFidelityReport, type FidelityReport } from "./luals-fidelity";
4
+ import { mergeLibraryModels, parseLualsSource } from "./parse-luals";
5
+
6
+ /**
7
+ * The LuaLS ingestion front-end pins its source per-entry: a druid-style library
8
+ * ships no `.d.ts`, only inline LuaLS `---@` annotations, and each such library
9
+ * lives in its own repo at its own tag. So every target carries its own
10
+ * `repo`/`ref`, unlike the ts-defold front-end's single shared `source`.
11
+ */
12
+ export interface LualsTarget {
13
+ repo: string;
14
+ ref: string;
15
+ sourceGlobs: string[];
16
+ moduleId: string;
17
+ namespace: string;
18
+ typeRenames: Record<string, string>;
19
+ ignore: string[];
20
+ }
21
+
22
+ export interface LualsTargets {
23
+ targets: LualsTarget[];
24
+ }
25
+
26
+ const REQUIRED_FIELDS = ["repo", "ref", "sourceGlobs", "moduleId", "namespace"] as const;
27
+
28
+ /**
29
+ * Read `luals-targets.json`, validate every required field per entry, and fill
30
+ * optional defaults (`typeRenames` → `{}`, `ignore` → `[]`). Throws on the first
31
+ * missing field naming both the field and the offending entry (its `moduleId`,
32
+ * or its index when `moduleId` itself is absent) — the loud-fail discipline the
33
+ * ts-defold `regenerate` uses for unmapped references. No network.
34
+ */
35
+ export function readLualsTargets(packageRoot: string): LualsTarget[] {
36
+ const parsed = JSON.parse(readFileSync(join(packageRoot, "luals-targets.json"), "utf8")) as {
37
+ targets: Partial<LualsTarget>[];
38
+ };
39
+ return parsed.targets.map((entry, index) => {
40
+ const label = typeof entry.moduleId === "string" ? entry.moduleId : `index ${index}`;
41
+ for (const field of REQUIRED_FIELDS) {
42
+ if (entry[field] === undefined) {
43
+ throw new Error(`luals-targets.json: entry ${label} is missing required field "${field}".`);
44
+ }
45
+ }
46
+ return {
47
+ repo: entry.repo as string,
48
+ ref: entry.ref as string,
49
+ sourceGlobs: entry.sourceGlobs as string[],
50
+ moduleId: entry.moduleId as string,
51
+ namespace: entry.namespace as string,
52
+ typeRenames: entry.typeRenames ?? {},
53
+ ignore: entry.ignore ?? [],
54
+ };
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Compile a glob to an anchored RegExp — mirrors `globToRegex` in
60
+ * `packages/cli/src/build-output.ts` rather than importing across packages
61
+ * (library-types must not depend on the cli package). A `**` path segment spans
62
+ * any number of segments, a bare `**` spans the rest, `*` a non-slash run, `?`
63
+ * one non-slash.
64
+ */
65
+ function globToRegex(pattern: string): RegExp {
66
+ let out = "";
67
+ for (let i = 0; i < pattern.length; i++) {
68
+ const c = pattern[i];
69
+ if (c === "*") {
70
+ if (pattern[i + 1] === "*") {
71
+ i++;
72
+ if (pattern[i + 1] === "/") {
73
+ i++;
74
+ out += "(?:[^/]+/)*";
75
+ } else {
76
+ out += ".*";
77
+ }
78
+ } else {
79
+ out += "[^/]*";
80
+ }
81
+ } else if (c === "?") {
82
+ out += "[^/]";
83
+ } else {
84
+ out += (c as string).replace(/[.+^$(){}|[\]\\]/g, "\\$&");
85
+ }
86
+ }
87
+ return new RegExp(`^${out}$`);
88
+ }
89
+
90
+ /**
91
+ * A path is selected iff at least one `sourceGlob` matches and no `ignore` glob
92
+ * matches. Returns the sorted, deduped subset — the fixture set the vendor step
93
+ * snapshots.
94
+ */
95
+ export function selectLualsSources(
96
+ paths: string[],
97
+ target: { sourceGlobs: string[]; ignore: string[] },
98
+ ): string[] {
99
+ const includes = target.sourceGlobs.map(globToRegex);
100
+ const excludes = target.ignore.map(globToRegex);
101
+ const selected = paths.filter(
102
+ (p) => includes.some((re) => re.test(p)) && !excludes.some((re) => re.test(p)),
103
+ );
104
+ return [...new Set(selected)].sort();
105
+ }
106
+
107
+ /** Enumerate the pinned tree of a LuaLS library repo at its ref. Network seam. */
108
+ export type ListLualsTree = (repo: string, ref: string) => Promise<string[]>;
109
+
110
+ /** Fetch the raw text at a URL. Network seam — mirrors `sync-library-types.ts`. */
111
+ export type FetchText = (url: string) => Promise<string>;
112
+
113
+ /**
114
+ * A GitHub repo URL reduced to the bare `<owner>/<repo>` slug used to address
115
+ * raw content. Mirrors `repoSlug` in the ts-defold front-end.
116
+ */
117
+ function repoSlug(repo: string): string {
118
+ return repo
119
+ .replace(/^https:\/\/github\.com\//, "")
120
+ .replace(/\.git$/, "")
121
+ .replace(/\/$/, "");
122
+ }
123
+
124
+ function rawUrl(target: LualsTarget, path: string): string {
125
+ return `https://raw.githubusercontent.com/${repoSlug(target.repo)}/${target.ref}/${path}`;
126
+ }
127
+
128
+ /**
129
+ * List the pinned tree, select the matching sources, fetch each via raw-content
130
+ * URL, and write it under `fixtures/luals/<namespace>/<relpath>` preserving tree
131
+ * shape. Snapshot only — no codemod. The `listTree`/`fetchText` seams keep the
132
+ * pass offline-testable; only the CLI `--fetch` arm wires the real network.
133
+ */
134
+ export async function fetchLualsFixtures(
135
+ packageRoot: string,
136
+ target: LualsTarget,
137
+ seams: { listTree: ListLualsTree; fetchText: FetchText },
138
+ ): Promise<void> {
139
+ const paths = selectLualsSources(await seams.listTree(target.repo, target.ref), target);
140
+ for (const path of paths) {
141
+ const text = await seams.fetchText(rawUrl(target, path));
142
+ const dest = join(packageRoot, "fixtures/luals", target.namespace, path);
143
+ mkdirSync(dirname(dest), { recursive: true });
144
+ writeFileSync(dest, text);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Parse and merge a target's committed fixtures, then build its fidelity report.
150
+ * Reads only `fixtures/luals/<namespace>/**` from disk — zero network — so both
151
+ * the `--fidelity` CLI arm and its round-trip test drive the exact same path and
152
+ * agree byte-for-byte. Fixture files are read in sorted order for determinism,
153
+ * mirroring the parse snapshot.
154
+ */
155
+ export function buildTargetFidelity(packageRoot: string, target: LualsTarget): FidelityReport {
156
+ const root = join(packageRoot, "fixtures/luals", target.namespace);
157
+ const files = readdirSync(root, { recursive: true })
158
+ .map((entry) => String(entry))
159
+ .filter((entry) => entry.endsWith(".lua"))
160
+ .sort();
161
+ const model = mergeLibraryModels(
162
+ files.map((rel) => parseLualsSource(readFileSync(join(root, rel), "utf8"))),
163
+ );
164
+ return buildFidelityReport(target.namespace, model, target.typeRenames);
165
+ }
166
+
167
+ /**
168
+ * A druid-style corpus member: a LuaLS-sourced pure-Lua library, distinct from
169
+ * the ts-defold hand-written modules. Standalone registry — the docs-site and
170
+ * CLI wirings belong to later slices, not this one.
171
+ */
172
+ export interface LualsCorpusEntry {
173
+ moduleId: string;
174
+ namespace: string;
175
+ classification: "pure-lua";
176
+ source: "luals";
177
+ }
178
+
179
+ export function lualsCorpusTargets(packageRoot: string): LualsCorpusEntry[] {
180
+ return readLualsTargets(packageRoot).map((target) => ({
181
+ moduleId: target.moduleId,
182
+ namespace: target.namespace,
183
+ classification: "pure-lua",
184
+ source: "luals",
185
+ }));
186
+ }
187
+
188
+ interface GithubTreeResponse {
189
+ tree?: { path: string }[];
190
+ }
191
+
192
+ const githubHeaders = (): Record<string, string> => {
193
+ const token = process.env.GITHUB_TOKEN;
194
+ return token ? { Authorization: `Bearer ${token}` } : {};
195
+ };
196
+
197
+ const defaultListTree: ListLualsTree = async (repo, ref) => {
198
+ const url = `https://api.github.com/repos/${repoSlug(repo)}/git/trees/${ref}?recursive=1`;
199
+ const res = await fetch(url, { headers: githubHeaders() });
200
+ if (!res.ok) {
201
+ throw new Error(`git-trees fetch failed: ${url} -> ${res.status} ${res.statusText}`);
202
+ }
203
+ const body = (await res.json()) as GithubTreeResponse;
204
+ return (body.tree ?? []).map((e) => e.path);
205
+ };
206
+
207
+ const defaultFetchText: FetchText = async (url) => {
208
+ const res = await fetch(url);
209
+ if (!res.ok) {
210
+ throw new Error(`fetch failed: ${url} -> ${res.status} ${res.statusText}`);
211
+ }
212
+ return res.text();
213
+ };
214
+
215
+ if (import.meta.main) {
216
+ const root = join(import.meta.dir, "..");
217
+ const argv = process.argv.slice(2);
218
+ if (argv.includes("--fetch")) {
219
+ const targets = readLualsTargets(root);
220
+ for (const target of targets) {
221
+ await fetchLualsFixtures(root, target, {
222
+ listTree: defaultListTree,
223
+ fetchText: defaultFetchText,
224
+ });
225
+ console.log(`snapshotted ${target.moduleId} from ${repoSlug(target.repo)}@${target.ref}`);
226
+ }
227
+ }
228
+ if (argv.includes("--fidelity")) {
229
+ const targets = readLualsTargets(root);
230
+ for (const target of targets) {
231
+ const report = buildTargetFidelity(root, target);
232
+ const dest = join(root, "fidelity", `${target.namespace}.json`);
233
+ mkdirSync(dirname(dest), { recursive: true });
234
+ writeFileSync(dest, `${JSON.stringify(report, null, 2)}\n`);
235
+ console.log(
236
+ `${target.moduleId}: coverage ${(report.coverage * 100).toFixed(1)}% (${report.unknownFallbacks} unknown, ${report.undocumentedMembers} undocumented)`,
237
+ );
238
+ }
239
+ }
240
+ }