Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
4 changed files with 69131 additions and 9001 deletions
232
scripts/examples/run-examples.ts
Normal file
232
scripts/examples/run-examples.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Build-and-e2e-run every story under `examples/` over every transport × era
|
||||
* leg it supports. Each story's `client.ts` is a self-verifying top-level-await
|
||||
* script: a `check.*` failure throws, Node prints the error and exits 1; on
|
||||
* success `client.close()` releases the last handle and Node exits 0. The
|
||||
* harness reports PASS/FAIL from the child's exit code (a timeout is a FAIL
|
||||
* with "hung — possible unclosed handle").
|
||||
*
|
||||
* - **stdio** (default for dual-transport stories): run `client.ts` with no
|
||||
* transport flag; it spawns the sibling server binary itself and speaks
|
||||
* MCP over the pipe.
|
||||
* - **HTTP**: start `server.ts --http --port <P>`, poll until ready, run
|
||||
* `client.ts --http http://127.0.0.1:<P>/<path>`, kill the server.
|
||||
* - **modern** (default): the client negotiates the 2026-07-28 era
|
||||
* (`versionNegotiation: { mode: 'auto' }`).
|
||||
* - **legacy**: pass `--legacy` to the client so it uses the 2025
|
||||
* `initialize` handshake (`versionNegotiation: { mode: 'legacy' }`).
|
||||
*
|
||||
* Per-story configuration lives in the story's `package.json` under the
|
||||
* `"example"` field — most stories have none. `excluded` stories are listed
|
||||
* (with their reason) but not run. Stories without a `client.ts` are skipped.
|
||||
*/
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { connect } from 'node:net';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
type Era = 'modern' | 'legacy';
|
||||
type Transport = 'stdio' | 'http';
|
||||
|
||||
interface ExampleConfig {
|
||||
/** Transports to run (default: `['stdio', 'http']`). */
|
||||
transports?: Transport[];
|
||||
/** `'dual'` (modern + legacy; the default), `'modern'`, or `'legacy'`. */
|
||||
era?: 'dual' | Era;
|
||||
/** HTTP port (default: a per-story port assigned below). */
|
||||
port?: number;
|
||||
/** Endpoint path (default: `'/mcp'`). */
|
||||
path?: string;
|
||||
/** Extra environment for the server process. */
|
||||
env?: Record<string, string>;
|
||||
/** Per-leg timeout in milliseconds (default: 30000). */
|
||||
timeoutMs?: number;
|
||||
/** Optional substring the client's stdout must contain. */
|
||||
expects?: { stdout?: string };
|
||||
/** When present, the story is skipped (with this reason printed). */
|
||||
excluded?: string;
|
||||
}
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '../..');
|
||||
const EXAMPLES = join(ROOT, 'examples');
|
||||
const TSX = join(ROOT, 'node_modules', '.bin', 'tsx');
|
||||
|
||||
/** Directories that are never stories. */
|
||||
const NON_STORY = new Set(['shared', 'guides', 'server-quickstart', 'client-quickstart', 'node_modules']);
|
||||
|
||||
/** Distinct per-story HTTP ports so the servers never collide. */
|
||||
let nextPort = 8530;
|
||||
const portFor = new Map<string, number>();
|
||||
function assignPort(story: string, config: ExampleConfig): number {
|
||||
if (config.port) return config.port;
|
||||
if (!portFor.has(story)) portFor.set(story, nextPort++);
|
||||
return portFor.get(story)!;
|
||||
}
|
||||
|
||||
function readConfig(dir: string): ExampleConfig {
|
||||
const file = join(dir, 'package.json');
|
||||
if (!existsSync(file)) return {};
|
||||
const pkg = JSON.parse(readFileSync(file, 'utf8')) as { example?: ExampleConfig };
|
||||
return pkg.example ?? {};
|
||||
}
|
||||
|
||||
function run(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: { cwd: string; env?: Record<string, string>; timeoutMs: number }
|
||||
): Promise<{ code: number; stdout: string; stderr: string; timedOut: boolean }> {
|
||||
return new Promise(resolvePromise => {
|
||||
const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env } });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', d => (stdout += String(d)));
|
||||
child.stderr.on('data', d => (stderr += String(d)));
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
resolvePromise({ code: 124, stdout, stderr: stderr + '\n[harness] timed out', timedOut: true });
|
||||
}, opts.timeoutMs);
|
||||
child.on('close', code => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise({ code: code ?? 1, stdout, stderr, timedOut: false });
|
||||
});
|
||||
child.on('error', err => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise({ code: 1, stdout, stderr: stderr + `\n[harness] spawn error: ${err.message}`, timedOut: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Story `client.ts` files are top-level-await scripts: a thrown `check.*`
|
||||
* propagates as an unhandled rejection (Node prints + exits 1); a clean run
|
||||
* exits 0 once `client.close()` releases the last handle. The harness prints
|
||||
* PASS/FAIL itself from the child's exit code — there is no in-band OK/FAIL
|
||||
* line. A timeout means the process never exited on its own and is reported as
|
||||
* a hang (possible unclosed handle).
|
||||
*/
|
||||
function toLegResult(
|
||||
story: string,
|
||||
leg: string,
|
||||
result: { code: number; stdout: string; stderr: string; timedOut: boolean },
|
||||
config: ExampleConfig,
|
||||
serverLog?: string
|
||||
): LegResult {
|
||||
if (result.timedOut) {
|
||||
return { story, leg, ok: false, detail: `(hung — possible unclosed handle)\n${result.stderr || result.stdout}${serverLog ?? ''}` };
|
||||
}
|
||||
const ok = result.code === 0 && (!config.expects?.stdout || result.stdout.includes(config.expects.stdout));
|
||||
return {
|
||||
story,
|
||||
leg,
|
||||
ok,
|
||||
detail: ok
|
||||
? (result.stdout.trim().split('\n').pop() ?? '')
|
||||
: `exit ${result.code}\n${result.stderr || result.stdout}${serverLog ?? ''}`
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForPort(port: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const ok = await new Promise<boolean>(resolvePromise => {
|
||||
const sock = connect({ port, host: '127.0.0.1' }, () => {
|
||||
sock.destroy();
|
||||
resolvePromise(true);
|
||||
});
|
||||
sock.on('error', () => resolvePromise(false));
|
||||
});
|
||||
if (ok) return true;
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface LegResult {
|
||||
story: string;
|
||||
leg: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const eraArgs = (era: Era): string[] => (era === 'legacy' ? ['--legacy'] : []);
|
||||
|
||||
async function runStdioLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise<LegResult> {
|
||||
const timeoutMs = config.timeoutMs ?? 30_000;
|
||||
const result = await run(TSX, [join(dir, 'client.ts'), ...eraArgs(era)], { cwd: ROOT, timeoutMs });
|
||||
return toLegResult(story, `stdio/${era}`, result, config);
|
||||
}
|
||||
|
||||
async function runHttpLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise<LegResult> {
|
||||
const timeoutMs = config.timeoutMs ?? 30_000;
|
||||
const port = assignPort(story, config);
|
||||
const path = config.path ?? '/mcp';
|
||||
const url = `http://127.0.0.1:${port}${path}`;
|
||||
let serverStderr = '';
|
||||
const server: ChildProcess = spawn(TSX, [join(dir, 'server.ts'), '--http', '--port', String(port)], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, PORT: String(port), ...config.env }
|
||||
});
|
||||
server.stderr?.on('data', d => (serverStderr += String(d)));
|
||||
server.stdout?.on('data', d => (serverStderr += String(d)));
|
||||
try {
|
||||
const ready = await waitForPort(port, 15_000);
|
||||
if (!ready) {
|
||||
return { story, leg: `http/${era}`, ok: false, detail: `server never bound :${port}\n--- server log ---\n${serverStderr}` };
|
||||
}
|
||||
const result = await run(TSX, [join(dir, 'client.ts'), '--http', url, ...eraArgs(era)], { cwd: ROOT, timeoutMs });
|
||||
return toLegResult(story, `http/${era}`, result, config, `\n--- server log ---\n${serverStderr}`);
|
||||
} finally {
|
||||
server.kill('SIGTERM');
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
// `.killed` flips true the moment kill() is called, so it can't gate
|
||||
// the backstop; check whether the process actually exited instead.
|
||||
if (server.exitCode === null && server.signalCode === null) server.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const stories = readdirSync(EXAMPLES, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && !NON_STORY.has(d.name))
|
||||
.map(d => d.name)
|
||||
.filter(name => existsSync(join(EXAMPLES, name, 'client.ts')))
|
||||
.sort();
|
||||
|
||||
const results: LegResult[] = [];
|
||||
const excluded: Array<{ story: string; reason: string }> = [];
|
||||
|
||||
for (const story of stories) {
|
||||
const dir = join(EXAMPLES, story);
|
||||
const config = readConfig(dir);
|
||||
if (config.excluded) {
|
||||
excluded.push({ story, reason: config.excluded });
|
||||
console.log(`\n::group::example ${story}\nSKIPPED: ${config.excluded}\n::endgroup::`);
|
||||
continue;
|
||||
}
|
||||
const transports: Transport[] = config.transports ?? ['stdio', 'http'];
|
||||
const era = config.era ?? 'dual';
|
||||
const eras: Era[] = era === 'dual' ? ['modern', 'legacy'] : [era];
|
||||
console.log(`\n::group::example ${story} (${transports.join('+')} × ${era})`);
|
||||
for (const t of transports) {
|
||||
for (const e of eras) {
|
||||
const r = t === 'stdio' ? await runStdioLeg(story, dir, config, e) : await runHttpLeg(story, dir, config, e);
|
||||
results.push(r);
|
||||
console.log(`[${r.leg}] ${r.ok ? 'PASS' : 'FAIL'}: ${r.detail.split('\n')[0]}`);
|
||||
if (!r.ok) console.log(r.detail);
|
||||
}
|
||||
}
|
||||
console.log('::endgroup::');
|
||||
}
|
||||
|
||||
const passed = results.filter(r => r.ok).length;
|
||||
const failed = results.filter(r => !r.ok);
|
||||
console.log('\n=== examples e2e summary ===');
|
||||
console.log(`stories: ${stories.length - excluded.length} run / ${excluded.length} excluded`);
|
||||
console.log(`legs: ${passed} passed / ${failed.length} failed`);
|
||||
for (const r of failed) console.log(` FAIL ${r.story} [${r.leg}]`);
|
||||
for (const e of excluded) console.log(` SKIP ${e.story}: ${e.reason}`);
|
||||
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
void main();
|
||||
73
scripts/fetch-schema-twins.ts
Normal file
73
scripts/fetch-schema-twins.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Vendors the generated `schema.json` twins from the spec repository into
|
||||
* `packages/core-internal/test/corpus/schema-twins/` as RAW UPSTREAM BYTES.
|
||||
*
|
||||
* The twins are TEST-ONLY conformance oracles (never bundled, never runtime):
|
||||
* `packages/core-internal/test/wire/schemaTwinConformance.test.ts` compiles them into
|
||||
* generated validators and locks the hand-written wire layer to them. Their
|
||||
* authority rests on provenance, so they are vendored verbatim — no
|
||||
* formatting of any kind (the directory is .prettierignore'd) — and each file
|
||||
* is locked to the manifest's sha256/byte values at test time. Any rewrite
|
||||
* (prettier, an editor, a manual touch-up) turns CI red.
|
||||
*
|
||||
* Refresh ATOMICALLY with the matching spec.types anchor (see
|
||||
* packages/core-internal/src/types/README.md lifecycle rule 4).
|
||||
*
|
||||
* Usage:
|
||||
* pnpm fetch:schema-twins [sha] # default: the manifest's current source commit
|
||||
*
|
||||
* Sources are fetched from GitHub at the given commit, mirroring
|
||||
* scripts/fetch-spec-types.ts; the manifest's provenance values (source
|
||||
* commit, sha256, byte size) are recomputed from the fetched bytes.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const PROJECT_ROOT = join(dirname(__filename), '..');
|
||||
|
||||
const SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol';
|
||||
const TWINS_DIR = join(PROJECT_ROOT, 'packages', 'core-internal', 'test', 'corpus', 'schema-twins');
|
||||
const MANIFEST_PATH = join(TWINS_DIR, 'manifest.json');
|
||||
|
||||
interface TwinManifest {
|
||||
comment: string;
|
||||
source: { repository: string; commit: string };
|
||||
files: Record<string, { sha256: string; bytes: number; upstreamPath: string }>;
|
||||
}
|
||||
|
||||
async function fetchRawBytes(sha: string, upstreamPath: string): Promise<Buffer> {
|
||||
const url = `https://raw.githubusercontent.com/${SPEC_REPO}/${sha}/${upstreamPath}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${upstreamPath}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as TwinManifest;
|
||||
const sha = process.argv[2] ?? manifest.source.commit;
|
||||
|
||||
for (const [revision, entry] of Object.entries(manifest.files)) {
|
||||
console.log(`[${revision}] Fetching ${entry.upstreamPath} at ${sha}`);
|
||||
const bytes = await fetchRawBytes(sha, entry.upstreamPath);
|
||||
// Verbatim: the twin IS the upstream artifact, byte for byte.
|
||||
writeFileSync(join(TWINS_DIR, `${revision}.schema.json`), bytes);
|
||||
entry.sha256 = createHash('sha256').update(bytes).digest('hex');
|
||||
entry.bytes = bytes.byteLength;
|
||||
console.log(`[${revision}] ${entry.bytes} bytes, sha256 ${entry.sha256}`);
|
||||
}
|
||||
|
||||
manifest.source = { repository: SPEC_REPO, commit: sha };
|
||||
writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 4)}\n`, 'utf8');
|
||||
console.log(`Updated ${MANIFEST_PATH}`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('Error:', error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
150
scripts/fetch-spec-examples.ts
Normal file
150
scripts/fetch-spec-examples.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Vendors the draft-revision (2026-07-28) example corpus from the spec
|
||||
* repository into `packages/core-internal/test/corpus/fixtures/2026-07-28/`.
|
||||
*
|
||||
* The spec repository ships canonical example instances for the draft schema
|
||||
* (`schema/draft/examples/<TypeName>/*.json`). The corpus harness
|
||||
* (`packages/core-internal/test/corpus/specCorpus.test.ts`) parses every vendored
|
||||
* example through the SDK's wire schemas, so accept-side drift between the
|
||||
* SDK and the specification turns CI red.
|
||||
*
|
||||
* Files are vendored verbatim, plus a `manifest.json` recording provenance
|
||||
* (source commit) and the directory/file inventory so corpus drift is loud.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm fetch:spec-examples --spec-dir <path-to-spec-checkout>
|
||||
* pnpm fetch:spec-examples [sha] # fetch from GitHub (default: latest main)
|
||||
*
|
||||
* With `--spec-dir`, examples are read from a local checkout of
|
||||
* modelcontextprotocol/modelcontextprotocol (provenance is the checkout's
|
||||
* HEAD commit). Without it, sources are fetched from GitHub at the given
|
||||
* commit, mirroring scripts/fetch-spec-types.ts.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const PROJECT_ROOT = join(dirname(__filename), '..');
|
||||
|
||||
const SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol';
|
||||
/** The upcoming protocol revision; its examples live in the spec repo's draft directory. */
|
||||
const DRAFT_REVISION = '2026-07-28';
|
||||
const EXAMPLES_PATH = 'schema/draft/examples';
|
||||
const OUTPUT_DIR = join(PROJECT_ROOT, 'packages', 'core-internal', 'test', 'corpus', 'fixtures', DRAFT_REVISION);
|
||||
|
||||
interface ExampleFile {
|
||||
/** `<TypeName>/<file>.json` relative to the examples root. */
|
||||
relPath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
async function fetchLatestSHA(): Promise<string> {
|
||||
const url = `https://api.github.com/repos/${SPEC_REPO}/commits?path=${EXAMPLES_PATH}&per_page=1`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Failed to fetch commit info: ${response.status} ${response.statusText}`);
|
||||
const commits = (await response.json()) as Array<{ sha: string }>;
|
||||
if (!commits?.length) throw new Error('No commits found for the examples path');
|
||||
return commits[0].sha;
|
||||
}
|
||||
|
||||
async function listExamplesFromGitHub(sha: string): Promise<string[]> {
|
||||
const url = `https://api.github.com/repos/${SPEC_REPO}/git/trees/${sha}?recursive=1`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Failed to fetch repo tree: ${response.status} ${response.statusText}`);
|
||||
const tree = (await response.json()) as { truncated?: boolean; tree: Array<{ path: string; type: string }> };
|
||||
if (tree.truncated) throw new Error('GitHub tree listing truncated; cannot enumerate examples reliably');
|
||||
return tree.tree
|
||||
.filter(entry => entry.type === 'blob' && entry.path.startsWith(`${EXAMPLES_PATH}/`) && entry.path.endsWith('.json'))
|
||||
.map(entry => entry.path.slice(EXAMPLES_PATH.length + 1));
|
||||
}
|
||||
|
||||
async function fetchExamplesFromGitHub(sha: string): Promise<ExampleFile[]> {
|
||||
const relPaths = await listExamplesFromGitHub(sha);
|
||||
const files: ExampleFile[] = [];
|
||||
for (const relPath of relPaths) {
|
||||
const url = `https://raw.githubusercontent.com/${SPEC_REPO}/${sha}/${EXAMPLES_PATH}/${relPath}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Failed to fetch ${relPath}: ${response.status} ${response.statusText}`);
|
||||
files.push({ relPath, content: await response.text() });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function readExamplesFromDir(specDir: string): { files: ExampleFile[]; sha: string } {
|
||||
const root = join(specDir, ...EXAMPLES_PATH.split('/'));
|
||||
const files: ExampleFile[] = [];
|
||||
for (const typeDir of readdirSync(root).sort()) {
|
||||
const dirPath = join(root, typeDir);
|
||||
if (!statSync(dirPath).isDirectory()) continue;
|
||||
for (const file of readdirSync(dirPath).sort()) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
files.push({ relPath: `${typeDir}/${file}`, content: readFileSync(join(dirPath, file), 'utf8') });
|
||||
}
|
||||
}
|
||||
const sha = execFileSync('git', ['-C', specDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
||||
return { files, sha };
|
||||
}
|
||||
|
||||
function writeCorpus(files: ExampleFile[], sha: string): void {
|
||||
if (files.length === 0) throw new Error('No example files found — refusing to write an empty corpus');
|
||||
|
||||
rmSync(OUTPUT_DIR, { recursive: true, force: true });
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
const dirs: Record<string, string[]> = {};
|
||||
for (const file of files.sort((a, b) => a.relPath.localeCompare(b.relPath))) {
|
||||
// The path components come from outside this repo (a spec checkout or the
|
||||
// GitHub trees API); reject anything that could escape the output directory.
|
||||
const parts = file.relPath.split('/');
|
||||
if (parts.length !== 2 || parts.some(p => !p || p === '.' || p === '..' || p.includes('\\'))) {
|
||||
throw new Error(`Unsafe or unexpected example path: ${file.relPath}`);
|
||||
}
|
||||
const [typeDir, fileName] = parts as [string, string];
|
||||
const destFile = resolve(OUTPUT_DIR, typeDir, fileName);
|
||||
if (!destFile.startsWith(resolve(OUTPUT_DIR) + sep)) {
|
||||
throw new Error(`Example path escapes the output directory: ${file.relPath}`);
|
||||
}
|
||||
mkdirSync(join(OUTPUT_DIR, typeDir), { recursive: true });
|
||||
// Validate now so a malformed upstream example fails the vendoring, not the harness.
|
||||
JSON.parse(file.content);
|
||||
writeFileSync(destFile, file.content);
|
||||
(dirs[typeDir] ??= []).push(fileName);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
revision: DRAFT_REVISION,
|
||||
source: { repo: SPEC_REPO, path: EXAMPLES_PATH, commit: sha },
|
||||
regenerate: 'pnpm fetch:spec-examples --spec-dir <spec-checkout> # or [sha] to fetch from GitHub',
|
||||
directoryCount: Object.keys(dirs).length,
|
||||
fileCount: files.length,
|
||||
directories: dirs
|
||||
};
|
||||
writeFileSync(join(OUTPUT_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 4)}\n`);
|
||||
|
||||
console.log(`Vendored ${files.length} example files across ${Object.keys(dirs).length} directories (source ${sha.slice(0, 8)})`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const specDirIndex = args.indexOf('--spec-dir');
|
||||
|
||||
if (specDirIndex !== -1) {
|
||||
const specDir = args[specDirIndex + 1];
|
||||
if (!specDir) throw new Error('--spec-dir requires a path argument');
|
||||
const { files, sha } = readExamplesFromDir(specDir);
|
||||
writeCorpus(files, sha);
|
||||
return;
|
||||
}
|
||||
|
||||
const sha = args[0] ?? (await fetchLatestSHA());
|
||||
const files = await fetchExamplesFromGitHub(sha);
|
||||
writeCorpus(files, sha);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -27,6 +27,23 @@ const UPSTREAM_SCHEMA_DIRS: Record<SpecVersion, string> = {
|
|||
'2026-07-28': 'draft'
|
||||
};
|
||||
|
||||
/**
|
||||
* Generation pin per released revision. Released revisions are frozen: without
|
||||
* an explicit SHA argument, their types are regenerated from the pinned spec
|
||||
* commit below — never from the latest upstream commit — so a released anchor
|
||||
* can only change through a deliberate, reviewed repin. Moving a pin (or
|
||||
* freezing a newly released revision) must land in the same commit that
|
||||
* retargets `.github/workflows/update-spec-types.yml`.
|
||||
*
|
||||
* Draft-tracking revisions have no entry and float to the latest upstream
|
||||
* commit via the nightly workflow's refresh PRs.
|
||||
*
|
||||
* See `packages/core-internal/src/types/README.md` for the full lifecycle policy.
|
||||
*/
|
||||
const RELEASED_REVISION_PINS: Partial<Record<SpecVersion, string>> = {
|
||||
'2025-11-25': '0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65'
|
||||
};
|
||||
|
||||
interface GitHubCommit {
|
||||
sha: string;
|
||||
}
|
||||
|
|
@ -59,10 +76,14 @@ async function fetchSpecTypes(version: SpecVersion, sha: string): Promise<string
|
|||
}
|
||||
|
||||
async function updateSpecTypes(version: SpecVersion, providedSHA?: string): Promise<void> {
|
||||
const pinnedSHA = RELEASED_REVISION_PINS[version];
|
||||
let sha: string;
|
||||
if (providedSHA) {
|
||||
console.log(`[${version}] Using provided SHA: ${providedSHA}`);
|
||||
sha = providedSHA;
|
||||
} else if (pinnedSHA) {
|
||||
console.log(`[${version}] Using pinned SHA for released revision: ${pinnedSHA}`);
|
||||
sha = pinnedSHA;
|
||||
} else {
|
||||
console.log(`[${version}] Fetching latest commit SHA...`);
|
||||
sha = await fetchLatestSHA(version);
|
||||
|
|
|
|||
Loading…
Reference in a new issue