@bowmark/web 1.12.2 → 1.12.3

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.
@@ -0,0 +1,278 @@
1
+ // The ARGUMENT SHAPE guard — refuse a value the declared signature does not accept,
2
+ // before the request.
3
+ //
4
+ // `guard.ts` asks a structural question about JSON: can this value cross the wire at
5
+ // all. It is the same question for every function and it says nothing about SHAPE, so
6
+ // `bowmark.music.search({ quesry: "x" })` sails through it and comes back as a
7
+ // capability failure, metered, after a round trip. This file asks the other question:
8
+ // does this value match what `bowmark.music.search` declares.
9
+ //
10
+ // ── A SCHEMA plus one interpreter, not 192 generated functions ───────────────
11
+ //
12
+ // The generator emits DATA (`generated/validators.ts`) and this file is the only
13
+ // thing that reads it. 192 bespoke emitted functions would be 192 pieces of code no
14
+ // test ever runs; one interpreter over a fixture table is testable in an afternoon,
15
+ // and the generator's job shrinks to "TypeScript type text in, tree out". Zero
16
+ // runtime dependencies is the product, so there is no validator library on either
17
+ // side of that line.
18
+ //
19
+ // ── EVERY rule here leans toward ACCEPTING ──────────────────────────────────
20
+ //
21
+ // This is the whole design, and it is not timidity. A false REFUSAL is a caller
22
+ // whose correct argument is rejected by their own client, with no way around it and
23
+ // no server to appeal to. A false ACCEPT costs one round trip and lands them in
24
+ // exactly the error they would have got before this file existed. The two failure
25
+ // directions are not comparable, so:
26
+ //
27
+ // - An object is OPEN. An unknown property is accepted, because TypeScript's
28
+ // excess-property check is a compile-time nicety that fires on literals only,
29
+ // and a caller who spread a wider object is doing something legal.
30
+ // - Anything the type compiler could not fully model becomes `any`, which accepts
31
+ // everything. It never guesses a shape from a name.
32
+ // - Arity is checked DOWNWARD only — a missing required argument is refused, a
33
+ // surplus one is not. A surplus argument is what a caller on an older published
34
+ // version has when the library grew a parameter, and JavaScript ignores it.
35
+ //
36
+ // The one place this file is deliberately strict is a string LITERAL union
37
+ // (`sort?: "hot" | "new"`), because a typo'd enum value is the single most common
38
+ // wrong argument and the accepted set makes a legible error message.
39
+ /** Split `["music", "search"]` / `["providers", "aa", "getFlightStatus"]` into the
40
+ * unit namespace and the function name.
41
+ *
42
+ * The last segment is always the function and everything before it is the
43
+ * namespace, which is true of both tiers by construction: a capability is
44
+ * `<id>.<fn>` and a provider is `providers.<id>.<fn>`. Nothing deeper exists — the
45
+ * runtime's own dispatcher refuses one. */
46
+ function splitPath(path) {
47
+ if (path.length < 2)
48
+ return null;
49
+ return { namespace: path.slice(0, -1).join("."), fn: path[path.length - 1] };
50
+ }
51
+ export function lookupParams(table, path) {
52
+ const split = splitPath(path);
53
+ if (!split)
54
+ return { kind: "unknown-unit" };
55
+ const unit = table.units[split.namespace];
56
+ if (!unit)
57
+ return { kind: "unknown-unit" };
58
+ if (!Object.hasOwn(unit.functions, split.fn))
59
+ return { kind: "unknown-function" };
60
+ // `hasOwn` above is what distinguishes an EXPLICIT null from an absent key, and
61
+ // `noUncheckedIndexedAccess` cannot see that it did — hence the widened read.
62
+ const params = unit.functions[split.fn];
63
+ return params === null ? { kind: "unchecked" } : { kind: "checked", params };
64
+ }
65
+ /** Check an argument list against a function's declared parameters.
66
+ *
67
+ * Returns the FIRST problem, or null. `args` is the caller's array verbatim; a
68
+ * trailing `undefined` is treated as absent, because that is what
69
+ * `f(a, undefined)` means to a caller passing an optional through. */
70
+ export function argsProblem(params, args, defs) {
71
+ for (let i = 0; i < params.length; i++) {
72
+ const param = params[i];
73
+ if (param.rest) {
74
+ for (let j = i; j < args.length; j++) {
75
+ const problem = check(args[j], param.schema, defs, `args[${j}]`);
76
+ if (problem)
77
+ return problem;
78
+ }
79
+ return null;
80
+ }
81
+ const supplied = i < args.length ? args[i] : undefined;
82
+ if (supplied === undefined) {
83
+ // Checked DOWNWARD only. A required parameter nobody passed is a refusal the
84
+ // caller can act on; a surplus one is not, and refusing it would break a
85
+ // caller on a published version older than the parameter.
86
+ if (!param.optional) {
87
+ return {
88
+ path: `args[${i}]`,
89
+ reason: `missing — \`${param.name}\` is required`,
90
+ };
91
+ }
92
+ continue;
93
+ }
94
+ const problem = check(supplied, param.schema, defs, `args[${i}]`);
95
+ if (problem)
96
+ return problem;
97
+ }
98
+ return null;
99
+ }
100
+ const MAX_DEPTH = 64;
101
+ function check(value, schema, defs, path, depth = 0) {
102
+ // A self-referential type plus a value deep enough to exhaust the stack. The wire
103
+ // guard already refused a CIRCULAR value, so anything reaching here is finite and
104
+ // 64 levels is far past any real argument — but a bound that fails OPEN is the
105
+ // rule of this file, so past it we accept rather than refuse.
106
+ if (depth > MAX_DEPTH)
107
+ return null;
108
+ switch (schema.k) {
109
+ case "any":
110
+ return null;
111
+ case "ref": {
112
+ const target = defs[schema.name];
113
+ // An unresolvable name accepts. The generator already refuses a signature
114
+ // naming a type its unit never declares (`gate:public-types`'s
115
+ // KNOWN_UNDECLARED), so this is unreachable today and must not be the thing
116
+ // that invents a refusal if it ever is.
117
+ if (!target)
118
+ return null;
119
+ return check(value, target, defs, path, depth + 1);
120
+ }
121
+ case "string":
122
+ return typeof value === "string" ? null : mismatch(path, value, "a string");
123
+ case "number":
124
+ return typeof value === "number" ? null : mismatch(path, value, "a number");
125
+ case "boolean":
126
+ return typeof value === "boolean" ? null : mismatch(path, value, "a boolean");
127
+ case "null":
128
+ return value === null ? null : mismatch(path, value, "null");
129
+ case "undefined":
130
+ return value === undefined ? null : mismatch(path, value, "undefined");
131
+ case "literal":
132
+ return value === schema.v ? null : mismatch(path, value, JSON.stringify(schema.v));
133
+ case "array": {
134
+ if (!Array.isArray(value))
135
+ return mismatch(path, value, "an array");
136
+ for (let i = 0; i < value.length; i++) {
137
+ const problem = check(value[i], schema.of, defs, `${path}[${i}]`, depth + 1);
138
+ if (problem)
139
+ return problem;
140
+ }
141
+ return null;
142
+ }
143
+ case "tuple": {
144
+ if (!Array.isArray(value))
145
+ return mismatch(path, value, "an array");
146
+ for (let i = 0; i < schema.of.length; i++) {
147
+ const problem = check(value[i], schema.of[i], defs, `${path}[${i}]`, depth + 1);
148
+ if (problem)
149
+ return problem;
150
+ }
151
+ return null;
152
+ }
153
+ case "record": {
154
+ if (!isPlainObject(value))
155
+ return mismatch(path, value, "an object");
156
+ for (const [key, child] of Object.entries(value)) {
157
+ const problem = check(child, schema.value, defs, `${path}.${key}`, depth + 1);
158
+ if (problem)
159
+ return problem;
160
+ }
161
+ return null;
162
+ }
163
+ case "object": {
164
+ if (!isPlainObject(value))
165
+ return mismatch(path, value, "an object");
166
+ const declared = new Set();
167
+ for (const prop of schema.props) {
168
+ declared.add(prop.name);
169
+ const child = value[prop.name];
170
+ if (child === undefined) {
171
+ if (prop.optional || !Object.hasOwn(value, prop.name)) {
172
+ if (!prop.optional) {
173
+ return { path: `${path}.${prop.name}`, reason: "missing — it is required" };
174
+ }
175
+ }
176
+ continue;
177
+ }
178
+ const problem = check(child, prop.schema, defs, `${path}.${prop.name}`, depth + 1);
179
+ if (problem)
180
+ return problem;
181
+ }
182
+ // OPEN on unlisted keys unless the type declared an index signature, which is
183
+ // the only case where the author said something about them.
184
+ if (schema.index) {
185
+ for (const [key, child] of Object.entries(value)) {
186
+ if (declared.has(key))
187
+ continue;
188
+ const problem = check(child, schema.index, defs, `${path}.${key}`, depth + 1);
189
+ if (problem)
190
+ return problem;
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+ case "union": {
196
+ let deepest = null;
197
+ for (const arm of schema.of) {
198
+ const problem = check(value, arm, defs, path, depth + 1);
199
+ if (!problem)
200
+ return null;
201
+ // The arm the caller MEANT is almost always the one the value got furthest
202
+ // into: `string | { query: string }` given `{ query: 5 }` fails the string
203
+ // arm at the root and the object arm at `.query`, and only the second says
204
+ // anything useful. Reporting the union itself would name six arms and point
205
+ // at nothing.
206
+ if (!deepest || problem.path.length > deepest.path.length)
207
+ deepest = problem;
208
+ }
209
+ if (deepest && deepest.path !== path)
210
+ return deepest;
211
+ return { path, reason: `${describe(value)} — expected ${describeSchema(schema, defs)}` };
212
+ }
213
+ }
214
+ }
215
+ /** A plain object, by the same test the wire guard uses: a class instance, a `Map`
216
+ * and a `Date` are all refused THERE, with a better message, before this runs. */
217
+ function isPlainObject(value) {
218
+ if (typeof value !== "object" || value === null || Array.isArray(value))
219
+ return false;
220
+ const prototype = Object.getPrototypeOf(value);
221
+ return prototype === Object.prototype || prototype === null;
222
+ }
223
+ function mismatch(path, value, expected) {
224
+ return { path, reason: `${describe(value)} — expected ${expected}` };
225
+ }
226
+ function describe(value) {
227
+ if (value === null)
228
+ return "null";
229
+ if (Array.isArray(value))
230
+ return "an array";
231
+ switch (typeof value) {
232
+ case "string":
233
+ return `the string ${JSON.stringify(value.length > 24 ? `${value.slice(0, 24)}…` : value)}`;
234
+ case "number":
235
+ return `the number ${String(value)}`;
236
+ case "boolean":
237
+ return `the boolean ${String(value)}`;
238
+ case "undefined":
239
+ return "undefined";
240
+ case "object":
241
+ return "an object";
242
+ default:
243
+ return `a ${typeof value}`;
244
+ }
245
+ }
246
+ /** One clause naming what a schema accepts. Only ever reached on a union that no
247
+ * arm matched, which is where a caller most needs the accepted set spelled out —
248
+ * a wrong string literal is the common case and `"hot" | "new" | "top"` is the
249
+ * whole answer. */
250
+ function describeSchema(schema, defs, depth = 0) {
251
+ if (depth > 4)
252
+ return "…";
253
+ switch (schema.k) {
254
+ case "any":
255
+ return "anything";
256
+ case "ref":
257
+ return schema.name;
258
+ case "literal":
259
+ return JSON.stringify(schema.v);
260
+ case "array":
261
+ return `an array of ${describeSchema(schema.of, defs, depth + 1)}`;
262
+ case "tuple":
263
+ return "an array";
264
+ case "record":
265
+ return "an object";
266
+ case "object":
267
+ return schema.props.length > 0
268
+ ? `an object with ${schema.props
269
+ .filter((p) => !p.optional)
270
+ .map((p) => p.name)
271
+ .join(", ") || "optional properties only"}`
272
+ : "an object";
273
+ case "union":
274
+ return schema.of.map((arm) => describeSchema(arm, defs, depth + 1)).join(" | ");
275
+ default:
276
+ return schema.k;
277
+ }
278
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bowmark/web",
3
- "version": "1.12.2",
3
+ "version": "1.12.3",
4
4
  "type": "module",
5
5
  "description": "The public client for the Bowmark capability library — real TypeScript for the whole bowmark.* surface, with no Bowmark source on the caller's disk. ZERO runtime dependencies, deliberately and permanently.",
6
6
  "license": "MIT",
@@ -20,13 +20,16 @@
20
20
  "browser-automation",
21
21
  "typescript"
22
22
  ],
23
- "main": "src/index.ts",
24
- "types": "src/index.ts",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
25
  "exports": {
26
- ".": "./src/index.ts"
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ }
27
30
  },
28
31
  "files": [
29
- "src",
32
+ "dist",
30
33
  "README.md"
31
34
  ],
32
35
  "engines": {
@@ -36,7 +39,8 @@
36
39
  "access": "public"
37
40
  },
38
41
  "scripts": {
39
- "typecheck": "tsc -p tsconfig.json --noEmit"
42
+ "typecheck": "tsc -p tsconfig.json --noEmit",
43
+ "build": "node scripts/build.mjs"
40
44
  },
41
45
  "devDependencies": {
42
46
  "@types/node": "^22.19.19",