@volter/twin-standard 1.0.149 → 1.0.151
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 -3
- package/client-sdks/bun.lock +0 -32
- package/client-sdks/package.json +0 -2
- package/dist/client-sdks/bun.lock +0 -32
- package/dist/client-sdks/package.json +0 -2
- package/dist/src/assessment.d.ts +5 -4
- package/dist/src/assessment.js +2 -1
- package/dist/src/cli.js +21 -6
- package/dist/src/clients/harness.d.ts +3 -1
- package/dist/src/clients/harness.js +4 -2
- package/dist/src/clients/jira.d.ts +2 -0
- package/dist/src/clients/jira.js +71 -0
- package/dist/src/clients/polar.d.ts +2 -0
- package/dist/src/clients/polar.js +73 -0
- package/dist/src/clients/resend.js +38 -0
- package/dist/src/clients/run.js +1 -1
- package/dist/src/clients/sentry.js +1 -26
- package/dist/src/clients/smtp.d.ts +2 -0
- package/dist/src/clients/smtp.js +54 -0
- package/dist/src/clients/stripe.js +1 -1
- package/dist/src/clients/volteridentity.js +4 -3
- package/dist/src/clients/x.d.ts +2 -0
- package/dist/src/clients/x.js +64 -0
- package/dist/src/conformance.d.ts +4 -1
- package/dist/src/conformance.js +256 -6
- package/dist/src/consumer.js +5 -1
- package/dist/src/create-pack.d.ts +5 -0
- package/dist/src/create-pack.js +31 -3
- package/dist/src/protocol-3.js +30 -2
- package/dist/src/spec-ir.d.ts +2 -0
- package/dist/src/spec-ir.js +43 -0
- package/dist/src/tools/judge-brief.d.ts +1 -0
- package/dist/src/tools/judge-brief.js +67 -0
- package/dist/src/tools/life-coverage-walk.d.ts +2 -0
- package/dist/src/tools/life-coverage-walk.js +220 -0
- package/dist/src/tools/life-coverage.d.ts +2 -0
- package/dist/src/tools/life-coverage.js +271 -0
- package/dist/src/tools/packs-root.d.ts +9 -0
- package/dist/src/tools/packs-root.js +17 -0
- package/dist/src/tools/run-tool.d.ts +1 -0
- package/dist/src/tools/run-tool.js +18 -0
- package/dist/src/tools/score-pack.d.ts +2 -0
- package/dist/src/tools/score-pack.js +264 -0
- package/dist/src/tools/vendor-examples.d.ts +2 -0
- package/dist/src/tools/vendor-examples.js +1493 -0
- package/dist/src/verdict.js +1 -1
- package/dist/src/walk/behavior-journey.d.ts +6 -0
- package/dist/src/walk/behavior-journey.js +8 -6
- package/dist/src/walk/journey-kit.d.ts +2 -0
- package/dist/src/walk/journey-kit.js +6 -2
- package/dist/src/walk/rendered-scalar.d.ts +3 -0
- package/dist/src/walk/rendered-scalar.js +23 -0
- package/dist/src/walk/sse-example.js +45 -0
- package/package.json +2 -2
- package/src/assessment.ts +2 -1
- package/src/cli.ts +15 -2
- package/src/clients/harness.ts +4 -2
- package/src/clients/jira.ts +73 -0
- package/src/clients/polar.ts +73 -0
- package/src/clients/resend.ts +32 -0
- package/src/clients/run.ts +2 -2
- package/src/clients/sentry.ts +1 -25
- package/src/clients/smtp.ts +49 -0
- package/src/clients/stripe.ts +1 -1
- package/src/clients/volteridentity.ts +4 -3
- package/src/clients/x.ts +66 -0
- package/src/conformance.ts +192 -8
- package/src/consumer.ts +5 -1
- package/src/create-pack.ts +27 -3
- package/src/protocol-3.ts +24 -2
- package/src/spec-ir.ts +31 -0
- package/src/tools/judge-brief.ts +63 -0
- package/src/tools/life-coverage-walk.ts +173 -0
- package/src/tools/life-coverage.ts +237 -0
- package/src/tools/packs-root.ts +21 -0
- package/src/tools/run-tool.ts +10 -0
- package/src/tools/score-pack.ts +244 -0
- package/src/tools/vendor-examples.ts +1250 -0
- package/src/verdict.ts +1 -1
- package/src/walk/behavior-journey.ts +6 -2
- package/src/walk/journey-kit.ts +7 -2
- package/src/walk/rendered-scalar.ts +17 -0
- package/src/walk/sse-example.ts +37 -0
package/src/clients/x.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// twitter-api-v2 1.29, unmodified (one of the SDKs the x descriptor's adoption names),
|
|
2
|
+
// with an OAuth 2.0 user access token made at the pack's doors for an account made there, as a person's sign-in at
|
|
3
|
+
// x.com and an App's authorize leg make them. The client sends through node's https with the agent it is given, so the
|
|
4
|
+
// World's routed agent is its httpAgent.
|
|
5
|
+
import type { ClientCase, ClientContext } from './harness.ts';
|
|
6
|
+
|
|
7
|
+
const CREATE = 'https://docs.x.com/x-api/posts/create-post';
|
|
8
|
+
const ME = 'https://docs.x.com/x-api/users/get-my-user';
|
|
9
|
+
const README = 'https://www.npmjs.com/package/twitter-api-v2/v/1.29.1';
|
|
10
|
+
|
|
11
|
+
/** An account named `username` and an access token for it with `scopes`; the client on that token. */
|
|
12
|
+
async function signedIn(c: ClientContext, username: string, scopes: string[], token = `case-${username}-token`) {
|
|
13
|
+
const door = async (path: string, body: unknown) => {
|
|
14
|
+
const answer = await c.fetch(`https://${c.host}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
15
|
+
if (answer.status >= 300) throw new Error(`${path} answered ${answer.status}`);
|
|
16
|
+
return ((await answer.json()) as { data: Record<string, string> }).data;
|
|
17
|
+
};
|
|
18
|
+
const account = await door('/_twin/accounts', { username, name: 'Case Account' });
|
|
19
|
+
await door('/_twin/tokens', { token, account_id: account.id, scopes });
|
|
20
|
+
const { TwitterApi } = await import('twitter-api-v2');
|
|
21
|
+
return { client: new TwitterApi(token, { httpAgent: c.agent }), account };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const cases: ClientCase[] = [
|
|
25
|
+
{
|
|
26
|
+
id: 'post-and-read-back',
|
|
27
|
+
asks: "a post made through the SDK answers its id and text, and reads back by id with that text, its author the signed-in account",
|
|
28
|
+
source: `${CREATE} "Create Posts"`,
|
|
29
|
+
async run(c) {
|
|
30
|
+
const { client, account } = await signedIn(c, 'case_poster', ['tweet.read', 'tweet.write', 'users.read']);
|
|
31
|
+
const made = await client.v2.tweet('Shipping the case today');
|
|
32
|
+
const failures: string[] = [];
|
|
33
|
+
if (!made.data?.id || made.data.text !== 'Shipping the case today') failures.push(`tweet answered ${JSON.stringify(made)}`);
|
|
34
|
+
const read = await client.v2.singleTweet(made.data.id, { 'tweet.fields': ['author_id'] });
|
|
35
|
+
if (read.data?.text !== 'Shipping the case today') failures.push(`the post reads back text ${JSON.stringify(read.data?.text)}`);
|
|
36
|
+
if (read.data?.author_id !== account.id) failures.push(`the post reads back author ${String(read.data?.author_id)}, not ${account.id}`);
|
|
37
|
+
return failures;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'me-is-the-signed-in-account',
|
|
42
|
+
asks: "v2.me answers the account the token was issued to, by its id and username (an app's account check after sign-in)",
|
|
43
|
+
source: `${ME} "Get Users Me"`,
|
|
44
|
+
async run(c) {
|
|
45
|
+
const { client, account } = await signedIn(c, 'case_me', ['tweet.read', 'users.read']);
|
|
46
|
+
const me = await client.v2.me();
|
|
47
|
+
return me.data?.id === account.id && me.data.username === 'case_me' ? [] : [`me answered ${JSON.stringify(me)}`];
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'unknown-token-api-response-error',
|
|
52
|
+
asks: 'a token X never issued is refused, and the client throws its ApiResponseError carrying the 401',
|
|
53
|
+
source: `${README} "Typed errors, meaningful error messages, error enumerations for both v1.1 and v2"`,
|
|
54
|
+
async run(c) {
|
|
55
|
+
const { TwitterApi, ApiResponseError } = await import('twitter-api-v2');
|
|
56
|
+
const client = new TwitterApi('case-never-issued-token', { httpAgent: c.agent });
|
|
57
|
+
try {
|
|
58
|
+
await client.v2.me();
|
|
59
|
+
return ['the never-issued token was accepted'];
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!(error instanceof ApiResponseError)) return [`the refusal is not an ApiResponseError: ${String(error).slice(0, 160)}`];
|
|
62
|
+
return error.code === 401 ? [] : [`the refusal's code is ${error.code}, not 401`];
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
];
|
package/src/conformance.ts
CHANGED
|
@@ -317,6 +317,131 @@ async function standalone(pack: string): Promise<string[]> {
|
|
|
317
317
|
return out;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
type SweptOperation = {
|
|
321
|
+
id: string; method: string; path: string; basePath?: string; pathParams?: string[];
|
|
322
|
+
query?: Array<{ name: string; type?: string; required?: boolean }>; body?: Array<{ name: string; required?: boolean }>;
|
|
323
|
+
bodyEncoding?: string; headers?: Record<string, string | null>; discriminator?: Record<string, string | null>;
|
|
324
|
+
responseMedia?: Record<string, string[]>; answers?: unknown;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** The request an application's first call of an operation sends when it knows nothing yet: every path parameter, every
|
|
328
|
+
* required query parameter and every header the operation is routed by holding a placeholder, its discriminator's
|
|
329
|
+
* values, and an empty body in the operation's encoding. What it measures is the twin's answer to a call it did not
|
|
330
|
+
* script: a refusal in the vendor's form, or a result in the vendor's shape, and never a crash, a hang or a row. */
|
|
331
|
+
function sweepRequest(op: SweptOperation, basePath: string, host: string, credential: Record<string, string>): Request {
|
|
332
|
+
const fill = (name: string, type?: string): string => (type === 'integer' || type === 'number' ? '1' : type === 'boolean' ? 'true' : `conformance-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`);
|
|
333
|
+
const path = `${op.basePath ?? basePath}${op.path}`.replace(/\{([^}+]+)\+?\}/g, (_m, name: string) => fill(name));
|
|
334
|
+
const url = new URL(`https://${host}${path}`);
|
|
335
|
+
for (const q of op.query ?? []) if (q.required) url.searchParams.set(q.name, fill(q.name, q.type));
|
|
336
|
+
for (const [name, value] of Object.entries(op.discriminator ?? {})) url.searchParams.set(name, value ?? fill(name));
|
|
337
|
+
const headers = new Headers(credential);
|
|
338
|
+
for (const [name, value] of Object.entries(op.headers ?? {})) headers.set(name, value ?? fill(name));
|
|
339
|
+
const method = op.method.toUpperCase();
|
|
340
|
+
let body: string | undefined;
|
|
341
|
+
if (op.bodyEncoding === 'form') { headers.set('content-type', 'application/x-www-form-urlencoded'); body = ''; }
|
|
342
|
+
else if (op.bodyEncoding === 'xml') { headers.set('content-type', 'application/xml'); body = ''; }
|
|
343
|
+
else if (op.bodyEncoding === 'json' || op.bodyEncoding === 'multipart') { headers.set('content-type', 'application/json'); body = '{}'; }
|
|
344
|
+
return new Request(url, { method, headers, ...(body !== undefined && !['GET', 'HEAD'].includes(method) ? { body } : {}) });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Every operation the pack serves, called over HTTP as an application calls it, against its server.ts served as a World
|
|
348
|
+
* runs it: once plainly and once with a scenario loaded (an empty one, as a World that scripts nothing loads it). Each
|
|
349
|
+
* answers within 10 s, never with a 500 the spec does not declare, never with a field of the twin's own (`_`
|
|
350
|
+
* bookkeeping, the kernel's `updatedAt`) and, answered successfully, in the shape the spec gives the operation; and every
|
|
351
|
+
* refresh list a fresh account can read (one with no parameter in its path) answers it successfully, so a World rooted
|
|
352
|
+
* at this twin reads its resources back. Escaped before: the scenario hang (a served pack given a scenario answered
|
|
353
|
+
* nothing), leaked kernel fields in answers no life step read, and refresh scopes on operations the pack answered with
|
|
354
|
+
* the gap. The life walks what a customer does; this calls what the pack declares it serves. */
|
|
355
|
+
async function surfaceSwept(pack: string, scratch: string): Promise<{ failures: string[]; note: string }> {
|
|
356
|
+
const out: string[] = [];
|
|
357
|
+
const descriptor = ((await manifestOf(pack)) as { descriptor?: { hosts?: Array<{ host?: string }>; credentialDoor?: { path: string; body: Record<string, unknown>; fill: Record<string, string> } } }).descriptor;
|
|
358
|
+
const fetchOf = ((await import(join(pack, 'src', 'fetch.ts'))) as Record<string, unknown>)[fetchName(pack)] as (() => { owners?: () => Owners; ownersByUnit?: () => Record<string, Owners> }) | undefined;
|
|
359
|
+
const root = fetchOf?.();
|
|
360
|
+
const byUnit = root?.ownersByUnit?.() ?? { '': root?.owners?.() ?? {} };
|
|
361
|
+
const { shapeChecker } = await import('./walk/shape.ts');
|
|
362
|
+
const { leaked } = await import('./walk/behavior-journey.ts');
|
|
363
|
+
const shape = await shapeChecker(pack);
|
|
364
|
+
type Unit = { name: string; host: string; basePath: string; ops: SweptOperation[]; refreshLists: Array<{ resource: string; id: string }>; auth?: { header: string; scheme: string } };
|
|
365
|
+
const units: Unit[] = [];
|
|
366
|
+
const vendorFields = new Set<string>();
|
|
367
|
+
for (const unit of unitsIn(pack).filter(hasOwnSurface)) {
|
|
368
|
+
const surface = readJson(join(unit, 'src', 'generated', 'surface.gen.json')) as { format?: string; basePath?: string; operations?: SweptOperation[]; resources?: Array<{ fields: Array<{ name: string }> }> } | undefined;
|
|
369
|
+
for (const r of surface?.resources ?? []) for (const f of r.fields) vendorFields.add(f.name);
|
|
370
|
+
// a line protocol and a command table ride in no path of their own: the life's line steps and the examples read them
|
|
371
|
+
if (!surface?.operations || ['command-reply', 'command-table'].includes(String(surface.format))) continue;
|
|
372
|
+
const m = (await manifestOf(unit)) as Manifest & { hosts?: Array<{ host?: string }>; auth?: { header: string; scheme: string } };
|
|
373
|
+
const host = m.hosts?.find((h) => h.host)?.host ?? descriptor?.hosts?.find((h) => h.host)?.host;
|
|
374
|
+
if (!host) { out.push(`${nameOf(pack, unit)}: no host is named (its manifest's hosts or the descriptor's) for an application to call it at`); continue; }
|
|
375
|
+
const owners = byUnit[unit === pack ? '' : basename(unit)] ?? {};
|
|
376
|
+
const refreshLists = Object.entries(m.resources ?? {}).flatMap(([resource, decl]) => (decl.refresh?.list ? [{ resource, id: decl.refresh.list }] : []));
|
|
377
|
+
units.push({ name: nameOf(pack, unit), host, basePath: surface.basePath ?? '', ops: surface.operations.filter((o) => owners[o.id] !== undefined && owners[o.id] !== 'gap'), refreshLists, ...(m.auth ? { auth: m.auth } : {}) });
|
|
378
|
+
}
|
|
379
|
+
if (!units.length) return { failures: out, note: 'no unit serves an operation on a path of its own' };
|
|
380
|
+
const tally = { calls: 0, ok: 0, refused: 0 };
|
|
381
|
+
const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env;
|
|
382
|
+
const scenarioPath = join(scratch, 'surface-scenario.json');
|
|
383
|
+
writeFileSync(scenarioPath, '{"handlers":[]}');
|
|
384
|
+
for (const mode of ['plain', 'scenario'] as const) {
|
|
385
|
+
const before = env.VOLTER_WORLD_BOOT_ID;
|
|
386
|
+
env.VOLTER_WORLD_BOOT_ID = 'conformance';
|
|
387
|
+
let server: Served;
|
|
388
|
+
try { server = await serve(pack, { root: join(scratch, `surface-${mode}`), ...(mode === 'scenario' ? { scenarioPath } : {}) }); } finally { if (before === undefined) delete env.VOLTER_WORLD_BOOT_ID; else env.VOLTER_WORLD_BOOT_ID = before; }
|
|
389
|
+
const http = servedHttpFetch(server.inspectPort ?? server.port);
|
|
390
|
+
const send = async (request: Request): Promise<Response | 'timeout' | Error> => {
|
|
391
|
+
await server.managedDatabaseUrl?.();
|
|
392
|
+
const abort = new AbortController();
|
|
393
|
+
try { return await within(10_000, http(request, { signal: abort.signal })); } catch (error) { return error as Error; } finally { abort.abort(); }
|
|
394
|
+
};
|
|
395
|
+
try {
|
|
396
|
+
// the account an application holds: the key the credential door issues, sent as the vendor reads its credential
|
|
397
|
+
let issued: string | undefined;
|
|
398
|
+
if (descriptor?.credentialDoor) {
|
|
399
|
+
const door = descriptor.credentialDoor;
|
|
400
|
+
const answer = await send(new Request(`https://${descriptor.hosts?.find((h) => h.host)?.host ?? units[0]!.host}${door.path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(door.body) }));
|
|
401
|
+
const fields = answer instanceof Response ? ((await answer.json().catch(() => ({}))) as Record<string, unknown>) : {};
|
|
402
|
+
issued = Object.values(door.fill).map((f) => fields[f]).find((v): v is string => typeof v === 'string');
|
|
403
|
+
}
|
|
404
|
+
const said = mode === 'scenario' ? ' with a scenario loaded' : '';
|
|
405
|
+
for (const u of units) {
|
|
406
|
+
// a unit whose manifest declares no `auth` checks its key in its own front: the key goes as most vendors read
|
|
407
|
+
// one, a bearer token, and a refusal of it is no finding of the refresh below
|
|
408
|
+
const auth = u.auth ?? { header: 'authorization', scheme: 'Bearer' };
|
|
409
|
+
const credential: Record<string, string> = issued ? { [auth.header]: auth.scheme ? `${auth.scheme} ${issued}` : issued } : {};
|
|
410
|
+
const answered = new Map<string, number>();
|
|
411
|
+
for (const op of u.ops) {
|
|
412
|
+
const request = sweepRequest(op, u.basePath, u.host, credential);
|
|
413
|
+
const where = `${u.name}: ${op.id} (${request.method} ${new URL(request.url).pathname})`;
|
|
414
|
+
const res = await send(request);
|
|
415
|
+
if (res === 'timeout') { out.push(`${where} answered nothing in 10 s${said}`); continue; }
|
|
416
|
+
if (res instanceof Error) { out.push(`${where}: the connection failed${said} (${res.message})`); continue; }
|
|
417
|
+
answered.set(op.id, res.status);
|
|
418
|
+
if (mode === 'plain') { tally.calls++; if (res.status < 300) tally.ok++; else tally.refused++; }
|
|
419
|
+
const text = await res.text();
|
|
420
|
+
if (res.status === 500 && !op.responseMedia?.['500'] && !op.responseMedia?.['5XX']) { out.push(`${where} answered 500${said}, which the spec does not declare: ${text.slice(0, 160)}`); continue; }
|
|
421
|
+
if (mode === 'scenario') continue;
|
|
422
|
+
const type = res.headers.get('content-type') ?? '';
|
|
423
|
+
let body: unknown;
|
|
424
|
+
if (/json/.test(type)) { try { body = JSON.parse(text); } catch { body = undefined; } }
|
|
425
|
+
for (const key of leaked(body, vendorFields, '', !op.answers || res.status >= 300)) out.push(`${where} answers the twin's own field ${key}`);
|
|
426
|
+
if (res.status >= 200 && res.status < 300 && shape) {
|
|
427
|
+
const hit = shape.operationOf(request.method, request.url, Object.fromEntries(request.headers));
|
|
428
|
+
if (hit) for (const detail of shape.failures(hit.op, body, type, request.headers.get('accept') ?? undefined, res.status, { method: request.method, responseText: text })) out.push(`${where}: ${detail}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (mode === 'plain') for (const r of u.refreshLists) {
|
|
432
|
+
const op = u.ops.find((o) => o.id === r.id);
|
|
433
|
+
if (!op || op.path.includes('{') || (op.basePath ?? '').includes('{')) continue;
|
|
434
|
+
const status = answered.get(r.id);
|
|
435
|
+
if (status !== undefined && (status === 401 || status === 403) && (!u.auth || !issued)) continue;
|
|
436
|
+
if (status !== undefined && (status < 200 || status >= 300)) out.push(`${u.name}: ${r.resource}'s refresh lists by ${r.id}, which answered a fresh account ${status}: a World rooted at this twin reads nothing back`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
} finally { await server.stop(); }
|
|
440
|
+
}
|
|
441
|
+
// one defect repeated over a surface reads as one: the first 40 findings, and how many more
|
|
442
|
+
return { failures: out.length > 40 ? [...out.slice(0, 40), `… ${out.length - 40} more`] : out, note: `${tally.calls} served operations called, twice each: ${tally.ok} answered successfully, ${tally.refused} refused, unscripted` };
|
|
443
|
+
}
|
|
444
|
+
|
|
320
445
|
/** A line protocol's connection over TCP, in the walk's stream shape. A line drew its replies when bytes arrived and then
|
|
321
446
|
* 50 ms passed with no more (a multi-line reply arrives in pieces), or when 500 ms passed with none (a line that draws no
|
|
322
447
|
* reply: a message's body before its end); 10 s bounds a server that keeps writing. The windows are timing, not protocol:
|
|
@@ -376,7 +501,9 @@ async function lifeServed(pack: string): Promise<string[]> {
|
|
|
376
501
|
socket: (url: string, headers: Record<string, string>) => new WebSocket(`ws://127.0.0.1:${port}${url}`, { headers } as unknown as string[]),
|
|
377
502
|
// a line connection opened as an application's client opens it: TCP to the protocol listener (the primary port)
|
|
378
503
|
stream: lineConnection(server.port),
|
|
379
|
-
|
|
504
|
+
// bounded: Bun's server.stop(true) never settles once a WebSocket peer has closed under it (discord's life, Bun
|
|
505
|
+
// 1.3.11: the walk ended and the stop spun at full CPU for 45 minutes), so the check waits 5 s and goes on
|
|
506
|
+
stop: async () => { await within(5000, Promise.resolve(server.stop())); },
|
|
380
507
|
};
|
|
381
508
|
};
|
|
382
509
|
const world = inMemoryWorld(`conform-${basename(pack)}`);
|
|
@@ -406,10 +533,12 @@ async function clientCases(pack: string, scratch: string): Promise<{ failures: s
|
|
|
406
533
|
? { failures: [`${vendor} names npm clients (${sdks.join(', ')}) and has no client cases: add packages/twin-standard/src/clients/${vendor}.ts, the SDK driving the pack as an application does, or the descriptor's clientCasesNone saying why none is owed`] }
|
|
407
534
|
: { failures: [], note: `${vendor} declares no SDK of its own` };
|
|
408
535
|
}
|
|
409
|
-
const descriptor = ((await manifestOf(pack)) as { descriptor?: { hosts?: Array<{ host?: string; suffix?: string }>; credentialDoor?: { path: string; body: Record<string, unknown>; fill: Record<string, string> } } }).descriptor;
|
|
536
|
+
const descriptor = ((await manifestOf(pack)) as { descriptor?: { hosts?: Array<{ host?: string; suffix?: string }>; hostsNone?: string; credentialDoor?: { path: string; body: Record<string, unknown>; fill: Record<string, string> } } }).descriptor;
|
|
410
537
|
// the first host that names one: a descriptor may lead with a suffix (a region's hosts under one domain), which no
|
|
411
538
|
// request addresses; the case's client reaches whatever host its issued credentials name
|
|
412
|
-
|
|
539
|
+
// a raw protocol's pack (hostsNone: SMTP, whose relay is whatever host the operator names) is reached by its TCP
|
|
540
|
+
// connection alone; its client names a stand-in host the connection never resolves
|
|
541
|
+
const host = descriptor?.hosts?.find((h) => h.host)?.host ?? (descriptor?.hostsNone ? 'relay.world.test' : undefined);
|
|
413
542
|
if (!host) return { failures: ['the descriptor names no host for its client to address'] };
|
|
414
543
|
const scenarioPath = join(scratch, 'client-scenario.json');
|
|
415
544
|
writeFileSync(scenarioPath, '{"handlers":[]}');
|
|
@@ -423,11 +552,27 @@ async function clientCases(pack: string, scratch: string): Promise<{ failures: s
|
|
|
423
552
|
const issued: Record<string, string> = {};
|
|
424
553
|
const door = descriptor?.credentialDoor;
|
|
425
554
|
if (door) {
|
|
426
|
-
|
|
555
|
+
// a door that answers nothing is the pack's failure, never conform's hang (a pack served with a scenario that answered
|
|
556
|
+
// nothing once held this request open for good)
|
|
557
|
+
const abort = new AbortController();
|
|
558
|
+
const asked = await within(10_000, fetch(`http://127.0.0.1:${server.port}${door.path}`, { method: 'POST', headers: { host, 'content-type': 'application/json' }, body: JSON.stringify(door.body), signal: abort.signal }).then((r) => r.json().catch(() => ({}))));
|
|
559
|
+
abort.abort();
|
|
560
|
+
if (asked === 'timeout') return { failures: [`the credential door (POST ${door.path}) answered nothing in 10 s, served with a scenario loaded`] };
|
|
561
|
+
const answer = asked as Record<string, unknown>;
|
|
427
562
|
for (const [name, field] of Object.entries(door.fill)) if (answer[field] !== undefined) issued[name] = String(answer[field]);
|
|
428
563
|
}
|
|
429
564
|
const job = join(scratch, 'client-job.json');
|
|
430
|
-
|
|
565
|
+
// a release carries its facts (generated/pack-facts.json, `twin-standard facts`); a source checkout does not, so its
|
|
566
|
+
// facts are compiled here, as that command compiles them, for the client the case runs
|
|
567
|
+
let packFacts = join(pack, 'generated', 'pack-facts.json');
|
|
568
|
+
if (!existsSync(packFacts)) {
|
|
569
|
+
const { compilePackFacts, renderPackFacts } = await import('./pack-facts.ts');
|
|
570
|
+
const compiled = await compilePackFacts([{ vendor, dir: pack }]);
|
|
571
|
+
if (compiled.undeclared.length) throw new Error('Pack descriptor is missing');
|
|
572
|
+
packFacts = join(scratch, 'pack-facts.json');
|
|
573
|
+
writeFileSync(packFacts, renderPackFacts(compiled));
|
|
574
|
+
}
|
|
575
|
+
writeFileSync(job, JSON.stringify({ vendor, port: server.port, host, env: issued, scenarioPath, packFacts, ...(descriptor?.hostsNone ? { raw: true } : {}) }));
|
|
431
576
|
// Node, as an application runs its SDK (asynchronous: this process serves the pack meanwhile)
|
|
432
577
|
const out = await new Promise<string>((resolve) => {
|
|
433
578
|
const child = spawn('node', ['--experimental-strip-types', '--no-warnings', join(clients, `run${extension}`), job], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -444,8 +589,42 @@ async function clientCases(pack: string, scratch: string): Promise<{ failures: s
|
|
|
444
589
|
} finally { await server.stop(); }
|
|
445
590
|
}
|
|
446
591
|
|
|
592
|
+
/** The gate over every unit of the pack: the form section of the grade, which holds the rules an author's code keeps
|
|
593
|
+
* (p3-rules.ts: ids from ctx.mint, the twin's URL from ctx.publicBase, the kernel's crypto, bytes in ctx.blobs, secrets
|
|
594
|
+
* drawn from ctx.secret) and the pack layout, handler, kernel and manifest contracts. A contributor's conform is the
|
|
595
|
+
* whole of what a machine decides of the pack, as the catalog's admission reads it. */
|
|
596
|
+
async function formOf(pack: string): Promise<string[]> {
|
|
597
|
+
const { p3Gate } = await import('./gate.ts');
|
|
598
|
+
return (await p3Gate(unitsIn(pack))).flatMap((r) => r.failures.map((f) => `${nameOf(pack, r.unit)}: ${f}`));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** The pack's records hold what a reviewer reads them for: each demand row cites the application's line it was read at
|
|
602
|
+
* (`<path>:<line>`, or `<package>@<version>:<path>:<line>` for a call a dependency makes), the spec's record names a
|
|
603
|
+
* vendored file that is there, the life names the session that wrote it (what makes a judge's verdict independent),
|
|
604
|
+
* and the package says what it serves in a README. Rules every builder brief carried by hand, decided here. */
|
|
605
|
+
function records(pack: string): string[] {
|
|
606
|
+
const out: string[] = [];
|
|
607
|
+
if (!existsSync(join(pack, 'README.md'))) out.push('README.md is missing: the package says what it serves, for whom, its doors and what it leaves out');
|
|
608
|
+
const life = readJson(join(pack, 'journeys', 'customer-life.json')) as { author?: unknown } | undefined;
|
|
609
|
+
if (life && (typeof life.author !== 'string' || !life.author.trim())) out.push('journeys/customer-life.json names no author (the session that wrote it), so no verdict on it can be shown independent');
|
|
610
|
+
for (const unit of unitsIn(pack)) {
|
|
611
|
+
const name = nameOf(pack, unit);
|
|
612
|
+
const journeys = hasJourneySource(unit) ? join(unit, 'journeys') : join(unit, '..', 'journeys');
|
|
613
|
+
const demand = unit === pack || hasJourneySource(unit) ? readJson(join(journeys, 'demand.json')) as { applications?: Array<{ name?: string; backend?: Array<{ operation?: string; evidence?: unknown }> }> } | undefined : undefined;
|
|
614
|
+
for (const app of demand?.applications ?? []) for (const row of app.backend ?? []) {
|
|
615
|
+
if (typeof row.evidence !== 'string' || !/:\d+/.test(row.evidence)) out.push(`${name}: ${app.name ?? 'an application'}'s call of ${row.operation ?? '(no operation)'} cites no line it was read at (evidence "<path>:<line>")`);
|
|
616
|
+
}
|
|
617
|
+
if (!hasOwnSurface(unit)) continue;
|
|
618
|
+
const source = existsSync(join(unit, 'spec', 'SOURCE.md')) ? readFileSync(join(unit, 'spec', 'SOURCE.md'), 'utf8') : '';
|
|
619
|
+
const file = /\*\*File:\*\*[^`\n]*`([^`]+)`/.exec(source)?.[1];
|
|
620
|
+
if (file && !existsSync(join(unit, 'spec', file))) out.push(`${name}: spec/SOURCE.md names ${file} as the vendored spec, and spec/ holds no such file`);
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
|
|
447
625
|
/** Every check over the pack at `dir`, in order. */
|
|
448
|
-
|
|
626
|
+
/** `onCheck` hears each check's id as it starts, so a caller can show where a long run stands. */
|
|
627
|
+
export async function conformance(dir: string, options: { onCheck?: (id: string) => void } = {}): Promise<ConformanceResult[]> {
|
|
449
628
|
const pack = resolve(dir);
|
|
450
629
|
if (!existsSync(join(pack, 'package.json'))) throw new Error(`${dir} is no pack (no package.json): conformance takes a pack's directory`);
|
|
451
630
|
// the kernel the pack itself resolves: one registry, one serve seam, the pack's own
|
|
@@ -453,8 +632,9 @@ export async function conformance(dir: string): Promise<ConformanceResult[]> {
|
|
|
453
632
|
const scratch = mkdtempSync(join(tmpdir(), 'conformance-'));
|
|
454
633
|
const here = process.cwd();
|
|
455
634
|
process.chdir(scratch);
|
|
456
|
-
const run = async (id: string, asks: string, check: () => Promise<string[]> | string[]): Promise<ConformanceResult> => {
|
|
457
|
-
|
|
635
|
+
const run = async (id: string, asks: string, check: () => Promise<string[] | { failures: string[]; note: string }> | string[]): Promise<ConformanceResult> => {
|
|
636
|
+
options.onCheck?.(id);
|
|
637
|
+
try { const r = await check(); return Array.isArray(r) ? { id, asks, failures: r } : { id, asks, failures: r.failures, note: r.note }; } catch (error) { return { id, asks, failures: [`the check could not run: ${(error as Error).message.split('\n')[0]}`] }; }
|
|
458
638
|
};
|
|
459
639
|
try {
|
|
460
640
|
return [
|
|
@@ -465,11 +645,15 @@ export async function conformance(dir: string): Promise<ConformanceResult[]> {
|
|
|
465
645
|
await run('registered', 'a vendor-backed unit, lane or not, reaches the registered pack\'s state system', () => registered(pack, runtime)),
|
|
466
646
|
await run('allowance', 'a rate budget above the fallback rests on the vendor\'s documented allowance, cited in its manifest', () => allowance(pack, runtime)),
|
|
467
647
|
await (async (): Promise<ConformanceResult> => {
|
|
648
|
+
options.onCheck?.('client');
|
|
468
649
|
const asks = "driven by the vendor's own client (its official SDK), served as a World runs it, the vendor's documented behaviour holds";
|
|
469
650
|
try { const r = await clientCases(pack, scratch); return { id: 'client', asks, failures: r.failures, ...(r.note ? { note: r.note } : {}) }; } catch (error) { return { id: 'client', asks, failures: [`the check could not run: ${(error as Error).message.split('\n')[0]}`] }; }
|
|
470
651
|
})(),
|
|
471
652
|
await run('life', 'the life walked over HTTP against the pack served as a World runs it, with its scenario: every check held', () => lifeServed(pack)),
|
|
472
653
|
await run('standalone', 'served by its server.ts as a World runs it, the pack answers HTTP and the boot probe, and each declared socket upgrades', () => standalone(pack)),
|
|
654
|
+
await run('surface', 'every served operation called over HTTP as an application calls it, plainly and with a scenario loaded: answered within 10 s, no undeclared 500, no field of the twin\'s own, a success in the spec\'s shape, and every refresh list a fresh account reads answering', () => surfaceSwept(pack, scratch)),
|
|
655
|
+
await run('records', 'each demand row cites the line it was read at, the spec\'s record names a vendored file that is there, the life names its author, the package has its README', () => records(pack)),
|
|
656
|
+
await run('form', 'every unit passes the form section of the grade (the gate): the layout, the handler and kernel contracts, the manifest as data, the rules an author\'s code holds', () => formOf(pack)),
|
|
473
657
|
];
|
|
474
658
|
} finally {
|
|
475
659
|
process.chdir(here);
|
package/src/consumer.ts
CHANGED
|
@@ -181,7 +181,11 @@ export async function runConsumerWorkflow(packDir: string, options: ConsumerOpti
|
|
|
181
181
|
if (JSON.stringify(before) !== JSON.stringify(resumed)) throw new Error('Resume changed recorded application state before readback');
|
|
182
182
|
await run('retained-readback', ['run', '--', ...recipe.retention.readback]);
|
|
183
183
|
const after = JSON.parse(await run('log-after-readback', ['log', '--json']));
|
|
184
|
-
|
|
184
|
+
// the readback adds no write of the application's: what it adds may only be the vendor's own moves (a key's
|
|
185
|
+
// last-used time, recorded by the vendor as a request uses it: actor `system`, ctx.asVendor), never an API call's
|
|
186
|
+
const added = (after as Array<{ id?: string; actor?: { kind?: string } }>).filter((entry) => !(before as Array<{ id?: string }>).some((prior) => prior.id === entry.id));
|
|
187
|
+
const kept = (before as Array<{ id?: string }>).every((prior) => (after as Array<{ id?: string }>).some((entry) => JSON.stringify(entry) === JSON.stringify(prior)));
|
|
188
|
+
if (!kept || added.some((entry) => entry.actor?.kind !== 'system')) throw new Error('Retained readback added mutations; it must only inspect existing state');
|
|
185
189
|
retainedReadback = true;
|
|
186
190
|
}
|
|
187
191
|
} catch (error) { failures.push((error as Error).message); }
|
package/src/create-pack.ts
CHANGED
|
@@ -150,6 +150,9 @@ export function packFetchFile(dir: string, name: string): string {
|
|
|
150
150
|
const hasLive = existsSync(join(semantics, 'live.ts'));
|
|
151
151
|
// a GraphQL API beside the REST one: its resolvers and the vendor's schema (graphql-wire.ts)
|
|
152
152
|
const hasGraphql = existsSync(join(semantics, 'graphql.ts')) && existsSync(join(dir, 'src', 'generated', 'graphql-sdl.gen.json'));
|
|
153
|
+
// a stream whose documents are BSON (the MongoDB wire): the pack's own bson library, resolved from the pack as graphql's is
|
|
154
|
+
const packageJson = existsSync(join(dir, 'package.json')) ? (parseExactJson(readFileSync(join(dir, 'package.json'), 'utf8')) as { dependencies?: Record<string, string> }) : {};
|
|
155
|
+
const hasBson = existsSync(join(dir, 'src', 'stream.ts')) && packageJson.dependencies?.bson !== undefined;
|
|
153
156
|
const screensDir = join(dir, 'src', 'screens');
|
|
154
157
|
const screens = existsSync(screensDir) ? readdirSync(screensDir).filter((n) => /\.tsx?$/.test(n) && !/\.(test|uitest)\.tsx?$/.test(n) && !/^shared\.tsx?$/.test(n)).sort() : [];
|
|
155
158
|
const id = (file: string): string => file.replace(/\.tsx?$/, '');
|
|
@@ -195,6 +198,7 @@ export function packFetchFile(dir: string, name: string): string {
|
|
|
195
198
|
...screens.map((f) => `import { screen as ${identifier(`screen_${id(f)}`)} } from './screens/${f}';`),
|
|
196
199
|
...laneImports,
|
|
197
200
|
'',
|
|
201
|
+
...(hasBson ? ["/** The pack's BSON library, resolved from the pack as graphql's loader is (architecture, the MongoDB wire). */", "export const loadBson = (): Promise<unknown> => import('bson');", ''] : []),
|
|
198
202
|
`export function ${name}(options: PackFetchOptions = {}): DerivedFetch {`,
|
|
199
203
|
' return createPackFetch({',
|
|
200
204
|
' manifest,',
|
|
@@ -502,6 +506,26 @@ export function laneInitFiles(vendor: string, lanes: string[]): Record<string, s
|
|
|
502
506
|
return files;
|
|
503
507
|
}
|
|
504
508
|
|
|
509
|
+
/** A unit's fixed files as the manifest and the spec make them now (architecture, "Creating a pack"): a lane's fetch, and
|
|
510
|
+
* a pack's fetch, index, server and cli. A pack with a wire of its own (`src/stream.ts`, SMTP's and Redis's byte-stream
|
|
511
|
+
* door) writes its server and cli itself: they carry its listener beside the fetch, so they are not fixed. `--index`
|
|
512
|
+
* writes these and the grade's `fixed-reproducible` compares them byte for byte. */
|
|
513
|
+
export async function fixedFiles(dir: string): Promise<Record<string, string>> {
|
|
514
|
+
const files: Record<string, string> = { 'src/fetch.ts': packFetchFile(dir, fetchName(dir)) };
|
|
515
|
+
if (!existsSync(join(dir, 'package.json'))) return files;
|
|
516
|
+
const { manifest } = await import(join(dir, 'src', 'manifest.ts')) as { manifest?: { vendor?: string } };
|
|
517
|
+
const vendor = manifest?.vendor;
|
|
518
|
+
if (!vendor || !/^[a-z][a-z0-9-]*$/.test(vendor)) throw new Error('Pack manifest must declare its vendor identity independently of the npm package name');
|
|
519
|
+
files['src/index.ts'] = packIndexFile(dir, vendor);
|
|
520
|
+
if (!existsSync(join(dir, 'src', 'stream.ts'))) {
|
|
521
|
+
// a unit whose spec is a proto (derive-pack generated its schema) is served over gRPC: its serve seam in that form
|
|
522
|
+
const lanesOnly = !hasOwnSurface(dir) && readdirSync(dir).some((n) => existsSync(join(dir, n, 'src', 'manifest.ts')));
|
|
523
|
+
files['src/server.ts'] = serverFile(vendor, !lanesOnly && existsSync(join(dir, 'src', 'generated', 'proto.gen.json')));
|
|
524
|
+
files['src/cli.ts'] = cliFile(vendor);
|
|
525
|
+
}
|
|
526
|
+
return files;
|
|
527
|
+
}
|
|
528
|
+
|
|
505
529
|
export async function createPack(args: string[]): Promise<void> {
|
|
506
530
|
const [unit, ...rest] = args;
|
|
507
531
|
if (!unit || (!rest.includes('--index') && !rest.includes('--brief') && !rest.includes('--init'))) {
|
|
@@ -562,9 +586,9 @@ export async function createPack(args: string[]): Promise<void> {
|
|
|
562
586
|
}
|
|
563
587
|
const path = join(dir, 'src', 'semantics', 'index.ts');
|
|
564
588
|
if (!lanesOnly) { writeFileSync(path, handlerIndex(dir)); console.log(`create-pack: wrote ${path}`); }
|
|
565
|
-
// the
|
|
566
|
-
|
|
567
|
-
console.log(`create-pack: wrote ${join(dir,
|
|
589
|
+
// the fixed files, again: a door file, the clock, a screen, a lane or a proto may have been added since, and a pack
|
|
590
|
+
// made by an earlier create-pack takes the current form
|
|
591
|
+
for (const [rel, text] of Object.entries(await fixedFiles(dir))) { writeFileSync(join(dir, rel), text); console.log(`create-pack: wrote ${join(dir, rel)}`); }
|
|
568
592
|
// a pack's own index (a lane's is the vendor's to name): the descriptor with what the kernel derives from the surface
|
|
569
593
|
if (existsSync(join(dir, 'package.json'))) {
|
|
570
594
|
const { manifest } = await import(join(dir, 'src', 'manifest.ts')) as { manifest?: { vendor?: string; descriptor?: { transport?: string } } };
|
package/src/protocol-3.ts
CHANGED
|
@@ -283,6 +283,18 @@ const criteria: Criterion[] = [
|
|
|
283
283
|
return changed.length ? fail(`derive-pack writes differently: ${changed.slice(0, 5).join(', ')}${changed.length > 5 ? ` and ${changed.length - 5} more` : ''}`) : pass(`${filesUnder(gen).length} files reproduced`);
|
|
284
284
|
},
|
|
285
285
|
},
|
|
286
|
+
{
|
|
287
|
+
id: 'fixed-reproducible', section: 'form',
|
|
288
|
+
asks: 'create-pack regenerates the fixed files (fetch, and a pack\'s index, server and cli) byte for byte',
|
|
289
|
+
async check(unit) {
|
|
290
|
+
// loaded when graded: create-pack reads this module's handler names, so a static import would be a cycle
|
|
291
|
+
const { fixedFiles } = await import('./create-pack.ts');
|
|
292
|
+
let want: Record<string, string>;
|
|
293
|
+
try { want = await fixedFiles(unit.dir); } catch (error) { return fail(`create-pack cannot write the fixed files: ${error instanceof Error ? error.message : String(error)}`.slice(0, 200)); }
|
|
294
|
+
const differ = Object.entries(want).filter(([rel, text]) => !existsSync(join(unit.dir, rel)) || readFileSync(join(unit.dir, rel), 'utf8') !== text).map(([rel]) => rel);
|
|
295
|
+
return differ.length ? fail(`create-pack --index writes differently: ${differ.join(', ')} (run it, never edit a fixed file)`) : pass(`${Object.keys(want).length} fixed files reproduced`);
|
|
296
|
+
},
|
|
297
|
+
},
|
|
286
298
|
{
|
|
287
299
|
id: 'layout', section: 'form',
|
|
288
300
|
asks: "the unit's files exactly Pack layout's tree: fixed names at src/'s root, semantics/ its fixed files and one per spec family, screens/ one per declared screen, journeys/ its closed set",
|
|
@@ -466,8 +478,8 @@ const criteria: Criterion[] = [
|
|
|
466
478
|
// an error message's "from '<name>'", is none)
|
|
467
479
|
let text = readFileSync(join(src, f), 'utf8');
|
|
468
480
|
if (f === 'fetch.ts') {
|
|
469
|
-
// The generated fetch supplies the kernel wire with the pack-local parser
|
|
470
|
-
// Exempt only
|
|
481
|
+
// The generated fetch supplies the kernel wire with the pack-local library: graphql's parser, a stream's BSON.
|
|
482
|
+
// Exempt only those exact generated parts; other static or dynamic imports still count.
|
|
471
483
|
const source = ts.createSourceFile(f, text, ts.ScriptTarget.Latest, true);
|
|
472
484
|
const loaders: Array<{ start: number; end: number }> = [];
|
|
473
485
|
const visit = (node: ts.Node): void => {
|
|
@@ -481,6 +493,16 @@ const criteria: Criterion[] = [
|
|
|
481
493
|
factory && ts.isCallExpression(factory) && ts.isIdentifier(factory.expression) && factory.expression.text === 'createPackFetch' &&
|
|
482
494
|
factory.arguments[0] === graphql.parent) loaders.push({ start: node.getStart(source), end: node.end });
|
|
483
495
|
}
|
|
496
|
+
// The generated BSON loader of a stream's pack (architecture, the MongoDB wire): exactly
|
|
497
|
+
// `export const loadBson = (): Promise<unknown> => import('bson');`, BSON supplied as graphql is.
|
|
498
|
+
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
499
|
+
node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0]!) && node.arguments[0]!.text === 'bson') {
|
|
500
|
+
const arrow = node.parent, declaration = arrow.parent, list = declaration?.parent, statement = list?.parent;
|
|
501
|
+
if (ts.isArrowFunction(arrow) && arrow.parameters.length === 0 && arrow.body === node &&
|
|
502
|
+
declaration && ts.isVariableDeclaration(declaration) && ts.isIdentifier(declaration.name) && declaration.name.text === 'loadBson' &&
|
|
503
|
+
statement && ts.isVariableStatement(statement) && statement.parent === source &&
|
|
504
|
+
statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) loaders.push({ start: node.getStart(source), end: node.end });
|
|
505
|
+
}
|
|
484
506
|
ts.forEachChild(node, visit);
|
|
485
507
|
};
|
|
486
508
|
visit(source);
|
package/src/spec-ir.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type IrOperation = {
|
|
|
49
49
|
bodyEncoding: 'json' | 'form' | 'multipart' | 'xml' | 'none';
|
|
50
50
|
/** A form-encoded operation's number and boolean fields, by bracket path (formScalarsOf). */
|
|
51
51
|
scalars?: Record<string, 'integer' | 'number' | 'boolean'>;
|
|
52
|
+
/** A form-encoded operation's string fields that take only listed values, by bracket path (formEnumsOf). */
|
|
53
|
+
enums?: Record<string, string[]>;
|
|
52
54
|
successStatus: number;
|
|
53
55
|
/** Binary media types declared by each response status, independent of the twin’s response headers. */
|
|
54
56
|
responseMedia?: Record<string, string[]>;
|
|
@@ -350,6 +352,33 @@ function formScalarsOf(doc: Json, op: Json, pathItem: Json, encoding: IrOperatio
|
|
|
350
352
|
return Object.keys(out).length ? Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b))) : undefined;
|
|
351
353
|
}
|
|
352
354
|
|
|
355
|
+
/** A form-encoded operation's string fields whose schema lists the values they take (`status`: active, inactive,
|
|
356
|
+
* canceled), by the bracket path formScalarsOf uses; a field another alternative leaves free (a string without an enum,
|
|
357
|
+
* or a number) takes any value and is not listed. */
|
|
358
|
+
function formEnumsOf(doc: Json, op: Json, pathItem: Json, encoding: IrOperation['bodyEncoding']): Record<string, string[]> | undefined {
|
|
359
|
+
if (encoding !== 'form') return undefined;
|
|
360
|
+
const out: Record<string, string[]> = {};
|
|
361
|
+
const visit = (schema: Json, at: string[], depth: number): void => {
|
|
362
|
+
const s = deref(doc, schema);
|
|
363
|
+
if (!s || typeof s !== 'object' || depth > 12) return;
|
|
364
|
+
const alts = [...(Array.isArray(s.anyOf) ? s.anyOf : []), ...(Array.isArray(s.oneOf) ? s.oneOf : [])].map((a: Json) => deref(doc, a));
|
|
365
|
+
if (alts.length) {
|
|
366
|
+
const free = alts.some((a: Json) => (a?.type === 'string' && !Array.isArray(a.enum)) || a?.type === 'integer' || a?.type === 'number' || a?.type === 'boolean');
|
|
367
|
+
const listed = alts.filter((a: Json) => a?.type === 'string' && Array.isArray(a.enum)).flatMap((a: Json) => a.enum.map(String));
|
|
368
|
+
if (!free && listed.length && at.length) out[at.join('.')] = [...new Set(listed)];
|
|
369
|
+
for (const a of alts) if (a?.type === 'object' || a?.type === 'array' || a?.properties) visit(a, at, depth + 1);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (s.type === 'string' && Array.isArray(s.enum) && at.length) { out[at.join('.')] = s.enum.map(String); return; }
|
|
373
|
+
if (s.type === 'array') { visit(s.items, [...at, '[]'], depth + 1); return; }
|
|
374
|
+
for (const [name, p] of Object.entries(propsOf(doc, s))) visit(p, [...at, name], depth + 1);
|
|
375
|
+
};
|
|
376
|
+
for (const p of [...(pathItem.parameters ?? []), ...(op.parameters ?? [])].map((q: Json) => deref(doc, q))) if (p?.in === 'query') visit(p.schema, [String(p.name)], 0);
|
|
377
|
+
const body = deref(doc, deref(doc, op.requestBody)?.content?.['application/x-www-form-urlencoded']?.schema);
|
|
378
|
+
for (const [name, p] of Object.entries(propsOf(doc, body))) visit(p, [name], 0);
|
|
379
|
+
return Object.keys(out).length ? Object.fromEntries(Object.entries(out).sort(([x], [y]) => x.localeCompare(y))) : undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
353
382
|
/** The class of one operation, from its method, its path and what it answers. An operation this cannot place is `action`, never CRUD. */
|
|
354
383
|
/** A path and its discriminator as one key: `/responses/{id}` and `/responses/{id}?beta=true` are different reads. */
|
|
355
384
|
// a null value (the key present with any value) keys as the bare name
|
|
@@ -477,6 +506,7 @@ export function fromOpenAPI(spec: Json): SpecIR {
|
|
|
477
506
|
const credentials = [...new Set(security.flatMap((req: Json) => { const names = Object.keys(req ?? {}); return names.length ? names : ['anonymous']; }))].sort();
|
|
478
507
|
const body = bodyOf(doc, op);
|
|
479
508
|
const scalars = formScalarsOf(doc, op, item, body.bodyEncoding);
|
|
509
|
+
const enums = formEnumsOf(doc, op, item, body.bodyEncoding);
|
|
480
510
|
const ownServer = String((op.servers ?? (item as Json).servers)?.[0]?.url ?? '');
|
|
481
511
|
const ownBase = /^https?:\/\//.test(ownServer) ? new URL(ownServer).pathname.replace(/\/+$/, '') : undefined;
|
|
482
512
|
raw.push({
|
|
@@ -488,6 +518,7 @@ export function fromOpenAPI(spec: Json): SpecIR {
|
|
|
488
518
|
...(ownBase !== undefined && ownBase !== docBase ? { basePath: ownBase } : {}),
|
|
489
519
|
...body,
|
|
490
520
|
...(scalars ? { scalars } : {}),
|
|
521
|
+
...(enums ? { enums } : {}),
|
|
491
522
|
successStatus: success.status,
|
|
492
523
|
...(Object.keys(responseMedia).length ? { responseMedia, ...(Object.keys(defaultResponseMedia).length ? { defaultResponseMedia } : {}) } : {}),
|
|
493
524
|
...(success.empty ? { emptySuccess: true as const } : {}),
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// A JUDGE'S BRIEF (pack-done's (f); architecture, "What a judge rejects"): what a judge reads and writes for one vendor's
|
|
2
|
+
// outline, life and published examples, printed for a judge in another session (`codex exec`, `claude -p`, any agent
|
|
3
|
+
// runtime). The verdict names its judge and the authors; the grade refuses one whose judge is an author
|
|
4
|
+
// (packages/twin-standard/src/verdict.ts). One judging per pack: the verdict stands through later changes.
|
|
5
|
+
//
|
|
6
|
+
// twin-standard judge-brief <vendor> --authors <author name>[,<agent>...] (from the pack repository's root; twin-world's
|
|
7
|
+
// bun scripts/judge-brief.ts runs the same)
|
|
8
|
+
import { attributionName } from '../verdict.ts';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { packsRoot } from './packs-root.ts';
|
|
13
|
+
const PACKS = packsRoot();
|
|
14
|
+
/** The architecture a judge's list is read from: the twin-world checkout this standard is in, else its published copy. */
|
|
15
|
+
const ARCHITECTURE = join(import.meta.dir, '..', '..', '..', '..', 'docs', 'contributing', 'architecture.md');
|
|
16
|
+
const PUBLISHED_ARCHITECTURE = 'https://world-docs.volter.ai/docs/contributing/architecture.md';
|
|
17
|
+
|
|
18
|
+
const vendor = process.argv[2];
|
|
19
|
+
const authorsAt = process.argv.indexOf('--authors');
|
|
20
|
+
const authors = authorsAt > 0 ? (process.argv[authorsAt + 1] ?? '').split(',').map((a) => a.trim()).filter(Boolean).map((name) => attributionName(name)!) : [];
|
|
21
|
+
if (!vendor || vendor.startsWith('-') || !authors.length) {
|
|
22
|
+
console.error('usage: twin-standard judge-brief <vendor> --authors <author name>[,<agent>...] (from the pack repository\'s root)');
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
const journeys = join(PACKS, vendor, 'journeys');
|
|
26
|
+
if (!existsSync(journeys)) { console.error(`judge-brief: ${vendor} has no journeys/ in ${PACKS}`); process.exit(2); }
|
|
27
|
+
// the list of what a judge rejects, as architecture holds it
|
|
28
|
+
const architecture = existsSync(ARCHITECTURE) ? readFileSync(ARCHITECTURE, 'utf8') : await fetch(PUBLISHED_ARCHITECTURE).then((r) => (r.ok ? r.text() : ''), () => '');
|
|
29
|
+
const listAt = architecture.indexOf('#### What a judge rejects\n');
|
|
30
|
+
if (listAt < 0) { console.error(`judge-brief: the architecture (${existsSync(ARCHITECTURE) ? ARCHITECTURE : PUBLISHED_ARCHITECTURE}) has no "#### What a judge rejects" section`); process.exit(2); }
|
|
31
|
+
const listFrom = architecture.indexOf('\n- ', listAt);
|
|
32
|
+
const listTo = architecture.indexOf('\n\n', listFrom + 1);
|
|
33
|
+
const rejects = architecture.slice(listFrom + 1, listTo < 0 ? undefined : listTo).trim();
|
|
34
|
+
const sha = (file: string): string => createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
35
|
+
const judged = [
|
|
36
|
+
{ file: 'customer-life.outline.md', verdict: 'customer-life.outline.verdict.json', what: "the life's outline: the customer's story in acts" },
|
|
37
|
+
{ file: 'customer-life.json', verdict: 'customer-life.verdict.json', what: 'the life: every step an application or person takes, its expectations' },
|
|
38
|
+
{ file: 'vendor-examples.json', verdict: 'vendor-examples.verdict.json', what: "the published examples: the vendor's own documented requests and answers, replayed" },
|
|
39
|
+
].filter((j) => existsSync(join(journeys, j.file)));
|
|
40
|
+
|
|
41
|
+
console.log(`You are the judge of the ${vendor} twin's journeys. You did not write them, and you judge them in a session other than
|
|
42
|
+
the run that wrote them. Judge them against the vendor and against the list below; do not edit any judged file, and run no
|
|
43
|
+
test. Read what you need: the pack's spec (${vendor}/spec/, SOURCE.md naming every page read), its demand
|
|
44
|
+
(${vendor}/journeys/demand.json: what the applications call), its decision table (journeys/decisions.json), and the
|
|
45
|
+
vendor's own documentation the spec cites.
|
|
46
|
+
|
|
47
|
+
What you judge (in ${vendor}/journeys/), each with the sha256 your verdict names:
|
|
48
|
+
${judged.map((j) => `- ${j.file} (${j.what}): sha256 ${sha(join(journeys, j.file))}`).join('\n')}
|
|
49
|
+
|
|
50
|
+
What a judge rejects (docs/contributing/architecture.md, "What a judge rejects"):
|
|
51
|
+
${rejects}
|
|
52
|
+
|
|
53
|
+
For each file write its verdict beside it, as JSON:
|
|
54
|
+
${judged.map((j) => `- ${j.verdict}`).join('\n')}
|
|
55
|
+
{
|
|
56
|
+
"judge": { "agent": "<what you are: your runtime and model>", "name": "<your author name or reviewer role>" },
|
|
57
|
+
"authors": ${JSON.stringify(authors)},
|
|
58
|
+
"sha256": "<the judged file's sha256 above>",
|
|
59
|
+
"judgedAt": "<today, YYYY-MM-DD>",
|
|
60
|
+
"plausible": <true only when no ruling stands>,
|
|
61
|
+
"rulings": [ { "id": "<short name>", "steps": [<step indexes, or acts>], "barItem": "<the list's item>", "reason": "<what is wrong, citing the step and the vendor's page>", "correction": "<what the author changes>" } ]
|
|
62
|
+
}
|
|
63
|
+
A correction naming a wait gives it as a life writes one: one number and one unit (\`915m\`, \`3d\`). A verdict with any ruling is not plausible. The authors apply every ruling; this is the one judging, and the verdict stands through their changes.`);
|