@volter/twin-standard 1.0.158 → 1.0.159
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/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/run.js +19 -14
- package/dist/src/conformance.d.ts +2 -0
- package/dist/src/conformance.js +392 -184
- package/dist/src/consumer.d.ts +22 -1
- package/dist/src/consumer.js +91 -26
- package/dist/src/credential-issuer-import.d.ts +3 -0
- package/dist/src/credential-issuer-import.js +42 -0
- package/dist/src/derive.js +3 -0
- package/dist/src/gate.js +4 -0
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +7 -0
- package/dist/src/protocol-3.js +8 -3
- package/dist/src/spec-documents.d.ts +4 -0
- package/dist/src/spec-documents.js +110 -3
- package/dist/src/tools/life-coverage-walk.js +38 -26
- package/dist/src/tools/score-pack.js +6 -5
- package/dist/src/tools/vendor-examples.js +91 -80
- package/dist/src/walk/behavior-journey.d.ts +22 -14
- package/dist/src/walk/behavior-journey.js +182 -133
- package/dist/src/walk/journey-kit.d.ts +3 -2
- 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 +38 -2
- package/dist/src/walk/shape.d.ts +25 -4
- package/dist/src/walk/shape.js +166 -56
- 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 +3 -3
- package/src/assessment.ts +12 -8
- package/src/browser-assessment.ts +12 -5
- package/src/clients/harness.ts +142 -19
- package/src/clients/jira.ts +4 -2
- package/src/clients/run.ts +17 -15
- package/src/conformance.ts +313 -128
- package/src/consumer.ts +83 -21
- package/src/credential-issuer-import.ts +38 -0
- package/src/derive.ts +3 -1
- package/src/gate.ts +3 -0
- package/src/lanes.ts +7 -0
- package/src/protocol-3.ts +7 -3
- package/src/spec-documents.ts +92 -3
- package/src/tools/life-coverage-walk.ts +32 -22
- package/src/tools/score-pack.ts +7 -6
- package/src/tools/vendor-examples.ts +88 -75
- package/src/walk/behavior-journey.ts +145 -109
- package/src/walk/journey-kit.ts +7 -5
- package/src/walk/replay.ts +103 -0
- package/src/walk/serve-pack.ts +25 -5
- package/src/walk/shape.ts +156 -54
- 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
|
+
}
|
|
@@ -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
|
@@ -312,7 +312,7 @@ if (existsSync(manifestPath)) {
|
|
|
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/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/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
|
|
@@ -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
|
}
|
package/src/spec-documents.ts
CHANGED
|
@@ -10,24 +10,113 @@ import { parse as parseYaml } from 'yaml';
|
|
|
10
10
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { gunzipSync } from 'node:zlib';
|
|
13
|
+
import { applySpecPatch, type SpecPatch } from './spec-patches.ts';
|
|
14
|
+
import { slug } from './spec-ir.ts';
|
|
13
15
|
|
|
14
16
|
type Json = any;
|
|
15
17
|
export type SpecDocuments = { documents: Record<string, Json> };
|
|
16
18
|
|
|
19
|
+
function readDocument(file: string): Json {
|
|
20
|
+
const bytes = readFileSync(file);
|
|
21
|
+
const text = (file.endsWith('.gz') ? gunzipSync(bytes) : bytes).toString('utf8');
|
|
22
|
+
return /\.ya?ml(\.gz)?$/.test(file) ? parseExactJson(JSON.stringify(parseYaml(text, { intAsBigInt: true }), (_, value) => typeof value === 'bigint' ? exactInteger(String(value)) : value)) : parseExactJson(text);
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
/** The documents of `spec/openapi/`, by name, or undefined when the pack vendors one spec file. */
|
|
18
26
|
export function readSpecDocuments(specDir: string): SpecDocuments | undefined {
|
|
19
27
|
const dir = join(specDir, 'openapi');
|
|
20
28
|
if (!existsSync(dir)) return undefined;
|
|
21
29
|
const documents: Record<string, Json> = {};
|
|
22
30
|
for (const f of readdirSync(dir).filter((f) => /\.(json|ya?ml)(\.gz)?$/.test(f)).sort()) {
|
|
23
|
-
|
|
24
|
-
const text = (f.endsWith('.gz') ? gunzipSync(bytes) : bytes).toString('utf8');
|
|
25
|
-
documents[f.replace(/\.(json|ya?ml)(\.gz)?$/, '')] = /\.ya?ml(\.gz)?$/.test(f) ? parseExactJson(JSON.stringify(parseYaml(text, { intAsBigInt: true }), (_, v) => typeof v === 'bigint' ? exactInteger(String(v)) : v)) : parseExactJson(text);
|
|
31
|
+
documents[f.replace(/\.(json|ya?ml)(\.gz)?$/, '')] = readDocument(join(dir, f));
|
|
26
32
|
}
|
|
27
33
|
if (Object.keys(documents).length < 2) throw new Error(`${dir}: one API in several documents needs at least two; a single document is spec/openapi.{json,yaml}`);
|
|
28
34
|
return { documents };
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
const openapiFiles = ['openapi.json.gz', 'openapi.json', 'openapi.yaml.gz', 'openapi.yaml'];
|
|
38
|
+
|
|
39
|
+
function readPatchedDocument(specDir: string, filenames: string[]): Json | undefined {
|
|
40
|
+
const file = filenames.map((name) => join(specDir, name)).find(existsSync);
|
|
41
|
+
const documents = file === undefined ? readSpecDocuments(specDir) : undefined;
|
|
42
|
+
let doc = file === undefined ? documents : readDocument(file);
|
|
43
|
+
if (doc === undefined) return undefined;
|
|
44
|
+
const patches = join(specDir, 'patches.json');
|
|
45
|
+
if (existsSync(patches)) for (const patch of (readDocument(patches) as { patches: SpecPatch[] }).patches) applySpecPatch(doc, patch);
|
|
46
|
+
if (documents !== undefined) {
|
|
47
|
+
const merged = mergeDocuments(doc);
|
|
48
|
+
if (merged.contradictions.length) throw new Error(`OpenAPI response declarations conflict: ${merged.contradictions.join('; ')}`);
|
|
49
|
+
doc = merged.doc;
|
|
50
|
+
}
|
|
51
|
+
return doc;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Response declarations as served, independent of their representation media or generated surface version. */
|
|
55
|
+
export function declaredResponseStatuses(specDir: string): Map<string, Set<string>> {
|
|
56
|
+
const doc = readPatchedDocument(specDir, openapiFiles);
|
|
57
|
+
const statuses = new Map<string, Set<string>>();
|
|
58
|
+
if (doc === undefined) return statuses;
|
|
59
|
+
for (const [path, item] of Object.entries(doc.paths ?? {}) as Array<[string, Record<string, { operationId?: string; responses?: Record<string, unknown> }>]>) {
|
|
60
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
61
|
+
if (!operation || typeof operation !== 'object' || !operation.responses) continue;
|
|
62
|
+
statuses.set(operation.operationId || slug(method, path), new Set(Object.keys(operation.responses)));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return statuses;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Declared schema fields, including nested objects that are not inferred as generated resources. */
|
|
69
|
+
export function schemaFieldNames(specDir: string): Set<string> {
|
|
70
|
+
const doc = readPatchedDocument(specDir, ['smithy.json.gz', 'smithy.json', ...openapiFiles, 'discovery.json.gz', 'discovery.json']);
|
|
71
|
+
const fields = new Set<string>();
|
|
72
|
+
if (doc === undefined) return fields;
|
|
73
|
+
for (const shape of Object.values(doc.shapes ?? {}) as Json[]) {
|
|
74
|
+
if (shape.type !== 'structure' && shape.type !== 'union') continue;
|
|
75
|
+
for (const [name, member] of Object.entries(shape.members ?? {}) as Array<[string, Json]>) {
|
|
76
|
+
fields.add(name);
|
|
77
|
+
for (const trait of ['smithy.api#jsonName', 'smithy.api#xmlName']) {
|
|
78
|
+
const wireName = member.traits?.[trait];
|
|
79
|
+
if (typeof wireName === 'string') fields.add(wireName);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const schema = (node: Json): void => {
|
|
84
|
+
if (Array.isArray(node)) { for (const item of node) schema(item); return; }
|
|
85
|
+
if (!node || typeof node !== 'object') return;
|
|
86
|
+
for (const [name, property] of Object.entries(node.properties ?? {}) as Array<[string, Json]>) {
|
|
87
|
+
fields.add(name);
|
|
88
|
+
if (typeof property?.xml?.name === 'string') fields.add(property.xml.name);
|
|
89
|
+
schema(property);
|
|
90
|
+
}
|
|
91
|
+
for (const keyword of ['$defs', 'definitions', 'patternProperties', 'dependentSchemas']) {
|
|
92
|
+
for (const child of Object.values(node[keyword] ?? {})) schema(child);
|
|
93
|
+
}
|
|
94
|
+
for (const keyword of ['items', 'additionalItems', 'additionalProperties', 'unevaluatedProperties', 'unevaluatedItems', 'not', 'contains', 'propertyNames', 'if', 'then', 'else', 'allOf', 'anyOf', 'oneOf', 'prefixItems']) schema(node[keyword]);
|
|
95
|
+
};
|
|
96
|
+
for (const catalog of [doc.components?.schemas, doc.definitions, doc.schemas]) {
|
|
97
|
+
for (const node of Object.values(catalog ?? {})) schema(node);
|
|
98
|
+
}
|
|
99
|
+
const body = (node: Json): void => {
|
|
100
|
+
schema(node?.schema);
|
|
101
|
+
for (const media of Object.values(node?.content ?? {}) as Json[]) schema(media?.schema);
|
|
102
|
+
};
|
|
103
|
+
for (const catalog of [doc.components?.responses, doc.components?.requestBodies, doc.components?.parameters, doc.components?.headers, doc.responses, doc.parameters]) {
|
|
104
|
+
for (const node of Object.values(catalog ?? {})) body(node);
|
|
105
|
+
}
|
|
106
|
+
for (const paths of [doc.paths, doc.webhooks, doc.components?.pathItems]) {
|
|
107
|
+
for (const item of Object.values(paths ?? {}) as Json[]) {
|
|
108
|
+
for (const parameter of item.parameters ?? []) body(parameter);
|
|
109
|
+
for (const method of ['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace']) {
|
|
110
|
+
const operation = item[method];
|
|
111
|
+
for (const parameter of operation?.parameters ?? []) body(parameter);
|
|
112
|
+
body(operation?.requestBody);
|
|
113
|
+
for (const response of Object.values(operation?.responses ?? {})) body(response);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return fields;
|
|
118
|
+
}
|
|
119
|
+
|
|
31
120
|
/** A node without its words (description, summary, examples, and the tags a document files it under): what two
|
|
32
121
|
* documents must agree on. */
|
|
33
122
|
const substance = (n: Json): string => JSON.stringify(n, (k, v) => (['description', 'summary', 'example', 'examples', 'x-mint', 'tags'].includes(k) ? undefined : v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v));
|
|
@@ -6,9 +6,9 @@ import { parseExactJson } from '@volter/world-core/exact-json';
|
|
|
6
6
|
// is its precondition, not what it measures. This module is no test file itself, so a published package holds it.
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import { listActions, observeTransitions, stateOf, type Actor, type DerivedManifest, type Transition } from '@volter/world-core/runtime';
|
|
9
|
+
import { listActions, observeTransitions, stateOf, type Actor, type DerivedManifest, type ErrorSpec, type Transition } from '@volter/world-core/runtime';
|
|
10
10
|
import { inMemoryWorld, walk, type Failure, usePacks } from '../walk/behavior-journey.ts';
|
|
11
|
-
import { lanesOf, shapeChecker } from '../walk/shape.ts';
|
|
11
|
+
import { lanesOf, lineShape, shapeChecker } from '../walk/shape.ts';
|
|
12
12
|
import { sharingLanesIn } from '../lanes.ts';
|
|
13
13
|
|
|
14
14
|
/** Registers, in the `bun test` run that imports it, the walks of `vendor` in the pack repository at `PACKS`. */
|
|
@@ -30,9 +30,12 @@ for (const [n, w] of walks.entries()) test(`${vendor}: ${w.file} walks`, async (
|
|
|
30
30
|
const { manifest } = (await import(join(PACKS, vendor, 'src', 'manifest.ts'))) as { manifest: DerivedManifest };
|
|
31
31
|
// the lanes over the vendor's state its serve factory serves are walked by this life, their machines with the vendor's
|
|
32
32
|
// ("Pack layout"); two manifests naming one resource's field would collide in the keys below, so that refuses
|
|
33
|
-
const
|
|
33
|
+
const units = [{ dir: join(PACKS, vendor), manifest, lane: undefined }, ...await Promise.all(sharingLanes(vendor).map(async (l) => {
|
|
34
|
+
const dir = join(PACKS, vendor, l);
|
|
35
|
+
return { dir, lane: l, manifest: ((await import(join(dir, 'src', 'manifest.ts'))) as { manifest: DerivedManifest }).manifest };
|
|
36
|
+
}))];
|
|
34
37
|
// a vendor of lanes' root (a VendorManifest) declares no resources of its own: its lanes do
|
|
35
|
-
const allResources =
|
|
38
|
+
const allResources = units.flatMap(({ manifest: mf }) => Object.entries(mf.resources ?? {}));
|
|
36
39
|
const machines = new Map<string, unknown>();
|
|
37
40
|
for (const [resource, decl] of allResources) for (const [field, machine] of Object.entries(decl.state ?? {})) {
|
|
38
41
|
const name = `${resource}.${field}`;
|
|
@@ -42,20 +45,25 @@ for (const [n, w] of walks.entries()) test(`${vendor}: ${w.file} walks`, async (
|
|
|
42
45
|
machines.set(name, machine);
|
|
43
46
|
}
|
|
44
47
|
const where = new Map<Transition, Array<{ at: string; field: string; type: string; service: string }>>();
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
const httpTransitions = new Set<Transition>();
|
|
49
|
+
for (const { dir, manifest: mf } of units) {
|
|
50
|
+
const http = !lineShape(dir);
|
|
51
|
+
for (const [resource, decl] of Object.entries(mf.resources ?? {})) {
|
|
52
|
+
for (const [field, m] of Object.entries(decl.state ?? {})) m.transitions.forEach((t, i) => {
|
|
53
|
+
if (http) httpTransitions.add(t);
|
|
54
|
+
const place = { at: `${resource}.${field}#${i}`, field, type: decl.storedAs ?? resource, service: mf.service ?? mf.vendor };
|
|
55
|
+
const places = where.get(t) ?? [];
|
|
56
|
+
if (!places.some((p) => p.at === place.at && p.type === place.type && p.service === place.service)) where.set(t, [...places, place]);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
51
59
|
}
|
|
52
60
|
const moves = new Map<string, Array<{ transition: Transition; from: string; field: string; actor: Actor; subject: string }>>();
|
|
53
61
|
const subjectKey = (service: string, type: string, id: string): string => JSON.stringify([service, type, id]);
|
|
54
62
|
observeTransitions((_decl, t, outcome, from, entry) => {
|
|
55
63
|
const places = where.get(t) ?? [];
|
|
56
64
|
if (outcome === 'refused') {
|
|
57
|
-
//
|
|
58
|
-
if (entry) return;
|
|
65
|
+
// HTTP refusals are credited from their received status and body; a line protocol owns its session's replies.
|
|
66
|
+
if (entry || httpTransitions.has(t)) return;
|
|
59
67
|
const groups = new Set(places.map((p) => JSON.stringify([p.service, p.type, p.field])));
|
|
60
68
|
if (groups.size === 1) for (const p of places) seen.add(`${p.at}|refused|${from}`);
|
|
61
69
|
return;
|
|
@@ -79,12 +87,14 @@ for (const [n, w] of walks.entries()) test(`${vendor}: ${w.file} walks`, async (
|
|
|
79
87
|
const root = world.root();
|
|
80
88
|
// what each step was answered, by the spec operation it called: a refusal is seen at the wire, whichever code gave it
|
|
81
89
|
const shape = await shapeChecker(join(PACKS, vendor));
|
|
82
|
-
const answered: Array<{ op: string;
|
|
90
|
+
const answered: Array<{ op: string; lane?: string; status: number; body: unknown }> = [];
|
|
83
91
|
try {
|
|
84
|
-
await walk(life, root, failures, { beats: w.beats, hooks: { after: (
|
|
92
|
+
await walk(life, root, failures, { beats: w.beats, hooks: { after: (_step, _path, answer) => {
|
|
85
93
|
// a line step's refusals are the machine's own (observeTransitions); a request's are read off its answer
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
94
|
+
const request = answer.request;
|
|
95
|
+
if (!request) return;
|
|
96
|
+
const hit = shape?.operationOf(request.method, request.url, request.headers);
|
|
97
|
+
if (hit) answered.push({ op: hit.op.id, lane: hit.lane, status: answer.status, body: answer.wireBody });
|
|
88
98
|
} } });
|
|
89
99
|
// a declared refusal is reached when a step on its operation was answered with it: its code, or its message
|
|
90
100
|
// up to the first placeholder
|
|
@@ -92,13 +102,13 @@ for (const [n, w] of walks.entries()) test(`${vendor}: ${w.file} walks`, async (
|
|
|
92
102
|
const holds = (value: unknown, code: string): boolean => (value !== null && typeof value === 'object'
|
|
93
103
|
? Object.values(value as Record<string, unknown>).some((v) => holds(v, code))
|
|
94
104
|
: (typeof value === 'string' || typeof value === 'number') && String(value) === code);
|
|
95
|
-
const answers = (a: {
|
|
96
|
-
r.code !== undefined ? holds(a.body, String(r.code)) : a.
|
|
97
|
-
for (const [resource, decl] of
|
|
105
|
+
const answers = (a: { status: number; body: unknown }, r: ErrorSpec): boolean =>
|
|
106
|
+
a.status === r.status && (r.code !== undefined ? holds(a.body, String(r.code)) : JSON.stringify(a.body)?.includes(r.message.split('{')[0]!.trim()) === true);
|
|
107
|
+
for (const { manifest: mf, lane } of units) for (const [resource, decl] of Object.entries(mf.resources ?? {})) {
|
|
98
108
|
for (const [field, m] of Object.entries(decl.state ?? {})) {
|
|
99
109
|
m.transitions.forEach((t, i) => {
|
|
100
110
|
if (!t.operation) return;
|
|
101
|
-
const on = answered.filter((a) => a.op === t.operation);
|
|
111
|
+
const on = answered.filter((a) => a.op === t.operation && a.lane === lane);
|
|
102
112
|
if (t.refusal && on.some((a) => answers(a, t.refusal!))) seen.add(`${resource}.${field}#${i}|refused|*`);
|
|
103
113
|
for (const [from, r] of Object.entries(t.refusals ?? {})) if (on.some((a) => answers(a, r))) seen.add(`${resource}.${field}#${i}|refused|${from}`);
|
|
104
114
|
});
|
|
@@ -108,13 +118,13 @@ for (const [n, w] of walks.entries()) test(`${vendor}: ${w.file} walks`, async (
|
|
|
108
118
|
// transition that allows it (from its state to the next, by its operation when both name one); a change no
|
|
109
119
|
// transition allows is an undeclared move
|
|
110
120
|
const fieldsOf = new Map<string, Array<[string, string, (typeof manifest.resources)[string]['state'] extends infer S ? S extends Record<string, infer F> ? F : never : never]>>();
|
|
111
|
-
for (const mf of
|
|
121
|
+
for (const { manifest: mf } of units) for (const [resource, decl] of Object.entries(mf.resources ?? {})) {
|
|
112
122
|
const key = JSON.stringify([mf.service ?? mf.vendor, decl.storedAs ?? resource]);
|
|
113
123
|
for (const [field, m] of Object.entries(decl.state ?? {})) fieldsOf.set(key, [...(fieldsOf.get(key) ?? []), [resource, field, m]]);
|
|
114
124
|
}
|
|
115
125
|
const current = new Map<string, Record<string, unknown>>();
|
|
116
126
|
// the services the life wrote: the vendor's, or its lanes' (a vendor of lanes' root names none of its own)
|
|
117
|
-
const services = [...new Set(
|
|
127
|
+
const services = [...new Set(units.map(({ manifest: mf }) => mf.service).filter((x): x is string => typeof x === 'string'))];
|
|
118
128
|
if (!services.length) services.push(manifest.vendor);
|
|
119
129
|
for (const a of services.flatMap((service) => listActions(service, root))) {
|
|
120
130
|
if (a.op !== 'set' || !a.fields) continue;
|
package/src/tools/score-pack.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// life per pack is walked through the pack's own fetch on the in-memory World, and the report says:
|
|
4
4
|
// - the life: whether a judge's plausible verdict names its current content (a verdict its author wrote is not one);
|
|
5
5
|
// - correctness: every check on every step (status, expectations, captures, SHAPE, the beats) and the life
|
|
6
|
-
// answering
|
|
6
|
+
// answering the same on a second fresh World under its declared engine outputs;
|
|
7
7
|
// - coverage: the hand-written code the life and the vendor's published examples run and the declared state logic
|
|
8
8
|
// they reach (life-coverage.ts), with what they never reach;
|
|
9
9
|
// - the vendor's published examples: replayed and judged (vendor-examples.ts);
|
|
@@ -27,6 +27,7 @@ import { dirname, join, sep } from 'node:path';
|
|
|
27
27
|
import { inMemoryWorld, walk, withPinnedWorldDraws, type Failure, usePacks } from '../walk/behavior-journey.ts';
|
|
28
28
|
import { expandFills, waitMs, type Journey } from '../walk/journey-kit.ts';
|
|
29
29
|
import { vendorFetch, vendorStream } from '../walk/vendor-fetch.ts';
|
|
30
|
+
import { compareReplays, type ReplayStep } from '../walk/replay.ts';
|
|
30
31
|
import { existenceProbe } from '../walk/journey-kit.ts';
|
|
31
32
|
import { kernelCheckout, packsRoot, toolPath } from './packs-root.ts';
|
|
32
33
|
const PACKS = packsRoot();
|
|
@@ -73,10 +74,10 @@ const judged = life.steps.length === 0 || !verdict ? 'unjudged' : dependent ? de
|
|
|
73
74
|
const world = inMemoryWorld(`score-${vendor}`);
|
|
74
75
|
const failures: Failure[] = [];
|
|
75
76
|
await walk(life, world.root(), failures, { beats: true });
|
|
76
|
-
const answers = async (): Promise<
|
|
77
|
-
const seen:
|
|
77
|
+
const answers = async (): Promise<ReplayStep[]> => {
|
|
78
|
+
const seen: ReplayStep[] = [];
|
|
78
79
|
// the World's own draws (its seed, its keys) are the same in both: two Worlds differ only by what is beyond them (R9)
|
|
79
|
-
await withPinnedWorldDraws(() => walk(life, world.root(),
|
|
80
|
+
await withPinnedWorldDraws(() => walk(life, world.root(), failures, { beats: false, hooks: { after: (step, path, answer) => seen.push({ name: step.name ?? '', path, status: answer.status, body: answer.body, ...(answer.sql ? { sql: answer.sql } : {}), ...(answer.connection ? { connection: answer.connection } : {}) }) } }));
|
|
80
81
|
return seen;
|
|
81
82
|
};
|
|
82
83
|
const [first, second] = [await answers(), await answers()];
|
|
@@ -166,8 +167,8 @@ const flags: Flag[] = [];
|
|
|
166
167
|
}
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
|
-
const
|
|
170
|
-
|
|
170
|
+
const replayComparison = compareReplays(first, second);
|
|
171
|
+
for (const detail of replayComparison.diagnostics) failures.push({ step: '(whole life)', beat: 'REPLAYABLE', detail });
|
|
171
172
|
|
|
172
173
|
// ── breadth ──────────────────────────────────────────────────────────────────────────────────
|
|
173
174
|
const factory = await vendorFetch(PACKS, vendor);
|