@defold-typescript/library-types 0.22.0 → 0.24.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.
Files changed (145) hide show
  1. package/api-doc/boom.json +3870 -0
  2. package/api-doc/{bridge.bridge.json → bridge.json} +754 -844
  3. package/api-doc/bzAnim.json +338 -0
  4. package/api-doc/decore.json +23 -23
  5. package/api-doc/defcon.json +150 -0
  6. package/api-doc/{defmath.defmath.json → defmath.json} +6 -46
  7. package/api-doc/defsave.json +518 -0
  8. package/api-doc/deftest.json +741 -0
  9. package/api-doc/{dicebag.dicebag.json → dicebag.json} +19 -1
  10. package/api-doc/druid.json +249 -1846
  11. package/api-doc/event.json +1074 -0
  12. package/api-doc/gooey.json +2050 -0
  13. package/api-doc/immutable.json +65 -0
  14. package/api-doc/in.accelerometer.json +75 -1
  15. package/api-doc/in.button.json +17 -10
  16. package/api-doc/in.cursor.json +26 -0
  17. package/api-doc/in.gesture.json +401 -6
  18. package/api-doc/in.keyboard.json +2 -0
  19. package/api-doc/in.mapper.json +12 -8
  20. package/api-doc/in.onscreen.json +61 -5
  21. package/api-doc/in.state.json +61 -13
  22. package/api-doc/in.textbox.json +23 -12
  23. package/api-doc/in.triggers.json +336 -0
  24. package/api-doc/lang.json +528 -0
  25. package/api-doc/{event.event.json → log.json} +96 -101
  26. package/api-doc/metrics.fps.json +61 -2
  27. package/api-doc/metrics.mem.json +61 -2
  28. package/api-doc/monarch.monarch.json +249 -7
  29. package/api-doc/monarch.transitions.easings.json +19 -26
  30. package/api-doc/monarch.transitions.gui.json +782 -79
  31. package/api-doc/nakama.engine.defold.json +31 -18
  32. package/api-doc/{nakama.nakama.json → nakama.json} +4752 -2554
  33. package/api-doc/nakama.util.log.json +32 -4
  34. package/api-doc/narrator.json +647 -0
  35. package/api-doc/openapi/nakama.nakama.json +7680 -0
  36. package/api-doc/orthographic.camera.json +96 -98
  37. package/api-doc/orthographic.json +602 -0
  38. package/api-doc/panthera.json +1183 -0
  39. package/api-doc/{persist.persist.json → persist.json} +26 -1
  40. package/api-doc/{platypus.platypus.json → platypus.json} +68 -14
  41. package/api-doc/proto.json +995 -0
  42. package/api-doc/{rendy.rendy.json → rendy.json} +46 -99
  43. package/api-doc/richtext.color.json +77 -0
  44. package/api-doc/richtext.richtext.json +375 -5
  45. package/api-doc/saver.saver.json +714 -263
  46. package/api-doc/saver.storage.json +283 -52
  47. package/api-doc/squid.json +846 -0
  48. package/api-doc/{starly.starly.json → starly.json} +1 -1
  49. package/api-doc/tweener.json +277 -0
  50. package/api-doc/{yagames.yagames.json → yagames.json} +784 -189
  51. package/api-doc/{zzfx.api.json → zzfx.json} +9 -9
  52. package/authored-targets.json +403 -0
  53. package/generated/{boom.boom.d.ts → boom.d.ts} +14 -0
  54. package/generated/bridge.d.ts +468 -0
  55. package/generated/{bzAnim.bzLibrary.d.ts → bzAnim.d.ts} +6 -0
  56. package/generated/decore.d.ts +37 -37
  57. package/generated/defcon.d.ts +43 -0
  58. package/generated/{defmath.defmath.d.ts → defmath.d.ts} +3 -2
  59. package/generated/defsave.d.ts +144 -0
  60. package/generated/{dicebag.dicebag.d.ts → dicebag.d.ts} +25 -0
  61. package/generated/druid.d.ts +198 -502
  62. package/generated/event.d.ts +318 -0
  63. package/generated/{gooey.gooey.d.ts → gooey.d.ts} +61 -6
  64. package/generated/immutable.d.ts +13 -0
  65. package/generated/in.onscreen.d.ts +5 -0
  66. package/generated/in.textbox.d.ts +4 -0
  67. package/generated/lang.d.ts +101 -0
  68. package/generated/log.d.ts +36 -0
  69. package/generated/metrics.fps.d.ts +19 -1
  70. package/generated/metrics.mem.d.ts +19 -1
  71. package/generated/monarch.monarch.d.ts +44 -1
  72. package/generated/monarch.transitions.easings.d.ts +0 -1
  73. package/generated/monarch.transitions.gui.d.ts +96 -12
  74. package/generated/nakama.d.ts +1158 -0
  75. package/generated/nakama.engine.defold.d.ts +3 -1
  76. package/generated/nakama.util.log.d.ts +6 -0
  77. package/generated/narrator.d.ts +121 -0
  78. package/generated/openapi/nakama.nakama.d.ts +884 -0
  79. package/generated/orthographic.camera.d.ts +24 -38
  80. package/generated/orthographic.d.ts +155 -0
  81. package/generated/panthera.d.ts +170 -0
  82. package/generated/{persist.persist.d.ts → persist.d.ts} +3 -0
  83. package/generated/{platypus.platypus.d.ts → platypus.d.ts} +9 -0
  84. package/generated/proto.d.ts +146 -0
  85. package/generated/{rendy.rendy.d.ts → rendy.d.ts} +33 -80
  86. package/generated/richtext.color.d.ts +15 -0
  87. package/generated/saver.saver.d.ts +289 -42
  88. package/generated/saver.storage.d.ts +79 -14
  89. package/generated/squid.d.ts +127 -0
  90. package/generated/tweener.d.ts +42 -0
  91. package/generated/{yagames.yagames.d.ts → yagames.d.ts} +151 -29
  92. package/generated/{zzfx.api.d.ts → zzfx.d.ts} +7 -0
  93. package/library-classification.json +0 -227
  94. package/library-targets.json +1 -284
  95. package/luals-targets.json +168 -1
  96. package/openapi-targets.json +17 -0
  97. package/package.json +9 -88
  98. package/script-api-targets.json +15 -0
  99. package/scripts/__snapshots__/parse-luals.test.ts.snap +504 -85
  100. package/scripts/apply-luals-overrides.ts +95 -0
  101. package/scripts/authored-parity.ts +718 -0
  102. package/scripts/emit-library-dts.ts +177 -19
  103. package/scripts/extract-api-doc.ts +189 -38
  104. package/scripts/fidelity-floor.ts +178 -0
  105. package/scripts/import-upstream-docs.ts +125 -0
  106. package/scripts/lower-api-doc.ts +69 -19
  107. package/scripts/luals-fidelity.ts +22 -10
  108. package/scripts/map-luals-types.ts +86 -22
  109. package/scripts/parse-lua-surface.ts +187 -0
  110. package/scripts/parse-luals.ts +473 -22
  111. package/scripts/parse-markdown-api.ts +247 -0
  112. package/scripts/parse-openapi-api.ts +313 -0
  113. package/scripts/sync-authored-types.ts +249 -0
  114. package/scripts/sync-library-types.ts +99 -3
  115. package/scripts/sync-luals-types.ts +29 -2
  116. package/scripts/sync-markdown-types.ts +761 -0
  117. package/scripts/sync-openapi-types.ts +308 -0
  118. package/scripts/sync-script-api-types.ts +367 -0
  119. package/api-doc/boom.boom.json +0 -26
  120. package/api-doc/bzAnim.bzLibrary.json +0 -156
  121. package/api-doc/defcon.console.json +0 -109
  122. package/api-doc/defsave.defsave.json +0 -175
  123. package/api-doc/deftest.deftest.json +0 -71
  124. package/api-doc/gooey.gooey.json +0 -963
  125. package/api-doc/immutable.immutable.json +0 -63
  126. package/api-doc/lang.lang.json +0 -411
  127. package/api-doc/log.log.json +0 -50
  128. package/api-doc/narrator.narrator.json +0 -150
  129. package/api-doc/proto.proto.json +0 -355
  130. package/api-doc/squid.squid.json +0 -660
  131. package/api-doc/tweener.tweener.json +0 -419
  132. package/generated/bridge.bridge.d.ts +0 -533
  133. package/generated/defcon.console.d.ts +0 -24
  134. package/generated/defsave.defsave.d.ts +0 -31
  135. package/generated/event.event.d.ts +0 -54
  136. package/generated/immutable.immutable.d.ts +0 -13
  137. package/generated/lang.lang.d.ts +0 -33
  138. package/generated/log.log.d.ts +0 -40
  139. package/generated/nakama.nakama.d.ts +0 -594
  140. package/generated/narrator.narrator.d.ts +0 -66
  141. package/generated/proto.proto.d.ts +0 -36
  142. package/generated/squid.squid.d.ts +0 -106
  143. package/generated/tweener.tweener.d.ts +0 -151
  144. /package/generated/{deftest.deftest.d.ts → deftest.d.ts} +0 -0
  145. /package/generated/{starly.starly.d.ts → starly.d.ts} +0 -0
