@defold-typescript/types 0.36.0 → 0.37.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.
@@ -202,10 +202,12 @@ declare global {
202
202
  * @example
203
203
  * ```ts
204
204
  * const body = b2d.get_body("#collisionobject");
205
- * const circle = b2d.shape.get_shape(body, 1);
206
- * circle.center = vmath.vector3(24, 0, 0);
207
- * b2d.shape.set_shape(body, 1, circle, true);
208
- * b2d.shape.set_shape(body, 2, { type: b2d.shape.SHAPE_TYPE_SEGMENT, v1: vmath.vector3(-32, 0, 0), v2: vmath.vector3(32, 0, 0) });
205
+ * if (body !== undefined) {
206
+ * const circle = b2d.shape.get_shape(body, 1);
207
+ * circle.center = vmath.vector3(24, 0, 0);
208
+ * b2d.shape.set_shape(body, 1, circle, true);
209
+ * b2d.shape.set_shape(body, 2, { type: b2d.shape.SHAPE_TYPE_SEGMENT, v1: vmath.vector3(-32, 0, 0), v2: vmath.vector3(32, 0, 0) });
210
+ * }
209
211
  * ```
210
212
  */
211
213
  function set_shape(shape_id: Opaque<"b2Shape">, definition: { type?: number; radius?: number; center?: Vector3; v0?: Vector3; v1?: Vector3; v2?: Vector3; v3?: Vector3; vertices?: Vector3[]; hx?: number; hy?: number; angle?: number; loop?: boolean; prev_vertex?: Vector3; next_vertex?: Vector3 }, update_mass: boolean): void;
@@ -844,7 +844,7 @@ declare global {
844
844
  * // the info table contains meta data about all the render target attachments
845
845
  * // so it's not necessary to use resource.get_texture here, but we do it here
846
846
  * // just to show that it's possible:
847
- * const info_attachment_1 = resource.get_texture_info(info.attachments[0].handle);
847
+ * const info_attachment_1 = resource.get_texture_info(info.attachments[0]!.handle);
848
848
  * },
849
849
  * });
850
850
  *
