@zenera/faker 1.1.21 → 1.1.22

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/README.md CHANGED
@@ -92,20 +92,21 @@ nothing it shouldn't, and hands back `abc` again - so the client asks for the
92
92
  same page forever.
93
93
 
94
94
  The faker reads the document for this. Where an operation has a paging
95
- parameter (`cursor`, `page`, `offset`, `page_token`, …) and a response property
96
- that carries the next one (`next`, `next_cursor`, `has_more`, …), three things
97
- happen, all in the operation's own names:
95
+ parameter (`cursor`, `page`, `offset`, `page_token`, …) in query or request body
96
+ and a response property that carries the next one (`next`, `next_cursor`, `has_more`, …),
97
+ three things happen, all in the operation's own names:
98
98
 
99
- - the model is told to fabricate **three pages** in total, to build the token
99
+ - the model is told to fabricate **between 1 and 10 pages** in total, to build the token
100
100
  out of the paging parameter rather than the seed, and to end the list - null,
101
- absent, or `has_more: false` where the schema leaves no other room;
101
+ absent, or `has_more: false` (and empty items) where the schema leaves no other room;
102
102
  - the generator is then **walked**: the faker calls it with no cursor, follows
103
103
  the token it gets back, and rejects the file if the token repeats, cycles, or
104
104
  never runs out. The diagnostics say which, and the model gets another go;
105
- - at request time a token identical to the one just sent is **cut** - nulled or
106
- dropped, whichever the schema allows - and the request line says so. Nothing
107
- is invented in its place; a generator written before this rule existed is
108
- still cached, and a cache is not rebuilt because a rule changed.
105
+ - at request time, pagination is tracked and bounded: a token identical to the one
106
+ just sent is **cut** (nulled or dropped), cycles are stopped, and streams are capped
107
+ to random 1-10 pages, returning empty results if a client loops past the end. The
108
+ request line narrates each cut. Nothing is invented in its place; a generator written
109
+ before this rule existed is still cached, and a cache is not rebuilt because a rule changed.
109
110
 
110
111
  Only paginated operations are affected. Their cache keys changed once, so they
111
112
  are written again on first use; everything else keeps the key it had.
@@ -114,8 +115,8 @@ are written again on first use; everything else keeps the key it had.
114
115
  Plenty of documents describe the envelope and never write down the parameter
115
116
  that reads it back. The first two steps cannot help there - nothing static can
116
117
  see a parameter that is not declared - but the cut still applies: it takes the
117
- paging parameter from the request itself, since a client only sends `?cursor=X`
118
- because a body handed it X.
118
+ paging parameter from query parameters or request body, since a client only sends
119
+ `cursor=X` because a body handed it X.
119
120
 
120
121
  ## Commands
121
122
 
package/dist/cache.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { Model } from '@zenera/neo';
2
2
  import type { Box } from './box.ts';
3
+ import type { GeneratorInput } from './envelope.ts';
3
4
  import { BuildFailed } from './generate.ts';
4
- import type { Operation } from './spec.ts';
5
+ import type { ExampleRequest } from './prompt.ts';
6
+ import { type Operation } from './spec.ts';
5
7
  import type { Checks } from './validate.ts';
6
8
  export declare const FAKER_KIND = "faker-generator";
7
9
  /** What is kept about a generator besides the code, for `zen faker cache ls`. */
@@ -12,6 +14,7 @@ export interface GeneratorMeta {
12
14
  source?: string;
13
15
  model?: string;
14
16
  attempts?: number;
17
+ rulesVersion?: number;
15
18
  createdAt?: string;
16
19
  }
17
20
  export interface Generator {
@@ -53,6 +56,13 @@ export interface CacheOptions {
53
56
  error: Error;
54
57
  }) => void;
55
58
  }
