@zenera/faker 1.1.21 → 1.1.23
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 +12 -11
- package/dist/cache.d.ts +14 -1
- package/dist/cache.js +57 -1
- package/dist/command.js +11 -1
- package/dist/generate.d.ts +10 -0
- package/dist/generate.js +50 -3
- package/dist/logger.d.ts +62 -0
- package/dist/logger.js +273 -0
- package/dist/paging.d.ts +31 -3
- package/dist/paging.js +315 -6
- package/dist/probe.js +11 -0
- package/dist/prompt.d.ts +14 -0
- package/dist/prompt.js +38 -5
- package/dist/server.d.ts +8 -0
- package/dist/server.js +323 -46
- package/dist/spec.d.ts +5 -0
- package/dist/spec.js +7 -1
- package/package.json +3 -3
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`, …)
|
|
96
|
-
that carries the next one (`next`, `next_cursor`, `has_more`, …),
|
|
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 **
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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}`);
|
package/dist/generate.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
/**
|
package/dist/logger.d.ts
ADDED
|
@@ -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
|