Skip to content
Merged
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
45 changes: 44 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
"scripts": {
"tsforge": "bun --cwd packages/core ./src/cli.ts",
"typecheck": "tsc -p packages/core/tsconfig.json",
"typecheck": "bun packages/core/scripts/tsc7.ts -p packages/core/tsconfig.json",
"lint": "eslint packages",
"lint:fix": "eslint packages --fix",
"format": "prettier --write \"packages/**/*.ts\"",
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"jsdom": "^29.1.1",
"prettier": "3.9.4",
"turndown": "^7.2.4",
"@typescript/native": "npm:[email protected]",
"typescript": "6.0.3",
"typescript-eslint": "8.62.1"
},
Expand Down
12 changes: 12 additions & 0 deletions packages/core/scripts/tsc7.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env bun
// Run the project typecheck on TypeScript 7's native compiler, resolved the SAME
// robust way the gate does (resolveTs7Tsc walks up for both the monorepo and
// published/hoisted layouts) — NOT a hardcoded node_modules path, which breaks
// when the package manager hoists @typescript/native somewhere else (e.g. CI).
import { spawnSync } from "node:child_process";
import { resolveTs7Tsc } from "../src/gate/tool-paths";

const bin = resolveTs7Tsc();
const res = spawnSync(bin, process.argv.slice(2), { stdio: "inherit" });

process.exit(res.status ?? 1);
53 changes: 52 additions & 1 deletion packages/core/src/gate/tool-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,63 @@ function resolveToolBin(name: string): string {
return name;
}

// TypeScript 7 (the Go-native compiler, ~10x faster typecheck) ships as the
// `@typescript/native` package so it can coexist with the `typescript` package,
// whose 6.x programmatic API our tooling (typescript-eslint, proptest) still
// needs — TS7 has no stable programmatic API until 7.1. It is a real dependency
// of this package, so consumers get it too (the gate typechecks on TS7
// everywhere, not just in-repo). Both packages expose a `tsc` bin, so `.bin/tsc`
// is ambiguous; resolve TS7's compiler by its package path instead. The plain
// `tsc` fallback is only a defensive safety net (should never fire given the
// dependency) that keeps the gate functional on TS6 rather than crashing.
export function resolveTs7Tsc(startDir: string = import.meta.dir): string {
let dir = startDir;
let parent = dirname(dir);

while (parent !== dir) {
// Hoisted (monorepo): <dir>/node_modules/@typescript/native/bin/tsc.
const hoisted = join(
dir,
"node_modules",
"@typescript",
"native",
"bin",
"tsc"
);

if (existsSync(hoisted)) {
return hoisted;
}

// Published/global install where `dir` is itself a `node_modules`: the
// package sits directly inside it (same double-`node_modules` case
// resolveToolBin handles). Without this, a consumer install misses TS7.
const direct = join(dir, "@typescript", "native", "bin", "tsc");

if (existsSync(direct)) {
return direct;
}

dir = parent;
parent = dirname(dir);
}

// @typescript/native is a dependency, so a miss is unexpected — say so LOUDLY
// (not a silent downgrade) before falling back to an ambient tsc so the gate
// degrades instead of crashing.
process.stderr.write(
"tsforge: @typescript/native (TypeScript 7) not found — the gate is falling back to an ambient `tsc`, which may be TS6 or another version.\n"
);

return resolveToolBin("tsc");
}

// This module lives at `src/gate/`, so the package root (where the bundled eslint
// configs + `scripts/` live) is TWO levels up — `import.meta.dir/../..`.
const PKG_ROOT = join(import.meta.dir, "..", "..");

export const ESLINT_BIN = resolveToolBin("eslint");
export const TSC_BIN = resolveToolBin("tsc");
export const TSC_BIN = resolveTs7Tsc();
export const PRETTIER_BIN = resolveToolBin("prettier");
export const STRICT_CONFIG = join(PKG_ROOT, "strict.eslint.config.mjs");
export const TYPE_AWARE_CONFIG = join(
Expand Down
6 changes: 3 additions & 3 deletions packages/core/tests/gate-incremental.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import {
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildGate } from "../src/gate";

const ROOT = join(import.meta.dir, "..", "..", "..");
const TSC_BIN = join(ROOT, "node_modules", ".bin", "tsc");
// Use the gate's OWN resolved compiler (TS7 via resolveTs7Tsc), not a hardcoded
// .bin/tsc — so this incremental proof runs the exact binary the gate embeds.
import { TSC_BIN } from "../src/gate/tool-paths";

const TSCONFIG = JSON.stringify({
compilerOptions: {
Expand Down
96 changes: 96 additions & 0 deletions packages/core/tests/gate-tool-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { test, expect, describe } from "bun:test";
import {
existsSync,
mkdirSync,
writeFileSync,
mkdtempSync,
rmSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TSC_BIN, resolveTs7Tsc } from "../src/gate/tool-paths";

describe("TSC_BIN (resolveTs7Tsc)", () => {
test("resolves the TypeScript 7 native compiler (@typescript/native)", () => {
// Deterministic: the gate references TS7 by package path, not the ambiguous
// .bin/tsc (both `typescript` and `@typescript/native` expose a `tsc` bin).
expect(TSC_BIN).toContain("@typescript/native");
expect(existsSync(TSC_BIN)).toBe(true);
});

test("the resolved binary is actually TypeScript 7", () => {
// Prefix with `bun` so this works cross-platform (extensionless bin files
// aren't directly executable on Windows).
const out = spawnSync("bun", [TSC_BIN, "--version"], { encoding: "utf8" });

expect(out.status).toBe(0);
expect(out.stdout).toContain("Version 7.");
});
});

describe("resolveTs7Tsc layout handling", () => {
function withLayout(rel: string): { dir: string; cleanup: () => void } {
const root = mkdtempSync(join(tmpdir(), "ts7-layout-"));
const binPath = join(root, rel);

mkdirSync(join(binPath, ".."), { recursive: true });
writeFileSync(binPath, "#!/bin/sh\n");

return {
dir: root,
cleanup: () => rmSync(root, { recursive: true, force: true }),
};
}

test("finds the hoisted (monorepo) layout: <dir>/node_modules/@typescript/native/bin/tsc", () => {
const { dir, cleanup } = withLayout(
"node_modules/@typescript/native/bin/tsc"
);

try {
// start from a nested dir so the walk-up has to climb
const start = join(dir, "packages", "core", "src", "gate");

mkdirSync(start, { recursive: true });

expect(resolveTs7Tsc(start)).toBe(
join(dir, "node_modules/@typescript/native/bin/tsc")
);
} finally {
cleanup();
}
});

test("finds the published layout where the start dir is itself node_modules", () => {
const { dir, cleanup } = withLayout(
"node_modules/@typescript/native/bin/tsc"
);

try {
// walk begins inside node_modules → the package is a DIRECT child
const start = join(dir, "node_modules", "@agjs", "tsforge", "dist");

mkdirSync(start, { recursive: true });

expect(resolveTs7Tsc(start)).toBe(
join(dir, "node_modules/@typescript/native/bin/tsc")
);
} finally {
cleanup();
}
});

test("falls back (and warns) when @typescript/native is absent", () => {
const empty = mkdtempSync(join(tmpdir(), "ts7-none-"));

try {
// No @typescript/native anywhere under `empty` → fallback path (not a crash)
const resolved = resolveTs7Tsc(empty);

expect(resolved).not.toContain("@typescript/native");
} finally {
rmSync(empty, { recursive: true, force: true });
}
});
});
Loading