59
+ export interface CacheRegenerateContext {
60
+ currentSource: string;
61
+ failingInput: GeneratorInput;
62
+ fault: string;
63
+ stderr?: string;
64
+ examples?: readonly ExampleRequest[];
65
+ }
56
66
  export declare class Cache {
57
67
  #private;
58
68
  constructor(opts: CacheOptions);
@@ -65,6 +75,9 @@ export declare class Cache {
65
75
  * build and for everyone who waited on it, and true from then on.
66
76
  */
67
77
  ensure(operation: Operation): Promise<Generator>;
78
+ get model(): Model;
79
+ getSource(key: string): Promise<string | undefined>;
80
+ regenerate(operation: Operation, ctx: CacheRegenerateContext): Promise<Generator>;
68
81
  }
69
82
  export { BuildFailed };
70
83
  //# sourceMappingURL=cache.d.ts.map
package/dist/cache.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Cache as Store } from '@zenera/cli/lib';
2
- import { build, BuildFailed } from "./generate.js";
2
+ import { build, BuildFailed, regenerate } from "./generate.js";
3
+ import { GENERATOR_RULES_VERSION } from "./spec.js";
3
4
  // ---------------------------------------------------------------------------
4
5
  // The cache
5
6
  //
@@ -64,6 +65,60 @@ export class Cache {
64
65
  this.#live.set(operation.key, started);
65
66
  return started;
66
67
  }
68
+ get model() {
69
+ return this.#opts.model;
70
+ }
71
+ async getSource(key) {
72
+ const found = this.#store.get(key);
73
+ return found?.source?.trim() ? found.source : undefined;
74
+ }
75
+ async regenerate(operation, ctx) {
76
+ const { box, model, checks, ephemeral } = this.#opts;
77
+ await this.#enter();
78
+ this.#opts.onStart?.({ operation });
79
+ try {
80
+ const built = await regenerate(operation, {
81
+ model,
82
+ box,
83
+ checks,
84
+ attempts: this.#opts.attempts,
85
+ currentSource: ctx.currentSource,
86
+ failingInput: ctx.failingInput,
87
+ fault: ctx.fault,
88
+ stderr: ctx.stderr,
89
+ examples: ctx.examples,
90
+ onAttempt: (attempt, diagnostics) => this.#opts.onAttempt?.({ operation, attempt, diagnostics }),
91
+ });
92
+ if (!ephemeral) {
93
+ this.#store.put(operation.key, {
94
+ source: built.source,
95
+ meta: {
96
+ operationId: operation.operationId,
97
+ method: operation.method,
98
+ path: operation.path,
99
+ source: operation.source,
100
+ model: model.id,
101
+ attempts: built.attempts,
102
+ rulesVersion: GENERATOR_RULES_VERSION,
103
+ createdAt: new Date().toISOString(),
104
+ },
105
+ });
106
+ }
107
+ this.#opts.onReady?.({ operation, cached: false, attempts: built.attempts });
108
+ const gen = { key: operation.key, source: built.source, cached: false };
109
+ this.#settled.add(operation.key);
110
+ this.#live.set(operation.key, Promise.resolve(gen));
111
+ return gen;
112
+ }
113
+ catch (err) {
114
+ const error = err instanceof Error ? err : new Error(String(err));
115
+ this.#opts.onFail?.({ operation, error });
116
+ throw error;
117
+ }
118
+ finally {
119
+ this.#leave();
120
+ }
121
+ }
67
122
  async #make(operation) {
68
123
  const { box, model, checks, rebuild, ephemeral } = this.#opts;
69
124
  // Read before queueing: a cache hit costs nothing and must not wait
@@ -93,6 +148,7 @@ export class Cache {
93
148
  source: operation.source,
94
149
  model: model.id,
95
150
  attempts: built.attempts,
151
+ rulesVersion: GENERATOR_RULES_VERSION,
96
152
  createdAt: new Date().toISOString(),
97
153
  },
98
154
  });
