feat: introduced omptype validation package and migrated workspace dependencies

- Introduce `@oh-my-pi/omptype` as a new ArkType-compatible schema validation package featuring a lazy JIT runtime, JSON Schema emission, and compatibility adapters.
- Replace `arktype` across workspace packages and test utilities with `@oh-my-pi/omptype`.
- Add benchmark suites, tests, and documentation for the new validation engine and adapters.
- Update workspace build, test runner, and release configurations to include the new package.
This commit is contained in:
can1357
2026-08-03 20:09:23 +02:00
parent b48aeacf25
commit bc39ffa265
239 changed files with 8799 additions and 1669 deletions
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "bun:test";
import { packages, rewriteManifest } from "./ci-release-publish";
describe("published manifest topology", () => {
it("repoints omptype runtime entries to dist/js with a bun source condition", async () => {
const pkg = packages.find(entry => entry.dir === "packages/omptype");
if (!pkg) throw new Error("omptype missing from publish set");
expect(pkg.publishJs).toBe(true);
const manifest = await rewriteManifest(pkg, false);
expect(manifest.main).toBe("./dist/js/index.js");
expect(manifest.types).toBe("./dist/types/index.d.ts");
expect(manifest.files).toContain("dist/js");
expect(manifest.files).toContain("dist/types");
// `src` must stay packed — the `bun` condition resolves into it.
expect(manifest.files).toContain("src");
expect(manifest.exports).toEqual({
".": {
types: "./dist/types/index.d.ts",
bun: "./src/index.ts",
default: "./dist/js/index.js",
},
"./*": {
types: "./dist/types/*.d.ts",
bun: "./src/*.ts",
default: "./dist/js/*.js",
},
"./*.js": {
types: "./dist/types/*.d.ts",
bun: "./src/*.ts",
default: "./dist/js/*.js",
},
});
});
it("keeps source-runtime packages on src with only types repointed", async () => {
const pkg = packages.find(entry => entry.dir === "packages/utils");
if (!pkg) throw new Error("utils missing from publish set");
const manifest = await rewriteManifest(pkg, false);
expect(manifest.main).toBe("./src/index.ts");
expect(manifest.exports).toEqual({
".": {
types: "./dist/types/index.d.ts",
import: "./src/index.ts",
},
"./*": {
types: "./dist/types/*.d.ts",
import: "./src/*.ts",
},
"./*.js": "./src/*.ts",
});
});
});
+58 -16
View File
@@ -14,9 +14,14 @@
* that points at `./src/*.ts(x)` is repointed to `./dist/types/*.d.ts`,
* `dist/types` (plus `dist/client` for `stats`) is added to `files`,
* and packages with a `publishBin` override get their `bin` swapped to
* the prepack bundle (coding-agent: `src/cli.ts` → `dist/cli.js`). The
* on-repo manifest keeps pointing at source so local dev and source
* installs (`bun link`, `install.sh --source`) work without a build.
* the prepack bundle (coding-agent: `src/cli.ts` → `dist/cli.js`).
* Packages flagged `publishJs` (omptype) additionally emit transpiled
* per-module JS into `dist/js/` and get their runtime entries (`main`,
* `exports[*]` import paths) repointed there, with a `bun` condition
* keeping TS-source resolution for Bun consumers — so the published
* package runs on plain Node. The on-repo manifest keeps pointing at
* source so local dev and source installs (`bun link`,
* `install.sh --source`) work without a build.
* 3. Pack with `bun pm pack` (resolves the `catalog:`/`workspace:`
* protocols npm cannot, and runs each package's `prepack` lifecycle),
* then publish the resolved tarball with `npm publish` — see
@@ -35,7 +40,7 @@ import {
generateNpmPackages,
LEAF_TARGETS,
} from "../packages/natives/scripts/gen-npm-packages.ts";
import { fixDtsExtensions } from "./fix-dts-extensions.ts";
import { fixEmitExtensions } from "./fix-emit-extensions.ts";
export interface PublishPackage {
dir: string;
@@ -46,6 +51,12 @@ export interface PublishPackage {
extraFiles?: readonly string[];
/** Extra tsgo invocations beyond `tsconfig.publish.json`. */
extraTypeConfigs?: readonly string[];
/**
* Also emit transpiled JS to `dist/js` (via `tsconfig.publish.js.json`)
* and repoint the published runtime entries there so the package runs on
* plain Node. Requires the package to be dependency-free of Bun APIs.
*/
publishJs?: boolean;
/**
* `bin` map for the published manifest. The on-repo manifest points `bin`
* at TS source so source installs (`bun link`, `install.sh --source`) work
@@ -87,6 +98,7 @@ const nativeLeafTag = nativeLeafTagFromArgs(process.argv.slice(2));
export const packages: PublishPackage[] = [
{ dir: "packages/utils", kind: "typescript" },
{ dir: "packages/wire", kind: "typescript" },
{ dir: "packages/omptype", kind: "typescript", publishJs: true },
{ dir: "packages/catalog", kind: "typescript" },
{ dir: "packages/ai", kind: "typescript" },
{ dir: "packages/natives", kind: "native" },
@@ -105,18 +117,30 @@ export const packages: PublishPackage[] = [
{ dir: "packages/coding-agent", kind: "typescript", publishBin: { omp: "dist/cli.js" } },
];
function rewriteSrcPath(value: string): string {
function rewriteSrcToTypes(value: string): string {
if (!value.startsWith("./src/")) return value;
const rel = value.slice("./src/".length).replace(/\.tsx?$/, "");
return `./dist/types/${rel}.d.ts`;
}
function rewriteExports(exports: JsonValue): JsonValue {
function rewriteSrcToJs(value: string): string {
if (!value.startsWith("./src/")) return value;
const rel = value.slice("./src/".length).replace(/\.tsx?$/, "");
return `./dist/js/${rel}.js`;
}
function rewriteExports(exports: JsonValue, publishJs: boolean): JsonValue {
if (exports === null || typeof exports !== "object" || Array.isArray(exports)) return exports;
const src = exports as JsonObject;
const out: JsonObject = {};
for (const key in src) {
const val = src[key];
if (publishJs && typeof val === "string" && val.startsWith("./src/")) {
// String-form subpath (e.g. `"./*.js": "./src/*.ts"`): declarations
// for TS, TS source for Bun, transpiled JS for everything else.
out[key] = { types: rewriteSrcToTypes(val), bun: val, default: rewriteSrcToJs(val) };
continue;
}
if (
val !== null &&
typeof val === "object" &&
@@ -124,9 +148,16 @@ function rewriteExports(exports: JsonValue): JsonValue {
typeof (val as JsonObject).types === "string" &&
((val as JsonObject).types as string).startsWith("./src/")
) {
const next: JsonObject = { ...(val as JsonObject) };
next.types = rewriteSrcPath(next.types as string);
out[key] = next;
const srcTypes = (val as JsonObject).types as string;
if (publishJs) {
// Condition order matters: `types` is TS-only, `bun` must win
// over `default` for Bun consumers.
out[key] = { types: rewriteSrcToTypes(srcTypes), bun: srcTypes, default: rewriteSrcToJs(srcTypes) };
} else {
const next: JsonObject = { ...(val as JsonObject) };
next.types = rewriteSrcToTypes(srcTypes);
out[key] = next;
}
} else {
out[key] = val;
}
@@ -134,17 +165,22 @@ function rewriteExports(exports: JsonValue): JsonValue {
return out;
}
async function rewriteManifest(pkg: PublishPackage, write: boolean): Promise<PackageManifest> {
/** Compute (and optionally write) the published manifest for a package. */
export async function rewriteManifest(pkg: PublishPackage, write: boolean): Promise<PackageManifest> {
const manifestPath = path.join(repoRoot, pkg.dir, "package.json");
const manifest = (await Bun.file(manifestPath).json()) as PackageManifest;
if (pkg.publishBin) manifest.bin = { ...pkg.publishBin };
if (typeof manifest.types === "string" && manifest.types.startsWith("./src/")) {
manifest.types = rewriteSrcPath(manifest.types);
manifest.types = rewriteSrcToTypes(manifest.types);
}
if (manifest.exports !== undefined) manifest.exports = rewriteExports(manifest.exports);
if (pkg.publishJs && typeof manifest.main === "string") {
manifest.main = rewriteSrcToJs(manifest.main);
}
if (manifest.exports !== undefined) manifest.exports = rewriteExports(manifest.exports, pkg.publishJs === true);
const files = Array.isArray(manifest.files) ? [...manifest.files] : [];
const hasDist = files.includes("dist");
if (!hasDist && !files.includes("dist/types")) files.push("dist/types");
if (pkg.publishJs && !hasDist && !files.includes("dist/js")) files.push("dist/js");
for (const extra of pkg.extraFiles ?? []) {
if (!hasDist && !files.includes(extra)) files.push(extra);
}
@@ -162,10 +198,16 @@ async function preparePackage(pkg: PublishPackage): Promise<PackageManifest> {
for (const cfg of pkg.extraTypeConfigs ?? []) {
await $`bun x tsgo -p ${cfg}`.cwd(pkgDir);
}
// The declaration emit runs under `moduleResolution: "Bundler"`, so relative
// specifiers land extensionless — unresolvable for a `nodenext` consumer.
// Rewrite them to explicit `.js` so the published types resolve everywhere.
await fixDtsExtensions(path.join(pkgDir, "dist/types"));
if (pkg.publishJs) {
await $`bun x tsgo -p tsconfig.publish.js.json`.cwd(pkgDir);
}
// Both emits run under `moduleResolution: "Bundler"`, so relative
// specifiers land extensionless — unresolvable for a `nodenext` consumer
// (types) and for Node ESM at runtime (js). Rewrite them to explicit `.js`.
await fixEmitExtensions(path.join(pkgDir, "dist/types"), ".d.ts");
if (pkg.publishJs) {
await fixEmitExtensions(path.join(pkgDir, "dist/js"), ".js");
}
return rewriteManifest(pkg, !isDryRun);
}
+1
View File
@@ -88,6 +88,7 @@ const codingAgentBucketPlans: Record<CodingAgentBucket, { label: string; paralle
const fastWorkspacePackages = [
"packages/hashline",
"packages/wire",
"packages/omptype",
"packages/utils",
"packages/catalog",
"packages/ai",
+32 -24
View File
@@ -1,24 +1,26 @@
#!/usr/bin/env bun
/**
* Rewrite extensionless relative specifiers in emitted `.d.ts` to explicit
* `.js` extensions so the published declarations resolve under
* Rewrite extensionless relative specifiers in emitted `.d.ts`/`.js` files to
* explicit `.js` extensions so published output resolves under Node ESM and
* `moduleResolution: "node16" | "nodenext"`.
*
* The workspace type-checks under `moduleResolution: "Bundler"`
* (`tsconfig.base.json`), which permits extensionless relative imports. `tsgo`
* therefore emits `export * from "./sdk"` / `import … from "./modes/components"`
* into `dist/types`. A downstream consumer on `nodenext` (the modern default)
* then can't resolve the barrel — every relative re-export is a `TS2834`, which
* cascades into `TS2305` "no exported member" on the whole package root.
* into `dist/types` (declarations) and `dist/js` (transpiled runtime). A
* downstream consumer on `nodenext` then can't resolve the barrel — every
* relative re-export is a `TS2834`, which cascades into `TS2305` "no exported
* member" on the whole package root — and Node refuses the extensionless
* import outright at runtime.
*
* This post-emit pass appends the correct extension to each **relative**
* specifier, resolving it against the emitted tree:
* `./sdk` → `./sdk.js` (sibling `sdk.d.ts`)
* `./sdk` → `./sdk.js` (sibling `sdk.d.ts` / `sdk.js`)
* `./modes/components` → `./modes/components/index.js` (directory barrel)
* Bare (`@scope/pkg`, `zod/v4`) and already-suffixed (`.js`, `.json`)
* specifiers are left untouched.
*
* Applied by `ci-release-publish.ts` after `tsgo -p tsconfig.publish.json`.
* Applied by `ci-release-publish.ts` after each `tsgo -p tsconfig.publish*.json`.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
@@ -28,19 +30,24 @@ import * as path from "node:path";
// Captures the quote-enclosed module string; we filter to relative ones below.
const SPECIFIER_RE = /(\b(?:from|import|module)\b\s*(?:\(\s*)?)("|')(\.[^"']*)(\2)/g;
/** Emitted-file kind: declaration or transpiled-runtime output. */
export type EmitExt = ".d.ts" | ".js";
/** Resolve an extensionless relative specifier to its `.js` runtime form,
* given the directory of the importing `.d.ts`. Returns null to leave as-is. */
export async function resolveDtsSpecifier(fromDir: string, spec: string): Promise<string | null> {
* given the directory of the importing emitted file and the extension the
* emitted tree uses (`.d.ts` for declarations, `.js` for runtime output).
* Returns null to leave as-is. */
export async function resolveEmitSpecifier(fromDir: string, spec: string, ext: EmitExt): Promise<string | null> {
// Already has a JS/JSON extension, or a declaration extension we map to .js.
if (/\.(js|json|mjs|cjs)$/.test(spec)) return null;
if (/\.d\.ts$/.test(spec)) return `${spec.slice(0, -".d.ts".length)}.js`;
const abs = path.join(fromDir, spec);
// Sibling declaration file: `./sdk` → `./sdk.js`.
if (await exists(`${abs}.d.ts`)) return `${spec}.js`;
// Sibling emitted file: `./sdk` → `./sdk.js`.
if (await exists(`${abs}${ext}`)) return `${spec}.js`;
// Directory barrel: `./modes/components` → `./modes/components/index.js`.
if (await exists(path.join(abs, "index.d.ts"))) return `${spec.replace(/\/$/, "")}/index.js`;
// Unresolved (e.g. an asset or a specifier with no emitted `.d.ts`): leave it.
if (await exists(path.join(abs, `index${ext}`))) return `${spec.replace(/\/$/, "")}/index.js`;
// Unresolved (e.g. an asset or a specifier with no emitted file): leave it.
return null;
}
@@ -53,8 +60,8 @@ async function exists(p: string): Promise<boolean> {
}
}
/** Rewrite one `.d.ts` file in place. Returns the number of specifiers changed. */
export async function fixDtsFile(filePath: string): Promise<number> {
/** Rewrite one emitted file in place. Returns the number of specifiers changed. */
export async function fixEmitFile(filePath: string, ext: EmitExt): Promise<number> {
const source = await Bun.file(filePath).text();
const fromDir = path.dirname(filePath);
let changed = 0;
@@ -63,7 +70,7 @@ export async function fixDtsFile(filePath: string): Promise<number> {
const edits: Array<{ match: string; replacement: string }> = [];
for (const m of source.matchAll(SPECIFIER_RE)) {
const [full, prefix, quote, spec] = m;
const resolved = await resolveDtsSpecifier(fromDir, spec);
const resolved = await resolveEmitSpecifier(fromDir, spec, ext);
if (resolved && resolved !== spec) {
edits.push({ match: full, replacement: `${prefix}${quote}${resolved}${quote}` });
}
@@ -79,15 +86,15 @@ export async function fixDtsFile(filePath: string): Promise<number> {
return changed;
}
/** Walk `dir` recursively and fix every `.d.ts`. Returns totals. */
export async function fixDtsExtensions(dir: string): Promise<{ files: number; specifiers: number }> {
/** Walk `dir` recursively and fix every emitted file with extension `ext`. Returns totals. */
export async function fixEmitExtensions(dir: string, ext: EmitExt): Promise<{ files: number; specifiers: number }> {
let files = 0;
let specifiers = 0;
const entries = await fs.readdir(dir, { withFileTypes: true, recursive: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".d.ts")) continue;
if (!entry.isFile() || !entry.name.endsWith(ext)) continue;
const filePath = path.join(entry.parentPath, entry.name);
const n = await fixDtsFile(filePath);
const n = await fixEmitFile(filePath, ext);
if (n > 0) {
files++;
specifiers += n;
@@ -98,10 +105,11 @@ export async function fixDtsExtensions(dir: string): Promise<{ files: number; sp
if (import.meta.main) {
const target = process.argv[2];
if (!target) {
console.error("usage: fix-dts-extensions.ts <dist/types dir>");
const ext = (process.argv[3] ?? ".d.ts") as EmitExt;
if (!target || (ext !== ".d.ts" && ext !== ".js")) {
console.error("usage: fix-emit-extensions.ts <emitted dir> [.d.ts|.js]");
process.exit(1);
}
const { files, specifiers } = await fixDtsExtensions(target);
console.log(`fix-dts-extensions: rewrote ${specifiers} specifiers across ${files} files in ${target}`);
const { files, specifiers } = await fixEmitExtensions(target, ext);
console.log(`fix-emit-extensions: rewrote ${specifiers} specifiers across ${files} files in ${target}`);
}
+17 -2
View File
@@ -95,7 +95,7 @@ cp "$natives_pkg_backup" "$ROOT_DIR/packages/natives/package.json"
# 3. Pack the remaining workspace packages (natives core and coding-agent
# handled separately). `collab-web` is private but still packed here so its
# prepack build and tarball file list stay release-safe.
for pkg in utils wire hashline catalog ai mnemopi snapcompact agent tui stats collab-web; do
for pkg in utils wire omptype hashline catalog ai mnemopi snapcompact agent tui stats collab-web; do
(
cd "$ROOT_DIR/packages/$pkg"
bun pm pack --destination "$TARBALL_DIR" --quiet >/dev/null
@@ -120,6 +120,7 @@ cp "$agent_pkg_backup" "$ROOT_DIR/packages/coding-agent/package.json"
utils_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-utils-*.tgz)"
wire_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-wire-*.tgz)"
omptype_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-omptype-*.tgz)"
natives_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-natives-[0-9]*.tgz)"
natives_leaf_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-natives-"$host_tag"-*.tgz)"
hashline_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-hashline-*.tgz)"
@@ -146,6 +147,7 @@ mkdir -p "$TARBALL_APP_DIR"
pkg.overrides = {
'@oh-my-pi/pi-utils': '$utils_tgz',
'@oh-my-pi/pi-wire': '$wire_tgz',
'@oh-my-pi/omptype': '$omptype_tgz',
'@oh-my-pi/pi-natives': '$natives_tgz',
'@oh-my-pi/pi-natives-$host_tag': '$natives_leaf_tgz',
'@oh-my-pi/hashline': '$hashline_tgz',
@@ -162,7 +164,7 @@ mkdir -p "$TARBALL_APP_DIR"
require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2));
"
bun add "$utils_tgz" "$wire_tgz" "$natives_tgz" "$hashline_tgz" "$catalog_tgz" "$ai_tgz" "$mnemopi_tgz" "$snapcompact_tgz" "$agent_tgz" "$tui_tgz" "$stats_tgz" "$coding_agent_tgz" "$collab_web_tgz"
bun add "$utils_tgz" "$wire_tgz" "$omptype_tgz" "$natives_tgz" "$hashline_tgz" "$catalog_tgz" "$ai_tgz" "$mnemopi_tgz" "$snapcompact_tgz" "$agent_tgz" "$tui_tgz" "$stats_tgz" "$coding_agent_tgz" "$collab_web_tgz"
# The platform leaf must arrive through the core's optionalDependencies +
# override, not as a direct dependency — assert it landed before smoking so a
# resolution regression is distinguishable from a runtime loader bug.
@@ -176,6 +178,19 @@ mkdir -p "$TARBALL_APP_DIR"
echo "Unexpected @oh-my-pi/pi-wire COLLAB_PROTO: $wire_proto"
exit 1
}
omptype_probe="$(bun -e '
import { type } from "@oh-my-pi/omptype";
import { Type } from "@oh-my-pi/omptype/typebox";
import { z } from "@oh-my-pi/omptype/zod";
const root = type({ name: "string", enabled: "boolean = false" }).assert({ name: "omp" });
const typebox = Type.Object({ name: Type.String() }).assert({ name: "tb" });
const zod = z.object({ name: z.string() }).parse({ name: "z" });
process.stdout.write(`${root.name}:${root.enabled}:${typebox.name}:${zod.name}`);
')"
[ "$omptype_probe" = "omp:false:tb:z" ] || {
echo "Unexpected @oh-my-pi/omptype probe result: $omptype_probe"
exit 1
}
[ -f "node_modules/@oh-my-pi/collab-web/dist/index.html" ] || {
echo "Collab web tarball did not install built dist/index.html"
exit 1