@zenera/gym 1.1.39 → 1.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +169 -104
  2. package/dist/agents.d.ts +25 -0
  3. package/dist/agents.js +52 -0
  4. package/dist/bindings.d.ts +25 -0
  5. package/dist/bindings.js +81 -0
  6. package/dist/box.d.ts +39 -36
  7. package/dist/box.js +183 -217
  8. package/dist/cache.d.ts +24 -14
  9. package/dist/cache.js +102 -66
  10. package/dist/catalog.d.ts +83 -0
  11. package/dist/catalog.js +293 -0
  12. package/dist/command.d.ts +1 -0
  13. package/dist/command.js +235 -68
  14. package/dist/fake.d.ts +4 -0
  15. package/dist/fake.js +142 -0
  16. package/dist/generate.d.ts +28 -24
  17. package/dist/generate.js +417 -120
  18. package/dist/inputs.d.ts +12 -0
  19. package/dist/inputs.js +132 -0
  20. package/dist/knowledge.d.ts +50 -0
  21. package/dist/knowledge.js +199 -0
  22. package/dist/logger.d.ts +10 -8
  23. package/dist/logger.js +4 -3
  24. package/dist/paging.d.ts +2 -2
  25. package/dist/paging.js +2 -2
  26. package/dist/probe.d.ts +5 -5
  27. package/dist/probe.js +15 -15
  28. package/dist/prompt.d.ts +31 -18
  29. package/dist/prompt.js +160 -146
  30. package/dist/quiet.d.ts +2 -0
  31. package/dist/quiet.js +7 -0
  32. package/dist/router.js +5 -1
  33. package/dist/runtime.d.ts +160 -0
  34. package/dist/runtime.js +229 -0
  35. package/dist/schema.js +1 -2
  36. package/dist/server.js +98 -57
  37. package/dist/setup.d.ts +47 -6
  38. package/dist/setup.js +157 -21
  39. package/dist/spec.d.ts +4 -3
  40. package/dist/spec.js +73 -22
  41. package/dist/store.d.ts +78 -0
  42. package/dist/store.js +253 -0
  43. package/dist/synth.d.ts +42 -0
  44. package/dist/synth.js +589 -0
  45. package/dist/worker.d.ts +41 -0
  46. package/dist/worker.js +99 -0
  47. package/package.json +5 -3
  48. package/dist/envelope.d.ts +0 -13
  49. package/dist/envelope.js +0 -9
  50. package/dist/image.d.ts +0 -25
  51. package/dist/image.js +0 -81
