233 lines
8.2 KiB
TypeScript
233 lines
8.2 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* relay-client contract-symmetry-smoke — keep @morphit/relay-client's
|
|
* `RelayErrorCode` union in sync with what the relay code actually
|
|
* emits on the wire.
|
|
*
|
|
* Part 122 cp7 — created after the cp6 deep-deep found seven
|
|
* contract gaps in the initial relay-client extraction:
|
|
*
|
|
* F16 — ghost code `invite_required` was in the union but the
|
|
* relay never emits it
|
|
* F17 — `chunked_unsupported` emitted by security middleware
|
|
* was missing
|
|
* F18 — `malformed_request` emitted by three sites was missing
|
|
* F19 — `origin_required` + `origin_not_allowed` emitted by
|
|
* origin-enforcement middleware were missing
|
|
* F20 — `internal` emitted by main.ts onError catch-all was
|
|
* missing
|
|
*
|
|
* All seven were hand-extraction omissions; this smoke would have
|
|
* caught every one mechanically.
|
|
*
|
|
* Two-way symmetry rule:
|
|
*
|
|
* (A) Every `code: '<literal>'` string in apps/relay/src/ MUST
|
|
* appear in `RelayErrorCode`'s union. Missing codes mean
|
|
* the contract under-promises (worse failure mode: types
|
|
* pass while consumers fall through to a default).
|
|
*
|
|
* (B) Every `RelayErrorCode` union member MUST appear somewhere
|
|
* in apps/relay/src/ as a `code: '<literal>'` emission.
|
|
* Ghost members mean the contract over-promises (consumers
|
|
* prepare for codes that never arrive — wasted i18n keys,
|
|
* dead error-handling branches).
|
|
*
|
|
* Both directions are equally serious for the schema-as-contract
|
|
* pattern — the whole point of having a shared package is that
|
|
* the contract reflects reality.
|
|
*
|
|
* The smoke deliberately scans `apps/relay/src/` (production code
|
|
* only) and excludes test files (`*.test.ts`), since test scenarios
|
|
* may construct synthetic codes for negative-path coverage.
|
|
*
|
|
* Usage:
|
|
* tsx packages/relay-client/scripts/contract-symmetry-smoke.ts
|
|
*/
|
|
|
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const REPO_ROOT = join(import.meta.dirname, '..', '..', '..');
|
|
const RELAY_SRC = join(REPO_ROOT, 'apps', 'relay', 'src');
|
|
const RELAY_CLIENT_INDEX = join(REPO_ROOT, 'packages', 'relay-client', 'src', 'index.ts');
|
|
|
|
interface ScenarioResult {
|
|
readonly name: string;
|
|
readonly ok: boolean;
|
|
readonly detail?: string;
|
|
}
|
|
const results: ScenarioResult[] = [];
|
|
|
|
/** Walk apps/relay/src/ for *.ts files (excluding tests). */
|
|
function walkRelayTsFiles(): string[] {
|
|
if (!existsSync(RELAY_SRC)) return [];
|
|
const out: string[] = [];
|
|
const stack: string[] = [RELAY_SRC];
|
|
while (stack.length > 0) {
|
|
const dir = stack.pop()!;
|
|
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = join(dir, ent.name);
|
|
if (ent.isDirectory()) {
|
|
stack.push(full);
|
|
} else if (ent.isFile() && ent.name.endsWith('.ts')) {
|
|
if (ent.name.endsWith('.test.ts')) continue;
|
|
out.push(full);
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Extract every `code: '<literal>'` string the relay can emit. */
|
|
function collectRelayEmittedCodes(): Set<string> {
|
|
const codes = new Set<string>();
|
|
for (const path of walkRelayTsFiles()) {
|
|
const src = readFileSync(path, 'utf-8');
|
|
// Match `code: '<literal>'` in JSON-body construction sites
|
|
// AND in discriminated-union return-type definitions
|
|
// (e.g. `| { ok: false; code: 'altcha_malformed' }`).
|
|
// Both shapes mean "this code is part of the wire contract."
|
|
for (const m of src.matchAll(/code:\s*'([a-z_][a-z0-9_]*)'/g)) {
|
|
codes.add(m[1]!);
|
|
}
|
|
}
|
|
return codes;
|
|
}
|
|
|
|
/** Parse the `RelayErrorCode` union member literals from index.ts. */
|
|
function parseRelayErrorCodeUnion(): Set<string> {
|
|
let src = readFileSync(RELAY_CLIENT_INDEX, 'utf-8');
|
|
// Strip JSDoc block comments BEFORE the [^;]+ regex looks for
|
|
// the union terminator — block comments routinely contain `;`
|
|
// (e.g. "Chunked transfer-encoding rejected; client must send
|
|
// Content-Length.") which would otherwise truncate the union
|
|
// at the first comment-internal semicolon. Bug caught during
|
|
// cp7 smoke development.
|
|
src = src.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
// Strip line comments too for the same reason.
|
|
src = src.replace(/^[ \t]*\/\/.*$/gm, '');
|
|
const blockMatch = /export type RelayErrorCode =([^;]+);/m.exec(src);
|
|
if (!blockMatch) {
|
|
throw new Error(
|
|
'Could not locate `export type RelayErrorCode = ...;` block in relay-client/src/index.ts'
|
|
);
|
|
}
|
|
const block = blockMatch[1]!;
|
|
const codes = new Set<string>();
|
|
for (const m of block.matchAll(/\|\s*'([a-z_][a-z0-9_]*)'/g)) {
|
|
codes.add(m[1]!);
|
|
}
|
|
return codes;
|
|
}
|
|
|
|
// ─── Internal-only codes that the relay defines as code: '...' in
|
|
// module-internal Result types but never emits on the wire.
|
|
// These are LEGITIMATELY not in RelayErrorCode and excluded from
|
|
// the symmetry check.
|
|
const INTERNAL_ONLY_CODES = new Set<string>([
|
|
// apps/relay/src/crypto/keyEnvelope.ts — Result type for
|
|
// keystore decryption. These never reach an HTTP response.
|
|
'decryption_failed',
|
|
'malformed',
|
|
'weak_params',
|
|
// apps/relay/src/crypto/promptPassphrase.ts — Result type for
|
|
// startup-only TTY-prompt errors. No HTTP path exposes these.
|
|
'no_tty',
|
|
'cancelled',
|
|
'timeout',
|
|
'empty'
|
|
]);
|
|
|
|
// ─── Run ──
|
|
const emittedCodes = collectRelayEmittedCodes();
|
|
const unionCodes = parseRelayErrorCodeUnion();
|
|
|
|
// Filter out internal-only codes from the emitted set for the
|
|
// symmetry check.
|
|
const wireEmittedCodes = new Set<string>();
|
|
for (const c of emittedCodes) {
|
|
if (!INTERNAL_ONLY_CODES.has(c)) {
|
|
wireEmittedCodes.add(c);
|
|
}
|
|
}
|
|
|
|
// (A) Every wire-emitted code must be in the union.
|
|
const missingFromUnion = [...wireEmittedCodes].filter((c) => !unionCodes.has(c));
|
|
results.push({
|
|
name: 'every relay-emitted code is in RelayErrorCode union (direction A)',
|
|
ok: missingFromUnion.length === 0,
|
|
detail:
|
|
missingFromUnion.length === 0
|
|
? undefined
|
|
: `Codes emitted by apps/relay/src/ but absent from RelayErrorCode: ` +
|
|
`[${missingFromUnion.sort().join(', ')}]. ` +
|
|
`Add them to the union in packages/relay-client/src/index.ts. ` +
|
|
`If a code is internal-only (never reaches an HTTP response), add it to ` +
|
|
`INTERNAL_ONLY_CODES in this smoke instead.`
|
|
});
|
|
|
|
// (B) Every union member must be emitted somewhere in the relay.
|
|
const ghostMembers = [...unionCodes].filter((c) => !emittedCodes.has(c));
|
|
results.push({
|
|
name: 'every RelayErrorCode union member is emitted by the relay (direction B)',
|
|
ok: ghostMembers.length === 0,
|
|
detail:
|
|
ghostMembers.length === 0
|
|
? undefined
|
|
: `Union members declared in RelayErrorCode but never emitted by ` +
|
|
`apps/relay/src/: [${ghostMembers.sort().join(', ')}]. ` +
|
|
`Either remove these ghost members from the union (the contract is ` +
|
|
`over-promising), or grep more carefully — they may be emitted via an ` +
|
|
`indirection this smoke doesn't follow.`
|
|
});
|
|
|
|
// (C) Sanity: at least some codes were found on both sides.
|
|
results.push({
|
|
name: 'sanity: at least 10 codes emitted by relay (catches a parse failure)',
|
|
ok: emittedCodes.size >= 10,
|
|
detail:
|
|
emittedCodes.size >= 10
|
|
? undefined
|
|
: `Found only ${emittedCodes.size} code: '<literal>' references in apps/relay/src/. ` +
|
|
`Expected dozens. Did the relay source-tree layout change?`
|
|
});
|
|
results.push({
|
|
name: 'sanity: at least 10 codes in RelayErrorCode union (catches a parse failure)',
|
|
ok: unionCodes.size >= 10,
|
|
detail:
|
|
unionCodes.size >= 10
|
|
? undefined
|
|
: `Parsed only ${unionCodes.size} members from RelayErrorCode union. ` +
|
|
`Expected dozens. Is the type block formatted unexpectedly?`
|
|
});
|
|
|
|
// ─── Report ──
|
|
console.log(
|
|
`relay-client contract-symmetry smoke: ${results.length} scenarios ` +
|
|
`(${emittedCodes.size} relay-emitted codes — ${INTERNAL_ONLY_CODES.size} ` +
|
|
`internal-only = ${wireEmittedCodes.size} wire-emitted; ` +
|
|
`${unionCodes.size} RelayErrorCode union members)\n`
|
|
);
|
|
let failed = 0;
|
|
for (const r of results) {
|
|
if (r.ok) {
|
|
console.log(` ✓ ${r.name}`);
|
|
} else {
|
|
console.log(` ✗ ${r.name}`);
|
|
if (r.detail) {
|
|
for (const line of r.detail.split('\n')) {
|
|
console.log(` ${line}`);
|
|
}
|
|
}
|
|
failed++;
|
|
}
|
|
}
|
|
console.log('');
|
|
if (failed === 0) {
|
|
console.log(`✓ all ${results.length} contract-symmetry checks hold`);
|
|
process.exit(0);
|
|
} else {
|
|
console.error(`✗ ${failed} failed, ${results.length - failed} passed`);
|
|
process.exit(1);
|
|
}
|