@defold-typescript/library-types 0.18.1

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.
Files changed (101) hide show
  1. package/NOTICE +52 -0
  2. package/README.md +78 -0
  3. package/api-doc/boom.boom.json +26 -0
  4. package/api-doc/bridge.bridge.json +1653 -0
  5. package/api-doc/bzAnim.bzLibrary.json +156 -0
  6. package/api-doc/defcon.console.json +109 -0
  7. package/api-doc/defmath.defmath.json +1381 -0
  8. package/api-doc/defsave.defsave.json +175 -0
  9. package/api-doc/deftest.deftest.json +71 -0
  10. package/api-doc/dicebag.dicebag.json +323 -0
  11. package/api-doc/event.event.json +238 -0
  12. package/api-doc/gooey.gooey.json +963 -0
  13. package/api-doc/immutable.immutable.json +63 -0
  14. package/api-doc/in.accelerometer.json +281 -0
  15. package/api-doc/in.button.json +148 -0
  16. package/api-doc/in.cursor.json +203 -0
  17. package/api-doc/in.gesture.json +206 -0
  18. package/api-doc/in.keyboard.json +45 -0
  19. package/api-doc/in.mapper.json +141 -0
  20. package/api-doc/in.onscreen.json +265 -0
  21. package/api-doc/in.state.json +195 -0
  22. package/api-doc/in.textbox.json +213 -0
  23. package/api-doc/in.triggers.json +1260 -0
  24. package/api-doc/lang.lang.json +411 -0
  25. package/api-doc/log.log.json +50 -0
  26. package/api-doc/metrics.fps.json +88 -0
  27. package/api-doc/metrics.mem.json +80 -0
  28. package/api-doc/monarch.monarch.json +1065 -0
  29. package/api-doc/monarch.transitions.easings.json +206 -0
  30. package/api-doc/monarch.transitions.gui.json +445 -0
  31. package/api-doc/nakama.engine.defold.json +165 -0
  32. package/api-doc/nakama.nakama.json +6574 -0
  33. package/api-doc/nakama.util.log.json +25 -0
  34. package/api-doc/narrator.narrator.json +150 -0
  35. package/api-doc/orthographic.camera.json +1019 -0
  36. package/api-doc/persist.persist.json +142 -0
  37. package/api-doc/platypus.platypus.json +631 -0
  38. package/api-doc/proto.proto.json +355 -0
  39. package/api-doc/rendy.rendy.json +408 -0
  40. package/api-doc/richtext.color.json +219 -0
  41. package/api-doc/richtext.richtext.json +382 -0
  42. package/api-doc/richtext.tags.json +67 -0
  43. package/api-doc/saver.saver.json +553 -0
  44. package/api-doc/saver.storage.json +174 -0
  45. package/api-doc/squid.squid.json +660 -0
  46. package/api-doc/starly.starly.json +488 -0
  47. package/api-doc/tweener.tweener.json +419 -0
  48. package/api-doc/yagames.yagames.json +1465 -0
  49. package/api-doc/zzfx.api.json +85 -0
  50. package/generated/boom.boom.d.ts +1585 -0
  51. package/generated/bridge.bridge.d.ts +533 -0
  52. package/generated/bzAnim.bzLibrary.d.ts +93 -0
  53. package/generated/defcon.console.d.ts +24 -0
  54. package/generated/defmath.defmath.d.ts +194 -0
  55. package/generated/defsave.defsave.d.ts +31 -0
  56. package/generated/deftest.deftest.d.ts +47 -0
  57. package/generated/dicebag.dicebag.d.ts +90 -0
  58. package/generated/event.event.d.ts +54 -0
  59. package/generated/gooey.gooey.d.ts +261 -0
  60. package/generated/immutable.immutable.d.ts +13 -0
  61. package/generated/in.accelerometer.d.ts +37 -0
  62. package/generated/in.button.d.ts +20 -0
  63. package/generated/in.cursor.d.ts +33 -0
  64. package/generated/in.gesture.d.ts +64 -0
  65. package/generated/in.keyboard.d.ts +18 -0
  66. package/generated/in.mapper.d.ts +23 -0
  67. package/generated/in.onscreen.d.ts +58 -0
  68. package/generated/in.state.d.ts +34 -0
  69. package/generated/in.textbox.d.ts +26 -0
  70. package/generated/in.triggers.d.ts +180 -0
  71. package/generated/lang.lang.d.ts +33 -0
  72. package/generated/log.log.d.ts +40 -0
  73. package/generated/metrics.fps.d.ts +16 -0
  74. package/generated/metrics.mem.d.ts +16 -0
  75. package/generated/monarch.monarch.d.ts +412 -0
  76. package/generated/monarch.transitions.easings.d.ts +65 -0
  77. package/generated/monarch.transitions.gui.d.ts +197 -0
  78. package/generated/nakama.engine.defold.d.ts +24 -0
  79. package/generated/nakama.nakama.d.ts +594 -0
  80. package/generated/nakama.util.log.d.ts +10 -0
  81. package/generated/narrator.narrator.d.ts +66 -0
  82. package/generated/orthographic.camera.d.ts +308 -0
  83. package/generated/persist.persist.d.ts +34 -0
  84. package/generated/platypus.platypus.d.ts +76 -0
  85. package/generated/proto.proto.d.ts +36 -0
  86. package/generated/rendy.rendy.d.ts +184 -0
  87. package/generated/richtext.color.d.ts +33 -0
  88. package/generated/richtext.richtext.d.ts +126 -0
  89. package/generated/richtext.tags.d.ts +12 -0
  90. package/generated/saver.saver.d.ts +44 -0
  91. package/generated/saver.storage.d.ts +16 -0
  92. package/generated/squid.squid.d.ts +106 -0
  93. package/generated/starly.starly.d.ts +148 -0
  94. package/generated/tweener.tweener.d.ts +151 -0
  95. package/generated/yagames.yagames.d.ts +279 -0
  96. package/generated/zzfx.api.d.ts +20 -0
  97. package/library-classification.json +726 -0
  98. package/library-targets.json +291 -0
  99. package/package.json +180 -0
  100. package/scripts/extract-api-doc.ts +418 -0
  101. package/scripts/sync-library-types.ts +542 -0
