@defold-typescript/library-types 0.23.0 → 0.25.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 (130) hide show
  1. package/NOTICE +2 -3
  2. package/api-doc/boom.json +3872 -0
  3. package/api-doc/bridge.json +10 -5
  4. package/api-doc/bzAnim.json +338 -0
  5. package/api-doc/checkpoint.json +127 -0
  6. package/api-doc/decore.json +1 -1
  7. package/api-doc/defcon.json +150 -0
  8. package/api-doc/{defmath.defmath.json → defmath.json} +6 -46
  9. package/api-doc/defsave.json +518 -0
  10. package/api-doc/deftest.json +745 -0
  11. package/api-doc/{dicebag.dicebag.json → dicebag.json} +19 -1
  12. package/api-doc/druid.json +90 -92
  13. package/api-doc/event.json +16 -16
  14. package/api-doc/gooey.json +2050 -0
  15. package/api-doc/in.accelerometer.json +75 -1
  16. package/api-doc/in.button.json +17 -10
  17. package/api-doc/in.cursor.json +26 -0
  18. package/api-doc/in.gesture.json +401 -6
  19. package/api-doc/in.keyboard.json +2 -0
  20. package/api-doc/in.mapper.json +12 -8
  21. package/api-doc/in.onscreen.json +61 -5
  22. package/api-doc/in.state.json +61 -13
  23. package/api-doc/in.textbox.json +23 -12
  24. package/api-doc/in.triggers.json +336 -0
  25. package/api-doc/lang.json +5 -5
  26. package/api-doc/log.json +1 -1
  27. package/api-doc/metrics.fps.json +61 -2
  28. package/api-doc/metrics.mem.json +61 -2
  29. package/api-doc/monarch.monarch.json +249 -7
  30. package/api-doc/monarch.transitions.easings.json +19 -26
  31. package/api-doc/monarch.transitions.gui.json +782 -79
  32. package/api-doc/nakama.engine.defold.json +31 -18
  33. package/api-doc/{nakama.nakama.json → nakama.json} +4752 -2554
  34. package/api-doc/nakama.session.json +315 -0
  35. package/api-doc/nakama.socket.json +3356 -0
  36. package/api-doc/nakama.util.log.json +32 -4
  37. package/api-doc/narrator.json +1 -1
  38. package/api-doc/openapi/nakama.nakama.json +7680 -0
  39. package/api-doc/orthographic.camera.json +96 -98
  40. package/api-doc/orthographic.json +602 -0
  41. package/api-doc/panthera.json +1183 -0
  42. package/api-doc/{persist.persist.json → persist.json} +26 -1
  43. package/api-doc/{platypus.platypus.json → platypus.json} +68 -14
  44. package/api-doc/{rendy.rendy.json → rendy.json} +46 -99
  45. package/api-doc/richtext.color.json +77 -0
  46. package/api-doc/richtext.richtext.json +375 -5
  47. package/api-doc/saver.saver.json +2 -1
  48. package/api-doc/saver.storage.json +2 -1
  49. package/api-doc/shutter.json +551 -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 +433 -0
  53. package/generated/{boom.boom.d.ts → boom.d.ts} +14 -0
  54. package/generated/bridge.d.ts +5 -5
  55. package/generated/{bzAnim.bzLibrary.d.ts → bzAnim.d.ts} +6 -0
  56. package/generated/checkpoint.d.ts +62 -0
  57. package/generated/decore.d.ts +1 -1
  58. package/generated/defcon.d.ts +43 -0
  59. package/generated/{defmath.defmath.d.ts → defmath.d.ts} +3 -2
  60. package/generated/defsave.d.ts +144 -0
  61. package/generated/{dicebag.dicebag.d.ts → dicebag.d.ts} +25 -0
  62. package/generated/druid.d.ts +81 -79
  63. package/generated/event.d.ts +15 -15
  64. package/generated/{gooey.gooey.d.ts → gooey.d.ts} +61 -6
  65. package/generated/in.onscreen.d.ts +5 -0
  66. package/generated/in.textbox.d.ts +4 -0
  67. package/generated/lang.d.ts +5 -5
  68. package/generated/log.d.ts +1 -1
  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.session.d.ts +95 -0
  77. package/generated/nakama.socket.d.ts +706 -0
  78. package/generated/nakama.util.log.d.ts +6 -0
  79. package/generated/narrator.d.ts +1 -1
  80. package/generated/openapi/nakama.nakama.d.ts +884 -0
  81. package/generated/orthographic.camera.d.ts +24 -38
  82. package/generated/orthographic.d.ts +155 -0
  83. package/generated/panthera.d.ts +170 -0
  84. package/generated/{persist.persist.d.ts → persist.d.ts} +3 -0
  85. package/generated/{platypus.platypus.d.ts → platypus.d.ts} +9 -0
  86. package/generated/proto.d.ts +4 -4
  87. package/generated/{rendy.rendy.d.ts → rendy.d.ts} +33 -80
  88. package/generated/richtext.color.d.ts +15 -0
  89. package/generated/saver.saver.d.ts +2 -0
  90. package/generated/saver.storage.d.ts +2 -0
  91. package/generated/shutter.d.ts +183 -0
  92. package/generated/{yagames.yagames.d.ts → yagames.d.ts} +151 -29
  93. package/generated/{zzfx.api.d.ts → zzfx.d.ts} +7 -0
  94. package/library-classification.json +0 -156
  95. package/library-targets.json +1 -218
  96. package/luals-targets.json +56 -3
  97. package/openapi-targets.json +17 -0
  98. package/package.json +7 -57
  99. package/scripts/__snapshots__/parse-luals.test.ts.snap +164 -10
  100. package/scripts/apply-luals-overrides.ts +39 -7
  101. package/scripts/authored-parity.ts +718 -0
  102. package/scripts/emit-library-dts.ts +94 -17
  103. package/scripts/extract-api-doc.ts +214 -42
  104. package/scripts/fidelity-floor.ts +178 -0
  105. package/scripts/import-upstream-docs.ts +125 -0
  106. package/scripts/lower-api-doc.ts +21 -3
  107. package/scripts/luals-fidelity.ts +16 -11
  108. package/scripts/map-luals-types.ts +52 -21
  109. package/scripts/parse-lua-surface.ts +187 -0
  110. package/scripts/parse-luals.ts +50 -4
  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 +14 -1
  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 +16 -17
  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/starly.starly.json +0 -488
  126. package/generated/defcon.console.d.ts +0 -24
  127. package/generated/defsave.defsave.d.ts +0 -31
  128. package/generated/nakama.nakama.d.ts +0 -594
  129. package/generated/starly.starly.d.ts +0 -148
  130. /package/generated/{deftest.deftest.d.ts → deftest.d.ts} +0 -0
