@volter/twin-standard 1.0.30 → 1.0.31
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/dist/src/archive-source.d.ts +13 -0
- package/dist/src/archive-source.js +176 -0
- package/dist/src/check-sources.d.ts +19 -2
- package/dist/src/check-sources.js +95 -37
- package/dist/src/cli.js +52 -9
- package/dist/src/clients/ai-gateway.d.ts +1 -0
- package/dist/src/clients/ai-gateway.js +3 -0
- package/dist/src/clients/anthropic-messages.d.ts +10 -0
- package/dist/src/clients/anthropic-messages.js +52 -0
- package/dist/src/clients/anthropic.d.ts +1 -0
- package/dist/src/clients/anthropic.js +3 -0
- package/dist/src/clients/aws.d.ts +2 -0
- package/dist/src/clients/aws.js +137 -0
- package/dist/src/clients/cerebras.d.ts +1 -0
- package/dist/src/clients/cerebras.js +3 -0
- package/dist/src/clients/clerk.d.ts +2 -0
- package/dist/src/clients/clerk.js +99 -0
- package/dist/src/clients/cloudflare.d.ts +2 -0
- package/dist/src/clients/cloudflare.js +86 -0
- package/dist/src/clients/elevenlabs.d.ts +2 -0
- package/dist/src/clients/elevenlabs.js +39 -0
- package/dist/src/clients/gemini.d.ts +2 -0
- package/dist/src/clients/gemini.js +92 -0
- package/dist/src/clients/github.d.ts +2 -0
- package/dist/src/clients/github.js +71 -0
- package/dist/src/clients/groq.d.ts +1 -0
- package/dist/src/clients/groq.js +3 -0
- package/dist/src/clients/harness.d.ts +36 -0
- package/dist/src/clients/harness.js +74 -0
- package/dist/src/clients/merge.d.ts +1 -0
- package/dist/src/clients/merge.js +8 -0
- package/dist/src/clients/openai-compatible.d.ts +9 -0
- package/dist/src/clients/openai-compatible.js +51 -0
- package/dist/src/clients/openai.d.ts +2 -0
- package/dist/src/clients/openai.js +65 -0
- package/dist/src/clients/openrouter.d.ts +1 -0
- package/dist/src/clients/openrouter.js +3 -0
- package/dist/src/clients/perplexity.d.ts +1 -0
- package/dist/src/clients/perplexity.js +3 -0
- package/dist/src/clients/planetscale.d.ts +2 -0
- package/dist/src/clients/planetscale.js +109 -0
- package/dist/src/clients/posthog.d.ts +2 -0
- package/dist/src/clients/posthog.js +145 -0
- package/dist/src/clients/run.d.ts +1 -0
- package/dist/src/clients/run.js +25 -0
- package/dist/src/clients/sentry.d.ts +2 -0
- package/dist/src/clients/sentry.js +167 -0
- package/dist/src/clients/slack.d.ts +2 -0
- package/dist/src/clients/slack.js +107 -0
- package/dist/src/clients/stripe.d.ts +2 -0
- package/dist/src/clients/stripe.js +161 -0
- package/dist/src/clients/tinybird.d.ts +2 -0
- package/dist/src/clients/tinybird.js +71 -0
- package/dist/src/clients/tunnel.d.ts +2 -0
- package/dist/src/clients/tunnel.js +99 -0
- package/dist/src/clients/upstash.d.ts +2 -0
- package/dist/src/clients/upstash.js +143 -0
- package/dist/src/clients/volteridentity.d.ts +2 -0
- package/dist/src/clients/volteridentity.js +128 -0
- package/dist/src/clients/xai.d.ts +2 -0
- package/dist/src/clients/xai.js +23 -0
- package/dist/src/conformance.d.ts +33 -0
- package/dist/src/conformance.js +520 -0
- package/dist/src/derive.js +113 -22
- package/dist/src/gate.js +22 -16
- package/dist/src/index.d.ts +5 -3
- package/dist/src/index.js +5 -3
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +7 -0
- package/dist/src/p3-rules.js +0 -2
- package/dist/src/protocol-3.d.ts +1 -0
- package/dist/src/protocol-3.js +76 -30
- package/dist/src/published.d.ts +6 -0
- package/dist/src/published.js +65 -11
- package/dist/src/spec-documents.js +2 -1
- package/dist/src/spec-ir-client.d.ts +5 -1
- package/dist/src/spec-ir-client.js +12 -2
- package/dist/src/spec-ir.d.ts +7 -2
- package/dist/src/spec-ir.js +42 -10
- package/dist/src/verdict.d.ts +14 -0
- package/dist/src/verdict.js +30 -0
- package/dist/src/walk/behavior-journey.d.ts +47 -0
- package/dist/src/walk/behavior-journey.js +1138 -0
- package/dist/src/walk/git-client.d.ts +26 -0
- package/dist/src/walk/git-client.js +139 -0
- package/dist/src/walk/journey-kit.d.ts +445 -0
- package/dist/src/walk/journey-kit.js +743 -0
- package/dist/src/walk/shape.d.ts +54 -0
- package/dist/src/walk/shape.js +210 -0
- package/dist/src/walk/sse-example.d.ts +2 -0
- package/dist/src/walk/sse-example.js +32 -0
- package/dist/src/walk/vendor-fetch.d.ts +20 -0
- package/dist/src/walk/vendor-fetch.js +92 -0
- package/dist/src/walk/walk-database.d.ts +12 -0
- package/dist/src/walk/walk-database.js +96 -0
- package/dist/src/walk/xml-view.d.ts +2 -0
- package/dist/src/walk/xml-view.js +55 -0
- package/package.json +39 -3
- package/src/archive-source.ts +141 -0
- package/src/check-sources.ts +83 -33
- package/src/cli.ts +40 -8
- package/src/clients/ai-gateway.ts +4 -0
- package/src/clients/anthropic-messages.ts +60 -0
- package/src/clients/anthropic.ts +4 -0
- package/src/clients/aws.ts +119 -0
- package/src/clients/cerebras.ts +4 -0
- package/src/clients/clerk.ts +97 -0
- package/src/clients/cloudflare.ts +80 -0
- package/src/clients/elevenlabs.ts +38 -0
- package/src/clients/gemini.ts +89 -0
- package/src/clients/github.ts +67 -0
- package/src/clients/groq.ts +4 -0
- package/src/clients/harness.ts +101 -0
- package/src/clients/merge.ts +9 -0
- package/src/clients/openai-compatible.ts +59 -0
- package/src/clients/openai.ts +66 -0
- package/src/clients/openrouter.ts +4 -0
- package/src/clients/perplexity.ts +4 -0
- package/src/clients/planetscale.ts +98 -0
- package/src/clients/posthog.ts +132 -0
- package/src/clients/run.ts +18 -0
- package/src/clients/sentry.ts +157 -0
- package/src/clients/slack.ts +98 -0
- package/src/clients/stripe.ts +129 -0
- package/src/clients/tinybird.ts +69 -0
- package/src/clients/tunnel.ts +79 -0
- package/src/clients/upstash.ts +137 -0
- package/src/clients/volteridentity.ts +108 -0
- package/src/clients/xai.ts +25 -0
- package/src/conformance.ts +446 -0
- package/src/derive.ts +87 -16
- package/src/gate.ts +20 -14
- package/src/index.ts +5 -3
- package/src/lanes.ts +7 -0
- package/src/p3-rules.ts +0 -2
- package/src/protocol-3.ts +71 -27
- package/src/published.ts +59 -11
- package/src/spec-documents.ts +2 -1
- package/src/spec-ir-client.ts +15 -4
- package/src/spec-ir.ts +43 -5
- package/src/verdict.ts +28 -0
- package/src/walk/behavior-journey.ts +806 -0
- package/src/walk/git-client.ts +138 -0
- package/src/walk/journey-kit.ts +858 -0
- package/src/walk/shape.ts +208 -0
- package/src/walk/sse-example.ts +25 -0
- package/src/walk/vendor-fetch.ts +80 -0
- package/src/walk/walk-database.ts +97 -0
- package/src/walk/xml-view.ts +44 -0
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
import { parseExactJson, exactInteger, isExactIntegerValue } from '@volter/world-core/exact-json';
|
|
2
|
+
// THE BEHAVIOURAL JOURNEY KIT — the declaration a pack makes about how it BEHAVES over time.
|
|
3
|
+
//
|
|
4
|
+
// The census answers "is this operation mapped?" and R9 answers "does it answer identically
|
|
5
|
+
// twice?". Neither asks whether the twin is RIGHT. A capability's verify does, but it is written
|
|
6
|
+
// by the author who already knew which path worked, and it is a few calls long: github's SDK
|
|
7
|
+
// parity test creates an issue with a title and lists it back, which is why nobody noticed that
|
|
8
|
+
// `labels` is dropped on create and that `?state=open` returns closed issues too.
|
|
9
|
+
//
|
|
10
|
+
// A JOURNEY is the other thing: one long walk through a pack, the way a consumer actually uses
|
|
11
|
+
// it — build state up, refer back to it, change it, and check it still holds many steps later.
|
|
12
|
+
// It is the API-level sibling of this repo's blind-walk doctrine, and its job is to prove the
|
|
13
|
+
// twin's internal logic and state persistence are basically functional, not to unit-test a
|
|
14
|
+
// handler.
|
|
15
|
+
//
|
|
16
|
+
// A journey is ORDERED and STATEFUL. Each step names what a person is doing, issues one request,
|
|
17
|
+
// and may capture values out of the response for later steps (`capture`) and assert things about
|
|
18
|
+
// it (`expect`). The runner adds beats no pack should have to write:
|
|
19
|
+
//
|
|
20
|
+
// ECHO every scalar and array field a create sends comes back on the create AND on a
|
|
21
|
+
// later read — the beat github fails on `labels`
|
|
22
|
+
// PERSISTS everything created early is still readable at the END of the walk, after every
|
|
23
|
+
// other write has happened
|
|
24
|
+
// ISOLATED a second world, walked nowhere, sees none of it
|
|
25
|
+
// REPLAYABLE the whole walk on a fresh root produces byte-identical answers
|
|
26
|
+
// UNKNOWN an id never created answers the vendor's own not-found shape, not a 200
|
|
27
|
+
//
|
|
28
|
+
// Steps are data, not code, so the runner can re-order, re-run and re-check them; assertions that
|
|
29
|
+
// need real logic go in `check`, which receives the response and everything captured so far.
|
|
30
|
+
//
|
|
31
|
+
// ONE RULE ABOUT SHAPE, and it is the rule that earns a journey its length: A STORY MUST RETURN
|
|
32
|
+
// SOMETHING TO A STATE IT ALREADY HELD. Close the issue and reopen it. Add the label, take it off,
|
|
33
|
+
// put it back. Bugs do not live in a transition, they live between two of them, and a walk that
|
|
34
|
+
// only ever moves forward cannot see the ones that matter. Both of the deepest defects found so
|
|
35
|
+
// far are of exactly this shape: github's timeline forgot `closed` and `reopened` (the story
|
|
36
|
+
// reopens), and the kernel drops a write that returns a value to what it was one instant earlier
|
|
37
|
+
// (slack's story does NOT re-add a reaction, so its walk was green while the defect was live).
|
|
38
|
+
|
|
39
|
+
import { parse, visit } from 'graphql';
|
|
40
|
+
import { digest, jwtSign, oauth1BaseString, oauth1Encode, oauth1Signature, presignSigV4, signSigV4 } from '@volter/world-core';
|
|
41
|
+
|
|
42
|
+
/** A value captured from an earlier step, written `{step.field}` — resolved in paths and bodies. */
|
|
43
|
+
export type Captured = Record<string, string>;
|
|
44
|
+
|
|
45
|
+
export type JourneyStep = {
|
|
46
|
+
/** What a PERSON is doing, in their words — this is what a failure report reads back. */
|
|
47
|
+
name: string;
|
|
48
|
+
method: string;
|
|
49
|
+
/** `{capture}` placeholders are substituted from earlier steps. */
|
|
50
|
+
path: string;
|
|
51
|
+
/** Start HTTP without waiting; a later step with await and the same method/path checks its answer. */
|
|
52
|
+
defer?: string;
|
|
53
|
+
/** Collect a previously deferred request once, with all ordinary response checks. */
|
|
54
|
+
await?: string;
|
|
55
|
+
body?: unknown;
|
|
56
|
+
/** A body of raw bytes (a gzip'd package, an image, an audio file), base64 in the journey and sent decoded: a
|
|
57
|
+
* string body is sent as UTF-8, which no binary format survives. A step carries `body` or `bodyBase64`, never both. */
|
|
58
|
+
bodyBase64?: string;
|
|
59
|
+
/** A large raw body written short: `text` repeated to `bytes` bytes (a part of several MiB, a body over a size limit),
|
|
60
|
+
* expanded before the step is sent, signed or compared (expandFills); never beside `body`, `bodyBase64` or `form`.
|
|
61
|
+
* Inside `body`, a value `{ "$fill": { text, bytes } }` is the same string written short. */
|
|
62
|
+
bodyFill?: { text: string; bytes: number };
|
|
63
|
+
/** How the client encodes `body` as it sends it, after its captures are filled: `gzip` (posthog-js's `gzip-js`, a
|
|
64
|
+
* Node SDK's gzip) or `base64-form` (a form whose `data` is the body's base64). */
|
|
65
|
+
encode?: 'gzip' | 'base64-form';
|
|
66
|
+
/** An HTML form the client posts as multipart/form-data (RFC 7578; `formBody`), as a browser posts a composer with a
|
|
67
|
+
* file from the person's machine or an SDK posts a POST-policy upload: `fields`, a template whose captures fill to a
|
|
68
|
+
* JSON `[{name, value}]` list (`"{vFields}"`), sent first and in its order (every field an S3 lease named), then
|
|
69
|
+
* `parts` in the form's order, each a text
|
|
70
|
+
* field (its `value`, captures filled: a value the page showed, read off it) or a file (`filename`, its `type`, and its
|
|
71
|
+
* bytes as `base64`, as text in `value`, or a real PNG of the size given, `png`). The walk builds the body and its boundary. A step carries
|
|
72
|
+
* one of `body`, `bodyBase64` and `form`. */
|
|
73
|
+
form?: JourneyForm;
|
|
74
|
+
headers?: Record<string, string>;
|
|
75
|
+
/** The status this step must answer. Defaults to "any 2xx". */
|
|
76
|
+
status?: number;
|
|
77
|
+
/** Values for later steps: name → a dot path into the answer, `text:<pattern>` for a value read off a page's or mail's
|
|
78
|
+
* text, `header:<name>[:<pattern>]` for a response header (a session cookie), or `view:<path>` for a path into the
|
|
79
|
+
* pack's view of the answer (`view`: a value the client decodes before it uses it). `sse:<event>:<path>` joins
|
|
80
|
+
* matching SSE string values; `sse-json:<event>:<path>` also decodes fragmented JSON tool input. */
|
|
81
|
+
capture?: Record<string, string>;
|
|
82
|
+
/** Marks this step as creating a resource: the runner applies the ECHO and PERSISTS beats,
|
|
83
|
+
* reading it back at `readBack` (a path, `{capture}` allowed) both now and at the end.
|
|
84
|
+
* `echoOn: 'read'` when the vendor's own create answer is thin by design — Jira's returns
|
|
85
|
+
* only `{id, key, self}`, so demanding the fields echo THERE would be testing a fiction; the
|
|
86
|
+
* read-back still has to carry them. */
|
|
87
|
+
creates?: {
|
|
88
|
+
readBack: string;
|
|
89
|
+
echoOn?: 'both' | 'read';
|
|
90
|
+
/** Where the created object sits in the response, when the vendor wraps it — Slack answers
|
|
91
|
+
* `{ok, channel:{…}}` on both the create and the read, so the ECHO beat looks under
|
|
92
|
+
* `channel` rather than at the envelope. Defaults to the whole body. */
|
|
93
|
+
at?: string;
|
|
94
|
+
/** fields the vendor legitimately transforms or drops */
|
|
95
|
+
echoExcept?: string[];
|
|
96
|
+
};
|
|
97
|
+
/** This write must VISIBLY CHANGE what `GET <changes>` returns (a path, `{capture}` allowed).
|
|
98
|
+
* The runner reads that path before and after the step and fails if the answer is unchanged.
|
|
99
|
+
*
|
|
100
|
+
* This is the LANDED beat, and it exists because of the worst defect class this repo has a
|
|
101
|
+
* name for: a write that answers the vendor's own success shape and stores nothing. Jira's
|
|
102
|
+
* `PUT .../issue/{key}/assignee` answered 204 — correct, per the vendor — while writing an
|
|
103
|
+
* empty field set, and the only reason the walk caught it was that a later step happened to
|
|
104
|
+
* read the field back. Declared here, every write is asked the question. */
|
|
105
|
+
changes?: string;
|
|
106
|
+
/** An OAuth 1.0a signed request (RFC 5849, HMAC-SHA1), as twitter-api-v2 signs X's: the walk builds the
|
|
107
|
+
* `Authorization: OAuth …` header over the method, the URL the step sends to (an absolute URL, the vendor's own, is
|
|
108
|
+
* what the client signs), its query and a form-encoded body. Each value may carry `{capture}`s; `token` and
|
|
109
|
+
* `tokenSecret` are the user's, `callback` the request-token leg's and `verifier` the access-token leg's. */
|
|
110
|
+
oauth1?: { consumerKey: string; consumerSecret: string; token?: string; tokenSecret?: string; callback?: string; verifier?: string };
|
|
111
|
+
/** A JWT the client signs itself and sends as `Authorization: Bearer` (a GitHub App's, with the private key its
|
|
112
|
+
* registration handed it): RS256 over `claims`, with the PEM `privatePem` names (a `{capture}`); claims may carry
|
|
113
|
+
* captures too. */
|
|
114
|
+
jwt?: { privatePem: string; claims: Record<string, unknown> };
|
|
115
|
+
/** A SigV4-signed request (an AWS SDK's): the walk adds `authorization` and `x-amz-date` (and S3's payload hash),
|
|
116
|
+
* signed over the URL it sends to, its headers and body, at the World clock's instant, with an access key a
|
|
117
|
+
* `{capture}` names. */
|
|
118
|
+
sigv4?: { accessKeyId: string; secretAccessKey: string; region: string; service: string };
|
|
119
|
+
/** A presigned URL (the step's path) signed again with an access key a `{capture}` names: its date, expiry and scope
|
|
120
|
+
* are the URL's, its credential and signature the key's (the kernel's presignSigV4). */
|
|
121
|
+
presign?: { accessKeyId: string; secretAccessKey: string };
|
|
122
|
+
/** A real assertion. Return true, or a string saying what was wrong. Hand-written journeys only:
|
|
123
|
+
* a derived pack's journeys are data and say what they expect in `expect`. */
|
|
124
|
+
check?: (res: { status: number; body: unknown }, captured: Captured) => true | string;
|
|
125
|
+
/** What the answer must hold, as data: the value at a dot path of the body, or of the response header `header`
|
|
126
|
+
* names (an answer that is a file, not JSON, says what it is in its Content-Type), equals one given, is one of
|
|
127
|
+
* several, or exists (`{capture}` placeholders allowed in `equals`). */
|
|
128
|
+
expect?: Array<{ path?: string; header?: string; equals?: unknown; oneOf?: unknown[]; members?: unknown[]; exists?: boolean; contains?: string; notContains?: string; matches?: string; shape?: JsonShape }>;
|
|
129
|
+
/** The pack's named view of the answer the step's `expect` paths read (`answerViews` in the pack's index): an answer
|
|
130
|
+
* whose values the wire encodes (a psdb result's rows, base64 values sliced by per-column lengths) is asserted by
|
|
131
|
+
* what it holds. Captures read the answer as it came. */
|
|
132
|
+
view?: string;
|
|
133
|
+
/** This step sends a vendor's published example, `<operation>#<title>` (scripts/vendor-examples.ts): its answer
|
|
134
|
+
* must hold every key path of the example's answer with the same JSON type. */
|
|
135
|
+
example?: string;
|
|
136
|
+
/** Caller-chosen values of the example's named placeholders, compared literally; captures may fill them.
|
|
137
|
+
* A binding never overrides an earlier capture with a different value. */
|
|
138
|
+
exampleBindings?: Record<string, string>;
|
|
139
|
+
/** A key path of the example's answer (or `status`) the twin answers otherwise → why the vendor's example is wrong, or
|
|
140
|
+
* why the server the twin models answers otherwise where the vendor's text allows it. */
|
|
141
|
+
exampleDiffers?: Record<string, string>;
|
|
142
|
+
/** A published body field this step leaves out (a dot path; `a[].b` in each item) → why: a rendered value naming an
|
|
143
|
+
* object the World does not hold (a Git repository behind an integration). Only a field the request schema does not
|
|
144
|
+
* require may be left out (scripts/vendor-examples.ts). */
|
|
145
|
+
unsent?: Record<string, string>;
|
|
146
|
+
/** answer paths whose value equals something the setup sent without the setup deciding it (a default the vendor applies
|
|
147
|
+
* that coincides), each with the reason: `vendor-examples.ts --sweep` does not ask them asserted, and the judge reads the reason */
|
|
148
|
+
undecided?: Record<string, string>;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export type JsonShape = { type?: string; properties?: Record<string, JsonShape>; required?: string[]; items?: JsonShape; additionalProperties?: boolean; [key: string]: unknown };
|
|
152
|
+
|
|
153
|
+
function schemaFailures(schema: unknown, path = '$'): string[] {
|
|
154
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return [path + ' shape is not an object'];
|
|
155
|
+
const node = schema as JsonShape;
|
|
156
|
+
const supported = new Set(['type', 'properties', 'required', 'items', 'additionalProperties', 'title', 'description', '$schema']);
|
|
157
|
+
const failures = Object.keys(node).filter(key => !supported.has(key)).map(key => path + ' unsupported shape keyword ' + key);
|
|
158
|
+
if (node.type !== undefined && !['object', 'array', 'string', 'boolean', 'null', 'number', 'integer'].includes(node.type)) failures.push(path + ' unsupported shape type');
|
|
159
|
+
if (node.required !== undefined && (!Array.isArray(node.required) || node.required.some(key => typeof key !== 'string'))) failures.push(path + ' required is not an array of strings');
|
|
160
|
+
if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') failures.push(path + ' additionalProperties is not boolean');
|
|
161
|
+
if (node.properties !== undefined) {
|
|
162
|
+
if (!node.properties || typeof node.properties !== 'object' || Array.isArray(node.properties)) failures.push(path + ' properties is not an object');
|
|
163
|
+
else for (const [key, child] of Object.entries(node.properties)) failures.push(...schemaFailures(child, path + '.properties.' + key));
|
|
164
|
+
}
|
|
165
|
+
if (node.items !== undefined) failures.push(...schemaFailures(node.items, path + '.items'));
|
|
166
|
+
return failures;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function shapeFailures(value: unknown, schema: JsonShape, path = '$'): string[] {
|
|
170
|
+
if (value === undefined) return [path + ' value is absent or could not be decoded'];
|
|
171
|
+
const object = value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
172
|
+
const typed = schema.type === undefined || schema.type === 'object' && object || schema.type === 'array' && Array.isArray(value) || schema.type === 'string' && typeof value === 'string' || schema.type === 'boolean' && typeof value === 'boolean' || schema.type === 'null' && value === null || schema.type === 'number' && (typeof value === 'number' && Number.isFinite(value) || isExactIntegerValue(value)) || schema.type === 'integer' && isExactIntegerValue(value);
|
|
173
|
+
if (!typed) return [path + ' does not have shape type ' + schema.type];
|
|
174
|
+
const failures: string[] = [];
|
|
175
|
+
if (object) {
|
|
176
|
+
const row = value as Record<string, unknown>;
|
|
177
|
+
for (const key of schema.required ?? []) if (!Object.hasOwn(row, key)) failures.push(path + '.' + key + ' is required');
|
|
178
|
+
for (const [key, child] of Object.entries(schema.properties ?? {})) if (Object.hasOwn(row, key)) failures.push(...shapeFailures(row[key], child, path + '.' + key));
|
|
179
|
+
if (schema.additionalProperties === false) for (const key of Object.keys(row)) if (!Object.hasOwn(schema.properties ?? {}, key)) failures.push(path + '.' + key + ' is an additional property');
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(value) && schema.items) value.forEach((item, i) => failures.push(...shapeFailures(item, schema.items!, path + '.' + i)));
|
|
182
|
+
return failures;
|
|
183
|
+
}
|
|
184
|
+
/** A step's form (`JourneyStep.form`): its listed fields, then its parts. */
|
|
185
|
+
export type JourneyForm = { fields?: string; parts: FormPart[] };
|
|
186
|
+
/** A part of a step's form: a text field and its value, or a file, its bytes as base64, as text (`value`, captures
|
|
187
|
+
* filled, sent as UTF-8: a CSV, a script) or a PNG of a stated size (an uncompressed RGB gradient, as heavy as an
|
|
188
|
+
* uncompressed export of that size). */
|
|
189
|
+
export type FormPart = { name: string; value: string } | { name: string; filename: string; type?: string; base64?: string; value?: string; png?: { width: number; height: number } };
|
|
190
|
+
|
|
191
|
+
/** A data journey's expectations against an answer: the failures, empty when all hold. */
|
|
192
|
+
export function expectFailures(expect: NonNullable<JourneyStep['expect']>, body: unknown, captured: Captured, headers: Record<string, string> = {}, wireText?: string): string[] {
|
|
193
|
+
const out: string[] = [];
|
|
194
|
+
for (const e0 of expect) {
|
|
195
|
+
const e = { ...e0, path: e0.header ? `header ${e0.header}` : e0.path ?? '' };
|
|
196
|
+
// `path: ""` asserted as text is the whole body as it came (an XML answer's document, not its decoded members)
|
|
197
|
+
const asText = e.path === '' && wireText !== undefined && (typeof e.equals === 'string' || typeof e.contains === 'string' || typeof e.notContains === 'string' || e.matches !== undefined);
|
|
198
|
+
const v = e0.header ? headers[e0.header.toLowerCase()] : asText ? wireText : captureAt(body, e.path, headers, wireText);
|
|
199
|
+
if (e.shape !== undefined) {
|
|
200
|
+
const invalid = schemaFailures(e.shape);
|
|
201
|
+
out.push(...(invalid.length ? invalid : shapeFailures(v, e.shape)).map(f => (e.path || 'the answer') + ': ' + f));
|
|
202
|
+
}
|
|
203
|
+
if (e.exists !== undefined && (v !== undefined && v !== null) !== e.exists) out.push(`${e.path} ${e.exists ? 'is absent' : 'is present'}`);
|
|
204
|
+
if ('equals' in e) {
|
|
205
|
+
// captures fill an expected value wherever they sit in it (a list of ids, a nested object)
|
|
206
|
+
const want = typeof e.equals === 'string' ? fill(e.equals, captured) : e.equals !== undefined && e.equals !== null && typeof e.equals === 'object' ? parseExactJson(fill(JSON.stringify(e.equals), captured)) : e.equals;
|
|
207
|
+
// a capture is text, so a number the vendor answers equals the capture it was taken as
|
|
208
|
+
const same = JSON.stringify(v) === JSON.stringify(want) || (typeof v === 'number' && typeof want === 'string' && String(v) === want);
|
|
209
|
+
if (!same) out.push(`${e.path} is ${JSON.stringify(v)}, not ${JSON.stringify(want)}`);
|
|
210
|
+
}
|
|
211
|
+
// `contains`: the value (a page's whole text at path "", a string field) holds the given text, captures filled;
|
|
212
|
+
// a step that follows a form or link a hosted page shows first checks the page shows it
|
|
213
|
+
if (e.contains !== undefined) {
|
|
214
|
+
const want = fill(e.contains, captured);
|
|
215
|
+
const text = typeof v === 'string' ? v : JSON.stringify(v ?? '');
|
|
216
|
+
if (!text.includes(want)) out.push(`${e.path || 'the answer'} does not contain ${JSON.stringify(want)}`);
|
|
217
|
+
}
|
|
218
|
+
// `notContains`: the value does not hold the text (a list that omits what the caller may not see)
|
|
219
|
+
if (e.notContains !== undefined) {
|
|
220
|
+
const unwanted = fill(e.notContains, captured);
|
|
221
|
+
const text = typeof v === 'string' ? v : JSON.stringify(v ?? '');
|
|
222
|
+
if (text.includes(unwanted)) out.push(`${e.path || 'the answer'} contains ${JSON.stringify(unwanted)}`);
|
|
223
|
+
}
|
|
224
|
+
// `matches`: the value's text matches the regular expression (a key's form, a URL's scheme), captures filled
|
|
225
|
+
if (e.matches !== undefined) {
|
|
226
|
+
const text = typeof v === 'string' ? v : JSON.stringify(v ?? '');
|
|
227
|
+
if (!new RegExp(fill(e.matches, captured)).test(text)) out.push(`${e.path || 'the answer'} is ${JSON.stringify(text).slice(0, 80)}, which does not match /${e.matches}/`);
|
|
228
|
+
}
|
|
229
|
+
// `members`: the value is an array holding exactly these, in any order (a Redis SCAN or SMEMBERS, whose order the
|
|
230
|
+
// vendor does not promise), captures filled wherever they sit in a member (an object's id, as `equals` fills them)
|
|
231
|
+
if (e.members !== undefined) {
|
|
232
|
+
const canonical = (value: unknown): unknown => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, canonical(v)])) : value;
|
|
233
|
+
const want = e.members.map((m) => fillJson(m, captured)).map((m) => JSON.stringify(canonical(m))).sort();
|
|
234
|
+
const got = Array.isArray(v) ? v.map((m) => JSON.stringify(canonical(m))).sort() : undefined;
|
|
235
|
+
if (!got || JSON.stringify(got) !== JSON.stringify(want)) out.push(`${e.path} is ${JSON.stringify(v)}, not the members ${JSON.stringify(e.members)} in any order`);
|
|
236
|
+
}
|
|
237
|
+
if (e.oneOf) {
|
|
238
|
+
const allowed = e.oneOf.map((o) => fillJson(o, captured));
|
|
239
|
+
if (!allowed.some((o) => JSON.stringify(o) === JSON.stringify(v))) out.push(`${e.path} is ${JSON.stringify(v)}, not one of ${JSON.stringify(allowed)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `text`'s UTF-8 bytes repeated, cut to exactly `bytes` of them: a cut may fall inside a character. */
|
|
246
|
+
function filledBytes(f: { text: string; bytes: number }): Buffer {
|
|
247
|
+
if (!f.text || !Number.isInteger(f.bytes) || f.bytes < 0) throw new Error('a fill names its text and a whole number of bytes');
|
|
248
|
+
const unit = Buffer.byteLength(f.text);
|
|
249
|
+
return Buffer.from(f.text.repeat(Math.ceil(f.bytes / unit))).subarray(0, f.bytes);
|
|
250
|
+
}
|
|
251
|
+
/** A fill inside a body, a string: its cut must land between characters, since a cut character would be sent as
|
|
252
|
+
* U+FFFD's three bytes and the body would not be `bytes` long. */
|
|
253
|
+
function filled(f: { text: string; bytes: number }): string {
|
|
254
|
+
const cut = filledBytes(f);
|
|
255
|
+
// the byte after the cut is the text's byte at the same place in its repetition: a UTF-8 continuation byte
|
|
256
|
+
// (10xxxxxx) there means the cut fell inside a character
|
|
257
|
+
const text = Buffer.from(f.text);
|
|
258
|
+
if ((text[f.bytes % text.length]! & 0xc0) === 0x80) throw new Error(`a $fill of ${JSON.stringify(f.text)} cut at ${f.bytes} bytes falls inside a character`);
|
|
259
|
+
return cut.toString('utf8');
|
|
260
|
+
}
|
|
261
|
+
/** `v` with its `{ $fill }` values written out; `v` itself (the same object) when it holds none, so a caller that
|
|
262
|
+
* finds a step by identity in the journey it passed (score-pack's sittings, round-trip's positions) still finds it. */
|
|
263
|
+
const expandValue = (v: unknown): unknown => {
|
|
264
|
+
if (Array.isArray(v)) {
|
|
265
|
+
const out = v.map(expandValue);
|
|
266
|
+
return out.some((x, i) => x !== v[i]) ? out : v;
|
|
267
|
+
}
|
|
268
|
+
if (v && typeof v === 'object') {
|
|
269
|
+
const o = v as Record<string, unknown>;
|
|
270
|
+
if (Object.keys(o).length === 1 && o.$fill && typeof o.$fill === 'object') return filled(o.$fill as { text: string; bytes: number });
|
|
271
|
+
const out = Object.entries(o).map(([k, x]) => [k, expandValue(x)] as const);
|
|
272
|
+
return out.some(([k, x]) => x !== o[k]) ? Object.fromEntries(out) : v;
|
|
273
|
+
}
|
|
274
|
+
return v;
|
|
275
|
+
};
|
|
276
|
+
/** A journey with its fills written out (a step's `bodyFill`, a `{ $fill }` inside a body): what is sent, signed and
|
|
277
|
+
* compared is the whole body, while the file keeps it short. A step with no fill is the journey's own object, and a
|
|
278
|
+
* journey with none is returned itself. */
|
|
279
|
+
export function expandFills<J extends { steps: unknown[] }>(journey: J): J {
|
|
280
|
+
const steps = journey.steps.map((st) => {
|
|
281
|
+
const step = st as JourneyStep;
|
|
282
|
+
if (!step || typeof step !== 'object' || !('method' in step)) return st;
|
|
283
|
+
if (step.bodyFill && (step.body !== undefined || step.bodyBase64 !== undefined || step.form !== undefined)) throw new Error(`${step.name}: a step carries one of body, bodyBase64, bodyFill and form`);
|
|
284
|
+
const { bodyFill, ...rest } = step;
|
|
285
|
+
if (bodyFill) return { ...rest, bodyBase64: filledBytes(bodyFill).toString('base64') };
|
|
286
|
+
const body = step.body === undefined ? undefined : expandValue(step.body);
|
|
287
|
+
return body === step.body ? step : { ...step, body };
|
|
288
|
+
});
|
|
289
|
+
return steps.some((x, i) => x !== journey.steps[i]) ? { ...journey, steps } : journey;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export type Journey = {
|
|
293
|
+
vendor: string;
|
|
294
|
+
/** The session that wrote it: a verdict from this session is no verdict (score-pack.ts). */
|
|
295
|
+
author?: string;
|
|
296
|
+
/** The host the pack serves this walk on (several packs route by host). */
|
|
297
|
+
host?: string;
|
|
298
|
+
/** Headers every step carries — auth, an API version. */
|
|
299
|
+
headers?: Record<string, string>;
|
|
300
|
+
/** The instant the story begins, where it is before the World's day one (2026-01-01): an account whose story starts
|
|
301
|
+
* while an API it used still made what it no longer makes. Its waits move the clock on from there. */
|
|
302
|
+
startsAt?: string;
|
|
303
|
+
/** The World's scenario for a generative pack's turns (a file beside the life, `journeys/<name>.json`, as a World
|
|
304
|
+
* loads `handlers/<vendor>.json`): the walk loads it, so the scripted turns and the pack's scenario code are walked. */
|
|
305
|
+
scenario?: string;
|
|
306
|
+
/** The packs of the same catalog, by vendor, that the vendor calls as its own behaviour (`ctx.vendorFetch`: a sign-in
|
|
307
|
+
* trading a code at its provider): the walk loads each over the walk's own World and root, the vendor's calls reach
|
|
308
|
+
* them in process, and a step to an absolute URL a companion's host rules claim is served by it (architecture,
|
|
309
|
+
* "Journeys"). */
|
|
310
|
+
companions?: string[];
|
|
311
|
+
/** The application's answers to what the vendor asks it (`ctx.ask`, or a `ctx.vendorFetch` URL read of an
|
|
312
|
+
* application-held file: an LLM endpoint an agent was configured with, a real-time authorization endpoint): each a
|
|
313
|
+
* request it takes (its method, POST by default, and a URL prefix; the request headers it must carry,
|
|
314
|
+
* `requestHeaders`) and the answer it gives (its status, its headers and its body), used in order among those that
|
|
315
|
+
* take the request; a question none takes is unreachable, as in a sealed World. They are given to this walk's fetch
|
|
316
|
+
* alone. `bodyError` returns the headers but fails body consumption, modeling a source disconnect. */
|
|
317
|
+
application?: Array<{ method?: string; url: string; requestHeaders?: Record<string, string>; status: number; headers?: Record<string, string>; body?: unknown; bodyError?: string }>;
|
|
318
|
+
/** What this walk is about, in one sentence: read by whoever gets the failure. */
|
|
319
|
+
about: string;
|
|
320
|
+
/** What "I don't have that" looks like for this vendor. The ISOLATED beat asks an untouched
|
|
321
|
+
* world for an id it never minted and this says whether it was refused. The default is "not
|
|
322
|
+
* 2xx", which is right for REST; Slack answers HTTP 200 with `{ok:false,error:…}` for every
|
|
323
|
+
* refusal, so a pack in that family declares its own or the beat reads a refusal as a hit. */
|
|
324
|
+
refused?: (res: { status: number; body: unknown }) => boolean;
|
|
325
|
+
steps: Step[];
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep;
|
|
329
|
+
|
|
330
|
+
/** A typed request/reply stream step. `resp` is one command or a pipeline of commands, on a named connection.
|
|
331
|
+
* Captures and expectations see `{ replies, last, error }`; binary arguments use `{ base64 }`.
|
|
332
|
+
* `read` drains pushes or a blocking command's answer without sending bytes. */
|
|
333
|
+
export type RespStep = {
|
|
334
|
+
name: string; connection: string; connect?: Record<string, unknown>; resp?: Array<string | { base64: string }> | Array<Array<string | { base64: string }>>; read?: boolean; close?: boolean; pending?: boolean;
|
|
335
|
+
refused?: string; capture?: Record<string, string>; expect?: JourneyStep['expect']; example?: string; exampleDiffers?: Record<string, string>;
|
|
336
|
+
};
|
|
337
|
+
export const isResp = (step: Step): step is RespStep => 'connection' in step;
|
|
338
|
+
|
|
339
|
+
/** A step on a byte-stream wire (a line protocol, SMTP): lines sent on a named connection, answered by the
|
|
340
|
+
* replies they drew. `connect` opens the connection, with the options the operator started the server with
|
|
341
|
+
* (the pack's `create<Name>TwinStream`); its answer is the server's greeting. The answer is the last reply
|
|
342
|
+
* (`code`, its `lines`, their `text`) and every reply the step drew (`replies`). */
|
|
343
|
+
export type LineStep = {
|
|
344
|
+
name: string;
|
|
345
|
+
on: string;
|
|
346
|
+
connect?: Record<string, unknown>;
|
|
347
|
+
send?: string | string[];
|
|
348
|
+
/** the last reply's code; any below 400 by default */
|
|
349
|
+
status?: number;
|
|
350
|
+
capture?: Record<string, string>;
|
|
351
|
+
expect?: JourneyStep['expect'];
|
|
352
|
+
/** as a request step's: this step sends a vendor's published example */
|
|
353
|
+
example?: string;
|
|
354
|
+
exampleDiffers?: Record<string, string>;
|
|
355
|
+
};
|
|
356
|
+
export const isLine = (step: Step): step is LineStep => 'on' in step;
|
|
357
|
+
|
|
358
|
+
/** A step on a vendor's socket (the manifest's `sockets`, the kernel's sockets.ts: Discord's Gateway): `open` opens
|
|
359
|
+
* a named connection at a path (or an absolute `ws(s)://` url, on that host: a client that dials the vendor's socket
|
|
360
|
+
* host itself), `send` sends frames on it (an object as JSON, captures filled), `close` closes it.
|
|
361
|
+
* Its answer is every frame the server sent since the connection's last step (`frames`, each parsed from JSON when
|
|
362
|
+
* it is JSON), the last of them (`last`), and how the server closed it (`closed`: its `code` and `reason`, or null);
|
|
363
|
+
* its status is the close code, or 101 while it is open. */
|
|
364
|
+
export type SocketStep = {
|
|
365
|
+
name: string;
|
|
366
|
+
socket: string;
|
|
367
|
+
open?: string;
|
|
368
|
+
/** the opening request's own headers (a key the vendor reads at the upgrade: OpenAI Realtime's `Authorization`), filled
|
|
369
|
+
* from the captures as a request's are */
|
|
370
|
+
headers?: Record<string, string>;
|
|
371
|
+
send?: unknown;
|
|
372
|
+
close?: boolean;
|
|
373
|
+
/** 101 (open) by default; a close code when the server is to close it */
|
|
374
|
+
status?: number;
|
|
375
|
+
capture?: Record<string, string>;
|
|
376
|
+
expect?: JourneyStep['expect'];
|
|
377
|
+
};
|
|
378
|
+
export const isSocket = (step: Step): step is SocketStep => 'socket' in step;
|
|
379
|
+
|
|
380
|
+
/** A step a git client takes on the pack's smart-HTTP plane (packages/twin-standard/src/walk/git-client.ts): `clone` reads `ref`'s tip (HEAD by
|
|
381
|
+
* default) — its `sha`, `message` and tree `paths`; `push` commits `files` (a path's content, or null to delete it) on
|
|
382
|
+
* `ref`'s tip, or rewrites the tip (`amend`), or deletes the ref (`delete`), and answers the server's report — `unpack`,
|
|
383
|
+
* the ref's `result` (`ok`, or the reason it was refused) and the pushed `sha`. `repo` is `owner/name`. */
|
|
384
|
+
export type GitFile = string | null | { mode: '100644' | '100755' | '120000'; content: string } | { mode: '160000'; sha: string };
|
|
385
|
+
export type GitStep = {
|
|
386
|
+
name: string;
|
|
387
|
+
git: 'clone' | 'push';
|
|
388
|
+
repo: string;
|
|
389
|
+
ref?: string;
|
|
390
|
+
files?: Record<string, GitFile>;
|
|
391
|
+
message?: string;
|
|
392
|
+
author?: string;
|
|
393
|
+
amend?: boolean;
|
|
394
|
+
delete?: boolean;
|
|
395
|
+
headers?: Record<string, string>;
|
|
396
|
+
/** the HTTP status; 200 by default */
|
|
397
|
+
status?: number;
|
|
398
|
+
capture?: Record<string, string>;
|
|
399
|
+
expect?: JourneyStep['expect'];
|
|
400
|
+
};
|
|
401
|
+
export const isGit = (step: Step): step is GitStep => 'git' in step;
|
|
402
|
+
|
|
403
|
+
/** Time passing in the story, with no request: the World clock moves on by `wait` (`25m`, `2h`, `3d`), so a
|
|
404
|
+
* move the vendor or the clock makes (a container idling out, a code expiring) happens as it would. */
|
|
405
|
+
export type WaitStep = { name: string; wait: string };
|
|
406
|
+
export const isWait = (step: Step): step is WaitStep => 'wait' in step;
|
|
407
|
+
|
|
408
|
+
/** What an application sends the World's managed database over the database's own wire, not through the vendor's API (a
|
|
409
|
+
* pack whose data plane is the World's Postgres, the descriptor's `managedDatabase`): its migrations as its deploy applies
|
|
410
|
+
* them and the queries it sends, each on the application's one connection to the walked World's database (world-core
|
|
411
|
+
* `databaseSession`), so a transaction it begins stays open into its next steps, as on its own connection. `sql` alone is
|
|
412
|
+
* one simple Query (a migration file, a `BEGIN`); with `values` it is one extended-protocol statement, the values bind
|
|
413
|
+
* parameters (a client's `query(text, values)`), captures filled in them; the SQL itself is the application's text, sent
|
|
414
|
+
* as written. Its answer is the last statement's `{ command, rows }`, each row its
|
|
415
|
+
* columns by name in Postgres's text format, which `expect` reads; a refusal fails the step with
|
|
416
|
+
* Postgres's SQLSTATE, unless the step names that SQLSTATE as its `refused` (the application's own catch of it), when
|
|
417
|
+
* its answer is `{ code, message }`. */
|
|
418
|
+
export type SqlStep = { name: string; sql: string; values?: Array<string | null>; refused?: string; expect?: JourneyStep['expect']; capture?: JourneyStep['capture'] };
|
|
419
|
+
export const isSql = (step: Step): step is SqlStep => 'sql' in step;
|
|
420
|
+
|
|
421
|
+
/** A wait's length in milliseconds; an unreadable one throws. */
|
|
422
|
+
export function waitMs(wait: string): number {
|
|
423
|
+
const m = /^(\d+)(s|m|h|d)$/.exec(wait.trim());
|
|
424
|
+
if (!m) throw new Error(`journey: wait "${wait}" is not a number of s, m, h or d`);
|
|
425
|
+
return Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as 's' | 'm' | 'h' | 'd'];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Dot-path read that tolerates arrays (`items.0.id`) and missing links. */
|
|
429
|
+
/** What a capture takes from an answer: a dot path into it, or `text:<pattern>` — the first match of a regular expression
|
|
430
|
+
* over the answer's text (a page's HTML, a mail's body), its first group if it has one. A value a page shows a person
|
|
431
|
+
* (a token shown once, a link) is read off the page this way, never assumed from the twin's sequence. `header:<name>`
|
|
432
|
+
* takes a response header, and `header:<name>:<pattern>` the first match in it: a sign-in's session cookie is kept this
|
|
433
|
+
* way and sent back on the person's later requests, as their browser does. `sse:<event>:<path>` assembles SSE values,
|
|
434
|
+
* and `sse-json:<event>:<path>` decodes the assembled JSON input. */
|
|
435
|
+
export function captureAt(body: unknown, spec: string, headers: Record<string, string> = {}, wireText?: string): unknown {
|
|
436
|
+
// `next:<path>`: the integer there plus one, as a client computes what it asks for next (a poll's offset: the last
|
|
437
|
+
// update_id it read plus one)
|
|
438
|
+
if (spec.startsWith('next:')) {
|
|
439
|
+
const value = captureAt(body, spec.slice(5), headers, wireText);
|
|
440
|
+
return (typeof value === 'number' && Number.isSafeInteger(value) || typeof value === 'string' && /^-?\d+$/.test(value)) ? exactInteger(String(BigInt(value) + 1n)) : undefined;
|
|
441
|
+
}
|
|
442
|
+
if (spec.startsWith('json:')) {
|
|
443
|
+
const text = at(body, spec.slice(5));
|
|
444
|
+
if (typeof text !== 'string') return undefined;
|
|
445
|
+
try { return parseExactJson(text); } catch { return undefined; }
|
|
446
|
+
}
|
|
447
|
+
const sse = /^sse(-json)?:([^:]+):(.+)$/.exec(spec);
|
|
448
|
+
if (sse) {
|
|
449
|
+
if (typeof body !== 'string') return undefined;
|
|
450
|
+
const values: unknown[] = [];
|
|
451
|
+
for (const frame of body.replace(/\r\n/g, '\n').split(/\n\n+/)) {
|
|
452
|
+
let event = 'message'; const data: string[] = [];
|
|
453
|
+
for (const line of frame.split('\n')) {
|
|
454
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
455
|
+
if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
|
|
456
|
+
}
|
|
457
|
+
if (event !== sse[2] || !data.length || data.join('\n').trim() === '[DONE]') continue;
|
|
458
|
+
let value: unknown;
|
|
459
|
+
try { value = at(parseExactJson(data.join('\n')), sse[3]!); } catch { return undefined; }
|
|
460
|
+
if (value !== undefined && value !== null) values.push(value);
|
|
461
|
+
}
|
|
462
|
+
if (!values.length) return undefined;
|
|
463
|
+
const joined = values.every(v => typeof v === 'string') ? values.join('') : values.length === 1 ? values[0] : values;
|
|
464
|
+
if (!sse[1]) return joined;
|
|
465
|
+
if (typeof joined !== 'string') return undefined;
|
|
466
|
+
try { return parseExactJson(joined); } catch { return undefined; }
|
|
467
|
+
}
|
|
468
|
+
if (spec.startsWith('header:')) {
|
|
469
|
+
const [name = '', ...pattern] = spec.slice('header:'.length).split(':');
|
|
470
|
+
const value = headers[name.toLowerCase()];
|
|
471
|
+
if (value === undefined || !pattern.length) return value;
|
|
472
|
+
const m = new RegExp(pattern.join(':')).exec(value);
|
|
473
|
+
return m ? (m[1] ?? m[0]) : undefined;
|
|
474
|
+
}
|
|
475
|
+
if (!spec.startsWith('text:')) return at(body, spec);
|
|
476
|
+
// a `text:` capture reads the body as it came, when the walk has it (an XML answer's `<UploadId>`)
|
|
477
|
+
const text = wireText ?? (typeof body === 'string' ? body : JSON.stringify(body ?? ''));
|
|
478
|
+
const m = new RegExp(spec.slice('text:'.length)).exec(text);
|
|
479
|
+
return m ? (m[1] ?? m[0]) : undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function at(body: unknown, path: string): unknown {
|
|
483
|
+
if (path === '') return body; // the whole answer: a page's text, a bare list
|
|
484
|
+
const keys = path.startsWith('/') ? path.slice(1).split('/').map((k) => k.replace(/~1/g, '/').replace(/~0/g, '~')) : path.split('.');
|
|
485
|
+
return keys.reduce<unknown>((v, k) => {
|
|
486
|
+
if (v === null || v === undefined) return undefined;
|
|
487
|
+
if (Array.isArray(v)) return v[Number(k)];
|
|
488
|
+
if (typeof v === 'object') return (v as Record<string, unknown>)[k];
|
|
489
|
+
return undefined;
|
|
490
|
+
}, body);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
/** Substitute `{name}` placeholders from captured values; throws on an unknown name so a typo
|
|
495
|
+
* in a journey fails loudly rather than requesting a literal `{oops}`. */
|
|
496
|
+
export function fill(template: string, captured: Captured, graphqlDocument = false): string {
|
|
497
|
+
// `{basic:user:password}` is HTTP Basic credentials (RFC 7617) from two captures: `Basic ` and the base64 of
|
|
498
|
+
// `user:password`, as a client sends a username and password it was issued (a PlanetScale password's)
|
|
499
|
+
const basic = template.replace(/\{basic:([A-Za-z0-9_.]+):([A-Za-z0-9_.]+)\}/g, (_, u: string, pw: string) => {
|
|
500
|
+
for (const k of [u, pw]) if (captured[k] === undefined) throw new Error(`journey: step refers to {${k}}, which no earlier step captured`);
|
|
501
|
+
return `Basic ${Buffer.from(`${captured[u]}:${captured[pw]}`).toString('base64')}`;
|
|
502
|
+
});
|
|
503
|
+
// `{encoded:name}` is a capture percent-encoded (encodeURIComponent), as a URL's path segment or query value or a form
|
|
504
|
+
// field carries it: a URN a vendor's page prints encoded in its paths (LinkedIn's /rest/posts/urn%3Ali%3Ashare%3A…), a
|
|
505
|
+
// sign-in's return path holding `&` that a browser posts back
|
|
506
|
+
const encoded = basic.replace(/\{encoded:([A-Za-z0-9_.]+)\}/g, (_, k: string) => {
|
|
507
|
+
if (captured[k] === undefined) throw new Error(`journey: step refers to {${k}}, which no earlier step captured`);
|
|
508
|
+
return encodeURIComponent(captured[k]!);
|
|
509
|
+
});
|
|
510
|
+
const selections = new Set<number>();
|
|
511
|
+
// Only request query documents opt into GraphQL grammar (their selection sets, found in the text the captures fill,
|
|
512
|
+
// which `{basic:…}` and `{encoded:…}` have already filled); variables remain ordinary captured JSON data.
|
|
513
|
+
if (graphqlDocument && !Object.hasOwn(captured, /^\{([A-Za-z_][A-Za-z0-9_.]*)\}$/.exec(encoded)?.[1] ?? '')) {
|
|
514
|
+
try { visit(parse(encoded), { SelectionSet(node) { if (node.loc) selections.add(node.loc.start); } }); } catch { /* invalid documents still reach the vendor's own syntax error */ }
|
|
515
|
+
}
|
|
516
|
+
// a doubled brace is the vendor's own template syntax ({{sample.output_text}} in an OpenAI grader), not a capture; a
|
|
517
|
+
// capture's name starts with a letter, so a pattern's count (`[A-Za-z0-9]{40}` in an expectation) is no capture
|
|
518
|
+
return encoded.replace(/\{\{[^{}]*\}\}|\{([A-Za-z_][A-Za-z0-9_.]*)\}/g, (m, key: string | undefined, offset: number) => {
|
|
519
|
+
if (selections.has(offset)) return m;
|
|
520
|
+
if (key === undefined) return m;
|
|
521
|
+
const v = captured[key];
|
|
522
|
+
// an UPPER_SNAKE name no step captured is the vendor's own placeholder ({CHECKOUT_SESSION_ID} in a
|
|
523
|
+
// Stripe success_url), filled by the vendor, not by the journey
|
|
524
|
+
if (v === undefined && /^[A-Z][A-Z0-9_]*$/.test(key)) return m;
|
|
525
|
+
if (v === undefined) throw new Error(`journey: step refers to {${key}}, which no earlier step captured`);
|
|
526
|
+
return v;
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** A step's `form` as the bytes a browser or an SDK's form encoder sends (RFC 7578), behind one fixed boundary so a
|
|
531
|
+
* walk sends the same bytes each time; its fields and text values filled from the captures. */
|
|
532
|
+
export function formBody(form: JourneyForm, captured: Captured): { contentType: string; bytes: Uint8Array<ArrayBuffer> } {
|
|
533
|
+
const boundary = '----volterJourneyForm7MA4YWxkTrZu0gW';
|
|
534
|
+
const listed = form.fields === undefined ? [] : (parseExactJson(fill(form.fields, captured)) as Array<{ name: string; value: string }>);
|
|
535
|
+
const chunks: Uint8Array[] = [];
|
|
536
|
+
const text = (s: string): void => { chunks.push(new TextEncoder().encode(s)); };
|
|
537
|
+
// a name or filename in a part's header escaped as a browser escapes it (the HTML standard's multipart/form-data
|
|
538
|
+
// encoding: `"` as %22, CR as %0D, LF as %0A), so a captured name never breaks the part
|
|
539
|
+
const quoted = (v: string): string => v.replace(/"/g, '%22').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
540
|
+
for (const f of listed) text(`--${boundary}\r\nContent-Disposition: form-data; name="${quoted(f.name)}"\r\n\r\n${f.value}\r\n`);
|
|
541
|
+
for (const p of form.parts) {
|
|
542
|
+
if (!('filename' in p)) { text(`--${boundary}\r\nContent-Disposition: form-data; name="${quoted(p.name)}"\r\n\r\n${fill(p.value, captured)}\r\n`); continue; }
|
|
543
|
+
text(`--${boundary}\r\nContent-Disposition: form-data; name="${quoted(p.name)}"; filename="${quoted(p.filename)}"\r\n${p.type ? `Content-Type: ${p.type.replace(/[\r\n]/g, '')}\r\n` : ''}\r\n`);
|
|
544
|
+
chunks.push(p.png ? gradientPng(p.png.width, p.png.height) : p.value !== undefined ? new TextEncoder().encode(fill(p.value, captured)) : Uint8Array.from(Buffer.from(p.base64 ?? '', 'base64')));
|
|
545
|
+
text('\r\n');
|
|
546
|
+
}
|
|
547
|
+
text(`--${boundary}--\r\n`);
|
|
548
|
+
const bytes = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
|
|
549
|
+
let at = 0;
|
|
550
|
+
for (const c of chunks) { bytes.set(c, at); at += c.length; }
|
|
551
|
+
return { contentType: `multipart/form-data; boundary=${boundary}`, bytes };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** A real PNG (8-bit RGB) of `width` × `height`, a gradient over the image, its rows stored uncompressed (zlib's stored
|
|
555
|
+
* blocks, RFC 1950/1951): the bytes an export without compression is, for a step that sends an image of a given weight. */
|
|
556
|
+
export function gradientPng(width: number, height: number): Uint8Array {
|
|
557
|
+
const crcTable = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
|
|
558
|
+
const crc = (b: Uint8Array): number => { let c = 0xffffffff; for (const x of b) c = crcTable[(c ^ x) & 0xff]! ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
|
559
|
+
const chunk = (type: string, data: Uint8Array): Uint8Array => {
|
|
560
|
+
const out = new Uint8Array(12 + data.length);
|
|
561
|
+
const v = new DataView(out.buffer);
|
|
562
|
+
v.setUint32(0, data.length);
|
|
563
|
+
out.set([...type].map((c) => c.charCodeAt(0)), 4);
|
|
564
|
+
out.set(data, 8);
|
|
565
|
+
v.setUint32(8 + data.length, crc(out.subarray(4, 8 + data.length)));
|
|
566
|
+
return out;
|
|
567
|
+
};
|
|
568
|
+
const row = 1 + width * 3;
|
|
569
|
+
const raw = new Uint8Array(row * height);
|
|
570
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
571
|
+
const at = y * row + 1 + x * 3;
|
|
572
|
+
raw[at] = Math.floor((x * 255) / Math.max(1, width - 1)); raw[at + 1] = Math.floor((y * 255) / Math.max(1, height - 1)); raw[at + 2] = 128;
|
|
573
|
+
}
|
|
574
|
+
const blocks = Math.max(1, Math.ceil(raw.length / 65535));
|
|
575
|
+
const z = new Uint8Array(2 + raw.length + blocks * 5 + 4);
|
|
576
|
+
z[0] = 0x78; z[1] = 0x01;
|
|
577
|
+
let o = 2;
|
|
578
|
+
for (let i = 0; i < blocks; i++) {
|
|
579
|
+
const part = raw.subarray(i * 65535, Math.min(raw.length, (i + 1) * 65535));
|
|
580
|
+
z[o++] = i === blocks - 1 ? 1 : 0; z[o++] = part.length & 0xff; z[o++] = part.length >> 8; z[o++] = ~part.length & 0xff; z[o++] = (~part.length >> 8) & 0xff;
|
|
581
|
+
z.set(part, o); o += part.length;
|
|
582
|
+
}
|
|
583
|
+
let a = 1; let b = 0;
|
|
584
|
+
for (const byte of raw) { a = (a + byte) % 65521; b = (b + a) % 65521; }
|
|
585
|
+
new DataView(z.buffer).setUint32(o, ((b << 16) | a) >>> 0);
|
|
586
|
+
const ihdr = new Uint8Array(13);
|
|
587
|
+
new DataView(ihdr.buffer).setUint32(0, width); new DataView(ihdr.buffer).setUint32(4, height);
|
|
588
|
+
ihdr.set([8, 2, 0, 0, 0], 8);
|
|
589
|
+
const parts = [new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', z), chunk('IEND', new Uint8Array())];
|
|
590
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
591
|
+
let at = 0;
|
|
592
|
+
for (const p of parts) { out.set(p, at); at += p.length; }
|
|
593
|
+
return out;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Build JSON values before serialization: captured text is data, including quotes and braces, not JSON source. */
|
|
597
|
+
export function fillJson(value: unknown, captured: Captured, typed: Record<string, unknown> = {}, graphqlBody = false): unknown {
|
|
598
|
+
if (typeof value === 'string') {
|
|
599
|
+
const exact = /^\{([A-Za-z0-9_.]+)\}$/.exec(value);
|
|
600
|
+
if (exact && Object.hasOwn(typed, exact[1]!)) return typed[exact[1]!];
|
|
601
|
+
return fill(value, captured);
|
|
602
|
+
}
|
|
603
|
+
if (Array.isArray(value)) return value.map((item) => fillJson(item, captured, typed));
|
|
604
|
+
if (value !== null && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [fill(key, captured), graphqlBody && key === 'query' && typeof item === 'string' ? fill(item, captured, true) : fillJson(item, captured, typed)]));
|
|
605
|
+
return value;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The ECHO beat, as a pure function so its rule is testable on its own. Every scalar and every
|
|
609
|
+
* array of scalars the request sent must appear in the response. An object-valued field is
|
|
610
|
+
* compared field by field. A field the vendor is KNOWN to transform is named in `echoExcept`;
|
|
611
|
+
* anything else that vanishes is the defect this beat exists to find. */
|
|
612
|
+
export function echoFailures(sent: unknown, got: unknown, except: string[] = [], prefix = ''): string[] {
|
|
613
|
+
if (sent === null || typeof sent !== 'object' || Array.isArray(sent)) return [];
|
|
614
|
+
const out: string[] = [];
|
|
615
|
+
for (const [k, v] of Object.entries(sent as Record<string, unknown>)) {
|
|
616
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
617
|
+
if (except.includes(key) || v === undefined || v === null) continue;
|
|
618
|
+
const actual = at(got, key);
|
|
619
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) { out.push(...echoFailures(v, got, except, key)); continue; }
|
|
620
|
+
if (Array.isArray(v)) {
|
|
621
|
+
// an array of scalars must come back with the same members, in any shape the vendor wraps
|
|
622
|
+
// them in: github returns labels as [{name}], stripe returns them verbatim
|
|
623
|
+
const flat = flatten(actual);
|
|
624
|
+
for (const member of v) {
|
|
625
|
+
if (member === null || typeof member === 'object') continue; // nested objects: not this beat's job
|
|
626
|
+
if (!flat.includes(String(member))) out.push(`sent ${key} containing ${JSON.stringify(member)}, and it is not in the response (${JSON.stringify(actual)})`);
|
|
627
|
+
}
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (actual === undefined) { out.push(`sent ${key}=${JSON.stringify(v)}, and the response has no ${key}`); continue; }
|
|
631
|
+
// A vendor may EXPAND a scalar reference into the object it names — GitHub takes `head:
|
|
632
|
+
// "my-branch"` and answers `head: {label, ref, sha}`. That is the vendor being faithful, not
|
|
633
|
+
// dropping the field, so a scalar is satisfied by appearing anywhere inside the expansion.
|
|
634
|
+
if (actual !== null && typeof actual === 'object') {
|
|
635
|
+
if (!flatten(actual).includes(String(v))) out.push(`sent ${key}=${JSON.stringify(v)}, and the response expands ${key} into ${JSON.stringify(actual)}, which does not mention it`);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (String(actual) !== String(v)) out.push(`sent ${key}=${JSON.stringify(v)}, and the response says ${JSON.stringify(actual)}`);
|
|
639
|
+
}
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Every scalar anywhere inside a value, as strings — so `[{name:'bug'}]` answers for `'bug'`. */
|
|
644
|
+
function flatten(v: unknown): string[] {
|
|
645
|
+
if (v === null || v === undefined) return [];
|
|
646
|
+
if (Array.isArray(v)) return v.flatMap(flatten);
|
|
647
|
+
if (typeof v === 'object') return Object.values(v as Record<string, unknown>).flatMap(flatten);
|
|
648
|
+
return [String(v)];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ── COVERAGE ────────────────────────────────────────────────────────────────────────────────
|
|
652
|
+
// "Did the walk exercise every state?" is the completeness question a journey has to answer,
|
|
653
|
+
// and its unit is the pack's STATE-SIMULATION MODULE: the sources under `src/` that write the
|
|
654
|
+
// world's state (they call `applyTwinWrite`), minus pull and push (`-connector`),
|
|
655
|
+
// which are the reconciler's business and not a story's. That set is DERIVED, per pack, never
|
|
656
|
+
// hand-listed — github's is `github-twin.ts` and `github-git-http.ts`; the second stays at 0%
|
|
657
|
+
// for a story that never speaks git-over-http, and that is a true sentence about the story.
|
|
658
|
+
//
|
|
659
|
+
// The measure is line and function coverage of those files WHILE THE STORY RUNS. Lines, not
|
|
660
|
+
// the names of the transitions the walk emitted: a state bug lives inside a transition, in the
|
|
661
|
+
// branch that only runs when the thing was already closed — github's timeline forgot `closed`
|
|
662
|
+
// and `reopened` while every transition NAME was emitted — and only line coverage can see that
|
|
663
|
+
// branch. A never-run range is reported with the function it sits in, so the reader gets "the
|
|
664
|
+
// story never took the reopen branch of updateIssue", not a line number.
|
|
665
|
+
//
|
|
666
|
+
// Read it as "what has this story never done", never as a score to raise: a line running
|
|
667
|
+
// proves it was reached, not that what it did was right. Right is what the beats and the
|
|
668
|
+
// checks are for.
|
|
669
|
+
|
|
670
|
+
/** The state-simulation module of a pack, as repo-relative paths. */
|
|
671
|
+
export function stateModuleFiles(vendor: string, twinDir: string, fs: { readdirSync: (d: string) => string[]; readFileSync: (p: string, e: 'utf8') => string }): string[] {
|
|
672
|
+
const dir = `${twinDir}/${vendor}/src`;
|
|
673
|
+
const out: string[] = [];
|
|
674
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
675
|
+
if (!f.endsWith('.ts') || f.endsWith('.test.ts') || f.endsWith('.uitest.ts') || f.includes('-connector')) continue;
|
|
676
|
+
if (/\bapplyTwinWrite(Atomic)?\(/.test(fs.readFileSync(`${dir}/${f}`, 'utf8'))) out.push(`${vendor}/src/${f}`);
|
|
677
|
+
}
|
|
678
|
+
return out;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export type StateCoverage = {
|
|
682
|
+
file: string;
|
|
683
|
+
lines: { found: number; hit: number };
|
|
684
|
+
functions: { found: number; hit: number };
|
|
685
|
+
/** Never-run line ranges, each with the declaration it sits under (nearest preceding
|
|
686
|
+
* function, arrow or method in the source), so a gap reads as "the reopen branch of
|
|
687
|
+
* updateIssue" and not as a line number. */
|
|
688
|
+
never: Array<{ from: number; to: number; fn?: string }>;
|
|
689
|
+
/** The declarations that ran: those whose first line ran (a never-run function reads 0 there). With a never-run
|
|
690
|
+
* range inside, such a declaration ran in part. */
|
|
691
|
+
ran?: string[];
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
/** Where functions live in a TypeScript source — `function name(`, `const name = (…) =>`,
|
|
695
|
+
* `const name = async (`, ` name(args) {` methods — at any depth, each with the line its
|
|
696
|
+
* block ENDS on: the first later non-blank line indented no deeper than the declaration (that
|
|
697
|
+
* line is the closer when it opens with `}`, `)` or `]`, and is included; a `)` that goes on to
|
|
698
|
+
* open a block is a multi-line parameter list and the scan continues). A one-line arrow
|
|
699
|
+
* spans one line. The declaration a line sits under is the innermost span containing it —
|
|
700
|
+
* which is what makes the names honest in a file whose handlers declare small helpers
|
|
701
|
+
* inline: a never-run branch at the bottom of `applyRepoWrite` is reported under
|
|
702
|
+
* `applyRepoWrite`, not under the one-line `commentExists` declared above it. */
|
|
703
|
+
export function functionIndex(source: string): Array<{ line: number; end: number; name: string }> {
|
|
704
|
+
const lines = source.split('\n');
|
|
705
|
+
const indentOf = (t: string): number => t.length - t.trimStart().length;
|
|
706
|
+
const out: Array<{ line: number; end: number; name: string }> = [];
|
|
707
|
+
lines.forEach((text, i) => {
|
|
708
|
+
const m =
|
|
709
|
+
/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*[<(]/.exec(text) ??
|
|
710
|
+
/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::\s*[^=]+)?=>/.exec(text) ??
|
|
711
|
+
/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?function\b/.exec(text) ??
|
|
712
|
+
/^\s+(?:public\s+|private\s+|protected\s+|static\s+)*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::\s*[^{]+)?\{\s*$/.exec(text);
|
|
713
|
+
if (!m || ['if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'constructor'].includes(m[1]!)) return;
|
|
714
|
+
const indent = indentOf(text);
|
|
715
|
+
let end = i;
|
|
716
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
717
|
+
const t = lines[j]!;
|
|
718
|
+
if (t.trim() === '') continue;
|
|
719
|
+
if (indentOf(t) <= indent) {
|
|
720
|
+
// a `)` that goes on to open a block (`): Foo {`) is the end of a multi-line parameter
|
|
721
|
+
// list, still inside the declaration; a bare closer ends the span and is part of it
|
|
722
|
+
if (/^\s*[})\]]/.test(t) && /\{\s*$/.test(t)) { end = j; continue; }
|
|
723
|
+
end = /^\s*[})\]]/.test(t) ? j : end;
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
end = j;
|
|
727
|
+
}
|
|
728
|
+
out.push({ line: i + 1, end: end + 1, name: m[1]! });
|
|
729
|
+
});
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Read an lcov report (bun's `--coverage-reporter=lcov`) for exactly `files`, matched by
|
|
734
|
+
* path suffix so an absolute or a cwd-relative `SF:` both resolve. Function totals come from
|
|
735
|
+
* the report (`FNF`/`FNH`); names for never-run ranges come from `FN` records when the
|
|
736
|
+
* producer writes them and otherwise from the source (`readSource`), since bun writes none.
|
|
737
|
+
* A file the report does not mention was never loaded by the walk: it is reported at 0 of
|
|
738
|
+
* 0, which is its own finding (the story never reached that module at all). */
|
|
739
|
+
export function stateCoverage(lcov: string, files: string[], readSource: (file: string) => string | undefined = () => undefined): StateCoverage[] {
|
|
740
|
+
type Rec = { fns: Array<{ line: number; name: string }>; hits: Map<number, number>; fnf?: number; fnh?: number };
|
|
741
|
+
const records = new Map<string, Rec>();
|
|
742
|
+
let current: Rec | undefined;
|
|
743
|
+
for (const raw of lcov.split('\n')) {
|
|
744
|
+
const line = raw.trim();
|
|
745
|
+
if (line.startsWith('SF:')) { current = { fns: [], hits: new Map() }; records.set(line.slice(3), current); continue; }
|
|
746
|
+
if (!current) continue;
|
|
747
|
+
if (line.startsWith('FN:')) { const [l, ...name] = line.slice(3).split(','); current.fns.push({ line: Number(l), name: name.join(',') }); continue; }
|
|
748
|
+
if (line.startsWith('FNF:')) { current.fnf = Number(line.slice(4)); continue; }
|
|
749
|
+
if (line.startsWith('FNH:')) { current.fnh = Number(line.slice(4)); continue; }
|
|
750
|
+
if (line.startsWith('DA:')) { const [l, c] = line.slice(3).split(','); current.hits.set(Number(l), Number(c)); continue; }
|
|
751
|
+
if (line === 'end_of_record') current = undefined;
|
|
752
|
+
}
|
|
753
|
+
return files.map((file) => {
|
|
754
|
+
const key = [...records.keys()].find((k) => k === file || k.endsWith(`/${file}`));
|
|
755
|
+
const rec = key ? records.get(key)! : undefined;
|
|
756
|
+
if (!rec) return { file, lines: { found: 0, hit: 0 }, functions: { found: 0, hit: 0 }, never: [] };
|
|
757
|
+
const lines = [...rec.hits.entries()].sort((a, b) => a[0] - b[0]);
|
|
758
|
+
const source = rec.fns.length === 0 ? readSource(file) : undefined;
|
|
759
|
+
const index: Array<{ line: number; end: number; name: string }> = rec.fns.length > 0
|
|
760
|
+
? rec.fns.map((f) => ({ ...f, end: Number.MAX_SAFE_INTEGER }))
|
|
761
|
+
: source !== undefined ? functionIndex(source) : [];
|
|
762
|
+
// innermost span containing the line: the latest start among spans that still contain it
|
|
763
|
+
const enclosing = (l: number): string | undefined => { let name: string | undefined; for (const f of index) { if (f.line > l) break; if (l <= f.end) name = f.name; } return name || undefined; };
|
|
764
|
+
const never: StateCoverage['never'] = [];
|
|
765
|
+
// whether a declaration ran (life-coverage.ts's header): bun reads a never-run `function`'s first line 0, but a
|
|
766
|
+
// `const f = (…) =>` line runs when the module loads, and the last line of a block that never ran can read as run
|
|
767
|
+
// once; so a declaration ran when it is a `function` whose first line ran, or when a line of its body before its
|
|
768
|
+
// last one ran
|
|
769
|
+
const sourceLines = (source ?? readSource(file) ?? '').split('\n');
|
|
770
|
+
const ran = new Set(index.filter((f) => {
|
|
771
|
+
if (/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\b/.test(sourceLines[f.line - 1] ?? '') && (rec.hits.get(f.line) ?? 0) > 0) return true;
|
|
772
|
+
const body = [...rec.hits.entries()].filter(([l]) => l > f.line && l <= f.end).sort((x, y) => x[0] - y[0]);
|
|
773
|
+
return body.slice(0, -1).some(([, c]) => c > 0);
|
|
774
|
+
}).map((f) => f.name));
|
|
775
|
+
for (const [l, c] of lines) {
|
|
776
|
+
if (c > 0) continue;
|
|
777
|
+
const last = never[never.length - 1];
|
|
778
|
+
if (last && last.to === l - 1 && last.fn === enclosing(l)) last.to = l; else never.push({ from: l, to: l, fn: enclosing(l) });
|
|
779
|
+
}
|
|
780
|
+
return {
|
|
781
|
+
file,
|
|
782
|
+
lines: { found: lines.length, hit: lines.filter(([, c]) => c > 0).length },
|
|
783
|
+
functions: { found: rec.fnf ?? rec.fns.length, hit: rec.fnh ?? 0 },
|
|
784
|
+
never,
|
|
785
|
+
ran: [...ran].sort(),
|
|
786
|
+
};
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** A client asking whether something exists before it makes it (a CLI's `get` answering 404, then its create): the 404
|
|
791
|
+
* is the answer it branches on, not a refusal. A GET answering 404 is that probe when a later successful write of the
|
|
792
|
+
* same sitting makes its subject: at the same path, or at its collection naming the subject exactly (a query value, or
|
|
793
|
+
* a top-level string of its JSON body). */
|
|
794
|
+
export function existenceProbe(walked: Array<{ method: string; path: string; body: string; status: number; refused: boolean; sitting: number }>, n: number): boolean {
|
|
795
|
+
const w = walked[n]!;
|
|
796
|
+
if (w.method !== 'GET' || w.status !== 404) return false;
|
|
797
|
+
const base = (p: string): string => p.split(/[?\s[]/)[0]!.replace(/\/$/, '');
|
|
798
|
+
const at = base(w.path);
|
|
799
|
+
const name = decodeURIComponent(at.split('/').pop() ?? '');
|
|
800
|
+
const parent = at.slice(0, at.lastIndexOf('/'));
|
|
801
|
+
const names = (x: { path: string; body: string }): string[] => {
|
|
802
|
+
const query = /\?([^\s[]*)/.exec(x.path)?.[1] ?? '';
|
|
803
|
+
const out = [...new URLSearchParams(query).values()];
|
|
804
|
+
try { const b = parseExactJson(x.body); if (b && typeof b === 'object' && !Array.isArray(b)) for (const v of Object.values(b)) if (typeof v === 'string') out.push(v); } catch { /* not JSON */ }
|
|
805
|
+
return out;
|
|
806
|
+
};
|
|
807
|
+
return walked.slice(n + 1).some((x) => x.sitting === w.sitting && !x.refused && /^(POST|PUT)$/.test(x.method)
|
|
808
|
+
&& (base(x.path) === at || (base(x.path) === parent && names(x).includes(name))));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** The `Authorization` header an OAuth 1.0a client sends (RFC 5849 §3.5.1), signed over the request (§3.4): its protocol
|
|
812
|
+
* parameters, the URL's query and a form-encoded body. The nonce and timestamp are drawn from the request itself and
|
|
813
|
+
* the instant given, so a walk sends the same bytes each time. */
|
|
814
|
+
export function oauth1Authorization(o: NonNullable<JourneyStep['oauth1']>, method: string, url: string, body: string | undefined, contentType: string | undefined, at: number): string {
|
|
815
|
+
const u = new URL(url);
|
|
816
|
+
const protocol: Array<[string, string]> = [
|
|
817
|
+
['oauth_consumer_key', o.consumerKey],
|
|
818
|
+
['oauth_nonce', digest('sha256', `${method} ${url} ${body ?? ''} ${at}`).slice(0, 32)],
|
|
819
|
+
['oauth_signature_method', 'HMAC-SHA1'],
|
|
820
|
+
['oauth_timestamp', String(Math.floor(at / 1000))],
|
|
821
|
+
...(o.token ? [['oauth_token', o.token] as [string, string]] : []),
|
|
822
|
+
...(o.callback ? [['oauth_callback', o.callback] as [string, string]] : []),
|
|
823
|
+
...(o.verifier ? [['oauth_verifier', o.verifier] as [string, string]] : []),
|
|
824
|
+
['oauth_version', '1.0'],
|
|
825
|
+
];
|
|
826
|
+
const query = [...u.searchParams] as Array<[string, string]>;
|
|
827
|
+
const form = body !== undefined && /application\/x-www-form-urlencoded/i.test(contentType ?? '') ? ([...new URLSearchParams(body)] as Array<[string, string]>) : [];
|
|
828
|
+
const base = oauth1BaseString(method, `${u.protocol}//${u.host.toLowerCase()}${u.pathname}`, [...protocol, ...query, ...form]);
|
|
829
|
+
const signed = [...protocol, ['oauth_signature', oauth1Signature(base, o.consumerSecret, o.tokenSecret ?? '')] as [string, string]];
|
|
830
|
+
return `OAuth ${signed.map(([k, v]) => `${oauth1Encode(k)}="${oauth1Encode(v)}"`).join(', ')}`;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** The `Authorization` header of a step's own JWT (`jwt`): RS256 with the PEM it names, over its claims, captures filled.
|
|
834
|
+
* A PEM is filled as text (its line breaks would not survive a JSON round trip); the claims through JSON, so a claim
|
|
835
|
+
* that is exactly one capture of a number stays a number. */
|
|
836
|
+
export function jwtAuthorization(o: NonNullable<JourneyStep['jwt']>, captured: Captured): string {
|
|
837
|
+
const claims = parseExactJson(fill(JSON.stringify(o.claims), captured)) as Record<string, unknown>;
|
|
838
|
+
const wallNow = Math.floor(Date.now() / 1000);
|
|
839
|
+
const now = typeof claims.iat === 'number' || isExactIntegerValue(claims.iat) ? claims.iat as number | string : wallNow;
|
|
840
|
+
// Supply defaults as claims, so an exact NumericDate also governs nbf and the default expiry.
|
|
841
|
+
const exp = typeof now === 'string' ? exactInteger(String(BigInt(now) + 60n)) : now + 60;
|
|
842
|
+
return `Bearer ${jwtSign({ iat: now, nbf: now, exp, ...claims }, { alg: 'RS256', privatePem: fill(o.privatePem, captured) }, { now: wallNow })}`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** The headers of a step's SigV4 signature (`sigv4`), captures filled, over the URL, headers and body it sends. */
|
|
846
|
+
export function sigv4Headers(o: NonNullable<JourneyStep['sigv4']>, captured: Captured, method: string, url: string, headers: Record<string, string>, body: string | Uint8Array | undefined, at: number): Record<string, string> {
|
|
847
|
+
const f = parseExactJson(fill(JSON.stringify(o), captured)) as NonNullable<JourneyStep['sigv4']>;
|
|
848
|
+
return signSigV4({ method, url, headers, ...(body !== undefined ? { body } : {}), keyId: f.accessKeyId, secret: f.secretAccessKey, region: f.region, service: f.service, at: new Date(at) });
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** A step's presigned path (`presign`) signed with its key, captures filled: a header the URL names among its signed
|
|
852
|
+
* ones is signed with the value the step sends (its `headers`), as the client that presigned it bound it. */
|
|
853
|
+
export function presignedPath(o: NonNullable<JourneyStep['presign']>, captured: Captured, method: string, path: string, host: string, headers: Record<string, string> = {}): string {
|
|
854
|
+
const f = parseExactJson(fill(JSON.stringify(o), captured)) as NonNullable<JourneyStep['presign']>;
|
|
855
|
+
const absolute = /^https?:\/\//.test(path);
|
|
856
|
+
const signed = presignSigV4({ method, url: absolute ? path : `http://${host}${path}`, keyId: f.accessKeyId, secret: f.secretAccessKey, headers });
|
|
857
|
+
return absolute ? signed : signed.replace(/^https?:\/\/[^/]+/, '');
|
|
858
|
+
}
|