package/dist/command.js CHANGED
@@ -33,6 +33,8 @@ const OPTIONS = {
33
33
  seed: { type: 'string' },
34
34
  timeout: { type: 'string' },
35
35
  'max-body': { type: 'string' },
36
+ 'regen-limit': { type: 'string' },
37
+ 'requests-dir': { type: 'string' },
36
38
  rebuild: { type: 'boolean' },
37
39
  'no-cache': { type: 'boolean' },
38
40
  clear: { type: 'boolean' },
@@ -62,6 +64,8 @@ export const command = {
62
64
  [' --concurrency <n>', dim('Generators written at once. Default 4.')],
63
65
  [' --timeout <s>', dim('Seconds one generator may take. Default 30.')],
64
66
  [' --max-body <n>', dim('Largest request body accepted, in bytes.')],
67
+ [' --regen-limit <n>', dim('Maximum regenerations per URL on error. Default 3.')],
68
+ [' --requests-dir <dir>', dim('Where to save request/response .md files.')],
65
69
  [' --rebuild', dim('Ignore what is cached and write it again.')],
66
70
  [' --no-cache', dim('Do not record what is written.')],
67
71
  [' --clear', dim('With `model`: forget the stored one.')],
@@ -99,6 +103,9 @@ async function serve(args, ctx) {
99
103
  printSpecs(setup.router.operations, ctx.cwd);
100
104
  }
101
105
  const host = values.host ?? '127.0.0.1';
106
+ const requestsDir = values['requests-dir']
107
+ ? resolve(ctx.cwd, values['requests-dir'])
108
+ : undefined;
102
109
  const listener = await listen({
103
110
  router: setup.router,
104
111
  cache: setup.cache,
@@ -106,7 +113,9 @@ async function serve(args, ctx) {
106
113
  box: setup.box,
107
114
  seed: number(values.seed, 'seed'),
108
115
  maxBody: number(values['max-body'], 'max-body'),
109
- onRequest: values.quiet ? undefined : (line) => note(dim(line)),
116
+ requestsDir,
117
+ regenLimit: number(values['regen-limit'], 'regen-limit'),
118
+ onRequest: values.quiet ? undefined : (line) => note(line),
110
119
  }, host, number(values.port, 'port') ?? 8787);
111
120
  if (!values.quiet) {
112
121
  note(`${green('listening')} ${cyan(`http://${host}:${listener.port}`)} ` +
@@ -115,6 +124,7 @@ async function serve(args, ctx) {
115
124
  note(yellow(`bound to ${host} — this mock is reachable from the network`));
116
125
  }
117
126
  note(dim(`generators: ${setup.root}/${GENERATORS}`));
127
+ note(dim(`request logs: ${requestsDir ?? `${setup.root}/requests`}`));
118
128
  }
119
129
  // The address is the answer; the rest was narration.
120
130
  write(`http://${host}:${listener.port}`);
@@ -1,5 +1,7 @@
1
1
  import type { Model } from '@zenera/neo';
2
2
  import type { Box } from './box.ts';
3
+ import type { GeneratorInput } from './envelope.ts';
4
+ import { type ExampleRequest } from './prompt.ts';
3
5
  import { type Operation } from './spec.ts';
4
6
  import { type Checks } from './validate.ts';
5
7
  export interface BuildOptions {
@@ -15,6 +17,13 @@ export interface Built {
15
17
  source: string;
16
18
  attempts: number;
17
19
  }
20
+ export interface RegenerateOptions extends BuildOptions {
21
+ currentSource: string;
22
+ failingInput: GeneratorInput;
23
+ fault: string;
24
+ stderr?: string;
25
+ examples?: readonly ExampleRequest[];
26
+ }
18
27
  export declare class BuildFailed extends Error {
19
28
  readonly operation: Operation;
20
29
  readonly diagnostics: readonly string[];
@@ -30,6 +39,7 @@ export declare class BuildFailed extends Error {
30
39
  */
31
40
  export declare function reason(err: unknown): string;
32
41
  export declare function build(operation: Operation, opts: BuildOptions): Promise<Built>;
42
+ export declare function regenerate(operation: Operation, opts: RegenerateOptions): Promise<Built>;
33
43
  /**
34
44
  * Models fence code even when told not to, and a stray ```python line is a
35
45
  * syntax error rather than a bad answer — not worth a round trip.
package/dist/generate.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { tokenOf } from "./paging.js";
2
2
  import { echoIssues, nextPage, probesFor, walkStart } from "./probe.js";
3
- import { instruction, retry, SYSTEM } from "./prompt.js";
3
+ import { instruction, regenerationInstruction, retry, SYSTEM, } from "./prompt.js";
4
4
  import { called } from "./spec.js";
5
5
  import { describeIssues, issues } from "./validate.js";
6
6
  export class BuildFailed extends Error {
@@ -71,6 +71,53 @@ export async function build(operation, opts) {
71
71
  }
72
72
  throw new BuildFailed(operation, last);
73
73
  }
74
+ export async function regenerate(operation, opts) {
75
+ const limit = Math.max(1, opts.attempts ?? 3);
76
+ const exampleInputs = opts.examples?.map((e) => e.input) ?? [];
77
+ const probes = [opts.failingInput, ...exampleInputs, ...probesFor(operation)];
78
+ const response = opts.checks.for(operation).response;
79
+ const messages = [
80
+ {
81
+ role: 'user',
82
+ content: [
83
+ {
84
+ type: 'text',
85
+ text: regenerationInstruction({
86
+ operation,
87
+ currentSource: opts.currentSource,
88
+ failingInput: opts.failingInput,
89
+ fault: opts.fault,
90
+ stderr: opts.stderr,
91
+ examples: opts.examples,
92
+ }),
93
+ },
94
+ ],
95
+ },
96
+ ];
97
+ let last = [];
98
+ for (let attempt = 1; attempt <= limit; attempt++) {
99
+ const answer = await ask(opts, {
100
+ system: SYSTEM,
101
+ messages,
102
+ tools: [],
103
+ signal: opts.signal,
104
+ });
105
+ const source = unfence(answer.text);
106
+ if (!source.trim()) {
107
+ last = ['the answer was empty'];
108
+ }
109
+ else {
110
+ await opts.box.write(operation.key, source);
111
+ last = await judge(operation, probes, response, opts.box);
112
+ if (last.length === 0) {
113
+ return { source, attempts: attempt };
114
+ }
115
+ }
116
+ opts.onAttempt?.(attempt, last);
117
+ messages.push({ role: 'assistant', content: source }, { role: 'user', content: [{ type: 'text', text: retry(last) }] });
118
+ }
119
+ throw new BuildFailed(operation, last);
120
+ }
74
121
  /**
75
122
  * Streamed when the adapter can, and every delta is dropped: nothing here
76
123
  * renders progress. It is the *socket* that needs them.
@@ -143,7 +190,7 @@ async function walk(operation, paging, response, box) {
143
190
  }
144
191
  if (token === sent) {
145
192
  return [
146
- `- ${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 three pages.`,
193
+ `- ${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.`,
147
194
  ];
148
195
  }
149
196
  if (seen.has(token)) {
@@ -156,7 +203,7 @@ async function walk(operation, paging, response, box) {
156
203
  input = nextPage(input, paging, token);
157
204
  }
158
205
  return [
159
- `- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set. Fabricate three pages in total and set it to null on the last.`,
206
+ `- ${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.`,
160
207
  ];
161
208
  }
162
209
  /**
@@ -0,0 +1,62 @@
1
+ import type { GeneratorInput } from './envelope.ts';
2
+ export type ErrorKind = 'CLIENT_ERROR: NOT_FOUND' | 'CLIENT_ERROR: METHOD_NOT_ALLOWED' | 'CLIENT_ERROR: BAD_REQUEST' | 'CLIENT_ERROR: VALIDATION_FAILED' | 'CLIENT_ERROR: PAYLOAD_TOO_LARGE' | 'GENERATOR_FAULT' | 'GENERATOR_TIMEOUT' | 'GENERATOR_SCHEMA_MISMATCH' | 'GENERATOR_BUILD_FAILED' | 'SERVER_INTERNAL_ERROR' | 'REGENERATION_LIMIT_HIT' | 'REGENERATION_FAILED' | 'SUCCESS';
3
+ export interface RequestDumpData {
4
+ id: string;
5
+ timestamp: Date;
6
+ durationMs: number;
7
+ method: string;
8
+ url: string;
9
+ pathname: string;
10
+ search: string;
11
+ operationId?: string;
12
+ routePath?: string;
13
+ pathParams?: Record<string, string>;
14
+ query?: Record<string, string>;
15
+ requestHeaders: Record<string, string>;
16
+ requestBody?: unknown;
17
+ status: number;
18
+ responseHeaders?: Record<string, string | number>;
19
+ responseBody?: unknown;
20
+ errorKind?: ErrorKind;
21
+ errorMessage?: string;
22
+ stderr?: string;
23
+ cacheStatus?: 'hit' | 'miss' | 'regenerated';
24
+ regenerated?: boolean;
25
+ regenAttempts?: number;
26
+ regenLimitHit?: boolean;
27
+ regenLimit?: number;
28
+ issues?: readonly unknown[];
29
+ note?: string;
30
+ }
31
+ export declare function formatTimestamp(d: Date): string;
32
+ export declare function statusText(status: number): string;
33
+ export declare function colorForStatus(status: number): (s: string) => string;
34
+ export declare function colorForErrorKind(kind: ErrorKind): (s: string) => string;
35
+ /**
36
+ * Saves a markdown file with all input information and output data for a request.
37
+ * Returns the absolute path of the generated markdown file.
38
+ */
39
+ export declare function saveRequestDump(dir: string, data: RequestDumpData): Promise<string>;
40
+ export declare function formatLogLine(data: RequestDumpData, dumpPath: string): string;
41
+ export declare function formatRegenLimitHit(urlPath: string, limit: number, operationId?: string): string;
42
+ export declare function formatRegenerating(urlPath: string, attempt: number, limit: number, operationId?: string, reason?: string): string;
43
+ export declare function formatRegenerated(urlPath: string, attempt: number, durationMs: number, operationId?: string): string;
44
+ export interface ExampleRequest {
45
+ input: GeneratorInput;
46
+ output?: unknown;
47
+ }
48
+ export declare class RequestHistory {
49
+ #private;
50
+ constructor(maxExamples?: number);
51
+ recordSuccess(operationKey: string, input: GeneratorInput, output: unknown): void;
52
+ getSuccessful(operationKey: string, count?: number): ExampleRequest[];
53
+ }
54
+ export declare class RegenerationLimiter {
55
+ #private;
56
+ readonly limit: number;
57
+ constructor(limit?: number);
58
+ countFor(url: string): number;
59
+ isLimitHit(url: string): boolean;
60
+ increment(url: string): number;
61
+ }
62
+ //# sourceMappingURL=logger.d.ts.map
package/dist/logger.js ADDED
@@ -0,0 +1,273 @@
1
+ import { bold, cyan, dim, green, red, yellow } from '@zenera/cli/lib';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ // ---------------------------------------------------------------------------
6
+ // Formatting Helpers
7
+ // ---------------------------------------------------------------------------
8
+ export function formatTimestamp(d) {
9
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
10
+ const yyyy = d.getFullYear();
11
+ const mm = pad(d.getMonth() + 1);
12
+ const dd = pad(d.getDate());
13
+ const hh = pad(d.getHours());
14
+ const min = pad(d.getMinutes());
15
+ const ss = pad(d.getSeconds());
16
+ const ms = pad(d.getMilliseconds(), 3);
17
+ return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}.${ms}`;
18
+ }
19
+ export function statusText(status) {
20
+ switch (status) {
21
+ case 200:
22
+ return 'OK';
23
+ case 201:
24
+ return 'Created';
25
+ case 202:
26
+ return 'Accepted';
27
+ case 204:
28
+ return 'No Content';
29
+ case 400:
30
+ return 'Bad Request';
31
+ case 401:
32
+ return 'Unauthorized';
33
+ case 403:
34
+ return 'Forbidden';
35
+ case 404:
36
+ return 'Not Found';
37
+ case 405:
38
+ return 'Method Not Allowed';
39
+ case 413:
40
+ return 'Payload Too Large';
41
+ case 500:
42
+ return 'Internal Server Error';
43
+ case 501:
44
+ return 'Not Implemented';
45
+ case 502:
46
+ return 'Bad Gateway';
47
+ case 504:
48
+ return 'Gateway Timeout';
49
+ default:
50
+ return status < 400 ? 'Success' : status < 500 ? 'Client Error' : 'Server Error';
51
+ }
52
+ }
53
+ export function colorForStatus(status) {
54
+ if (status < 300)
55
+ return green;
56
+ if (status < 400)
57
+ return cyan;
58
+ if (status < 500)
59
+ return yellow;
60
+ return red;
61
+ }
62
+ export function colorForErrorKind(kind) {
63
+ if (kind.startsWith('CLIENT_ERROR'))
64
+ return yellow;
65
+ if (kind === 'SUCCESS')
66
+ return green;
67
+ return (s) => red(bold(s));
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // Markdown Request / Response Dump File
71
+ // ---------------------------------------------------------------------------
72
+ /**
73
+ * Saves a markdown file with all input information and output data for a request.
74
+ * Returns the absolute path of the generated markdown file.
75
+ */
76
+ export async function saveRequestDump(dir, data) {
77
+ mkdirSync(dir, { recursive: true });
78
+ const safePath = data.pathname.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'root';
79
+ const isoPrefix = data.timestamp.toISOString().replace(/[:.]/g, '-');
80
+ const filename = `${isoPrefix}-${data.method.toUpperCase()}-${safePath}-${data.id}.md`;
81
+ const fullPath = join(dir, filename);
82
+ const lines = [
83
+ `# Request: ${data.method.toUpperCase()} ${data.pathname}${data.search}`,
84
+ '',
85
+ `- **Request ID**: \`${data.id}\``,
86
+ `- **Timestamp**: ${data.timestamp.toISOString()}`,
87
+ `- **Status**: ${data.status} ${statusText(data.status)}`,
88
+ `- **Duration**: ${data.durationMs}ms`,
89
+ `- **Operation ID**: ${data.operationId ? `\`${data.operationId}\`` : '_None_'}`,
90
+ `- **Route Pattern**: ${data.routePath ? `\`${data.routePath}\`` : '_None_'}`,
91
+ `- **Cache**: ${data.cacheStatus ?? 'n/a'}`,
92
+ ];
93
+ if (data.errorKind) {
94
+ lines.push(`- **Error Kind**: \`${data.errorKind}\``);
95
+ }
96
+ if (data.regenerated) {
97
+ lines.push(`- **Regenerated**: Yes (attempts: ${data.regenAttempts ?? 1})`);
98
+ }
99
+ if (data.regenLimitHit) {
100
+ lines.push(`- **Regeneration Limit**: Hit (limit: ${data.regenLimit})`);
101
+ }
102
+ if (data.note) {
103
+ lines.push(`- **Note**: ${data.note}`);
104
+ }
105
+ lines.push('', '---', '', '## Request Info', '');
106
+ lines.push('### URL & Method', '```http', `${data.method.toUpperCase()} ${data.url}`, '```', '');
107
+ lines.push('### Path Parameters');
108
+ if (data.pathParams && Object.keys(data.pathParams).length > 0) {
109
+ lines.push('```json', JSON.stringify(data.pathParams, null, 2), '```');
110
+ }
111
+ else {
112
+ lines.push('_None_');
113
+ }
114
+ lines.push('');
115
+ lines.push('### Query Parameters');
116
+ if (data.query && Object.keys(data.query).length > 0) {
117
+ lines.push('```json', JSON.stringify(data.query, null, 2), '```');
118
+ }
119
+ else {
120
+ lines.push('_None_');
121
+ }
122
+ lines.push('');
123
+ lines.push('### Request Headers');
124
+ if (data.requestHeaders && Object.keys(data.requestHeaders).length > 0) {
125
+ lines.push('```json', JSON.stringify(data.requestHeaders, null, 2), '```');
126
+ }
127
+ else {
128
+ lines.push('_None_');
129
+ }
130
+ lines.push('');
131
+ lines.push('### Request Body');
132
+ if (data.requestBody !== undefined) {
133
+ lines.push('```json', typeof data.requestBody === 'string'
134
+ ? data.requestBody
135
+ : JSON.stringify(data.requestBody, null, 2), '```');
136
+ }
137
+ else {
138
+ lines.push('_Empty_');
139
+ }
140
+ lines.push('');
141
+ lines.push('---', '', '## Response Data', '');
142
+ lines.push('### Status', '```http', `HTTP/1.1 ${data.status} ${statusText(data.status)}`, '```', '');
143
+ lines.push('### Response Headers');
144
+ if (data.responseHeaders && Object.keys(data.responseHeaders).length > 0) {
145
+ lines.push('```json', JSON.stringify(data.responseHeaders, null, 2), '```');
146
+ }
147
+ else {
148
+ lines.push('_None_');
149
+ }
150
+ lines.push('');
151
+ lines.push('### Response Body');
152
+ if (data.responseBody !== undefined) {
153
+ lines.push('```json', typeof data.responseBody === 'string'
154
+ ? data.responseBody
155
+ : JSON.stringify(data.responseBody, null, 2), '```');
156
+ }
157
+ else {
158
+ lines.push('_Empty_');
159
+ }
160
+ lines.push('');
161
+ if (data.errorMessage || data.stderr || (data.issues && data.issues.length > 0)) {
162
+ lines.push('---', '', '## Error & Diagnostics', '');
163
+ if (data.errorKind) {
164
+ lines.push(`### Classification`, `\`${data.errorKind}\``, '');
165
+ }
166
+ if (data.errorMessage) {
167
+ lines.push(`### Error Message`, `\`${data.errorMessage}\``, '');
168
+ }
169
+ if (data.issues && data.issues.length > 0) {
170
+ lines.push('### Validation Issues', '```json', JSON.stringify(data.issues, null, 2), '```', '');
171
+ }
172
+ if (data.stderr) {
173
+ lines.push('### Stderr / Traceback', '```', data.stderr, '```', '');
174
+ }
175
+ }
176
+ await writeFile(fullPath, lines.join('\n'), 'utf8');
177
+ return resolve(fullPath);
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // Log Line Formatter
181
+ // ---------------------------------------------------------------------------
182
+ export function formatLogLine(data, dumpPath) {
183
+ const ts = dim(`[${formatTimestamp(data.timestamp)}]`);
184
+ const method = cyan(bold(data.method.toUpperCase()));
185
+ const target = `${data.pathname}${data.search}`;
186
+ const statusCol = colorForStatus(data.status);
187
+ const status = statusCol(String(data.status));
188
+ const duration = dim(`${data.durationMs}ms`);
189
+ let tag = '';
190
+ if (data.errorKind) {
191
+ const tagCol = colorForErrorKind(data.errorKind);
192
+ tag = ` ${tagCol(`[${data.errorKind}]`)}`;
193
+ }
194
+ else if (data.status < 400) {
195
+ tag = ` ${green('[SUCCESS]')}`;
196
+ }
197
+ const note = data.note ? ` ${dim(`· ${data.note}`)}` : '';
198
+ const errDetail = data.errorMessage ? ` ${dim(`(${data.errorMessage})`)}` : '';
199
+ const firstLine = `${ts} ${method} ${target} ${status} ${duration}${tag}${note}${errDetail}`;
200
+ const lines = [firstLine];
201
+ if (data.stderr && data.stderr.trim()) {
202
+ const lastStderr = data.stderr.trim().split('\n').slice(-4).join('\n ');
203
+ lines.push(` ${dim('stderr:')} ${red(lastStderr)}`);
204
+ }
205
+ if (dumpPath) {
206
+ const fileUrl = `file://${resolve(dumpPath)}`;
207
+ lines.push(` ${dim('↳ dump:')} ${cyan(fileUrl)}`);
208
+ }
209
+ return lines.join('\n');
210
+ }
211
+ // ---------------------------------------------------------------------------
212
+ // Special Log Messages
213
+ // ---------------------------------------------------------------------------
214
+ export function formatRegenLimitHit(urlPath, limit, operationId) {
215
+ const ts = dim(`[${formatTimestamp(new Date())}]`);
216
+ const op = operationId ? ` (${operationId})` : '';
217
+ return `${ts} ${red(bold('[REGENERATION_LIMIT_HIT]'))} Maximum regeneration limit (${limit}) reached for URL "${urlPath}"${op}. Halting further regeneration attempts.`;
218
+ }
219
+ export function formatRegenerating(urlPath, attempt, limit, operationId, reason) {
220
+ const ts = dim(`[${formatTimestamp(new Date())}]`);
221
+ const op = operationId ? ` (${operationId})` : '';
222
+ const r = reason ? ` [${reason}]` : '';
223
+ return `${ts} ${yellow(bold('[REGENERATING]'))} Error in generator for "${urlPath}"${op}${r}. Attempting regeneration (${attempt}/${limit})...`;
224
+ }
225
+ export function formatRegenerated(urlPath, attempt, durationMs, operationId) {
226
+ const ts = dim(`[${formatTimestamp(new Date())}]`);
227
+ const op = operationId ? ` (${operationId})` : '';
228
+ return `${ts} ${green(bold('[REGENERATED]'))} Successfully regenerated generator for "${urlPath}"${op} in attempt ${attempt} (${durationMs}ms).`;
229
+ }
230
+ export class RequestHistory {
231
+ #successful = new Map();
232
+ #maxExamples;
233
+ constructor(maxExamples = 5) {
234
+ this.#maxExamples = maxExamples;
235
+ }
236
+ recordSuccess(operationKey, input, output) {
237
+ let list = this.#successful.get(operationKey);
238
+ if (!list) {
239
+ list = [];
240
+ this.#successful.set(operationKey, list);
241
+ }
242
+ list.push({ input, output });
243
+ if (list.length > this.#maxExamples) {
244
+ list.shift();
245
+ }
246
+ }
247
+ getSuccessful(operationKey, count = 2) {
248
+ const list = this.#successful.get(operationKey);
249
+ if (!list || list.length === 0) {
250
+ return [];
251
+ }
252
+ return list.slice(-count);
253
+ }
254
+ }
255
+ export class RegenerationLimiter {
256
+ #counts = new Map();
257
+ limit;
258
+ constructor(limit = 3) {
259
+ this.limit = limit;
260
+ }
261
+ countFor(url) {
262
+ return this.#counts.get(url) ?? 0;
263
+ }
264
+ isLimitHit(url) {
265
+ return (this.#counts.get(url) ?? 0) >= this.limit;
266
+ }
267
+ increment(url) {
268
+ const next = (this.#counts.get(url) ?? 0) + 1;
269
+ this.#counts.set(url, next);
270
+ return next;
271
+ }
272
+ }
273
+ //# sourceMappingURL=logger.js.map