@volter/twin-standard 1.0.32 → 1.0.34
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 +9 -0
- package/dist/src/assessment.d.ts +49 -0
- package/dist/src/assessment.js +124 -0
- package/dist/src/browser-assessment.d.ts +6 -0
- package/dist/src/browser-assessment.js +69 -0
- package/dist/src/cli.js +49 -1
- package/dist/src/create-pack.d.ts +38 -0
- package/dist/src/create-pack.js +576 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/pack-facts.d.ts +117 -0
- package/dist/src/pack-facts.js +242 -0
- package/dist/src/walk/behavior-journey.d.ts +1 -0
- package/dist/src/walk/behavior-journey.js +22 -7
- package/dist/src/walk/vendor-fetch.js +1 -7
- package/package.json +7 -3
- package/src/assessment.ts +93 -0
- package/src/browser-assessment.ts +47 -0
- package/src/cli.ts +30 -1
- package/src/create-pack.ts +523 -0
- package/src/index.ts +5 -0
- package/src/pack-facts.ts +240 -0
- package/src/walk/behavior-journey.ts +21 -8
- package/src/walk/vendor-fetch.ts +1 -8
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
3
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
4
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
5
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
return path;
|
|
9
|
+
};
|
|
10
|
+
// create-pack — the files of a Protocol 3 pack that nobody should write (the board's `create-pack` card;
|
|
11
|
+
// docs/contributing/architecture.md, "Creating a pack"), and the brief an author writes the rest from.
|
|
12
|
+
//
|
|
13
|
+
// bun scripts/create-pack.ts <dir> --init <vendor> write every fixed file of a new pack that does not exist yet
|
|
14
|
+
// bun scripts/create-pack.ts <dir> --init <vendor> --lanes a,b
|
|
15
|
+
// the same for a pack of lanes, one per API of the vendor, each
|
|
16
|
+
// derived from its own spec (<dir>/<lane>/spec): each lane's
|
|
17
|
+
// manifest, fetch, index and semantics, and the pack's server
|
|
18
|
+
// sending each lane's host to it
|
|
19
|
+
// (package.json, README.md, LICENSE, src/{manifest,fetch,server,cli,
|
|
20
|
+
// index}.ts, src/semantics/{states,shared,doors}.ts,
|
|
21
|
+
// journeys/demand.json): the author fills the manifest's
|
|
22
|
+
// vendor facts, the descriptor's hosts and the families
|
|
23
|
+
// bun scripts/create-pack.ts <unit> --index write src/semantics/index.ts, the handler map, and src/fetch.ts from the
|
|
24
|
+
// families' files (every exported function, by its name)
|
|
25
|
+
// bun scripts/create-pack.ts <unit> --brief [--out f] the author's brief: every operation the derived core does not
|
|
26
|
+
// serve and no handler answers yet, by family, with what the
|
|
27
|
+
// vendored spec says of it (summary, description, parameters,
|
|
28
|
+
// the answers it documents and their examples), so a handler
|
|
29
|
+
// is written from the vendor's words, never from memory
|
|
30
|
+
//
|
|
31
|
+
// <unit> is a pack (`clerk`) or a lane (`clerk/fapi`). A brief is a work aid, printed (or written to --out); it is not
|
|
32
|
+
// part of the pack.
|
|
33
|
+
import { parseExactJson, exactInteger } from '@volter/world-core/exact-json';
|
|
34
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
36
|
+
import { gunzipSync } from 'node:zlib';
|
|
37
|
+
import { parse as parseYaml } from 'yaml';
|
|
38
|
+
import { coreFor } from '@volter/world-core';
|
|
39
|
+
import { decisionTableProblems } from "./conformance.js";
|
|
40
|
+
import { handlerName } from "./protocol-3.js";
|
|
41
|
+
import { hasOwnSurface } from "./lanes.js";
|
|
42
|
+
const FIXED = new Set(['answer-views.ts', 'states.ts', 'index.ts', 'shared.ts', 'doors.ts', 'seed.ts', 'around.ts', 'events.ts', 'clock.ts', 'tenant.ts', 'scenario.ts', 'gap.ts', 'graphql.ts', 'sockets.ts', 'board.ts']);
|
|
43
|
+
/** A family file's name as a JavaScript identifier (`well-known` is `well_known`). */
|
|
44
|
+
/** A family's name in code: as a handler's (a name JavaScript reserves, the family `default`, takes a trailing `_`). */
|
|
45
|
+
const identifier = (family) => handlerName(family);
|
|
46
|
+
/** The families' files of a unit's semantics, with the handlers each exports. */
|
|
47
|
+
function families(dir) {
|
|
48
|
+
const semantics = join(dir, 'src', 'semantics');
|
|
49
|
+
if (!existsSync(semantics))
|
|
50
|
+
return [];
|
|
51
|
+
return readdirSync(semantics)
|
|
52
|
+
// events.ts is the webhook renderer's; a vendor with an `events` family keeps that family's handlers there too
|
|
53
|
+
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && (!FIXED.has(f) || f === 'events.ts'))
|
|
54
|
+
.sort()
|
|
55
|
+
.map((f) => ({
|
|
56
|
+
family: f.replace(/\.ts$/, ''),
|
|
57
|
+
handlers: [...readFileSync(join(semantics, f), 'utf8').matchAll(/^export\s+async\s+function\s+([A-Za-z0-9_$]+)/gm)].map((m) => m[1]),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
/** src/semantics/index.ts: the handler map by operationId, keeping the name the unit's fetch imports it by. Each
|
|
61
|
+
* operation of the generated surface whose handler (named as `handlerName` names it) a family's file exports is an
|
|
62
|
+
* entry: an id no JavaScript name can hold (`storage.object.get`, `pulls/create`) is keyed as it is. */
|
|
63
|
+
export function handlerIndex(dir) {
|
|
64
|
+
const path = join(dir, 'src', 'semantics', 'index.ts');
|
|
65
|
+
const vendor = basename(dir);
|
|
66
|
+
const fallback = `${vendor.split(/[^A-Za-z0-9]+/).filter(Boolean).map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join('')}Semantics`;
|
|
67
|
+
const name = existsSync(path) ? /export\s+const\s+([A-Za-z0-9_$]+)\s*:/.exec(readFileSync(path, 'utf8'))?.[1] ?? fallback : fallback;
|
|
68
|
+
const fams = families(dir).filter((f) => f.handlers.length > 0);
|
|
69
|
+
const surfacePath = join(dir, 'src', 'generated', 'surface.gen.json');
|
|
70
|
+
// a command table's surface (Redis's) names no HTTP operation: its wire is the pack's front, so no handler is mapped
|
|
71
|
+
const ids = existsSync(surfacePath) ? (parseExactJson(readFileSync(surfacePath, 'utf8')).operations ?? []).map((o) => o.id) : [];
|
|
72
|
+
// an index written before the derive has no operations to map, and every served call would fall to the gap (404): refuse
|
|
73
|
+
// it rather than write an empty map (2026-09-30: six packs committed so)
|
|
74
|
+
if (!existsSync(surfacePath) && fams.length)
|
|
75
|
+
throw new Error(`${dir}: no src/generated/surface.gen.json yet; derive the unit (scripts/derive-pack.ts) before --index`);
|
|
76
|
+
const where = new Map(fams.flatMap((f) => f.handlers.map((h) => [h, f.family])));
|
|
77
|
+
const named = new Set([...new Set(ids)].map(handlerName));
|
|
78
|
+
const stray = [...where.keys()].filter((h) => !named.has(h));
|
|
79
|
+
// a stray export breaks the handler contract (each export an operation of its family): the index is written, and the
|
|
80
|
+
// command fails, so a caller reads it from the exit code
|
|
81
|
+
if (stray.length) {
|
|
82
|
+
console.error(`create-pack: ${basename(dir)}: ${stray.join(', ')} name${stray.length === 1 ? 's' : ''} no operation of the surface: not in the handler map`);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
}
|
|
85
|
+
const entries = [...new Set(ids)].filter((id) => where.has(handlerName(id))).sort().map((id) => ` ${/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(id) ? id : JSON.stringify(id)}: ${identifier(where.get(handlerName(id)))}.${handlerName(id)},`);
|
|
86
|
+
return [
|
|
87
|
+
'// GENERATED by scripts/create-pack.ts --index: the handler map, by operationId, from the families\' files (each',
|
|
88
|
+
'// handler named as its operation). Do not edit; add a handler to its family\'s file and generate again.',
|
|
89
|
+
"import type { Handler } from '@volter/world-core';",
|
|
90
|
+
...fams.map((f) => `import * as ${identifier(f.family)} from './${f.family}.ts';`),
|
|
91
|
+
'',
|
|
92
|
+
`export const ${name}: Record<string, Handler> = {`,
|
|
93
|
+
...entries,
|
|
94
|
+
'};',
|
|
95
|
+
'',
|
|
96
|
+
].join('\n');
|
|
97
|
+
}
|
|
98
|
+
/** What a unit serves, checked against its decision table and its demand (the P3 procedure: demand, then the decision
|
|
99
|
+
* table, then the code). `journeys/decisions.json` (the vendor's, beside its lanes) names, for each operation the unit
|
|
100
|
+
* serves, the demand it answers and the mechanism that serves it (`handler` or `core`); an operation it does not serve
|
|
101
|
+
* is `unmodeled` in the manifest. The unit's owners are the vendor's dispatch's for that unit. A problem stops the index:
|
|
102
|
+
* six packs once shipped answering 404 to everything, and scope once grew past demand, both unseen by the tools. */
|
|
103
|
+
export async function decisionProblems(dir) {
|
|
104
|
+
const out = [];
|
|
105
|
+
const journeys = existsSync(join(dir, 'journeys')) ? join(dir, 'journeys') : join(dir, '..', 'journeys');
|
|
106
|
+
const tablePath = join(journeys, 'decisions.json');
|
|
107
|
+
// the table is the vendor's, so a unit is read from the vendor's dispatch, each unit against its own operations
|
|
108
|
+
// (decisionTableProblems): an id the root and a lane share is two operations, and a lane's gap stays its gap
|
|
109
|
+
const vendorDir = journeys === join(dir, 'journeys') || !existsSync(join(dir, '..', 'src', 'fetch.ts')) ? dir : join(dir, '..');
|
|
110
|
+
const unit = vendorDir === dir ? '' : basename(dir);
|
|
111
|
+
const mod = await import(__rewriteRelativeImportExtension(resolve(vendorDir, 'src', 'fetch.ts')));
|
|
112
|
+
const make = mod[fetchName(vendorDir)];
|
|
113
|
+
const dispatch = make?.();
|
|
114
|
+
// a unit with a spec serves operations: a dispatch that names no owners cannot be checked, and passes nothing
|
|
115
|
+
if (hasOwnSurface(dir) && typeof dispatch?.owners !== 'function')
|
|
116
|
+
return [`${relative(process.cwd(), dir)}/src/fetch.ts: its dispatch names no owners() to check against the decision table`];
|
|
117
|
+
const byUnit = dispatch?.ownersByUnit?.() ?? { '': dispatch?.owners?.() ?? {} };
|
|
118
|
+
const owners = byUnit[unit] ?? {};
|
|
119
|
+
// an operation no demand names answers the vendor's gap (coverage counts it `todo`, architecture "Doors, screens and the
|
|
120
|
+
// gap"); a demanded one served by nothing is refused, and one the manifest keeps from the core is `unmodeled`
|
|
121
|
+
if (!existsSync(tablePath))
|
|
122
|
+
return [...out, `${relative(dir, tablePath)} is missing: the decision table names each served operation, its demand and its mechanism`];
|
|
123
|
+
const table = parseExactJson(readFileSync(tablePath, 'utf8'));
|
|
124
|
+
const demandPath = join(journeys, 'demand.json');
|
|
125
|
+
const demanded = existsSync(demandPath)
|
|
126
|
+
? (parseExactJson(readFileSync(demandPath, 'utf8')).applications ?? []).flatMap((a) => a.backend ?? []).flatMap((b) => String(b.operation ?? '').split(/;\s*/))
|
|
127
|
+
: [];
|
|
128
|
+
// a refresh reads the vendor's list or retrieve, and a World rooted at a second World's twin of the pack reads it from
|
|
129
|
+
// that twin (pack-done's (g)): an operation a refresh scope names is one the pack serves
|
|
130
|
+
const { manifest } = await import(__rewriteRelativeImportExtension(resolve(dir, 'src', 'manifest.ts')));
|
|
131
|
+
out.push(...decisionTableProblems(unit, byUnit, table.decisions ?? [], demanded, manifest?.doors?.map((d) => d.id) ?? []));
|
|
132
|
+
for (const [name, decl] of Object.entries(manifest?.resources ?? {})) {
|
|
133
|
+
for (const id of [decl.refresh?.list ?? decl.refresh?.get, decl.refresh?.detail])
|
|
134
|
+
if (id && id in owners && owners[id] === 'gap')
|
|
135
|
+
out.push(`${id}: ${name}'s refresh reads it, and nothing serves it (serve it, or declare refresh none with why)`);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/** A unit's fetch.ts, a fixed file: `createPackFetch` over its parts as they stand (the manifest, the surface, the handler
|
|
140
|
+
* map, and its doors, clock and screens where it has them). `--init` writes it, and `--index` again when a door file,
|
|
141
|
+
* the clock or a screen is added. `name` is the fetch's export (`createClerkFetch`, a lane's `createFapiLaneFetch`). */
|
|
142
|
+
/** A unit's fetch export: a pack's `create<Vendor>Fetch`, a lane's (a unit with no package.json of its own) `create<Lane>LaneFetch`. */
|
|
143
|
+
export const fetchName = (dir) => (existsSync(join(dir, 'package.json')) ? `create${pascal(basename(dir))}Fetch` : `create${pascal(basename(dir))}LaneFetch`);
|
|
144
|
+
export function packFetchFile(dir, name) {
|
|
145
|
+
const semantics = join(dir, 'src', 'semantics');
|
|
146
|
+
const index = join(semantics, 'index.ts');
|
|
147
|
+
const map = existsSync(index) ? /export\s+const\s+([A-Za-z0-9_$]+)\s*:/.exec(readFileSync(index, 'utf8'))?.[1] : undefined;
|
|
148
|
+
const hasDoors = existsSync(join(semantics, 'doors.ts'));
|
|
149
|
+
const hasClock = existsSync(join(semantics, 'clock.ts'));
|
|
150
|
+
const hasAround = existsSync(join(semantics, 'around.ts'));
|
|
151
|
+
const hasBoard = existsSync(join(semantics, 'board.ts'));
|
|
152
|
+
const hasTenant = existsSync(join(semantics, 'tenant.ts'));
|
|
153
|
+
const hasScenario = existsSync(join(semantics, 'scenario.ts'));
|
|
154
|
+
const hasGap = existsSync(join(semantics, 'gap.ts'));
|
|
155
|
+
const hasSockets = existsSync(join(dir, 'src', 'semantics', 'sockets.ts'));
|
|
156
|
+
// a GraphQL API beside the REST one: its resolvers and the vendor's schema (graphql-wire.ts)
|
|
157
|
+
const hasGraphql = existsSync(join(semantics, 'graphql.ts')) && existsSync(join(dir, 'src', 'generated', 'graphql-sdl.gen.json'));
|
|
158
|
+
const screensDir = join(dir, 'src', 'screens');
|
|
159
|
+
const screens = existsSync(screensDir) ? readdirSync(screensDir).filter((n) => /\.tsx?$/.test(n) && !/\.(test|uitest)\.tsx?$/.test(n) && !/^shared\.tsx?$/.test(n)).sort() : [];
|
|
160
|
+
const id = (file) => file.replace(/\.tsx?$/, '');
|
|
161
|
+
// a vendor's own lanes (a pack's directories with a manifest of their own), which its manifest's `lanes` routes to
|
|
162
|
+
const lanes = existsSync(join(dir, 'package.json')) ? readdirSync(dir).filter((n) => existsSync(join(dir, n, 'src', 'manifest.ts'))).sort() : [];
|
|
163
|
+
const laneImports = lanes.map((l) => `import { ${fetchName(join(dir, l))}, manifest as ${identifier(`${l}_manifest`)} } from '../${l}/src/index.ts';`);
|
|
164
|
+
const laneParts = `{ ${lanes.map((l) => `${JSON.stringify(l)}: { fetch: ${fetchName(join(dir, l))}(options), manifest: ${identifier(`${l}_manifest`)} }`).join(', ')} }`;
|
|
165
|
+
// a vendor whose every API is a lane: no spec of its own, only its manifest's lanes (architecture, "Other wires: lanes")
|
|
166
|
+
if (lanes.length && !hasOwnSurface(dir)) {
|
|
167
|
+
return [
|
|
168
|
+
'// GENERATED by scripts/create-pack.ts (--init, --index): a fixed file. The kernel serves the vendor\'s discovery door and',
|
|
169
|
+
"// sends each request to the lane its manifest's routes name (architecture, \"Other wires: lanes\").",
|
|
170
|
+
"import { createVendorFetch, type DerivedFetch, type PackFetchOptions } from '@volter/world-core';",
|
|
171
|
+
"import { manifest } from './manifest.ts';",
|
|
172
|
+
...laneImports,
|
|
173
|
+
'',
|
|
174
|
+
`export function ${name}(options: PackFetchOptions = {}): DerivedFetch {`,
|
|
175
|
+
` return createVendorFetch(manifest, ${laneParts});`,
|
|
176
|
+
'}',
|
|
177
|
+
'',
|
|
178
|
+
].join('\n');
|
|
179
|
+
}
|
|
180
|
+
const surfacePath = join(dir, 'src', 'generated', 'surface.gen.json');
|
|
181
|
+
// a command table's surface (Redis's commands, a line protocol's command-reply) is no HTTP surface: it is cast through unknown
|
|
182
|
+
const commandSurface = existsSync(surfacePath) && ['command-reply', 'command-table'].includes(parseExactJson(readFileSync(surfacePath, 'utf8')).format);
|
|
183
|
+
return [
|
|
184
|
+
'// GENERATED by scripts/create-pack.ts (--init, --index): a fixed file. The kernel serves the discovery door, the clock,',
|
|
185
|
+
"// the World's doors, the vendor's screens and the API from the pack's parts (architecture, \"The pack contract\").",
|
|
186
|
+
"import { createPackFetch, type DerivedFetch, type DerivedSurface, type PackFetchOptions } from '@volter/world-core';",
|
|
187
|
+
"import surface from './generated/surface.gen.json' with { type: 'json' };",
|
|
188
|
+
"import { manifest } from './manifest.ts';",
|
|
189
|
+
...(hasAround ? ["import { around } from './semantics/around.ts';"] : []),
|
|
190
|
+
...(hasBoard ? ["import { board } from './semantics/board.ts';"] : []),
|
|
191
|
+
...(hasClock ? ["import { clock } from './semantics/clock.ts';"] : []),
|
|
192
|
+
...(hasTenant ? ["import { tenant } from './semantics/tenant.ts';"] : []),
|
|
193
|
+
...(hasScenario ? ["import { scenario } from './semantics/scenario.ts';"] : []),
|
|
194
|
+
...(hasGap ? ["import { gap } from './semantics/gap.ts';"] : []),
|
|
195
|
+
...(hasSockets ? ["import * as sockets from './semantics/sockets.ts';"] : []),
|
|
196
|
+
...(hasGraphql ? ["import { graphql } from './semantics/graphql.ts';", "import schema from './generated/graphql-sdl.gen.json' with { type: 'json' };", "import graphqlSurface from './generated/graphql.gen.json' with { type: 'json' };"] : []),
|
|
197
|
+
...(hasDoors ? ["import * as doors from './semantics/doors.ts';"] : []),
|
|
198
|
+
...(map ? [`import { ${map} } from './semantics/index.ts';`] : []),
|
|
199
|
+
...screens.map((f) => `import { screen as ${identifier(`screen_${id(f)}`)} } from './screens/${f}';`),
|
|
200
|
+
...laneImports,
|
|
201
|
+
'',
|
|
202
|
+
`export function ${name}(options: PackFetchOptions = {}): DerivedFetch {`,
|
|
203
|
+
' return createPackFetch({',
|
|
204
|
+
' manifest,',
|
|
205
|
+
commandSurface ? ' surface: surface as unknown as DerivedSurface,' : ' surface: surface as DerivedSurface,',
|
|
206
|
+
...(map ? [` handlers: ${map},`] : []),
|
|
207
|
+
...(hasDoors ? [' doors,'] : []),
|
|
208
|
+
...(hasAround ? [' around,'] : []),
|
|
209
|
+
...(hasBoard ? [' board,'] : []),
|
|
210
|
+
...(hasClock ? [' clock,'] : []),
|
|
211
|
+
...(hasTenant ? [' tenant,'] : []),
|
|
212
|
+
...(hasScenario ? [' scenario,'] : []),
|
|
213
|
+
...(hasGap ? [' gap,'] : []),
|
|
214
|
+
...(hasSockets ? [' sockets,'] : []),
|
|
215
|
+
...(hasGraphql ? [' graphql: { ...graphql, operations: graphqlSurface.operations as NonNullable<typeof graphql.operations>, sdl: schema.sdl },'] : []),
|
|
216
|
+
...(screens.length ? [` screens: { ${screens.map((f) => `${JSON.stringify(id(f))}: ${identifier(`screen_${id(f)}`)}`).join(', ')} },`] : []),
|
|
217
|
+
...(lanes.length ? [` lanes: ${laneParts},`] : []),
|
|
218
|
+
' }, options);',
|
|
219
|
+
'}',
|
|
220
|
+
'',
|
|
221
|
+
].join('\n');
|
|
222
|
+
}
|
|
223
|
+
/** The vendored OpenAPI document's operations by operationId (other spec formats have no brief yet). */
|
|
224
|
+
function specOperations(dir) {
|
|
225
|
+
const file = ['openapi.json.gz', 'openapi.json', 'openapi.yaml.gz', 'openapi.yaml'].map((f) => join(dir, 'spec', f)).find((p) => existsSync(p));
|
|
226
|
+
const out = new Map();
|
|
227
|
+
if (!file)
|
|
228
|
+
return out;
|
|
229
|
+
const bytes = readFileSync(file);
|
|
230
|
+
const text = (file.endsWith('.gz') ? gunzipSync(bytes) : bytes).toString('utf8');
|
|
231
|
+
const doc = (file.includes('.yaml') ? parseExactJson(JSON.stringify(parseYaml(text, { intAsBigInt: true }), (_, v) => typeof v === 'bigint' ? exactInteger(String(v)) : v)) : parseExactJson(text));
|
|
232
|
+
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
233
|
+
for (const [method, op] of Object.entries(item ?? {})) {
|
|
234
|
+
if (!op || typeof op !== 'object')
|
|
235
|
+
continue;
|
|
236
|
+
if (typeof op.operationId === 'string')
|
|
237
|
+
out.set(op.operationId, op);
|
|
238
|
+
// an operation without an operationId is derived as method_path; index it by that too
|
|
239
|
+
else
|
|
240
|
+
out.set(`${method.toLowerCase()}_${path.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')}`, op);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
out.components = doc.components;
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
const oneLine = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
247
|
+
/** The author's brief: the operations left to write, by family, each with the spec's words. */
|
|
248
|
+
export async function brief(unit) {
|
|
249
|
+
const dir = resolve(unit);
|
|
250
|
+
const surface = parseExactJson(readFileSync(join(dir, 'src', 'generated', 'surface.gen.json'), 'utf8'));
|
|
251
|
+
const { manifest } = (await import(__rewriteRelativeImportExtension(join(dir, 'src', 'manifest.ts'))));
|
|
252
|
+
const core = coreFor(manifest);
|
|
253
|
+
const written = new Set(families(dir).flatMap((f) => f.handlers));
|
|
254
|
+
const unmodeled = new Set(manifest.unmodeled ?? []);
|
|
255
|
+
const spec = specOperations(dir);
|
|
256
|
+
const components = spec.components;
|
|
257
|
+
const left = surface.operations.filter((o) => !core.owns(o) && !written.has(handlerName(o.id)));
|
|
258
|
+
const byFamily = new Map();
|
|
259
|
+
for (const o of left) {
|
|
260
|
+
const family = o.family ?? (o.path.split('/').filter((s) => s && !/^v\d/.test(s) && !s.startsWith('{'))[0] ?? 'operations').replace(/^\.well-known$/, 'well-known');
|
|
261
|
+
byFamily.set(family, [...(byFamily.get(family) ?? []), o]);
|
|
262
|
+
}
|
|
263
|
+
const lines = [`# ${unit}: the operations left to write`, '', `${left.length} of ${surface.operations.length} operations: the derived core does not serve them and no handler answers them yet${unmodeled.size ? ` (${[...unmodeled].filter((id) => left.some((o) => o.id === id)).length} of them named unmodeled)` : ''}. Each handler goes in \`src/semantics/<family>.ts\`, named as its operation, under the handler contract; every rule it writes cites the spec or a page beside it (\`// source:\`).`, ''];
|
|
264
|
+
for (const [family, ops] of [...byFamily].sort(([a], [b]) => a.localeCompare(b))) {
|
|
265
|
+
lines.push(`## ${family}`, '');
|
|
266
|
+
for (const o of ops) {
|
|
267
|
+
const s = spec.get(o.id);
|
|
268
|
+
lines.push(`### ${o.id}${unmodeled.has(o.id) ? ' (unmodeled)' : ''}`, '', `\`${o.method.toUpperCase()} ${o.path}\` — ${o.class}${s?.summary ? `: ${oneLine(s.summary)}` : ''}`);
|
|
269
|
+
// the citation a handler carries, ready to paste: by operationId where the spec names one, else by its JSON pointer,
|
|
270
|
+
// quoting the spec's own words (derive-pack refuses a quote the spec does not hold)
|
|
271
|
+
const words = oneLine(s?.summary ?? s?.description ?? '').split(/(?<=\.)\s/)[0].replace(/"/g, '\\"');
|
|
272
|
+
const cite = s && s.operationId === o.id ? `spec:${o.id}` : `spec:/paths/${o.path.replace(/~/g, '~0').replace(/\//g, '~1')}/${o.method.toLowerCase()}`;
|
|
273
|
+
if (words)
|
|
274
|
+
lines.push('', `Cite: \`// source: ${cite} "${words}"\``);
|
|
275
|
+
if (s?.description)
|
|
276
|
+
lines.push('', `> ${oneLine(s.description)}`);
|
|
277
|
+
const params = [...o.query ?? [], ...(o.body ?? [])];
|
|
278
|
+
if (params.length)
|
|
279
|
+
lines.push('', `Parameters: ${params.map((p) => `\`${p.name}\`${p.required ? ' (required)' : ''}`).join(', ')}`);
|
|
280
|
+
const answers = Object.entries(s?.responses ?? {}).map(([status, r]) => {
|
|
281
|
+
const ref = r.$ref;
|
|
282
|
+
const described = ref ? components?.responses?.[ref.split('/').at(-1)]?.description ?? ref.split('/').at(-1) : r.description;
|
|
283
|
+
return `\`${status}\` ${oneLine(described)}`;
|
|
284
|
+
});
|
|
285
|
+
if (answers.length)
|
|
286
|
+
lines.push('', `Documented answers: ${answers.join('; ')}`);
|
|
287
|
+
lines.push('');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return lines.join('\n');
|
|
291
|
+
}
|
|
292
|
+
/** A pack's `src/index.ts` (a fixed file): the descriptor registered with what the kernel derives from the pack's
|
|
293
|
+
* generated surface (the real-system adapters), or, for a vendor whose every API is a lane, each lane's manifest and
|
|
294
|
+
* surface. A lane's own index is not a pack's and is not written here. */
|
|
295
|
+
export function packIndexFile(dir, vendor) {
|
|
296
|
+
const Name = pascal(vendor);
|
|
297
|
+
const lanes = readdirSync(dir).filter((n) => existsSync(join(dir, n, 'src', 'manifest.ts'))).sort();
|
|
298
|
+
const hasGraphql = existsSync(join(dir, 'src', 'generated', 'graphql-sdl.gen.json'));
|
|
299
|
+
const gqlLanes = new Set(lanes.filter((l) => existsSync(join(dir, l, 'src', 'generated', 'graphql-sdl.gen.json'))));
|
|
300
|
+
const laneSurface = (l) => gqlLanes.has(l) ? `{ ...${id(l)}Surface, graphql: { sdl: ${id(l)}Schema.sdl } }` : `${id(l)}Surface`;
|
|
301
|
+
const lanesOnly = lanes.length > 0 && !hasOwnSurface(dir);
|
|
302
|
+
const id = (l) => l.replace(/[^A-Za-z0-9]/g, '_');
|
|
303
|
+
// a unit with no generated surface (a gRPC one's is proto.gen.json) has nothing to derive the vendor-backed half from
|
|
304
|
+
if (!lanesOnly && !existsSync(join(dir, 'src', 'generated', 'surface.gen.json')))
|
|
305
|
+
return initFiles(vendor)['src/index.ts'];
|
|
306
|
+
if (lanes.some((l) => !existsSync(join(dir, l, 'src', 'generated', 'surface.gen.json'))))
|
|
307
|
+
return initFiles(vendor)['src/index.ts'];
|
|
308
|
+
return [
|
|
309
|
+
`// @volter/twin-${vendor} — the ${vendor} twin (Protocol 3). A fixed file (create-pack): the descriptor is the manifest's, and the`,
|
|
310
|
+
`// kernel derives the vendor-backed half from the generated surface, unless the manifest declares none (vendorBacked.none;`,
|
|
311
|
+
`// architecture, "The real-system adapters").`,
|
|
312
|
+
`import { packOf, registerPack, type DerivedSurface } from '@volter/world-core';`,
|
|
313
|
+
`import { manifest } from './manifest.ts';`,
|
|
314
|
+
...lanes.flatMap((l) => [`import { manifest as ${id(l)}Manifest } from '../${l}/src/manifest.ts';`, `import ${id(l)}Surface from '../${l}/src/generated/surface.gen.json' with { type: 'json' };`, ...(gqlLanes.has(l) ? [`import ${id(l)}Schema from '../${l}/src/generated/graphql-sdl.gen.json' with { type: 'json' };`] : [])]),
|
|
315
|
+
...(lanesOnly ? [] : [`import surface from './generated/surface.gen.json' with { type: 'json' };`]),
|
|
316
|
+
...(hasGraphql ? [`import schema from './generated/graphql-sdl.gen.json' with { type: 'json' };`] : []),
|
|
317
|
+
'',
|
|
318
|
+
`export { create${Name}Fetch } from './fetch.ts';`,
|
|
319
|
+
`export { create${Name}TwinServer } from './server.ts';`,
|
|
320
|
+
...(existsSync(join(dir, 'src', 'stream.ts')) ? [`export { create${Name}TwinStream } from './stream.ts';`] : []),
|
|
321
|
+
...(existsSync(join(dir, 'src', 'semantics', 'answer-views.ts')) ? [`export { answerViews } from './semantics/answer-views.ts';`] : []),
|
|
322
|
+
'export { manifest };',
|
|
323
|
+
'',
|
|
324
|
+
lanesOnly
|
|
325
|
+
? `export const pack = registerPack(packOf(manifest, { ${lanes.map((l) => `${JSON.stringify(l)}: { manifest: ${id(l)}Manifest, surface: ${laneSurface(l)} as unknown as DerivedSurface }`).join(', ')} }));`
|
|
326
|
+
: lanes.length
|
|
327
|
+
// an API of its own with lanes beside it (Clerk's Frontend API): one state system over all of them
|
|
328
|
+
? `export const pack = registerPack(packOf(manifest, ${hasGraphql ? '{ ...surface, graphql: { sdl: schema.sdl } }' : 'surface'} as unknown as DerivedSurface, { ${lanes.map((l) => `${JSON.stringify(l)}: { manifest: ${id(l)}Manifest, surface: ${laneSurface(l)} as unknown as DerivedSurface }`).join(', ')} }));`
|
|
329
|
+
: `export const pack = registerPack(packOf(manifest, ${hasGraphql ? '{ ...surface, graphql: { sdl: schema.sdl } }' : 'surface'} as unknown as DerivedSurface));`,
|
|
330
|
+
'',
|
|
331
|
+
].join('\n');
|
|
332
|
+
}
|
|
333
|
+
/** A pack's `src/server.ts` (a fixed file), the kernel's serve seam around its fetch, naming only what its unit has so
|
|
334
|
+
* it typechecks and a bundler follows every import: the plain seam (serveHttp, which takes the sockets a fetch carries
|
|
335
|
+
* itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema derive-pack generated
|
|
336
|
+
* (src/generated/proto.gen.json, imported by its literal path). `--init` writes the plain one, `--index` the gRPC one once
|
|
337
|
+
* the unit's proto is derived. */
|
|
338
|
+
export function serverFile(vendor, grpc) {
|
|
339
|
+
const Name = pascal(vendor);
|
|
340
|
+
const head = `// FETCH-FIRST (runtime contract R12b): a fixed file, the kernel's serve seam around the pack's fetch (./fetch.ts).\n`;
|
|
341
|
+
const signature = `export async function create${Name}TwinServer(options: PackFetchOptions & { port?: number } = {}): Promise<{ port: number; stop: () => void }> {\n`;
|
|
342
|
+
if (grpc) {
|
|
343
|
+
return `${head}import { grpc, type PackFetchOptions } from '@volter/world-core';\nimport schema from './generated/proto.gen.json' with { type: 'json' };\nimport { create${Name}Fetch } from './fetch.ts';\n\n${signature} const fetch = create${Name}Fetch(options);\n // the vendor speaks gRPC (its manifest's \`grpc\`): HTTP/2 and HTTP/1 on one port, each rpc through the fetch\n return grpc.serveGrpc({ ...(options.port ? { port: options.port } : {}), fetch: async (r) => fetch(r), schema: schema as unknown as Parameters<typeof grpc.serveGrpc>[0]['schema'] });\n}\n`;
|
|
344
|
+
}
|
|
345
|
+
return `${head}import { serveHttp, type PackFetchOptions } from '@volter/world-core';\nimport { create${Name}Fetch } from './fetch.ts';\n\n${signature} const server = await serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch: create${Name}Fetch(options) });\n return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };\n}\n`;
|
|
346
|
+
}
|
|
347
|
+
/** A vendor id as a name in code: `npm-registry` is `NpmRegistry`. */
|
|
348
|
+
const pascal = (vendor) => vendor.split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
349
|
+
/** A pack's tsconfig.json, a fixed file: what the grade's typecheck (soundness) compiles, the pack and its lanes. */
|
|
350
|
+
export const TSCONFIG = `${JSON.stringify({
|
|
351
|
+
compilerOptions: { target: 'ES2022', module: 'ESNext', moduleResolution: 'bundler', allowImportingTsExtensions: true, resolveJsonModule: true, jsx: 'react-jsx', strict: true, skipLibCheck: true, types: ['node', 'bun'], noEmit: true },
|
|
352
|
+
include: ['**/src/**/*.ts', '**/src/**/*.tsx'],
|
|
353
|
+
exclude: ['node_modules'],
|
|
354
|
+
}, null, 2)}\n`;
|
|
355
|
+
/** The fixed files of a new pack, by path, for a vendor: what nobody should write by hand. */
|
|
356
|
+
export function initFiles(vendor) {
|
|
357
|
+
const Name = pascal(vendor);
|
|
358
|
+
const semantics = `${Name[0].toLowerCase()}${Name.slice(1)}Semantics`;
|
|
359
|
+
return {
|
|
360
|
+
'package.json': `${JSON.stringify({
|
|
361
|
+
name: `@volter/twin-${vendor}`, version: '1.0.0', description: `The ${vendor} twin (Protocol 3).`, author: 'Volter (https://github.com/volter-ai)', license: 'Apache-2.0',
|
|
362
|
+
type: 'module', exports: { '.': './src/index.ts' }, bin: { [`world-${vendor}`]: 'src/cli.ts' }, files: ['src', 'journeys', 'spec', 'README.md', '!**/*.test.ts'],
|
|
363
|
+
peerDependencies: { '@volter/world-core': '*' }, engines: { node: '>=22.3' },
|
|
364
|
+
}, null, 2)}\n`,
|
|
365
|
+
'tsconfig.json': TSCONFIG,
|
|
366
|
+
'README.md': `# @volter/twin-${vendor}\n\nThe ${vendor} twin (Protocol 3). Its surface is derived from the vendor's spec (\`spec/\`, provenance in\n\`spec/SOURCE.md\`); what it serves and why is its manifest and semantics.\n\nRules, layout and tools: twin-world's architecture ("Protocol 3", "The catalog: where twins come from").\n`,
|
|
367
|
+
'journeys/demand.json': `${JSON.stringify({ measured: 'FILL: when and how the applications were read', applications: [] }, null, 1)}\n`,
|
|
368
|
+
'src/semantics/states.ts': `// ${vendor}'s state machines and state rulings, each transition cited (architecture, "State").\nexport const states = {};\n`,
|
|
369
|
+
'src/semantics/shared.ts': `// What every family of ${vendor} shares: its stored shapes, who is calling, and the vendor's answers.\nimport type { HandlerContext } from '@volter/world-core';\n\nexport type Row = Record<string, unknown>;\nexport type { HandlerContext };\n`,
|
|
370
|
+
'src/semantics/doors.ts': `// The World's doors (architecture, "Doors, screens and the gap"): what the vendor's own pages do that its API does\n// not, each declared in the manifest's \`doors\` (its id, method and path under \`/_twin/\`) and answered here by the\n// export its id names, over the contract's context.\nimport type { HandlerContext } from '@volter/world-core';\n\nexport type { HandlerContext };\n`,
|
|
371
|
+
'src/manifest.ts': `// ${vendor}'s manifest: the vendor facts its spec does not carry (architecture, "Protocol 3"). The surface is generated\n// (./generated/surface.gen.json, from ../spec by derive-pack). Every line marked FILL is a vendor fact to state from\n// the vendor's pages, cited.\nimport type { DerivedManifest } from '@volter/world-core';\n\nexport const manifest: DerivedManifest = {\n vendor: '${vendor}',\n service: '${vendor}',\n body: { json: 'always' },\n ids: { template: '{uuid}' }, // FILL: the vendor's id form\n time: 'iso', // FILL: iso, unix or unix-ms\n error: { error: { message: '{message}', code: '{code}' } }, // FILL: the vendor's error body\n readOnly: { status: 403, message: 'This twin was started read-only; writes are refused.' }, // FILL: the vendor's refusal\n notFound: { status: 404, message: 'Not found' }, // FILL: the vendor's not-found\n list: { style: 'envelope', envelope: { data: '{data}' }, limit: { param: 'limit', default: 20, max: 100 } }, // FILL: its paging\n deleted: {},\n resources: {}, // FILL: the resources the core serves (plain create, read, update, delete, list)\n discovery: { twinOf: 'FILL: what the twin is of', stores: 'FILL: what it keeps' },\n // the descriptor, as data (twin-world's architecture, "The descriptor"): the pack registers packOf(manifest)\n descriptor: {\n protocol: '3',\n transport: 'rest',\n archetype: 'crud',\n bin: 'world-${vendor}',\n resources: [], // FILL: the stored types\n specSource: 'FILL: the vendored spec (spec/SOURCE.md)',\n description: 'FILL: one line',\n adoption: { sdks: [], envStems: [] }, // FILL: its npm clients, and each credential variable's stem (TRIPO for TRIPO_API_KEY: the suffix removed)\n hosts: [], // FILL: the vendor's hosts\n endpointEnvNone: 'FILL: the SDK\\'s base-URL variable (endpointEnv) or why none',\n },\n};\n`,
|
|
372
|
+
'src/server.ts': serverFile(vendor, false),
|
|
373
|
+
'src/cli.ts': `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the World's managed Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n// the World's scenario file, for a model vendor's turns\nconst scenarioPath = optionValue(rest, '--scenario') || undefined;\n\nif (cmd === 'serve' || cmd === undefined) {\n const s = await create${Name}TwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {}), ...(scenarioPath ? { scenarioPath } : {}) });\n process.stdout.write(\`${vendor} twin\${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:\${s.port}\\n\`);\n await keepProcessAlive();\n} else {\n process.stdout.write('Usage: world-${vendor} serve [--port N] [--root DIR] [--read-only] [--database URL] [--scenario FILE]\\n');\n}\n`,
|
|
374
|
+
'src/index.ts': `// @volter/twin-${vendor} — the ${vendor} twin (Protocol 3). A fixed file: the descriptor is the manifest's.\nimport { packOf, registerPack } from '@volter/world-core';\nimport { manifest } from './manifest.ts';\n\nexport { create${Name}Fetch } from './fetch.ts';\nexport { create${Name}TwinServer } from './server.ts';\nexport { manifest };\n\n// The descriptor: twin-world's architecture, "The descriptor"; declared in manifest.ts.\nexport const pack = registerPack(packOf(manifest));\n`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/** The fixed files of a pack of lanes (Cloudflare's API and R2, Sigstore's Fulcio and Rekor): each lane a derived
|
|
378
|
+
* surface of its own under `<lane>/src`, keeping the vendor's one store and sharing its `src/semantics/shared.ts`, and the
|
|
379
|
+
* pack's server routing each lane's host to it. */
|
|
380
|
+
export function laneInitFiles(vendor, lanes) {
|
|
381
|
+
const Name = pascal(vendor);
|
|
382
|
+
const camel = (s) => { const p = pascal(s); return `${p[0].toLowerCase()}${p.slice(1)}`; };
|
|
383
|
+
const single = initFiles(vendor);
|
|
384
|
+
const files = {
|
|
385
|
+
'package.json': single['package.json'].replace('"spec",', lanes.map((l) => `"${l}",`).join('\n ')),
|
|
386
|
+
'README.md': `# @volter/twin-${vendor}\n\nThe ${vendor} twin (Protocol 3), one lane per API: ${lanes.map((l) => `\`${l}/\``).join(', ')}, each derived from its own spec\n(\`<lane>/spec/\`, provenance in its SOURCE.md). FILL: what each lane is of.\n`,
|
|
387
|
+
'journeys/demand.json': single['journeys/demand.json'],
|
|
388
|
+
'tsconfig.json': TSCONFIG,
|
|
389
|
+
// the vendor's front, as data: its lanes and how its gateway routes them, its discovery facts, its descriptor. The
|
|
390
|
+
// kernel serves it (createVendorFetch, from the generated fetch.ts); nothing routes by hand
|
|
391
|
+
'src/manifest.ts': [
|
|
392
|
+
`// ${vendor}'s manifest: a vendor whose every API is a lane (architecture, "Other wires: lanes"). The kernel sends a`,
|
|
393
|
+
`// request to the lane whose manifest declares its /_twin/ door, else to the first route whose conditions hold, else to`,
|
|
394
|
+
`// the default lane. Every line marked FILL is a vendor fact to state from the vendor's pages, cited.`,
|
|
395
|
+
`import type { VendorManifest } from '@volter/world-core';`,
|
|
396
|
+
``,
|
|
397
|
+
`export const manifest: VendorManifest = {`,
|
|
398
|
+
` vendor: '${vendor}',`,
|
|
399
|
+
` discovery: { twinOf: 'FILL: what the twin is of', stores: 'FILL: what it keeps' },`,
|
|
400
|
+
` lanes: {`,
|
|
401
|
+
` // FILL: each lane's host (a regular expression), path prefix or header, as the vendor's clients reach it`,
|
|
402
|
+
` routes: [${lanes.map((l) => `{ lane: ${JSON.stringify(l)}, host: 'FILL' }`).join(', ')}],`,
|
|
403
|
+
` },`,
|
|
404
|
+
` descriptor: {`,
|
|
405
|
+
` protocol: '3',`,
|
|
406
|
+
` transport: 'rest',`,
|
|
407
|
+
` archetype: 'crud',`,
|
|
408
|
+
` bin: 'world-${vendor}',`,
|
|
409
|
+
` resources: [], // FILL: the stored types`,
|
|
410
|
+
` specSource: 'FILL: each lane\\'s vendored spec (<lane>/spec/SOURCE.md)',`,
|
|
411
|
+
` description: 'FILL: one line',`,
|
|
412
|
+
` adoption: { sdks: [], envStems: [] }, // FILL`,
|
|
413
|
+
` hosts: [], // FILL: every lane's hosts`,
|
|
414
|
+
` endpointEnvNone: 'FILL: the SDK\\'s base-URL variable (endpointEnv) or why none',`,
|
|
415
|
+
` },`,
|
|
416
|
+
`};`,
|
|
417
|
+
``,
|
|
418
|
+
].join('\n'),
|
|
419
|
+
'src/semantics/shared.ts': single['src/semantics/shared.ts'].replace(`What every family of ${vendor} shares`, `What every lane of ${vendor} shares`),
|
|
420
|
+
'src/server.ts': serverFile(vendor, false),
|
|
421
|
+
'src/cli.ts': single['src/cli.ts'],
|
|
422
|
+
'src/index.ts': single['src/index.ts'],
|
|
423
|
+
};
|
|
424
|
+
for (const lane of lanes) {
|
|
425
|
+
const L = pascal(lane);
|
|
426
|
+
const one = initFiles(lane);
|
|
427
|
+
// every lane keeps the vendor's one store (its manifest names the vendor's service); a lane that is an independent
|
|
428
|
+
// service of its own (Sigstore's Fulcio and Rekor) names `<vendor>-<lane>` instead, by the author's ruling
|
|
429
|
+
files[`${lane}/src/manifest.ts`] = one['src/manifest.ts'].replace(`// ${lane}'s manifest:`, `// The ${lane} lane's manifest:`).replace(`vendor: '${lane}'`, `vendor: '${vendor}'`).replace(`service: '${lane}'`, `service: '${vendor}'`);
|
|
430
|
+
files[`${lane}/src/semantics/states.ts`] = one['src/semantics/states.ts'];
|
|
431
|
+
// what the lanes share is the vendor's (src/semantics/shared.ts); each lane's shared.ts re-exports it
|
|
432
|
+
files[`${lane}/src/semantics/shared.ts`] = `// What the ${lane} lane shares: the vendor's (../../../src/semantics/shared.ts).\nexport * from '../../../src/semantics/shared.ts';\n`;
|
|
433
|
+
files[`${lane}/src/index.ts`] = `// The ${lane} lane of the ${vendor} pack: the vendor's manifest routes its requests here.\nexport { create${L}LaneFetch } from './fetch.ts';\nexport { manifest } from './manifest.ts';\n`;
|
|
434
|
+
}
|
|
435
|
+
return files;
|
|
436
|
+
}
|
|
437
|
+
export async function createPack(args) {
|
|
438
|
+
const [unit, ...rest] = args;
|
|
439
|
+
if (!unit || (!rest.includes('--index') && !rest.includes('--brief') && !rest.includes('--init'))) {
|
|
440
|
+
console.error('usage: bun scripts/create-pack.ts <dir> --init <vendor> | <unit> --index | --brief [--out <file>]');
|
|
441
|
+
process.exit(2);
|
|
442
|
+
}
|
|
443
|
+
const dir = resolve(unit);
|
|
444
|
+
if (rest.includes('--init')) {
|
|
445
|
+
const vendor = rest[rest.indexOf('--init') + 1];
|
|
446
|
+
if (!vendor || !/^[a-z0-9-]+$/.test(vendor)) {
|
|
447
|
+
console.error('create-pack --init: name the vendor id ([a-z0-9-]+)');
|
|
448
|
+
process.exit(2);
|
|
449
|
+
}
|
|
450
|
+
const lanesArg = rest.includes('--lanes') ? String(rest[rest.indexOf('--lanes') + 1] ?? '').split(',').filter(Boolean) : [];
|
|
451
|
+
if (lanesArg.some((l) => !/^[a-z0-9-]+$/.test(l))) {
|
|
452
|
+
console.error('create-pack --init --lanes: each lane an id ([a-z0-9-]+)');
|
|
453
|
+
process.exit(2);
|
|
454
|
+
}
|
|
455
|
+
for (const [rel, text] of Object.entries(lanesArg.length ? laneInitFiles(vendor, lanesArg) : initFiles(vendor))) {
|
|
456
|
+
const path = join(dir, rel);
|
|
457
|
+
if (existsSync(path)) {
|
|
458
|
+
console.log(`create-pack: kept ${rel}`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
462
|
+
writeFileSync(path, text);
|
|
463
|
+
console.log(`create-pack: wrote ${rel}`);
|
|
464
|
+
}
|
|
465
|
+
if (rest.includes('--package')) {
|
|
466
|
+
const name = rest[rest.indexOf('--package') + 1];
|
|
467
|
+
if (!name || !/^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(name))
|
|
468
|
+
throw new Error('--package needs a scoped npm name');
|
|
469
|
+
const file = join(dir, 'package.json');
|
|
470
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8'));
|
|
471
|
+
pkg.name = name;
|
|
472
|
+
delete pkg.author;
|
|
473
|
+
pkg.files = [...new Set([...pkg.files, 'generated', 'LICENSE'])];
|
|
474
|
+
writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
475
|
+
}
|
|
476
|
+
// the package's licence, the platform's (Apache-2.0), as every published pack carries it
|
|
477
|
+
const licence = join(dir, 'LICENSE');
|
|
478
|
+
if (!existsSync(licence)) {
|
|
479
|
+
writeFileSync(licence, readFileSync(new URL('../LICENSE', import.meta.url)));
|
|
480
|
+
console.log('create-pack: wrote LICENSE');
|
|
481
|
+
}
|
|
482
|
+
// the handler map each fetch imports, empty until the families are written (--index again then)
|
|
483
|
+
for (const at of lanesArg.length ? [...lanesArg.map((l) => join(dir, l)), dir] : [dir]) {
|
|
484
|
+
if (at === dir && lanesArg.length) {
|
|
485
|
+
const f = join(dir, 'src', 'fetch.ts');
|
|
486
|
+
if (!existsSync(f)) {
|
|
487
|
+
writeFileSync(f, packFetchFile(dir, fetchName(dir)));
|
|
488
|
+
console.log('create-pack: wrote src/fetch.ts');
|
|
489
|
+
}
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const index = join(at, 'src', 'semantics', 'index.ts');
|
|
493
|
+
if (!existsSync(index)) {
|
|
494
|
+
mkdirSync(dirname(index), { recursive: true });
|
|
495
|
+
writeFileSync(index, handlerIndex(at));
|
|
496
|
+
console.log(`create-pack: wrote ${relative(dir, index)}`);
|
|
497
|
+
}
|
|
498
|
+
const fetchPath = join(at, 'src', 'fetch.ts');
|
|
499
|
+
if (!existsSync(fetchPath)) {
|
|
500
|
+
writeFileSync(fetchPath, packFetchFile(at, fetchName(at)));
|
|
501
|
+
console.log(`create-pack: wrote ${relative(dir, fetchPath)}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (rest.includes('--index')) {
|
|
506
|
+
// a vendor whose every API is a lane has no handlers of its own: only its fetch
|
|
507
|
+
const lanesOnly = !hasOwnSurface(dir) && readdirSync(dir).some((n) => existsSync(join(dir, n, 'src', 'manifest.ts')));
|
|
508
|
+
// the index maps the derived surface's operations: before the derive there is none, so say what comes first
|
|
509
|
+
if (!lanesOnly && !existsSync(join(dir, 'src', 'generated', 'surface.gen.json'))) {
|
|
510
|
+
console.error(`create-pack --index: ${relative(process.cwd(), dir) || dir} has no src/generated/surface.gen.json yet: derive it first (twin-standard derive ${relative(process.cwd(), dir) || dir}), then --index`);
|
|
511
|
+
process.exit(2);
|
|
512
|
+
}
|
|
513
|
+
// a lane is served through its vendor's server.ts, which speaks HTTP: a lane whose spec is a proto has no seam that
|
|
514
|
+
// serves it over gRPC, so it is refused before anything is written, rather than served as plain HTTP (a gRPC API is a pack of its own)
|
|
515
|
+
if (!existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'src', 'generated', 'proto.gen.json'))) {
|
|
516
|
+
console.error(`create-pack --index: ${relative(process.cwd(), dir) || dir} is a lane whose spec is a proto; its vendor's server.ts serves lanes over HTTP alone, so a gRPC API is a pack of its own (architecture, "Pack layout")`);
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
const path = join(dir, 'src', 'semantics', 'index.ts');
|
|
520
|
+
if (!lanesOnly) {
|
|
521
|
+
writeFileSync(path, handlerIndex(dir));
|
|
522
|
+
console.log(`create-pack: wrote ${path}`);
|
|
523
|
+
}
|
|
524
|
+
// the fetch, again: a door file, the clock or a screen may have been added since
|
|
525
|
+
writeFileSync(join(dir, 'src', 'fetch.ts'), packFetchFile(dir, fetchName(dir)));
|
|
526
|
+
console.log(`create-pack: wrote ${join(dir, 'src', 'fetch.ts')}`);
|
|
527
|
+
// a pack's own index (a lane's is the vendor's to name): the descriptor with what the kernel derives from the surface
|
|
528
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
529
|
+
const vendor = parseExactJson(readFileSync(join(dir, 'package.json'), 'utf8')).name.replace(/^@volter\/twin-/, '');
|
|
530
|
+
writeFileSync(join(dir, 'src', 'index.ts'), packIndexFile(dir, vendor));
|
|
531
|
+
console.log(`create-pack: wrote ${join(dir, 'src', 'index.ts')}`);
|
|
532
|
+
// what the grade's typecheck compiles: a pack made before init wrote it gets it here
|
|
533
|
+
if (!existsSync(join(dir, 'tsconfig.json'))) {
|
|
534
|
+
writeFileSync(join(dir, 'tsconfig.json'), TSCONFIG);
|
|
535
|
+
console.log(`create-pack: wrote ${join(dir, 'tsconfig.json')}`);
|
|
536
|
+
}
|
|
537
|
+
// a unit whose spec is a proto (derive-pack generated its schema) is served over gRPC: its serve seam in that form,
|
|
538
|
+
// 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)
|
|
539
|
+
if (!lanesOnly && existsSync(join(dir, 'src', 'generated', 'proto.gen.json'))) {
|
|
540
|
+
writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true));
|
|
541
|
+
console.log(`create-pack: wrote ${join(dir, 'src', 'server.ts')}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// a handler is an exported async function, one per operation (the handler contract); anything else a family file
|
|
545
|
+
// exports is not in the map, said at once rather than found by the grade
|
|
546
|
+
const semantics = join(dir, 'src', 'semantics');
|
|
547
|
+
// a family file exports handlers only: a helper it shares belongs in semantics/shared.ts, so the index refuses one
|
|
548
|
+
let odd = 0;
|
|
549
|
+
for (const f of existsSync(semantics) ? readdirSync(semantics).filter((n) => n.endsWith('.ts') && !n.endsWith('.test.ts') && !FIXED.has(n)) : []) {
|
|
550
|
+
const names = [...readFileSync(join(semantics, f), 'utf8').matchAll(/^export\s+(?:const|let|var|class|function)\s+([A-Za-z0-9_$]+)/gm)].map((m) => m[1]);
|
|
551
|
+
if (names.length) {
|
|
552
|
+
odd += 1;
|
|
553
|
+
console.error(`create-pack: ${f} exports ${names.join(', ')} otherwise than as an async function: a handler is \`export async function <name>(ctx: HandlerContext)\`, and a shared helper belongs in semantics/shared.ts`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (odd)
|
|
557
|
+
process.exit(1);
|
|
558
|
+
if (!lanesOnly) {
|
|
559
|
+
const problems = await decisionProblems(dir);
|
|
560
|
+
if (problems.length) {
|
|
561
|
+
console.error(`create-pack: ${basename(dir)}: ${problems.length} problem(s) with what the unit serves:\n ${problems.join('\n ')}`);
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (rest.includes('--brief')) {
|
|
567
|
+
const text = await brief(unit);
|
|
568
|
+
const at = rest.indexOf('--out');
|
|
569
|
+
if (at >= 0 && rest[at + 1]) {
|
|
570
|
+
writeFileSync(rest[at + 1], text);
|
|
571
|
+
console.log(`create-pack: wrote ${rest[at + 1]}`);
|
|
572
|
+
}
|
|
573
|
+
else
|
|
574
|
+
console.log(text);
|
|
575
|
+
}
|
|
576
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -7,3 +7,6 @@ export { notIndependent, type Verdict } from './verdict.js';
|
|
|
7
7
|
export { sharingLanesIn, hasOwnSurface } from './lanes.js';
|
|
8
8
|
export { unitAt } from './unit.js';
|
|
9
9
|
export type { Criterion, CriterionResult, GradeUnit, ScoreReport, Standard } from './types.js';
|
|
10
|
+
export { compilePackFacts, renderPackFacts, type PackFacts, type PackFactsFile } from './pack-facts.js';
|
|
11
|
+
export { assessPack, type AssessmentOptions } from './assessment.js';
|
|
12
|
+
export { createPack } from './create-pack.js';
|
package/dist/src/index.js
CHANGED
|
@@ -11,3 +11,6 @@ export { handlerName, protocol3, PLANNED, withCheckoutFiles } from "./protocol-3
|
|
|
11
11
|
export { notIndependent } from "./verdict.js";
|
|
12
12
|
export { sharingLanesIn, hasOwnSurface } from "./lanes.js";
|
|
13
13
|
export { unitAt } from "./unit.js";
|
|
14
|
+
export { compilePackFacts, renderPackFacts } from "./pack-facts.js";
|
|
15
|
+
export { assessPack } from "./assessment.js";
|
|
16
|
+
export { createPack } from "./create-pack.js";
|