@@ -852,7 +852,7 @@ declare global {
852
852
  * export default defineScript({
853
853
  * init() {
854
854
  * const info = resource.get_render_target_info("/my_render_target.render_targetc");
855
- * const attachment = info.attachments[0].texture;
855
+ * const attachment = info.attachments[0]!.texture!;
856
856
  * // you can also get texture info from the 'texture' field, since it's a resource hash
857
857
  * const texture_info = resource.get_texture_info(attachment);
858
858
  * go.set("#model", "texture0", attachment);
@@ -860,7 +860,7 @@ declare global {
860
860
  * });
861
861
  * ```
862
862
  */
863
- function get_render_target_info(path: Hash | string | number): { handle: number; width: number; height: number; depth: number; mipmaps: number; type: number; buffer_type: number; texture: Hash };
863
+ function get_render_target_info(path: Hash | string | number): { handle: number; attachments: { handle: number; width: number; height: number; depth: number; mipmaps: number; type: number; buffer_type: number; texture?: Hash }[] };
864
864
  /**
865
865
  * Gets the text metrics from a font
866
866
  *
@@ -1051,7 +1051,7 @@ declare global {
1051
1051
  * export default defineScript({
1052
1052
  * init(self) {
1053
1053
  * const rt_info = resource.get_render_target_info(self.my_render_target);
1054
- * go.set("#model", "texture0", rt_info.attachments[0].texture);
1054
+ * go.set("#model", "texture0", rt_info.attachments[0]!.texture!);
1055
1055
  * },
1056
1056
  * });
1057
1057
  * ```
@@ -294,7 +294,7 @@ declare global {
294
294
  * }
295
295
  * ```
296
296
  */
297
- function get_ifaddrs(): { name: string; address: string; mac: string; up: boolean; running: boolean }[];
297
+ function get_ifaddrs(): { name: string; address?: string; mac?: string; up: boolean; running: boolean }[];
298
298
  /**
299
299
  * The save-file path is operating system specific and is typically located under the user's home directory.
300
300
  * This function will raise a Lua error if unable to get the save file path.
@@ -365,7 +365,7 @@ declare global {
365
365
  * }
366
366
  * ```
367
367
  */
368
- function get_sys_info(options?: { ignore_secure?: boolean }): { device_model: string; manufacturer: string; system_name: string; system_version: string; api_version: string; language: string; device_language: string; territory: string; gmt_offset: number; device_ident: string; user_agent: string };
368
+ function get_sys_info(options?: { ignore_secure?: boolean }): { device_model?: string; manufacturer?: string; system_name: string; system_version: string; api_version: string; language: string; device_language: string; territory: string; gmt_offset: number; device_ident?: string; user_agent?: string };
369
369
  /**
370
370
  * If the file exists, it must have been created by `sys.save` to be loaded.
371
371
  * This function will raise a Lua error if an error occurs while loading the file.
@@ -95,7 +95,7 @@ declare global {
95
95
  *
96
96
  * - number `inset_bottom`
97
97
  */
98
- function get_safe_area(): { safe_area: { x: number; y: number; width: number; height: number; inset_left: number; inset_top: number; inset_right: number; inset_bottom: number } };
98
+ function get_safe_area(): { x: number; y: number; width: number; height: number; inset_left: number; inset_top: number; inset_right: number; inset_bottom: number };
99
99
  /**
100
100
  * This returns the current window size (width and height).
101
101
  */
@@ -95,7 +95,7 @@ declare global {
95
95
  *
96
96
  * - number `inset_bottom`
97
97
  */
98
- function get_safe_area(): { safe_area: { x: number; y: number; width: number; height: number; inset_left: number; inset_top: number; inset_right: number; inset_bottom: number } };
98
+ function get_safe_area(): { x: number; y: number; width: number; height: number; inset_left: number; inset_top: number; inset_right: number; inset_bottom: number };
99
99
  /**
100
100
  * This returns the current window size (width and height).
101
101
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defold-typescript/types",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "TypeScript types for the Defold engine's Lua APIs.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/emit-dts.ts CHANGED
@@ -245,6 +245,32 @@ export const RETURN_TYPE_OVERRIDES: ReadonlyMap<string, string> = new Map([
245
245
  ["editor.tx.add", 'Opaque<"transaction_step">'],
246
246
  ]);
247
247
 
248
+ // A function whose `returnvalues` declare a concrete type while the same slot's
249
+ // prose documents a `nil` alternative. Distinct from RETURN_TYPE_OVERRIDES
250
+ // above, which fills an *empty* `returnvalues`: these contradict a token
251
+ // upstream explicitly declares, so each entry records it and
252
+ // `return-correction-provenance.test.ts` pins it against every vendored
253
+ // ref-doc. Upstream adding `nil` to `types` hands the case to the top-level
254
+ // `nil` projection in `mapSlotUnion` and reds the entry, which is then deleted
255
+ // rather than re-pinned.
256
+ export interface ReturnTypeCorrection {
257
+ readonly ts: string;
258
+ readonly upstream: readonly string[];
259
+ readonly reason: string;
260
+ }
261
+
262
+ // Keyed by FQN, mirroring PROPERTY_TYPE_CORRECTIONS.
263
+ export const RETURN_TYPE_CORRECTIONS: ReadonlyMap<string, ReturnTypeCorrection> = new Map([
264
+ [
265
+ "b2d.get_body",
266
+ {
267
+ ts: 'Opaque<"b2Body"> | undefined',
268
+ upstream: ["b2Body"],
269
+ reason: "the body if successful. Otherwise nil.",
270
+ },
271
+ ],
272
+ ]);
273
+
248
274
  // A property whose upstream `<span class="type">` states a type the engine does
249
275
  // not use. Unlike every other override here, which fills a gap upstream left
250
276
  // empty, these contradict a token upstream explicitly declares — so each entry
@@ -819,6 +845,38 @@ export const TABLE_SLOT_CURATIONS: ReadonlyMap<string, TableSlotCuration> = new
819
845
  ],
820
846
  },
821
847
  ],
848
+ // resource.get_render_target_info's return `<dl>` writes one key as
849
+ // `<dt>'attachments'</dt>` — quoted rather than wrapped in `<code>` — so
850
+ // TABLE_FIELD drops it and parseTableFields hoists the seven per-attachment
851
+ // fields to the top level, contradicting the `info.attachments[0].handle`
852
+ // access in the doc's own `@example`. Same shape and same remedy as
853
+ // font.get_info:return:info above: replace the mis-parsed field list with the
854
+ // faithful nested isList object. `texture` is documented "only available if
855
+ // the render target passed in is a resource", hence optional.
856
+ [
857
+ "resource.get_render_target_info:return:table",
858
+ {
859
+ kind: "object",
860
+ fields: [
861
+ { name: "handle", types: ["number"] },
862
+ {
863
+ name: "attachments",
864
+ types: ["table"],
865
+ isList: true,
866
+ fields: [
867
+ { name: "handle", types: ["number"] },
868
+ { name: "width", types: ["number"] },
869
+ { name: "height", types: ["number"] },
870
+ { name: "depth", types: ["number"] },
871
+ { name: "mipmaps", types: ["number"] },
872
+ { name: "type", types: ["number"] },
873
+ { name: "buffer_type", types: ["number"] },
874
+ { name: "texture", types: ["hash"], optional: true },
875
+ ],
876
+ },
877
+ ],
878
+ },
879
+ ],
822
880
  // profiler.view_recorded_frame's `frame_index` is a typed `<ul>` "specify one
823
881
  // of the following": `distance` and `frame`, both numeric frame offsets. The
824
882
  // <li> items carry no `<span class="type">`, so parseTableFields recovers
@@ -1369,6 +1427,95 @@ export const TABLE_FIELD_TYPE_OVERRIDES: ReadonlyMap<string, string> = new Map([
1369
1427
  ["material.get_vertex_attributes:return:table:value", ATTRIBUTE_VALUE_TS],
1370
1428
  ]);
1371
1429
 
1430
+ // A return-slot field upstream documents as platform-gated or nil-valued while
1431
+ // declaring it like any other. Lua removes a key assigned `nil`, so "might be
1432
+ // nil if not available" and "only available on iOS and Android" describe the
1433
+ // same runtime state — the key is absent — and the correction is always `?`,
1434
+ // never a required field widened to `| undefined`.
1435
+ //
1436
+ // Separate from TABLE_FIELD_TYPE_OVERRIDES because presence is the other axis:
1437
+ // the parser reads these fields' types correctly and stays authoritative for
1438
+ // them, so an entry here restates no type and cannot drift when upstream
1439
+ // retypes a field. `upstream` is pinned rather than consumed, and `evidence` is
1440
+ // the decoded prose `return-correction-provenance.test.ts` searches the slot doc
1441
+ // for; both red the entry once upstream marks the field or drops the gate.
1442
+ //
1443
+ // Keyed `<element>:<kind>:<slot>:<field>`, the TABLE_FIELD_TYPE_OVERRIDES shape.
1444
+ export interface ReturnFieldOptionalityCorrection {
1445
+ readonly upstream: readonly string[];
1446
+ readonly evidence: string;
1447
+ }
1448
+
1449
+ // The platform-gate markers upstream renders as `<span class="icon-*">` decode
1450
+ // to `[ios]`, `[android]`, `[html5]` — `user_agent` states its gate through the
1451
+ // marker alone, with no availability sentence to pin.
1452
+ export const RETURN_FIELD_OPTIONALITY_CORRECTIONS: ReadonlyMap<
1453
+ string,
1454
+ ReturnFieldOptionalityCorrection
1455
+ > = new Map([
1456
+ [
1457
+ "sys.get_sys_info:return:sys_info:device_model",
1458
+ { upstream: ["string"], evidence: "[ios][android] Only available on iOS and Android." },
1459
+ ],
1460
+ [
1461
+ "sys.get_sys_info:return:sys_info:manufacturer",
1462
+ { upstream: ["string"], evidence: "[ios][android] Only available on iOS and Android." },
1463
+ ],
1464
+ [
1465
+ "sys.get_sys_info:return:sys_info:device_ident",
1466
+ {
1467
+ upstream: ["string"],
1468
+ evidence: '[ios] "identifierForVendor" on iOS. [android] "android_id" on Android.',
1469
+ },
1470
+ ],
1471
+ [
1472
+ "sys.get_sys_info:return:sys_info:user_agent",
1473
+ { upstream: ["string"], evidence: "[html5] The HTTP user agent" },
1474
+ ],
1475
+ [
1476
+ "sys.get_ifaddrs:return:ifaddrs:address",
1477
+ { upstream: ["string"], evidence: "might be nil if not available." },
1478
+ ],
1479
+ [
1480
+ "sys.get_ifaddrs:return:ifaddrs:mac",
1481
+ { upstream: ["string"], evidence: "might be nil if not available." },
1482
+ ],
1483
+ ]);
1484
+
1485
+ // Mark a parser-recovered field optional from RETURN_FIELD_OPTIONALITY_CORRECTIONS,
1486
+ // leaving every other field — and every field's type — untouched. Sets the
1487
+ // `optional` flag `TableField` already carries and `inlineTableType` already
1488
+ // reads, so no `mapType` is needed and no correction can change a type.
1489
+ //
1490
+ // A correction keyed to this slot that matches no recovered field throws rather
1491
+ // than no-opping: a field upstream renamed would otherwise leave a stale entry
1492
+ // looking applied while the emitted declaration silently re-widened.
1493
+ // Returns a new array; never mutates the parser's result.
1494
+ export function applyFieldOptionalityCorrections(
1495
+ elementName: string,
1496
+ slotKind: "param" | "return" | undefined,
1497
+ slotName: string | undefined,
1498
+ fields: readonly TableField[],
1499
+ ): TableField[] {
1500
+ if (slotKind === undefined || slotName === undefined) return [...fields];
1501
+ const prefix = `${tableSlotKey(elementName, slotKind, slotName)}:`;
1502
+ const corrected = fields.map((field) =>
1503
+ RETURN_FIELD_OPTIONALITY_CORRECTIONS.has(`${prefix}${field.name}`)
1504
+ ? { ...field, optional: true }
1505
+ : field,
1506
+ );
1507
+ const recovered = new Set(fields.map((field) => field.name));
1508
+ const stale = [...RETURN_FIELD_OPTIONALITY_CORRECTIONS.keys()].filter(
1509
+ (key) => key.startsWith(prefix) && !recovered.has(key.slice(prefix.length)),
1510
+ );
1511
+ if (stale.length > 0) {
1512
+ throw new Error(
1513
+ `field-optionality correction names no recovered field: ${stale.sort().join(", ")}`,
1514
+ );
1515
+ }
1516
+ return corrected;
1517
+ }
1518
+
1372
1519
  // Pin a parser-recovered field's TS type from TABLE_FIELD_TYPE_OVERRIDES,
1373
1520
  // leaving every other field parser-authoritative. A field the override does not
1374
1521
  // name is returned unchanged; a field it names gains a `tsType` (emitted
@@ -1605,7 +1752,11 @@ export function buildTableDocResolver(sources: readonly TableDocSource[]): Table
1605
1752
  return (elementName) => docByName.get(elementName);
1606
1753
  }
1607
1754
 
1608
- export function parseTableFields(doc: string, resolver?: TableDocResolver): TableField[] | null {
1755
+ export function parseTableFields(
1756
+ doc: string,
1757
+ resolver?: TableDocResolver,
1758
+ slotName?: string,
1759
+ ): TableField[] | null {
1609
1760
  const fields: TableField[] = [];
1610
1761
  for (const match of doc.matchAll(TABLE_FIELD)) {
1611
1762
  const name = (match[1] ?? "").trim();
@@ -1624,14 +1775,20 @@ export function parseTableFields(doc: string, resolver?: TableDocResolver): Tabl
1624
1775
  }
1625
1776
  // Nested recovery, scoped to the one unambiguous shape: a `<dl>` declaring a
1626
1777
  // single `table`-typed field whose keys sit in an immediately-following
1627
- // top-level `<ul>` typed-field list (`window.get_safe_area`). Attach the
1628
- // `<ul>` fields as that field's nested shape so the slot recovers as a nested
1629
- // object instead of a `Record`.
1778
+ // top-level `<ul>` typed-field list. Attach the `<ul>` fields as that field's
1779
+ // nested shape so the slot recovers as a nested object instead of a `Record`.
1780
+ //
1781
+ // Unless the header restates the slot it describes (`window.get_safe_area`'s
1782
+ // `safe_area` return documented under a `safe_area` header): that is a
1783
+ // restatement, not a nesting level, and wrapping it emits a level the engine
1784
+ // never returns. Only a caller that knows the slot name can tell the two
1785
+ // apart, so the `<ul>` keys are returned directly when they match.
1630
1786
  if (fields.length === 1) {
1631
1787
  const only = fields[0];
1632
1788
  if (only && only.types.length === 1 && only.types[0] === "table") {
1633
1789
  const nested = parseUlFields(doc);
1634
1790
  if (nested.length > 0) {
1791
+ if (slotName !== undefined && slotName === only.name) return nested;
1635
1792
  only.fields = nested;
1636
1793
  return [only];
1637
1794
  }
@@ -2586,6 +2743,10 @@ function emitReturn(
2586
2743
  // slots and leaves the consumer on its token-derived fallback.
2587
2744
  const override = RETURN_TYPE_OVERRIDES.get(elementName);
2588
2745
  if (override !== undefined) return { type: override, trailing: "", slots: [] };
2746
+ // Kept beside the override lookup so the two stay visibly distinct: this one
2747
+ // contradicts a declared token rather than filling an empty `returnvalues`,
2748
+ // and it is a per-slot render, so it reports its slot.
2749
+ const correction = RETURN_TYPE_CORRECTIONS.get(elementName);
2589
2750
  if (returnValues.length === 0) return { type: "void", trailing: "", slots: [] };
2590
2751
  if (returnValues.length > 1) {
2591
2752
  // Defold multi-returns are positional and always present; each slot maps
@@ -2617,6 +2778,13 @@ function emitReturn(
2617
2778
  }
2618
2779
  const first = returnValues[0];
2619
2780
  if (!first) return { type: "void", trailing: "", slots: [] };
2781
+ if (correction !== undefined) {
2782
+ return {
2783
+ type: correction.ts,
2784
+ trailing: "",
2785
+ slots: [{ position: 0, name: first.name, ts: correction.ts }],
2786
+ };
2787
+ }
2620
2788
  const ts =
2621
2789
  first.types.length > 0
2622
2790
  ? mapSlotUnion(
@@ -2727,19 +2895,25 @@ function mapSlotUnion(
2727
2895
  // arbitrary name. Reusing the parser keeps the inner fields in lockstep
2728
2896
  // with the doc instead of a hand-listed drift-prone copy; a documented
2729
2897
  // array-form field is pinned via TABLE_FIELD_TYPE_OVERRIDES.
2730
- const parsed = applyFieldTypeOverrides(
2898
+ const parsed = applyFieldOptionalityCorrections(
2731
2899
  elementName,
2732
2900
  slotKind,
2733
2901
  slotName,
2734
- parseTableFields(doc, resolver) ?? [],
2902
+ applyFieldTypeOverrides(
2903
+ elementName,
2904
+ slotKind,
2905
+ slotName,
2906
+ parseTableFields(doc, resolver, slotName) ?? [],
2907
+ ),
2735
2908
  );
2736
2909
  const object = inlineTableType(parsed, mapType, optionalFields);
2737
2910
  ts = `Record<string, ${object}>`;
2738
2911
  } else {
2739
- const parsed = parseTableFields(doc, resolver);
2912
+ const parsed = parseTableFields(doc, resolver, slotName);
2740
2913
  if (parsed !== null) {
2741
2914
  const nested = applyNestedFieldCurations(elementName, slotKind, slotName, parsed);
2742
- const fields = applyFieldTypeOverrides(elementName, slotKind, slotName, nested);
2915
+ const typed = applyFieldTypeOverrides(elementName, slotKind, slotName, nested);
2916
+ const fields = applyFieldOptionalityCorrections(elementName, slotKind, slotName, typed);
2743
2917
  const object = inlineTableType(fields, mapType, optionalFields);
2744
2918
  ts = isSlotLevelList(doc) ? `${object}[]` : object;
2745
2919
  } else {
@@ -0,0 +1,306 @@
1
+ // Provenance helpers for the two return-side correction tables. Both read the
2
+ // vendored ref-doc as text rather than through `parseDefoldApiDoc`, because the
3
+ // evidence a return-side correction rests on lives in the slot's HTML prose and
4
+ // the parser discards it.
5
+
6
+ import type { ReturnFieldOptionalityCorrection, ReturnTypeCorrection } from "./emit-dts";
7
+ import type { ProvenanceSurface } from "./optional-correction-provenance";
8
+
9
+ const NAMED_ENTITIES: Record<string, string> = {
10
+ "&lt;": "<",
11
+ "&gt;": ">",
12
+ "&quot;": '"',
13
+ "&#39;": "'",
14
+ "&apos;": "'",
15
+ };
16
+
17
+ function decodeEntities(text: string): string {
18
+ let out = text;
19
+ for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {
20
+ out = out.split(entity).join(char);
21
+ }
22
+ out = out.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
23
+ // `&amp;` last so an already-decoded `&` is never re-interpreted.
24
+ return out.split("&amp;").join("&");
25
+ }
26
+
27
+ /**
28
+ * Decode a ref-doc prose fragment to the plain text a correction's `evidence`
29
+ * string is matched against.
30
+ *
31
+ * Unlike `htmlToDocText`, a `<span class="icon-X">` marker survives as `[X]`.
32
+ * Upstream states some field-presence gates only through that marker —
33
+ * `user_agent` carries `icon-html5` and no availability sentence at all — so
34
+ * stripping it would leave those corrections pinned to prose that says nothing
35
+ * about presence, and an upstream fix removing the gate would not red them.
36
+ */
37
+ export function decodeSlotProse(html: string): string {
38
+ const withIcons = html.replace(
39
+ /<span class="icon-([a-z0-9-]+)"><\/span>/gi,
40
+ (_, platform: string) => `[${platform}]`,
41
+ );
42
+ return decodeEntities(withIcons.replace(/<[^>]+>/g, ""))
43
+ .replace(/[ \t]+/g, " ")
44
+ .replace(/ *\n */g, "\n")
45
+ .trim();
46
+ }
47
+
48
+ /**
49
+ * The decoded prose of one `<dd>` in a slot doc's `<dl>`, keyed by the `<dt>`
50
+ * field name. Returns `undefined` when the slot doc has no entry for the field,
51
+ * which is the signal that upstream renamed or dropped it.
52
+ */
53
+ export function fieldProse(slotDoc: string, field: string): string | undefined {
54
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
+ const match = new RegExp(`<dt><code>${escaped}</code></dt>\\s*<dd>([\\s\\S]*?)</dd>`, "i").exec(
56
+ slotDoc,
57
+ );
58
+ return match?.[1] === undefined ? undefined : decodeSlotProse(match[1]);
59
+ }
60
+
61
+ /**
62
+ * The `<span class="type">` tokens upstream declares for one `<dd>`, split the
63
+ * way the table-field parser splits them. An absent field yields `undefined`;
64
+ * a field with no type span yields an empty array.
65
+ */
66
+ export function fieldTypeTokens(slotDoc: string, field: string): readonly string[] | undefined {
67
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
68
+ const match = new RegExp(`<dt><code>${escaped}</code></dt>\\s*<dd>([\\s\\S]*?)</dd>`, "i").exec(
69
+ slotDoc,
70
+ );
71
+ if (match?.[1] === undefined) return undefined;
72
+ const span = /<span class="type">([^<]*)<\/span>/.exec(match[1]);
73
+ if (span?.[1] === undefined) return [];
74
+ return span[1]
75
+ .split("|")
76
+ .map((token) => token.trim())
77
+ .filter((token) => token.length > 0);
78
+ }
79
+
80
+ interface RawSlot {
81
+ readonly name?: string;
82
+ readonly doc?: string;
83
+ readonly types?: readonly string[];
84
+ }
85
+
86
+ function isRecord(value: unknown): value is Record<string, unknown> {
87
+ return typeof value === "object" && value !== null && !Array.isArray(value);
88
+ }
89
+
90
+ // Every `returnvalues` entry of every FUNCTION in the surface named `element`,
91
+ // unfiltered by slot: one target can declare the element more than once.
92
+ function returnSlots(doc: unknown, element: string): RawSlot[] {
93
+ if (!isRecord(doc) || !Array.isArray(doc.elements)) return [];
94
+ return doc.elements.flatMap((candidate) =>
95
+ isRecord(candidate) &&
96
+ candidate.type === "FUNCTION" &&
97
+ candidate.name === element &&
98
+ Array.isArray(candidate.returnvalues)
99
+ ? candidate.returnvalues.filter(isRecord)
100
+ : [],
101
+ );
102
+ }
103
+
104
+ // The first `returnvalues` entry of each declaration of `element`, one per
105
+ // declaration so a target that declares the element twice is folded, not sliced.
106
+ function firstReturnSlots(doc: unknown, element: string): RawSlot[] {
107
+ if (!isRecord(doc) || !Array.isArray(doc.elements)) return [];
108
+ return doc.elements.flatMap((candidate) => {
109
+ if (
110
+ !isRecord(candidate) ||
111
+ candidate.type !== "FUNCTION" ||
112
+ candidate.name !== element ||
113
+ !Array.isArray(candidate.returnvalues)
114
+ ) {
115
+ return [];
116
+ }
117
+ const first = candidate.returnvalues[0];
118
+ return isRecord(first) ? [first] : [];
119
+ });
120
+ }
121
+
122
+ function namespaceOf(element: string): string {
123
+ return element.slice(0, element.lastIndexOf("."));
124
+ }
125
+
126
+ // A sighted target lands in exactly one bucket, strongest signal first. A
127
+ // dropped field outranks a needed one because `applyFieldOptionalityCorrections`
128
+ // throws for that target, so regen fails there whatever the other declarations
129
+ // say; a needed one outranks a resolved one so an overload that still shows the
130
+ // defect keeps the correction alive, matching `correctionProvenance`.
131
+ type FieldVerdict = "missing" | "needed" | "resolved";
132
+
133
+ const FIELD_VERDICT_RANK: Record<FieldVerdict, number> = {
134
+ missing: 2,
135
+ needed: 1,
136
+ resolved: 0,
137
+ };
138
+
139
+ export interface ReturnFieldProvenance {
140
+ readonly key: string;
141
+ readonly sightedIn: readonly string[];
142
+ readonly neededBy: readonly string[];
143
+ readonly resolvedIn: readonly string[];
144
+ readonly missingFieldIn: readonly string[];
145
+ readonly typeDrift: readonly string[];
146
+ }
147
+
148
+ // Upstream sets no structural optionality flag on a return *field* — the parser
149
+ // never populates `TableField.optional`, only the curation and correction passes
150
+ // do — so there is no `isMarkedOptional` analogue to read here. "Resolved" is
151
+ // therefore prose- and token-shaped: the evidence sentence is gone from the
152
+ // field's `<dd>`, or its type span now declares `nil` itself.
153
+ //
154
+ // `missingFieldIn` is deliberately not a resolution. A field upstream dropped
155
+ // from a slot the target still declares makes the correction stale, and
156
+ // `applyFieldOptionalityCorrections` throws rather than no-op for it, so that
157
+ // target needs a human decision instead of a silent deletion.
158
+ export function fieldCorrectionProvenance(
159
+ entries: readonly [string, ReturnFieldOptionalityCorrection][],
160
+ surfaces: readonly ProvenanceSurface[],
161
+ ): ReturnFieldProvenance[] {
162
+ return entries.map(([key, correction]) => {
163
+ const parts = key.split(":");
164
+ if (parts.length !== 4) {
165
+ throw new Error(`fieldCorrectionProvenance: malformed key ${key}`);
166
+ }
167
+ const [element, , slotName, field] = parts as [string, string, string, string];
168
+ const namespace = namespaceOf(element);
169
+
170
+ const verdicts = new Map<string, FieldVerdict>();
171
+ const drift = new Map<string, Set<string>>();
172
+ for (const surface of surfaces) {
173
+ if (surface.namespace !== namespace) continue;
174
+ for (const slot of returnSlots(surface.doc, element)) {
175
+ if (slot.name !== slotName) continue;
176
+ const slotDoc = slot.doc ?? "";
177
+ const prose = fieldProse(slotDoc, field);
178
+ const tokens = fieldTypeTokens(slotDoc, field);
179
+
180
+ let verdict: FieldVerdict;
181
+ if (prose === undefined) {
182
+ verdict = "missing";
183
+ } else if (!prose.includes(correction.evidence) || (tokens ?? []).includes("nil")) {
184
+ verdict = "resolved";
185
+ } else {
186
+ verdict = "needed";
187
+ if ((tokens ?? []).join("|") !== correction.upstream.join("|")) {
188
+ const messages = drift.get(surface.target) ?? new Set<string>();
189
+ drift.set(surface.target, messages);
190
+ messages.add(
191
+ `${surface.target}: pinned ${correction.upstream.join("|")}, found ${tokens?.join("|") ?? "no field"}`,
192
+ );
193
+ }
194
+ }
195
+
196
+ const seen = verdicts.get(surface.target);
197
+ if (seen === undefined || FIELD_VERDICT_RANK[verdict] > FIELD_VERDICT_RANK[seen]) {
198
+ verdicts.set(surface.target, verdict);
199
+ }
200
+ }
201
+ }
202
+
203
+ const sightedIn = [...verdicts.keys()].sort();
204
+ const inBucket = (verdict: FieldVerdict) =>
205
+ sightedIn.filter((target) => verdicts.get(target) === verdict);
206
+ const neededBy = inBucket("needed");
207
+ return {
208
+ key,
209
+ sightedIn,
210
+ neededBy,
211
+ resolvedIn: inBucket("resolved"),
212
+ missingFieldIn: inBucket("missing"),
213
+ typeDrift: neededBy.flatMap((target) => [...(drift.get(target) ?? [])]).sort(),
214
+ };
215
+ });
216
+ }
217
+
218
+ export interface ReturnTypeProvenance {
219
+ readonly key: string;
220
+ readonly sightedIn: readonly string[];
221
+ readonly neededBy: readonly string[];
222
+ readonly resolvedIn: readonly string[];
223
+ readonly driftedIn: readonly string[];
224
+ readonly typeDrift: readonly string[];
225
+ }
226
+
227
+ // Same ranking argument as `FIELD_VERDICT_RANK`: `emitReturn` applies the
228
+ // correction to every declaration of the FQN in a target, so one drifted
229
+ // declaration makes that target's emit wrong whatever its siblings say.
230
+ type ReturnVerdict = "drift" | "needed" | "resolved";
231
+
232
+ const RETURN_VERDICT_RANK: Record<ReturnVerdict, number> = {
233
+ drift: 2,
234
+ needed: 1,
235
+ resolved: 0,
236
+ };
237
+
238
+ function baseTokens(tokens: readonly string[]): string {
239
+ return tokens.filter((token) => token !== "nil").join("|");
240
+ }
241
+
242
+ // The return-type class has the same two-sided evidence: the declared tokens
243
+ // still say what the correction contradicts, and the slot prose still states the
244
+ // contradiction. A target that fixes either half no longer needs the entry.
245
+ //
246
+ // Unlike the field lane, a changed base type is drift here rather than a
247
+ // resolution. A field correction only flips `optional` and leaves the type
248
+ // parser-authoritative, so a retyped field still emits what upstream declares; a
249
+ // return correction replaces the whole emitted type, so a base upstream no longer
250
+ // declares means the correction now overwrites a type nobody verified — a
251
+ // decision for a human, not a deletion and not a silent bank.
252
+ export function returnCorrectionProvenance(
253
+ entries: readonly [string, ReturnTypeCorrection][],
254
+ surfaces: readonly ProvenanceSurface[],
255
+ ): ReturnTypeProvenance[] {
256
+ return entries.map(([key, correction]) => {
257
+ const namespace = namespaceOf(key);
258
+ const pinned = baseTokens(correction.upstream);
259
+
260
+ const verdicts = new Map<string, ReturnVerdict>();
261
+ const drift = new Map<string, Set<string>>();
262
+ for (const surface of surfaces) {
263
+ if (surface.namespace !== namespace) continue;
264
+ // The correction rewrites the first return only, so only that slot is
265
+ // evidence; a declaration with no returns at all sights nothing.
266
+ for (const slot of firstReturnSlots(surface.doc, key)) {
267
+ const tokens = slot.types ?? [];
268
+
269
+ let verdict: ReturnVerdict;
270
+ if (baseTokens(tokens) !== pinned) {
271
+ verdict = "drift";
272
+ const messages = drift.get(surface.target) ?? new Set<string>();
273
+ drift.set(surface.target, messages);
274
+ messages.add(
275
+ `${surface.target}: pinned ${correction.upstream.join("|")}, found ${tokens.join("|")}`,
276
+ );
277
+ } else if (
278
+ tokens.includes("nil") ||
279
+ !decodeSlotProse(slot.doc ?? "").includes(correction.reason)
280
+ ) {
281
+ verdict = "resolved";
282
+ } else {
283
+ verdict = "needed";
284
+ }
285
+
286
+ const seen = verdicts.get(surface.target);
287
+ if (seen === undefined || RETURN_VERDICT_RANK[verdict] > RETURN_VERDICT_RANK[seen]) {
288
+ verdicts.set(surface.target, verdict);
289
+ }
290
+ }
291
+ }
292
+
293
+ const sightedIn = [...verdicts.keys()].sort();
294
+ const inBucket = (verdict: ReturnVerdict) =>
295
+ sightedIn.filter((target) => verdicts.get(target) === verdict);
296
+ const driftedIn = inBucket("drift");
297
+ return {
298
+ key,
299
+ sightedIn,
300
+ neededBy: inBucket("needed"),
301
+ resolvedIn: inBucket("resolved"),
302
+ driftedIn,
303
+ typeDrift: driftedIn.flatMap((target) => [...(drift.get(target) ?? [])]).sort(),
304
+ };
305
+ });
306
+ }