@volter/twin-standard 1.0.158 → 1.0.160
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 +3 -2
- package/client-sdks/package.json +1 -0
- package/client-sdks/pnpm-lock.yaml +210 -0
- package/dist/client-sdks/package.json +1 -0
- package/dist/client-sdks/pnpm-lock.yaml +210 -0
- package/dist/src/assessment.d.ts +13 -3
- package/dist/src/assessment.js +10 -6
- package/dist/src/browser-assessment.js +16 -13
- package/dist/src/check-sources.js +2 -2
- package/dist/src/clients/harness.d.ts +25 -4
- package/dist/src/clients/harness.js +184 -22
- package/dist/src/clients/jira.js +11 -2
- package/dist/src/clients/resend.first-use.json +2 -2
- package/dist/src/clients/run.js +19 -14
- package/dist/src/conformance.d.ts +3 -4
- package/dist/src/conformance.js +396 -203
- package/dist/src/consumer.d.ts +22 -1
- package/dist/src/consumer.js +91 -26
- package/dist/src/create-pack.d.ts +1 -1
- package/dist/src/create-pack.js +13 -12
- package/dist/src/credential-issuer-import.d.ts +3 -0
- package/dist/src/credential-issuer-import.js +42 -0
- package/dist/src/derive.js +6 -3
- package/dist/src/gate.js +4 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +7 -0
- package/dist/src/pack-facts.d.ts +3 -0
- package/dist/src/pack-facts.js +36 -1
- package/dist/src/protocol-3.js +13 -8
- package/dist/src/spec-documents.d.ts +4 -0
- package/dist/src/spec-documents.js +110 -3
- package/dist/src/spec-ir-client.js +1 -1
- package/dist/src/spec-ir-commands.js +1 -1
- package/dist/src/spec-ir-lines.js +1 -1
- package/dist/src/spec-ir-proto.d.ts +1 -1
- package/dist/src/spec-patches.js +2 -2
- package/dist/src/tools/judge-brief.js +1 -2
- package/dist/src/tools/life-coverage-walk.js +38 -26
- package/dist/src/tools/life-coverage.js +2 -2
- package/dist/src/tools/packs-root.js +3 -3
- package/dist/src/tools/score-pack.js +7 -7
- package/dist/src/tools/vendor-examples.js +95 -85
- package/dist/src/verdict.js +1 -1
- package/dist/src/walk/behavior-journey.d.ts +22 -14
- package/dist/src/walk/behavior-journey.js +199 -133
- package/dist/src/walk/journey-kit.d.ts +5 -4
- package/dist/src/walk/journey-kit.js +6 -2
- package/dist/src/walk/replay.d.ts +23 -0
- package/dist/src/walk/replay.js +118 -0
- package/dist/src/walk/serve-pack.d.ts +2 -2
- package/dist/src/walk/serve-pack.js +44 -2
- package/dist/src/walk/shape.d.ts +26 -5
- package/dist/src/walk/shape.js +167 -57
- package/dist/src/walk/vendor-fetch.d.ts +7 -2
- package/dist/src/walk/vendor-fetch.js +13 -2
- package/dist/src/walk/walk-database.d.ts +9 -1
- package/dist/src/walk/walk-database.js +35 -13
- package/dist/src/walk/xml-view.d.ts +3 -2
- package/dist/src/walk/xml-view.js +5 -4
- package/package.json +6 -4
- package/src/assessment.ts +12 -8
- package/src/browser-assessment.ts +12 -5
- package/src/check-sources.ts +2 -2
- package/src/clients/harness.ts +142 -19
- package/src/clients/jira.ts +4 -2
- package/src/clients/resend.first-use.json +2 -2
- package/src/clients/run.ts +17 -15
- package/src/conformance.ts +317 -147
- package/src/consumer.ts +83 -21
- package/src/create-pack.ts +13 -12
- package/src/credential-issuer-import.ts +38 -0
- package/src/derive.ts +6 -4
- package/src/gate.ts +3 -0
- package/src/index.ts +1 -1
- package/src/lanes.ts +7 -0
- package/src/pack-facts.ts +25 -2
- package/src/protocol-3.ts +12 -8
- package/src/spec-documents.ts +92 -3
- package/src/spec-ir-client.ts +1 -1
- package/src/spec-ir-commands.ts +1 -1
- package/src/spec-ir-lines.ts +1 -1
- package/src/spec-ir-proto.ts +1 -1
- package/src/spec-patches.ts +2 -2
- package/src/tools/judge-brief.ts +1 -2
- package/src/tools/life-coverage-walk.ts +32 -22
- package/src/tools/life-coverage.ts +2 -2
- package/src/tools/packs-root.ts +3 -3
- package/src/tools/score-pack.ts +8 -8
- package/src/tools/vendor-examples.ts +92 -80
- package/src/verdict.ts +1 -1
- package/src/walk/behavior-journey.ts +158 -109
- package/src/walk/journey-kit.ts +9 -7
- package/src/walk/replay.ts +103 -0
- package/src/walk/serve-pack.ts +30 -5
- package/src/walk/shape.ts +157 -55
- package/src/walk/vendor-fetch.ts +16 -4
- package/src/walk/walk-database.ts +41 -15
- package/src/walk/xml-view.ts +5 -4
package/src/consumer.ts
CHANGED
|
@@ -19,13 +19,28 @@ export type ConsumerRecipe = {
|
|
|
19
19
|
};
|
|
20
20
|
export type ConsumerOptions = { appDir: string; cli: string; artifactIntegrity: string };
|
|
21
21
|
type ConsumerInstallation = { cli: string; artifact?: string };
|
|
22
|
+
export type ConsumerResult = Awaited<ReturnType<typeof runConsumerWorkflow>>;
|
|
23
|
+
|
|
24
|
+
function recipeDependencies(recipe: ConsumerRecipe): Record<string, string> {
|
|
25
|
+
const app = JSON.parse(recipe.files['package.json']!);
|
|
26
|
+
const dependencies = { ...app.dependencies, ...app.devDependencies, ...app.optionalDependencies };
|
|
27
|
+
for (const [name, version] of Object.entries(dependencies)) {
|
|
28
|
+
if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error(`${name}: customer SDK dependencies require exact registry versions`);
|
|
29
|
+
}
|
|
30
|
+
return dependencies;
|
|
31
|
+
}
|
|
22
32
|
|
|
23
33
|
function packageIdentity(entry: string, name: string): { directory: string; version: string } {
|
|
24
34
|
let directory = dirname(realpathSync(entry));
|
|
25
35
|
while (true) {
|
|
26
|
-
const file = join(directory, 'package.json');
|
|
27
|
-
if (existsSync(file)) { const pkg = JSON.parse(readFileSync(file, 'utf8')); if (pkg.name === name) return { directory, version: pkg.version }; }
|
|
28
36
|
const parent = dirname(directory);
|
|
37
|
+
// The installed node_modules slot owns the entry; nested distribution manifests do not identify the package.
|
|
38
|
+
const boundary = basename(parent) === 'node_modules' || (basename(parent).startsWith('@') && basename(dirname(parent)) === 'node_modules');
|
|
39
|
+
if (boundary) {
|
|
40
|
+
const pkg = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
|
|
41
|
+
if (pkg.name !== name) throw new Error(`${name}: installed package boundary belongs to ${String(pkg.name)}`);
|
|
42
|
+
return { directory, version: pkg.version };
|
|
43
|
+
}
|
|
29
44
|
if (parent === directory) throw new Error(`${name} has no installed package identity`);
|
|
30
45
|
directory = parent;
|
|
31
46
|
}
|
|
@@ -39,6 +54,21 @@ function consumerTools(cli: string) {
|
|
|
39
54
|
};
|
|
40
55
|
}
|
|
41
56
|
|
|
57
|
+
function consumerEntries(directory: string, names: string[]): Map<string, string> {
|
|
58
|
+
if (!names.length) return new Map();
|
|
59
|
+
const result = spawnSync('node', ['--input-type=module', '--eval', 'process.stdout.write(JSON.stringify(process.argv.slice(1).map(name => import.meta.resolve(name))))', ...names], { cwd: directory, encoding: 'utf8' });
|
|
60
|
+
if (result.error || result.status !== 0) throw new Error(`Consumer ESM dependency resolution failed: ${result.error?.message ?? result.stderr.trim()}`);
|
|
61
|
+
const entries = JSON.parse(result.stdout) as string[];
|
|
62
|
+
return new Map(names.map((name, index) => [name, fileURLToPath(entries[index]!)]));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function consumerPeerVersions(metadata: { dependencies?: Record<string, string>; optionalDependencies?: Record<string, string>; peerDependencies?: Record<string, string>; peerDependenciesMeta?: Record<string, { optional?: boolean }> }, cli: string, sdkDependencies: Record<string, string>): Record<string, string> {
|
|
66
|
+
const names = Object.keys(metadata.peerDependencies ?? {})
|
|
67
|
+
.filter((name) => name !== '@volter/world-core' && !metadata.peerDependenciesMeta?.[name]?.optional && !metadata.dependencies?.[name] && !metadata.optionalDependencies?.[name] && !sdkDependencies[name]);
|
|
68
|
+
const entries = consumerEntries(dirname(realpathSync(cli)), names);
|
|
69
|
+
return Object.fromEntries(names.map((name) => [name, packageIdentity(entries.get(name)!, name).version]));
|
|
70
|
+
}
|
|
71
|
+
|
|
42
72
|
/** Bind behavior inputs on both source and packed layouts; compiled outputs and score caches are not inputs. */
|
|
43
73
|
export function consumerSourceDigest(pack: string): string {
|
|
44
74
|
const entries: Array<[string, string]> = [];
|
|
@@ -83,9 +113,7 @@ export function prepareConsumerApp(packDir: string, appDir: string, installation
|
|
|
83
113
|
app.packageManager = 'pnpm@12.15.0';
|
|
84
114
|
app.devEngines = { packageManager: { name: 'pnpm', version: '12.15.0', onFail: 'error' } };
|
|
85
115
|
if (app.scripts || app.workspaces) throw new Error('Consumer fixture supplies direct SDK commands, not install scripts or workspace links');
|
|
86
|
-
|
|
87
|
-
if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error(`${name}: customer SDK dependencies require exact registry versions`);
|
|
88
|
-
}
|
|
116
|
+
const sdkDependencies = recipeDependencies(recipe);
|
|
89
117
|
const files = Object.entries(recipe.files).map(([path, content]) => {
|
|
90
118
|
if (isAbsolute(path) || path.includes('\\') || path.split('/').some((part) => !part || part === '.' || part === '..' || part === '.git') || path.split('/').includes('node_modules') || ['.env', '.npmrc', '.volter/world.json'].includes(path)) throw new Error(`Invalid consumer file: ${path}`);
|
|
91
119
|
if (typeof content !== 'string') throw new Error('Consumer files must contain text');
|
|
@@ -96,7 +124,7 @@ export function prepareConsumerApp(packDir: string, appDir: string, installation
|
|
|
96
124
|
const tools = consumerTools(installation.cli);
|
|
97
125
|
if (installation.artifact && (!isAbsolute(installation.artifact) || !statSync(installation.artifact).isFile())) throw new Error('Consumer candidate must be an absolute prepared archive path');
|
|
98
126
|
if (Object.hasOwn(app.dependencies ?? {}, metadata.name)) throw new Error('Consumer SDK dependencies must not substitute the candidate package');
|
|
99
|
-
app.devDependencies = { ...app.devDependencies, '@volter/world-core': tools['@volter/world-core'], [metadata.name]: installation.artifact ? `file:${installation.artifact}` : metadata.version };
|
|
127
|
+
app.devDependencies = { ...app.devDependencies, ...consumerPeerVersions(metadata, installation.cli, sdkDependencies), '@volter/world-core': tools['@volter/world-core'], [metadata.name]: installation.artifact ? `file:${installation.artifact}` : metadata.version };
|
|
100
128
|
}
|
|
101
129
|
files.find(([path]) => path === 'package.json')![1] = JSON.stringify(app, null, 2) + '\n';
|
|
102
130
|
files.push(['pnpm-workspace.yaml', 'packages:\n - .\npackageImportMethod: hardlink\nnodeLinker: hoisted\nautoInstallPeers: false\nstrictPeerDependencies: false\n']);
|
|
@@ -118,40 +146,56 @@ export function prepareConsumerApp(packDir: string, appDir: string, installation
|
|
|
118
146
|
return { recipeSha256: createHash('sha256').update(JSON.stringify(recipe)).digest('hex'), dependencies: app.dependencies ?? {} };
|
|
119
147
|
}
|
|
120
148
|
|
|
149
|
+
/** Run the unchanged customer command, with the environment supplied by its World. */
|
|
150
|
+
export function runConsumerCommand(argv: string[], appDir: string, env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
|
|
151
|
+
return new Promise((done) => {
|
|
152
|
+
const child = spawn(argv[0]!, argv.slice(1), { cwd: appDir, env, signal, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
153
|
+
let stdout = '', stderr = '';
|
|
154
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
155
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
156
|
+
child.on('error', (error) => { stderr += `\n${error.message}`; });
|
|
157
|
+
child.on('close', (exitCode) => done({ exitCode, stdout, stderr }));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
121
161
|
export async function runConsumerWorkflow(packDir: string, options: ConsumerOptions) {
|
|
122
162
|
if (!isAbsolute(options.appDir) || !isAbsolute(options.cli) || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(options.artifactIntegrity)) throw new Error('Consumer requires absolute prepared paths and candidate SHA-512 integrity');
|
|
123
163
|
if (existsSync(join(options.appDir, '.volter/world.json'))) throw new Error('Consumer app already has a World; prepare a new app rather than reusing prior state');
|
|
124
164
|
const cliEntry = realpathSync(options.cli);
|
|
125
165
|
const tools = consumerTools(cliEntry);
|
|
126
166
|
const metadata = JSON.parse(readFileSync(join(packDir, 'package.json'), 'utf8'));
|
|
167
|
+
const facts = JSON.parse(readFileSync(join(packDir, 'generated/pack-facts.json'), 'utf8'));
|
|
168
|
+
const vendor = Object.keys(facts.packs ?? {})[0];
|
|
169
|
+
if (!vendor || Object.keys(facts.packs).length !== 1) throw new Error('Consumer workflow requires one selected artifact identity');
|
|
170
|
+
const recipe = consumerRecipe(vendor, packDir), sdkDependencies = recipeDependencies(recipe);
|
|
127
171
|
const appRoot = realpathSync(options.appDir), appRequire = createRequire(join(appRoot, 'package.json'));
|
|
128
172
|
const candidate = packageIdentity(appRequire.resolve(metadata.name), metadata.name);
|
|
129
173
|
const kernel = packageIdentity(appRequire.resolve('@volter/world-core/runtime'), '@volter/world-core');
|
|
130
|
-
|
|
174
|
+
const peerVersions = consumerPeerVersions(metadata, cliEntry, sdkDependencies);
|
|
175
|
+
const entries = consumerEntries(appRoot, [...Object.keys(peerVersions), ...Object.keys(sdkDependencies)]);
|
|
176
|
+
const peers = Object.keys(peerVersions).map((name) => [name, packageIdentity(entries.get(name)!, name)] as const);
|
|
177
|
+
for (const [name, identity] of [[metadata.name, candidate], ['@volter/world-core', kernel], ...peers] as const) {
|
|
131
178
|
const location = relative(join(appRoot, 'node_modules'), identity.directory);
|
|
132
179
|
if (location.startsWith('..') || isAbsolute(location)) throw new Error(`${name}: consumer resolves an evaluator or ancestor dependency; install it in the app`);
|
|
133
180
|
}
|
|
134
181
|
const appLock = dependencyLock(appRoot), appLockBytes = appLock.bytes;
|
|
135
182
|
if (candidate.version !== metadata.version || appLock.entry(metadata.name)?.integrity !== options.artifactIntegrity) throw new Error('Consumer app candidate does not match the exact assessed artifact');
|
|
136
183
|
if (kernel.version !== tools['@volter/world-core'] || appLock.entry('@volter/world-core')?.version !== kernel.version) throw new Error('Consumer app kernel does not match the CLI kernel pin');
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
184
|
+
for (const [name, identity] of peers) if (identity.version !== peerVersions[name] || appLock.entry(name)?.version !== identity.version) throw new Error(`${name}: consumer peer does not match the installed CLI closure and exact application lock version ${peerVersions[name]}`);
|
|
185
|
+
const sdks = Object.entries(sdkDependencies).map(([name, version]) => {
|
|
186
|
+
const entry = realpathSync(entries.get(name)!), identity = packageIdentity(entry, name);
|
|
187
|
+
const location = relative(join(appRoot, 'node_modules'), identity.directory);
|
|
188
|
+
if (location.startsWith('..') || isAbsolute(location)) throw new Error(`${name}: customer SDK resolves outside the application installation`);
|
|
189
|
+
if (identity.version !== version || appLock.entry(name)?.version !== version) throw new Error(`${name}: customer SDK does not match its exact recipe and application lock version ${version}`);
|
|
190
|
+
return { package: name, version, entry: relative(appRoot, entry).split('\\').join('/') };
|
|
191
|
+
});
|
|
192
|
+
const application = { candidate: { package: metadata.name, version: candidate.version, integrity: options.artifactIntegrity }, kernel: { package: '@volter/world-core', version: kernel.version }, peers: peers.map(([name, identity]) => ({ package: name, version: identity.version })), sdks, dependencyLockSha256: createHash('sha256').update(appLockBytes).digest('hex'), appLocal: true };
|
|
142
193
|
const commands: Array<{ label: string; argv: string[]; exitCode: number | null; stdout: string; stderr: string }> = [];
|
|
143
194
|
const failures: string[] = [];
|
|
144
195
|
const compiledCli = /\.[cm]?js$/.test(cliEntry);
|
|
145
196
|
let nodeVersion: string | null = null;
|
|
146
197
|
const execute = async (label: string, executable: string, argv: string[]) => {
|
|
147
|
-
const result = await
|
|
148
|
-
const child = spawn(executable, argv, { cwd: options.appDir, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
149
|
-
let stdout = '', stderr = '';
|
|
150
|
-
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
151
|
-
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
152
|
-
child.on('error', (error) => done({ exitCode: 1, stdout, stderr: `${stderr}\n${error.message}` }));
|
|
153
|
-
child.on('close', (exitCode) => done({ exitCode, stdout, stderr }));
|
|
154
|
-
});
|
|
198
|
+
const result = await runConsumerCommand([executable, ...argv], options.appDir, process.env);
|
|
155
199
|
commands.push({ label, argv: [executable, ...argv], ...result });
|
|
156
200
|
if (result.exitCode !== 0) throw new Error(`${label} exited ${result.exitCode}: ${result.stderr.trim().split('\n')[0] ?? ''}`);
|
|
157
201
|
return result.stdout;
|
|
@@ -213,7 +257,7 @@ export async function runConsumerWorkflow(packDir: string, options: ConsumerOpti
|
|
|
213
257
|
}
|
|
214
258
|
}
|
|
215
259
|
return {
|
|
216
|
-
measured: true, passed: failures.length === 0 && teardownVerified,
|
|
260
|
+
measured: true as const, passed: failures.length === 0 && teardownVerified,
|
|
217
261
|
scope: 'Exact installed artifact; normal CLI initialization, unchanged SDK, result assertions and retained-state read-only resume where applicable',
|
|
218
262
|
package: metadata.name, version: metadata.version, integrity: options.artifactIntegrity,
|
|
219
263
|
tools, application,
|
|
@@ -227,3 +271,21 @@ export async function runConsumerWorkflow(packDir: string, options: ConsumerOpti
|
|
|
227
271
|
at: new Date().toISOString(),
|
|
228
272
|
};
|
|
229
273
|
}
|
|
274
|
+
|
|
275
|
+
/** The same installed execution can prove a declared SDK when the standard has no built-in client module. */
|
|
276
|
+
export function consumerClientProof(packDir: string, vendor: string, sdks: string[], result?: ConsumerResult): { failures: string[]; note?: string } {
|
|
277
|
+
if (!existsSync(join(packDir, 'journeys/first-use.json'))) return { failures: ['the pack supplies no journeys/first-use.json installed SDK workflow'] };
|
|
278
|
+
const recipe = consumerRecipe(vendor, packDir);
|
|
279
|
+
const dependencies = Object.entries(recipeDependencies(recipe)).filter(([name]) => sdks.includes(name));
|
|
280
|
+
if (!dependencies.length) return { failures: ['the installed workflow has no exact direct dependency named by adoption.sdks'] };
|
|
281
|
+
if (!result?.measured || !result.passed || result.failures.length || !result.teardownVerified) return { failures: ['the installed SDK workflow has no passing execution with verified teardown'] };
|
|
282
|
+
const metadata = JSON.parse(readFileSync(join(packDir, 'package.json'), 'utf8'));
|
|
283
|
+
const candidate = result.application.candidate;
|
|
284
|
+
if (result.package !== metadata.name || result.version !== metadata.version || candidate.package !== metadata.name || candidate.version !== metadata.version || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(result.integrity) || candidate.integrity !== result.integrity || result.application.appLocal !== true) return { failures: ['the installed SDK result does not identify this exact app-local candidate artifact'] };
|
|
285
|
+
if (result.fixture !== 'journeys/first-use.json' || result.sourceInputsSha256 !== consumerSourceDigest(packDir) || result.recipeSha256 !== createHash('sha256').update(JSON.stringify(recipe)).digest('hex')) return { failures: ['the installed SDK result does not describe the current behavior inputs and authored recipe'] };
|
|
286
|
+
for (const [name, version] of dependencies) {
|
|
287
|
+
const sdk = result.application.sdks.find((entry) => entry.package === name);
|
|
288
|
+
if (sdk?.version !== version || !sdk.entry.startsWith('node_modules/') || sdk.entry.split('/').includes('..')) return { failures: [`${name}: the installed SDK result has no exact app-local ${version} resolution`] };
|
|
289
|
+
}
|
|
290
|
+
return { failures: [], note: `installed customer workflow through ${dependencies.map(([name, version]) => `${name}@${version}`).join(', ')}; exact artifact and current recipe` };
|
|
291
|
+
}
|
package/src/create-pack.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// create-pack — the files of a Protocol 3 pack that nobody should write (the board's `create-pack` card;
|
|
3
3
|
// docs/contributing/architecture.md, "Creating a pack"), and the brief an author writes the rest from.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// twin-standard create <dir> --init <vendor> --package <@scope/name>
|
|
6
|
+
// write every fixed file of a new pack that does not exist yet
|
|
7
|
+
// twin-standard create <dir> --init <vendor> --package <@scope/name> --lanes a,b
|
|
7
8
|
// the same for a pack of lanes, one per API of the vendor, each
|
|
8
9
|
// derived from its own spec (<dir>/<lane>/spec): each lane's
|
|
9
10
|
// manifest, fetch, index and semantics, and the pack's server
|
|
@@ -12,11 +13,11 @@
|
|
|
12
13
|
// index}.ts, src/semantics/{states,shared,doors}.ts,
|
|
13
14
|
// journeys/{demand,first-use}.json): the author fills the manifest's
|
|
14
15
|
// vendor facts, the descriptor's hosts and the families
|
|
15
|
-
//
|
|
16
|
+
// twin-standard create <unit> --index write src/semantics/index.ts, the handler map, and src/fetch.ts from the
|
|
16
17
|
// families' files (every exported function, by its name)
|
|
17
|
-
//
|
|
18
|
+
// twin-standard create <unit> --index --building the same during construction; unfinished operations stay gaps,
|
|
18
19
|
// and implemented operations must match their decisions
|
|
19
|
-
//
|
|
20
|
+
// twin-standard create <unit> --brief [--out f] the author's brief: every operation the derived core does not
|
|
20
21
|
// serve and no handler answers yet, by family, with what the
|
|
21
22
|
// vendored spec says of it (summary, description, parameters,
|
|
22
23
|
// the answers it documents and their examples), so a handler
|
|
@@ -69,7 +70,7 @@ export function handlerIndex(dir: string): string {
|
|
|
69
70
|
const ids = existsSync(surfacePath) ? ((parseExactJson(readFileSync(surfacePath, 'utf8')) as { operations?: Array<{ id: string }> }).operations ?? []).map((o) => o.id) : [];
|
|
70
71
|
// an index written before the derive has no operations to map, and every served call would fall to the gap (404): refuse
|
|
71
72
|
// it rather than write an empty map (2026-09-30: six packs committed so)
|
|
72
|
-
if (!existsSync(surfacePath) && fams.length) throw new Error(`${dir}: no src/generated/surface.gen.json yet; derive the unit (
|
|
73
|
+
if (!existsSync(surfacePath) && fams.length) throw new Error(`${dir}: no src/generated/surface.gen.json yet; derive the unit (twin-standard derive) before --index`);
|
|
73
74
|
const where = new Map(fams.flatMap((f) => f.handlers.map((h) => [h, f.family] as const)));
|
|
74
75
|
const named = new Set([...new Set(ids)].map(handlerName));
|
|
75
76
|
const stray = [...where.keys()].filter((h) => !named.has(h));
|
|
@@ -269,7 +270,7 @@ export async function brief(unit: string): Promise<string> {
|
|
|
269
270
|
const s = spec.get(o.id);
|
|
270
271
|
lines.push(`### ${o.id}${unmodeled.has(o.id) ? ' (unmodeled)' : ''}`, '', `\`${o.method.toUpperCase()} ${o.path}\` — ${o.class}${s?.summary ? `: ${oneLine(s.summary)}` : ''}`);
|
|
271
272
|
// the citation a handler carries, ready to paste: by operationId where the spec names one, else by its JSON pointer,
|
|
272
|
-
// quoting the spec's own words (
|
|
273
|
+
// quoting the spec's own words (the derivation refuses a quote the spec does not hold)
|
|
273
274
|
const words = oneLine(s?.summary ?? s?.description ?? '').split(/(?<=\.)\s/)[0]!.replace(/"/g, '\\"');
|
|
274
275
|
const cite = s && (s as { operationId?: string }).operationId === o.id ? `spec:${o.id}` : `spec:/paths/${o.path.replace(/~/g, '~0').replace(/\//g, '~1')}/${o.method.toLowerCase()}`;
|
|
275
276
|
if (words) lines.push('', `Cite: \`// source: ${cite} "${words}"\``);
|
|
@@ -331,7 +332,7 @@ export function packIndexFile(dir: string, vendor: string): string {
|
|
|
331
332
|
|
|
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
|
|
333
334
|
* it typechecks and a bundler follows every import: the plain seam (serveHttp, which takes the sockets a fetch carries
|
|
334
|
-
* itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema
|
|
335
|
+
* itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema the derivation generated
|
|
335
336
|
* (src/generated/proto.gen.json, imported by its literal path). `--init` writes the plain one, `--index` the gRPC one once
|
|
336
337
|
* the unit's proto is derived. */
|
|
337
338
|
export function serverFile(vendor: string, grpc: boolean, wire = false, live = false): string {
|
|
@@ -436,7 +437,7 @@ export function initFiles(vendor: string): Record<string, string> {
|
|
|
436
437
|
'src/semantics/states.ts': `// ${vendor}'s state machines and state rulings, each transition cited (architecture, "State").\nexport const states = {};\n`,
|
|
437
438
|
'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`,
|
|
438
439
|
'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`,
|
|
439
|
-
'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
|
|
440
|
+
'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 twin-standard derive). 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`,
|
|
440
441
|
'src/server.ts': serverFile(vendor, false),
|
|
441
442
|
'src/cli.ts': cliFile(vendor),
|
|
442
443
|
'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`,
|
|
@@ -515,7 +516,7 @@ export async function fixedFiles(dir: string): Promise<Record<string, string>> {
|
|
|
515
516
|
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');
|
|
516
517
|
files['src/index.ts'] = packIndexFile(dir, vendor);
|
|
517
518
|
if (!existsSync(join(dir, 'src', 'stream.ts'))) {
|
|
518
|
-
// a unit whose spec is a proto (
|
|
519
|
+
// a unit whose spec is a proto (the derivation generated its schema) is served over gRPC: its serve seam in that form
|
|
519
520
|
const lanesOnly = !hasOwnSurface(dir) && readdirSync(dir).some((n) => existsSync(join(dir, n, 'src', 'manifest.ts')));
|
|
520
521
|
const grpc = !lanesOnly && existsSync(join(dir, 'src', 'generated', 'proto.gen.json'));
|
|
521
522
|
// a unit that takes broadcasts over RTMP (its manifest's `live`) has the live forms --index writes, so they reproduce
|
|
@@ -529,7 +530,7 @@ export async function fixedFiles(dir: string): Promise<Record<string, string>> {
|
|
|
529
530
|
export async function createPack(args: string[]): Promise<void> {
|
|
530
531
|
const [unit, ...rest] = args;
|
|
531
532
|
if (!unit || (!rest.includes('--index') && !rest.includes('--brief') && !rest.includes('--init'))) {
|
|
532
|
-
console.error('usage:
|
|
533
|
+
console.error('usage: twin-standard create <dir> --init <vendor> --package <@scope/name> | <unit> --index [--building] | --brief [--out <file>]');
|
|
533
534
|
process.exit(2);
|
|
534
535
|
}
|
|
535
536
|
if (rest.includes('--building') && (!rest.includes('--index') || rest.includes('--init') || rest.includes('--brief'))) {
|
|
@@ -598,7 +599,7 @@ export async function createPack(args: string[]): Promise<void> {
|
|
|
598
599
|
console.log(`create-pack: wrote ${join(dir, 'src', 'index.ts')}`);
|
|
599
600
|
// what the grade's typecheck compiles: a pack made before init wrote it gets it here
|
|
600
601
|
if (!existsSync(join(dir, 'tsconfig.json'))) { writeFileSync(join(dir, 'tsconfig.json'), TSCONFIG); console.log(`create-pack: wrote ${join(dir, 'tsconfig.json')}`); }
|
|
601
|
-
// a unit whose spec is a proto (
|
|
602
|
+
// a unit whose spec is a proto (the derivation generated its schema) is served over gRPC: its serve seam in that form,
|
|
602
603
|
// 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)
|
|
603
604
|
if (!lanesOnly && existsSync(join(dir, 'src', 'generated', 'proto.gen.json'))) {
|
|
604
605
|
const wire = existsSync(join(dir, 'src', 'stream.ts'));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
|
|
5
|
+
/** A literal property's effective value; a later spread or computed key cannot establish an import permission. */
|
|
6
|
+
function property(expression: ts.Expression | undefined, name: string): ts.Expression | undefined {
|
|
7
|
+
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression))) expression = expression.expression;
|
|
8
|
+
if (!expression || !ts.isObjectLiteralExpression(expression)) return undefined;
|
|
9
|
+
for (const member of [...expression.properties].reverse()) {
|
|
10
|
+
if (!ts.isPropertyAssignment(member) || ts.isComputedPropertyName(member.name)) return undefined;
|
|
11
|
+
if (member.name.text === name) return member.initializer;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The declared credential issuer's public subpaths, pinned as a direct runtime dependency. Reading the manifest as
|
|
17
|
+
* data keeps the source/tarball gate independent of dependency installation; typechecking verifies exported paths. */
|
|
18
|
+
export function credentialIssuerImports(unitDir: string, packageDir: string): (specifier: string) => boolean {
|
|
19
|
+
const path = join(unitDir, 'src', 'manifest.ts');
|
|
20
|
+
const packagePath = join(packageDir, 'package.json');
|
|
21
|
+
if (!existsSync(path) || !existsSync(packagePath)) return () => false;
|
|
22
|
+
const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true);
|
|
23
|
+
const manifests = source.statements.flatMap(statement => ts.isVariableStatement(statement)
|
|
24
|
+
&& statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
|
25
|
+
? statement.declarationList.declarations.filter(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === 'manifest') : []);
|
|
26
|
+
const issuer = manifests.length === 1 ? property(property(manifests[0]!.initializer, 'descriptor'), 'credentialIssuer') : undefined;
|
|
27
|
+
if (!issuer || !ts.isStringLiteral(issuer) || !/^[a-z]/.test(issuer.text) || /[^a-z0-9-]/.test(issuer.text)) return () => false;
|
|
28
|
+
const packageName = `@volter/twin-${issuer.text}`;
|
|
29
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf8')) as { name?: string; dependencies?: Record<string, string> };
|
|
30
|
+
const version = pkg.dependencies?.[packageName];
|
|
31
|
+
const number = '(?:0|[1-9][0-9]*)';
|
|
32
|
+
const prerelease = '(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)';
|
|
33
|
+
const exactVersion = new RegExp(`^${number}\\.${number}\\.${number}(?:-${prerelease}(?:\\.${prerelease})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`);
|
|
34
|
+
if (pkg.name === packageName || typeof version !== 'string' || exactVersion.exec(version)?.[0] !== version) return () => false;
|
|
35
|
+
const prefix = `${packageName}/`;
|
|
36
|
+
return specifier => specifier.startsWith(prefix) && specifier.slice(prefix.length).split('/').every(segment =>
|
|
37
|
+
segment !== '' && segment !== '.' && segment !== '..' && !/[^A-Za-z0-9._-]/.test(segment) && segment.toLowerCase() !== 'node_modules');
|
|
38
|
+
}
|
package/src/derive.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { parseExactJson, exactInteger } from '@volter/world-core/exact-json';
|
|
|
5
5
|
// vendored spec (`<pack>/spec/openapi.json[.gz]`), builds the spec IR (./spec-ir.ts) and writes the pack's
|
|
6
6
|
// `src/generated/`. Deterministic: a second run over the same spec writes the same bytes. Never hand-edit what it writes.
|
|
7
7
|
//
|
|
8
|
-
// Usage: twin-standard derive <pack dir> [--out <dir>]
|
|
8
|
+
// Usage: twin-standard derive <pack dir> [--out <dir>]
|
|
9
9
|
|
|
10
10
|
import { parse as parseYaml } from 'yaml';
|
|
11
11
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -41,7 +41,7 @@ const PKG = unitAt(vendor).dir;
|
|
|
41
41
|
const rfc = existsSync(join(PKG, 'spec')) ? readdirSync(join(PKG, 'spec')).find((f) => /^rfc\d+\.txt$/.test(f)) : undefined;
|
|
42
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));
|
|
43
43
|
if (!specPath) {
|
|
44
|
-
console.error(`derive
|
|
44
|
+
console.error(`derive: 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
45
|
process.exit(SOURCE_REFUSAL);
|
|
46
46
|
}
|
|
47
47
|
const rpcService = basename(specPath) === 'proto';
|
|
@@ -308,11 +308,11 @@ if (existsSync(manifestPath)) {
|
|
|
308
308
|
}
|
|
309
309
|
// a resource's refresh scope ("The real-system adapters", What a pack declares) names the list that enumerates it or
|
|
310
310
|
// its retrieve, or says why the vendor offers no read-back; whether every stored resource declares one is
|
|
311
|
-
//
|
|
311
|
+
// not this build's to decide
|
|
312
312
|
// (a resource the spec names is read by its own list or retrieve; one the manifest names apart from the spec's, an RPC
|
|
313
313
|
// vendor's, by the list or read the scope names, with `items` where the answer holds them when the spec does not say)
|
|
314
314
|
const specNamed = new Set(ir.resources.map((r) => r.name));
|
|
315
|
-
const scopes = Object.entries(manifest.resources as Record<string, { storedAs?: string; refresh?: { list?: string; get?: string; none?: string; items?: string; detail?: string } }>).flatMap(([name, decl]) => {
|
|
315
|
+
const scopes = Object.entries(manifest.resources as Record<string, { storedAs?: string; refresh?: { list?: string; get?: string; known?: boolean; none?: string; items?: string; detail?: string } }>).flatMap(([name, decl]) => {
|
|
316
316
|
const r = decl.refresh;
|
|
317
317
|
if (!r) return [];
|
|
318
318
|
if ('graphql' in r && r.graphql) {
|
|
@@ -363,6 +363,8 @@ if (existsSync(manifestPath)) {
|
|
|
363
363
|
if (r.get !== undefined) {
|
|
364
364
|
const o = op(r.get);
|
|
365
365
|
if (o?.class === 'retrieve' && ours(o)) return [];
|
|
366
|
+
// A known subject may be addressed by an id query whose native answer is a list envelope.
|
|
367
|
+
if (r.known && o?.class === 'list' && o.answers?.list && ours(o) && o.method.toUpperCase() === 'GET') return [];
|
|
366
368
|
// a resource the spec does not name (a client-derived spec classes every request an action) is read by the GET
|
|
367
369
|
// the scope names, the answer itself the subject
|
|
368
370
|
if (o && !specNamed.has(name) && !o.resource && String(o.method).toUpperCase() === 'GET') return [];
|
package/src/gate.ts
CHANGED
|
@@ -19,6 +19,7 @@ import ts from 'typescript';
|
|
|
19
19
|
import { grade, type GradeOptions } from './grade.ts';
|
|
20
20
|
import { withCheckoutFiles } from './protocol-3.ts';
|
|
21
21
|
import { isPackArtifact } from './pack-files.ts';
|
|
22
|
+
import { credentialIssuerImports } from './credential-issuer-import.ts';
|
|
22
23
|
|
|
23
24
|
/** Every unit of a pack repository: each pack (a directory with a package.json) and each lane in it (a directory with
|
|
24
25
|
* its own src/manifest.ts and no package.json). */
|
|
@@ -102,6 +103,7 @@ export function laneRootFailures(dir: string): string[] {
|
|
|
102
103
|
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');
|
|
103
104
|
// the root's own files hold to the pack contract as a unit's do (the grade's kernel-contract)
|
|
104
105
|
const beyond: string[] = [];
|
|
106
|
+
const issuerImport = credentialIssuerImports(dir, dir);
|
|
105
107
|
for (const n of existsSync(join(dir, 'src')) ? readdirSync(join(dir, 'src')).filter((x) => /\.tsx?$/.test(x) && !x.endsWith('.d.ts')) : []) {
|
|
106
108
|
// the file's imports as TypeScript reads them, as the grade's kernel-contract does
|
|
107
109
|
for (const from of ts.preProcessFile(readFileSync(join(dir, 'src', n), 'utf8'), true, true).importedFiles.map((i) => i.fileName)) {
|
|
@@ -111,6 +113,7 @@ export function laneRootFailures(dir: string): string[] {
|
|
|
111
113
|
if (/^\.\.\/[^/]+\/src\//.test(from) && n !== 'fetch.ts' && !laneData) { beyond.push(`src/${n} imports ${from}: only src/fetch.ts reaches a lane`); continue; }
|
|
112
114
|
if (n === 'manifest.ts' && from !== '@volter/world-core') { beyond.push(`src/manifest.ts imports ${from}: the manifest is data`); continue; }
|
|
113
115
|
if (from.startsWith('.') || from === '@volter/world-core' || from === '@volter/world-ui') continue;
|
|
116
|
+
if (issuerImport(from)) continue;
|
|
114
117
|
if (n === 'cli.ts' && (from === '@volter/world-core/args' || from === '@volter/world-core/lifecycle')) continue;
|
|
115
118
|
beyond.push(`src/${n} imports ${from}`);
|
|
116
119
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// of a submission — judges by the same standard at the version it pins. Changing a criterion is a release of this
|
|
5
5
|
// package: a criterion's id never changes meaning, a changed criterion takes a new id and a new standard version.
|
|
6
6
|
export { grade, STANDARDS, type CriterionGrade, type GradeOptions, type StandardGrade } from './grade.ts';
|
|
7
|
-
export { conformance, decisionTableProblems, type ConformanceResult, UNIT_FILES
|
|
7
|
+
export { conformance, decisionTableProblems, type ConformanceResult, UNIT_FILES } from './conformance.ts';
|
|
8
8
|
export { laneRootFailures, p3Gate, unitsOf } from './gate.ts';
|
|
9
9
|
export { conformPublished, gatePublished, vendorOfPackage, type PublishedGate } from './published.ts';
|
|
10
10
|
export { handlerName, protocol3, PLANNED, withCheckoutFiles } from './protocol-3.ts';
|
package/src/lanes.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
|
|
7
|
+
/** The pack and its manifest-bearing lanes, without crossing another package's boundary. */
|
|
8
|
+
export function unitsIn(pack: string): string[] {
|
|
9
|
+
if (!existsSync(pack)) return [];
|
|
10
|
+
return [pack, ...readdirSync(pack).sort().map((name) => join(pack, name))
|
|
11
|
+
.filter((directory) => !existsSync(join(directory, 'package.json')) && existsSync(join(directory, 'src', 'manifest.ts')))];
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
/** Metadata such as sources.json is not a derivable API of a vendor whose APIs all belong to its lanes. */
|
|
8
15
|
export function hasOwnSurface(pkg: string): boolean {
|
|
9
16
|
if (existsSync(join(pkg, 'src', 'generated', 'surface.gen.json'))) return true;
|
package/src/pack-facts.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type PackFacts = {
|
|
|
8
8
|
serverBacking?: { command: string };
|
|
9
9
|
maxRequestBodySize?: number;
|
|
10
10
|
adoption?: { sdks?: string[]; pypi?: string[]; scopes?: string[]; envStems?: string[]; worldIds?: string[]; tools?: Array<{ package: string; usage: 'build' | 'deployment' }>; configFiles?: Array<{ file: string; usage: 'application' | 'build' | 'deployment'; when?: string }> };
|
|
11
|
-
hosts?: Array<{ host?: string; suffix?: string; hostPattern?: string; pathPattern?: string; demandPathPattern?: string | false; key?: string; exclude?: true }>;
|
|
11
|
+
hosts?: Array<{ host?: string; suffix?: string; hostPattern?: string; pathPattern?: string; demandPathPattern?: string | false; key?: string; exclude?: true; basic?: string }>;
|
|
12
12
|
hostsNone?: string;
|
|
13
13
|
hostsClaimed?: { door: string; note: string };
|
|
14
14
|
/** The subject types the twin projects (packRegistry `resources`) — invariant R2 at the resource
|
|
@@ -42,6 +42,8 @@ export type PackFacts = {
|
|
|
42
42
|
managedDatabase?: { kind: 'postgres'; arg: string; user: string; database: string; clock: 'wall'; credentialField: string; note: string };
|
|
43
43
|
/** Listeners the twin opens beside its main port (see TwinPack.sidePorts). */
|
|
44
44
|
sidePorts?: Array<{ name: string; option: string; flag: string; path?: string; scheme?: string; note: string }>;
|
|
45
|
+
/** The vendor's own requests a World's generated seed makes (see TwinPack.seed). */
|
|
46
|
+
seed?: TwinPack['seed'];
|
|
45
47
|
/** DERIVED, never declared: the pack's `create<Name>TwinServer` factory export — the
|
|
46
48
|
* `({port,root,readOnly}) => {port,stop}` contract the colocated host mounts
|
|
47
49
|
* (world-runtime/src/host.ts). Read off the module's own exports because the name is not
|
|
@@ -78,6 +80,7 @@ function validateHostRules(vendor: string, rules: NonNullable<PackFacts['hosts']
|
|
|
78
80
|
try { new RegExp(rule.demandPathPattern); } catch (e) { throw new Error(`pack-facts: ${vendor} demandPathPattern does not compile: ${(e as Error).message}`); }
|
|
79
81
|
}
|
|
80
82
|
if (rule.exclude !== undefined && rule.exclude !== true) throw new Error(`pack-facts: ${vendor} host rule ${JSON.stringify(rule)} — exclude is \`true\` or absent, never a falsy flag`);
|
|
83
|
+
if (rule.basic !== undefined && (typeof rule.basic !== 'string' || !/^[^:\s]+$/.test(rule.basic) || rule.exclude)) throw new Error(`pack-facts: ${vendor} host rule ${JSON.stringify(rule)} — basic names a user (no colon, no space) on an included host`);
|
|
81
84
|
const key = rule.key ?? vendor;
|
|
82
85
|
// A foreign key is a WORLD identity too: `volter-world init` emits <KEY>_TWIN_URL only for the
|
|
83
86
|
// ids in adoption.worldIds (covers.ts injectorVendorKeysFor), so a key not listed there would
|
|
@@ -221,6 +224,26 @@ export async function compilePackFacts(dirs: Array<{ vendor: string; dir: string
|
|
|
221
224
|
}
|
|
222
225
|
facts.sidePorts = pack.sidePorts;
|
|
223
226
|
}
|
|
227
|
+
if (pack.seed !== undefined) {
|
|
228
|
+
// ADR 0033 §3: one door per kind of fact, each the vendor's own request at its own host; only a domain's DNS record
|
|
229
|
+
// is a `/_twin/` stand-in
|
|
230
|
+
const seed = pack.seed as Record<string, unknown>;
|
|
231
|
+
const door = (value: unknown, what: string, standIn = false): void => {
|
|
232
|
+
let url: URL;
|
|
233
|
+
try { url = new URL(String(value)); } catch { throw new Error(`pack-facts: ${vendor} seed ${what} "${String(value)}" is not a URL`); }
|
|
234
|
+
if (url.protocol !== 'https:') throw new Error(`pack-facts: ${vendor} seed ${what} is the vendor's own https request`);
|
|
235
|
+
if (!standIn && url.pathname.startsWith('/_twin/')) throw new Error(`pack-facts: ${vendor} seed ${what} is the vendor's own request, never a /_twin/ door`);
|
|
236
|
+
};
|
|
237
|
+
if (!seed || typeof seed !== 'object' || Array.isArray(seed) || Object.keys(seed).some((k) => k !== 'signUp' && k !== 'domain')) throw new Error(`pack-facts: ${vendor} seed is { signUp?, domain? }`);
|
|
238
|
+
if (seed.signUp !== undefined) door(seed.signUp, 'signUp');
|
|
239
|
+
if (seed.domain !== undefined) {
|
|
240
|
+
const domain = seed.domain as Record<string, unknown>;
|
|
241
|
+
if (!domain || typeof domain !== 'object' || Array.isArray(domain) || Object.keys(domain).sort().join() !== 'add,record,settles,verify') throw new Error(`pack-facts: ${vendor} seed.domain is { add, record, verify, settles }`);
|
|
242
|
+
door(domain.add, 'domain.add'); door(domain.verify, 'domain.verify'); door(domain.record, 'domain.record', true);
|
|
243
|
+
if (typeof domain.settles !== 'string' || !/^\d+[smhd]$/.test(domain.settles)) throw new Error(`pack-facts: ${vendor} seed.domain.settles is a World-clock time (10m)`);
|
|
244
|
+
}
|
|
245
|
+
facts.seed = pack.seed;
|
|
246
|
+
}
|
|
224
247
|
// The serve factory is DERIVED from the module's exports (see PackFacts.serveExport);
|
|
225
248
|
// a pack exporting SEVERAL factories must declare which one `cli.ts serve` boots — the
|
|
226
249
|
// exports alone cannot reveal that judgment (linear defaults to its DERIVED server).
|
|
@@ -240,7 +263,7 @@ export async function compilePackFacts(dirs: Array<{ vendor: string; dir: string
|
|
|
240
263
|
if (Object.keys(facts).length > 0) packs[vendor] = facts;
|
|
241
264
|
}
|
|
242
265
|
return {
|
|
243
|
-
'//': 'GENERATED by
|
|
266
|
+
'//': 'GENERATED by twin-standard facts from the pack descriptor; no prior-artifact entries are retained.',
|
|
244
267
|
packs: sortObject(packs),
|
|
245
268
|
undeclared,
|
|
246
269
|
};
|
package/src/protocol-3.ts
CHANGED
|
@@ -17,6 +17,7 @@ import ts from 'typescript';
|
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import { sharingLanesIn } from './lanes.ts';
|
|
19
19
|
import { isPackArtifact } from './pack-files.ts';
|
|
20
|
+
import { credentialIssuerImports } from './credential-issuer-import.ts';
|
|
20
21
|
import type { Criterion, GradeUnit, Standard } from './types.ts';
|
|
21
22
|
|
|
22
23
|
// A path relative to another with '/' between its parts on every platform: git lists files that way, and every rule
|
|
@@ -170,7 +171,7 @@ function noReport(unit: GradeUnit): string {
|
|
|
170
171
|
/** An operation's family: the first segment of its path after a version (`/v1/organizations/{id}` is `organizations`), a
|
|
171
172
|
* dotted RPC method's first part (`chat.postMessage` is `chat`), a line command's name (architecture, "Pack layout"). */
|
|
172
173
|
function familyOf(path: string): string {
|
|
173
|
-
// the interim rule ("What an author writes"), until
|
|
174
|
+
// the interim rule ("What an author writes"), until the derivation writes the spec's own grouping on each operation
|
|
174
175
|
const segs = path.split('/').filter((x) => x && !/^v?\d+(\.\d+)?$|^\d{4}-\d{2}-\d{2}$/.test(x) && !x.startsWith('{'));
|
|
175
176
|
if (!segs.length) return 'operations'; // named in a header: every path is `/`
|
|
176
177
|
const first = segs[0]!;
|
|
@@ -261,7 +262,7 @@ const criteria: Criterion[] = [
|
|
|
261
262
|
},
|
|
262
263
|
{
|
|
263
264
|
id: 'derived-reproducible', section: 'form',
|
|
264
|
-
asks: '
|
|
265
|
+
asks: 'twin-standard derive regenerates src/generated/ byte for byte',
|
|
265
266
|
check(unit) {
|
|
266
267
|
const gen = join(unit.dir, 'src', 'generated');
|
|
267
268
|
if (!existsSync(gen)) return fail('src/generated/ is missing');
|
|
@@ -278,9 +279,9 @@ const criteria: Criterion[] = [
|
|
|
278
279
|
// the pack's own path and the scratch directory are this checkout's, not the pack's: the verdict names neither, so
|
|
279
280
|
// a standing written from one checkout reads the same from any other
|
|
280
281
|
if (run.status === null || run.signal !== null || run.error) return { pass: false, cacheable: false,
|
|
281
|
-
reason: `derive
|
|
282
|
-
if (run.status !== 0) return { ...fail(`derive
|
|
283
|
-
return changed.length ? fail(`derive
|
|
282
|
+
reason: `derive interrupted: status=${run.status} signal=${run.signal} error=${run.error?.message ?? ''}` };
|
|
283
|
+
if (run.status !== 0) return { ...fail(`derive 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 };
|
|
284
|
+
return changed.length ? fail(`derive writes differently: ${changed.slice(0, 5).join(', ')}${changed.length > 5 ? ` and ${changed.length - 5} more` : ''}`) : pass(`${filesUnder(gen).length} files reproduced`);
|
|
284
285
|
},
|
|
285
286
|
},
|
|
286
287
|
{
|
|
@@ -340,13 +341,14 @@ const criteria: Criterion[] = [
|
|
|
340
341
|
},
|
|
341
342
|
{
|
|
342
343
|
id: 'handler-contract', section: 'form',
|
|
343
|
-
asks: 'every file in semantics/ reaches no randomness, clock, environment, network or module but the kernel, the states
|
|
344
|
+
asks: 'every file in semantics/ reaches no randomness, clock, environment, network or module but the kernel, the states, the shared helpers and a public subpath of its declared credential issuer pinned at an exact version as a direct runtime dependency; each handler file is its family\'s and exports only its operations, named by the fixed rule',
|
|
344
345
|
check(unit) {
|
|
345
346
|
const dir = join(unit.dir, 'src', 'semantics');
|
|
346
347
|
if (!existsSync(dir)) return pass('no handlers');
|
|
347
348
|
const ops = operationsOf(unit);
|
|
348
349
|
const families = new Set(ops.values());
|
|
349
350
|
const found: string[] = [];
|
|
351
|
+
const issuerImport = credentialIssuerImports(unit.dir, packageOf(unit));
|
|
350
352
|
for (const f of filesUnder(dir).filter((x) => /\.tsx?$/.test(x) && !TEST_FILE.test(x))) {
|
|
351
353
|
const text = stripComments(readFileSync(join(dir, f), 'utf8'));
|
|
352
354
|
const raw = readFileSync(join(dir, f), 'utf8');
|
|
@@ -363,7 +365,7 @@ const criteria: Criterion[] = [
|
|
|
363
365
|
const engineModule = /^\.\.\/engine\/[A-Za-z0-9_-]+\.ts$/.test(from) || vendorPath(unit, from, 3, /^engine\/[A-Za-z0-9_-]+\.ts$/);
|
|
364
366
|
// a lane over its vendor's state takes the vendor's states and shared helpers ("Pack layout")
|
|
365
367
|
const vendorShared = vendorPath(unit, from, 3, /^(semantics\/(states|shared)|manifest)\.ts$/);
|
|
366
|
-
if (from !== '@volter/world-core' && !/^\.\/(states|shared)\.ts$/.test(from) && !mapImport && !engineModule && !vendorShared) found.push(`${f} imports ${from}`);
|
|
368
|
+
if (from !== '@volter/world-core' && !/^\.\/(states|shared)\.ts$/.test(from) && !mapImport && !engineModule && !vendorShared && !issuerImport(from)) found.push(`${f} imports ${from}`);
|
|
367
369
|
}
|
|
368
370
|
// the handler map is typed over the narrow context, so the compiler holds every handler it names to the contract
|
|
369
371
|
if (f === 'index.ts' && !/:\s*Record<\s*string\s*,\s*Handler\s*>/.test(text)) found.push('index.ts does not type its handler map Record<string, Handler>');
|
|
@@ -468,11 +470,12 @@ const criteria: Criterion[] = [
|
|
|
468
470
|
},
|
|
469
471
|
{
|
|
470
472
|
id: 'kernel-contract', section: 'form',
|
|
471
|
-
asks: "every file of src/ imports the kernel through its root, the pack contract, and nothing past it: no `@volter/world-core/runtime` or other entry (cli.ts alone takes args and lifecycle), no node builtin, no other package but @volter/world-ui and the generated GraphQL library loader",
|
|
473
|
+
asks: "every file of src/ imports the kernel through its root, the pack contract, and nothing past it: no `@volter/world-core/runtime` or other entry (cli.ts alone takes args and lifecycle), no node builtin, no other package but @volter/world-ui, a public subpath of its declared credential issuer pinned at an exact version as a direct runtime dependency, and the generated GraphQL library loader",
|
|
472
474
|
check(unit) {
|
|
473
475
|
const src = join(unit.dir, 'src');
|
|
474
476
|
if (!existsSync(src)) return fail('src/ is missing');
|
|
475
477
|
const found: string[] = [];
|
|
478
|
+
const issuerImport = credentialIssuerImports(unit.dir, packageOf(unit));
|
|
476
479
|
for (const f of filesUnder(src).filter((x) => /\.tsx?$/.test(x) && !TEST_FILE.test(x) && !x.startsWith('generated/'))) {
|
|
477
480
|
// the file's imports, re-exports and dynamic imports as TypeScript reads them (a string that only reads like one,
|
|
478
481
|
// an error message's "from '<name>'", is none)
|
|
@@ -512,6 +515,7 @@ const criteria: Criterion[] = [
|
|
|
512
515
|
for (const from of specifiers) {
|
|
513
516
|
if (from.startsWith('.')) continue;
|
|
514
517
|
if (from === '@volter/world-core' || from === '@volter/world-ui') continue;
|
|
518
|
+
if (issuerImport(from)) continue;
|
|
515
519
|
if (f === 'cli.ts' && (from === '@volter/world-core/args' || from === '@volter/world-core/lifecycle')) continue;
|
|
516
520
|
found.push(`${f} imports ${from}`);
|
|
517
521
|
}
|