Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Check generated sources
run: npm run generate:check

- name: Check formatting
run: npm run format:check

Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default [
"*.config.js",
".github/",
"src/schema.ts",
"src/docs/",
"src/.schema-*/",
],
},
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,15 @@
"clean": "rm -rf dist tsconfig.tsbuildinfo",
"test": "vitest run",
"generate": "node scripts/generate.js",
"generate:check": "node scripts/generate.js --skip-download --check",
"build": "tsc",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint",
"lint:fix": "eslint --fix",
"spellcheck": "./scripts/spellcheck.sh",
"spellcheck:fix": "./scripts/spellcheck.sh --write-changes",
"check": "npm run lint && npm run format:check && npm run spellcheck && npm run build && npm run test && npm run docs:ts:verify",
"check": "npm run generate:check && npm run lint && npm run format:check && npm run spellcheck && npm run build && npm run test && npm run docs:ts:verify",
"docs:ts:build": "cd src && typedoc --options typedoc.json && typedoc --options typedoc.v2.json && echo 'TypeScript documentation generated in ./src/docs'",
"docs:ts:verify": "cd src && typedoc --options typedoc.json --emit none && typedoc --options typedoc.v2.json --emit none && echo 'TypeDoc verification passed'"
},
Expand Down
218 changes: 209 additions & 9 deletions scripts/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as prettier from "prettier";

const CURRENT_V1_SCHEMA_RELEASE = "schema-v1.20.0";
const CURRENT_V2_SCHEMA_RELEASE = "schema-v2.0.0-alpha.2";
const CHECK_GENERATED = process.argv.includes("--check");

// ── Extensible-union pipeline ────────────────────────────────────────────────
// Several schemas model forward compatibility as an "extensible union": known
Expand Down Expand Up @@ -87,6 +88,7 @@ const SCHEMA_CONFIGS = [
stagingDir: "./src/.schema-v2-staging",
previousDir: "./src/.schema-v2-previous",
schemaDeserializeImport: "../../schema-deserialize.js",
absolutePathImport: "../absolute-path.js",
expectedExtensibleUnions: V2_EXTENSIBLE_UNIONS,
openApiVersion: "2.0.0",
releaseTag: CURRENT_V2_SCHEMA_RELEASE,
Expand All @@ -109,17 +111,23 @@ async function main() {
}

for (const config of SCHEMA_CONFIGS) {
await generateSchema(config);
await generateSchema(config, CHECK_GENERATED);
}
}