@@ -0,0 +1,542 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import ts from "typescript";
4
+ import { extractApiDoc } from "./extract-api-doc";
5
+
6
+ /**
7
+ * ts-defold/library core-type references -> the @defold-typescript/types surface.
8
+ * Dotted `vmath.*` names collapse to a single core-type name; bare handle tokens
9
+ * become `Opaque<…>` brands, resolving against the globals in
10
+ * `@defold-typescript/types` `engine-globals.d.ts`. `table` is intentionally
11
+ * absent: ts-defold modules declare their own local `type table = {}` alias, so
12
+ * renaming it would rewrite an unrelated local type.
13
+ */
14
+ export const CORE_TYPE_RENAMES: Readonly<Record<string, string>> = {
15
+ "vmath.vector": "Vector",
16
+ "vmath.vector3": "Vector3",
17
+ "vmath.vector4": "Vector4",
18
+ "vmath.matrix4": "Matrix4",
19
+ "vmath.quat": "Quaternion",
20
+ "vmath.quaternion": "Quaternion",
21
+ hash: "Hash",
22
+ url: "Url",
23
+ node: 'Opaque<"node">',
24
+ texture: 'Opaque<"texture">',
25
+ render_target: 'Opaque<"render_target">',
26
+ constant: 'Opaque<"constant">',
27
+ constant_buffer: 'Opaque<"constant_buffer">',
28
+ buffer: 'Opaque<"buffer">',
29
+ bufferstream: 'Opaque<"bufferstream">',
30
+ resource: 'Opaque<"resource">',
31
+ userdata: 'Opaque<"userdata">',
32
+ b2World: 'Opaque<"b2World">',
33
+ b2Body: 'Opaque<"b2Body">',
34
+ };
35
+
36
+ export interface CodemodResult {
37
+ output: string;
38
+ unmapped: string[];
39
+ }
40
+
41
+ function entityNameText(name: ts.EntityName): string {
42
+ return ts.isIdentifier(name) ? name.text : `${entityNameText(name.left)}.${name.right.text}`;
43
+ }
44
+
45
+ /**
46
+ * Rename every Defold core-type reference in an ambient `.d.ts` to the
47
+ * @defold-typescript/types surface. Matches type references only — property
48
+ * names, JSDoc, `declare module`, and passthrough extensions (`LuaMultiReturn`,
49
+ * `LuaMap`) stay byte-identical because `forEachChild` never descends into
50
+ * identifiers-as-names or comments. Any `vmath.*` reference with no mapping is
51
+ * collected into `unmapped` and left verbatim so a missing rename surfaces as a
52
+ * red typecheck rather than a silent `any`.
53
+ */
54
+ export function codemodDeclaration(source: string): CodemodResult {
55
+ const sf = ts.createSourceFile(
56
+ "module.d.ts",
57
+ source,
58
+ ts.ScriptTarget.Latest,
59
+ /* setParentNodes */ true,
60
+ ts.ScriptKind.TS,
61
+ );
62
+ const edits: { start: number; end: number; text: string }[] = [];
63
+ const unmapped = new Set<string>();
64
+
65
+ const visit = (node: ts.Node): void => {
66
+ if (ts.isTypeReferenceNode(node)) {
67
+ const name = entityNameText(node.typeName);
68
+ const rename = CORE_TYPE_RENAMES[name];
69
+ if (rename !== undefined) {
70
+ edits.push({
71
+ start: node.typeName.getStart(sf),
72
+ end: node.typeName.getEnd(),
73
+ text: rename,
74
+ });
75
+ } else if (name.startsWith("vmath.")) {
76
+ unmapped.add(name);
77
+ }
78
+ }
79
+ ts.forEachChild(node, visit);
80
+ };
81
+ visit(sf);
82
+
83
+ edits.sort((a, b) => b.start - a.start);
84
+ let output = source;
85
+ for (const edit of edits) {
86
+ output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
87
+ }
88
+ return { output, unmapped: [...unmapped] };
89
+ }
90
+
91
+ export interface LibrarySource {
92
+ repo: string;
93
+ commit: string;
94
+ license: string;
95
+ }
96
+
97
+ export interface LibraryTarget {
98
+ module: string;
99
+ path: string;
100
+ fixture: string;
101
+ generated: string;
102
+ }
103
+
104
+ export interface LibraryTargets {
105
+ source: LibrarySource;
106
+ targets: LibraryTarget[];
107
+ }
108
+
109
+ function readTargets(packageRoot: string): LibraryTargets {
110
+ return JSON.parse(
111
+ readFileSync(join(packageRoot, "library-targets.json"), "utf8"),
112
+ ) as LibraryTargets;
113
+ }
114
+
115
+ /**
116
+ * Read every pinned fixture, codemod it, and write the renamed `declare module`
117
+ * to its `generated/` path. Throws on the first unmapped reference so a stale
118
+ * rename table never ships a silently-broken type.
119
+ */
120
+ export function regenerate(packageRoot: string): void {
121
+ const targets = readTargets(packageRoot);
122
+ for (const target of targets.targets) {
123
+ const source = readFileSync(join(packageRoot, target.fixture), "utf8");
124
+ const { output, unmapped } = codemodDeclaration(source);
125
+ if (unmapped.length > 0) {
126
+ throw new Error(
127
+ `${target.module}: unmapped core-type references ${unmapped.join(", ")} — extend CORE_TYPE_RENAMES.`,
128
+ );
129
+ }
130
+ writeFileSync(join(packageRoot, target.generated), output);
131
+ }
132
+ writeApiDocs(packageRoot);
133
+ }
134
+
135
+ /** The pretty-printed ref-doc JSON a generated module extracts to. */
136
+ function apiDocJson(packageRoot: string, target: LibraryTarget): string {
137
+ const generated = readFileSync(join(packageRoot, target.generated), "utf8");
138
+ return `${JSON.stringify(extractApiDoc(generated, target.module), null, 2)}\n`;
139
+ }
140
+
141
+ /**
142
+ * Extract every generated `declare module` into a committed
143
+ * `api-doc/<module>.json` ref-doc fixture the docs-site loads via
144
+ * `parseDefoldApiDoc`. Runs at the tail of `regenerate` so the fixtures never
145
+ * drift from the `.d.ts` they describe.
146
+ */
147
+ export function writeApiDocs(packageRoot: string): void {
148
+ const { targets } = readTargets(packageRoot);
149
+ mkdirSync(join(packageRoot, "api-doc"), { recursive: true });
150
+ for (const target of targets) {
151
+ writeFileSync(
152
+ join(packageRoot, "api-doc", `${target.module}.json`),
153
+ apiDocJson(packageRoot, target),
154
+ );
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Verify each committed `api-doc/<module>.json` still equals a fresh extraction
160
+ * from its generated file, without rewriting — the `--check` counterpart to
161
+ * `writeApiDocs`. A missing or stale fixture reports `false`.
162
+ */
163
+ export function checkApiDocs(packageRoot: string): { module: string; ok: boolean }[] {
164
+ const { targets } = readTargets(packageRoot);
165
+ return targets.map((target) => {
166
+ const expected = apiDocJson(packageRoot, target);
167
+ let committed = "";
168
+ try {
169
+ committed = readFileSync(join(packageRoot, "api-doc", `${target.module}.json`), "utf8");
170
+ } catch {
171
+ committed = "";
172
+ }
173
+ return { module: target.module, ok: committed === expected };
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Verify the curated overrides are applied in the committed
179
+ * `library-descriptions.json` without doing a network fetch. Fetched-only
180
+ * descriptions use the committed file as the baseline and round-trip unchanged;
181
+ * stale override entries surface so `bun run sync --check` exits red.
182
+ */
183
+ export function checkDescriptions(packageRoot: string): string[] {
184
+ let committed: Record<string, string> = {};
185
+ const path = join(packageRoot, "library-descriptions.json");
186
+ if (existsSync(path)) {
187
+ committed = JSON.parse(readFileSync(path, "utf8")) as Record<string, string>;
188
+ }
189
+ const expected = mergeLibraryDescriptions(committed, readOverrides(packageRoot));
190
+ const allDirs = new Set<string>([...Object.keys(expected), ...Object.keys(committed)]);
191
+ return [...allDirs].filter((dir) => expected[dir] !== committed[dir]).sort();
192
+ }
193
+
194
+ /**
195
+ * A GitHub repo URL (`https://github.com/<owner>/<repo>[.git]`) reduced to the
196
+ * bare `<owner>/<repo>` slug used to address raw content and the git-trees API.
197
+ * A non-github URL is returned unchanged so callers can decide whether to skip
198
+ * it (the descriptions pass does; the raw-content / tree passes don't).
199
+ */
200
+ export function repoSlug(repo: string): string {
201
+ return repo
202
+ .replace(/^https:\/\/github\.com\//, "")
203
+ .replace(/\.git$/, "")
204
+ .replace(/\/$/, "");
205
+ }
206
+
207
+ const GITHUB_REPO_URL = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?\/?$/;
208
+
209
+ /**
210
+ * The raw.githubusercontent.com URL for a target's upstream `.d.ts` at the
211
+ * pinned commit. `source.repo` is the human `https://github.com/<owner>/<repo>`
212
+ * form; raw content is addressed by the bare `<owner>/<repo>` slug.
213
+ */
214
+ export function rawUrl(source: LibrarySource, target: LibraryTarget): string {
215
+ return `https://raw.githubusercontent.com/${repoSlug(source.repo)}/${source.commit}/${target.path}`;
216
+ }
217
+
218
+ export type DriftStatus = "ok" | "upstream-drift" | "transform-drift";
219
+
220
+ export interface DriftResult {
221
+ module: string;
222
+ status: DriftStatus;
223
+ }
224
+
225
+ export type FetchText = (url: string) => Promise<string>;
226
+
227
+ const defaultFetch: FetchText = async (url) => {
228
+ const res = await fetch(url);
229
+ if (!res.ok) {
230
+ throw new Error(`fetch failed: ${url} -> ${res.status} ${res.statusText}`);
231
+ }
232
+ return res.text();
233
+ };
234
+
235
+ /**
236
+ * Live-fetch each pinned upstream `.d.ts` and classify it against the committed
237
+ * files: `upstream-drift` when the fetched bytes no longer match the committed
238
+ * fixture (upstream moved off the pin), `transform-drift` when the fixture is
239
+ * unchanged but the committed generated file differs from a fresh codemod (the
240
+ * codemod or fixture changed without a `bun regen`). The `fetchText` seam keeps
241
+ * the classifier offline-testable; only the CLI wires the real network.
242
+ */
243
+ export async function checkDrift(
244
+ packageRoot: string,
245
+ fetchText: FetchText = defaultFetch,
246
+ ): Promise<DriftResult[]> {
247
+ const { source, targets } = readTargets(packageRoot);
248
+ const results: DriftResult[] = [];
249
+ for (const target of targets) {
250
+ const fetched = await fetchText(rawUrl(source, target));
251
+ const fixture = readFileSync(join(packageRoot, target.fixture), "utf8");
252
+ let status: DriftStatus;
253
+ if (fetched !== fixture) {
254
+ status = "upstream-drift";
255
+ } else {
256
+ const generated = readFileSync(join(packageRoot, target.generated), "utf8");
257
+ status = codemodDeclaration(fetched).output === generated ? "ok" : "transform-drift";
258
+ }
259
+ results.push({ module: target.module, status });
260
+ }
261
+ return results;
262
+ }
263
+
264
+ export type DirClassification = "pure-lua" | "native" | "already-vendored" | "covered-by-goal";
265
+
266
+ export interface ClassificationEntry {
267
+ dir: string;
268
+ classification: DirClassification;
269
+ modules: string[];
270
+ }
271
+
272
+ // A ts-defold/library `<name>-<version>.d.ts` alias file (e.g. `monarch-5.1.0`,
273
+ // `taptic_engine-1.2`) — the "latest" pointer, not a distinct module name.
274
+ const VERSION_ALIAS = /-\d+\.\d+(\.\d+)?$/;
275
+
276
+ /**
277
+ * Group the vendored module names in a ts-defold/library tree by library dir.
278
+ * Every library lives at `packages/<dir>/<module>.d.ts`; each contributes
279
+ * `<module>`, minus the versioned alias files (`<name>-<semver>.d.ts`). A dir
280
+ * is registered from any path under it, so a dir carrying only a `library.json`
281
+ * (or nothing but an alias) maps to `[]`. Root files, dot-prefixed dirs, and
282
+ * non-`packages/` paths are ignored. Module lists are sorted for stable output.
283
+ */
284
+ export function libraryModulesFromTree(paths: string[]): Map<string, string[]> {
285
+ const byDir = new Map<string, string[]>();
286
+ for (const p of paths) {
287
+ const segments = p.split("/");
288
+ if (segments[0] !== "packages" || segments.length < 3) continue;
289
+ const dir = segments[1];
290
+ if (dir === undefined || dir.startsWith(".")) continue;
291
+ if (!byDir.has(dir)) byDir.set(dir, []);
292
+ const file = segments[segments.length - 1];
293
+ if (file === undefined || !file.endsWith(".d.ts")) continue;
294
+ const moduleName = file.slice(0, -".d.ts".length);
295
+ if (VERSION_ALIAS.test(moduleName)) continue;
296
+ byDir.get(dir)?.push(moduleName);
297
+ }
298
+ for (const modules of byDir.values()) modules.sort();
299
+ return byDir;
300
+ }
301
+
302
+ /**
303
+ * Classify each library dir from its module-name shape. A Defold native
304
+ * extension registers a bare global module (`daabbcc`, `astar`), while a
305
+ * pure-Lua library is required by a dotted path (`monarch.monarch`,
306
+ * `richtext.richtext`) — so a dir is `pure-lua` iff it has at least one module
307
+ * and every module name is dotted; any bare module (or none at all) means
308
+ * `native`. The exclusion sets win: a vendored or goal-covered dir keeps that
309
+ * label regardless of shape. The module names are recorded as the classification
310
+ * evidence. Sorted by `dir` for a stable committed manifest.
311
+ */
312
+ export function classifyLibraryDirs(
313
+ dirs: { dir: string; modules: string[] }[],
314
+ opts: { vendoredDirs: ReadonlySet<string>; coveredByGoalDirs: ReadonlySet<string> },
315
+ ): ClassificationEntry[] {
316
+ return dirs
317
+ .map(({ dir, modules }): ClassificationEntry => {
318
+ let classification: DirClassification;
319
+ if (opts.vendoredDirs.has(dir)) {
320
+ classification = "already-vendored";
321
+ } else if (opts.coveredByGoalDirs.has(dir)) {
322
+ classification = "covered-by-goal";
323
+ } else if (modules.length > 0 && modules.every((m) => m.includes("."))) {
324
+ classification = "pure-lua";
325
+ } else {
326
+ classification = "native";
327
+ }
328
+ return { dir, classification, modules };
329
+ })
330
+ .sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
331
+ }
332
+
333
+ interface GithubTreeResponse {
334
+ tree?: { path: string }[];
335
+ }
336
+
337
+ interface GithubRepoResponse {
338
+ description?: string | null;
339
+ }
340
+
341
+ const githubHeaders = (): Record<string, string> => {
342
+ const token = process.env.GITHUB_TOKEN;
343
+ return token ? { Authorization: `Bearer ${token}` } : {};
344
+ };
345
+
346
+ async function githubTreePaths(slug: string, ref: string): Promise<string[]> {
347
+ const url = `https://api.github.com/repos/${slug}/git/trees/${ref}?recursive=1`;
348
+ const res = await fetch(url, { headers: githubHeaders() });
349
+ if (!res.ok) {
350
+ throw new Error(`git-trees fetch failed: ${url} -> ${res.status} ${res.statusText}`);
351
+ }
352
+ const body = (await res.json()) as GithubTreeResponse;
353
+ return (body.tree ?? []).map((e) => e.path);
354
+ }
355
+
356
+ /**
357
+ * Fetch the GitHub `description` field for `<owner>/<repo>` via the repos API.
358
+ * Returns the trimmed string, or `""` when the field is missing/null/blank.
359
+ * Network seam kept out of CI by the `--descriptions` CLI arm.
360
+ */
361
+ export type FetchRepoDescription = (owner: string, repo: string) => Promise<string>;
362
+
363
+ const defaultFetchRepoDescription: FetchRepoDescription = async (owner, repo) => {
364
+ const url = `https://api.github.com/repos/${owner}/${repo}`;
365
+ const res = await fetch(url, { headers: githubHeaders() });
366
+ if (!res.ok) {
367
+ throw new Error(`repo fetch failed: ${url} -> ${res.status} ${res.statusText}`);
368
+ }
369
+ const body = (await res.json()) as GithubRepoResponse;
370
+ return (body.description ?? "").trim();
371
+ };
372
+
373
+ /** Enumerate the ts-defold/library tree at the pinned commit. Network seam. */
374
+ export type ListTree = (source: LibrarySource) => Promise<string[]>;
375
+
376
+ const defaultListTree: ListTree = (source) => githubTreePaths(repoSlug(source.repo), source.commit);
377
+
378
+ /**
379
+ * Enumerate every ts-defold/library dir at the pin, classify each by its
380
+ * module-name shape, and write `library-classification.json`. The `listTree`
381
+ * seam keeps the pass offline-testable; only the CLI wires the real call, and it
382
+ * stays out of CI (mirrors `--check`). The manifest pins the same `source` as
383
+ * `library-targets.json`.
384
+ */
385
+ export async function writeClassification(
386
+ packageRoot: string,
387
+ seams: { listTree?: ListTree } = {},
388
+ ): Promise<void> {
389
+ const listTree = seams.listTree ?? defaultListTree;
390
+ const { source, targets } = readTargets(packageRoot);
391
+ const vendoredDirs = new Set(
392
+ targets.map((t) => t.path.split("/")[1]).filter((d): d is string => d !== undefined),
393
+ );
394
+ const coveredByGoalDirs = new Set(["defold-lldebugger", "defold-xmath"]);
395
+
396
+ const modulesByDir = libraryModulesFromTree(await listTree(source));
397
+ const dirs = [...modulesByDir].map(([dir, modules]) => ({ dir, modules }));
398
+ const entries = classifyLibraryDirs(dirs, { vendoredDirs, coveredByGoalDirs });
399
+ writeFileSync(
400
+ join(packageRoot, "library-classification.json"),
401
+ `${JSON.stringify({ source, dirs: entries }, null, 2)}\n`,
402
+ );
403
+ }
404
+
405
+ // `library-description-overrides.json` shape: `{ "<dir>": "<one-line description>" }`.
406
+ // Curated entries win over the fetched GitHub description, so empty or poorly-
407
+ // phrased upstream descriptions can be patched without a network run. The
408
+ // committed map seeds every dir that has no `info.description` in its api-doc
409
+ // fixture, so a fresh checkout ships every library page with a non-empty intro.
410
+ export type DescriptionOverrides = Readonly<Record<string, string>>;
411
+
412
+ function readOverrides(packageRoot: string): DescriptionOverrides {
413
+ const path = join(packageRoot, "library-description-overrides.json");
414
+ if (!existsSync(path)) return {};
415
+ return JSON.parse(readFileSync(path, "utf8")) as DescriptionOverrides;
416
+ }
417
+
418
+ /**
419
+ * Pure merge of `fetched` descriptions with the curated `overrides` map.
420
+ * Override wins per-dir; a missing or blank value (in either source) means the
421
+ * dir is dropped; output is sorted by key for stable committed output.
422
+ */
423
+ export function mergeLibraryDescriptions(
424
+ fetched: Readonly<Record<string, string>>,
425
+ overrides: Readonly<Record<string, string>>,
426
+ ): Record<string, string> {
427
+ const dirs = new Set<string>([...Object.keys(fetched), ...Object.keys(overrides)]);
428
+ const merged: Record<string, string> = {};
429
+ for (const dir of dirs) {
430
+ const text = (overrides[dir] ?? fetched[dir] ?? "").trim();
431
+ if (text.length > 0) merged[dir] = text;
432
+ }
433
+ return Object.fromEntries(
434
+ Object.entries(merged).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
435
+ );
436
+ }
437
+
438
+ // A NOTICE credit line: `- <dir> — <author>, <url>`.
439
+ const NOTICE_CREDIT = /^\s*-\s+(\S+)\s+—\s+(.+?),\s+(https?:\/\/\S+)\s*$/;
440
+
441
+ /**
442
+ * Read the `NOTICE` credit table and resolve each dir's upstream `owner/repo`
443
+ * from its GitHub URL. A non-github URL or an entry with no URL is skipped — the
444
+ * vendored library still gets a description from `overrides`, but no network
445
+ * fetch is attempted for it.
446
+ */
447
+ function parseNoticeRepoSlugs(packageRoot: string): Map<string, { owner: string; repo: string }> {
448
+ const path = join(packageRoot, "NOTICE");
449
+ if (!existsSync(path)) return new Map();
450
+ const slugs = new Map<string, { owner: string; repo: string }>();
451
+ for (const raw of readFileSync(path, "utf8").split("\n")) {
452
+ const match = NOTICE_CREDIT.exec(raw);
453
+ if (!match) continue;
454
+ const dir = match[1];
455
+ const url = match[3];
456
+ if (!dir || !url) continue;
457
+ const gh = GITHUB_REPO_URL.exec(url);
458
+ if (!gh) continue;
459
+ const owner = gh[1];
460
+ const repo = gh[2];
461
+ if (!owner || !repo) continue;
462
+ slugs.set(dir, { owner, repo });
463
+ }
464
+ return slugs;
465
+ }
466
+
467
+ /**
468
+ * Fetch each NOTICE-credited upstream's GitHub `description`, apply the curated
469
+ * overrides, and write `library-descriptions.json` as a sorted `{ dir: text }`
470
+ * map. Dirs with no upstream GitHub URL contribute only via overrides; dirs
471
+ * that fetch to empty AND have no override are dropped.
472
+ */
473
+ export async function writeDescriptions(
474
+ packageRoot: string,
475
+ seams: {
476
+ fetchRepoDescription?: FetchRepoDescription;
477
+ overrides?: DescriptionOverrides;
478
+ } = {},
479
+ ): Promise<void> {
480
+ const fetchRepoDescription = seams.fetchRepoDescription ?? defaultFetchRepoDescription;
481
+ const overrides = seams.overrides ?? readOverrides(packageRoot);
482
+
483
+ const slugs = parseNoticeRepoSlugs(packageRoot);
484
+ const fetched: Record<string, string> = {};
485
+ for (const [dir, { owner, repo }] of slugs) {
486
+ fetched[dir] = await fetchRepoDescription(owner, repo);
487
+ }
488
+ writeFileSync(
489
+ join(packageRoot, "library-descriptions.json"),
490
+ `${JSON.stringify(mergeLibraryDescriptions(fetched, overrides), null, 2)}\n`,
491
+ );
492
+ }
493
+
494
+ if (import.meta.main) {
495
+ const root = join(import.meta.dir, "..");
496
+ const argv = process.argv.slice(2);
497
+ if (argv.includes("--classify")) {
498
+ await writeClassification(root);
499
+ const { dirs } = JSON.parse(
500
+ readFileSync(join(root, "library-classification.json"), "utf8"),
501
+ ) as { dirs: ClassificationEntry[] };
502
+ const counts = new Map<DirClassification, number>();
503
+ for (const e of dirs) counts.set(e.classification, (counts.get(e.classification) ?? 0) + 1);
504
+ console.log(`classified ${dirs.length} upstream dir(s) from ts-defold/library`);
505
+ for (const [classification, n] of [...counts].sort()) {
506
+ console.log(` ${classification}: ${n}`);
507
+ }
508
+ } else if (argv.includes("--descriptions")) {
509
+ await writeDescriptions(root);
510
+ const descriptions = JSON.parse(
511
+ readFileSync(join(root, "library-descriptions.json"), "utf8"),
512
+ ) as Record<string, string>;
513
+ console.log(
514
+ `wrote ${Object.keys(descriptions).length} description(s) to library-descriptions.json`,
515
+ );
516
+ } else if (argv.includes("--check")) {
517
+ const results = await checkDrift(root);
518
+ console.log(`checked ${results.length} vendored target(s) against ts-defold/library`);
519
+ for (const { module, status } of results) {
520
+ console.log(` ${status}: ${module}`);
521
+ }
522
+ const apiDocs = checkApiDocs(root);
523
+ console.log(`checked ${apiDocs.length} api-doc fixture(s)`);
524
+ for (const { module, ok } of apiDocs) {
525
+ if (!ok) console.log(` api-doc-drift: ${module}`);
526
+ }
527
+ const descriptionDrift = checkDescriptions(root);
528
+ if (descriptionDrift.length > 0) {
529
+ console.log(`checked library-descriptions.json`);
530
+ for (const dir of descriptionDrift) console.log(` description-drift: ${dir}`);
531
+ }
532
+ if (
533
+ results.some((r) => r.status !== "ok") ||
534
+ apiDocs.some((r) => !r.ok) ||
535
+ descriptionDrift.length > 0
536
+ ) {
537
+ process.exitCode = 1;
538
+ }
539
+ } else {
540
+ regenerate(root);
541
+ }
542
+ }