@volter/twin-standard 1.0.148 → 1.0.150
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 +6 -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 -3
- package/dist/src/conformance.js +293 -8
- package/dist/src/consumer.js +5 -1
- package/dist/src/create-pack.d.ts +5 -0
- package/dist/src/create-pack.js +38 -4
- package/dist/src/derive.js +21 -13
- package/dist/src/gate.d.ts +1 -0
- package/dist/src/gate.js +47 -4
- package/dist/src/grade.d.ts +1 -0
- package/dist/src/grade.js +1 -1
- package/dist/src/pack-facts.d.ts +9 -1
- package/dist/src/pack-facts.js +11 -2
- package/dist/src/pack-files.js +2 -1
- package/dist/src/protocol-3.js +40 -6
- package/dist/src/spec-ir-client.js +3 -2
- package/dist/src/spec-ir-commands.d.ts +73 -0
- package/dist/src/spec-ir-commands.js +63 -0
- 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/types.d.ts +2 -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 +108 -8
- package/dist/src/walk/journey-kit.d.ts +23 -1
- package/dist/src/walk/journey-kit.js +7 -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 +226 -10
- package/src/consumer.ts +5 -1
- package/src/create-pack.ts +35 -5
- package/src/derive.ts +21 -14
- package/src/gate.ts +40 -6
- package/src/grade.ts +2 -2
- package/src/pack-facts.ts +10 -3
- package/src/pack-files.ts +2 -1
- package/src/protocol-3.ts +32 -6
- package/src/spec-ir-client.ts +3 -2
- package/src/spec-ir-commands.ts +81 -0
- 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/types.ts +2 -0
- package/src/verdict.ts +1 -1
- package/src/walk/behavior-journey.ts +65 -4
- package/src/walk/journey-kit.ts +21 -3
- package/src/walk/rendered-scalar.ts +17 -0
- package/src/walk/sse-example.ts +37 -0
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,12 +586,12 @@ 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
|
-
const { manifest } = await import(join(dir, 'src', 'manifest.ts')) as { manifest?: { vendor?: string } };
|
|
594
|
+
const { manifest } = await import(join(dir, 'src', 'manifest.ts')) as { manifest?: { vendor?: string; descriptor?: { transport?: string } } };
|
|
571
595
|
const vendor = manifest?.vendor;
|
|
572
596
|
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');
|
|
573
597
|
writeFileSync(join(dir, 'src', 'index.ts'), packIndexFile(dir, vendor));
|
|
@@ -577,7 +601,13 @@ export async function createPack(args: string[]): Promise<void> {
|
|
|
577
601
|
// a unit whose spec is a proto (derive-pack generated its schema) is served over gRPC: its serve seam in that form,
|
|
578
602
|
// the one form a unit takes after --init (a server.ts of a wire of the pack's own, SMTP's, is never written over)
|
|
579
603
|
if (!lanesOnly && existsSync(join(dir, 'src', 'generated', 'proto.gen.json'))) {
|
|
580
|
-
|
|
604
|
+
const wire = existsSync(join(dir, 'src', 'stream.ts'));
|
|
605
|
+
// a raw-tcp pack exporting one create<Name>TwinStream is taken to serve that stream on its main port, and is admitted
|
|
606
|
+
// by its TCP answer alone (world-runtime host.ts servesByteStream); this one serves gRPC there, its stream on a side port
|
|
607
|
+
if (wire && manifest?.descriptor?.transport === 'raw-tcp') {
|
|
608
|
+
throw new Error(`create-pack: ${vendor} serves gRPC on its main port and its own wire (./stream.ts) on a side port, so its descriptor's transport is not raw-tcp: raw-tcp with a stream export says the main port is that stream (world-runtime host.ts servesByteStream).`);
|
|
609
|
+
}
|
|
610
|
+
writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true, wire));
|
|
581
611
|
console.log(`create-pack: wrote ${join(dir, 'src', 'server.ts')}`);
|
|
582
612
|
} else if ((manifest as { live?: unknown[] } | undefined)?.live?.length) {
|
|
583
613
|
// a unit that takes broadcasts over RTMP serves its ingest beside its HTTP host, and its bin takes the ingest's
|
package/src/derive.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { gunzipSync } from 'node:zlib';
|
|
|
14
14
|
import { CRUD_CLASSES, fromOpenAPI, fromSmithy, fromSwagger2, slug, type SpecIR } from './spec-ir.ts';
|
|
15
15
|
import { fromCommandReplies, type LineSpec } from './spec-ir-lines.ts';
|
|
16
16
|
import { fromProto, protoSchema, readProtoFiles } from './spec-ir-proto.ts';
|
|
17
|
-
import { fromRedisCommands, type CommandTableSpec } from './spec-ir-commands.ts';
|
|
17
|
+
import { fromDriverCommandBuilders, fromRedisCommands, type CommandTableSpec, type DocumentCommandTableSpec, type DriverCommandBuilders } from './spec-ir-commands.ts';
|
|
18
18
|
import { fromClientCalls, type ClientOpsSpec } from './spec-ir-client.ts';
|
|
19
19
|
import { discoveryMethods, fromDiscovery, isDiscovery } from './spec-ir-discovery.ts';
|
|
20
20
|
import { handlerCitations, quotedPage, sourceHeld, type Patch, type SourceRecord, urlsIn } from './check-sources.ts';
|
|
@@ -28,22 +28,28 @@ if (!vendor) {
|
|
|
28
28
|
console.error('usage: twin-standard derive <pack dir> [--out <dir>]');
|
|
29
29
|
process.exit(1);
|
|
30
30
|
}
|
|
31
|
+
// A source-declared refusal is deterministic over the pinned bytes. Unexpected
|
|
32
|
+
// loader, filesystem and process failures keep status 1 and must be retried.
|
|
33
|
+
const SOURCE_REFUSAL = 2;
|
|
31
34
|
const PKG = unitAt(vendor).dir;
|
|
32
35
|
// an OpenAPI document, an AWS Smithy JSON model (smithy.json), the RFC a line protocol is (rfcNNNN.txt), or the
|
|
33
36
|
// Protocol Buffers files an RPC service is published as (spec/proto/, every file the service imports), or the calls a
|
|
34
37
|
// vendor's own client makes where the vendor publishes no spec (client-ops.json, spec-ir-client.ts), or one API's several
|
|
35
38
|
// OpenAPI documents (spec/openapi/, read as their union: spec-documents.ts), or a Google API Discovery document
|
|
36
|
-
// (discovery.json, spec-ir-discovery.ts)
|
|
39
|
+
// (discovery.json, spec-ir-discovery.ts), or the command documents a vendor's own client builds where the vendor
|
|
40
|
+
// publishes no table a pack may derive from (command-builders.json, spec-ir-commands.ts)
|
|
37
41
|
const rfc = existsSync(join(PKG, 'spec')) ? readdirSync(join(PKG, 'spec')).find((f) => /^rfc\d+\.txt$/.test(f)) : undefined;
|
|
38
|
-
const specPath = ['openapi.json.gz', 'openapi.json', 'openapi.yaml.gz', 'openapi.yaml', 'smithy.json.gz', 'smithy.json', 'discovery.json.gz', 'discovery.json', ...(rfc ? [rfc] : []), 'openapi', 'proto', 'commands', 'client-ops.json'].map((f) => join(PKG, 'spec', f)).find((p) => existsSync(p));
|
|
42
|
+
const specPath = ['openapi.json.gz', 'openapi.json', 'openapi.yaml.gz', 'openapi.yaml', 'smithy.json.gz', 'smithy.json', 'discovery.json.gz', 'discovery.json', ...(rfc ? [rfc] : []), 'openapi', 'proto', 'commands', 'command-builders.json', 'client-ops.json'].map((f) => join(PKG, 'spec', f)).find((p) => existsSync(p));
|
|
39
43
|
if (!specPath) {
|
|
40
|
-
console.error(`derive-pack: no spec at ${join(PKG, 'spec')}/openapi.{json,yaml}[.gz], smithy.json[.gz], rfcNNNN.txt, proto/, commands
|
|
41
|
-
process.exit(
|
|
44
|
+
console.error(`derive-pack: no spec at ${join(PKG, 'spec')}/openapi.{json,yaml}[.gz], smithy.json[.gz], rfcNNNN.txt, proto/, commands/, command-builders.json or client-ops.json`);
|
|
45
|
+
process.exit(SOURCE_REFUSAL);
|
|
42
46
|
}
|
|
43
47
|
const rpcService = basename(specPath) === 'proto';
|
|
44
48
|
const severalDocuments = basename(specPath) === 'openapi';
|
|
45
49
|
// a command language's table, one vendored file per command (Redis's src/commands/*.json), with its version in SOURCE.md
|
|
46
50
|
const commandTable = basename(specPath) === 'commands';
|
|
51
|
+
// a language whose commands are documents: the table a pack's extractor read from the vendor's client
|
|
52
|
+
const commandBuilders = basename(specPath) === 'command-builders.json';
|
|
47
53
|
const protoTree = (dir: string, at = ''): Record<string, string> => Object.fromEntries(readdirSync(join(dir, at), { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? Object.entries(protoTree(dir, join(at, e.name))) : e.name.endsWith('.proto') ? [[join(at, e.name), readFileSync(join(dir, at, e.name), 'utf8')]] : [])));
|
|
48
54
|
const bytes = rpcService || commandTable || severalDocuments ? Buffer.from('') : readFileSync(specPath);
|
|
49
55
|
const text = (specPath.endsWith('.gz') ? gunzipSync(bytes) : bytes).toString('utf8');
|
|
@@ -139,7 +145,7 @@ for (const c of handlerCitations(PKG)) {
|
|
|
139
145
|
const patchPath = join(PKG, 'spec', 'patches.json');
|
|
140
146
|
if (existsSync(patchPath)) {
|
|
141
147
|
for (const p of parseExactJson(readFileSync(patchPath, 'utf8')).patches as Array<{ op: string; path: string; from?: string; value?: unknown; why: string }>) {
|
|
142
|
-
if (!p.why?.trim()) { console.error(`${vendor}: spec patch ${p.op} ${p.path} gives no why`); process.exit(
|
|
148
|
+
if (!p.why?.trim()) { console.error(`${vendor}: spec patch ${p.op} ${p.path} gives no why`); process.exit(SOURCE_REFUSAL); }
|
|
143
149
|
{
|
|
144
150
|
const s = (p as Patch).source;
|
|
145
151
|
const held = s?.recording !== undefined ? unheldRecording(s.recording, s.quote)
|
|
@@ -156,7 +162,7 @@ if (severalDocuments) {
|
|
|
156
162
|
const { doc: merged, contradictions } = mergeDocuments(doc);
|
|
157
163
|
if (contradictions.length) {
|
|
158
164
|
console.error(`${vendor}: the documents contradict each other; a patch (citing what shows which holds) rules each:\n ${contradictions.join('\n ')}`);
|
|
159
|
-
process.exit(
|
|
165
|
+
process.exit(SOURCE_REFUSAL);
|
|
160
166
|
}
|
|
161
167
|
doc = merged;
|
|
162
168
|
}
|
|
@@ -166,8 +172,9 @@ const out = outAt >= 0 && process.argv[outAt + 1] ? process.argv[outAt + 1]! : j
|
|
|
166
172
|
mkdirSync(out, { recursive: true });
|
|
167
173
|
// A line protocol's surface is its commands and the replies each may answer; its manifest's transitions
|
|
168
174
|
// name commands and cite the RFC.
|
|
169
|
-
if (
|
|
170
|
-
|
|
175
|
+
if (commandBuilders) doc = fromDriverCommandBuilders(doc as DriverCommandBuilders);
|
|
176
|
+
if (lineProtocol || commandTable || commandBuilders) {
|
|
177
|
+
const spec = doc as LineSpec | CommandTableSpec | DocumentCommandTableSpec;
|
|
171
178
|
writeFileSync(join(out, 'surface.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: basename(specPath), ...spec })}\n`);
|
|
172
179
|
const manifestPath = join(PKG, 'src', 'manifest.ts');
|
|
173
180
|
if (existsSync(manifestPath)) {
|
|
@@ -184,7 +191,7 @@ if (lineProtocol || commandTable) {
|
|
|
184
191
|
}
|
|
185
192
|
if (evidence.length) {
|
|
186
193
|
console.error(`${vendor}: ${evidence.length} citation(s) do not hold:\n ${[...new Set(evidence)].join('\n ')}`);
|
|
187
|
-
process.exit(
|
|
194
|
+
process.exit(SOURCE_REFUSAL);
|
|
188
195
|
}
|
|
189
196
|
console.log(`${vendor}: spec ${spec.version}, ${spec.commands.length} commands${'anyCommand' in spec ? `; any command may answer ${spec.anyCommand.join(', ')}` : ''}`);
|
|
190
197
|
process.exit(0);
|
|
@@ -288,7 +295,7 @@ if (existsSync(manifestPath)) {
|
|
|
288
295
|
}
|
|
289
296
|
if (evidence.length) {
|
|
290
297
|
console.error(`${vendor}: ${evidence.length} citation(s) do not hold:\n ${[...new Set(evidence)].join('\n ')}`);
|
|
291
|
-
process.exit(
|
|
298
|
+
process.exit(SOURCE_REFUSAL);
|
|
292
299
|
}
|
|
293
300
|
// every state candidate of a resource the pack models is ruled on: a state field, or named not one
|
|
294
301
|
const unruled = ir.resources.flatMap((r) => {
|
|
@@ -297,7 +304,7 @@ if (existsSync(manifestPath)) {
|
|
|
297
304
|
});
|
|
298
305
|
if (unruled.length) {
|
|
299
306
|
console.error(`${vendor}: the manifest does not rule on ${unruled.length} state candidate(s): ${unruled.join(', ')} (declare each a state field or list it in notState)`);
|
|
300
|
-
process.exit(
|
|
307
|
+
process.exit(SOURCE_REFUSAL);
|
|
301
308
|
}
|
|
302
309
|
// a resource's refresh scope ("The real-system adapters", What a pack declares) names the list that enumerates it or
|
|
303
310
|
// its retrieve, or says why the vendor offers no read-back; whether every stored resource declares one is
|
|
@@ -368,14 +375,14 @@ if (existsSync(manifestPath)) {
|
|
|
368
375
|
});
|
|
369
376
|
if (scopes.length) {
|
|
370
377
|
console.error(`${vendor}: ${scopes.length} refresh scope(s) do not hold:\n ${scopes.join('\n ')}`);
|
|
371
|
-
process.exit(
|
|
378
|
+
process.exit(SOURCE_REFUSAL);
|
|
372
379
|
}
|
|
373
380
|
// the architecture's mechanical rules over the pack's own code (./p3-rules.ts): a line breaking one records why, or
|
|
374
381
|
// the build stops
|
|
375
382
|
const hits = p3RuleHits(PKG);
|
|
376
383
|
if (hits.length) {
|
|
377
384
|
console.error(`${vendor}: ${hits.length} line(s) break a Protocol 3 rule (fix it, or record why on the line: // p3: <reason>):\n ${hits.map((h) => `${h.file}:${h.line}: ${h.rule}\n ${h.text}`).join('\n ')}`);
|
|
378
|
-
process.exit(
|
|
385
|
+
process.exit(SOURCE_REFUSAL);
|
|
379
386
|
}
|
|
380
387
|
}
|
|
381
388
|
|
package/src/gate.ts
CHANGED
|
@@ -42,6 +42,39 @@ export function unitsOf(repo: string): string[] {
|
|
|
42
42
|
* routes by hand: only the fetch reaches a lane. A directory with no manifest and no lane is no Protocol 3 pack. */
|
|
43
43
|
const ROOT_ENTRIES = new Set(['README.md', 'package.json', 'LICENSE', 'CHANGELOG.md', 'tsconfig.json', 'tsconfig.build.json', 'gate.ts', 'census.json', 'vendor', 'journeys', 'spec', 'generated', 'src', 'node_modules']);
|
|
44
44
|
const ROOT_SRC = new Set(['manifest.ts', 'fetch.ts', 'index.ts', 'cli.ts', 'server.ts']);
|
|
45
|
+
/** The copied root seed is data, never an alternate executable seed runner. */
|
|
46
|
+
function rootSeedFailures(dir: string): string[] {
|
|
47
|
+
const path = join(dir, 'src', 'semantics', 'seed.ts');
|
|
48
|
+
if (!existsSync(path)) return [];
|
|
49
|
+
const text = readFileSync(path, 'utf8');
|
|
50
|
+
if (ts.transpileModule(text, { fileName: path, reportDiagnostics: true }).diagnostics?.some(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error)) return ['handler-contract: root seed has invalid TypeScript syntax'];
|
|
51
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true);
|
|
52
|
+
const literal = (node: ts.Expression): boolean => {
|
|
53
|
+
if (ts.isStringLiteral(node) || ts.isNumericLiteral(node) || [ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NullKeyword].includes(node.kind)) return true;
|
|
54
|
+
if (ts.isPrefixUnaryExpression(node)) return node.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(node.operand);
|
|
55
|
+
if (ts.isArrayLiteralExpression(node)) return node.elements.every(element => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element) && literal(element));
|
|
56
|
+
return ts.isObjectLiteralExpression(node) && node.properties.every(property => ts.isPropertyAssignment(property)
|
|
57
|
+
&& !ts.isComputedPropertyName(property.name) && literal(property.initializer));
|
|
58
|
+
};
|
|
59
|
+
let imports = 0, seeds = 0;
|
|
60
|
+
for (const statement of source.statements) {
|
|
61
|
+
if (ts.isImportDeclaration(statement)) {
|
|
62
|
+
const clause = statement.importClause, bindings = clause?.namedBindings;
|
|
63
|
+
if (!clause?.isTypeOnly || clause.name || !bindings || !ts.isNamedImports(bindings)
|
|
64
|
+
|| bindings.elements.length !== 1 || bindings.elements[0]!.name.text !== 'SeedCall' || bindings.elements[0]!.propertyName
|
|
65
|
+
|| !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== '@volter/world-core') return ['handler-contract: root seed imports only the SeedCall type from world-core'];
|
|
66
|
+
imports++; continue;
|
|
67
|
+
}
|
|
68
|
+
if (!ts.isVariableStatement(statement) || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
|
69
|
+
|| !(statement.declarationList.flags & ts.NodeFlags.Const) || statement.declarationList.declarations.length !== 1) return ['handler-contract: root seed exports only const seed: SeedCall[] literal data'];
|
|
70
|
+
const declaration = statement.declarationList.declarations[0]!;
|
|
71
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== 'seed' || !declaration.type || !ts.isArrayTypeNode(declaration.type)
|
|
72
|
+
|| !ts.isTypeReferenceNode(declaration.type.elementType) || declaration.type.elementType.typeName.getText(source) !== 'SeedCall' || declaration.type.elementType.typeArguments?.length
|
|
73
|
+
|| !declaration.initializer || !ts.isArrayLiteralExpression(declaration.initializer) || !literal(declaration.initializer)) return ['handler-contract: root seed must be literal SeedCall[] data'];
|
|
74
|
+
seeds++;
|
|
75
|
+
}
|
|
76
|
+
return imports === 1 && seeds === 1 ? [] : ['handler-contract: root seed requires one SeedCall type import and one seed export'];
|
|
77
|
+
}
|
|
45
78
|
/** What the repository ignores (a build's dist/, a World's state) is no file of the pack, as protocol-3's layout reads it. */
|
|
46
79
|
function ignored(dir: string, names: string[]): Set<string> {
|
|
47
80
|
if (!names.length) return new Set();
|
|
@@ -55,15 +88,16 @@ export function laneRootFailures(dir: string): string[] {
|
|
|
55
88
|
const skip = ignored(dir, stray);
|
|
56
89
|
const outside = [
|
|
57
90
|
...stray.filter((n) => !skip.has(n)),
|
|
58
|
-
...(existsSync(join(dir, 'spec')) ? readdirSync(join(dir, 'spec')).filter((n) => !['sources.json', 'SOURCE.md', 'README.md'].includes(n)).map((n) => `spec/${n}`) : []),
|
|
59
|
-
...(existsSync(join(dir, 'src')) ? readdirSync(join(dir, 'src')).filter((n) => !ROOT_SRC.has(n) && !n.endsWith('.d.ts') && n !== 'semantics').map((n) => `src/${n}`) : []),
|
|
91
|
+
...(existsSync(join(dir, 'spec')) ? readdirSync(join(dir, 'spec')).filter((n) => !isPackArtifact(n) && !['sources.json', 'SOURCE.md', 'README.md'].includes(n)).map((n) => `spec/${n}`) : []),
|
|
92
|
+
...(existsSync(join(dir, 'src')) ? readdirSync(join(dir, 'src')).filter((n) => !isPackArtifact(n) && !ROOT_SRC.has(n) && !n.endsWith('.d.ts') && n !== 'semantics').map((n) => `src/${n}`) : []),
|
|
60
93
|
// what the lanes share, and nothing else (architecture, "Pack layout": a vendor whose every API is a lane)
|
|
61
|
-
...(existsSync(join(dir, 'src', 'semantics')) ? readdirSync(join(dir, 'src', 'semantics')).filter((n) => n
|
|
94
|
+
...(existsSync(join(dir, 'src', 'semantics')) ? readdirSync(join(dir, 'src', 'semantics')).filter((n) => !isPackArtifact(n) && !['shared.ts', 'seed.ts'].includes(n)).map((n) => `src/semantics/${n}`) : []),
|
|
62
95
|
];
|
|
63
96
|
const failures = outside.length ? [`layout: ${outside.length} outside the vendor's root (its lanes hold the rest): ${outside.slice(0, 8).join(', ')}${outside.length > 8 ? ` and ${outside.length - 8} more` : ''}`] : [];
|
|
64
97
|
// the vendor's front is declared and the kernel serves it: a manifest, and a fetch that is createVendorFetch over it
|
|
65
98
|
const src = (n: string): string => (existsSync(join(dir, 'src', n)) ? readFileSync(join(dir, 'src', n), 'utf8') : '');
|
|
66
99
|
failures.push(...unpublishedUnitFiles(dir).map((f) => `layout: ${f}`));
|
|
100
|
+
failures.push(...rootSeedFailures(dir));
|
|
67
101
|
if (!src('manifest.ts')) failures.push("layout: no src/manifest.ts: a vendor of lanes declares its lanes' routes, discovery and descriptor");
|
|
68
102
|
if (!/\bcreateVendorFetch\(/.test(src('fetch.ts'))) failures.push('layout: src/fetch.ts builds no createVendorFetch: the kernel routes a vendor\'s lanes from its manifest, nothing by hand');
|
|
69
103
|
// the root's own files hold to the pack contract as a unit's do (the grade's kernel-contract)
|
|
@@ -85,9 +119,9 @@ export function laneRootFailures(dir: string): string[] {
|
|
|
85
119
|
}
|
|
86
120
|
|
|
87
121
|
/** Each unit's blocking form failures (empty when it may be in the repository). */
|
|
88
|
-
export async function p3Gate(units: string[], opts: Pick<GradeOptions, 'onCriterion'> = {}): Promise<Array<{ unit: string; failures: string[] }>> {
|
|
122
|
+
export async function p3Gate(units: string[], opts: Pick<GradeOptions, 'onCriterion'> = {}): Promise<Array<{ unit: string; failures: string[]; cacheable?: boolean }>> {
|
|
89
123
|
return withCheckoutFiles(async () => {
|
|
90
|
-
const out: Array<{ unit: string; failures: string[] }> = [];
|
|
124
|
+
const out: Array<{ unit: string; failures: string[]; cacheable?: boolean }> = [];
|
|
91
125
|
for (const unit of units) {
|
|
92
126
|
// a vendor whose every API is a lane has no spec of its own: its lanes are graded, and its root is only the vendor's
|
|
93
127
|
// front (architecture, "Pack layout", "Other wires: lanes")
|
|
@@ -95,7 +129,7 @@ export async function p3Gate(units: string[], opts: Pick<GradeOptions, 'onCriter
|
|
|
95
129
|
if (lanesOnly || !existsSync(join(unit, 'src', 'manifest.ts'))) { out.push({ unit, failures: laneRootFailures(unit) }); continue; }
|
|
96
130
|
const grades = await grade(unit, { static: true, sections: ['form'], ...opts });
|
|
97
131
|
const form = Object.values(grades).flatMap((g) => g.criteria).filter((c) => !c.pass);
|
|
98
|
-
out.push({ unit, failures: form.map((c) => `${c.id}: ${c.reason}`) });
|
|
132
|
+
out.push({ unit, failures: form.map((c) => `${c.id}: ${c.reason}`), cacheable: Object.values(grades).every(g => g.criteria.every(c => c.cacheable !== false)) });
|
|
99
133
|
}
|
|
100
134
|
return out;
|
|
101
135
|
});
|
package/src/grade.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { unitAt } from './unit.ts';
|
|
|
10
10
|
/** The latest version of each standard. A new protocol, or a revision, replaces or joins this list. */
|
|
11
11
|
export const STANDARDS: Standard[] = [protocol3];
|
|
12
12
|
|
|
13
|
-
export interface CriterionGrade { id: string; section: string; asks: string; pass: boolean; reason: string }
|
|
13
|
+
export interface CriterionGrade { id: string; section: string; asks: string; pass: boolean; reason: string; cacheable?: boolean }
|
|
14
14
|
export interface StandardGrade { grade: number; met: number; total: number; criteria: CriterionGrade[]; planned: string[] }
|
|
15
15
|
|
|
16
16
|
export type GradeOptions = {
|
|
@@ -40,7 +40,7 @@ export async function grade(dir: string, opts: GradeOptions = {}): Promise<Recor
|
|
|
40
40
|
if (opts.sections && !opts.sections.includes(c.section)) continue;
|
|
41
41
|
opts.onCriterion?.(name, c.id);
|
|
42
42
|
let result;
|
|
43
|
-
try { result = await c.check(unit); } catch (e) { result = { pass: false, reason: `the check threw: ${(e as Error).message.slice(0, 160)}` }; }
|
|
43
|
+
try { result = await c.check(unit); } catch (e) { result = { pass: false, cacheable: false, reason: `the check threw: ${(e as Error).message.slice(0, 160)}` }; }
|
|
44
44
|
opts.onCriterion?.(name, c.id, result);
|
|
45
45
|
criteria.push({ id: c.id, section: c.section, asks: c.asks, ...result });
|
|
46
46
|
}
|
package/src/pack-facts.ts
CHANGED
|
@@ -37,7 +37,7 @@ export type PackFacts = {
|
|
|
37
37
|
/** The Prisma driver adapter this database twin is reached through (see TwinPack.prismaAdapter). */
|
|
38
38
|
prismaAdapter?: { adapter: string; export: string; urlEnv: string };
|
|
39
39
|
/** The managed-infrastructure kind this pack is the World's backing for (architecture.md, "The descriptor"). */
|
|
40
|
-
managedService?: { kind: 'redis' | 'mongodb'; scheme: string; note: string };
|
|
40
|
+
managedService?: { kind: 'redis' | 'mongodb' | 'clickhouse'; scheme: string; note: string; configurationArg?: '--configuration'; originalInputs?: { version: 1; manifestArg: '--original-inputs'; rootArg: '--original-root'; digestArg: '--original-inputs-sha256'; paths: string[] } };
|
|
41
41
|
/** The World's managed database the runtime binds the twin to (see TwinPack.managedDatabase). */
|
|
42
42
|
managedDatabase?: { kind: 'postgres'; arg: string; note: string };
|
|
43
43
|
/** Listeners the twin opens beside its main port (see TwinPack.sidePorts). */
|
|
@@ -191,10 +191,17 @@ export async function compilePackFacts(dirs: Array<{ vendor: string; dir: string
|
|
|
191
191
|
facts.managedDatabase = pack.managedDatabase;
|
|
192
192
|
}
|
|
193
193
|
if (pack.managedService !== undefined) {
|
|
194
|
-
const ms = pack.managedService as { kind?: unknown; scheme?: unknown; note?: unknown };
|
|
195
|
-
if (ms.kind !== 'redis' && ms.kind !== 'mongodb') throw new Error(`pack-facts: ${vendor} declares managedService.kind "${String(ms.kind)}"; the managed-infrastructure kinds a twin backs are redis and
|
|
194
|
+
const ms = pack.managedService as { kind?: unknown; scheme?: unknown; note?: unknown; configurationArg?: unknown; originalInputs?: unknown };
|
|
195
|
+
if (ms.kind !== 'redis' && ms.kind !== 'mongodb' && ms.kind !== 'clickhouse') throw new Error(`pack-facts: ${vendor} declares managedService.kind "${String(ms.kind)}"; the managed-infrastructure kinds a twin backs are redis, mongodb and clickhouse`);
|
|
196
196
|
if (typeof ms.scheme !== 'string' || !/^[a-z][a-z0-9+.-]*$/.test(ms.scheme)) throw new Error(`pack-facts: ${vendor} declares managedService.scheme "${String(ms.scheme)}"; it is the published URL's scheme`);
|
|
197
197
|
if (typeof ms.note !== 'string' || ms.note.trim() === '') throw new Error(`pack-facts: ${vendor} declares managedService with no note`);
|
|
198
|
+
if (ms.configurationArg !== undefined && ms.configurationArg !== '--configuration') throw new Error(`pack-facts: ${vendor} has an unsupported managed operator-configuration flag`);
|
|
199
|
+
if (ms.originalInputs !== undefined) {
|
|
200
|
+
const input = ms.originalInputs as { version?: unknown; manifestArg?: unknown; rootArg?: unknown; digestArg?: unknown; paths?: unknown };
|
|
201
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || input.version !== 1 || input.manifestArg !== '--original-inputs' || input.rootArg !== '--original-root' || input.digestArg !== '--original-inputs-sha256'
|
|
202
|
+
|| !Array.isArray(input.paths) || !input.paths.length || new Set(input.paths).size !== input.paths.length
|
|
203
|
+
|| input.paths.some(path => typeof path !== 'string' || !path.startsWith('/') || path === '/' || path.slice(1).split('/').some(part => !part || part === '.' || part === '..' || /[\\\0\r\n]/.test(part)))) throw new Error(`pack-facts: ${vendor} has an invalid original-input consumer`);
|
|
204
|
+
}
|
|
198
205
|
if (pack.bin === undefined) throw new Error(`pack-facts: ${vendor} declares managedService with no bin; the managed infrastructure starts the pack by its bin`);
|
|
199
206
|
const other = Object.entries(packs).find(([, f]) => f.managedService?.kind === ms.kind);
|
|
200
207
|
if (other) throw new Error(`pack-facts: ${vendor} and ${other[0]} both declare managedService.kind "${ms.kind}"; one pack backs a kind`);
|
package/src/pack-files.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Release outputs and runtime state, relative to the pack directory being listed. */
|
|
2
2
|
export function isPackArtifact(path: string): boolean {
|
|
3
|
-
return /^(dist|node_modules|\.volter)(\/|$)/.test(path) || path === 'generated/pack-facts.json'
|
|
3
|
+
return /^(dist|node_modules|\.volter)(\/|$)/.test(path) || path === 'generated/pack-facts.json'
|
|
4
|
+
|| path.split('/').includes('.metadata_never_index');
|
|
4
5
|
}
|
package/src/protocol-3.ts
CHANGED
|
@@ -88,7 +88,7 @@ function filesUnder(dir: string, base = dir): string[] {
|
|
|
88
88
|
if (run.status !== 0 || run.error) {
|
|
89
89
|
throw new Error(`git ls-files failed: directory=${JSON.stringify(top)} status=${run.status} signal=${JSON.stringify(run.signal)} error=${JSON.stringify(run.error?.message ?? null)} stderr=${JSON.stringify(String(run.stderr ?? ''))}`);
|
|
90
90
|
}
|
|
91
|
-
files = [...new Set(String(run.stdout).split('\0').filter(
|
|
91
|
+
files = [...new Set(String(run.stdout).split('\0').filter(f => f && !f.split('/').includes('.metadata_never_index')))];
|
|
92
92
|
listing?.set(top, files);
|
|
93
93
|
}
|
|
94
94
|
const under = rel(top, dir);
|
|
@@ -100,6 +100,8 @@ function allFilesUnder(dir: string, base = dir, excludeArtifacts = false): strin
|
|
|
100
100
|
if (!existsSync(dir)) return [];
|
|
101
101
|
const out: string[] = [];
|
|
102
102
|
for (const name of readdirSync(dir)) {
|
|
103
|
+
// macOS directory bookkeeping is not a pack source or release output.
|
|
104
|
+
if (name === '.metadata_never_index') continue;
|
|
103
105
|
const path = join(dir, name);
|
|
104
106
|
if (excludeArtifacts && isPackArtifact(rel(base, path))) continue;
|
|
105
107
|
if (statSync(path).isDirectory()) out.push(...allFilesUnder(path, base, excludeArtifacts));
|
|
@@ -275,10 +277,24 @@ const criteria: Criterion[] = [
|
|
|
275
277
|
}
|
|
276
278
|
// the pack's own path and the scratch directory are this checkout's, not the pack's: the verdict names neither, so
|
|
277
279
|
// a standing written from one checkout reads the same from any other
|
|
278
|
-
if (run.status
|
|
280
|
+
if (run.status === null || run.signal !== null || run.error) return { pass: false, cacheable: false,
|
|
281
|
+
reason: `derive-pack interrupted: status=${run.status} signal=${run.signal} error=${run.error?.message ?? ''}` };
|
|
282
|
+
if (run.status !== 0) return { ...fail(`derive-pack refused: ${String(run.stderr || run.stdout).split(unit.dir).join('.').split(tmp).join('<out>').trim().split('\n').slice(-2).join(' ').slice(0, 200)}`), cacheable: run.status === 2 };
|
|
279
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`);
|
|
280
284
|
},
|
|
281
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
|
+
},
|
|
282
298
|
{
|
|
283
299
|
id: 'layout', section: 'form',
|
|
284
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",
|
|
@@ -462,8 +478,8 @@ const criteria: Criterion[] = [
|
|
|
462
478
|
// an error message's "from '<name>'", is none)
|
|
463
479
|
let text = readFileSync(join(src, f), 'utf8');
|
|
464
480
|
if (f === 'fetch.ts') {
|
|
465
|
-
// The generated fetch supplies the kernel wire with the pack-local parser
|
|
466
|
-
// 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.
|
|
467
483
|
const source = ts.createSourceFile(f, text, ts.ScriptTarget.Latest, true);
|
|
468
484
|
const loaders: Array<{ start: number; end: number }> = [];
|
|
469
485
|
const visit = (node: ts.Node): void => {
|
|
@@ -477,6 +493,16 @@ const criteria: Criterion[] = [
|
|
|
477
493
|
factory && ts.isCallExpression(factory) && ts.isIdentifier(factory.expression) && factory.expression.text === 'createPackFetch' &&
|
|
478
494
|
factory.arguments[0] === graphql.parent) loaders.push({ start: node.getStart(source), end: node.end });
|
|
479
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
|
+
}
|
|
480
506
|
ts.forEachChild(node, visit);
|
|
481
507
|
};
|
|
482
508
|
visit(source);
|
|
@@ -552,13 +578,13 @@ const criteria: Criterion[] = [
|
|
|
552
578
|
const manifestPath = join(unit.dir, 'src', 'manifest.ts');
|
|
553
579
|
const statesPath = join(unit.dir, 'src', 'semantics', 'states.ts');
|
|
554
580
|
let manifest: { resources?: Record<string, { state?: unknown; notState?: unknown }> } | undefined;
|
|
555
|
-
try { manifest = ((await import(manifestPath)) as { manifest?: typeof manifest }).manifest; } catch (e) { return fail(`manifest.ts does not load: ${(e as Error).message.slice(0, 120)}`); }
|
|
581
|
+
try { manifest = ((await import(manifestPath)) as { manifest?: typeof manifest }).manifest; } catch (e) { return { ...fail(`manifest.ts does not load: ${(e as Error).message.slice(0, 120)}`), cacheable: false }; }
|
|
556
582
|
const resources = Object.entries(manifest?.resources ?? {});
|
|
557
583
|
const ruled = resources.filter(([, r]) => (r.state && Object.keys(r.state as object).length) || (Array.isArray(r.notState) && r.notState.length));
|
|
558
584
|
if (!ruled.length) return pass('the pack declares no state machine and rules no candidate');
|
|
559
585
|
if (!existsSync(statesPath)) return fail(`${ruled.length} resources carry states or rulings, and there is no semantics/states.ts`);
|
|
560
586
|
let states: Record<string, { state?: unknown; notState?: unknown }> | undefined;
|
|
561
|
-
try { states = ((await import(statesPath)) as { states?: typeof states }).states; } catch (e) { return fail(`semantics/states.ts does not load: ${(e as Error).message.slice(0, 120)}`); }
|
|
587
|
+
try { states = ((await import(statesPath)) as { states?: typeof states }).states; } catch (e) { return { ...fail(`semantics/states.ts does not load: ${(e as Error).message.slice(0, 120)}`), cacheable: false }; }
|
|
562
588
|
if (!states) return fail('semantics/states.ts exports no `states`');
|
|
563
589
|
const off = ruled.filter(([name, r]) => (r.state !== undefined && r.state !== states![name]?.state) || (r.notState !== undefined && r.notState !== states![name]?.notState)).map(([name]) => name);
|
|
564
590
|
return off.length ? fail(`resources whose states or rulings are not states.ts's own: ${off.join(', ')}`) : pass(`${ruled.length} resources, their states and rulings from states.ts`);
|
package/src/spec-ir-client.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// A vendor that publishes no spec of its API but ships its own client: the API as that client calls it. Tinybird
|
|
2
2
|
// publishes no OpenAPI of its platform API (its reference pages omit endpoints its own CLI calls: /v0/tags,
|
|
3
3
|
// /v0/sql_tables), and its Python client (`tinybird/client.py`, class TinyB, in tinybird-cli on PyPI) makes every
|
|
4
|
-
// request through `_req(url, method=..., data=...)`. Each such call, read from the client's syntax tree
|
|
5
|
-
// `spec/
|
|
4
|
+
// request through `_req(url, method=..., data=...)`. Each such call, read from the client's syntax tree, is vendored as
|
|
5
|
+
// data in `spec/client-ops.json` (the program that read it is not in the pack: a pack keeps no converter in its
|
|
6
|
+
// `spec/`, Creating a pack, step 2): its method, its URL as the client
|
|
6
7
|
// builds it (a label is the Python expression the client interpolates), the query keys it sends (literal, or the keys
|
|
7
8
|
// of the dict it urlencodes) and its body's keys, each typed by the client's own annotation of the value (`null`
|
|
8
9
|
// where the client does not say). A spec patch corrects the table as it corrects a JSON spec (derive-pack): what the
|
package/src/spec-ir-commands.ts
CHANGED
|
@@ -48,3 +48,84 @@ export function fromRedisCommands(files: Record<string, RedisCommandFile>, versi
|
|
|
48
48
|
commands.sort((a, b) => a.id.localeCompare(b.id));
|
|
49
49
|
return { format: 'command-table', version, commands };
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
// ── A command language whose commands are DOCUMENTS ─────────────────────────────────────────────────────────────
|
|
53
|
+
// A command of MongoDB's wire protocol is not words with an arity: it is a document whose first key names the
|
|
54
|
+
// command and whose other keys are named fields, some of them lists of statements (an update's `updates`) with
|
|
55
|
+
// fields of their own, and every command also carries the fields the layers under it add (the session's, the
|
|
56
|
+
// connection's). The vendor publishes no table of these that a pack may derive from (its reference and its driver
|
|
57
|
+
// specifications are licensed NonCommercial); its own client is Apache-2.0 and builds every one of them. So the
|
|
58
|
+
// table is the client's statement: the client's syntax tree is read by a program (as Tinybird's Python client's is,
|
|
59
|
+
// spec-ir-client.ts) and what it read is the pack's vendored document, `spec/command-builders.json`; this reads that
|
|
60
|
+
// data into the command table. The program is kept beside that document in the pack's `spec/`, as the table's
|
|
61
|
+
// provenance, and is never a kernel script (Creating a pack, step 2).
|
|
62
|
+
//
|
|
63
|
+
// What the table says and does not: which commands a client can send and, for each, every field, its declared
|
|
64
|
+
// type, and the condition the client sends it under. It says nothing of what a server does with them. A field's
|
|
65
|
+
// condition is recorded, never evaluated: `always`; `given` (the condition tests only the option itself);
|
|
66
|
+
// `wire-version` (it depends on what the server's hello says); `other`, kept as the client wrote it.
|
|
67
|
+
|
|
68
|
+
export type FieldCondition = { form: 'always' | 'given' | 'wire-version' | 'other'; text?: string };
|
|
69
|
+
/** A field of a command document: where the client sets it and under what condition. `by` names a helper that sets
|
|
70
|
+
* it; `from` the builder it was read in, where that is not the command's own (a base class, a subclass). */
|
|
71
|
+
export type CommandField = { name: string; type: string; optional?: boolean; sentWhen: FieldCondition; source: { file: string; line: number }; by?: string; from?: string };
|
|
72
|
+
/** A command that is a document. `statements`: for a field that is a list of statements, the fields of one. */
|
|
73
|
+
export type DocumentCommandDef = { id: string; fields: CommandField[]; statements?: Record<string, CommandField[]> };
|
|
74
|
+
export type DocumentCommandTableSpec = { format: 'command-table'; language: 'document'; version: string; source: { package: string; commit: string; licence: string }; commands: DocumentCommandDef[]; envelope: CommandField[] };
|
|
75
|
+
|
|
76
|
+
type BuilderField = CommandField & { typeFrom?: string; value?: string; also?: unknown[]; spreadUnder?: string };
|
|
77
|
+
type Builder = { builder: string; kind: 'command' | 'statement'; file: string; line: number; command: string | null; class?: string; extends?: string; addsTo?: string; fields: BuilderField[] };
|
|
78
|
+
export type DriverCommandBuilders = {
|
|
79
|
+
format: 'driver-command-builders';
|
|
80
|
+
driver: { package: string; version: string; commit: string; licence: string };
|
|
81
|
+
builders: Builder[];
|
|
82
|
+
envelope: Array<{ layer: string; file: string; line: number; fields: BuilderField[] }>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const fieldOf = (f: BuilderField, from?: string): CommandField => ({
|
|
86
|
+
name: f.name, type: f.type, ...(f.optional ? { optional: true } : {}), sentWhen: f.sentWhen, source: f.source,
|
|
87
|
+
...(f.by ? { by: f.by } : {}), ...(from ? { from } : {}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** The command table of a client's command builders: each command with its own fields, the fields its subclasses
|
|
91
|
+
* add, the fields a base class adds to the commands that descend from it, and the fields of its statements. A builder this cannot place is
|
|
92
|
+
* an error naming it; a table with a silent hole is never returned. */
|
|
93
|
+
export function fromDriverCommandBuilders(doc: DriverCommandBuilders): DocumentCommandTableSpec {
|
|
94
|
+
const named = doc.builders.filter((b) => b.kind === 'command' && b.command !== null);
|
|
95
|
+
const classOf = new Map(named.filter((b) => b.class).map((b) => [b.class!, b.command!]));
|
|
96
|
+
const commands = new Map<string, DocumentCommandDef>();
|
|
97
|
+
const add = (id: string, fields: BuilderField[], from?: string): void => {
|
|
98
|
+
const held = commands.get(id) ?? { id, fields: [] };
|
|
99
|
+
for (const f of fields) if (!held.fields.some((h) => h.name === f.name)) held.fields.push(fieldOf(f, from));
|
|
100
|
+
commands.set(id, held);
|
|
101
|
+
};
|
|
102
|
+
for (const b of named) add(b.command!, b.fields);
|
|
103
|
+
// what a subclass adds to its parent's document, and what the base of every command adds to all of them
|
|
104
|
+
const everyCommand: Builder[] = [];
|
|
105
|
+
for (const b of doc.builders.filter((b) => b.kind === 'command' && b.command === null)) {
|
|
106
|
+
if (b.addsTo === undefined) throw new Error(`command-builders: ${b.builder} (${b.file}:${b.line}) names no command and adds to none`);
|
|
107
|
+
const parent = classOf.get(b.addsTo);
|
|
108
|
+
if (parent !== undefined) add(parent, b.fields, b.builder);
|
|
109
|
+
else if (b.class === b.addsTo) everyCommand.push(b);
|
|
110
|
+
else throw new Error(`command-builders: ${b.builder} (${b.file}:${b.line}) adds to ${b.addsTo}, which builds no named command`);
|
|
111
|
+
}
|
|
112
|
+
// the base's fields go to the commands whose class descends from it, and to no other (a getMore is not one)
|
|
113
|
+
const parentOf = new Map(doc.builders.filter((b) => b.class && b.extends).map((b) => [b.class!, b.extends!]));
|
|
114
|
+
const descends = (cls: string | undefined, base: string): boolean => { for (let at = cls, hops = 0; at !== undefined && hops < 16; at = parentOf.get(at), hops += 1) if (at === base) return true; return false; };
|
|
115
|
+
for (const b of everyCommand) for (const n of named) if (descends(n.extends, b.class!)) add(n.command!, b.fields, b.builder);
|
|
116
|
+
// a statement builder's fields belong to the command of its file that carries the statement list
|
|
117
|
+
for (const b of doc.builders.filter((b) => b.kind === 'statement')) {
|
|
118
|
+
const owner = named.find((n) => n.file === b.file && n.fields.some((f) => f.value === 'this.statements'));
|
|
119
|
+
const list = owner?.fields.find((f) => f.value === 'this.statements');
|
|
120
|
+
if (!owner || !list) throw new Error(`command-builders: ${b.builder} (${b.file}:${b.line}) builds a statement and no command of its file carries a statement list`);
|
|
121
|
+
(commands.get(owner.command!)!.statements ??= {})[list.name] = b.fields.map((f) => fieldOf(f));
|
|
122
|
+
}
|
|
123
|
+
const envelope: CommandField[] = [];
|
|
124
|
+
for (const layer of doc.envelope) for (const f of layer.fields) if (!envelope.some((h) => h.name === f.name)) envelope.push(fieldOf(f, layer.layer));
|
|
125
|
+
if (commands.size === 0) throw new Error('command-builders: no command was read');
|
|
126
|
+
return {
|
|
127
|
+
format: 'command-table', language: 'document', version: doc.driver.version,
|
|
128
|
+
source: { package: doc.driver.package, commit: doc.driver.commit, licence: doc.driver.licence },
|
|
129
|
+
commands: [...commands.values()].sort((a, b) => a.id.localeCompare(b.id)), envelope,
|
|
130
|
+
};
|
|
131
|
+
}
|
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 } : {}),
|