async function generateSchema(config) {
async function generateSchema(config, checkGenerated) {
const metadata = JSON.parse(await fs.readFile(config.metadataPath, "utf8"));

const schemaSrc = await fs.readFile(config.schemaPath, "utf8");
const jsonSchema = JSON.parse(
schemaSrc.replaceAll("#/$defs/", "#/components/schemas/"),
);
assertMetadataMatchesSchema(
metadata,
jsonSchema.$defs,
config.name,
Number.parseInt(config.openApiVersion, 10),
);
addExperimentalTags(jsonSchema);
stripAnyOfDiscriminators(jsonSchema);
const defExclusions = annotateExtensibleUnions(jsonSchema.$defs);
Expand Down Expand Up @@ -176,6 +184,13 @@ async function generateSchema(config) {
// behavior that isn't in the schema descriptions: custom variants bypass
// the lenient-field salvage known variants get.
let zodDocs = updateDocs(zodSrc, schemaDefs);
if (config.absolutePathImport) {
zodDocs = addAbsolutePathValidation(
zodDocs,
config.absolutePathImport,
config.name,
);
}
for (const [name, exclusion] of defExclusions) {
zodDocs = appendDocNote(
zodDocs,
Expand Down Expand Up @@ -205,15 +220,17 @@ async function generateSchema(config) {

const tsPath = `${stagingDir}/types.gen.ts`;
const tsSrc = await fs.readFile(tsPath, "utf8");
const ts = await formatStable(
updateDocs(
tsSrc.replace(
`export type ClientOptions`,
`// eslint-disable-next-line @typescript-eslint/no-unused-vars\ntype ClientOptions`,
),
schemaDefs,
let tsDocs = updateDocs(
tsSrc.replace(
`export type ClientOptions`,
`// eslint-disable-next-line @typescript-eslint/no-unused-vars\ntype ClientOptions`,
),
schemaDefs,
);
if (config.absolutePathImport) {
tsDocs = addAbsolutePathBrand(tsDocs, config.name);
}
const ts = await formatStable(tsDocs);
await fs.writeFile(tsPath, ts);

// Always write the file: the staging swap replaces the whole directory, so
Expand Down Expand Up @@ -241,6 +258,19 @@ export const PROTOCOL_VERSION = ${metadata.version};
await formatStable(`${indexSrc.replace(/\s*ClientOptions,/, "")}\n${meta}`),
);

if (checkGenerated) {
const drift = await compareGeneratedDirectories(schemaDir, stagingDir);
await fs.rm(stagingDir, { recursive: true, force: true });
if (drift.length > 0) {
throw new Error(
`[${config.name}] Generated sources are stale:\n` +
`${drift.map((change) => ` ${change}`).join("\n")}\n` +
"Run `npm run generate -- --skip-download` and commit the result.",
);
}
return;
}

// Rename-aside swap: a valid src/schema exists at every instant, so an
// interruption strands at worst an ignored dot-directory, never a missing
// schema. (ENOENT: a fresh checkout may have no schema dir to set aside.)
Expand All @@ -252,6 +282,136 @@ export const PROTOCOL_VERSION = ${metadata.version};
await fs.rm(previousDir, { recursive: true, force: true });
}

function assertMetadataMatchesSchema(
metadata,
schemaDefs,
lane,
expectedVersion,
) {
if (metadata.version !== expectedVersion) {
throw new Error(
`[${lane}] Expected metadata protocol version ${expectedVersion}, ` +
`found ${JSON.stringify(metadata.version)}`,
);
}

const schemaMethods = {
agent: new Set(),
client: new Set(),
protocol: new Set(),
};
for (const [name, schema] of Object.entries(schemaDefs)) {
const method = schema["x-method"];
const side = schema["x-side"];
if (method === undefined) continue;
if (typeof method !== "string") {
throw new Error(
`[${lane}] ${name} has a non-string x-method: ${JSON.stringify(method)}`,
);
}

if (side === "both") {
schemaMethods.agent.add(method);
schemaMethods.client.add(method);
} else if (side in schemaMethods) {
schemaMethods[side].add(method);
} else {
throw new Error(
`[${lane}] ${name} has x-method ${JSON.stringify(method)} but an ` +
`unsupported x-side: ${JSON.stringify(side)}`,
);
}
}

const metadataMethods = {
agent: metadataMethodSet(metadata.agentMethods, lane, "agentMethods"),
client: metadataMethodSet(metadata.clientMethods, lane, "clientMethods"),
protocol: metadataMethodSet(
metadata.protocolMethods,
lane,
"protocolMethods",
),
};

for (const side of Object.keys(schemaMethods)) {
const missing = [...schemaMethods[side]]
.filter((method) => !metadataMethods[side].has(method))
.sort();
const extra = [...metadataMethods[side]]
.filter((method) => !schemaMethods[side].has(method))
.sort();
if (missing.length > 0 || extra.length > 0) {
throw new Error(
`[${lane}] ${side} method metadata does not match schema x-methods.` +
`${missing.length > 0 ? `\n missing: ${missing.join(", ")}` : ""}` +
`${extra.length > 0 ? `\n extra: ${extra.join(", ")}` : ""}`,
);
}
}
}

function metadataMethodSet(value, lane, field) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`[${lane}] Metadata ${field} must be an object`);
}
const methods = Object.values(value);
if (!methods.every((method) => typeof method === "string")) {
throw new Error(`[${lane}] Metadata ${field} values must all be strings`);
}
if (new Set(methods).size !== methods.length) {
throw new Error(`[${lane}] Metadata ${field} contains duplicate methods`);
}
return new Set(methods);
}

async function compareGeneratedDirectories(expectedDir, generatedDir) {
const expected = await readDirectoryFiles(expectedDir);
const generated = await readDirectoryFiles(generatedDir);
const paths = [...new Set([...expected.keys(), ...generated.keys()])].sort();
const drift = [];

for (const path of paths) {
if (!expected.has(path)) {
drift.push(`added: ${path}`);
} else if (!generated.has(path)) {
drift.push(`removed: ${path}`);
} else if (!expected.get(path).equals(generated.get(path))) {
drift.push(`changed: ${path}`);
}
}

return drift;
}

async function readDirectoryFiles(root, relative = "") {
const files = new Map();
const entries = await fs.readdir(`${root}/${relative}`, {
withFileTypes: true,
});

for (const entry of entries.sort((left, right) =>
left.name.localeCompare(right.name),
)) {
const path = relative ? `${relative}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
for (const [childPath, contents] of await readDirectoryFiles(
root,
path,
)) {
files.set(childPath, contents);
}
} else if (entry.isFile()) {
files.set(path, await fs.readFile(`${root}/${path}`));
} else {
throw new Error(
`Unsupported generated filesystem entry: ${root}/${path}`,
);
}
}

return files;
}

// Formats until prettier reaches a fixed point. Prettier's member-chain
// heuristic keeps chains that arrive pre-broken, so formatting hey-api's raw
// output once can produce a string that `prettier --check` would still
Expand All @@ -269,6 +429,46 @@ async function formatStable(source) {
);
}

function addAbsolutePathValidation(source, importPath, lane) {
const zodImport = source.match(/import \* as z from ["']zod\/v4["'];/)?.[0];
const absolutePathSchema = "export const zAbsolutePath = z.string();";
if (!zodImport || !source.includes(absolutePathSchema)) {
throw new Error(
`[${lane}] Could not attach absolute-path validation to generated Zod schemas`,
);
}

return source
.replace(
zodImport,
`import { isAbsolutePath } from ${JSON.stringify(importPath)};\n` +
`import type { AbsolutePath } from "./types.gen.js";\n` +
`${zodImport}`,
)
.replace(
absolutePathSchema,
`export const zAbsolutePath = z.string().refine(isAbsolutePath, {\n` +
` message: "Expected an absolute filesystem path",\n` +
`}).transform((value) => value as AbsolutePath);`,
);
}

function addAbsolutePathBrand(source, lane) {
const absolutePathType = "export type AbsolutePath = string;";
if (!source.includes(absolutePathType)) {
throw new Error(
`[${lane}] Could not brand the generated AbsolutePath type`,
);
}

return source.replace(
absolutePathType,
`export type AbsolutePath = string & {\n` +
` readonly __brand: "AbsolutePath";\n` +
`};`,
);
}

/**
* Downloads a file from a URL to a local path
* @param {string} url - The URL to download from
Expand Down
2 changes: 1 addition & 1 deletion src/examples/dual-version-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type V2Turn = {
};

type V2Session = {
cwd: string;
cwd: v2.AbsolutePath;
active: boolean;
history: v2.SessionUpdate[];
turn?: V2Turn;
Expand Down
47 changes: 47 additions & 0 deletions src/v2/absolute-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";

import { absolutePath, isAbsolutePath } from "./absolute-path.js";
import { zAbsolutePath } from "./schema/zod.gen.js";
import type { AbsolutePath } from "./schema/types.gen.js";

const typedAbsolutePath: AbsolutePath = absolutePath("/workspace");
const parsedAbsolutePath: AbsolutePath = zAbsolutePath.parse("/workspace");
// @ts-expect-error Raw strings must be validated before use as protocol paths.
const unvalidatedAbsolutePath: AbsolutePath = "/workspace";
void typedAbsolutePath;
void parsedAbsolutePath;
void unvalidatedAbsolutePath;

describe("absolute protocol paths", () => {
it.each([
"/",
"/tmp/project",
"C:\\",
"C:\\Users\\agent\\project",
"D:/projects/acp",
"\\\\server\\share",
"\\\\server/share/directory",
"//server/share/directory",
"\\\\?\\C:\\long\\path",
])("accepts %s", (value) => {
expect(isAbsolutePath(value)).toBe(true);
expect(absolutePath(value)).toBe(value);
expect(zAbsolutePath.parse(value)).toBe(value);
});

it.each([
"",
".",
"./project",
"../project",
"tmp/project",
"C:relative",
"\\rooted-without-drive",
"~/project",
"/tmp/\0project",
])("rejects %s", (value) => {
expect(isAbsolutePath(value)).toBe(false);
expect(() => absolutePath(value)).toThrow(TypeError);
expect(zAbsolutePath.safeParse(value).success).toBe(false);
});
});
Loading