@@ -0,0 +1,761 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { FidelityReport } from "./luals-fidelity";
4
+ import { type MarkdownDoc, parseMarkdownApi } from "./parse-markdown-api";
5
+ import {
6
+ computeScriptApiFidelity,
7
+ type FetchText,
8
+ loadTypeResolver,
9
+ type ScriptApiDoc,
10
+ type TypeResolver,
11
+ } from "./sync-script-api-types";
12
+
13
+ /**
14
+ * The markdown ingestion front-end: a third `library-types` corpus mode beside
15
+ * the LuaLS front-end (`sync-luals-types.ts`) and the `.script_api` front-end
16
+ * (`sync-script-api-types.ts`). It reads a library's committed README/`.md`
17
+ * snapshot (`parseMarkdownApi`) into the shared ref-doc `doc` shape and routes it
18
+ * through the same emitter (`generateModuleDeclaration`) and fidelity machinery.
19
+ *
20
+ * The README documents its API under the library's own require alias (e.g.
21
+ * `camera.` for defold-orthographic); each target pins the `namespace` this repo
22
+ * publishes it under, so the parsed doc is *retargeted* onto that namespace
23
+ * before emitting — the goldens are named for the target's `namespace` verbatim
24
+ * (`generated/<namespace>.d.ts` — so `orthographic` lands at
25
+ * `generated/orthographic.d.ts`), keeping the file layout uniform with druid.
26
+ *
27
+ * A markdown parse is likely **lower fidelity** than the hand-written ts-defold
28
+ * `.d.ts` it would replace (a README rarely documents constants or every
29
+ * overload), so each cutover is gated: `compareFidelityToTsDefold` reports the
30
+ * member surface lost versus the retired ts-defold declaration, and the target's
31
+ * recorded `decision` (`go` / `no-go`) must match. The parser + front-end land as
32
+ * the reusable foundation either way; only the per-library cutover is gated.
33
+ */
34
+ export interface MarkdownTarget {
35
+ repo: string;
36
+ ref: string;
37
+ markdown: string;
38
+ moduleId: string;
39
+ namespace: string;
40
+ generated: string;
41
+ apiDoc: string;
42
+ // Defaults to `fidelity/<namespace>.json` when omitted.
43
+ fidelity: string;
44
+ // SPDX-style license id, surfaced by the docs-site provenance block. Optional
45
+ // in the config; defaults to "".
46
+ license: string;
47
+ // The recorded fidelity go/no-go decision for this target. `no-go` keeps the
48
+ // library ts-defold-sourced; `go` cuts it over. Optional until the gate runs.
49
+ decision?: "go" | "no-go";
50
+ }
51
+
52
+ export interface MarkdownTargets {
53
+ targets: MarkdownTarget[];
54
+ }
55
+
56
+ const REQUIRED_FIELDS = [
57
+ "repo",
58
+ "ref",
59
+ "markdown",
60
+ "moduleId",
61
+ "namespace",
62
+ "generated",
63
+ "apiDoc",
64
+ ] as const;
65
+
66
+ /**
67
+ * Read `markdown-targets.json`, validate every required field per entry, and fill
68
+ * optional defaults (`fidelity` → `fidelity/<namespace>.json`, `license` → "").
69
+ * Throws on the first missing field naming both the field and the offending entry
70
+ * (its `moduleId`, or its index when `moduleId` itself is absent) — the loud-fail
71
+ * discipline `readScriptApiTargets`/`readLualsTargets` use. No network.
72
+ */
73
+ export function readMarkdownTargets(packageRoot: string): MarkdownTarget[] {
74
+ const parsed = JSON.parse(readFileSync(join(packageRoot, "markdown-targets.json"), "utf8")) as {
75
+ targets: Partial<MarkdownTarget>[];
76
+ };
77
+ return parsed.targets.map((entry, index) => {
78
+ const label = typeof entry.moduleId === "string" ? entry.moduleId : `index ${index}`;
79
+ for (const field of REQUIRED_FIELDS) {
80
+ if (entry[field] === undefined) {
81
+ throw new Error(
82
+ `markdown-targets.json: entry ${label} is missing required field "${field}".`,
83
+ );
84
+ }
85
+ }
86
+ return {
87
+ repo: entry.repo as string,
88
+ ref: entry.ref as string,
89
+ markdown: entry.markdown as string,
90
+ moduleId: entry.moduleId as string,
91
+ namespace: entry.namespace as string,
92
+ generated: entry.generated as string,
93
+ apiDoc: entry.apiDoc as string,
94
+ fidelity: entry.fidelity ?? `fidelity/${entry.namespace as string}.json`,
95
+ license: entry.license ?? "",
96
+ ...(entry.decision !== undefined ? { decision: entry.decision } : {}),
97
+ };
98
+ });
99
+ }
100
+
101
+ /** A GitHub repo URL reduced to the bare `<owner>/<repo>` slug. */
102
+ function repoSlug(repo: string): string {
103
+ return repo
104
+ .replace(/^https:\/\/github\.com\//, "")
105
+ .replace(/\.git$/, "")
106
+ .replace(/\/$/, "");
107
+ }
108
+
109
+ function rawUrl(target: MarkdownTarget): string {
110
+ return `https://raw.githubusercontent.com/${repoSlug(target.repo)}/${target.ref}/${target.markdown}`;
111
+ }
112
+
113
+ function fixturePath(packageRoot: string, target: MarkdownTarget): string {
114
+ return join(packageRoot, "fixtures/markdown", `${target.moduleId}.md`);
115
+ }
116
+
117
+ /**
118
+ * Snapshot the pinned README/`.md` into `fixtures/markdown/<moduleId>.md` via the
119
+ * raw-content URL. Snapshot only — no parse. The `fetchText` seam keeps the pass
120
+ * offline-testable; only the CLI `--fetch` arm wires the real network.
121
+ */
122
+ export async function fetchMarkdownFixture(
123
+ packageRoot: string,
124
+ target: MarkdownTarget,
125
+ seams: { fetchText: FetchText },
126
+ ): Promise<void> {
127
+ const text = await seams.fetchText(rawUrl(target));
128
+ const dest = fixturePath(packageRoot, target);
129
+ mkdirSync(dirname(dest), { recursive: true });
130
+ writeFileSync(dest, text);
131
+ }
132
+
133
+ interface RegenModule {
134
+ generateModuleDeclaration: (entry: {
135
+ namespace: string;
136
+ doc: unknown;
137
+ outFile: string;
138
+ importsFrom?: string;
139
+ moduleId?: string;
140
+ }) => { contents: string; dropped: string[] };
141
+ }
142
+
143
+ // orthographic references only ambient engine handles (`hash`/`url`/`vmath.*`),
144
+ // which the module-wrapped form emits as globals, so this import never reaches
145
+ // the golden; it mirrors the script_api front-end for the day a markdown target
146
+ // needs a core-types import.
147
+ const MARKDOWN_CORE_TYPES_IMPORT = "../src/core-types";
148
+
149
+ async function loadGenerate(
150
+ packageRoot: string,
151
+ ): Promise<RegenModule["generateModuleDeclaration"]> {
152
+ const typesRoot = join(packageRoot, "..", "types");
153
+ const regen = (await import(join(typesRoot, "scripts", "regen.ts"))) as RegenModule;
154
+ return regen.generateModuleDeclaration;
155
+ }
156
+
157
+ function parseFixtureDoc(packageRoot: string, target: MarkdownTarget): MarkdownDoc {
158
+ const text = readFileSync(fixturePath(packageRoot, target), "utf8");
159
+ return parseMarkdownApi(text, target.moduleId);
160
+ }
161
+
162
+ /**
163
+ * Retarget the parsed doc onto the pinned publish namespace: rewrite each
164
+ * element's leading `<readmePrefix>.` to `<namespace>.` and set `info.namespace`.
165
+ * The README's own alias (`camera`) becomes the target namespace (`orthographic`).
166
+ */
167
+ export function retargetDoc(doc: MarkdownDoc, namespace: string): MarkdownDoc {
168
+ const oldPrefix = `${doc.info.namespace}.`;
169
+ return {
170
+ info: { ...doc.info, namespace },
171
+ elements: doc.elements.map((element) => ({
172
+ ...element,
173
+ name: element.name.startsWith(oldPrefix)
174
+ ? `${namespace}.${element.name.slice(oldPrefix.length)}`
175
+ : element.name,
176
+ })),
177
+ };
178
+ }
179
+
180
+ function retargetedFixtureDoc(packageRoot: string, target: MarkdownTarget): MarkdownDoc {
181
+ return retargetDoc(parseFixtureDoc(packageRoot, target), target.namespace);
182
+ }
183
+
184
+ /**
185
+ * `parseMarkdownApi` -> retarget -> `generateModuleDeclaration`. Returns an
186
+ * importable module keyed by `moduleId` (`declare module '<moduleId>'`).
187
+ */
188
+ export async function emitMarkdownDeclaration(
189
+ packageRoot: string,
190
+ target: MarkdownTarget,
191
+ ): Promise<string> {
192
+ const generate = await loadGenerate(packageRoot);
193
+ const doc = retargetedFixtureDoc(packageRoot, target);
194
+ const { contents } = generate({
195
+ namespace: target.namespace,
196
+ doc,
197
+ outFile: `${target.moduleId}.d.ts`,
198
+ importsFrom: MARKDOWN_CORE_TYPES_IMPORT,
199
+ moduleId: target.moduleId,
200
+ });
201
+ return contents;
202
+ }
203
+
204
+ /** The api-doc golden is the retargeted ref-doc `doc` itself, pretty-printed. */
205
+ export function lowerMarkdownApiDoc(packageRoot: string, target: MarkdownTarget): string {
206
+ const doc = retargetedFixtureDoc(packageRoot, target);
207
+ return `${JSON.stringify(doc, null, 2)}\n`;
208
+ }
209
+
210
+ // Type tokens a human triaged as acceptably lossy for a markdown cutover. The
211
+ // allowlist is drained — every token orthographic's README uses now maps to a
212
+ // real TS type — but the mechanism stays: a new sibling's unexpected token
213
+ // loud-fails at regen until it is mapped or deliberately added here.
214
+ const KNOWN_LOSSY_TOKENS = new Set<string>([]);
215
+
216
+ /**
217
+ * markdown-scoped wrapper over the shared `computeScriptApiFidelity`. After the
218
+ * report is built, any `unknownToken` outside `KNOWN_LOSSY_TOKENS` loud-fails —
219
+ * a brand-new library's unclassified token must not be swallowed as `unknown` at
220
+ * regen time. The shared `computeScriptApiFidelity` is left untouched so the
221
+ * script_api/luals goldens (which legitimately carry unknown tokens) stay green.
222
+ */
223
+ export function computeMarkdownFidelity(
224
+ namespace: string,
225
+ doc: MarkdownDoc,
226
+ resolver: TypeResolver,
227
+ ): FidelityReport {
228
+ const report = computeScriptApiFidelity(namespace, doc as unknown as ScriptApiDoc, resolver);
229
+ const unmappable = report.unknownTokens.filter((token) => !KNOWN_LOSSY_TOKENS.has(token));
230
+ if (unmappable.length > 0) {
231
+ throw new Error(
232
+ `markdown fidelity [${namespace}]: unmappable type token(s) ${JSON.stringify(unmappable)} — resolve them or add to KNOWN_LOSSY_TOKENS`,
233
+ );
234
+ }
235
+ return report;
236
+ }
237
+
238
+ export async function buildMarkdownFidelity(
239
+ packageRoot: string,
240
+ target: MarkdownTarget,
241
+ ): Promise<FidelityReport> {
242
+ const resolver = await loadTypeResolver(packageRoot);
243
+ const doc = retargetedFixtureDoc(packageRoot, target);
244
+ return computeMarkdownFidelity(target.namespace, doc, resolver);
245
+ }
246
+
247
+ /** The result of comparing an emitted markdown surface against the ts-defold
248
+ * `.d.ts` it would replace. `decision` is `no-go` whenever a ts-defold member is
249
+ * absent from the markdown emit (a missing member), **or** a member both surfaces
250
+ * share was downgraded to `unknown` by the markdown emit (a lost type), **or** a
251
+ * shared member kept its name but lost part of its signature (dropped parameters,
252
+ * or a non-`void` return collapsed to `void`), **or** a shared member kept its
253
+ * arity but emitted an optional ts-defold parameter as required, **or** the
254
+ * ts-defold side presents no comparable member at all (an opaque surface), which
255
+ * would otherwise leave every other term vacuously empty and read as `go`. */
256
+ export interface FidelityComparison {
257
+ tsDefoldMembers: string[];
258
+ markdownMembers: string[];
259
+ missingMembers: string[];
260
+ addedMembers: string[];
261
+ downgradedMembers: string[];
262
+ signatureLossMembers: string[];
263
+ optionalityLossMembers: string[];
264
+ opaqueTsDefoldSurface: boolean;
265
+ decision: "go" | "no-go";
266
+ }
267
+
268
+ interface TsDefoldMember {
269
+ kind: "function" | "const";
270
+ signature: string;
271
+ }
272
+
273
+ // Optional `export`, then `function`/`const`, then the member name. Bare (no
274
+ // `export`) declarations are valid inside `declare module` and the markdown
275
+ // emitter produces them, so `export` must not be required.
276
+ const MEMBER_DECL = /(?:export\s+)?(function|const)\s+([A-Za-z_]\w*)/g;
277
+
278
+ /** Strip block and line comments so keyword-shaped prose inside a doc comment
279
+ * (e.g. "This function is called…") never latches onto `MEMBER_DECL`, and so a
280
+ * `unknown` mentioned in JSDoc never reads as a real type downgrade. A single
281
+ * left-to-right scan decides at each `/` which form opens, so `//*` — a section
282
+ * marker some bindings use — reads as a line comment rather than as a block
283
+ * comment running to the next block-comment terminator many declarations
284
+ * later. */
285
+ function stripComments(dts: string): string {
286
+ let out = "";
287
+ let index = 0;
288
+ while (index < dts.length) {
289
+ if (dts[index] === "/" && dts[index + 1] === "/") {
290
+ const end = dts.indexOf("\n", index);
291
+ if (end === -1) break;
292
+ index = end;
293
+ } else if (dts[index] === "/" && dts[index + 1] === "*") {
294
+ const end = dts.indexOf("*/", index + 2);
295
+ index = end === -1 ? dts.length : end + 2;
296
+ } else {
297
+ out += dts[index];
298
+ index++;
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+
304
+ /** Scan forward from `start` to the index of the first `end` character that sits
305
+ * at group depth zero, so a delimiter inside `()`/`[]`/`{}`/`<>` never
306
+ * terminates the construct being read. `-1` when the source runs out. */
307
+ function findAtDepthZero(src: string, start: number, end: string): number {
308
+ let depth = 0;
309
+ for (let i = start; i < src.length; i++) {
310
+ if (src[i] === end && depth === 0) return i;
311
+ if (opensGroup(src[i])) depth++;
312
+ else if (closesGroup(src, i)) depth--;
313
+ }
314
+ return -1;
315
+ }
316
+
317
+ /** Every `interface Name { … }` body and `type Name = …` right-hand side the
318
+ * source declares, keyed by name. Both forms are read by brace/angle balancing
319
+ * rather than by line, so a multiline body or a generic argument list carrying
320
+ * its own delimiters stays intact. */
321
+ function declaredShapes(src: string): Map<string, string> {
322
+ const shapes = new Map<string, string>();
323
+ const decl = /\b(interface|type)\s+([A-Za-z_]\w*)/g;
324
+ let match = decl.exec(src);
325
+ while (match !== null) {
326
+ const name = match[2] as string;
327
+ if (match[1] === "interface") {
328
+ const open = src.indexOf("{", decl.lastIndex);
329
+ if (open !== -1) {
330
+ const close = findAtDepthZero(src, open + 1, "}");
331
+ if (close !== -1 && !shapes.has(name)) shapes.set(name, src.slice(open, close + 1));
332
+ }
333
+ } else {
334
+ const assign = findAtDepthZero(src, decl.lastIndex, "=");
335
+ if (assign !== -1) {
336
+ const end = findAtDepthZero(src, assign + 1, ";");
337
+ const body = src.slice(assign + 1, end === -1 ? src.length : end);
338
+ if (!shapes.has(name)) shapes.set(name, body);
339
+ }
340
+ }
341
+ match = decl.exec(src);
342
+ }
343
+ return shapes;
344
+ }
345
+
346
+ /** Split a type expression on its top-level `&`, so an intersection arm keeps
347
+ * any `&` nested inside its own generic arguments. */
348
+ function splitIntersection(expression: string): string[] {
349
+ const arms: string[] = [];
350
+ let depth = 0;
351
+ let current = "";
352
+ for (let i = 0; i < expression.length; i++) {
353
+ const ch = expression[i] as string;
354
+ if (opensGroup(ch)) depth++;
355
+ else if (closesGroup(expression, i)) depth--;
356
+ if (ch === "&" && depth === 0) {
357
+ arms.push(current);
358
+ current = "";
359
+ continue;
360
+ }
361
+ current += ch;
362
+ }
363
+ arms.push(current);
364
+ return arms.map((arm) => arm.trim()).filter((arm) => arm !== "");
365
+ }
366
+
367
+ /** The inner type of a `Readonly<X>` wrapper, or `undefined` when the
368
+ * expression is not one. `Readonly<>` only restates members as read-only, so
369
+ * the module surface underneath it is the same surface. */
370
+ function readonlyInner(expression: string): string | undefined {
371
+ if (!expression.startsWith("Readonly<") || !expression.endsWith(">")) return undefined;
372
+ const inner = expression.slice("Readonly<".length, -1);
373
+ return findAtDepthZero(inner, 0, ">") === -1 ? inner : undefined;
374
+ }
375
+
376
+ /** The members of an object body (`{ … }`), split on its top-level `;`/`,`
377
+ * separators. A member with a call signature normalizes to a `function`
378
+ * declaration and a field to a `const` one, so `signatureShape`,
379
+ * `isOptionalParameter` and the `unknown` test read a resolved member exactly
380
+ * as they read a flat one. */
381
+ function objectBodyMembers(body: string): Map<string, TsDefoldMember> {
382
+ const members = new Map<string, TsDefoldMember>();
383
+ const inner = body.trim().replace(/^\{/, "").replace(/\}$/, "");
384
+ const segments: string[] = [];
385
+ let depth = 0;
386
+ let current = "";
387
+ for (let i = 0; i < inner.length; i++) {
388
+ const ch = inner[i] as string;
389
+ if (opensGroup(ch)) depth++;
390
+ else if (closesGroup(inner, i)) depth--;
391
+ if ((ch === ";" || ch === ",") && depth === 0) {
392
+ segments.push(current);
393
+ current = "";
394
+ continue;
395
+ }
396
+ current += ch;
397
+ }
398
+ segments.push(current);
399
+ for (const segment of segments) {
400
+ const text = segment
401
+ .trim()
402
+ .replace(/^readonly\s+/, "")
403
+ .replace(/\s+/g, " ");
404
+ const head = /^([A-Za-z_]\w*)\s*\??\s*([(:])/.exec(text);
405
+ if (head === null) continue;
406
+ const name = head[1] as string;
407
+ if (members.has(name)) continue;
408
+ if (head[2] === "(") {
409
+ const open = text.indexOf("(");
410
+ const close = findAtDepthZero(text, open + 1, ")");
411
+ if (close === -1) continue;
412
+ const parameters = text.slice(open + 1, close).trim();
413
+ const tail = text.slice(close + 1).trim();
414
+ const returnType = tail.startsWith(":") ? tail.slice(1).trim() : "";
415
+ members.set(name, {
416
+ kind: "function",
417
+ signature: `function ${name}(${parameters})${returnType === "" ? "" : `: ${returnType}`};`,
418
+ });
419
+ } else {
420
+ const type = text.slice(text.indexOf(":") + 1).trim();
421
+ members.set(name, { kind: "const", signature: `const ${name}: ${type};` });
422
+ }
423
+ }
424
+ return members;
425
+ }
426
+
427
+ /** Resolve a type expression to the members it publishes: an inline object body,
428
+ * a named `interface`/`type` the source declares, a `Readonly<>` wrapper, or the
429
+ * union of an intersection's arms. An arm that names no object shape — an
430
+ * undeclared token, a `LuaMap<…>`, a primitive — contributes nothing instead of
431
+ * failing, so a partly-opaque handle still yields the part that is readable. */
432
+ function resolveShapeMembers(
433
+ expression: string,
434
+ shapes: Map<string, string>,
435
+ seen: Set<string> = new Set(),
436
+ ): Map<string, TsDefoldMember> {
437
+ const text = expression.trim();
438
+ const arms = splitIntersection(text);
439
+ if (arms.length > 1) {
440
+ const merged = new Map<string, TsDefoldMember>();
441
+ for (const arm of arms) {
442
+ for (const [name, member] of resolveShapeMembers(arm, shapes, seen)) {
443
+ if (!merged.has(name)) merged.set(name, member);
444
+ }
445
+ }
446
+ return merged;
447
+ }
448
+ if (text.startsWith("{") && text.endsWith("}")) return objectBodyMembers(text);
449
+ const unwrapped = readonlyInner(text);
450
+ if (unwrapped !== undefined) return resolveShapeMembers(unwrapped, shapes, seen);
451
+ if (!/^[A-Za-z_]\w*$/.test(text) || seen.has(text)) return new Map();
452
+ const shape = shapes.get(text);
453
+ if (shape === undefined) return new Map();
454
+ return resolveShapeMembers(shape, shapes, new Set([...seen, text]));
455
+ }
456
+
457
+ /**
458
+ * Extract every top-level `function`/`const` member of a module `.d.ts` — both
459
+ * `export`ed and bare — with its declaration text (through the terminating
460
+ * top-level `;`, balancing `()`/`{}` for multiline signatures and object-typed
461
+ * consts). The emitted markdown module uses bare `function`, so a single
462
+ * extractor serves both sides of the comparison.
463
+ *
464
+ * A module that publishes through `export = handle` is read through the handle's
465
+ * shape instead: the module *is* that object, so the shape's members are the
466
+ * module's own flat members — the same flattening `emitLibraryDeclarations`
467
+ * performs on the markdown side. A handle whose type resolves to no member at
468
+ * all is left standing, so an uncomparable surface still presents as one.
469
+ */
470
+ export function tsDefoldSurface(dts: string): Map<string, TsDefoldMember> {
471
+ const src = stripComments(dts);
472
+ const members = new Map<string, TsDefoldMember>();
473
+ MEMBER_DECL.lastIndex = 0;
474
+ let match = MEMBER_DECL.exec(src);
475
+ while (match !== null) {
476
+ const kind = match[1] as "function" | "const";
477
+ const name = match[2] as string;
478
+ let index = MEMBER_DECL.lastIndex;
479
+ let parens = 0;
480
+ let braces = 0;
481
+ while (index < src.length) {
482
+ const ch = src[index];
483
+ if (ch === "(") parens++;
484
+ else if (ch === ")") parens--;
485
+ else if (ch === "{") braces++;
486
+ else if (ch === "}") braces--;
487
+ else if (ch === ";" && parens === 0 && braces === 0) break;
488
+ index++;
489
+ }
490
+ const signature = src
491
+ .slice(match.index, index + 1)
492
+ .replace(/\s+/g, " ")
493
+ .trim();
494
+ if (!members.has(name)) members.set(name, { kind, signature });
495
+ MEMBER_DECL.lastIndex = index + 1;
496
+ match = MEMBER_DECL.exec(src);
497
+ }
498
+ const handle = /export\s*=\s*([A-Za-z_]\w*)\s*;/.exec(src)?.[1];
499
+ const handleEntry = handle === undefined ? undefined : members.get(handle);
500
+ if (handle !== undefined && handleEntry?.kind === "const") {
501
+ const declaredType = handleEntry.signature.slice(handleEntry.signature.indexOf(":") + 1);
502
+ const resolved = resolveShapeMembers(declaredType.replace(/;\s*$/, ""), declaredShapes(src));
503
+ if (resolved.size > 0) {
504
+ members.delete(handle);
505
+ for (const [name, member] of resolved) {
506
+ if (!members.has(name)) members.set(name, member);
507
+ }
508
+ }
509
+ }
510
+ return members;
511
+ }
512
+
513
+ /** The top-level member locals of a module `.d.ts`, sorted. */
514
+ export function tsDefoldMembers(dts: string): string[] {
515
+ return [...tsDefoldSurface(dts).keys()].sort();
516
+ }
517
+
518
+ /** Whether a closing bracket at `index` really closes a type group. `>` is the
519
+ * only ambiguous one: in `(x: string) => void` it belongs to the arrow, not to a
520
+ * generic argument list. */
521
+ function closesGroup(text: string, index: number): boolean {
522
+ const ch = text[index];
523
+ if (ch === ")" || ch === "]" || ch === "}") return true;
524
+ return ch === ">" && text[index - 1] !== "=";
525
+ }
526
+
527
+ function opensGroup(ch: string | undefined): boolean {
528
+ return ch === "(" || ch === "[" || ch === "{" || ch === "<";
529
+ }
530
+
531
+ /** Split a parameter list on its top-level commas, so a comma inside a
532
+ * parameter's own type (`Record<string, number>`, `[a, b]`) never reads as a
533
+ * separator. */
534
+ function splitParameters(inner: string): string[] {
535
+ const parts: string[] = [];
536
+ let depth = 0;
537
+ let current = "";
538
+ for (let i = 0; i < inner.length; i++) {
539
+ const ch = inner[i] as string;
540
+ if (opensGroup(ch)) depth++;
541
+ else if (closesGroup(inner, i)) depth--;
542
+ if (ch === "," && depth === 0) {
543
+ parts.push(current);
544
+ current = "";
545
+ continue;
546
+ }
547
+ current += ch;
548
+ }
549
+ parts.push(current);
550
+ return parts.map((part) => part.trim()).filter((part) => part !== "");
551
+ }
552
+
553
+ /** Whether a parameter slot is declared optional. Optionality is a `?` on the
554
+ * parameter *name*; a `?` anywhere in the parameter's own type (`cb: (x?: number)
555
+ * => void`) belongs to that type, not to the slot. */
556
+ function isOptionalParameter(parameter: string): boolean {
557
+ return /^[A-Za-z_]\w*\?\s*:/.test(parameter.trim());
558
+ }
559
+
560
+ interface SignatureShape {
561
+ parameters: string[];
562
+ returnType: string;
563
+ }
564
+
565
+ /** The top-level parameter list and return type of a `function` declaration.
566
+ * `undefined` when the text carries no balanced parameter list at all — a `const`
567
+ * declaration, or a signature the extractor truncated. */
568
+ function signatureShape(signature: string): SignatureShape | undefined {
569
+ const open = signature.indexOf("(");
570
+ if (open === -1) return undefined;
571
+ let depth = 0;
572
+ let close = -1;
573
+ for (let i = open; i < signature.length; i++) {
574
+ if (opensGroup(signature[i])) depth++;
575
+ else if (closesGroup(signature, i)) {
576
+ depth--;
577
+ if (depth === 0) {
578
+ close = i;
579
+ break;
580
+ }
581
+ }
582
+ }
583
+ if (close === -1) return undefined;
584
+ const tail = signature
585
+ .slice(close + 1)
586
+ .trim()
587
+ .replace(/;\s*$/, "")
588
+ .trim();
589
+ return {
590
+ parameters: splitParameters(signature.slice(open + 1, close)),
591
+ returnType: tail.startsWith(":") ? tail.slice(1).trim() : "",
592
+ };
593
+ }
594
+
595
+ /**
596
+ * Compare the emitted markdown `.d.ts` against the ts-defold `.d.ts` it would
597
+ * replace and derive the go/no-go decision. A ts-defold member absent from the
598
+ * markdown emit is a missing member; a shared member whose markdown signature
599
+ * introduced `unknown` the ts-defold declaration lacked is a type downgrade; a
600
+ * shared member that kept its name but dropped parameters, or collapsed a
601
+ * non-`void` return to `void`, is a signature loss; a shared member that kept
602
+ * every ts-defold parameter but emitted an optional one as required is an
603
+ * optionality loss. A ts-defold side whose only top-level declaration is its
604
+ * `export =` re-export handle — one whose type named no readable shape, since a
605
+ * handle that resolves is already flattened to its members — or which declares
606
+ * no top-level member at all is an opaque surface: there is nothing to compare
607
+ * against, so every other term is vacuously empty. Any of the five forces
608
+ * `no-go`.
609
+ */
610
+ export function compareFidelityToTsDefold(
611
+ markdownEmittedDts: string,
612
+ tsDefoldDts: string,
613
+ ): FidelityComparison {
614
+ const tsMap = tsDefoldSurface(tsDefoldDts);
615
+ const mdMap = tsDefoldSurface(markdownEmittedDts);
616
+ const tsMembers = [...tsMap.keys()].sort();
617
+ const mdMembers = [...mdMap.keys()].sort();
618
+ const missingMembers = tsMembers.filter((name) => !mdMap.has(name));
619
+ const addedMembers = mdMembers.filter((name) => !tsMap.has(name));
620
+ const hasUnknown = (member: TsDefoldMember | undefined): boolean =>
621
+ member !== undefined && /\bunknown\b/.test(member.signature);
622
+ const downgradedMembers = tsMembers.filter(
623
+ (name) => hasUnknown(mdMap.get(name)) && !hasUnknown(tsMap.get(name)),
624
+ );
625
+ const signatureLossMembers = tsMembers.filter((name) => {
626
+ const ts = tsMap.get(name);
627
+ const md = mdMap.get(name);
628
+ if (ts === undefined || md === undefined) return false;
629
+ if (ts.kind !== "function" || md.kind !== "function") return false;
630
+ const tsShape = signatureShape(ts.signature);
631
+ const mdShape = signatureShape(md.signature);
632
+ if (tsShape === undefined || mdShape === undefined) return false;
633
+ if (mdShape.parameters.length < tsShape.parameters.length) return true;
634
+ // An unannotated ts-defold return has nothing to lose against.
635
+ return (
636
+ mdShape.returnType === "void" && tsShape.returnType !== "void" && tsShape.returnType !== ""
637
+ );
638
+ });
639
+ const optionalityLossMembers = tsMembers.filter((name) => {
640
+ const ts = tsMap.get(name);
641
+ const md = mdMap.get(name);
642
+ if (ts === undefined || md === undefined) return false;
643
+ if (ts.kind !== "function" || md.kind !== "function") return false;
644
+ const tsShape = signatureShape(ts.signature);
645
+ const mdShape = signatureShape(md.signature);
646
+ if (tsShape === undefined || mdShape === undefined) return false;
647
+ return tsShape.parameters.some((parameter, index) => {
648
+ const counterpart = mdShape.parameters[index];
649
+ return (
650
+ counterpart !== undefined &&
651
+ isOptionalParameter(parameter) &&
652
+ !isOptionalParameter(counterpart)
653
+ );
654
+ });
655
+ });
656
+ // Reached only by a handle whose type resolved to nothing — a resolved one is
657
+ // no longer in the surface under its own name. `tsDefoldMembers` keeps
658
+ // reporting what the extractor really saw; only the predicate discounts the
659
+ // handle, so a record can still quote it as the evidence for the opacity.
660
+ const reExportHandle = /export\s*=\s*(\w+)\s*;/.exec(tsDefoldDts)?.[1];
661
+ const comparableTsMembers = tsMembers.filter((name) => name !== reExportHandle);
662
+ const opaqueTsDefoldSurface = comparableTsMembers.length === 0;
663
+ return {
664
+ tsDefoldMembers: tsMembers,
665
+ markdownMembers: mdMembers,
666
+ missingMembers,
667
+ addedMembers,
668
+ downgradedMembers,
669
+ signatureLossMembers,
670
+ optionalityLossMembers,
671
+ opaqueTsDefoldSurface,
672
+ decision:
673
+ missingMembers.length === 0 &&
674
+ downgradedMembers.length === 0 &&
675
+ signatureLossMembers.length === 0 &&
676
+ optionalityLossMembers.length === 0 &&
677
+ !opaqueTsDefoldSurface
678
+ ? "go"
679
+ : "no-go",
680
+ };
681
+ }
682
+
683
+ /**
684
+ * Evaluate one cutover candidate without writing anything: emit the markdown
685
+ * surface in memory and compare it against the `fixtures/ts-defold/<moduleId>.d.ts`
686
+ * it would replace.
687
+ *
688
+ * This is the entry point for a library whose markdown `namespace` *equals* its
689
+ * live `moduleId` (defold-input, monarch), where the canonical
690
+ * `generated`/`api-doc`/`fidelity` paths a registered target would write are the
691
+ * very files the live ts-defold module already owns. Registering a no-go target
692
+ * to obtain its comparison would overwrite them in place; passing an unregistered
693
+ * in-memory target here computes the same decision and leaves them untouched.
694
+ *
695
+ * `snapshot` overrides the compared-against surface, package-root-relative. A
696
+ * library that severed its ts-defold dependency (forking its surface into the
697
+ * authored lane) has no `fixtures/ts-defold/` entry left, but its recorded
698
+ * markdown verdict is still owed a comparison: a verbatim fork is byte-identical
699
+ * to the retired snapshot, so pointing this at the vendored copy reproduces the
700
+ * decision exactly.
701
+ */
702
+ export async function evaluateMarkdownCandidate(
703
+ packageRoot: string,
704
+ target: MarkdownTarget,
705
+ snapshot?: string,
706
+ ): Promise<FidelityComparison & { emitted: string }> {
707
+ const emitted = await emitMarkdownDeclaration(packageRoot, target);
708
+ const tsDefold = readFileSync(
709
+ join(packageRoot, snapshot ?? join("fixtures/ts-defold", `${target.moduleId}.d.ts`)),
710
+ "utf8",
711
+ );
712
+ return { emitted, ...compareFidelityToTsDefold(emitted, tsDefold) };
713
+ }
714
+
715
+ const defaultFetchText: FetchText = async (url) => {
716
+ const res = await fetch(url);
717
+ if (!res.ok) {
718
+ throw new Error(`fetch failed: ${url} -> ${res.status} ${res.statusText}`);
719
+ }
720
+ return res.text();
721
+ };
722
+
723
+ if (import.meta.main) {
724
+ const root = join(import.meta.dir, "..");
725
+ const argv = process.argv.slice(2);
726
+ if (argv.includes("--fetch")) {
727
+ for (const target of readMarkdownTargets(root)) {
728
+ await fetchMarkdownFixture(root, target, { fetchText: defaultFetchText });
729
+ console.log(`snapshotted ${target.moduleId} from ${repoSlug(target.repo)}@${target.ref}`);
730
+ }
731
+ }
732
+ if (argv.includes("--emit")) {
733
+ for (const target of readMarkdownTargets(root)) {
734
+ const contents = await emitMarkdownDeclaration(root, target);
735
+ const dest = join(root, target.generated);
736
+ mkdirSync(dirname(dest), { recursive: true });
737
+ writeFileSync(dest, contents);
738
+ console.log(`emitted ${target.moduleId} -> ${target.generated}`);
739
+ }
740
+ }
741
+ if (argv.includes("--api-doc")) {
742
+ for (const target of readMarkdownTargets(root)) {
743
+ const json = lowerMarkdownApiDoc(root, target);
744
+ const dest = join(root, target.apiDoc);
745
+ mkdirSync(dirname(dest), { recursive: true });
746
+ writeFileSync(dest, json);
747
+ console.log(`lowered ${target.moduleId} -> ${target.apiDoc}`);
748
+ }
749
+ }
750
+ if (argv.includes("--fidelity")) {
751
+ for (const target of readMarkdownTargets(root)) {
752
+ const report = await buildMarkdownFidelity(root, target);
753
+ const dest = join(root, target.fidelity);
754
+ mkdirSync(dirname(dest), { recursive: true });
755
+ writeFileSync(dest, `${JSON.stringify(report, null, 2)}\n`);
756
+ console.log(
757
+ `${target.moduleId}: coverage ${(report.coverage * 100).toFixed(1)}% (${report.unknownFallbacks} unknown, ${report.undocumentedMembers} undocumented)`,
758
+ );
759
+ }
760
+ }
761
+ }