package/dist/generate.js CHANGED
@@ -1,22 +1,44 @@
1
+ import { loose, tool } from '@zenera/neo';
2
+ import { rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import RandExp from 'randexp';
5
+ import { runAgent } from "./agents.js";
6
+ import { ACCOUNTS, OWNER_TYPE } from "./runtime.js";
1
7
  import { tokenOf } from "./paging.js";
2
8
  import { echoIssues, nextPage, probesFor, walkStart } from "./probe.js";
3
- import { instruction, regenerationInstruction, retry, SYSTEM, } from "./prompt.js";
9
+ import { brief, repairBrief, retryNudge, SYSTEM } from "./prompt.js";
10
+ import { properties } from "./schema.js";
4
11
  import { called } from "./spec.js";
5
12
  import { describeIssues, issues } from "./validate.js";
6
- /**
7
- * Tries per generator. Each one carries the previous file and what was wrong
8
- * with it, so the later attempts are the informed ones — a model that has seen
9
- * its own traceback usually needs two or three goes at a fiddly schema, and the
10
- * whole loop costs nothing once the file is cached.
11
- */
13
+ // ---------------------------------------------------------------------------
14
+ // Writing a handler
15
+ //
16
+ // An agent with tools rather than a prompt with a retry loop. It can look
17
+ // before it writes — the operations around this one, the handlers already
18
+ // written for them, the collections the world already holds, any type it was
19
+ // not shown — and that is what makes `getMessage` read the collection
20
+ // `listMessages` filled instead of inventing its own.
21
+ //
22
+ // It cannot decide it is finished. `test_handler` is the judge: it runs the
23
+ // module against probe requests in a throwaway copy of the world and reports
24
+ // everything wrong with the answers, and the first test that passes ends the
25
+ // run. The judgement is deliberately harsher than the schema — a body can
26
+ // validate and still be about somebody else (the echo rule), or validate and
27
+ // page forever (the walk).
28
+ // ---------------------------------------------------------------------------
29
+ /** Failed tests per build before giving up. */
12
30
  export const DEFAULT_ATTEMPTS = 5;
31
+ /** How many times a run that stopped without passing is started again. */
32
+ const RESTARTS = 2;
13
33
  export class BuildFailed extends Error {
14
34
  operation;
15
35
  diagnostics;
16
36
  /** the build report on disk, filled in by whoever was writing one */
17
37
  dump;
38
+ /** the agent's HTML run report */
39
+ report;
18
40
  constructor(operation, diagnostics) {
19
- super(`could not write a generator for ${operation.method.toUpperCase()} ${called(operation)}`);
41
+ super(`could not write a handler for ${operation.method.toUpperCase()} ${called(operation)}`);
20
42
  this.name = 'BuildFailed';
21
43
  this.operation = operation;
22
44
  this.diagnostics = diagnostics;
@@ -27,8 +49,7 @@ export class BuildFailed extends Error {
27
49
  *
28
50
  * `fetch failed` is undici's word for a dozen different problems — a DNS
29
51
  * miss, a refused connection, a TLS error, a timeout — and which one it was is
30
- * only ever in `cause`. Reporting the top-level message alone turns every
31
- * network fault into the same useless sentence.
52
+ * only ever in `cause`.
32
53
  */
33
54
  export function reason(err) {
34
55
  if (err instanceof BuildFailed && err.diagnostics.length > 0) {
@@ -53,148 +74,376 @@ export function reason(err) {
53
74
  }
54
75
  return parts.join(' — ') || String(err);
55
76
  }
56
- export async function build(operation, opts) {
77
+ /** Writes the handler for an operation, or repairs the one it has. */
78
+ export async function build(operation, opts, repair) {
57
79
  const limit = Math.max(1, opts.attempts ?? DEFAULT_ATTEMPTS);
58
- const probes = probesFor(operation);
59
- const response = opts.checks.for(operation).response;
60
- const messages = [
61
- { role: 'user', content: [{ type: 'text', text: instruction(operation) }] },
62
- ];
63
- let last = [];
64
- for (let attempt = 1; attempt <= limit; attempt++) {
65
- const answer = await ask(opts, {
66
- system: SYSTEM,
67
- messages,
68
- tools: [],
80
+ const state = {
81
+ bindings: {},
82
+ failures: 0,
83
+ passed: undefined,
84
+ last: [],
85
+ lastSource: '',
86
+ };
87
+ let report;
88
+ const intro = [
89
+ brief(operation, {
90
+ catalog: opts.catalog,
91
+ siblings: await siblings(operation, opts),
92
+ collections: opts.world.store.collections(),
93
+ bindings: opts.bindings.all(),
94
+ passages: await opts.world.knowledge.recall([
95
+ operation.summary,
96
+ operation.operationId,
97
+ `${operation.method.toUpperCase()} ${operation.path}`,
98
+ ]
99
+ .filter(Boolean)
100
+ .join(' — '), 3),
101
+ }),
102
+ ...(repair ? ['', repairBrief(repair)] : []),
103
+ ].join('\n');
104
+ for (let run = 0; run <= RESTARTS && !state.passed && state.failures < limit; run++) {
105
+ const input = run === 0
106
+ ? intro
107
+ : [
108
+ intro,
109
+ '',
110
+ 'YOUR LAST VERSION:',
111
+ '```ts',
112
+ state.lastSource,
113
+ '```',
114
+ '',
115
+ retryNudge(state.last.length > 0 ? state.last : ['- it was never tested']),
116
+ ].join('\n');
117
+ const done = await runAgent({
118
+ model: opts.model,
119
+ name: 'builder',
120
+ label: operation.operationId,
121
+ instructions: SYSTEM,
122
+ tools: tools(operation, opts, state, limit),
123
+ input,
124
+ maxToolCalls: limit * 3 + 10,
125
+ done: () => Boolean(state.passed) || state.failures >= limit,
126
+ reportDir: opts.reportDir,
69
127
  signal: opts.signal,
70
128
  });
71
- const source = unfence(answer.text);
72
- if (!source.trim()) {
73
- last = ['the answer was empty'];
74
- }
75
- else {
76
- await opts.box.write(operation.key, source);
77
- last = await judge(operation, probes, response, opts.box);
78
- if (last.length === 0) {
79
- return { source, attempts: attempt };
80
- }
81
- }
82
- await opts.onAttempt?.(attempt, last, source, limit);
83
- messages.push({ role: 'assistant', content: source }, { role: 'user', content: [{ type: 'text', text: retry(last) }] });
129
+ report = done.report ?? report;
84
130
  }
85
- throw new BuildFailed(operation, last);
131
+ if (state.passed) {
132
+ return {
133
+ source: state.passed,
134
+ attempts: Math.max(1, state.failures + 1),
135
+ report,
136
+ bindings: state.bindings,
137
+ };
138
+ }
139
+ const failed = new BuildFailed(operation, state.last.length > 0 ? state.last : ['the agent stopped without testing a handler']);
140
+ failed.report = report;
141
+ throw failed;
86
142
  }
87
- export async function regenerate(operation, opts) {
88
- const limit = Math.max(1, opts.attempts ?? DEFAULT_ATTEMPTS);
89
- const exampleInputs = opts.examples?.map((e) => e.input) ?? [];
90
- const probes = [opts.failingInput, ...exampleInputs, ...probesFor(operation)];
91
- const response = opts.checks.for(operation).response;
92
- const messages = [
93
- {
94
- role: 'user',
95
- content: [
96
- {
97
- type: 'text',
98
- text: regenerationInstruction({
99
- operation,
100
- currentSource: opts.currentSource,
101
- failingInput: opts.failingInput,
102
- fault: opts.fault,
103
- stderr: opts.stderr,
104
- examples: opts.examples,
105
- }),
106
- },
107
- ],
143
+ function tools(operation, opts, state, limit) {
144
+ const listOperations = tool({
145
+ name: 'list_operations',
146
+ description: 'Operations this server answers, with whether each already has a handler. ' +
147
+ 'Filter by path (e.g. "/users/*/messages*") or by operationId.',
148
+ parameters: {
149
+ type: 'object',
150
+ properties: {
151
+ path: { type: 'string', description: 'A substring, or a glob with * and ?.' },
152
+ name: { type: 'string', description: 'A substring of the operationId.' },
153
+ },
154
+ additionalProperties: false,
108
155
  },
109
- ];
110
- let last = [];
111
- for (let attempt = 1; attempt <= limit; attempt++) {
112
- const answer = await ask(opts, {
113
- system: SYSTEM,
114
- messages,
115
- tools: [],
116
- signal: opts.signal,
117
- });
118
- const source = unfence(answer.text);
119
- if (!source.trim()) {
120
- last = ['the answer was empty'];
121
- }
122
- else {
123
- await opts.box.write(operation.key, source);
124
- last = await judge(operation, probes, response, opts.box);
125
- if (last.length === 0) {
126
- return { source, attempts: attempt };
156
+ execute: async ({ path, name }) => {
157
+ const byPath = path ? loose(path) : undefined;
158
+ const byName = name ? loose(name) : undefined;
159
+ const found = opts.operations.filter((o) => (!byPath || byPath(o.path)) && (!byName || byName(o.operationId)));
160
+ const rows = await Promise.all(found.slice(0, 80).map(async (o) => {
161
+ const built = Boolean(await opts.sourceOf(o));
162
+ return `${built ? '[built]' : '[ ]'} ${o.method.toUpperCase()} ${called(o)} ${o.operationId}`;
163
+ }));
164
+ return { found: found.length, operations: rows };
165
+ },
166
+ });
167
+ const readHandler = tool({
168
+ name: 'read_handler',
169
+ description: 'The handler already written for another operation. Read the siblings of this ' +
170
+ 'operation before writing it, and use the same collections and types they use.',
171
+ parameters: {
172
+ type: 'object',
173
+ properties: { operationId: { type: 'string' } },
174
+ required: ['operationId'],
175
+ additionalProperties: false,
176
+ },
177
+ execute: async ({ operationId }) => {
178
+ const other = opts.operations.find((o) => o.operationId === operationId);
179
+ if (!other) {
180
+ return {
181
+ error: `no operation called ${operationId}`,
182
+ hint: 'call list_operations',
183
+ };
184
+ }
185
+ const source = await opts.sourceOf(other);
186
+ return source ? { source } : { error: `${operationId} has no handler yet` };
187
+ },
188
+ });
189
+ const listCollections = tool({
190
+ name: 'list_collections',
191
+ description: 'The collections the world holds, with record counts and sample ids.',
192
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
193
+ execute: () => ({ collections: opts.world.store.collections() }),
194
+ });
195
+ const readCollection = tool({
196
+ name: 'read_collection',
197
+ description: 'A few records from a collection, to see the shape stored there.',
198
+ parameters: {
199
+ type: 'object',
200
+ properties: {
201
+ collection: { type: 'string' },
202
+ limit: { type: 'integer', description: 'Default 3.' },
203
+ },
204
+ required: ['collection'],
205
+ additionalProperties: false,
206
+ },
207
+ execute: ({ collection, limit }) => {
208
+ const col = opts.world.store.collection(collection);
209
+ const text = JSON.stringify(col.all().slice(0, Math.min(limit ?? 3, 10)));
210
+ return {
211
+ total: col.count(),
212
+ records: text.length > 12_000 ? `${text.slice(0, 12_000)}…` : JSON.parse(text),
213
+ };
214
+ },
215
+ });
216
+ const describeTypes = tool({
217
+ name: 'describe_types',
218
+ description: 'Schema types by name, as TypeScript, with everything they refer to.',
219
+ parameters: {
220
+ type: 'object',
221
+ properties: { names: { type: 'array', items: { type: 'string' } } },
222
+ required: ['names'],
223
+ additionalProperties: false,
224
+ },
225
+ execute: ({ names }) => {
226
+ const { code, omitted } = opts.catalog.declare(names, 30_000);
227
+ return code
228
+ ? { typescript: code, ...(omitted.length ? { omitted } : {}) }
229
+ : { error: `no such type: ${names.join(', ')}` };
230
+ },
231
+ });
232
+ const testHandler = tool({
233
+ name: 'test_handler',
234
+ description: 'Saves the handler module and judges it: runs it against probe requests in a ' +
235
+ 'copy of the world, checks every answer against the response schema, the echo ' +
236
+ 'rule and, for paged operations, that the pages advance and end. Returns ok, or ' +
237
+ 'every problem found.',
238
+ parameters: {
239
+ type: 'object',
240
+ properties: {
241
+ source: { type: 'string', description: 'The whole TypeScript module.' },
242
+ },
243
+ required: ['source'],
244
+ additionalProperties: false,
245
+ },
246
+ execute: async ({ source }) => {
247
+ if (state.passed || state.failures >= limit) {
248
+ return { error: 'the build is over' };
127
249
  }
250
+ const code = unfence(String(source ?? ''));
251
+ state.lastSource = code;
252
+ const { problems, placed } = code.trim()
253
+ ? await test(operation, code, opts)
254
+ : { problems: ['- the module is empty'], placed: {} };
255
+ if (problems.length === 0) {
256
+ // Bound only on a pass: a failing handler must not fix where a
257
+ // type lives for everyone after it.
258
+ for (const [type, binding] of Object.entries(placed)) {
259
+ opts.bindings.bind(type, binding);
260
+ }
261
+ state.passed = code;
262
+ state.bindings = placed;
263
+ return { ok: true };
264
+ }
265
+ state.failures++;
266
+ state.last = problems;
267
+ await opts.onAttempt?.(state.failures, problems, code, limit);
268
+ return {
269
+ ok: false,
270
+ problems,
271
+ testsLeft: Math.max(0, limit - state.failures),
272
+ };
273
+ },
274
+ });
275
+ return [
276
+ listOperations,
277
+ readHandler,
278
+ listCollections,
279
+ readCollection,
280
+ describeTypes,
281
+ ...opts.catalog.tools(),
282
+ ...opts.world.knowledge.tools,
283
+ testHandler,
284
+ ];
285
+ }
286
+ /** Writes the module and judges it in a copy of the world nobody else can see. */
287
+ async function test(operation, source, opts) {
288
+ await opts.box.write(operation.key, source);
289
+ const scratch = join(opts.box.root, 'scratch', `${operation.key}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.sqlite`);
290
+ opts.world.store.snapshot(scratch);
291
+ try {
292
+ const used = [];
293
+ const problems = await judge(operation, opts, scratch, used);
294
+ const { problems: misplaced, placed } = placement(used, opts);
295
+ return { problems: [...problems, ...misplaced], placed };
296
+ }
297
+ finally {
298
+ opts.box.release(scratch);
299
+ opts.world.release(scratch);
300
+ for (const suffix of ['', '-wal', '-shm']) {
301
+ rmSync(`${scratch}${suffix}`, { force: true });
128
302
  }
129
- await opts.onAttempt?.(attempt, last, source, limit);
130
- messages.push({ role: 'assistant', content: source }, { role: 'user', content: [{ type: 'text', text: retry(last) }] });
131
303
  }
132
- throw new BuildFailed(operation, last);
133
- }
134
- /**
135
- * Streamed when the adapter can, and every delta is dropped: nothing here
136
- * renders progress. It is the *socket* that needs them.
137
- *
138
- * A generator is a few hundred lines, and a model asked for one in a single
139
- * non-streaming call sends nothing at all while it thinks. Something upstream
140
- * closes a connection that has been idle for a minute, and the whole attempt
141
- * dies with `UND_ERR_SOCKET other side closed` at ~61 s — not a timeout anyone
142
- * here set, and not the SDK's, which is ten minutes. Tokens on the wire keep
143
- * it open.
144
- */
145
- function ask(opts, request) {
146
- return opts.model.stream ? opts.model.stream(request, discard) : opts.model.generate(request);
147
304
  }
148
- const discard = () => { };
149
- /** Every probe, run and checked. Empty means the generator is good. */
150
- async function judge(operation, probes, response, box) {
305
+ async function judge(operation, opts, db, used) {
306
+ const response = opts.checks.for(operation).response;
151
307
  const out = [];
152
- for (const probe of probes) {
153
- const outcome = await box.run(operation.key, probe);
154
- const what = `input ${JSON.stringify({ pathParams: probe.pathParams, query: probe.query })}`;
308
+ for (const probe of fitted(operation, probesFor(operation))) {
309
+ const outcome = await opts.box.run(operation.key, probe, { db, probe: true });
310
+ used.push(...(outcome.used ?? []));
311
+ const what = `request ${JSON.stringify({ pathParams: probe.pathParams, query: probe.query, ...(probe.body !== undefined ? { body: probe.body } : {}) }).slice(0, 600)}`;
155
312
  if (!outcome.ok) {
156
- out.push(`- ${what}: the file ${outcome.fault}.`);
313
+ out.push(`- ${what}: the handler ${outcome.fault}.`);
157
314
  if (outcome.stderr) {
158
- out.push(` stderr: ${tail(outcome.stderr)}`);
315
+ out.push(` stack: ${tail(outcome.stderr)}`);
159
316
  }
160
- // A file that will not run says nothing about the next probe.
161
317
  break;
162
318
  }
319
+ if (outcome.status !== undefined) {
320
+ out.push(`- ${what}: answered HTTP ${outcome.status} ${JSON.stringify(outcome.value).slice(0, 200)}. ` +
321
+ 'An ordinary request must get a body: make missing content with ctx.lookup / ctx.ensure instead of failing.');
322
+ continue;
323
+ }
163
324
  if (response && !response(outcome.value)) {
164
- out.push(`- ${what}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`);
325
+ out.push(`- ${what}: the answer does not match the response schema — ${describeIssues(issues('', response.errors))}. ` +
326
+ `It answered: ${JSON.stringify(outcome.value).slice(0, 400)}`);
165
327
  }
166
328
  const echo = echoIssues(probe, outcome.value, operation.success.schema);
167
329
  if (echo.length > 0) {
168
330
  out.push(`- ${what}: ${describeIssues(echo)}.`);
169
331
  }
170
332
  }
171
- // Only worth the container round trips once the file answers at all, and
172
- // only for an operation that hands out a token somebody could follow.
173
333
  if (out.length === 0 && operation.paging?.next) {
174
- out.push(...(await walk(operation, operation.paging, response, box)));
334
+ out.push(...(await walk(operation, operation.paging, opts, db, used)));
175
335
  }
176
336
  return out;
177
337
  }
178
- /** How many pages a mock may offer before it is simply not terminating. */
179
- const MAX_PAGES = 8;
180
338
  /**
181
- * Follows the operation's own cursor and reports the ways that walk fails to
182
- * end. A schema cannot express this and neither can any one response: the bug
183
- * is a relation between two of them.
339
+ * Probe path values that the response would have to echo, made to fit the
340
+ * response's own pattern. Asking for message `ada` when ids are sixteen hex
341
+ * digits is a test no handler can pass.
184
342
  */
185
- async function walk(operation, paging, response, box) {
343
+ function fitted(operation, probes) {
344
+ const schema = operation.success.schema;
345
+ if (!schema) {
346
+ return probes;
347
+ }
348
+ const patterned = new Map();
349
+ for (const p of properties(schema)) {
350
+ if (typeof p.schema.pattern === 'string' && !patterned.has(p.name)) {
351
+ patterned.set(p.name, p.schema.pattern);
352
+ }
353
+ }
354
+ return probes.map((probe, i) => {
355
+ const pathParams = { ...probe.pathParams };
356
+ for (const name of Object.keys(pathParams)) {
357
+ const pattern = patterned.get(name);
358
+ if (pattern) {
359
+ try {
360
+ const gen = new RandExp(pattern);
361
+ gen.max = 6;
362
+ let n = i + 7;
363
+ gen.randInt = (a, b) => a + ((n = (n * 48271) % 2147483647) % (b - a + 1));
364
+ pathParams[name] = gen.gen();
365
+ }
366
+ catch {
367
+ // an unreadable pattern keeps the plain probe value
368
+ }
369
+ }
370
+ }
371
+ return { ...probe, pathParams };
372
+ });
373
+ }
374
+ /**
375
+ * Where the handler put each type it touched, checked against where every
376
+ * handler before it put them. Untyped access is refused outright: a collection
377
+ * nobody can name a type for is a collection the next handler cannot find.
378
+ */
379
+ function placement(used, opts) {
380
+ const problems = new Set();
381
+ const byType = new Map();
382
+ for (const use of used) {
383
+ if (use.collection === ACCOUNTS || use.type === OWNER_TYPE) {
384
+ continue;
385
+ }
386
+ if (!use.type) {
387
+ problems.add(`- \`${use.collection}\` is used without a type. Name the type instead — ` +
388
+ `ctx.lookup('Message', id), ctx.ensure('Message', …), ctx.db.collection('Message') — ` +
389
+ 'so every handler finds the same records in the same place.');
390
+ continue;
391
+ }
392
+ if (!opts.catalog.hasType(use.type)) {
393
+ problems.add(`- \`${use.type}\` is not a type the documents declare; ` +
394
+ 'describe_types, list_api or grep_api finds the right name.');
395
+ continue;
396
+ }
397
+ const seen = byType.get(use.type) ?? { collections: new Set(), idFields: new Set() };
398
+ seen.collections.add(use.collection);
399
+ if (use.idField) {
400
+ seen.idFields.add(use.idField);
401
+ }
402
+ byType.set(use.type, seen);
403
+ }
404
+ const placed = {};
405
+ for (const [type, seen] of byType) {
406
+ if (seen.collections.size > 1 || seen.idFields.size > 1) {
407
+ problems.add(`- \`${type}\` is stored two ways in this handler ` +
408
+ `(${[...seen.collections].join(', ')}; keyed by ${[...seen.idFields].join(', ') || 'id'}). ` +
409
+ 'Use one type name and one id field throughout.');
410
+ continue;
411
+ }
412
+ const binding = {
413
+ collection: [...seen.collections][0],
414
+ idField: [...seen.idFields][0] ?? opts.bindings.of(type)?.idField ?? 'id',
415
+ };
416
+ const conflict = opts.bindings.conflict(type, binding);
417
+ if (conflict) {
418
+ problems.add(`- ${conflict} — fixed by an earlier handler. Use the type name ` +
419
+ `(ctx.lookup('${type}', …)) and leave the collection and id field to the binding.`);
420
+ continue;
421
+ }
422
+ placed[type] = binding;
423
+ }
424
+ return { problems: [...problems], placed };
425
+ }
426
+ /** How many pages a judged handler may offer before it is not terminating. */
427
+ const MAX_PAGES = 8;
428
+ async function walk(operation, paging, opts, db, used) {
186
429
  let input = walkStart(operation, paging);
187
430
  const seen = new Set();
188
431
  let sent;
432
+ // Enough content to need more than one page, so the token is exercised.
433
+ if (paging.size) {
434
+ input = setSize(input, paging, 2);
435
+ }
189
436
  for (let page = 1; page <= MAX_PAGES; page++) {
190
- const outcome = await box.run(operation.key, input);
437
+ const outcome = await opts.box.run(operation.key, input, { db, probe: true });
438
+ used.push(...(outcome.used ?? []));
191
439
  const what = `page ${page} of ${operation.method.toUpperCase()} ${called(operation)}`;
192
440
  if (!outcome.ok) {
193
- return [`- ${what}: the file ${outcome.fault}.`];
441
+ return [`- ${what}: the handler ${outcome.fault}.`];
194
442
  }
443
+ const response = opts.checks.for(operation).response;
195
444
  if (response && !response(outcome.value)) {
196
445
  return [
197
- `- ${what}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`,
446
+ `- ${what}: the answer does not match the response schema — ${describeIssues(issues('', response.errors))}.`,
198
447
  ];
199
448
  }
200
449
  const token = tokenOf(outcome.value, paging);
@@ -203,12 +452,12 @@ async function walk(operation, paging, response, box) {
203
452
  }
204
453
  if (token === sent) {
205
454
  return [
206
- `- ${what}: \`${paging.next}\` came back as ${JSON.stringify(token)}, the very token the request carried in \`${paging.param}\`. A client following it never advances. Build the token from \`${paging.param}\` so it counts up, and stop after pagination ends.`,
455
+ `- ${what}: \`${paging.next}\` came back as ${JSON.stringify(token)}, the token the request sent in \`${paging.param}\`. Pass the client's token to ctx.page and answer with its nextToken.`,
207
456
  ];
208
457
  }
209
458
  if (seen.has(token)) {
210
459
  return [
211
- `- ${what}: the page tokens cycle — ${JSON.stringify(token)} was handed out earlier in this walk. Every page must offer a token no page has offered before, and the last one must offer none.`,
460
+ `- ${what}: the page tokens cycle — ${JSON.stringify(token)} was handed out before.`,
212
461
  ];
213
462
  }
214
463
  seen.add(token);
@@ -216,12 +465,60 @@ async function walk(operation, paging, response, box) {
216
465
  input = nextPage(input, paging, token);
217
466
  }
218
467
  return [
219
- `- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set. Fabricate between 1 and 10 pages in total and set it to null on the last.`,
468
+ `- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set. Page with ctx.page so the list ends.`,
220
469
  ];
221
470
  }
471
+ function setSize(input, paging, size) {
472
+ if (!paging.size) {
473
+ return input;
474
+ }
475
+ if (paging.paramIn === 'body' && typeof input.body === 'object' && input.body !== null) {
476
+ return { ...input, body: { ...input.body, [paging.size]: size } };
477
+ }
478
+ return { ...input, query: { ...input.query, [paging.size]: String(size) } };
479
+ }
480
+ /** Operations sharing this one's resource path, or a response type with it. */
481
+ /**
482
+ * The operations a handler must agree with, closest first. Same record type and
483
+ * same resource are the strongest ties; operations that merely carry the type
484
+ * somewhere (traced through the graph) come next; nearby paths last. In a large
485
+ * API the list is cut, and the cut must fall on the weakest.
486
+ */
487
+ async function siblings(operation, opts) {
488
+ const base = resourceOf(operation.path);
489
+ const mine = opts.catalog.itemType(operation);
490
+ const carriers = new Set((mine ? opts.catalog.carriers(mine) : []).map((c) => `${c.method} ${c.path}`));
491
+ const scored = opts.operations
492
+ .filter((o) => o !== operation)
493
+ .map((o) => {
494
+ const type = opts.catalog.itemType(o);
495
+ const other = resourceOf(o.path);
496
+ const score = (mine && type === mine ? 4 : 0) +
497
+ (other === base ? 3 : 0) +
498
+ (carriers.has(`${o.method} ${o.path}`) ? 2 : 0) +
499
+ (near(o.path, base) ? 1 : 0);
500
+ return { operation: o, type, score };
501
+ })
502
+ .filter((x) => x.score > 0)
503
+ .sort((a, b) => b.score - a.score || a.operation.path.localeCompare(b.operation.path))
504
+ .slice(0, 25);
505
+ return Promise.all(scored.map(async ({ operation: o, type }) => ({
506
+ operation: o,
507
+ type,
508
+ built: Boolean(await opts.sourceOf(o)),
509
+ })));
510
+ }
511
+ /** One or two segments above or below. */
512
+ function near(path, base) {
513
+ const depth = (p) => p.split('/').filter(Boolean).length;
514
+ const [short, long] = path.length < base.length ? [path, base] : [base, path];
515
+ return long.startsWith(`${short}/`) && depth(long) - depth(short) <= 2;
516
+ }
517
+ /** `/users/{userId}/messages/{id}` → `/users/{userId}/messages`. */
518
+ const resourceOf = (path) => path.replace(/\/\{[^/]+\}$/, '') || '/';
222
519
  /**
223
- * Models fence code even when told not to, and a stray ```python line is a
224
- * syntax error rather than a bad answer — not worth a round trip.
520
+ * Models fence code even when told not to, and a stray ```ts line is a syntax
521
+ * error rather than a bad answer — not worth a round trip.
225
522
  */
226
523
  export function unfence(text) {
227
524
  const trimmed = text.trim();
@@ -232,5 +529,5 @@ export function unfence(text) {
232
529
  const close = body.lastIndexOf('```');
233
530
  return (close === -1 ? body : body.slice(0, close)).trimEnd();
234
531
  }
235
- const tail = (s) => s.trim().split('\n').slice(-6).join('\n ');
532
+ const tail = (s) => s.trim().split('\n').slice(0, 6).join('\n ');
236
533
  //# sourceMappingURL=generate.js.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Every API document the arguments name, absolute, de-duplicated, in the order
3
+ * named (and sorted within a directory or a pattern, so a start is repeatable).
4
+ */
5
+ export declare function expandSchemas(args: readonly string[], cwd: string): Promise<string[]>;
6
+ /**
7
+ * Checks story and docs arguments before anything reads them. They are read by
8
+ * `@zenera/rag`, which takes a named file at its word — so an API document
9
+ * passed here by mistake would quietly become a very long story.
10
+ */
11
+ export declare function checkTexts(args: readonly string[], cwd: string, flag: '--story' | '--docs'): Promise<void>;
12
+ //# sourceMappingURL=inputs.d.ts.map