@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.
- package/api-doc/boom.json +3870 -0
- package/api-doc/{bridge.bridge.json → bridge.json} +754 -844
- package/api-doc/bzAnim.json +338 -0
- package/api-doc/decore.json +23 -23
- package/api-doc/defcon.json +150 -0
- package/api-doc/{defmath.defmath.json → defmath.json} +6 -46
- package/api-doc/defsave.json +518 -0
- package/api-doc/deftest.json +741 -0
- package/api-doc/{dicebag.dicebag.json → dicebag.json} +19 -1
- package/api-doc/druid.json +249 -1846
- package/api-doc/event.json +1074 -0
- package/api-doc/gooey.json +2050 -0
- package/api-doc/immutable.json +65 -0
- package/api-doc/in.accelerometer.json +75 -1
- package/api-doc/in.button.json +17 -10
- package/api-doc/in.cursor.json +26 -0
- package/api-doc/in.gesture.json +401 -6
- package/api-doc/in.keyboard.json +2 -0
- package/api-doc/in.mapper.json +12 -8
- package/api-doc/in.onscreen.json +61 -5
- package/api-doc/in.state.json +61 -13
- package/api-doc/in.textbox.json +23 -12
- package/api-doc/in.triggers.json +336 -0
- package/api-doc/lang.json +528 -0
- package/api-doc/{event.event.json → log.json} +96 -101
- package/api-doc/metrics.fps.json +61 -2
- package/api-doc/metrics.mem.json +61 -2
- package/api-doc/monarch.monarch.json +249 -7
- package/api-doc/monarch.transitions.easings.json +19 -26
- package/api-doc/monarch.transitions.gui.json +782 -79
- package/api-doc/nakama.engine.defold.json +31 -18
- package/api-doc/{nakama.nakama.json → nakama.json} +4752 -2554
- package/api-doc/nakama.util.log.json +32 -4
- package/api-doc/narrator.json +647 -0
- package/api-doc/openapi/nakama.nakama.json +7680 -0
- package/api-doc/orthographic.camera.json +96 -98
- package/api-doc/orthographic.json +602 -0
- package/api-doc/panthera.json +1183 -0
- package/api-doc/{persist.persist.json → persist.json} +26 -1
- package/api-doc/{platypus.platypus.json → platypus.json} +68 -14
- package/api-doc/proto.json +995 -0
- package/api-doc/{rendy.rendy.json → rendy.json} +46 -99
- package/api-doc/richtext.color.json +77 -0
- package/api-doc/richtext.richtext.json +375 -5
- package/api-doc/saver.saver.json +714 -263
- package/api-doc/saver.storage.json +283 -52
- package/api-doc/squid.json +846 -0
- package/api-doc/{starly.starly.json → starly.json} +1 -1
- package/api-doc/tweener.json +277 -0
- package/api-doc/{yagames.yagames.json → yagames.json} +784 -189
- package/api-doc/{zzfx.api.json → zzfx.json} +9 -9
- package/authored-targets.json +403 -0
- package/generated/{boom.boom.d.ts → boom.d.ts} +14 -0
- package/generated/bridge.d.ts +468 -0
- package/generated/{bzAnim.bzLibrary.d.ts → bzAnim.d.ts} +6 -0
- package/generated/decore.d.ts +37 -37
- package/generated/defcon.d.ts +43 -0
- package/generated/{defmath.defmath.d.ts → defmath.d.ts} +3 -2
- package/generated/defsave.d.ts +144 -0
- package/generated/{dicebag.dicebag.d.ts → dicebag.d.ts} +25 -0
- package/generated/druid.d.ts +198 -502
- package/generated/event.d.ts +318 -0
- package/generated/{gooey.gooey.d.ts → gooey.d.ts} +61 -6
- package/generated/immutable.d.ts +13 -0
- package/generated/in.onscreen.d.ts +5 -0
- package/generated/in.textbox.d.ts +4 -0
- package/generated/lang.d.ts +101 -0
- package/generated/log.d.ts +36 -0
- package/generated/metrics.fps.d.ts +19 -1
- package/generated/metrics.mem.d.ts +19 -1
- package/generated/monarch.monarch.d.ts +44 -1
- package/generated/monarch.transitions.easings.d.ts +0 -1
- package/generated/monarch.transitions.gui.d.ts +96 -12
- package/generated/nakama.d.ts +1158 -0
- package/generated/nakama.engine.defold.d.ts +3 -1
- package/generated/nakama.util.log.d.ts +6 -0
- package/generated/narrator.d.ts +121 -0
- package/generated/openapi/nakama.nakama.d.ts +884 -0
- package/generated/orthographic.camera.d.ts +24 -38
- package/generated/orthographic.d.ts +155 -0
- package/generated/panthera.d.ts +170 -0
- package/generated/{persist.persist.d.ts → persist.d.ts} +3 -0
- package/generated/{platypus.platypus.d.ts → platypus.d.ts} +9 -0
- package/generated/proto.d.ts +146 -0
- package/generated/{rendy.rendy.d.ts → rendy.d.ts} +33 -80
- package/generated/richtext.color.d.ts +15 -0
- package/generated/saver.saver.d.ts +289 -42
- package/generated/saver.storage.d.ts +79 -14
- package/generated/squid.d.ts +127 -0
- package/generated/tweener.d.ts +42 -0
- package/generated/{yagames.yagames.d.ts → yagames.d.ts} +151 -29
- package/generated/{zzfx.api.d.ts → zzfx.d.ts} +7 -0
- package/library-classification.json +0 -227
- package/library-targets.json +1 -284
- package/luals-targets.json +168 -1
- package/openapi-targets.json +17 -0
- package/package.json +9 -88
- package/script-api-targets.json +15 -0
- package/scripts/__snapshots__/parse-luals.test.ts.snap +504 -85
- package/scripts/apply-luals-overrides.ts +95 -0
- package/scripts/authored-parity.ts +718 -0
- package/scripts/emit-library-dts.ts +177 -19
- package/scripts/extract-api-doc.ts +189 -38
- package/scripts/fidelity-floor.ts +178 -0
- package/scripts/import-upstream-docs.ts +125 -0
- package/scripts/lower-api-doc.ts +69 -19
- package/scripts/luals-fidelity.ts +22 -10
- package/scripts/map-luals-types.ts +86 -22
- package/scripts/parse-lua-surface.ts +187 -0
- package/scripts/parse-luals.ts +473 -22
- package/scripts/parse-markdown-api.ts +247 -0
- package/scripts/parse-openapi-api.ts +313 -0
- package/scripts/sync-authored-types.ts +249 -0
- package/scripts/sync-library-types.ts +99 -3
- package/scripts/sync-luals-types.ts +29 -2
- package/scripts/sync-markdown-types.ts +761 -0
- package/scripts/sync-openapi-types.ts +308 -0
- package/scripts/sync-script-api-types.ts +367 -0
- package/api-doc/boom.boom.json +0 -26
- package/api-doc/bzAnim.bzLibrary.json +0 -156
- package/api-doc/defcon.console.json +0 -109
- package/api-doc/defsave.defsave.json +0 -175
- package/api-doc/deftest.deftest.json +0 -71
- package/api-doc/gooey.gooey.json +0 -963
- package/api-doc/immutable.immutable.json +0 -63
- package/api-doc/lang.lang.json +0 -411
- package/api-doc/log.log.json +0 -50
- package/api-doc/narrator.narrator.json +0 -150
- package/api-doc/proto.proto.json +0 -355
- package/api-doc/squid.squid.json +0 -660
- package/api-doc/tweener.tweener.json +0 -419
- package/generated/bridge.bridge.d.ts +0 -533
- package/generated/defcon.console.d.ts +0 -24
- package/generated/defsave.defsave.d.ts +0 -31
- package/generated/event.event.d.ts +0 -54
- package/generated/immutable.immutable.d.ts +0 -13
- package/generated/lang.lang.d.ts +0 -33
- package/generated/log.log.d.ts +0 -40
- package/generated/nakama.nakama.d.ts +0 -594
- package/generated/narrator.narrator.d.ts +0 -66
- package/generated/proto.proto.d.ts +0 -36
- package/generated/squid.squid.d.ts +0 -106
- package/generated/tweener.tweener.d.ts +0 -151
- /package/generated/{deftest.deftest.d.ts → deftest.d.ts} +0 -0
- /package/generated/{starly.starly.d.ts → starly.d.ts} +0 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface parity for the authored/forked lane: what the fork *declares* against
|
|
3
|
+
* what the pinned upstream Lua *defines*.
|
|
4
|
+
*
|
|
5
|
+
* The lane's own gate is an identity diff between the vendored `.d.ts` and the
|
|
6
|
+
* emitted golden, which proves the emit is lossless and says nothing about whether
|
|
7
|
+
* the fork is right. `parse-lua-surface.ts` reads the upstream side; this module
|
|
8
|
+
* compares it with `api-doc/<namespace>.json` — the artifact the docs-site renders,
|
|
9
|
+
* so the report measures the surface a user actually sees rather than an
|
|
10
|
+
* intermediate the reader alone knows about.
|
|
11
|
+
*
|
|
12
|
+
* The declared side is the module's *own* api-doc members. An element marked
|
|
13
|
+
* `global: true` is a file-scope declaration the library installs into the environment,
|
|
14
|
+
* defined upstream in files this target does not vendor, so it is a member of no module
|
|
15
|
+
* and is compared by neither axis — counting one would report real upstream API as
|
|
16
|
+
* invented. `declaredGlobals` records how many were set aside.
|
|
17
|
+
*
|
|
18
|
+
* Both halves of the surface are compared, on two axes that are reported separately
|
|
19
|
+
* and never averaged. The *callable* axis puts upstream members carrying a parameter
|
|
20
|
+
* list against api-doc `FUNCTION` elements; the *field* axis puts upstream constants
|
|
21
|
+
* (`M.APIOPERATOR_BEST = "BEST"`) against api-doc `VARIABLE` elements. A `TYPEDEF` is
|
|
22
|
+
* a type rather than a runtime member and enters neither. The same reason picks the
|
|
23
|
+
* declared side for both: `api-doc` is what the docs-site renders.
|
|
24
|
+
*
|
|
25
|
+
* A name that is callable on *either* side belongs to the callable axis and is never
|
|
26
|
+
* counted as a field, so one defect cannot be charged twice. `monarch.transitions.gui`
|
|
27
|
+
* was the case that forced this — it declared twelve upstream *functions* as `VARIABLE`,
|
|
28
|
+
* which the callable axis already reported as `missingMembers`, and calling them phantom
|
|
29
|
+
* fields as well would both double-count and describe an upstream name as invented. That
|
|
30
|
+
* fork has since been corrected, so the rule now stands on `classifyFieldAxis` alone: no
|
|
31
|
+
* measured target produces any of its four clauses, and all four are pinned by synthetic
|
|
32
|
+
* name sets.
|
|
33
|
+
*
|
|
34
|
+
* Every classifier here is one-sided on purpose. `missingMembers`/`missingFields` and
|
|
35
|
+
* `phantomMembers`/`phantomFields` are name-set differences, `arityMismatches` compares
|
|
36
|
+
* parameter *counts* for shared names (upstream Lua names its parameters, the fork
|
|
37
|
+
* renames freely, and a rename is not the defect this measures) — except where upstream
|
|
38
|
+
* is variadic, which has no fixed count to disagree with, so its named parameters are a
|
|
39
|
+
* floor the fork must meet and `variadicMembers` says how many members were only checked
|
|
40
|
+
* that way, and except where the fork declares several overloads, which offer a *set* of
|
|
41
|
+
* counts rather than one and where `overloadedMembers` says how many were compared that
|
|
42
|
+
* way, and except where upstream's own generator left a trailing discard, which is
|
|
43
|
+
* dropped before counting and where `placeholderMembers` says how many carried one,
|
|
44
|
+
* `coverage` is the
|
|
45
|
+
* fraction of upstream members that are declared *and* agree on arity, and
|
|
46
|
+
* `fieldCoverage` the fraction of upstream fields that are declared at all — a phantom
|
|
47
|
+
* enters neither, having no upstream member to be a fraction of. A non-empty list is a
|
|
48
|
+
* correction to make in the fork, never a number to re-baseline.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
52
|
+
import { dirname, join } from "node:path";
|
|
53
|
+
import { type LuaMember, parseLuaSurface } from "./parse-lua-surface";
|
|
54
|
+
import { type AuthoredTarget, readAuthoredTargets } from "./sync-authored-types";
|
|
55
|
+
|
|
56
|
+
export const AUTHORED_PARITY_DIR = "fidelity/authored";
|
|
57
|
+
|
|
58
|
+
export interface AuthoredArityMismatch {
|
|
59
|
+
name: string;
|
|
60
|
+
upstream: number;
|
|
61
|
+
/** The widest signature the fork offers for this name — the same number for a member
|
|
62
|
+
* declared once, and the largest of the set for one declared as overloads. One number
|
|
63
|
+
* per name is what keeps this list a diff-stable correction list. */
|
|
64
|
+
declared: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AuthoredParityReport {
|
|
68
|
+
namespace: string;
|
|
69
|
+
upstreamMembers: number;
|
|
70
|
+
/** Non-callable upstream members (`M.SOME_CONSTANT = "X"`), and the denominator of
|
|
71
|
+
* `fieldCoverage`. Recorded beside `upstreamMembers` so a high `callableCoverage`
|
|
72
|
+
* over a handful of functions cannot be read as a complete audit of the module. */
|
|
73
|
+
upstreamFields: number;
|
|
74
|
+
declaredFields: number;
|
|
75
|
+
missingFields: string[];
|
|
76
|
+
phantomFields: string[];
|
|
77
|
+
fieldCoverage: number;
|
|
78
|
+
/** Declared names that are *not* members of this module — file-scope declarations the
|
|
79
|
+
* library installs into the environment, whose definitions live upstream in files this
|
|
80
|
+
* target does not vendor. Compared by neither axis, and recorded so a small
|
|
81
|
+
* `declaredMembers` beside a large api-doc is readable rather than surprising. */
|
|
82
|
+
declaredGlobals: number;
|
|
83
|
+
declaredMembers: number;
|
|
84
|
+
missingMembers: string[];
|
|
85
|
+
phantomMembers: string[];
|
|
86
|
+
arityMismatches: AuthoredArityMismatch[];
|
|
87
|
+
/** Upstream members the fork deliberately does not declare, each with the reason
|
|
88
|
+
* upstream's own source gives. Counted as correct, so a target can reach a full
|
|
89
|
+
* `callableCoverage` by justification rather than by declaring everything — and named
|
|
90
|
+
* here, so that justification stays readable instead of vanishing into the ratio. */
|
|
91
|
+
parityExceptions: AuthoredParityException[];
|
|
92
|
+
/** Upstream prose that reached neither the fork nor the import — a member whose block
|
|
93
|
+
* the reader accepted but which carries no summary to lower, being nothing but tags or
|
|
94
|
+
* nothing but the member's own name. Distinct from `refusedDocBlocks`, where prose does
|
|
95
|
+
* exist and the reader declined the block it sits in. */
|
|
96
|
+
undocumentedMembers: number;
|
|
97
|
+
/** Declared elements carrying upstream's own summary because the fork supplied none,
|
|
98
|
+
* on both axes. Recorded beside `undocumentedMembers` because it is where that
|
|
99
|
+
* shortfall went: without it, a target the import silently skipped and a target the
|
|
100
|
+
* fork documents itself would read the same. */
|
|
101
|
+
importedDocs: number;
|
|
102
|
+
/** Upstream comment blocks the reader declined *and* the fork left unanswered. Such a
|
|
103
|
+
* block leaves no `doc` behind, so `undocumentedMembers` cannot charge it and the loss
|
|
104
|
+
* would otherwise read as a clean zero. Narrowed to the unanswered ones so it measures
|
|
105
|
+
* documentation actually missing from `/api` rather than a reader property no fork
|
|
106
|
+
* edit can move: authoring the fork's own doc-comment clears the charge. A member the
|
|
107
|
+
* fork documents is answered — the prose cannot have come from the refused block,
|
|
108
|
+
* which imports nothing, so no `docSource` check is needed. A recorded
|
|
109
|
+
* `parityException` is excused too: the member is deliberately undeclared, so there is
|
|
110
|
+
* no element to hang a brief on and the ledger already carries a human's reading of
|
|
111
|
+
* that same comment. A merely missing name is not excused — its remedy is declaring
|
|
112
|
+
* it, and `callableCoverage` charging it there is a different loss. */
|
|
113
|
+
refusedDocBlocks: number;
|
|
114
|
+
/** The raw refusal count, before the answered and excused ones are subtracted. Kept
|
|
115
|
+
* beside `refusedDocBlocks` so a corpus reading 0 cannot be mistaken for upstream
|
|
116
|
+
* writing no `--`-only blocks, and the reader diagnostic that justifies the `---` rule
|
|
117
|
+
* stays legible. */
|
|
118
|
+
refusedDocBlocksTotal: number;
|
|
119
|
+
/** How many agreeing members were compared against a floor rather than an exact count,
|
|
120
|
+
* upstream being variadic. Recorded so a `callableCoverage` of 1 over a variadic
|
|
121
|
+
* surface cannot be read as fully verified. */
|
|
122
|
+
variadicMembers: number;
|
|
123
|
+
/** How many members the fork declares as several overloads, so the comparison had a
|
|
124
|
+
* set of counts to accept rather than one. Recorded beside `variadicMembers` and for
|
|
125
|
+
* the same reason: a member that agrees because *one* of its shapes matches is a
|
|
126
|
+
* weaker result than one that agrees outright. */
|
|
127
|
+
overloadedMembers: number;
|
|
128
|
+
/** How many members upstream defines with a trailing bare `_` the comparison dropped
|
|
129
|
+
* before counting. A generated-module artefact: recorded so a `callableCoverage` over
|
|
130
|
+
* such a surface cannot be read as agreement on every parameter upstream wrote. */
|
|
131
|
+
placeholderMembers: number;
|
|
132
|
+
/** Named for its axis, not for the module: neither this nor `fieldCoverage` is
|
|
133
|
+
* "the" coverage of a target, and the two are never averaged into one. */
|
|
134
|
+
callableCoverage: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface DeclaredMember {
|
|
138
|
+
/** Every declared signature's parameter count, ascending. A name the api-doc holds
|
|
139
|
+
* once yields one entry; an overload pair yields both. */
|
|
140
|
+
params: number[];
|
|
141
|
+
documented: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Package-root-relative POSIX path of a target's committed parity artifact. */
|
|
145
|
+
export function authoredParityPath(target: AuthoredTarget): string {
|
|
146
|
+
return `${AUTHORED_PARITY_DIR}/${target.namespace}.json`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The opted-in targets, in `authored-targets.json` order. A target that vendors
|
|
150
|
+
* no upstream Lua is measured by nothing and emits nothing. */
|
|
151
|
+
export function authoredParityTargets(packageRoot: string): AuthoredTarget[] {
|
|
152
|
+
return readAuthoredTargets(packageRoot).filter((target) => target.upstreamLua.length > 0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Four places is what the near-zero end of this lane needs: `nakama` sits at
|
|
156
|
+
// 4/156, where three places would round two whole members into the same figure.
|
|
157
|
+
function round4(value: number): number {
|
|
158
|
+
return Math.round(value * 10_000) / 10_000;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface UpstreamSurface {
|
|
162
|
+
callable: Map<string, LuaMember>;
|
|
163
|
+
/** Every non-callable name, raw: a name also defined with a parameter list is still
|
|
164
|
+
* here, and `classifyFieldAxis` is what subtracts it. */
|
|
165
|
+
fields: Set<string>;
|
|
166
|
+
/** The *names* whose block the reader declined, gathered here rather than in the
|
|
167
|
+
* callable loop below, which never visits the field side: most refused blocks in this
|
|
168
|
+
* corpus sit above a constant. Names rather than a count, so each one can be asked
|
|
169
|
+
* whether the fork answered it. */
|
|
170
|
+
refusedDocs: Set<string>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function upstreamSurface(packageRoot: string, target: AuthoredTarget): UpstreamSurface {
|
|
174
|
+
const callable = new Map<string, LuaMember>();
|
|
175
|
+
const fields = new Set<string>();
|
|
176
|
+
const refusedDocs = new Set<string>();
|
|
177
|
+
for (const relative of target.upstreamLua) {
|
|
178
|
+
const surface = parseLuaSurface(readFileSync(join(packageRoot, relative), "utf8"));
|
|
179
|
+
for (const member of surface.members) {
|
|
180
|
+
if (member.refusedDoc) refusedDocs.add(member.name);
|
|
181
|
+
if (member.params === undefined) fields.add(member.name);
|
|
182
|
+
else callable.set(member.name, member);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { callable, fields, refusedDocs };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface DeclaredSurface {
|
|
189
|
+
callable: Map<string, DeclaredMember>;
|
|
190
|
+
/** Every `VARIABLE` name, raw: a name the fork also declares as a `FUNCTION` is still
|
|
191
|
+
* here, and `classifyFieldAxis` is what subtracts it. */
|
|
192
|
+
fields: Set<string>;
|
|
193
|
+
globals: number;
|
|
194
|
+
/** Elements carrying `docSource: "upstream"`, counted across both axes — the field
|
|
195
|
+
* side has no `documented` bookkeeping of its own, and the count is about provenance
|
|
196
|
+
* rather than callability. */
|
|
197
|
+
imported: number;
|
|
198
|
+
/** Every name whose element carries prose, across both axes. `DeclaredMember` tracks
|
|
199
|
+
* the same thing for the callable side alone; `refusedDocBlocks` has to ask the
|
|
200
|
+
* question of a constant too, most of this corpus's refused blocks sitting above one. */
|
|
201
|
+
documented: Set<string>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function declaredSurface(packageRoot: string, target: AuthoredTarget): DeclaredSurface {
|
|
205
|
+
const doc = JSON.parse(readFileSync(join(packageRoot, target.apiDoc), "utf8")) as {
|
|
206
|
+
elements: {
|
|
207
|
+
type: string;
|
|
208
|
+
name: string;
|
|
209
|
+
global?: boolean;
|
|
210
|
+
brief?: string;
|
|
211
|
+
description?: string;
|
|
212
|
+
docSource?: string;
|
|
213
|
+
parameters?: unknown[];
|
|
214
|
+
}[];
|
|
215
|
+
};
|
|
216
|
+
const callable = new Map<string, DeclaredMember>();
|
|
217
|
+
const fields = new Set<string>();
|
|
218
|
+
const documented = new Set<string>();
|
|
219
|
+
let globals = 0;
|
|
220
|
+
let imported = 0;
|
|
221
|
+
for (const element of doc.elements) {
|
|
222
|
+
if (element.type !== "FUNCTION" && element.type !== "VARIABLE") continue;
|
|
223
|
+
if (element.global === true) {
|
|
224
|
+
globals += 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (element.docSource === "upstream") imported += 1;
|
|
228
|
+
if ((element.brief ?? "") !== "" || (element.description ?? "") !== "") {
|
|
229
|
+
documented.add(element.name);
|
|
230
|
+
}
|
|
231
|
+
if (element.type === "FUNCTION") {
|
|
232
|
+
// Merged rather than overwritten: the api-doc holds one `FUNCTION` element per
|
|
233
|
+
// overload, and keeping only the last read a correctly-modelled pair as whichever
|
|
234
|
+
// shape happened to come last. `documented` is true when *any* of them carries
|
|
235
|
+
// prose — the docs-site renders the group, not the element.
|
|
236
|
+
const documented = (element.brief ?? "") !== "" || (element.description ?? "") !== "";
|
|
237
|
+
const existing = callable.get(element.name);
|
|
238
|
+
const params = [...(existing?.params ?? []), element.parameters?.length ?? 0].sort(
|
|
239
|
+
(a, b) => a - b,
|
|
240
|
+
);
|
|
241
|
+
callable.set(element.name, {
|
|
242
|
+
params,
|
|
243
|
+
documented: (existing?.documented ?? false) || documented,
|
|
244
|
+
});
|
|
245
|
+
} else fields.add(element.name);
|
|
246
|
+
}
|
|
247
|
+
return { callable, fields, globals, imported, documented };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The four raw name sets of a target, callable and non-callable on both sides. */
|
|
251
|
+
export interface FieldAxisInput {
|
|
252
|
+
upstreamCallable: ReadonlySet<string>;
|
|
253
|
+
upstreamNonCallable: ReadonlySet<string>;
|
|
254
|
+
declaredCallable: ReadonlySet<string>;
|
|
255
|
+
declaredVariables: ReadonlySet<string>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The field axis of one target: the two examined sets and their two differences. */
|
|
259
|
+
export interface FieldAxis {
|
|
260
|
+
upstreamFields: Set<string>;
|
|
261
|
+
declaredFields: Set<string>;
|
|
262
|
+
missingFields: string[];
|
|
263
|
+
phantomFields: string[];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The whole either-side rule, in one place and pure over name sets: a name callable on
|
|
268
|
+
* either side belongs to the callable axis and is never counted as a field.
|
|
269
|
+
*
|
|
270
|
+
* Four clauses enforce it — a subtraction per side, then a filter per difference — and
|
|
271
|
+
* only one of them has a case in the measured corpus, so the rest are pinned by
|
|
272
|
+
* synthetic sets rather than by any target. `missingFields` re-tests `declaredCallable`
|
|
273
|
+
* even though `declaredFields` already excludes those names: a name the fork declares
|
|
274
|
+
* *only* as a `FUNCTION` never enters `declaredFields` to begin with, and that is the
|
|
275
|
+
* corner the clause exists for.
|
|
276
|
+
*/
|
|
277
|
+
export function classifyFieldAxis(input: FieldAxisInput): FieldAxis {
|
|
278
|
+
const upstreamFields = new Set(input.upstreamNonCallable);
|
|
279
|
+
for (const name of input.upstreamCallable) upstreamFields.delete(name);
|
|
280
|
+
const declaredFields = new Set(input.declaredVariables);
|
|
281
|
+
for (const name of input.declaredCallable) declaredFields.delete(name);
|
|
282
|
+
return {
|
|
283
|
+
upstreamFields,
|
|
284
|
+
declaredFields,
|
|
285
|
+
missingFields: [...upstreamFields]
|
|
286
|
+
.filter((name) => !declaredFields.has(name) && !input.declaredCallable.has(name))
|
|
287
|
+
.sort(),
|
|
288
|
+
phantomFields: [...declaredFields]
|
|
289
|
+
.filter((name) => !upstreamFields.has(name) && !input.upstreamCallable.has(name))
|
|
290
|
+
.sort(),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Whether upstream's parameter list ends in a discard the comparison should drop.
|
|
296
|
+
*
|
|
297
|
+
* Only a *trailing* `_` is unreachable by position: one upstream names in the middle
|
|
298
|
+
* still has to be passed for the parameters after it to land, so the fork declares it.
|
|
299
|
+
*
|
|
300
|
+
* `varargs` decides the case the list alone cannot show. `readParams` filters a `...` tail
|
|
301
|
+
* out of `params` and reports it separately, so `function M.f(a, _, ...)` arrives here with
|
|
302
|
+
* `params` ending in `_` — and that `_` is still passed for any vararg to land, which is
|
|
303
|
+
* the same positional argument again.
|
|
304
|
+
*/
|
|
305
|
+
export function hasTrailingDiscard(params: readonly string[], varargs: boolean): boolean {
|
|
306
|
+
return !varargs && params.at(-1) === "_";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** One shared name's two parameter counts, and whether upstream's definition ends in
|
|
310
|
+
* `...`. A named object rather than three scalars: two of them are numbers, and a
|
|
311
|
+
* transposed call would compare the wrong pair silently. */
|
|
312
|
+
export interface ArityInput {
|
|
313
|
+
upstreamNamed: number;
|
|
314
|
+
upstreamVariadic: boolean;
|
|
315
|
+
/** True when upstream's last named parameter is a generated discard no body reads and no
|
|
316
|
+
* consumer can pass meaningfully, so it is not counted — `hasTrailingDiscard` is the
|
|
317
|
+
* derivation, and reads more than the last entry of the list. */
|
|
318
|
+
upstreamPlaceholder: boolean;
|
|
319
|
+
/** Every parameter count the fork declares for this name — one entry per overload,
|
|
320
|
+
* and never empty. */
|
|
321
|
+
declared: number[];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Whether the fork's count is acceptable, whether saying so took a floor or a set, and
|
|
325
|
+
* the single count a disagreement is reported at. */
|
|
326
|
+
export interface ArityVerdict {
|
|
327
|
+
agrees: boolean;
|
|
328
|
+
floorChecked: boolean;
|
|
329
|
+
overloadChecked: boolean;
|
|
330
|
+
placeholderChecked: boolean;
|
|
331
|
+
/** The widest shape the fork offers — what `arityMismatches` reports, so the list
|
|
332
|
+
* stays one number per name whether or not the member is overloaded. */
|
|
333
|
+
declaredWidest: number;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The arity comparison for one shared member, pure over the counts.
|
|
338
|
+
*
|
|
339
|
+
* `function M.play(...)` names no parameters, so an exact comparison reads the fork's one
|
|
340
|
+
* rest parameter as a mismatch against zero — the instrument's defect, not the fork's. A
|
|
341
|
+
* variadic upstream member has no fixed count to disagree with, so its named parameters
|
|
342
|
+
* become a floor: the fork must declare at least them, and anything above is the rest.
|
|
343
|
+
*
|
|
344
|
+
* A fork may also declare several overloads for one name, modelling an upstream body that
|
|
345
|
+
* branches on whether an argument was passed. That is a *set* of call shapes, and the
|
|
346
|
+
* member agrees when any one of them meets the rule above — reading a single count would
|
|
347
|
+
* charge a correct fork for whichever shape the reader happened to keep.
|
|
348
|
+
*
|
|
349
|
+
* A generated module adds a third case. `nakama.lua` is written by
|
|
350
|
+
* `codegen/generate-rest.go`, and 66 of its exports end in a bare `_` that no body reads
|
|
351
|
+
* and no LuaDoc documents — the generator's trailing discard rather than a parameter a
|
|
352
|
+
* consumer can pass. Only a *trailing* one is unreachable by position, so only a trailing
|
|
353
|
+
* one is dropped, and it is dropped before either branch above: a variadic member's floor
|
|
354
|
+
* is the count that remains. The reader is untouched by this — it transcribes what
|
|
355
|
+
* upstream wrote, and the interpretation belongs here.
|
|
356
|
+
*
|
|
357
|
+
* All three softenings stay visible. `floorChecked` is true whenever upstream is variadic,
|
|
358
|
+
* `overloadChecked` whenever the fork declares more than one shape, and
|
|
359
|
+
* `placeholderChecked` whenever a discard was dropped — in each case whether or not it
|
|
360
|
+
* changed the verdict — so `variadicMembers`, `overloadedMembers` and `placeholderMembers`
|
|
361
|
+
* count every member the weaker check covered rather than only the ones it rescued.
|
|
362
|
+
*/
|
|
363
|
+
export function classifyArity(input: ArityInput): ArityVerdict {
|
|
364
|
+
const upstreamNamed = input.upstreamNamed - (input.upstreamPlaceholder ? 1 : 0);
|
|
365
|
+
const meets = (declared: number) =>
|
|
366
|
+
input.upstreamVariadic ? declared >= upstreamNamed : declared === upstreamNamed;
|
|
367
|
+
return {
|
|
368
|
+
agrees: input.declared.some(meets),
|
|
369
|
+
floorChecked: input.upstreamVariadic,
|
|
370
|
+
overloadChecked: input.declared.length > 1,
|
|
371
|
+
placeholderChecked: input.upstreamPlaceholder,
|
|
372
|
+
declaredWidest: Math.max(...input.declared),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export const AUTHORED_EXCEPTIONS_MANIFEST_FILE = "authored-parity-exceptions.json";
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Why the fork is right not to declare an upstream member. Closed, and both members
|
|
380
|
+
* were introduced by a real case rather than anticipated: a speculative kind would
|
|
381
|
+
* read as a licence to except anything.
|
|
382
|
+
*
|
|
383
|
+
* `script-lifecycle` — upstream exports it for the library's own bundled `.script`,
|
|
384
|
+
* saying so in its own comment; a consumer never calls it, and declaring it would
|
|
385
|
+
* widen the published surface with functions no consumer should reach.
|
|
386
|
+
* `deprecated-stub` — upstream's body is nothing but `error("… is deprecated")`, so
|
|
387
|
+
* declaring the member offers a call that cannot succeed.
|
|
388
|
+
*
|
|
389
|
+
* An arity or phantom exception is deliberately not expressible here. `nakama`'s 26
|
|
390
|
+
* phantoms are the case that will ask for one, and it states its own shape.
|
|
391
|
+
*/
|
|
392
|
+
export const AUTHORED_EXCEPTION_KINDS = ["deprecated-stub", "script-lifecycle"] as const;
|
|
393
|
+
|
|
394
|
+
export type AuthoredExceptionKind = (typeof AUTHORED_EXCEPTION_KINDS)[number];
|
|
395
|
+
|
|
396
|
+
/** One justified divergence. `reason` cites upstream's own file and line, so the
|
|
397
|
+
* entry can be re-checked against the pin rather than taken on trust. */
|
|
398
|
+
export interface AuthoredParityException {
|
|
399
|
+
name: string;
|
|
400
|
+
kind: AuthoredExceptionKind;
|
|
401
|
+
reason: string;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function exceptionField(entry: Record<string, unknown>, field: string, path: string, at: string) {
|
|
405
|
+
const value = entry[field];
|
|
406
|
+
if (typeof value !== "string" || value === "") {
|
|
407
|
+
throw new Error(`${path}: ${at} needs a non-empty string "${field}", got ${describe(value)}`);
|
|
408
|
+
}
|
|
409
|
+
return value;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The exceptions manifest's parse-time contract: a plain object mapping each namespace
|
|
414
|
+
* to an array of `{ name, kind, reason }` entries. Modelled on `parseAuthoredFloors` —
|
|
415
|
+
* pure over `raw`, so the caller owns the read — and on the `parityVerdict` closed set,
|
|
416
|
+
* whose unknown-reason throw this mirrors.
|
|
417
|
+
*/
|
|
418
|
+
export function parseAuthoredExceptions(
|
|
419
|
+
raw: unknown,
|
|
420
|
+
path: string,
|
|
421
|
+
): Record<string, AuthoredParityException[]> {
|
|
422
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
423
|
+
throw new Error(`${path}: expected a JSON object of exceptions, got ${describe(raw)}`);
|
|
424
|
+
}
|
|
425
|
+
const manifest: Record<string, AuthoredParityException[]> = {};
|
|
426
|
+
for (const [namespace, entries] of Object.entries(raw)) {
|
|
427
|
+
if (!Array.isArray(entries)) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`${path}: "${namespace}" must be an array of entries, got ${describe(entries)}`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
manifest[namespace] = entries.map((entry) => {
|
|
433
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
434
|
+
throw new Error(`${path}: "${namespace}" holds a non-object entry ${describe(entry)}`);
|
|
435
|
+
}
|
|
436
|
+
const record = entry as Record<string, unknown>;
|
|
437
|
+
const name = exceptionField(record, "name", path, `an entry of "${namespace}"`);
|
|
438
|
+
const at = `"${namespace}.${name}"`;
|
|
439
|
+
const kind = record.kind;
|
|
440
|
+
if (!AUTHORED_EXCEPTION_KINDS.includes(kind as AuthoredExceptionKind)) {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`${path}: ${at} has unknown kind ${describe(kind)} — expected one of ${AUTHORED_EXCEPTION_KINDS.join(", ")}`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
name,
|
|
447
|
+
kind: kind as AuthoredExceptionKind,
|
|
448
|
+
reason: exceptionField(record, "reason", path, at),
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return manifest;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The committed ledger, read from the package root the parity pass measures. */
|
|
456
|
+
export function readAuthoredExceptions(
|
|
457
|
+
packageRoot: string,
|
|
458
|
+
): Record<string, AuthoredParityException[]> {
|
|
459
|
+
const path = AUTHORED_EXCEPTIONS_MANIFEST_FILE;
|
|
460
|
+
return parseAuthoredExceptions(JSON.parse(readFileSync(join(packageRoot, path), "utf8")), path);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The parity report for one opted-in target, recomputed from the vendored upstream
|
|
465
|
+
* Lua and the committed api-doc. Pure with respect to the working tree: it reads,
|
|
466
|
+
* never writes, so the committed artifact and this return value can be compared.
|
|
467
|
+
*/
|
|
468
|
+
export function buildAuthoredParity(
|
|
469
|
+
packageRoot: string,
|
|
470
|
+
target: AuthoredTarget,
|
|
471
|
+
exceptions: Record<string, AuthoredParityException[]> = readAuthoredExceptions(packageRoot),
|
|
472
|
+
): AuthoredParityReport {
|
|
473
|
+
const {
|
|
474
|
+
callable: upstream,
|
|
475
|
+
fields: upstreamVariables,
|
|
476
|
+
refusedDocs: refused,
|
|
477
|
+
} = upstreamSurface(packageRoot, target);
|
|
478
|
+
const {
|
|
479
|
+
callable: declared,
|
|
480
|
+
fields: declaredVariables,
|
|
481
|
+
globals: declaredGlobals,
|
|
482
|
+
imported: importedDocs,
|
|
483
|
+
documented: declaredDocumented,
|
|
484
|
+
} = declaredSurface(packageRoot, target);
|
|
485
|
+
const { upstreamFields, declaredFields, missingFields, phantomFields } = classifyFieldAxis({
|
|
486
|
+
upstreamCallable: new Set(upstream.keys()),
|
|
487
|
+
upstreamNonCallable: upstreamVariables,
|
|
488
|
+
declaredCallable: new Set(declared.keys()),
|
|
489
|
+
declaredVariables,
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Checked ahead of the loop rather than inside it, so a stale entry throws on the
|
|
493
|
+
// entry's own terms — an entry naming nothing upstream is never reached by a loop
|
|
494
|
+
// over upstream members at all.
|
|
495
|
+
const excepted = new Map<string, AuthoredParityException>();
|
|
496
|
+
for (const exception of exceptions[target.namespace] ?? []) {
|
|
497
|
+
const at = `${target.namespace}.${exception.name}`;
|
|
498
|
+
if (!upstream.has(exception.name)) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`${AUTHORED_EXCEPTIONS_MANIFEST_FILE}: "${at}" excepts a member upstream does not define — delete the entry`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
if (declared.has(exception.name)) {
|
|
504
|
+
throw new Error(
|
|
505
|
+
`${AUTHORED_EXCEPTIONS_MANIFEST_FILE}: "${at}" is unnecessary, the fork declares it — delete the entry`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
excepted.set(exception.name, exception);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const refusedDocBlocks = [...refused].filter(
|
|
512
|
+
(name) => !declaredDocumented.has(name) && !excepted.has(name),
|
|
513
|
+
).length;
|
|
514
|
+
|
|
515
|
+
const missingMembers: string[] = [];
|
|
516
|
+
const parityExceptions: AuthoredParityException[] = [];
|
|
517
|
+
const arityMismatches: AuthoredArityMismatch[] = [];
|
|
518
|
+
let undocumentedMembers = 0;
|
|
519
|
+
let variadicMembers = 0;
|
|
520
|
+
let overloadedMembers = 0;
|
|
521
|
+
let placeholderMembers = 0;
|
|
522
|
+
let correct = 0;
|
|
523
|
+
|
|
524
|
+
for (const [name, member] of upstream) {
|
|
525
|
+
const match = declared.get(name);
|
|
526
|
+
if (match === undefined) {
|
|
527
|
+
const exception = excepted.get(name);
|
|
528
|
+
if (exception === undefined) missingMembers.push(name);
|
|
529
|
+
else {
|
|
530
|
+
parityExceptions.push(exception);
|
|
531
|
+
correct += 1;
|
|
532
|
+
}
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const params = member.params as string[];
|
|
536
|
+
const placeholder = hasTrailingDiscard(params, member.varargs);
|
|
537
|
+
// The count actually compared, so `arityMismatches` reports the number the fork has
|
|
538
|
+
// to meet rather than the one upstream's line reads.
|
|
539
|
+
const upstreamParams = params.length - (placeholder ? 1 : 0);
|
|
540
|
+
const verdict = classifyArity({
|
|
541
|
+
upstreamNamed: params.length,
|
|
542
|
+
upstreamVariadic: member.varargs,
|
|
543
|
+
upstreamPlaceholder: placeholder,
|
|
544
|
+
declared: match.params,
|
|
545
|
+
});
|
|
546
|
+
if (verdict.agrees) correct += 1;
|
|
547
|
+
else arityMismatches.push({ name, upstream: upstreamParams, declared: verdict.declaredWidest });
|
|
548
|
+
if (verdict.floorChecked) variadicMembers += 1;
|
|
549
|
+
if (verdict.overloadChecked) overloadedMembers += 1;
|
|
550
|
+
if (verdict.placeholderChecked) placeholderMembers += 1;
|
|
551
|
+
if (member.doc !== "" && !match.documented) undocumentedMembers += 1;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const phantomMembers = [...declared.keys()].filter((name) => !upstream.has(name));
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
namespace: target.namespace,
|
|
558
|
+
upstreamMembers: upstream.size,
|
|
559
|
+
upstreamFields: upstreamFields.size,
|
|
560
|
+
declaredFields: declaredFields.size,
|
|
561
|
+
missingFields,
|
|
562
|
+
phantomFields,
|
|
563
|
+
fieldCoverage:
|
|
564
|
+
upstreamFields.size === 0
|
|
565
|
+
? 1
|
|
566
|
+
: round4((upstreamFields.size - missingFields.length) / upstreamFields.size),
|
|
567
|
+
declaredGlobals,
|
|
568
|
+
declaredMembers: declared.size,
|
|
569
|
+
missingMembers: missingMembers.sort(),
|
|
570
|
+
phantomMembers: phantomMembers.sort(),
|
|
571
|
+
arityMismatches: arityMismatches.sort((a, b) => a.name.localeCompare(b.name)),
|
|
572
|
+
parityExceptions: parityExceptions.sort((a, b) => a.name.localeCompare(b.name)),
|
|
573
|
+
undocumentedMembers,
|
|
574
|
+
importedDocs,
|
|
575
|
+
refusedDocBlocks,
|
|
576
|
+
refusedDocBlocksTotal: refused.size,
|
|
577
|
+
variadicMembers,
|
|
578
|
+
overloadedMembers,
|
|
579
|
+
placeholderMembers,
|
|
580
|
+
callableCoverage: upstream.size === 0 ? 1 : round4(correct / upstream.size),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The committed artifact's exact bytes, so a round-trip comparison is total. */
|
|
585
|
+
export function renderAuthoredParity(report: AuthoredParityReport): string {
|
|
586
|
+
return `${JSON.stringify(report, null, 2)}\n`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export const AUTHORED_FLOOR_MANIFEST_FILE = "authored-parity-floor.json";
|
|
590
|
+
|
|
591
|
+
/** One artifact's floor, one entry per reported axis. Both are required: an entry
|
|
592
|
+
* naming a single axis would leave the other unratcheted while every surrounding
|
|
593
|
+
* assertion stayed green, which is the failure this shape exists to make impossible. */
|
|
594
|
+
export interface AuthoredFloor {
|
|
595
|
+
callable: number;
|
|
596
|
+
field: number;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** The coverage pair a floor entry is compared against. */
|
|
600
|
+
export interface AuthoredCoverage {
|
|
601
|
+
namespace: string;
|
|
602
|
+
callableCoverage: number;
|
|
603
|
+
fieldCoverage: number;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const AUTHORED_FLOOR_AXES = ["callable", "field"] as const;
|
|
607
|
+
|
|
608
|
+
// `JSON.stringify` renders NaN and Infinity as `null` and returns `undefined` for
|
|
609
|
+
// `undefined`, either of which would misreport the value the manifest actually holds.
|
|
610
|
+
function describe(value: unknown): string {
|
|
611
|
+
if (typeof value === "number") return String(value);
|
|
612
|
+
return JSON.stringify(value) ?? String(value);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* The authored floor manifest's parse-time contract: a plain object mapping each
|
|
617
|
+
* artifact path to a `{ callable, field }` pair of finite ratios in `[0, 1]`.
|
|
618
|
+
*
|
|
619
|
+
* Separate from `fidelity-floor.ts`'s `parseFloors`, which validates the token lane's
|
|
620
|
+
* flat ratios — this manifest ratchets two axes per key, so the bare number that
|
|
621
|
+
* lane accepts is precisely the pre-migration shape rejected here.
|
|
622
|
+
*/
|
|
623
|
+
export function parseAuthoredFloors(raw: unknown, path: string): Record<string, AuthoredFloor> {
|
|
624
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
625
|
+
throw new Error(`${path}: expected a JSON object of floors, got ${describe(raw)}`);
|
|
626
|
+
}
|
|
627
|
+
const floors: Record<string, AuthoredFloor> = {};
|
|
628
|
+
for (const [key, entry] of Object.entries(raw)) {
|
|
629
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
630
|
+
throw new Error(
|
|
631
|
+
`${path}: floor "${key}" must be an object of "callable" and "field" ratios, got ${describe(entry)}`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
for (const axis of AUTHORED_FLOOR_AXES) {
|
|
635
|
+
if (!(axis in entry)) {
|
|
636
|
+
throw new Error(`${path}: floor "${key}" is missing its "${axis}" axis`);
|
|
637
|
+
}
|
|
638
|
+
const value = (entry as Record<string, unknown>)[axis];
|
|
639
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`${path}: floor "${key}" axis "${axis}" must be a finite number in [0, 1], got ${describe(value)}`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
floors[key] = entry as unknown as AuthoredFloor;
|
|
646
|
+
}
|
|
647
|
+
return floors;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function coverageField(raw: Record<string, unknown>, field: string, path: string): number {
|
|
651
|
+
const value = raw[field];
|
|
652
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
653
|
+
throw new Error(`${path}: expected a finite numeric "${field}", got ${describe(value)}`);
|
|
654
|
+
}
|
|
655
|
+
return value;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Every committed parity artifact, keyed by package-root-relative POSIX path and
|
|
660
|
+
* sorted by that key. Both axes are read under a validating contract, so a renamed
|
|
661
|
+
* or dropped coverage key throws here rather than reaching the ratchet as
|
|
662
|
+
* `undefined` — where every `<` comparison would be false and the gate would pass
|
|
663
|
+
* while comparing nothing.
|
|
664
|
+
*/
|
|
665
|
+
export function collectAuthoredParity(packageRoot: string): Record<string, AuthoredCoverage> {
|
|
666
|
+
const dir = join(packageRoot, AUTHORED_PARITY_DIR);
|
|
667
|
+
if (!existsSync(dir)) return {};
|
|
668
|
+
const artifacts: Record<string, AuthoredCoverage> = {};
|
|
669
|
+
for (const name of readdirSync(dir).sort()) {
|
|
670
|
+
if (!name.endsWith(".json")) continue;
|
|
671
|
+
const key = `${AUTHORED_PARITY_DIR}/${name}`;
|
|
672
|
+
const raw = JSON.parse(readFileSync(join(dir, name), "utf8")) as Record<string, unknown>;
|
|
673
|
+
artifacts[key] = {
|
|
674
|
+
namespace: String(raw.namespace),
|
|
675
|
+
callableCoverage: coverageField(raw, "callableCoverage", key),
|
|
676
|
+
fieldCoverage: coverageField(raw, "fieldCoverage", key),
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
return artifacts;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Every axis of every artifact that sits below its floor, one message per axis so a
|
|
684
|
+
* drop on one can never be masked by the other holding. An artifact with no floor
|
|
685
|
+
* entry is not reported here — the bijection assertions cover that separately.
|
|
686
|
+
*/
|
|
687
|
+
export function authoredFloorRegressions(
|
|
688
|
+
artifacts: Record<string, AuthoredCoverage>,
|
|
689
|
+
floors: Record<string, AuthoredFloor>,
|
|
690
|
+
): string[] {
|
|
691
|
+
const regressions: string[] = [];
|
|
692
|
+
for (const [path, artifact] of Object.entries(artifacts)) {
|
|
693
|
+
const floor = floors[path];
|
|
694
|
+
if (floor === undefined) continue;
|
|
695
|
+
const measured = { callable: artifact.callableCoverage, field: artifact.fieldCoverage };
|
|
696
|
+
for (const axis of AUTHORED_FLOOR_AXES) {
|
|
697
|
+
if (measured[axis] < floor[axis]) {
|
|
698
|
+
regressions.push(
|
|
699
|
+
`${artifact.namespace}: ${axis} coverage ${measured[axis]} is below its floor ${floor[axis]} — correct the fork, do not lower the floor`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return regressions;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (import.meta.main) {
|
|
708
|
+
const root = join(import.meta.dir, "..");
|
|
709
|
+
for (const target of authoredParityTargets(root)) {
|
|
710
|
+
const report = buildAuthoredParity(root, target);
|
|
711
|
+
const dest = join(root, authoredParityPath(target));
|
|
712
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
713
|
+
writeFileSync(dest, renderAuthoredParity(report));
|
|
714
|
+
console.log(
|
|
715
|
+
`${target.namespace}: callable ${report.upstreamMembers} upstream, ${report.missingMembers.length} missing, ${report.phantomMembers.length} phantom, ${report.arityMismatches.length} arity, coverage ${report.callableCoverage} | fields ${report.upstreamFields} upstream, ${report.missingFields.length} missing, ${report.phantomFields.length} phantom, coverage ${report.fieldCoverage}`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
}
|