@@ -0,0 +1,313 @@
1
+ /**
2
+ * The OpenAPI/proto ingestion front-end's parser: a fifth `library-types` corpus
3
+ * source beside the LuaLS annotations (`parse-luals.ts`), the typed `.script_api`
4
+ * (`scriptApiToFixtureJson`), the README markdown (`parse-markdown-api.ts`), and
5
+ * the authored `.d.ts` lane. It reads a structured REST swagger (JSON) plus the
6
+ * realtime `.proto` message list — the machine-readable source a codegen'd Lua
7
+ * client (nakama-defold) is generated from — and produces the same ref-doc `doc`
8
+ * shape those front-ends feed the shared emitter (`generateModuleDeclaration`), so
9
+ * the OpenAPI path reuses the exact emit + fidelity machinery.
10
+ *
11
+ * The surface mirrors the client codegen's naming, not the raw wire schema:
12
+ *
13
+ * - a swagger `paths` operation (`operationId` `Nakama_AuthenticateCustom`) becomes
14
+ * a client RPC function `authenticate_custom` (service prefix stripped, snake);
15
+ * - a swagger `definitions` object (`apiAccountCustom`) becomes a constructor
16
+ * `create_api_account_custom`;
17
+ * - a realtime proto `message ChannelMessageSend` becomes a realtime message
18
+ * constructor `create_channel_message_send_message`.
19
+ *
20
+ * Scope is a **flat signature surface**, not deep schema modelling (a PRD
21
+ * non-goal): swagger scalar types and `$ref`/object/array types collapse to the
22
+ * emitter's token vocabulary (`string`/`number`/`boolean`/`table`), and proto
23
+ * scalars/messages likewise. Every parameter and return slot carries at least one
24
+ * resolvable token — a slot is never left type-empty (the "no silent swallow"
25
+ * contract the fidelity gate depends on).
26
+ */
27
+
28
+ /** A single ref-doc parameter or return slot. `is_optional` mirrors the consumer
29
+ * contract's string flag (`"True"`); it is present only when set. */
30
+ export interface OpenApiParam {
31
+ name: string;
32
+ doc: string;
33
+ types: string[];
34
+ is_optional?: "True";
35
+ }
36
+
37
+ export interface OpenApiElement {
38
+ type: "FUNCTION";
39
+ name: string;
40
+ description: string;
41
+ parameters: OpenApiParam[];
42
+ returnvalues: OpenApiParam[];
43
+ }
44
+
45
+ /** The ref-doc `doc` shape `generateModuleDeclaration` consumes. `info.namespace`
46
+ * is empty here — the swagger/proto source names no publish alias — and the
47
+ * front-end retargets the bare element names onto the pinned namespace. */
48
+ export interface OpenApiDoc {
49
+ info: { namespace: string; brief: string; description: string };
50
+ elements: OpenApiElement[];
51
+ }
52
+
53
+ /** camelCase / PascalCase -> snake_case, splitting acronym runs on the trailing
54
+ * capitalised word (`HTTPStatus` -> `http_status`). */
55
+ function snake(name: string): string {
56
+ return name
57
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
58
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
59
+ .toLowerCase();
60
+ }
61
+
62
+ /** Map a swagger schema type keyword to a resolvable emitter token. Object,
63
+ * array, and `$ref` shapes all collapse to `table` (a Lua table), the faithful
64
+ * shape a Lua client marshals them as. */
65
+ function swaggerType(type: string | undefined, hasRef: boolean): string {
66
+ if (hasRef) return "table";
67
+ switch (type) {
68
+ case "integer":
69
+ case "number":
70
+ return "number";
71
+ case "boolean":
72
+ return "boolean";
73
+ case "string":
74
+ return "string";
75
+ default:
76
+ // `object`, `array`, and anything unrecognised marshal as a table.
77
+ return "table";
78
+ }
79
+ }
80
+
81
+ interface SwaggerSchema {
82
+ type?: string;
83
+ $ref?: string;
84
+ items?: SwaggerSchema;
85
+ properties?: Record<string, SwaggerSchema>;
86
+ required?: string[];
87
+ additionalProperties?: unknown;
88
+ }
89
+
90
+ interface SwaggerParameter {
91
+ name: string;
92
+ in: string;
93
+ required?: boolean;
94
+ type?: string;
95
+ schema?: SwaggerSchema;
96
+ description?: string;
97
+ }
98
+
99
+ interface SwaggerOperation {
100
+ operationId?: string;
101
+ summary?: string;
102
+ description?: string;
103
+ parameters?: SwaggerParameter[];
104
+ responses?: Record<string, { schema?: SwaggerSchema; description?: string }>;
105
+ }
106
+
107
+ interface SwaggerRoot {
108
+ paths?: Record<string, Record<string, SwaggerOperation>>;
109
+ definitions?: Record<string, SwaggerSchema>;
110
+ }
111
+
112
+ /** Strip a leading `Service_` prefix from an operationId, then snake the rest. */
113
+ function operationName(operationId: string): string {
114
+ const underscore = operationId.indexOf("_");
115
+ const bare = underscore >= 0 ? operationId.slice(underscore + 1) : operationId;
116
+ return snake(bare);
117
+ }
118
+
119
+ function schemaToken(schema: SwaggerSchema): string {
120
+ return swaggerType(schema.type, typeof schema.$ref === "string");
121
+ }
122
+
123
+ function swaggerParamSlot(param: SwaggerParameter): OpenApiParam {
124
+ const token =
125
+ param.in === "body" && param.schema !== undefined
126
+ ? schemaToken(param.schema)
127
+ : swaggerType(param.type, false);
128
+ const slot: OpenApiParam = {
129
+ name: param.name,
130
+ doc: (param.description ?? "").trim(),
131
+ types: [token],
132
+ };
133
+ if (param.required !== true) slot.is_optional = "True";
134
+ return slot;
135
+ }
136
+
137
+ function swaggerReturnSlots(op: SwaggerOperation): OpenApiParam[] {
138
+ const schema = op.responses?.["200"]?.schema;
139
+ if (schema === undefined) return [];
140
+ return [{ name: "result", doc: "", types: [schemaToken(schema)] }];
141
+ }
142
+
143
+ function parseSwaggerOperations(root: SwaggerRoot): OpenApiElement[] {
144
+ const elements: OpenApiElement[] = [];
145
+ for (const methods of Object.values(root.paths ?? {})) {
146
+ for (const op of Object.values(methods)) {
147
+ if (typeof op.operationId !== "string") continue;
148
+ elements.push({
149
+ type: "FUNCTION",
150
+ name: operationName(op.operationId),
151
+ description: (op.summary ?? op.description ?? "").trim(),
152
+ parameters: (op.parameters ?? []).map(swaggerParamSlot),
153
+ returnvalues: swaggerReturnSlots(op),
154
+ });
155
+ }
156
+ }
157
+ return elements;
158
+ }
159
+
160
+ function parseSwaggerDefinitions(root: SwaggerRoot): OpenApiElement[] {
161
+ const elements: OpenApiElement[] = [];
162
+ for (const [name, schema] of Object.entries(root.definitions ?? {})) {
163
+ // Only object definitions become constructors; enum/scalar definitions carry
164
+ // no properties and would emit a meaningless zero-argument builder.
165
+ if (schema.type !== "object" || schema.properties === undefined) continue;
166
+ const required = new Set(schema.required ?? []);
167
+ const parameters = Object.entries(schema.properties).map(([propName, propSchema]) => {
168
+ const slot: OpenApiParam = { name: propName, doc: "", types: [schemaToken(propSchema)] };
169
+ if (!required.has(propName)) slot.is_optional = "True";
170
+ return slot;
171
+ });
172
+ elements.push({
173
+ type: "FUNCTION",
174
+ name: `create_${snake(name)}`,
175
+ description: (root.definitions?.[name] as { description?: string }).description?.trim() ?? "",
176
+ parameters,
177
+ returnvalues: [{ name: "result", doc: "", types: ["table"] }],
178
+ });
179
+ }
180
+ return elements;
181
+ }
182
+
183
+ /** Map a proto field type to a resolvable emitter token. Scalar numerics and
184
+ * `bytes` collapse per Lua marshalling; message/enum/`map`/`repeated` types are
185
+ * tables. */
186
+ function protoType(rawType: string): string {
187
+ const type = rawType.trim();
188
+ if (type.startsWith("map<") || type.startsWith("repeated ")) return "table";
189
+ switch (type) {
190
+ case "double":
191
+ case "float":
192
+ case "int32":
193
+ case "int64":
194
+ case "uint32":
195
+ case "uint64":
196
+ case "sint32":
197
+ case "sint64":
198
+ case "fixed32":
199
+ case "fixed64":
200
+ case "sfixed32":
201
+ case "sfixed64":
202
+ return "number";
203
+ case "bool":
204
+ return "boolean";
205
+ case "string":
206
+ case "bytes":
207
+ return "string";
208
+ default:
209
+ // A message- or enum-typed field marshals as a table.
210
+ return "table";
211
+ }
212
+ }
213
+
214
+ // A proto field line: an optional `repeated`, a type (`map<...>` allowed), a field
215
+ // name, `=`, a tag, `;`. Reserved lines, `oneof`, options, and nested blocks are
216
+ // ignored by requiring the `= <tag>;` tail.
217
+ const PROTO_FIELD = /^\s*(map<[^>]+>|repeated\s+[\w.]+|[\w.]+)\s+([a-z_]\w*)\s*=\s*\d+\s*;/;
218
+ const PROTO_MESSAGE_OPEN = /^message\s+([A-Za-z_]\w*)\s*\{/;
219
+ // A `oneof` groups alternatives that are the enclosing message's own fields; any
220
+ // other block (`message`/`enum`) owns its fields and must not leak them upward, so
221
+ // non-`oneof` openers default to an isolating `other` frame.
222
+ const PROTO_ONEOF_OPEN = /^\s*oneof\s+[A-Za-z_]\w*\s*\{/;
223
+
224
+ function parseProtoMessages(protoText: string): OpenApiElement[] {
225
+ const elements: OpenApiElement[] = [];
226
+ const lines = protoText.split("\n");
227
+ let current: { name: string; parameters: OpenApiParam[] } | null = null;
228
+ // Block-kind frames nested inside the current message, excluding the message
229
+ // root (which `current !== null` represents). Integer depth cannot tell a
230
+ // `oneof` alternative (a parent field) from a nested-`message` field (not one);
231
+ // the frame kinds carry that distinction.
232
+ let frames: Array<"oneof" | "other"> = [];
233
+
234
+ const emit = () => {
235
+ if (current === null) return;
236
+ elements.push({
237
+ type: "FUNCTION",
238
+ name: `create_${snake(current.name)}_message`,
239
+ description: "",
240
+ parameters: current.parameters,
241
+ returnvalues: [],
242
+ });
243
+ current = null;
244
+ frames = [];
245
+ };
246
+
247
+ // Account one message-body segment (a whole line, or the tail of the open line
248
+ // after its header brace): count a field only when the frames above the root
249
+ // are empty or all `oneof`, then apply the segment's braces — a `}` that finds
250
+ // no nested frame closes the message root.
251
+ const processBody = (body: string): void => {
252
+ if (current === null) return;
253
+ const field = PROTO_FIELD.exec(body);
254
+ if (field !== null && frames.every((f) => f === "oneof")) {
255
+ const slot: OpenApiParam = {
256
+ name: field[2] as string,
257
+ doc: "",
258
+ types: [protoType(field[1] as string)],
259
+ };
260
+ if (frames.includes("oneof")) slot.is_optional = "True";
261
+ current.parameters.push(slot);
262
+ }
263
+ const openKind: "oneof" | "other" = PROTO_ONEOF_OPEN.test(body) ? "oneof" : "other";
264
+ for (let i = 0; i < (body.match(/\{/g) ?? []).length; i++) frames.push(openKind);
265
+ for (let i = 0; i < (body.match(/\}/g) ?? []).length; i++) {
266
+ if (frames.length > 0) {
267
+ frames.pop();
268
+ } else {
269
+ emit();
270
+ return;
271
+ }
272
+ }
273
+ };
274
+
275
+ for (const line of lines) {
276
+ if (current === null) {
277
+ const open = PROTO_MESSAGE_OPEN.exec(line);
278
+ if (open !== null) {
279
+ current = { name: open[1] as string, parameters: [] };
280
+ frames = [];
281
+ // Process the same line's remainder so `message Ping {}` closes here
282
+ // instead of swallowing every following top-level message.
283
+ processBody(line.slice(open[0].length));
284
+ }
285
+ continue;
286
+ }
287
+ processBody(line);
288
+ }
289
+ return elements;
290
+ }
291
+
292
+ /**
293
+ * Parse the pinned swagger JSON and realtime `.proto` into the shared ref-doc
294
+ * `doc` shape. Swagger operations, swagger object definitions, and proto messages
295
+ * each contribute one element; the first occurrence of a codegen'd name wins so a
296
+ * definition and a like-named operation never emit a duplicate member.
297
+ */
298
+ export function parseOpenApi(swaggerText: string, protoText: string): OpenApiDoc {
299
+ const root = JSON.parse(swaggerText) as SwaggerRoot;
300
+ const all = [
301
+ ...parseSwaggerOperations(root),
302
+ ...parseSwaggerDefinitions(root),
303
+ ...parseProtoMessages(protoText),
304
+ ];
305
+ const seen = new Set<string>();
306
+ const elements: OpenApiElement[] = [];
307
+ for (const element of all) {
308
+ if (seen.has(element.name)) continue;
309
+ seen.add(element.name);
310
+ elements.push(element);
311
+ }
312
+ return { info: { namespace: "", brief: "", description: "" }, elements };
313
+ }
@@ -0,0 +1,249 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { extractApiDoc } from "./extract-api-doc";
4
+ import { type ApiDocElement, importUpstreamDocs } from "./import-upstream-docs";
5
+ import { type LuaMember, parseLuaSurface } from "./parse-lua-surface";
6
+
7
+ /**
8
+ * The authored/forked front-end: a fourth `library-types` corpus mode beside the
9
+ * LuaLS (`sync-luals-types.ts`), `.script_api` (`sync-script-api-types.ts`), and
10
+ * markdown (`sync-markdown-types.ts`) lanes. The other three ingest a *non-*`.d.ts`
11
+ * primary source and generate a declaration; this one is for a library whose
12
+ * upstream has no usable structured source, so its `.d.ts` is hand-authored or
13
+ * forked from the retired ts-defold `generated/` golden and vendored under
14
+ * `fixtures/authored/<moduleId>.d.ts`.
15
+ *
16
+ * A vendored authored `.d.ts` is already target-form — a `declare module
17
+ * '<moduleId>'` ambient — so the lane is light: it emits the vendored source
18
+ * verbatim as the `generated/<namespace>.d.ts` golden and runs
19
+ * `extractApiDoc` for the `api-doc/<namespace>.json` the docs-site consumes. The
20
+ * api-doc alone carries one thing the fork does not: where a target vendors its
21
+ * upstream `.lua` and the fork documents a member nowhere, upstream's own LuaDoc
22
+ * summary is lowered in, marked `docSource: "upstream"` (see
23
+ * `import-upstream-docs.ts`). The *emitted* surface is still the vendored authored
24
+ * source untouched, so the go/no-go gate is a
25
+ * forked-vs-generated identity diff (see the golden loop) — emission fidelity.
26
+ * There is no *type* coverage comparison because upstream declares no types; the
27
+ * *surface* is another matter, and a target that vendors its upstream `.lua` under
28
+ * `upstreamLua` is measured on member names and arity by `authored-parity.ts`.
29
+ * Beyond that, a corrected fork's accuracy rests on its manual audit against
30
+ * upstream plus the per-library shape assertions in the sibling test.
31
+ */
32
+ /**
33
+ * Why a target's surface cannot be measured against upstream. A closed set: the
34
+ * alternative to `upstreamLua` has to be a *chosen* category, because free prose
35
+ * would let "nobody looked yet" and "looked, and it is genuinely unmeasurable"
36
+ * read the same in the config.
37
+ *
38
+ * - `unresolved-path` — the `moduleId`-derived path does not resolve at the
39
+ * pinned `ref` (including a repository that no longer exists there).
40
+ * - `no-module-file` — the upstream resolves but declares no single module file
41
+ * this target could be measured against.
42
+ * - `unparseable-shape` — the module file exists but closes in a way
43
+ * `parseLuaSurface` refuses, such as a metatable that could delegate members
44
+ * no column-0 scan would find.
45
+ */
46
+ export const PARITY_VERDICT_REASONS = [
47
+ "no-module-file",
48
+ "unresolved-path",
49
+ "unparseable-shape",
50
+ ] as const;
51
+
52
+ export type ParityVerdictReason = (typeof PARITY_VERDICT_REASONS)[number];
53
+
54
+ export interface ParityVerdict {
55
+ reason: ParityVerdictReason;
56
+ note: string;
57
+ }
58
+
59
+ export interface AuthoredTarget {
60
+ repo: string;
61
+ ref: string;
62
+ // Package-relative path of the vendored authored/forked `.d.ts`
63
+ // (`fixtures/authored/<moduleId>.d.ts`).
64
+ authored: string;
65
+ moduleId: string;
66
+ namespace: string;
67
+ generated: string;
68
+ apiDoc: string;
69
+ // Defaults to `fidelity/<namespace>.json` when omitted. A fork carries no
70
+ // fidelity artifact — there is no primary source to measure against, and the
71
+ // emit is lossless by construction — but the field mirrors the sibling lanes
72
+ // so a future hand-authored target can record one.
73
+ fidelity: string;
74
+ // SPDX-style license id, surfaced by the docs-site provenance block. Optional
75
+ // in the config; defaults to "".
76
+ license: string;
77
+ // Package-relative paths of the vendored upstream `.lua` sources this target's
78
+ // surface is measured against (`authored-parity.ts`). Optional and defaulted to
79
+ // `[]`: a target with no vendored upstream is simply unmeasured, which is why it
80
+ // must not join `REQUIRED_FIELDS`.
81
+ upstreamLua: string[];
82
+ // Why this target vendors no upstream `.lua`. Mutually exclusive with a
83
+ // non-empty `upstreamLua`, and one of the two is mandatory: neither field is
84
+ // required on its own, but the *disjunction* is, so a target cannot land
85
+ // unexamined and read as measured-and-clean. Absent on a measured target.
86
+ parityVerdict?: ParityVerdict;
87
+ }
88
+
89
+ export interface AuthoredTargets {
90
+ targets: AuthoredTarget[];
91
+ }
92
+
93
+ const REQUIRED_FIELDS = [
94
+ "repo",
95
+ "ref",
96
+ "authored",
97
+ "moduleId",
98
+ "namespace",
99
+ "generated",
100
+ "apiDoc",
101
+ ] as const;
102
+
103
+ /** Validate the parity declaration: exactly one of a non-empty `upstreamLua` and
104
+ * a well-formed `parityVerdict`. Enforced here rather than through
105
+ * `REQUIRED_FIELDS` because neither field is required on its own — the bijection
106
+ * between them is the gate. */
107
+ function validateParityDeclaration(
108
+ label: string,
109
+ upstreamLua: string[],
110
+ verdict: ParityVerdict | undefined,
111
+ ): void {
112
+ if (upstreamLua.length > 0 && verdict !== undefined) {
113
+ throw new Error(
114
+ `authored-targets.json: entry ${label} declares both upstreamLua and parityVerdict — a measured target carries no verdict.`,
115
+ );
116
+ }
117
+ if (upstreamLua.length === 0 && verdict === undefined) {
118
+ throw new Error(
119
+ `authored-targets.json: entry ${label} declares neither upstreamLua nor parityVerdict — vendor its upstream .lua, or record a parityVerdict saying why it cannot be measured.`,
120
+ );
121
+ }
122
+ if (verdict === undefined) return;
123
+ if (!(PARITY_VERDICT_REASONS as readonly string[]).includes(verdict.reason)) {
124
+ throw new Error(
125
+ `authored-targets.json: entry ${label} has parityVerdict reason "${verdict.reason}", which is not one of ${PARITY_VERDICT_REASONS.join(", ")}.`,
126
+ );
127
+ }
128
+ if (typeof verdict.note !== "string" || verdict.note.trim() === "") {
129
+ throw new Error(
130
+ `authored-targets.json: entry ${label} has a parityVerdict with an empty note — say what was looked at and why it could not be measured.`,
131
+ );
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Read `authored-targets.json`, validate every required field per entry, and fill
137
+ * optional defaults (`fidelity` → `fidelity/<namespace>.json`, `license` → "",
138
+ * `upstreamLua` → []).
139
+ * Throws on the first missing field naming both the field and the offending entry
140
+ * (its `moduleId`, or its index when `moduleId` itself is absent) — the loud-fail
141
+ * discipline `readMarkdownTargets`/`readScriptApiTargets`/`readLualsTargets` use.
142
+ * Also throws when an entry's parity declaration is missing, doubled, or
143
+ * malformed (see `validateParityDeclaration`).
144
+ */
145
+ export function readAuthoredTargets(packageRoot: string): AuthoredTarget[] {
146
+ const parsed = JSON.parse(readFileSync(join(packageRoot, "authored-targets.json"), "utf8")) as {
147
+ targets: Partial<AuthoredTarget>[];
148
+ };
149
+ return parsed.targets.map((entry, index) => {
150
+ const label = typeof entry.moduleId === "string" ? entry.moduleId : `index ${index}`;
151
+ for (const field of REQUIRED_FIELDS) {
152
+ if (entry[field] === undefined) {
153
+ throw new Error(
154
+ `authored-targets.json: entry ${label} is missing required field "${field}".`,
155
+ );
156
+ }
157
+ }
158
+ const upstreamLua = entry.upstreamLua ?? [];
159
+ validateParityDeclaration(label, upstreamLua, entry.parityVerdict);
160
+ return {
161
+ repo: entry.repo as string,
162
+ ref: entry.ref as string,
163
+ authored: entry.authored as string,
164
+ moduleId: entry.moduleId as string,
165
+ namespace: entry.namespace as string,
166
+ generated: entry.generated as string,
167
+ apiDoc: entry.apiDoc as string,
168
+ fidelity: entry.fidelity ?? `fidelity/${entry.namespace as string}.json`,
169
+ license: entry.license ?? "",
170
+ upstreamLua,
171
+ ...(entry.parityVerdict === undefined ? {} : { parityVerdict: entry.parityVerdict }),
172
+ };
173
+ });
174
+ }
175
+
176
+ function readAuthoredSource(packageRoot: string, target: AuthoredTarget): string {
177
+ return readFileSync(join(packageRoot, target.authored), "utf8");
178
+ }
179
+
180
+ /**
181
+ * The `generated/<namespace>.d.ts` golden, under whatever stem the entry pins —
182
+ * a bare segment or the dotted `moduleId`. The vendored authored
183
+ * `.d.ts` is already a `declare module '<moduleId>'` ambient, so the emit is the
184
+ * source verbatim — the vendored surface passes through unchanged, which is what
185
+ * makes the emission lossless. It says nothing about how that vendored surface
186
+ * compares to upstream.
187
+ */
188
+ export function emitAuthoredDeclaration(packageRoot: string, target: AuthoredTarget): string {
189
+ return readAuthoredSource(packageRoot, target);
190
+ }
191
+
192
+ /** The pinned upstream members by name, the way `authored-parity.ts` builds them:
193
+ * a later definition of a name wins, matching Lua. Empty for a target that vendors
194
+ * no upstream `.lua`, which is then never parsed. */
195
+ function upstreamMembers(packageRoot: string, target: AuthoredTarget): Map<string, LuaMember> {
196
+ const members = new Map<string, LuaMember>();
197
+ for (const relative of target.upstreamLua) {
198
+ for (const member of parseLuaSurface(readFileSync(join(packageRoot, relative), "utf8")).members)
199
+ members.set(member.name, member);
200
+ }
201
+ return members;
202
+ }
203
+
204
+ /**
205
+ * `extractApiDoc` the vendored `.d.ts` under the pinned publish namespace, plus
206
+ * upstream's own LuaDoc for the members the fork documents nowhere, pretty-printed
207
+ * as the `api-doc/<namespace>.json` golden.
208
+ *
209
+ * The api-doc is therefore the fork *plus* imported upstream prose, and it is the
210
+ * only artifact that is: the emitted `generated/<namespace>.d.ts` stays the vendored
211
+ * source verbatim, so the identity diff still proves the emit is lossless. An
212
+ * imported element carries `docSource: "upstream"` through to the docs-site, which
213
+ * marks it rather than presenting it as first-party. See `import-upstream-docs.ts`
214
+ * for the merge rules.
215
+ */
216
+ export function lowerAuthoredApiDoc(packageRoot: string, target: AuthoredTarget): string {
217
+ // `extractApiDoc` returns `unknown`; the merge needs the element list, and
218
+ // overwriting the existing `elements` key by spread keeps it in place, so the
219
+ // golden's key order is unchanged.
220
+ const doc = extractApiDoc(readAuthoredSource(packageRoot, target), target.namespace) as Record<
221
+ string,
222
+ unknown
223
+ > & { elements: ApiDocElement[] };
224
+ const elements = importUpstreamDocs(doc.elements, upstreamMembers(packageRoot, target));
225
+ return `${JSON.stringify({ ...doc, elements }, null, 2)}\n`;
226
+ }
227
+
228
+ if (import.meta.main) {
229
+ const root = join(import.meta.dir, "..");
230
+ const argv = process.argv.slice(2);
231
+ if (argv.includes("--emit")) {
232
+ for (const target of readAuthoredTargets(root)) {
233
+ const contents = emitAuthoredDeclaration(root, target);
234
+ const dest = join(root, target.generated);
235
+ mkdirSync(dirname(dest), { recursive: true });
236
+ writeFileSync(dest, contents);
237
+ console.log(`emitted ${target.moduleId} -> ${target.generated}`);
238
+ }
239
+ }
240
+ if (argv.includes("--api-doc")) {
241
+ for (const target of readAuthoredTargets(root)) {
242
+ const json = lowerAuthoredApiDoc(root, target);
243
+ const dest = join(root, target.apiDoc);
244
+ mkdirSync(dirname(dest), { recursive: true });
245
+ writeFileSync(dest, json);
246
+ console.log(`lowered ${target.moduleId} -> ${target.apiDoc}`);
247
+ }
248
+ }
249
+ }
@@ -2,6 +2,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import ts from "typescript";
4
4
  import { extractApiDoc } from "./extract-api-doc";
5
+ import { readAuthoredTargets } from "./sync-authored-types";
6
+ import { readLualsTargets } from "./sync-luals-types";
7
+ import { readMarkdownTargets } from "./sync-markdown-types";
8
+ import { readOpenApiTargets } from "./sync-openapi-types";
9
+ import { readScriptApiTargets } from "./sync-script-api-types";
5
10
 
6
11
  /**
7
12
  * ts-defold/library core-type references -> the @defold-typescript/types surface.
@@ -330,6 +335,65 @@ export function classifyLibraryDirs(
330
335
  .sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
331
336
  }
332
337
 
338
+ /**
339
+ * The union every maintained-here lane contributes: authored `moduleId`s, and
340
+ * the `namespace`s of the LuaLS, script_api, OpenAPI and markdown front-ends.
341
+ * Namespaces are matched in both forms because the lanes use both — druid-style
342
+ * libraries register a bare `tweener`, while `defold-saver` registers the dotted
343
+ * `saver.saver` / `saver.storage`.
344
+ */
345
+ export interface MaintainedHereRegistry {
346
+ moduleIds: ReadonlySet<string>;
347
+ namespaces: ReadonlySet<string>;
348
+ }
349
+
350
+ /**
351
+ * The subset of `modules` this repo maintains itself rather than sourcing from
352
+ * ts-defold/library. A live `library-targets.json` row always wins, so a module
353
+ * registered on a maintained-here lane *and* still vendored stays
354
+ * ts-defold-sourced and keeps its dir classified `already-vendored`. The
355
+ * registry is empty today, so no committed module takes that branch; it is what
356
+ * a returning row would take.
357
+ */
358
+ export function maintainedHereModules(
359
+ modules: readonly string[],
360
+ registry: MaintainedHereRegistry,
361
+ liveModules: ReadonlySet<string>,
362
+ ): Set<string> {
363
+ return new Set(
364
+ modules.filter(
365
+ (m) =>
366
+ !liveModules.has(m) &&
367
+ (registry.moduleIds.has(m) ||
368
+ registry.namespaces.has(m) ||
369
+ registry.namespaces.has(m.split(".")[0] as string)),
370
+ ),
371
+ );
372
+ }
373
+
374
+ /**
375
+ * The upstream dirs this repo has severed onto a maintained-here lane. Severance
376
+ * deletes a library's `library-targets.json` row, but its dir still exists
377
+ * upstream — so a regen would re-add it under whatever its module shape implies
378
+ * unless it is filtered out first. The registries give an exact derivation with
379
+ * no hand-maintained list: a dir is severed iff it has at least one module and
380
+ * every module is maintained here. A dir mixing maintained-here and live modules
381
+ * is still upstream-sourced and stays classified — `nakama-defold` was the last
382
+ * such dir, and severed whole once its live row went.
383
+ */
384
+ export function severedDirsFromModules(
385
+ modulesByDir: Map<string, string[]>,
386
+ registry: MaintainedHereRegistry,
387
+ liveModules: ReadonlySet<string>,
388
+ ): Set<string> {
389
+ const severed = new Set<string>();
390
+ for (const [dir, modules] of modulesByDir) {
391
+ const maintained = maintainedHereModules(modules, registry, liveModules);
392
+ if (modules.length > 0 && maintained.size === modules.length) severed.add(dir);
393
+ }
394
+ return severed;
395
+ }
396
+
333
397
  interface GithubTreeResponse {
334
398
  tree?: { path: string }[];
335
399
  }
@@ -376,8 +440,28 @@ export type ListTree = (source: LibrarySource) => Promise<string[]>;
376
440
  const defaultListTree: ListTree = (source) => githubTreePaths(repoSlug(source.repo), source.commit);
377
441
 
378
442
  /**
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`
443
+ * Read every maintained-here registry off the package root and union them.
444
+ * Each reader `readFileSync`s unconditionally and throws on a missing required
445
+ * field — deliberately no absent-file fallback, so a lane added without its
446
+ * fixture fails loudly rather than silently un-severing that lane's dirs.
447
+ */
448
+ export function readMaintainedHereRegistry(packageRoot: string): MaintainedHereRegistry {
449
+ return {
450
+ moduleIds: new Set(readAuthoredTargets(packageRoot).map((t) => t.moduleId)),
451
+ namespaces: new Set([
452
+ ...readLualsTargets(packageRoot).map((t) => t.namespace),
453
+ ...readScriptApiTargets(packageRoot).map((t) => t.namespace),
454
+ ...readOpenApiTargets(packageRoot).map((t) => t.namespace),
455
+ ...readMarkdownTargets(packageRoot).map((t) => t.namespace),
456
+ ]),
457
+ };
458
+ }
459
+
460
+ /**
461
+ * Enumerate every ts-defold/library dir at the pin, drop the dirs severed onto
462
+ * a maintained-here lane, classify each survivor by its module-name shape, and write
463
+ * `library-classification.json`. Filtering happens here, not in
464
+ * `classifyLibraryDirs`, so that pass stays a pure map-and-sort. The `listTree`
381
465
  * seam keeps the pass offline-testable; only the CLI wires the real call, and it
382
466
  * stays out of CI (mirrors `--check`). The manifest pins the same `source` as
383
467
  * `library-targets.json`.
@@ -392,9 +476,21 @@ export async function writeClassification(
392
476
  targets.map((t) => t.path.split("/")[1]).filter((d): d is string => d !== undefined),
393
477
  );
394
478
  const coveredByGoalDirs = new Set(["defold-lldebugger", "defold-xmath"]);
479
+ const liveModules = new Set(targets.map((t) => t.module));
480
+
481
+ const registry = readMaintainedHereRegistry(packageRoot);
395
482
 
396
483
  const modulesByDir = libraryModulesFromTree(await listTree(source));
397
- const dirs = [...modulesByDir].map(([dir, modules]) => ({ dir, modules }));
484
+ const severedDirs = severedDirsFromModules(modulesByDir, registry, liveModules);
485
+ const dirs = [...modulesByDir]
486
+ // Vendored wins: a stale maintained-here entry can never erase a live ts-defold row.
487
+ .filter(([dir]) => !severedDirs.has(dir) || vendoredDirs.has(dir))
488
+ // A survivor records only its ts-defold-sourced remainder as evidence, so its
489
+ // written shape agrees with its own `modules` array.
490
+ .map(([dir, modules]) => {
491
+ const maintained = maintainedHereModules(modules, registry, liveModules);
492
+ return { dir, modules: modules.filter((m) => !maintained.has(m)) };
493
+ });
398
494
  const entries = classifyLibraryDirs(dirs, { vendoredDirs, coveredByGoalDirs });
399
495
  writeFileSync(
400
496
  join(packageRoot, "library-classification.json"),