From 352902ebfe9adde7ba159676e030ad8b39253609 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:32:03 -0700 Subject: [PATCH 1/5] build(typescript): port the build and coverage scripts off .mjs First batch of #9527: control-plane-coverage, actionlint-download-attempts, and both packages' check-syntax + strip-bin-sourcemap become real TypeScript, with their package.json runners, importers, and tests updated. check-syntax scanned scripts/*.mjs, so porting those files would have silently removed them from the only syntax check that covered them. node --check accepts a .ts file under --experimental-strip-types (verified it still rejects broken syntax), so both scripts now scan .ts too and the file counts are unchanged. Refs #9527 --- package.json | 2 +- packages/loopover-mcp/package.json | 8 +- .../{check-syntax.mjs => check-syntax.ts} | 15 ++- ...n-sourcemap.mjs => strip-bin-sourcemap.ts} | 0 packages/loopover-miner/package.json | 2 +- .../{check-syntax.mjs => check-syntax.ts} | 16 ++- scripts/actionlint.ts | 2 +- ...coverage.mjs => control-plane-coverage.ts} | 4 +- scripts/engine-coverage.ts | 2 +- ...ts.mjs => actionlint-download-attempts.ts} | 5 +- scripts/validate-no-hand-written-js.ts | 106 ++++++++++++++++++ .../unit/actionlint-download-attempts.test.ts | 2 +- test/unit/codecov-policy.test.ts | 4 +- test/unit/miner-package-skeleton.test.ts | 4 +- test/unit/support/mcp-cli-harness.ts | 2 +- test/unit/support/miner-cli-harness.ts | 2 +- 16 files changed, 144 insertions(+), 32 deletions(-) rename packages/loopover-mcp/scripts/{check-syntax.mjs => check-syntax.ts} (64%) rename packages/loopover-mcp/scripts/{strip-bin-sourcemap.mjs => strip-bin-sourcemap.ts} (100%) rename packages/loopover-miner/scripts/{check-syntax.mjs => check-syntax.ts} (59%) rename scripts/{control-plane-coverage.mjs => control-plane-coverage.ts} (93%) rename scripts/lib/{actionlint-download-attempts.mjs => actionlint-download-attempts.ts} (82%) create mode 100644 scripts/validate-no-hand-written-js.ts diff --git a/package.json b/package.json index b57ddb4563..255e56f252 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "rees:coverage": "node --experimental-strip-types scripts/rees-coverage.ts", "control-plane:install": "npm ci --prefix control-plane --prefer-offline --no-audit --no-fund", "control-plane:test": "npm run control-plane:install && npm --prefix control-plane test", - "control-plane:coverage": "node scripts/control-plane-coverage.mjs", + "control-plane:coverage": "node --experimental-strip-types scripts/control-plane-coverage.ts", "engine:coverage": "node --experimental-strip-types scripts/engine-coverage.ts", "db:migrations:check": "tsx scripts/check-migrations.ts", "db:schema-drift:check": "tsx scripts/check-schema-drift.ts", diff --git a/packages/loopover-mcp/package.json b/packages/loopover-mcp/package.json index b1ecdc8339..7739dfa7a0 100644 --- a/packages/loopover-mcp/package.json +++ b/packages/loopover-mcp/package.json @@ -36,13 +36,13 @@ "dist", "scripts", "CHANGELOG.md", - "!scripts/check-syntax.mjs", - "!scripts/strip-bin-sourcemap.mjs" + "!scripts/check-syntax.ts", + "!scripts/strip-bin-sourcemap.ts" ], "scripts": { "build": "npm run build:tsc && npm run build:verify", - "build:tsc": "tsc -p tsconfig.json && node scripts/strip-bin-sourcemap.mjs", - "build:verify": "node scripts/check-syntax.mjs" + "build:tsc": "tsc -p tsconfig.json && node --experimental-strip-types scripts/strip-bin-sourcemap.ts", + "build:verify": "node --experimental-strip-types scripts/check-syntax.ts" }, "dependencies": { "@loopover/contract": "^0.1.0", diff --git a/packages/loopover-mcp/scripts/check-syntax.mjs b/packages/loopover-mcp/scripts/check-syntax.ts similarity index 64% rename from packages/loopover-mcp/scripts/check-syntax.mjs rename to packages/loopover-mcp/scripts/check-syntax.ts index 863b943518..50da24a145 100644 --- a/packages/loopover-mcp/scripts/check-syntax.mjs +++ b/packages/loopover-mcp/scripts/check-syntax.ts @@ -13,20 +13,25 @@ import { execFileSync } from "node:child_process"; // drive prefix (D:\D:\...), which breaks readdirSync. Same pattern as packages/loopover-miner/bin. const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); -function listFiles(dir, extension) { +function listFiles(dir: string, extension: string): string[] { return readdirSync(join(ROOT, dir), { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) .map((entry) => join(dir, entry.name)); } -const files = [...listFiles("dist/bin", ".js"), ...listFiles("dist/lib", ".js"), ...listFiles("scripts", ".mjs")].sort(); +// scripts/ holds both .ts (this file, strip-bin-sourcemap) and .mjs (gittensor-score-preview, which +// ships in the tarball and runs under end users' plain node). `node --check` accepts a .ts file when +// --experimental-strip-types is on, so both extensions stay syntax-verified after the #9527 port -- +// without it, porting a script here would have silently removed it from this check's reach. +const files = [...listFiles("dist/bin", ".js"), ...listFiles("dist/lib", ".js"), ...listFiles("scripts", ".mjs"), ...listFiles("scripts", ".ts")].sort(); -const failures = []; +const failures: Array<{ file: string; message: string }> = []; for (const file of files) { try { - execFileSync(process.execPath, ["--check", file], { cwd: ROOT, stdio: "pipe" }); + execFileSync(process.execPath, ["--experimental-strip-types", "--check", file], { cwd: ROOT, stdio: "pipe" }); } catch (error) { - failures.push({ file, message: error.stderr?.toString().trim() || String(error) }); + const stderr = (error as { stderr?: Buffer | string }).stderr; + failures.push({ file, message: stderr?.toString().trim() || String(error) }); } } diff --git a/packages/loopover-mcp/scripts/strip-bin-sourcemap.mjs b/packages/loopover-mcp/scripts/strip-bin-sourcemap.ts similarity index 100% rename from packages/loopover-mcp/scripts/strip-bin-sourcemap.mjs rename to packages/loopover-mcp/scripts/strip-bin-sourcemap.ts diff --git a/packages/loopover-miner/package.json b/packages/loopover-miner/package.json index efc7b2209f..83c7d8295f 100644 --- a/packages/loopover-miner/package.json +++ b/packages/loopover-miner/package.json @@ -44,7 +44,7 @@ "cross-repo-eval": "node scripts/cross-repo-evaluation.mjs", "build": "npm run build:tsc && npm run build:verify", "build:tsc": "tsc -p tsconfig.json", - "build:verify": "node scripts/check-syntax.mjs" + "build:verify": "node --experimental-strip-types scripts/check-syntax.ts" }, "dependencies": { "@loopover/engine": "^3.15.2", diff --git a/packages/loopover-miner/scripts/check-syntax.mjs b/packages/loopover-miner/scripts/check-syntax.ts similarity index 59% rename from packages/loopover-miner/scripts/check-syntax.mjs rename to packages/loopover-miner/scripts/check-syntax.ts index 94ff752cc2..08625eef71 100644 --- a/packages/loopover-miner/scripts/check-syntax.mjs +++ b/packages/loopover-miner/scripts/check-syntax.ts @@ -11,20 +11,24 @@ import { execFileSync } from "node:child_process"; const ROOT = new URL("..", import.meta.url).pathname; -function listJsFiles(dir) { +function listFiles(dir: string, extension: string): string[] { return readdirSync(join(ROOT, dir), { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith(".js")) + .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) .map((entry) => join(dir, entry.name)); } -const files = [...listJsFiles("dist/bin"), ...listJsFiles("dist/lib")].sort(); +// scripts/*.ts is included so this package's own build scripts stay syntax-verified after the #9527 +// port -- `node --check` accepts .ts under --experimental-strip-types. Without it, porting a script +// out of .mjs would silently remove it from every check in the repo. +const files = [...listFiles("dist/bin", ".js"), ...listFiles("dist/lib", ".js"), ...listFiles("scripts", ".ts")].sort(); -const failures = []; +const failures: Array<{ file: string; message: string }> = []; for (const file of files) { try { - execFileSync(process.execPath, ["--check", file], { cwd: ROOT, stdio: "pipe" }); + execFileSync(process.execPath, ["--experimental-strip-types", "--check", file], { cwd: ROOT, stdio: "pipe" }); } catch (error) { - failures.push({ file, message: error.stderr?.toString().trim() || String(error) }); + const stderr = (error as { stderr?: Buffer | string }).stderr; + failures.push({ file, message: stderr?.toString().trim() || String(error) }); } } diff --git a/scripts/actionlint.ts b/scripts/actionlint.ts index 9044e4b47a..124ae067b0 100644 --- a/scripts/actionlint.ts +++ b/scripts/actionlint.ts @@ -2,7 +2,7 @@ import { readFileSync, readdirSync } from "node:fs"; import { createRequire } from "node:module"; import { join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; -import { resolveActionlintDownloadAttempts } from "./lib/actionlint-download-attempts.mjs"; +import { resolveActionlintDownloadAttempts } from "./lib/actionlint-download-attempts.ts"; import type { ActionlintOptions, ActionlintResult } from "github-actionlint"; import type { Result } from "@tktco/node-actionlint/build/types.js"; diff --git a/scripts/control-plane-coverage.mjs b/scripts/control-plane-coverage.ts similarity index 93% rename from scripts/control-plane-coverage.mjs rename to scripts/control-plane-coverage.ts index 000d6e8397..5848481ea1 100644 --- a/scripts/control-plane-coverage.mjs +++ b/scripts/control-plane-coverage.ts @@ -11,7 +11,7 @@ const c8Bin = join(root, "control-plane", "node_modules", "c8", "bin", "c8.js"); const reportDir = join(root, "control-plane", "coverage"); const testRoot = join(root, "control-plane", "test"); -function collectTests(dir, out = []) { +function collectTests(dir: string, out: string[] = []): string[] { for (const ent of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, ent.name); if (ent.isDirectory()) collectTests(path, out); @@ -56,7 +56,7 @@ try { const raw = readFileSync(lcovPath, "utf8"); writeFileSync( lcovPath, - raw.replace(/^SF:(.*)$/gm, (_match, path) => `SF:${String(path).replace(/\\/g, "/")}`), + raw.replace(/^SF:(.*)$/gm, (_match: string, path: string) => `SF:${path.replace(/\\/g, "/")}`), ); } catch { // CI's "Verify control-plane coverage report exists" step fails closed if the report is missing. diff --git a/scripts/engine-coverage.ts b/scripts/engine-coverage.ts index 7e73d7c667..8742800044 100644 --- a/scripts/engine-coverage.ts +++ b/scripts/engine-coverage.ts @@ -9,7 +9,7 @@ // // Runs c8 from the monorepo root so source-map remapping (packages/loopover-engine/tsconfig.json's // "sourceMap": true) yields `packages/loopover-engine/src/**` paths, not bare `dist/**` -- mirroring -// rees-coverage.ts / control-plane-coverage.mjs's identical "node:test suite invisible to vitest" +// rees-coverage.ts / control-plane-coverage.ts's identical "node:test suite invisible to vitest" // shape and their `--include=/dist/**/*.js` + `--all` + lcov-path-normalize recipe. import { spawnSync } from "node:child_process"; import { readdirSync, readFileSync, writeFileSync } from "node:fs"; diff --git a/scripts/lib/actionlint-download-attempts.mjs b/scripts/lib/actionlint-download-attempts.ts similarity index 82% rename from scripts/lib/actionlint-download-attempts.mjs rename to scripts/lib/actionlint-download-attempts.ts index 666270351c..6363921bcf 100644 --- a/scripts/lib/actionlint-download-attempts.mjs +++ b/scripts/lib/actionlint-download-attempts.ts @@ -5,11 +5,8 @@ * in JS, so `Number.parseInt("0", 10) || 4` wrongly yields 4, silently ignoring an operator's explicit `"0"`. * A missing/blank/non-integer value defaults to 4; any parsed integer is honored and floored to a minimum of * 1. Mirrors the validation shape in migrate-selfhost-sqlite-to-postgres.ts (`Number.isFinite`, not `||`). - * - * @param {string | undefined} raw - * @returns {number} attempt count, always >= 1 */ -export function resolveActionlintDownloadAttempts(raw) { +export function resolveActionlintDownloadAttempts(raw: string | undefined): number { const parsed = Number.parseInt(raw ?? "4", 10); return Math.max(1, Number.isFinite(parsed) ? parsed : 4); } diff --git a/scripts/validate-no-hand-written-js.ts b/scripts/validate-no-hand-written-js.ts new file mode 100644 index 0000000000..7360ca4680 --- /dev/null +++ b/scripts/validate-no-hand-written-js.ts @@ -0,0 +1,106 @@ +// The TypeScript lock (#9527). +// +// Hand-written .mjs/.js/.cjs files are untyped surfaces the compiler cannot protect, and they +// accumulate the same way hand-copied schemas do -- 37 of them existed when this landed, including +// load-bearing generators that run inside test:ci. After the migration, this gate is what makes the +// state permanent: any tracked JavaScript (or hand-written .d.ts) outside the small, reasoned +// allowlist below fails CI. +// +// Two properties keep the allowlist itself from rotting: +// 1. Every entry carries a reason, in this file, next to the path. +// 2. An entry whose path no longer exists FAILS the check. A watched path that silently stops +// existing is how metagraphed's MCP version-sync workflow rotted for months -- the guard is the +// lesson. +// +// Run as `npm run validate:no-hand-written-js` (wired into test:ci). `git ls-files` is the source +// of truth, so untracked local files never fail and gitignored build output is never scanned. +import { execFileSync } from "node:child_process"; + +type AllowlistEntry = { + /** Exact tracked path, or a directory prefix ending in "/". */ + path: string; + reason: string; +}; + +export const ALLOWLIST: AllowlistEntry[] = [ + { + path: "test/fixtures/", + reason: + "Subprocess test doubles (scorer/store/allocator children) spawned as real child processes by plain `node` to reproduce exact runtime behavior, several deliberately malformed. Porting them buys no type safety and couples every spawn site to a TS loader.", + }, + { + path: "packages/loopover-mcp/scripts/gittensor-score-preview.mjs", + reason: + "Shipped in the published npm tarball (see scripts/mcp-package-allowlist.ts) and executed by end users' own plain `node`, which cannot be assumed to run TypeScript.", + }, + { + path: "apps/loopover-ui/public/sw.js", + reason: "Service worker served byte-for-byte to browsers from public/; there is no build step between this file and the client.", + }, + { + path: "scripts/rees-coverage-chdir.cjs", + reason: + "Preloaded via node --require, which only loads CommonJS. Its 3 lines chdir the spawned test child so c8's lcov paths remap for Codecov (#6250).", + }, + { + path: "src/env.d.ts", + reason: "Ambient declarations for the Worker env -- ambient typing is what .d.ts is for; there is no runtime module to port.", + }, + { + path: "control-plane/src/env.d.ts", + reason: "Ambient declarations, same as src/env.d.ts.", + }, + { + path: "packages/discovery-index/src/env.d.ts", + reason: "Ambient declarations, same as src/env.d.ts.", + }, + { + path: "src/selfhost/stubs/gifenc.d.ts", + reason: "Hand-written module declaration for the untyped gifenc dependency -- an ambient stub, not a shadowed implementation.", + }, +]; + +/** Generated .d.ts is exempt by name, not allowlisted: `cf-typegen:check` already proves these + * match their generator, which is a stronger guarantee than a reason string. */ +const GENERATED_BASENAMES = new Set(["worker-configuration.d.ts"]); + +export function isAllowed(path: string): boolean { + return ALLOWLIST.some((entry) => (entry.path.endsWith("/") ? path.startsWith(entry.path) : path === entry.path)); +} + +export function isGenerated(path: string): boolean { + const basename = path.slice(path.lastIndexOf("/") + 1); + return GENERATED_BASENAMES.has(basename); +} + +export function classify(trackedFiles: readonly string[]): { violations: string[]; staleAllowlist: string[] } { + const targets = trackedFiles.filter( + (path) => (path.endsWith(".mjs") || path.endsWith(".cjs") || path.endsWith(".js") || path.endsWith(".d.ts")) && !isGenerated(path), + ); + const violations = targets.filter((path) => !isAllowed(path)); + const tracked = new Set(trackedFiles); + const staleAllowlist = ALLOWLIST.filter((entry) => + entry.path.endsWith("/") ? ![...tracked].some((path) => path.startsWith(entry.path)) : !tracked.has(entry.path), + ).map((entry) => entry.path); + return { violations, staleAllowlist }; +} + +function main(): void { + const trackedFiles = execFileSync("git", ["ls-files"], { encoding: "utf8" }).split("\n").filter(Boolean); + const { violations, staleAllowlist } = classify(trackedFiles); + + if (violations.length > 0) { + console.error("Hand-written JavaScript (or hand-written .d.ts) is not allowed in this repo (#9527)."); + console.error("Port these to .ts, or -- only with a real reason -- add an entry to ALLOWLIST in scripts/validate-no-hand-written-js.ts:"); + for (const path of violations) console.error(` ${path}`); + } + if (staleAllowlist.length > 0) { + console.error("These ALLOWLIST entries match nothing tracked -- delete them (a silently-dead watched path is how checks rot):"); + for (const path of staleAllowlist) console.error(` ${path}`); + } + if (violations.length > 0 || staleAllowlist.length > 0) process.exit(1); + console.log(`no-hand-written-js: clean (${ALLOWLIST.length} allowlisted entries, all present).`); +} + +// Import-safe for tests; only executes as a CLI entry. +if (process.argv[1]?.endsWith("validate-no-hand-written-js.ts")) main(); diff --git a/test/unit/actionlint-download-attempts.test.ts b/test/unit/actionlint-download-attempts.test.ts index c5372ad7de..bc31d3ea44 100644 --- a/test/unit/actionlint-download-attempts.test.ts +++ b/test/unit/actionlint-download-attempts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveActionlintDownloadAttempts } from "../../scripts/lib/actionlint-download-attempts.mjs"; +import { resolveActionlintDownloadAttempts } from "../../scripts/lib/actionlint-download-attempts.ts"; // #7773: an explicit ACTIONLINT_DOWNLOAD_ATTEMPTS="0" must be respected (floored to 1), not silently turned // into the default of 4 by a `parseInt(...) || 4` fallback (0 is falsy). diff --git a/test/unit/codecov-policy.test.ts b/test/unit/codecov-policy.test.ts index 9188d81f68..220dc06242 100644 --- a/test/unit/codecov-policy.test.ts +++ b/test/unit/codecov-policy.test.ts @@ -253,8 +253,8 @@ describe("Codecov policy", () => { it("captures control-plane node:test coverage for Codecov (#7743)", () => { const rootPkg = JSON.parse(readFileSync("package.json", "utf8")) as { scripts: Record }; - expect(rootPkg.scripts["control-plane:coverage"]).toBe("node scripts/control-plane-coverage.mjs"); - const coverageScript = readFileSync("scripts/control-plane-coverage.mjs", "utf8"); + expect(rootPkg.scripts["control-plane:coverage"]).toBe("node --experimental-strip-types scripts/control-plane-coverage.ts"); + const coverageScript = readFileSync("scripts/control-plane-coverage.ts", "utf8"); expect(coverageScript).toContain("c8"); expect(coverageScript).toContain("control-plane"); expect(coverageScript).toContain("coverage"); diff --git a/test/unit/miner-package-skeleton.test.ts b/test/unit/miner-package-skeleton.test.ts index 07f37a77a1..39c7da7de8 100644 --- a/test/unit/miner-package-skeleton.test.ts +++ b/test/unit/miner-package-skeleton.test.ts @@ -49,7 +49,7 @@ describe("loopover-miner package skeleton (#2287)", () => { // hand-listed ~119-file chain here that had to be kept in sync by hand). expect(miner.scripts.build).toBe("npm run build:tsc && npm run build:verify"); expect(miner.scripts["build:tsc"]).toBe("tsc -p tsconfig.json"); - expect(miner.scripts["build:verify"]).toBe("node scripts/check-syntax.mjs"); + expect(miner.scripts["build:verify"]).toBe("node --experimental-strip-types scripts/check-syntax.ts"); }); it( @@ -64,7 +64,7 @@ describe("loopover-miner package skeleton (#2287)", () => { // load. 60000ms matches the same evidence-based ceiling already used for this repo's other // real-subprocess timeout flakes (agent-sdk-driver.test.ts, miner-attempt-worktree.test.ts, // miner-repo-clone.test.ts, #6869/#6871). - const result = spawnSync("node", ["scripts/check-syntax.mjs"], { cwd: minerRoot, encoding: "utf8" }); + const result = spawnSync("node", ["--experimental-strip-types", "scripts/check-syntax.ts"], { cwd: minerRoot, encoding: "utf8" }); expect(result.status).toBe(0); expect(result.stdout).toContain("node --check passed for all"); expect(result.stdout).toMatch(/passed for all \d+ files in dist\/bin\/ and dist\/lib\//); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index c38f340d43..58a9ed0a62 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -15,7 +15,7 @@ import { expect } from "vitest"; // compiled dist/bin/loopover-mcp.js as plain JavaScript -- requires `npm run build:mcp` to have run first // (same precondition ci.yml's "Build MCP" step + this repo's local `npm run test:ci` already satisfy), but // is otherwise identical: same subprocess, same argv, same env plumbing. process.execPath (not a bare -// "node") mirrors scripts/check-syntax.mjs's own convention -- guarantees the exact Node binary already +// "node") mirrors scripts/check-syntax.ts's own convention -- guarantees the exact Node binary already // running the test, not whatever "node" resolves to on PATH. export const bin = join(process.cwd(), "packages/loopover-mcp/dist/bin/loopover-mcp.js"); export const repoOnboardingPackFixture = { diff --git a/test/unit/support/miner-cli-harness.ts b/test/unit/support/miner-cli-harness.ts index 05de79082f..0a78bbca89 100644 --- a/test/unit/support/miner-cli-harness.ts +++ b/test/unit/support/miner-cli-harness.ts @@ -29,7 +29,7 @@ export type CliProcessResult = { // compiled dist/bin/loopover-miner.js as plain JavaScript -- requires `npm run build:miner` to have run // first (same precondition ci.yml's "Build miner CLI" step + this repo's local `npm run test:ci` already // satisfy), but is otherwise identical: same subprocess, same argv, same env plumbing. process.execPath -// (not a bare "node") mirrors scripts/check-syntax.mjs's own convention -- guarantees the exact Node +// (not a bare "node") mirrors scripts/check-syntax.ts's own convention -- guarantees the exact Node // binary already running the test, not whatever "node" resolves to on PATH. export const bin = join( process.cwd(), From d53ef7f20a76e463975bb66c88ab0a27cb57ce7b Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:33:39 -0700 Subject: [PATCH 2/5] build(typescript): port the eslint configs, dev scripts, and miner env-reference generator Second batch of #9527. eslint.config.js -> .ts in both UI apps (eslint 9 resolves TS configs through the hoisted jiti; lint still reports 0 errors), the three benchmark/load-test scripts, and generate-env-reference, which runs inside test:ci via miner:env-reference:check. The generator embeds its own filename in the header of both artifacts it emits, so the rename changes generated output -- regenerated and re-checked, 48 env var references unchanged in each. Refs #9527 --- .github/workflows/ci.yml | 2 +- .../{eslint.config.js => eslint.config.ts} | 0 .../loopover-ui/{eslint.config.js => eslint.config.ts} | 0 apps/loopover-ui/src/lib/ams-env-reference.ts | 2 +- package.json | 10 +++++----- ...test-iterate-loop.mjs => load-test-iterate-loop.ts} | 0 packages/loopover-miner/lib/discovery-index-client.ts | 2 +- packages/loopover-miner/lib/tenant-client.ts | 2 +- packages/loopover-miner/package.json | 4 ++-- .../scripts/{benchmark.mjs => benchmark.ts} | 0 ...ss-repo-evaluation.mjs => cross-repo-evaluation.ts} | 0 ...ate-env-reference.mjs => generate-env-reference.ts} | 2 +- test/unit/miner-env-reference-script.test.ts | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) rename apps/loopover-miner-ui/{eslint.config.js => eslint.config.ts} (100%) rename apps/loopover-ui/{eslint.config.js => eslint.config.ts} (100%) rename packages/loopover-engine/scripts/{load-test-iterate-loop.mjs => load-test-iterate-loop.ts} (100%) rename packages/loopover-miner/scripts/{benchmark.mjs => benchmark.ts} (100%) rename packages/loopover-miner/scripts/{cross-repo-evaluation.mjs => cross-repo-evaluation.ts} (100%) rename packages/loopover-miner/scripts/{generate-env-reference.mjs => generate-env-reference.ts} (98%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecac98ad0b..92022f8e88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -330,7 +330,7 @@ jobs: # exact gap let a stale env-reference block an unrelated MCP npm release, since the release # workflow was the only place this check ever ran. Gated on `ui` too, same reason as its sibling: # the generated UI-side file lives under apps/loopover-ui/**. Also gated on `engine`: - # DEFAULT_SOURCE_ROOTS in generate-env-reference.mjs explicitly includes + # DEFAULT_SOURCE_ROOTS in generate-env-reference.ts explicitly includes # packages/loopover-engine/src/miner (the coding-agent driver's env vars live there, not under # packages/loopover-miner itself), so an engine-only PR touching that directory used to skip this # check entirely. diff --git a/apps/loopover-miner-ui/eslint.config.js b/apps/loopover-miner-ui/eslint.config.ts similarity index 100% rename from apps/loopover-miner-ui/eslint.config.js rename to apps/loopover-miner-ui/eslint.config.ts diff --git a/apps/loopover-ui/eslint.config.js b/apps/loopover-ui/eslint.config.ts similarity index 100% rename from apps/loopover-ui/eslint.config.js rename to apps/loopover-ui/eslint.config.ts diff --git a/apps/loopover-ui/src/lib/ams-env-reference.ts b/apps/loopover-ui/src/lib/ams-env-reference.ts index 3e1ffaf34f..e0608fedfa 100644 --- a/apps/loopover-ui/src/lib/ams-env-reference.ts +++ b/apps/loopover-ui/src/lib/ams-env-reference.ts @@ -1,4 +1,4 @@ -// Generated by scripts/generate-env-reference.mjs (npm run miner:env-reference). Do not edit manually. +// Generated by scripts/generate-env-reference.ts (npm run miner:env-reference). Do not edit manually. export type MinerEnvReferenceRow = { name: string; firstReference: string; diff --git a/package.json b/package.json index 255e56f252..0f7035f83c 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,11 @@ "selfhost:postgres:migrate": "tsx scripts/migrate-selfhost-sqlite-to-postgres.ts", "selfhost:env-reference": "node --experimental-strip-types scripts/gen-selfhost-env-reference.ts", "selfhost:env-reference:check": "node --experimental-strip-types scripts/gen-selfhost-env-reference.ts --check", - "miner:env-reference": "tsx packages/loopover-miner/scripts/generate-env-reference.mjs", - "miner:env-reference:check": "tsx packages/loopover-miner/scripts/generate-env-reference.mjs --check", - "benchmark:miner": "node packages/loopover-miner/scripts/benchmark.mjs", - "cross-repo-eval:miner": "node packages/loopover-miner/scripts/cross-repo-evaluation.mjs", - "loadtest:iterate-loop": "npm run build --workspace @loopover/engine && node packages/loopover-engine/scripts/load-test-iterate-loop.mjs", + "miner:env-reference": "tsx packages/loopover-miner/scripts/generate-env-reference.ts", + "miner:env-reference:check": "tsx packages/loopover-miner/scripts/generate-env-reference.ts --check", + "benchmark:miner": "node --experimental-strip-types packages/loopover-miner/scripts/benchmark.ts", + "cross-repo-eval:miner": "node --experimental-strip-types packages/loopover-miner/scripts/cross-repo-evaluation.ts", + "loadtest:iterate-loop": "npm run build --workspace @loopover/engine && node --experimental-strip-types packages/loopover-engine/scripts/load-test-iterate-loop.ts", "loadtest:worker": "node --experimental-strip-types scripts/load-test-worker.ts", "command-reference": "node --experimental-strip-types scripts/gen-command-reference.ts", "command-reference:check": "node --experimental-strip-types scripts/gen-command-reference.ts --check", diff --git a/packages/loopover-engine/scripts/load-test-iterate-loop.mjs b/packages/loopover-engine/scripts/load-test-iterate-loop.ts similarity index 100% rename from packages/loopover-engine/scripts/load-test-iterate-loop.mjs rename to packages/loopover-engine/scripts/load-test-iterate-loop.ts diff --git a/packages/loopover-miner/lib/discovery-index-client.ts b/packages/loopover-miner/lib/discovery-index-client.ts index bd22bfc065..8c0821d3ee 100644 --- a/packages/loopover-miner/lib/discovery-index-client.ts +++ b/packages/loopover-miner/lib/discovery-index-client.ts @@ -44,7 +44,7 @@ function isTruthyEnvValue(value: string): boolean { // Reads below use literal `env.LOOPOVER_MINER_*` property access (not the exported *_FLAG constants above, // which exist for callers/tests to reference the exact name without a typo) because -// scripts/generate-env-reference.mjs statically greps for exactly this `env.NAME ?? "default"` shape to keep +// scripts/generate-env-reference.ts statically greps for exactly this `env.NAME ?? "default"` shape to keep // packages/loopover-miner/docs/env-reference.md honest -- a dynamic `env[SOME_CONST]` lookup is invisible to it. /** Master opt-in (default off). When false, no discovery-index traffic and no telemetry may be emitted. */ diff --git a/packages/loopover-miner/lib/tenant-client.ts b/packages/loopover-miner/lib/tenant-client.ts index 971394f11d..3cbb01e0e7 100644 --- a/packages/loopover-miner/lib/tenant-client.ts +++ b/packages/loopover-miner/lib/tenant-client.ts @@ -37,7 +37,7 @@ function isTruthyEnvValue(value: string): boolean { } // Reads below use literal `env.LOOPOVER_MINER_*` property access (not the *_FLAG constants) because -// scripts/generate-env-reference.mjs statically greps for exactly this `env.NAME ?? "..."` shape to keep the +// scripts/generate-env-reference.ts statically greps for exactly this `env.NAME ?? "..."` shape to keep the // generated env reference honest -- a dynamic `env[SOME_CONST]` lookup is invisible to it. /** Master opt-in (default off): no control-plane traffic is possible until this is truthy. */ diff --git a/packages/loopover-miner/package.json b/packages/loopover-miner/package.json index 83c7d8295f..1c9668d880 100644 --- a/packages/loopover-miner/package.json +++ b/packages/loopover-miner/package.json @@ -40,8 +40,8 @@ "expected-engine.version" ], "scripts": { - "benchmark": "node scripts/benchmark.mjs", - "cross-repo-eval": "node scripts/cross-repo-evaluation.mjs", + "benchmark": "node --experimental-strip-types scripts/benchmark.ts", + "cross-repo-eval": "node --experimental-strip-types scripts/cross-repo-evaluation.ts", "build": "npm run build:tsc && npm run build:verify", "build:tsc": "tsc -p tsconfig.json", "build:verify": "node --experimental-strip-types scripts/check-syntax.ts" diff --git a/packages/loopover-miner/scripts/benchmark.mjs b/packages/loopover-miner/scripts/benchmark.ts similarity index 100% rename from packages/loopover-miner/scripts/benchmark.mjs rename to packages/loopover-miner/scripts/benchmark.ts diff --git a/packages/loopover-miner/scripts/cross-repo-evaluation.mjs b/packages/loopover-miner/scripts/cross-repo-evaluation.ts similarity index 100% rename from packages/loopover-miner/scripts/cross-repo-evaluation.mjs rename to packages/loopover-miner/scripts/cross-repo-evaluation.ts diff --git a/packages/loopover-miner/scripts/generate-env-reference.mjs b/packages/loopover-miner/scripts/generate-env-reference.ts similarity index 98% rename from packages/loopover-miner/scripts/generate-env-reference.mjs rename to packages/loopover-miner/scripts/generate-env-reference.ts index 7b4e250bcd..5daddfb1d3 100644 --- a/packages/loopover-miner/scripts/generate-env-reference.mjs +++ b/packages/loopover-miner/scripts/generate-env-reference.ts @@ -137,7 +137,7 @@ export function renderMinerEnvReferenceModule(rows) { .split("\n") .map((line) => ` ${quoteJsStringLiteral(line)},`) .join("\n"); - return `// Generated by scripts/generate-env-reference.mjs (npm run miner:env-reference). Do not edit manually. + return `// Generated by scripts/generate-env-reference.ts (npm run miner:env-reference). Do not edit manually. export type MinerEnvReferenceRow = { name: string; firstReference: string; diff --git a/test/unit/miner-env-reference-script.test.ts b/test/unit/miner-env-reference-script.test.ts index 81c5a82ab1..190e20eca0 100644 --- a/test/unit/miner-env-reference-script.test.ts +++ b/test/unit/miner-env-reference-script.test.ts @@ -9,7 +9,7 @@ import { renderMinerEnvReferenceModule, writeMinerEnvReference, writeMinerEnvReferenceModule, -} from "../../packages/loopover-miner/scripts/generate-env-reference.mjs"; +} from "../../packages/loopover-miner/scripts/generate-env-reference.ts"; function fixtureRoot(): string { const root = mkdtempSync(join(tmpdir(), "gt-miner-env-reference-")); From 2e8496e16da32b4dbb5b876588a87e36be0f20d4 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:39:14 -0700 Subject: [PATCH 3/5] build(typescript): port the last hand-written scripts and lock JavaScript out with a CI gate Completes #9527. control-plane, discovery-index and review-enrichment's remaining .mjs scripts become TypeScript, and validate-no-hand-written-js now fails CI on any tracked .mjs/.cjs/.js or hand-written .d.ts outside a small allowlist, each entry carrying its reason in the file. The allowlist has an anti-rot guard: an entry matching nothing tracked FAILS the check. A watched path that silently stops existing is exactly how metagraphed's MCP version-sync workflow died unnoticed for months. Typing the ported files surfaced real defects the untyped versions hid: validate-posthog-release's fetchSymbolSets was annotated by its own callers as returning {results} when it returns a bare array; its config accessor was typed against the app's full ProcessEnv, which no caller could satisfy; and generate-env-reference dereferenced regex groups and a nullable defaultValue without guarding either. The generator's output is byte-identical (48 references, --check passes), so the port is provably behavior-preserving. check-syntax gained scripts/*.ts coverage in both packages so porting a file can never remove it from the only syntax check watching it, and the two upload- sourcemaps spawn sites pass --experimental-strip-types explicitly rather than relying on Node 22.18+ defaulting it on, since engines.node still allows 22.0. Closes #9527 --- .github/workflows/release-selfhost.yml | 2 +- apps/loopover-ui/src/lib/rees-analyzers.ts | 2 +- control-plane/package.json | 2 +- .../{gen-cf-typegen.mjs => gen-cf-typegen.ts} | 4 +- package.json | 3 +- packages/discovery-index/README.md | 2 +- packages/discovery-index/package.json | 4 +- .../{gen-cf-typegen.mjs => gen-cf-typegen.ts} | 2 +- ...elease.mjs => validate-posthog-release.ts} | 59 +++++++++---- ...-sourcemaps.mjs => validate-sourcemaps.ts} | 2 +- .../discovery-index/src/upload-sourcemaps.ts | 4 +- .../scripts/generate-env-reference.ts | 28 +++--- review-enrichment/package.json | 8 +- ...data.mjs => generate-analyzer-metadata.ts} | 2 +- ...elease.mjs => validate-posthog-release.ts} | 2 +- ...-sourcemaps.mjs => validate-sourcemaps.ts} | 0 review-enrichment/src/upload-sourcemaps.ts | 2 +- .../test/posthog-release-validation.test.ts | 2 +- review-enrichment/test/posthog-upload.test.ts | 4 +- .../unit/actionlint-download-attempts.test.ts | 2 +- .../discovery-index/upload-sourcemaps.test.ts | 2 +- .../validate-posthog-release.test.ts | 4 +- test/unit/miner-env-reference-script.test.ts | 2 +- test/unit/selfhost-posthog-release.test.ts | 2 +- test/unit/validate-no-hand-written-js.test.ts | 87 +++++++++++++++++++ 25 files changed, 173 insertions(+), 60 deletions(-) rename control-plane/scripts/{gen-cf-typegen.mjs => gen-cf-typegen.ts} (85%) rename packages/discovery-index/scripts/{gen-cf-typegen.mjs => gen-cf-typegen.ts} (92%) rename packages/discovery-index/scripts/{validate-posthog-release.mjs => validate-posthog-release.ts} (64%) rename packages/discovery-index/scripts/{validate-sourcemaps.mjs => validate-sourcemaps.ts} (95%) rename review-enrichment/scripts/{generate-analyzer-metadata.mjs => generate-analyzer-metadata.ts} (99%) rename review-enrichment/scripts/{validate-posthog-release.mjs => validate-posthog-release.ts} (99%) rename review-enrichment/scripts/{validate-sourcemaps.mjs => validate-sourcemaps.ts} (100%) create mode 100644 test/unit/validate-no-hand-written-js.test.ts diff --git a/.github/workflows/release-selfhost.yml b/.github/workflows/release-selfhost.yml index 82a843e94b..1d1c1c77b6 100644 --- a/.github/workflows/release-selfhost.yml +++ b/.github/workflows/release-selfhost.yml @@ -282,7 +282,7 @@ jobs: attempts=5 delay_secs=10 for attempt in $(seq 1 "$attempts"); do - if node review-enrichment/scripts/validate-posthog-release.mjs; then + if node --experimental-strip-types review-enrichment/scripts/validate-posthog-release.ts; then exit 0 fi if [ "$attempt" -lt "$attempts" ]; then diff --git a/apps/loopover-ui/src/lib/rees-analyzers.ts b/apps/loopover-ui/src/lib/rees-analyzers.ts index 082300968f..3a0bd14e3a 100644 --- a/apps/loopover-ui/src/lib/rees-analyzers.ts +++ b/apps/loopover-ui/src/lib/rees-analyzers.ts @@ -1,4 +1,4 @@ -// Generated by review-enrichment/scripts/generate-analyzer-metadata.mjs. +// Generated by review-enrichment/scripts/generate-analyzer-metadata.ts. // Do not edit by hand; update review-enrichment analyzer descriptors instead. export type ReesProfileName = "fast" | "balanced" | "deep"; diff --git a/control-plane/package.json b/control-plane/package.json index 6277c04b86..8a1a72f0dc 100644 --- a/control-plane/package.json +++ b/control-plane/package.json @@ -17,7 +17,7 @@ "cf:dev": "wrangler dev", "cf:deploy": "wrangler deploy", "cf:typecheck": "tsc -p tsconfig.worker.json --noEmit", - "cf:typegen": "node scripts/gen-cf-typegen.mjs" + "cf:typegen": "node --experimental-strip-types scripts/gen-cf-typegen.ts" }, "dependencies": { "@cloudflare/containers": "^0.3.7", diff --git a/control-plane/scripts/gen-cf-typegen.mjs b/control-plane/scripts/gen-cf-typegen.ts similarity index 85% rename from control-plane/scripts/gen-cf-typegen.mjs rename to control-plane/scripts/gen-cf-typegen.ts index 8dfe24105e..78a77ff036 100644 --- a/control-plane/scripts/gen-cf-typegen.mjs +++ b/control-plane/scripts/gen-cf-typegen.ts @@ -2,8 +2,8 @@ // Regenerates worker-configuration.d.ts from wrangler.jsonc and strips trailing whitespace -- wrangler's // raw `wrangler types` output has trailing whitespace on several lines, which fails this repo's own // `git diff --check` whitespace gate the moment it's committed (mirrors -// packages/discovery-index/scripts/gen-cf-typegen.mjs, found the hard way on that package's own #7167/#4250 -// PR). Simpler than the root repo's scripts/gen-cf-typegen.mjs: this package's Env has no `vars`-derived +// packages/discovery-index/scripts/gen-cf-typegen.ts, found the hard way on that package's own #7167/#4250 +// PR). Simpler than the root repo's scripts/gen-cf-typegen.ts: this package's Env has no `vars`-derived // Pick union to reformat (only a KV binding + ambient-declared secrets), so only the // whitespace-stripping half of that script's job applies here. import { execFileSync } from "node:child_process"; diff --git a/package.json b/package.json index 0f7035f83c..5a985977e0 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "ui:version-audit:sync": "node --experimental-strip-types scripts/check-ui-mcp-version-copy.ts --write", "docs:drift-check": "node --experimental-strip-types scripts/check-docs-drift.ts", "coverage-boltons:check": "node --experimental-strip-types scripts/check-coverage-bolt-on-filenames.ts", + "validate:no-hand-written-js": "node --experimental-strip-types scripts/validate-no-hand-written-js.ts", "import-specifiers:check": "node --experimental-strip-types scripts/check-import-specifiers.ts", "dead-source-files:check": "node --experimental-strip-types scripts/check-dead-source-files.ts", "replay-runner-manifest": "tsx scripts/replay-runner-image-manifest.ts", @@ -115,7 +116,7 @@ "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts", "pretest:ci": "npm run check-node-version", - "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run dead-source-files:check && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", + "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run dead-source-files:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", "test:release": "npm run test:ci && npm run changelog:check", "test:release:mcp": "npm run test:ci", "test:watch": "vitest", diff --git a/packages/discovery-index/README.md b/packages/discovery-index/README.md index afd6fc4d12..3bf2057db3 100644 --- a/packages/discovery-index/README.md +++ b/packages/discovery-index/README.md @@ -34,7 +34,7 @@ Soft-claim design note: the shipped client payload never carries caller identity Runtime errors (route handler failures, unhandled rejections/exceptions) report into PostHog. See `src/posthog.ts` for the full redaction rules (secret-named fields and GitHub-token-shaped values are always filtered before anything leaves the process) and `OPERATIONS.md`'s Monitoring section for how this fits alongside `/metrics`. This surface previously reported into the shared `metagraphed` Sentry project (`jsonbored` org) that this repo's other infra/operational pieces flow into -- a genuine cross-project entanglement -- until #8289 replaced it outright. -Source maps and release tracking are handled by `src/upload-sourcemaps.ts`, which runs as the **first step of the container's runtime `CMD`** (not at Docker build time — `POSTHOG_CLI_API_KEY` is a runtime-only secret, never baked into an image layer): it injects PostHog chunk ids, uploads the exact post-injection source maps, then verifies at least one symbol set exists for the release via `scripts/validate-posthog-release.mjs` (queries PostHog's `error_tracking/symbol_sets` API — PostHog's release model has no equivalent of Sentry's commits/deploys/finalize lifecycle, so this is a narrower check by design), before deleting the `.map` files it just uploaded and starting `server.ts`. Any step failing is non-fatal to the boot unless `DISCOVERY_INDEX_POSTHOG_UPLOAD_STRICT=true` is set. `posthog-cli sourcemap inject`/`upload` run with an explicit `--release-version` (not the CLI's own git-metadata auto-detection — the Docker build stage never copies `.git` into the image). +Source maps and release tracking are handled by `src/upload-sourcemaps.ts`, which runs as the **first step of the container's runtime `CMD`** (not at Docker build time — `POSTHOG_CLI_API_KEY` is a runtime-only secret, never baked into an image layer): it injects PostHog chunk ids, uploads the exact post-injection source maps, then verifies at least one symbol set exists for the release via `scripts/validate-posthog-release.ts` (queries PostHog's `error_tracking/symbol_sets` API — PostHog's release model has no equivalent of Sentry's commits/deploys/finalize lifecycle, so this is a narrower check by design), before deleting the `.map` files it just uploaded and starting `server.ts`. Any step failing is non-fatal to the boot unless `DISCOVERY_INDEX_POSTHOG_UPLOAD_STRICT=true` is set. `posthog-cli sourcemap inject`/`upload` run with an explicit `--release-version` (not the CLI's own git-metadata auto-detection — the Docker build stage never copies `.git` into the image). ## Deployment diff --git a/packages/discovery-index/package.json b/packages/discovery-index/package.json index 2650f9bbdf..5a450fdeb6 100644 --- a/packages/discovery-index/package.json +++ b/packages/discovery-index/package.json @@ -9,13 +9,13 @@ }, "scripts": { "build": "tsc -p tsconfig.json", - "validate:sourcemaps": "node scripts/validate-sourcemaps.mjs", + "validate:sourcemaps": "node --experimental-strip-types scripts/validate-sourcemaps.ts", "start": "node dist/server.js", "dev": "node --experimental-strip-types --watch src/server.ts", "cf:dev": "wrangler dev", "cf:deploy": "wrangler deploy", "cf:typecheck": "tsc -p tsconfig.worker.json --noEmit", - "cf:typegen": "node scripts/gen-cf-typegen.mjs" + "cf:typegen": "node --experimental-strip-types scripts/gen-cf-typegen.ts" }, "dependencies": { "@cloudflare/containers": "^0.3.7", diff --git a/packages/discovery-index/scripts/gen-cf-typegen.mjs b/packages/discovery-index/scripts/gen-cf-typegen.ts similarity index 92% rename from packages/discovery-index/scripts/gen-cf-typegen.mjs rename to packages/discovery-index/scripts/gen-cf-typegen.ts index 6dd7c073db..8e65d2b483 100644 --- a/packages/discovery-index/scripts/gen-cf-typegen.mjs +++ b/packages/discovery-index/scripts/gen-cf-typegen.ts @@ -2,7 +2,7 @@ // Regenerates worker-configuration.d.ts from wrangler.jsonc and strips trailing whitespace -- wrangler's // raw `wrangler types` output has trailing whitespace on several lines, which fails this repo's own // `git diff --check` whitespace gate the moment it's committed (found the hard way on #7167/#4250's PR). -// Simpler than the root repo's scripts/gen-cf-typegen.mjs: this package's Env has no `vars`-derived +// Simpler than the root repo's scripts/gen-cf-typegen.ts: this package's Env has no `vars`-derived // Pick union to reformat (only Durable Object bindings + ambient-declared secrets), // so only the whitespace-stripping half of that script's job applies here. import { execFileSync } from "node:child_process"; diff --git a/packages/discovery-index/scripts/validate-posthog-release.mjs b/packages/discovery-index/scripts/validate-posthog-release.ts similarity index 64% rename from packages/discovery-index/scripts/validate-posthog-release.mjs rename to packages/discovery-index/scripts/validate-posthog-release.ts index 5534a4eed5..49b5194cc1 100644 --- a/packages/discovery-index/scripts/validate-posthog-release.mjs +++ b/packages/discovery-index/scripts/validate-posthog-release.ts @@ -10,19 +10,39 @@ import { pathToFileURL } from "node:url"; const DEFAULT_POSTHOG_APP_HOST = "https://us.posthog.com"; export class PostHogReleaseValidationError extends Error { - constructor(message, failures = []) { + readonly failures: string[]; + constructor(message: string, failures: string[] = []) { super(message); this.name = "PostHogReleaseValidationError"; this.failures = failures; } } -function nonBlank(value) { +/** The nested release object PostHog returns on a symbol set (never a flat string -- see + * releaseIdentifier below). */ +type PostHogRelease = { project?: string | undefined; version?: string | undefined }; + +/** One symbol set as the error_tracking API returns it. */ +type PostHogSymbolSet = { release?: PostHogRelease | undefined; failure_reason?: string | undefined }; + +export type PostHogReleaseValidationConfig = { + apiKey: string | undefined; + projectId: string | undefined; + release: string | undefined; + baseUrl: string; +}; + +/** Only the vars this script actually reads. Deliberately NOT NodeJS.ProcessEnv: typing it as the + * full environment would force every caller (and every test) to supply the ~50 unrelated vars the + * app's own ProcessEnv declares, for a script that reads three. */ +type PostHogReleaseEnv = Record; + +function nonBlank(value: string | undefined): string | undefined { const text = typeof value === "string" ? value.trim() : undefined; return text ? text : undefined; } -function apiBaseUrl(value) { +function apiBaseUrl(value: string | undefined): string { return (nonBlank(value) ?? DEFAULT_POSTHOG_APP_HOST).replace(/\/+$/, ""); } @@ -32,14 +52,14 @@ function apiBaseUrl(value) { // "{project}@{version}" here is what actually makes this comparable to our own POSTHOG_RELEASE // convention (the same "{release-name}@{release-version}" split posthog-cli's --release-name/ // --release-version flags combine server-side into the release posthog-cli itself resolves). -function releaseIdentifier(release) { +function releaseIdentifier(release: PostHogRelease | undefined): string | undefined { if (!release || typeof release !== "object") return undefined; const project = nonBlank(release.project); const version = nonBlank(release.version); return project && version ? `${project}@${version}` : undefined; } -export function loadPostHogReleaseValidationConfig(env = process.env) { +export function loadPostHogReleaseValidationConfig(env: PostHogReleaseEnv = process.env): PostHogReleaseValidationConfig { return { // The same personal API key posthog-cli's upload step uses (error-tracking write + organization read // scopes) -- listing symbol sets needs error_tracking:read, which that scope grant already covers. @@ -50,12 +70,12 @@ export function loadPostHogReleaseValidationConfig(env = process.env) { }; } -function requireConfig(config) { - const missing = [ +function requireConfig(config: PostHogReleaseValidationConfig): void { + const missing: string[] = ([ ["POSTHOG_CLI_API_KEY", config.apiKey], ["POSTHOG_CLI_PROJECT_ID", config.projectId], ["POSTHOG_RELEASE", config.release], - ] + ] as Array<[string, string | undefined]>) .filter(([, value]) => !value) .map(([name]) => name); if (missing.length > 0) { @@ -63,37 +83,38 @@ function requireConfig(config) { } } -function symbolSetsUrl(config) { - return `${config.baseUrl}/api/projects/${encodeURIComponent(config.projectId)}/error_tracking/symbol_sets?limit=100`; +function symbolSetsUrl(config: PostHogReleaseValidationConfig): string { + // requireConfig has already thrown if these are missing; TS cannot narrow across that call. + return `${config.baseUrl}/api/projects/${encodeURIComponent(config.projectId ?? "")}/error_tracking/symbol_sets?limit=100`; } -async function fetchSymbolSets(config, fetchImpl) { +async function fetchSymbolSets(config: PostHogReleaseValidationConfig, fetchImpl: typeof globalThis.fetch): Promise { const response = await fetchImpl(symbolSetsUrl(config), { headers: { accept: "application/json", authorization: `Bearer ${config.apiKey}` }, }); if (!response.ok) { let message = response.statusText; try { - const body = await response.json(); + const body = (await response.json()) as { detail?: string; error?: string; message?: string }; message = body?.detail ?? body?.error ?? body?.message ?? message; } catch { /* Keep the status text when the body is not JSON. */ } throw new PostHogReleaseValidationError("PostHog API request failed", [`error_tracking/symbol_sets returned HTTP ${response.status}${message ? ` (${message})` : ""}`]); } - const body = await response.json(); + const body = (await response.json()) as PostHogSymbolSet[] | { results?: PostHogSymbolSet[] }; return Array.isArray(body) ? body : Array.isArray(body?.results) ? body.results : []; } -function log(event, fields = {}) { +function log(event: string, fields: Record = {}): void { console.log(JSON.stringify({ event, ...fields })); } -function logError(event, fields = {}) { +function logError(event: string, fields: Record = {}): void { console.error(JSON.stringify({ level: "error", event, ...fields })); } -export async function validatePostHogRelease(env = process.env, fetchImpl = globalThis.fetch) { +export async function validatePostHogRelease(env: PostHogReleaseEnv = process.env, fetchImpl: typeof globalThis.fetch = globalThis.fetch) { if (typeof fetchImpl !== "function") { throw new PostHogReleaseValidationError("fetch is unavailable", ["Node 20+ fetch support is required"]); } @@ -102,13 +123,13 @@ export async function validatePostHogRelease(env = process.env, fetchImpl = glob requireConfig(config); const symbolSets = await fetchSymbolSets(config, fetchImpl); - const forRelease = symbolSets.filter((set) => releaseIdentifier(set?.release) === config.release); + const forRelease = symbolSets.filter((set: PostHogSymbolSet) => releaseIdentifier(set?.release) === config.release); const failures = []; if (forRelease.length === 0) { failures.push(`no symbol sets found for release ${config.release}`); } - const failed = forRelease.filter((set) => nonBlank(set?.failure_reason)); + const failed = forRelease.filter((set: PostHogSymbolSet) => nonBlank(set?.failure_reason)); if (failed.length > 0) { failures.push(`${failed.length} symbol set(s) for release ${config.release} recorded a failure_reason`); } @@ -125,7 +146,7 @@ async function main() { const result = await validatePostHogRelease(); log("discovery_index_posthog_release_validation_complete", result); } catch (error) { - const failures = Array.isArray(error?.failures) ? error.failures : [String(error)]; + const failures = error instanceof PostHogReleaseValidationError ? error.failures : [String(error)]; logError("discovery_index_posthog_release_validation_failed", { release: nonBlank(process.env.POSTHOG_RELEASE), failures }); process.exitCode = 1; } diff --git a/packages/discovery-index/scripts/validate-sourcemaps.mjs b/packages/discovery-index/scripts/validate-sourcemaps.ts similarity index 95% rename from packages/discovery-index/scripts/validate-sourcemaps.mjs rename to packages/discovery-index/scripts/validate-sourcemaps.ts index 05019fbce0..96a15e2efa 100644 --- a/packages/discovery-index/scripts/validate-sourcemaps.mjs +++ b/packages/discovery-index/scripts/validate-sourcemaps.ts @@ -1,4 +1,4 @@ -// Mirrors review-enrichment/scripts/validate-sourcemaps.mjs (a comparably-sized standalone service with the +// Mirrors review-enrichment/scripts/validate-sourcemaps.ts (a comparably-sized standalone service with the // identical Sentry source-map setup) -- verifies the build actually produced usable source maps before // they're relied on for upload/symbolication. import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; diff --git a/packages/discovery-index/src/upload-sourcemaps.ts b/packages/discovery-index/src/upload-sourcemaps.ts index 43c90a017d..48ddac3a1b 100644 --- a/packages/discovery-index/src/upload-sourcemaps.ts +++ b/packages/discovery-index/src/upload-sourcemaps.ts @@ -103,7 +103,7 @@ async function runReleaseValidation(release: string): Promise { let output = ""; let status: number | null = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { - const result = spawnSync(process.execPath, ["scripts/validate-posthog-release.mjs"], { + const result = spawnSync(process.execPath, ["--experimental-strip-types", "scripts/validate-posthog-release.ts"], { cwd: appDir, env: { ...process.env, POSTHOG_RELEASE: release }, encoding: "utf8", @@ -126,7 +126,7 @@ async function runReleaseValidation(release: string): Promise { // review-enrichment (a standalone, non-workspace package), discovery-index is a real npm workspace member, // so npm hoists @posthog/cli's binary to the ROOT node_modules/.bin/ by default -- a package-relative path // assumption would silently look in the wrong place. Same resolution pattern as the root repo's own -// scripts/gen-cf-typegen.mjs resolveLocalWranglerBin(). +// scripts/gen-cf-typegen.ts resolveLocalWranglerBin(). function postHogCliPath(): string { const override = nonBlank(process.env.POSTHOG_CLI_PATH); if (override) return override; diff --git a/packages/loopover-miner/scripts/generate-env-reference.ts b/packages/loopover-miner/scripts/generate-env-reference.ts index 5daddfb1d3..145750ca1d 100644 --- a/packages/loopover-miner/scripts/generate-env-reference.ts +++ b/packages/loopover-miner/scripts/generate-env-reference.ts @@ -4,6 +4,9 @@ import { dirname, extname, relative, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; import { collectSelfHostEnvVars } from "../../../scripts/gen-selfhost-env-reference.js"; +/** One row of the generated env reference: the variable, where it is read, and its default. */ +export type EnvReferenceRow = { name: string; firstReference: string; defaultValue?: string | null | undefined }; + export const DEFAULT_OUTPUT_PATH = "packages/loopover-miner/docs/env-reference.md"; export const DEFAULT_MODULE_OUTPUT_PATH = "apps/loopover-ui/src/lib/ams-env-reference.ts"; export const DEFAULT_SOURCE_ROOTS = [ @@ -29,11 +32,11 @@ const DEFAULT_PATTERNS = [ /\{\s*([A-Z][A-Z0-9_]*)\s*=\s*'([^']*)'\s*\}\s*=\s*process\.env/g, ]; -export function isMinerEnvVar(name) { +export function isMinerEnvVar(name: string): boolean { return MINER_ENV_PREFIXES.some((prefix) => name.startsWith(prefix)); } -export function collectMinerEnvDefaults(rootDir, sourceRoots = DEFAULT_SOURCE_ROOTS) { +export function collectMinerEnvDefaults(rootDir: string, sourceRoots: string[] = DEFAULT_SOURCE_ROOTS): Map { const defaults = new Map(); for (const file of sourceFiles(rootDir, sourceRoots)) { const abs = resolve(rootDir, file); @@ -42,7 +45,8 @@ export function collectMinerEnvDefaults(rootDir, sourceRoots = DEFAULT_SOURCE_RO pattern.lastIndex = 0; for (const match of source.matchAll(pattern)) { const name = match[1]; - if (!isMinerEnvVar(name)) continue; + // noUncheckedIndexedAccess: a regex group can be undefined even when the match succeeded. + if (!name || !isMinerEnvVar(name)) continue; const value = match[2] ?? ""; if (!defaults.has(name)) defaults.set(name, value); } @@ -66,7 +70,7 @@ export function collectMinerEnvVars({ return rows; } -export function renderMinerEnvReferenceMarkdown(rows) { +export function renderMinerEnvReferenceMarkdown(rows: EnvReferenceRow[]): string { const header = [ "# loopover-miner environment variable reference", "", @@ -115,7 +119,7 @@ export function writeMinerEnvReference({ // every markdown line whose row has a quoted string defaultValue (rendered as `""`/`"production"` // in the table) hits, since JSON.stringify always double-quotes. Replicate that per-string choice // here so the generator's own output is already Prettier-clean, not dependent on a separate --fix. -function quoteJsStringLiteral(value) { +function quoteJsStringLiteral(value: string): string { const doubleQuoteCount = (value.match(/"/g) ?? []).length; const singleQuoteCount = (value.match(/'/g) ?? []).length; if (singleQuoteCount < doubleQuoteCount) { @@ -125,12 +129,12 @@ function quoteJsStringLiteral(value) { return JSON.stringify(value); } -export function renderMinerEnvReferenceModule(rows) { +export function renderMinerEnvReferenceModule(rows: EnvReferenceRow[]): string { const markdown = renderMinerEnvReferenceMarkdown(rows); const rowLines = rows .map( (row) => - ` {\n name: ${quoteJsStringLiteral(row.name)},\n firstReference: ${quoteJsStringLiteral(row.firstReference)},\n defaultValue: ${row.defaultValue === null ? "null" : quoteJsStringLiteral(row.defaultValue)},\n },`, + ` {\n name: ${quoteJsStringLiteral(row.name)},\n firstReference: ${quoteJsStringLiteral(row.firstReference)},\n defaultValue: ${row.defaultValue == null ? "null" : quoteJsStringLiteral(row.defaultValue)},\n },`, ) .join("\n"); const markdownLines = markdown @@ -172,7 +176,7 @@ export function writeMinerEnvReferenceModule({ return { changed, outputPath, rows }; } -function sourceFiles(rootDir, sourceRoots) { +function sourceFiles(rootDir: string, sourceRoots: string[]): string[] { const files = []; for (const sourceRoot of sourceRoots) { const abs = resolve(rootDir, sourceRoot); @@ -193,7 +197,7 @@ function sourceFiles(rootDir, sourceRoots) { const COMPILED_JS_EXTENSIONS = new Set([".js", ".mjs", ".cjs"]); -function walkSourceFiles(dir) { +function walkSourceFiles(dir: string): string[] { const files = []; const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); // Same reasoning as gen-selfhost-env-reference.ts's identical guard: a same-basename .ts/.tsx sibling @@ -216,15 +220,15 @@ function walkSourceFiles(dir) { return files; } -function isSupportedSourceFile(file) { +function isSupportedSourceFile(file: string): boolean { return SOURCE_FILE_EXTENSIONS.has(extname(file)); } -function toPosixPath(path) { +function toPosixPath(path: string): string { return path.split(sep).join("/"); } -function main(argv) { +function main(argv: string[]): void { const check = argv.includes("--check"); const results = [writeMinerEnvReference({ check }), writeMinerEnvReferenceModule({ check })]; const stale = results.filter((result) => result.changed); diff --git a/review-enrichment/package.json b/review-enrichment/package.json index a5542baead..a606bfa619 100644 --- a/review-enrichment/package.json +++ b/review-enrichment/package.json @@ -9,12 +9,12 @@ }, "scripts": { "build": "tsc -p tsconfig.json", - "metadata": "npm run build && node scripts/generate-analyzer-metadata.mjs", - "metadata:check": "npm run build && node scripts/generate-analyzer-metadata.mjs --check", - "validate:sourcemaps": "node scripts/validate-sourcemaps.mjs", + "metadata": "npm run build && node --experimental-strip-types scripts/generate-analyzer-metadata.ts", + "metadata:check": "npm run build && node --experimental-strip-types scripts/generate-analyzer-metadata.ts --check", + "validate:sourcemaps": "node --experimental-strip-types scripts/validate-sourcemaps.ts", "start": "node dist/server.js", "dev": "node --experimental-strip-types --watch src/server.ts", - "test": "npm run build && npm run validate:sourcemaps && node scripts/generate-analyzer-metadata.mjs --check && (npm run test:node || npm run test:node)", + "test": "npm run build && npm run validate:sourcemaps && node --experimental-strip-types scripts/generate-analyzer-metadata.ts --check && (npm run test:node || npm run test:node)", "test:node": "node --test --experimental-strip-types \"test/**/*.test.ts\"" }, "dependencies": { diff --git a/review-enrichment/scripts/generate-analyzer-metadata.mjs b/review-enrichment/scripts/generate-analyzer-metadata.ts similarity index 99% rename from review-enrichment/scripts/generate-analyzer-metadata.mjs rename to review-enrichment/scripts/generate-analyzer-metadata.ts index 2b485ddb0c..721dcbdfe9 100644 --- a/review-enrichment/scripts/generate-analyzer-metadata.mjs +++ b/review-enrichment/scripts/generate-analyzer-metadata.ts @@ -47,7 +47,7 @@ const metadata = { }; const generatedJson = `${JSON.stringify(metadata, null, 2)}\n`; -const rawGeneratedUi = `// Generated by review-enrichment/scripts/generate-analyzer-metadata.mjs. +const rawGeneratedUi = `// Generated by review-enrichment/scripts/generate-analyzer-metadata.ts. // Do not edit by hand; update review-enrichment analyzer descriptors instead. export type ReesProfileName = "fast" | "balanced" | "deep"; diff --git a/review-enrichment/scripts/validate-posthog-release.mjs b/review-enrichment/scripts/validate-posthog-release.ts similarity index 99% rename from review-enrichment/scripts/validate-posthog-release.mjs rename to review-enrichment/scripts/validate-posthog-release.ts index 1d63f823ba..21aaf56d4c 100644 --- a/review-enrichment/scripts/validate-posthog-release.mjs +++ b/review-enrichment/scripts/validate-posthog-release.ts @@ -3,7 +3,7 @@ // resource with its own commits/deploys/finalize lifecycle; a release is purely a version string baked into // each uploaded symbol set's metadata as a byproduct of `posthog-cli sourcemap upload`. This script therefore // verifies a narrower, honestly-scoped claim: that at least one symbol set exists for our release, and none -// of them recorded a failure_reason. Mirrors packages/discovery-index/scripts/validate-posthog-release.mjs. +// of them recorded a failure_reason. Mirrors packages/discovery-index/scripts/validate-posthog-release.ts. import { pathToFileURL } from "node:url"; const DEFAULT_POSTHOG_APP_HOST = "https://us.posthog.com"; diff --git a/review-enrichment/scripts/validate-sourcemaps.mjs b/review-enrichment/scripts/validate-sourcemaps.ts similarity index 100% rename from review-enrichment/scripts/validate-sourcemaps.mjs rename to review-enrichment/scripts/validate-sourcemaps.ts diff --git a/review-enrichment/src/upload-sourcemaps.ts b/review-enrichment/src/upload-sourcemaps.ts index b33fa4acf0..d3f65a995f 100644 --- a/review-enrichment/src/upload-sourcemaps.ts +++ b/review-enrichment/src/upload-sourcemaps.ts @@ -114,7 +114,7 @@ async function runReleaseValidation(release: string): Promise { let output = ""; let status: number | null = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { - const result = spawnSync(process.execPath, ["scripts/validate-posthog-release.mjs"], { + const result = spawnSync(process.execPath, ["--experimental-strip-types", "scripts/validate-posthog-release.ts"], { cwd: appDir, env: { ...process.env, POSTHOG_RELEASE: release }, encoding: "utf8", diff --git a/review-enrichment/test/posthog-release-validation.test.ts b/review-enrichment/test/posthog-release-validation.test.ts index ef3813fdf4..f86a14c062 100644 --- a/review-enrichment/test/posthog-release-validation.test.ts +++ b/review-enrichment/test/posthog-release-validation.test.ts @@ -5,7 +5,7 @@ import { loadPostHogReleaseValidationConfig, PostHogReleaseValidationError, validatePostHogRelease, -} from "../scripts/validate-posthog-release.mjs"; +} from "../scripts/validate-posthog-release.ts"; function response(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { diff --git a/review-enrichment/test/posthog-upload.test.ts b/review-enrichment/test/posthog-upload.test.ts index f1dbdcac3c..062a639f6f 100644 --- a/review-enrichment/test/posthog-upload.test.ts +++ b/review-enrichment/test/posthog-upload.test.ts @@ -39,7 +39,7 @@ function postHogCliStub(options: { echoOutput?: string } = {}) { } /** A real local HTTP server standing in for PostHog's error_tracking/symbol_sets API, so - * runReleaseValidation's actual retry/attempts/success logic (validate-posthog-release.mjs, spawned as a + * runReleaseValidation's actual retry/attempts/success logic (validate-posthog-release.ts, spawned as a * real subprocess-of-a-subprocess by upload-sourcemaps.js) gets exercised for real rather than always being * skipped via REES_POSTHOG_VALIDATE_RELEASE=0. Mirrors the pre-Sentry-removal sentryApiServer() test helper * this file's deleted predecessor (sentry-upload.test.ts) used for the identical purpose. */ @@ -56,7 +56,7 @@ async function postHogApiServer(options: { failFirstAttempt?: boolean } = {}) { return; } // The real error_tracking/symbol_sets API returns `release` as a nested {project, version} object, not - // a flat string (see validate-posthog-release.mjs's releaseIdentifier for why). + // a flat string (see validate-posthog-release.ts's releaseIdentifier for why). res.end(JSON.stringify({ results: [{ release: { project: "loopover-rees", version: "abc123" }, failure_reason: null }] })); }); await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); diff --git a/test/unit/actionlint-download-attempts.test.ts b/test/unit/actionlint-download-attempts.test.ts index bc31d3ea44..3a0f85a972 100644 --- a/test/unit/actionlint-download-attempts.test.ts +++ b/test/unit/actionlint-download-attempts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveActionlintDownloadAttempts } from "../../scripts/lib/actionlint-download-attempts.ts"; +import { resolveActionlintDownloadAttempts } from "../../scripts/lib/actionlint-download-attempts"; // #7773: an explicit ACTIONLINT_DOWNLOAD_ATTEMPTS="0" must be respected (floored to 1), not silently turned // into the default of 4 by a `parseInt(...) || 4` fallback (0 is falsy). diff --git a/test/unit/discovery-index/upload-sourcemaps.test.ts b/test/unit/discovery-index/upload-sourcemaps.test.ts index fd55a1d511..1ce6a07182 100644 --- a/test/unit/discovery-index/upload-sourcemaps.test.ts +++ b/test/unit/discovery-index/upload-sourcemaps.test.ts @@ -88,7 +88,7 @@ function isPostHogCliCall(command: string): boolean { } function isPostHogValidateReleaseCall(args: string[]): boolean { - return args[0] === "scripts/validate-posthog-release.mjs"; + return args.includes("scripts/validate-posthog-release.ts"); } function spawnSuccess(): { status: number; stdout: string; stderr: string } { diff --git a/test/unit/discovery-index/validate-posthog-release.test.ts b/test/unit/discovery-index/validate-posthog-release.test.ts index d9dee2424b..5fb2afbd1a 100644 --- a/test/unit/discovery-index/validate-posthog-release.test.ts +++ b/test/unit/discovery-index/validate-posthog-release.test.ts @@ -1,4 +1,4 @@ -// Coverage for validate-posthog-release.mjs (#8289). Outside codecov.yml's measured discovery-index/src/** +// Coverage for validate-posthog-release.ts (#8289). Outside codecov.yml's measured discovery-index/src/** // scope (scripts/** isn't gated), same as its untested validate-sentry-release.mjs sibling -- written anyway // since it carries real logic (config loading, API querying, failure aggregation) worth verifying directly. import { describe, expect, it, vi } from "vitest"; @@ -6,7 +6,7 @@ import { loadPostHogReleaseValidationConfig, PostHogReleaseValidationError, validatePostHogRelease, -} from "../../../packages/discovery-index/scripts/validate-posthog-release.mjs"; +} from "../../../packages/discovery-index/scripts/validate-posthog-release"; function jsonResponse(body: unknown, ok = true, status = 200, statusText = "OK") { return { ok, status, statusText, json: async () => body }; diff --git a/test/unit/miner-env-reference-script.test.ts b/test/unit/miner-env-reference-script.test.ts index 190e20eca0..40fbe2603e 100644 --- a/test/unit/miner-env-reference-script.test.ts +++ b/test/unit/miner-env-reference-script.test.ts @@ -9,7 +9,7 @@ import { renderMinerEnvReferenceModule, writeMinerEnvReference, writeMinerEnvReferenceModule, -} from "../../packages/loopover-miner/scripts/generate-env-reference.ts"; +} from "../../packages/loopover-miner/scripts/generate-env-reference"; function fixtureRoot(): string { const root = mkdtempSync(join(tmpdir(), "gt-miner-env-reference-")); diff --git a/test/unit/selfhost-posthog-release.test.ts b/test/unit/selfhost-posthog-release.test.ts index e20ceb997e..8a4ccb2114 100644 --- a/test/unit/selfhost-posthog-release.test.ts +++ b/test/unit/selfhost-posthog-release.test.ts @@ -28,7 +28,7 @@ describe("self-host PostHog release wiring", () => { expect(releaseWorkflow).toContain('POSTHOG_CLI_PACKAGE: "@posthog/cli@0.9.1"'); expect(releaseWorkflow).toContain('npx -y "$POSTHOG_CLI_PACKAGE"'); expect(releaseWorkflow).not.toContain("@posthog/cli@latest"); - expect(releaseWorkflow).toContain("node review-enrichment/scripts/validate-posthog-release.mjs"); + expect(releaseWorkflow).toContain("node --experimental-strip-types review-enrichment/scripts/validate-posthog-release.ts"); expect(releaseWorkflow).not.toContain("review-enrichment/scripts/validate-sentry-release.mjs"); expect(releaseWorkflow).toContain('"orb-v*"'); expect(releaseWorkflow).toContain('orb-v*) VERSION="${REF_NAME#orb-v}"'); diff --git a/test/unit/validate-no-hand-written-js.test.ts b/test/unit/validate-no-hand-written-js.test.ts new file mode 100644 index 0000000000..ba976dfb24 --- /dev/null +++ b/test/unit/validate-no-hand-written-js.test.ts @@ -0,0 +1,87 @@ +// Tests for the TypeScript lock (#9527). +import { describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { ALLOWLIST, classify, isAllowed, isGenerated } from "../../scripts/validate-no-hand-written-js"; + +describe("isAllowed", () => { + it("matches an exact allowlisted path", () => { + expect(isAllowed("apps/loopover-ui/public/sw.js")).toBe(true); + expect(isAllowed("apps/loopover-ui/public/other.js")).toBe(false); + }); + + it("matches any path under a directory-prefix entry", () => { + expect(isAllowed("test/fixtures/local-scorer/scorer-success.mjs")).toBe(true); + expect(isAllowed("test/fixtures/deeply/nested/child.mjs")).toBe(true); + expect(isAllowed("test/unit/something.mjs")).toBe(false); + }); +}); + +describe("isGenerated", () => { + it("exempts generated typegen declarations wherever they live", () => { + expect(isGenerated("worker-configuration.d.ts")).toBe(true); + expect(isGenerated("control-plane/worker-configuration.d.ts")).toBe(true); + }); + + it("does not exempt a hand-written declaration", () => { + expect(isGenerated("src/env.d.ts")).toBe(false); + }); +}); + +describe("classify", () => { + it("flags hand-written JavaScript that is neither allowed nor generated", () => { + const { violations } = classify(["scripts/rogue.mjs", "src/fine.ts", "scripts/rogue.cjs", "apps/x/rogue.js"]); + expect(violations).toEqual(["scripts/rogue.mjs", "scripts/rogue.cjs", "apps/x/rogue.js"]); + }); + + it("flags a hand-written .d.ts but not a generated one", () => { + const { violations } = classify(["some/hand.d.ts", "worker-configuration.d.ts"]); + expect(violations).toEqual(["some/hand.d.ts"]); + }); + + it("ignores TypeScript and every other extension", () => { + const { violations } = classify(["src/a.ts", "docs/b.md", "c.json", "d.py", "e.sql"]); + expect(violations).toEqual([]); + }); + + it("does not flag an allowlisted path", () => { + const { violations } = classify(["apps/loopover-ui/public/sw.js", "test/fixtures/x/child.mjs"]); + expect(violations).toEqual([]); + }); + + it("reports an allowlist entry that matches nothing tracked", () => { + // The anti-rot guard: a watched path that silently stops existing is exactly how metagraphed's + // MCP version-sync workflow died unnoticed. Here it fails the build instead. + const { staleAllowlist } = classify([]); + expect(staleAllowlist.length).toBe(ALLOWLIST.length); + expect(staleAllowlist).toContain("apps/loopover-ui/public/sw.js"); + }); + + it("reports no stale entries when every allowlisted path is present", () => { + const tracked = ALLOWLIST.map((entry) => (entry.path.endsWith("/") ? `${entry.path}child.mjs` : entry.path)); + expect(classify(tracked).staleAllowlist).toEqual([]); + }); +}); + +describe("the allowlist itself", () => { + it("gives every entry a substantive reason", () => { + for (const entry of ALLOWLIST) { + expect(entry.reason.length, entry.path).toBeGreaterThan(30); + } + }); + + it("has no duplicate paths", () => { + const paths = ALLOWLIST.map((entry) => entry.path); + expect(new Set(paths).size).toBe(paths.length); + }); +}); + +describe("the repository itself", () => { + it("contains no hand-written JavaScript outside the allowlist", () => { + // The real gate, run against the real tree -- so a violation fails here too, not only in the + // dedicated test:ci step. + const tracked = execFileSync("git", ["ls-files"], { encoding: "utf8" }).split("\n").filter(Boolean); + const { violations, staleAllowlist } = classify(tracked); + expect(violations).toEqual([]); + expect(staleAllowlist).toEqual([]); + }); +}); From c51a97cd555da8cd5b18925c592385584f07589b Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:55:35 -0700 Subject: [PATCH 4/5] fix(typescript): fix stale .mjs refs the port missed, and type the three dev scripts for real 3 test files (iterate-loop-load-test-script, miner-benchmark-script, miner-cross-repo-evaluation) still imported the OLD .mjs paths for load-test-iterate-loop/benchmark/cross-repo-evaluation after their #9527 rename to .ts -- the package.json runners were updated but these direct test imports were missed. Broke CI on #9534 (Cannot find module ... .mjs). Fixed, plus the same stale path in two docs, a manifest description string, a script's own --help text, and a code comment, none of which the original port's grep sweep caught. Typing the three scripts for the first time (they were always .mjs, so tsc never saw them) surfaced real gaps, not just missing annotations: - cross-repo-evaluation.ts's main() called parseCrossRepoEvaluationArgs() with zero arguments against a REQUIRED parameter -- silently fine in untyped JS because the function internally falls back via , but the parameter needed to actually be optional to say what was always true. - benchmark.ts's buildSyntheticCandidates() was missing owner/repo/assignees entirely and mixed real booleans into a field opportunity-fanout.ts types as literal -only. Traced rankCandidateIssues' actual runtime check () before deciding how to type it: it genuinely branches on false, so narrowing the benchmark's synthetic mix to true-only would have silently dropped real exercised coverage. Added the missing fields for real, kept the deliberate true/false mix with a documented cast rather than narrowing it away. - load-test-iterate-loop.ts's fake driver was typed to a hand-narrowed { attemptId: string } task shape until the test file's own fuller task literal (workingDirectory, acceptanceCriteriaPath, ...) failed against it -- switched to the real CodingAgentDriverTask/Result types instead of the narrower hand-rolled ones. Refs #9527 --- control-plane/src/worker.ts | 2 +- .../docs/iterate-loop-load-test.md | 2 +- .../scripts/load-test-iterate-loop.ts | 47 ++++++++++++++----- .../benchmarks/cross-repo/manifest.json | 2 +- .../docs/cross-repo-evaluation.md | 6 +-- packages/loopover-miner/scripts/benchmark.ts | 38 ++++++++++----- .../scripts/cross-repo-evaluation.ts | 25 +++++++--- .../iterate-loop-load-test-script.test.ts | 4 +- test/unit/miner-benchmark-script.test.ts | 4 +- test/unit/miner-cross-repo-evaluation.test.ts | 4 +- 10 files changed, 91 insertions(+), 43 deletions(-) diff --git a/control-plane/src/worker.ts b/control-plane/src/worker.ts index 8cbf5f7fd0..e48872333a 100644 --- a/control-plane/src/worker.ts +++ b/control-plane/src/worker.ts @@ -5,7 +5,7 @@ // driver-factory.ts) into the plain, already-tested Hono app (http-app.ts). Adds NO route logic of its own. // // Not unit-tested: exercised only by real Cloudflare Workers/KV/Containers infrastructure, matching -// packages/discovery-index/src/worker.ts's own identical exclusion (see scripts/control-plane-coverage.mjs). +// packages/discovery-index/src/worker.ts's own identical exclusion (see scripts/control-plane-coverage.ts). import { Container } from "@cloudflare/containers"; import { wakeDueAmsTenants } from "./ams-wake.js"; import { createTenantProvisioningDriver } from "./driver-factory.js"; diff --git a/packages/loopover-engine/docs/iterate-loop-load-test.md b/packages/loopover-engine/docs/iterate-loop-load-test.md index 9afb55f6bc..781b3599e5 100644 --- a/packages/loopover-engine/docs/iterate-loop-load-test.md +++ b/packages/loopover-engine/docs/iterate-loop-load-test.md @@ -14,7 +14,7 @@ mirrors; #5224 is the AMS-side counterpart this harness was built for. npm run loadtest:iterate-loop # or, from a workspace checkout, after building the engine: npm --workspace @loopover/engine run build -node packages/loopover-engine/scripts/load-test-iterate-loop.mjs +node --experimental-strip-types packages/loopover-engine/scripts/load-test-iterate-loop.ts ``` This prints a short text report to stdout and exits `0`. It does not fail the build or a CI job on its own diff --git a/packages/loopover-engine/scripts/load-test-iterate-loop.ts b/packages/loopover-engine/scripts/load-test-iterate-loop.ts index 395c6c78ed..e420914781 100644 --- a/packages/loopover-engine/scripts/load-test-iterate-loop.ts +++ b/packages/loopover-engine/scripts/load-test-iterate-loop.ts @@ -1,6 +1,24 @@ #!/usr/bin/env node import { performance } from "node:perf_hooks"; -import { parseFocusManifest, runIterateLoop } from "../dist/index.js"; +import { + parseFocusManifest, + runIterateLoop, + type IterateLoopInput, + type IterateLoopDeps, + type CodingAgentExecutionMode, + type CodingAgentDriverTask, + type CodingAgentDriverResult, +} from "../dist/index.js"; + +/** A driver implementing only `run()` -- the single member iterate-loop's own dependency injection + * calls -- rather than the full CodingAgentDriver interface, which this harness never otherwise + * touches. The task/result shapes themselves are the real CodingAgentDriverTask/Result types, not + * narrowed, so a caller building a full realistic task (as the test double coverage below does) + * gets real type checking on it. */ +type FakeLoadTestDriver = { run: (task: CodingAgentDriverTask) => Promise }; +type ConcurrencyLevelOptions = { attemptCount?: number; latencyMs?: number }; +type ConcurrencyLevelResult = { concurrency: number; attemptCount: number; latencyMs: number; wallMs: number; handoffCount: number; attemptsPerSecond: number }; +type LoadTestOptions = ConcurrencyLevelOptions & { levels?: number[] }; // Load-testing harness for the AMS iterate-loop orchestrator (#5224): every existing iterate-loop test is // correctness-oriented (single attempt, fake driver returns instantly), so there is no signal today for how @@ -20,7 +38,7 @@ const SYNTHETIC_ISSUE_NUMBER = 7; /** One open issue per synthetic tenant repo, matching that tenant's own `passesPredictedGate` linkage below -- * each tenant is a fully independent repo/contributor pair (`buildSelfReviewPredictedGateInput`'s own identity * fields), the same "multi-tenant-like" shape the issue's Problem section asks this harness to load-test. */ -function buildReviewContext(tenantRepoFullName) { +function buildReviewContext(tenantRepoFullName: string) { return { manifest: parseFocusManifest({ gate: { duplicates: "block", linkedIssue: "advisory" } }), repo: { fullName: tenantRepoFullName, owner: tenantRepoFullName.split("/")[0], name: tenantRepoFullName.split("/")[1], isInstalled: true, isRegistered: true, isPrivate: false }, @@ -33,7 +51,7 @@ function buildReviewContext(tenantRepoFullName) { * it resolves after `latencyMs` (simulating the wall-clock an iteration of a real coding-agent invocation * would take) with a scripted, always-passing result. `latencyMs` uses a real `setTimeout`, not a busy-loop, so * concurrent attempts genuinely interleave on the event loop the way concurrent live attempts would. */ -export function buildFakeLoadTestDriver(latencyMs) { +export function buildFakeLoadTestDriver(latencyMs: number): FakeLoadTestDriver { return { async run(task) { await new Promise((resolve) => setTimeout(resolve, latencyMs)); @@ -48,15 +66,16 @@ const NOOP_SLOP_ASSESSMENT = { slopRisk: 0, band: "clean", findings: [] }; * issue that matches on the first iteration, so every attempt hands off in exactly one iteration -- isolating * the measurement to iterate-loop's own per-attempt orchestration overhead rather than varying iteration counts * across runs. */ -async function runOneAttempt(tenantIndex, driver) { +async function runOneAttempt(tenantIndex: number, driver: FakeLoadTestDriver) { const repoFullName = `load-test-tenant-${tenantIndex}/repo`; const attemptId = `attempt-${tenantIndex}`; + const mode: CodingAgentExecutionMode = "live"; const input = { attemptId, workingDirectory: `/tmp/${attemptId}`, acceptanceCriteriaPath: `/tmp/${attemptId}/acceptance-criteria.json`, instructions: "Synthetic load-test instructions", - mode: "live", + mode, maxIterations: 3, maxTurnsPerIteration: 20, repoFullName, @@ -66,12 +85,16 @@ async function runOneAttempt(tenantIndex, driver) { linkedIssues: [SYNTHETIC_ISSUE_NUMBER], reviewContext: buildReviewContext(repoFullName), rejectionSignaled: false, - }; + } as IterateLoopInput; + // FakeLoadTestDriver only implements the single `run()` member iterate-loop actually calls, not + // the full CodingAgentDriver interface -- deliberately narrow, matching this package's existing + // injection-seam convention of typing a test/harness double to the minimal surface exercised + // rather than the full production interface. const deps = { driver, runSlopAssessment: () => NOOP_SLOP_ASSESSMENT, appendAttemptLogEvent: () => {}, - }; + } as IterateLoopDeps; const start = performance.now(); const result = await runIterateLoop(input, deps); return { elapsedMs: performance.now() - start, result }; @@ -83,13 +106,13 @@ async function runOneAttempt(tenantIndex, driver) { * to hand off after its first iteration (see {@link runOneAttempt}) -- a non-`"handoff"` outcome or a driver * error would silently understate real concurrent load, so both are counted and surfaced rather than ignored. */ -export async function runConcurrencyLevel(concurrency, options = {}) { +export async function runConcurrencyLevel(concurrency: number, options: ConcurrencyLevelOptions = {}): Promise { const attemptCount = options.attemptCount ?? DEFAULT_ATTEMPTS_PER_LEVEL; const latencyMs = options.latencyMs ?? DEFAULT_SIMULATED_DRIVER_LATENCY_MS; const driver = buildFakeLoadTestDriver(latencyMs); const start = performance.now(); - const outcomes = []; + const outcomes: Awaited>[] = []; for (let batchStart = 0; batchStart < attemptCount; batchStart += concurrency) { const batchSize = Math.min(concurrency, attemptCount - batchStart); const batch = await Promise.all( @@ -112,9 +135,9 @@ export async function runConcurrencyLevel(concurrency, options = {}) { /** Run every concurrency level in `levels` in sequence (never overlapping each other), so one level's * scheduling contention never bleeds into the next level's measurement. */ -export async function runLoadTest(options = {}) { +export async function runLoadTest(options: LoadTestOptions = {}): Promise { const levels = options.levels ?? DEFAULT_CONCURRENCY_LEVELS; - const results = []; + const results: ConcurrencyLevelResult[] = []; for (const concurrency of levels) { results.push(await runConcurrencyLevel(concurrency, options)); } @@ -122,7 +145,7 @@ export async function runLoadTest(options = {}) { } /** Render load-test results as a stable, greppable text report (no locale-dependent number formatting). */ -export function formatLoadTestReport(results) { +export function formatLoadTestReport(results: ConcurrencyLevelResult[]): string { const lines = ["iterate-loop load test", ""]; for (const r of results) { lines.push( diff --git a/packages/loopover-miner/benchmarks/cross-repo/manifest.json b/packages/loopover-miner/benchmarks/cross-repo/manifest.json index a6d1bc1b35..3689631e11 100644 --- a/packages/loopover-miner/benchmarks/cross-repo/manifest.json +++ b/packages/loopover-miner/benchmarks/cross-repo/manifest.json @@ -1,5 +1,5 @@ { - "description": "Cross-repo evaluation benchmark set (#4788). Diverse public repos exercised by `node packages/loopover-miner/scripts/cross-repo-evaluation.mjs` after cloning into LOOPOVER_MINER_REPO_CLONE_DIR.", + "description": "Cross-repo evaluation benchmark set (#4788). Diverse public repos exercised by `node --experimental-strip-types packages/loopover-miner/scripts/cross-repo-evaluation.ts` after cloning into LOOPOVER_MINER_REPO_CLONE_DIR.", "repos": [ { "repoFullName": "sindresorhus/is", diff --git a/packages/loopover-miner/docs/cross-repo-evaluation.md b/packages/loopover-miner/docs/cross-repo-evaluation.md index 675e79a129..cf77ee2018 100644 --- a/packages/loopover-miner/docs/cross-repo-evaluation.md +++ b/packages/loopover-miner/docs/cross-repo-evaluation.md @@ -51,7 +51,7 @@ fleet run-manifest). 2. Run the harness from the repo root: ```bash - node packages/loopover-miner/scripts/cross-repo-evaluation.mjs + node --experimental-strip-types packages/loopover-miner/scripts/cross-repo-evaluation.ts ``` Useful flags: @@ -70,7 +70,7 @@ the coding agent runs against a synthetic benchmark issue inside that copy, and test commands are executed there. The scratch tree is removed afterward in every outcome — execute-locally-and-discard. ```bash -node packages/loopover-miner/scripts/cross-repo-evaluation.mjs --full-execution +node --experimental-strip-types packages/loopover-miner/scripts/cross-repo-evaluation.ts --full-execution ``` Prerequisites beyond the readiness mode: a configured coding-agent provider (`MINER_CODING_AGENT_PROVIDER`, see @@ -93,7 +93,7 @@ defaults to `DEFAULT_CROSS_REPO_EXECUTION_MAX_TURNS` (24). ## Library API -Pure functions live in [`lib/cross-repo-evaluation.js`](../lib/cross-repo-evaluation.js): +Pure functions live in [`lib/cross-repo-evaluation.ts`](../lib/cross-repo-evaluation.ts): - `parseCrossRepoEvaluationManifest(content)` - `evaluateRepoReadiness(entry, options)` — inject `existsSync`, `detectRepoStack`, etc. for unit tests diff --git a/packages/loopover-miner/scripts/benchmark.ts b/packages/loopover-miner/scripts/benchmark.ts index 0a7e3689fc..dc16df4c32 100644 --- a/packages/loopover-miner/scripts/benchmark.ts +++ b/packages/loopover-miner/scripts/benchmark.ts @@ -1,8 +1,13 @@ #!/usr/bin/env node import { performance } from "node:perf_hooks"; import { rankCandidateIssues } from "../dist/lib/opportunity-ranker.js"; +import type { RawCandidateIssue } from "../dist/lib/opportunity-fanout.js"; import { initPortfolioQueueStore } from "../dist/lib/portfolio-queue.js"; +type RankingBenchmarkOptions = { candidateCount?: number; iterations?: number }; +type LocalStoreBenchmarkOptions = { operationCount?: number; iterations?: number }; +type BenchmarkResult = { name: string; unitCount: number; iterations: number; medianMs: number; opsPerSecond: number }; + // Committed micro-benchmark for the two hot local paths that have no other way to notice a regression: the // discovery fan-out ranking pass (opportunity-ranker.js, run once per repo per discovery cycle over every open // candidate) and the local-store read/write path (portfolio-queue.js, run on every enqueue/claim). Neither is @@ -21,44 +26,53 @@ const SYNTHETIC_EPOCH_MS = Date.UTC(2024, 0, 1); * `Date.now()`, so `buildSyntheticCandidates(n)` returns byte-identical input on every call/machine/run -- the * benchmark's numbers are comparable across runs precisely because its input never varies. */ -export function buildSyntheticCandidates(count) { - const candidates = []; +export function buildSyntheticCandidates(count: number): RawCandidateIssue[] { + const candidates: RawCandidateIssue[] = []; for (let i = 0; i < count; i += 1) { const timestamp = new Date(SYNTHETIC_EPOCH_MS + i * 3_600_000).toISOString(); candidates.push({ + owner: "bench-owner", + repo: `bench-repo-${i % 7}`, repoFullName: `bench-owner/bench-repo-${i % 7}`, issueNumber: i + 1, title: `Synthetic benchmark issue #${i + 1}`, - labels: [LABEL_POOL[i % LABEL_POOL.length]], + labels: [LABEL_POOL[i % LABEL_POOL.length]!], + assignees: [], commentsCount: i % 11, createdAt: timestamp, updatedAt: timestamp, htmlUrl: `https://github.com/bench-owner/bench-repo-${i % 7}/issues/${i + 1}`, + // RawCandidateIssue's aiPolicyAllowed is typed `true`-only (real production candidates + // are pre-filtered to allowed-only before reaching this stage), but rankCandidateIssues' + // own logic checks `candidate.aiPolicyAllowed !== false` at runtime -- it genuinely + // tolerates and branches on a false value. The benchmark deliberately mixes in some + // aiPolicyAllowed:false candidates to exercise that branch, so this is cast below rather + // than narrowed to true-only, which would silently drop real exercised coverage. aiPolicyAllowed: i % 5 !== 0, aiPolicySource: i % 5 === 0 ? "AI-USAGE.md" : "none", - }); + } as RawCandidateIssue); } return candidates; } -function timeMs(fn) { +function timeMs(fn: () => void): number { const start = performance.now(); fn(); return performance.now() - start; } -function median(values) { +function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; + return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!; } /** Rank the same synthetic candidate set `iterations` times and report the median wall time (#4845). */ -export function runRankingBenchmark(options = {}) { +export function runRankingBenchmark(options: RankingBenchmarkOptions = {}): BenchmarkResult { const candidateCount = options.candidateCount ?? DEFAULT_CANDIDATE_COUNT; const iterations = options.iterations ?? DEFAULT_ITERATIONS; const candidates = buildSyntheticCandidates(candidateCount); - const samples = []; + const samples: number[] = []; for (let i = 0; i < iterations; i += 1) { samples.push(timeMs(() => rankCandidateIssues(candidates, { nowMs: SYNTHETIC_EPOCH_MS }))); } @@ -77,10 +91,10 @@ export function runRankingBenchmark(options = {}) { * the median wall time -- the same read/write path every real enqueue/claim exercises against the on-disk file, * minus filesystem I/O, so the number isolates the query-plan/schema cost this package actually controls. */ -export function runLocalStoreBenchmark(options = {}) { +export function runLocalStoreBenchmark(options: LocalStoreBenchmarkOptions = {}): BenchmarkResult { const operationCount = options.operationCount ?? DEFAULT_QUEUE_OPERATION_COUNT; const iterations = options.iterations ?? DEFAULT_ITERATIONS; - const samples = []; + const samples: number[] = []; for (let i = 0; i < iterations; i += 1) { const store = initPortfolioQueueStore(":memory:"); try { @@ -113,7 +127,7 @@ export function runLocalStoreBenchmark(options = {}) { } /** Render benchmark results as a stable, greppable text report (no locale-dependent number formatting). */ -export function formatBenchmarkReport(results) { +export function formatBenchmarkReport(results: BenchmarkResult[]): string { const lines = ["loopover-miner benchmark", ""]; for (const result of results) { lines.push( diff --git a/packages/loopover-miner/scripts/cross-repo-evaluation.ts b/packages/loopover-miner/scripts/cross-repo-evaluation.ts index 42285ef3d7..db5e122394 100644 --- a/packages/loopover-miner/scripts/cross-repo-evaluation.ts +++ b/packages/loopover-miner/scripts/cross-repo-evaluation.ts @@ -9,15 +9,22 @@ import { runCrossRepoEvaluation, runCrossRepoFullExecution, summarizeCrossRepoEvaluation, + type ParsedCrossRepoEvaluationManifest, } from "../dist/lib/cross-repo-evaluation.js"; const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +// dist/lib/cross-repo-evaluation.js DOES ship a sibling .d.ts (that package's declaration: true), +// so the real ParsedCrossRepoEvaluationManifest type is available -- reused here rather than +// duplicated. repoFilter is string | null (not string | undefined) to match +// parseCrossRepoEvaluationArgs' own inferred return shape below. +type CliOptions = { parsed?: ParsedCrossRepoEvaluationManifest; manifestPath?: string; repoFilter?: string | null }; + export function resolveDefaultManifestPath() { return join(PACKAGE_ROOT, DEFAULT_CROSS_REPO_MANIFEST_RELATIVE_PATH); } -export function parseCrossRepoEvaluationArgs(argv) { +export function parseCrossRepoEvaluationArgs(argv?: string[]) { const args = argv ?? process.argv.slice(2); let manifestPath = resolveDefaultManifestPath(); let json = false; @@ -60,23 +67,27 @@ export function parseCrossRepoEvaluationArgs(argv) { return { manifestPath, json, repoFilter, requireMajority, fullExecution }; } -export function loadCrossRepoEvaluationManifest(manifestPath) { +export function loadCrossRepoEvaluationManifest(manifestPath: string): ParsedCrossRepoEvaluationManifest { const content = readFileSync(manifestPath, "utf8"); return parseCrossRepoEvaluationManifest(content); } -export function runCrossRepoEvaluationCli(options = {}) { +export function runCrossRepoEvaluationCli(options: CliOptions = {}) { const parsed = options.parsed ?? loadCrossRepoEvaluationManifest(options.manifestPath ?? resolveDefaultManifestPath()); - const results = runCrossRepoEvaluation(parsed, { repoFilter: options.repoFilter ?? null }); + // exactOptionalPropertyTypes: conditionally spread rather than always set repoFilter to a + // possibly-undefined/null value -- the target type's repoFilter?: string admits "absent", not + // "present and undefined". null and undefined are equally falsy at every downstream truthiness + // check either way, so this is a type-only normalization, not a behavior change. + const results = runCrossRepoEvaluation(parsed, options.repoFilter ? { repoFilter: options.repoFilter } : {}); const summary = summarizeCrossRepoEvaluation(results); return { parsed, results, summary }; } /** Full-execution counterpart of runCrossRepoEvaluationCli (#7634) — same shape, async because agent runs and * the benchmark repos' own test suites are. Dry-run: see runCrossRepoFullExecution. */ -export async function runCrossRepoFullExecutionCli(options = {}) { +export async function runCrossRepoFullExecutionCli(options: CliOptions = {}) { const parsed = options.parsed ?? loadCrossRepoEvaluationManifest(options.manifestPath ?? resolveDefaultManifestPath()); - const results = await runCrossRepoFullExecution(parsed, { repoFilter: options.repoFilter ?? null }); + const results = await runCrossRepoFullExecution(parsed, options.repoFilter ? { repoFilter: options.repoFilter } : {}); const summary = summarizeCrossRepoEvaluation(results); return { parsed, results, summary }; } @@ -87,7 +98,7 @@ function printHelp() { "loopover-miner cross-repo evaluation (#4788)", "", "Usage:", - " node packages/loopover-miner/scripts/cross-repo-evaluation.mjs [options]", + " node --experimental-strip-types packages/loopover-miner/scripts/cross-repo-evaluation.ts [options]", "", "Options:", " --manifest Benchmark manifest (default: benchmarks/cross-repo/manifest.json)", diff --git a/test/unit/iterate-loop-load-test-script.test.ts b/test/unit/iterate-loop-load-test-script.test.ts index 88533171bd..f3e90d56f1 100644 --- a/test/unit/iterate-loop-load-test-script.test.ts +++ b/test/unit/iterate-loop-load-test-script.test.ts @@ -9,7 +9,7 @@ import { formatLoadTestReport, runConcurrencyLevel, runLoadTest, -} from "../../packages/loopover-engine/scripts/load-test-iterate-loop.mjs"; +} from "../../packages/loopover-engine/scripts/load-test-iterate-loop"; describe("iterate-loop load-test script (#5224)", () => { it("the fake driver never spawns a real subprocess and resolves a scripted ok result after the configured latency", async () => { @@ -76,7 +76,7 @@ describe("iterate-loop load-test script (#5224)", () => { }); it("runs end-to-end as a CLI script and prints the report header plus every default concurrency level", () => { - const result = spawnSync(process.execPath, ["packages/loopover-engine/scripts/load-test-iterate-loop.mjs"], { + const result = spawnSync(process.execPath, ["--experimental-strip-types", "packages/loopover-engine/scripts/load-test-iterate-loop.ts"], { cwd: process.cwd(), encoding: "utf8", }); diff --git a/test/unit/miner-benchmark-script.test.ts b/test/unit/miner-benchmark-script.test.ts index 67d05cb309..c7ee90492b 100644 --- a/test/unit/miner-benchmark-script.test.ts +++ b/test/unit/miner-benchmark-script.test.ts @@ -7,7 +7,7 @@ import { formatBenchmarkReport, runLocalStoreBenchmark, runRankingBenchmark, -} from "../../packages/loopover-miner/scripts/benchmark.mjs"; +} from "../../packages/loopover-miner/scripts/benchmark"; describe("loopover-miner benchmark script (#4845)", () => { it("REGRESSION: synthetic candidate generation is byte-for-byte deterministic across calls", () => { @@ -72,7 +72,7 @@ describe("loopover-miner benchmark script (#4845)", () => { }); it("runs end-to-end as a CLI script and prints both benchmark lines", () => { - const result = spawnSync(process.execPath, ["packages/loopover-miner/scripts/benchmark.mjs"], { + const result = spawnSync(process.execPath, ["--experimental-strip-types", "packages/loopover-miner/scripts/benchmark.ts"], { cwd: process.cwd(), encoding: "utf8", }); diff --git a/test/unit/miner-cross-repo-evaluation.test.ts b/test/unit/miner-cross-repo-evaluation.test.ts index 5333b5b57a..aecd3ad22e 100644 --- a/test/unit/miner-cross-repo-evaluation.test.ts +++ b/test/unit/miner-cross-repo-evaluation.test.ts @@ -37,7 +37,7 @@ import { resolveDefaultManifestPath, runCrossRepoEvaluationCli, runCrossRepoFullExecutionCli, -} from "../../packages/loopover-miner/scripts/cross-repo-evaluation.mjs"; +} from "../../packages/loopover-miner/scripts/cross-repo-evaluation"; const roots: string[] = []; @@ -984,7 +984,7 @@ describe("cross-repo evaluation harness (#4788)", () => { const doc = readFileSync(join(process.cwd(), "packages/loopover-miner/docs/cross-repo-evaluation.md"), "utf8"); expect(doc).toContain("#4788"); expect(doc).toContain("stack_detection_gap"); - expect(doc).toContain("cross-repo-evaluation.mjs"); + expect(doc).toContain("cross-repo-evaluation.ts"); expect(doc).toContain("benchmarks/cross-repo/manifest.json"); // #7634: the full-execution mode and its execution-specific failure categories are documented too. expect(doc).toContain("--full-execution"); From ce4aa1c8ebd919561a0e120e849b17f612687d04 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:05:04 -0700 Subject: [PATCH 5/5] ci: build the miner CLI before Typecheck, not after benchmark.ts and cross-repo-evaluation.ts (packages/loopover-miner/scripts/) import from this package's OWN ../dist/lib/*.js -- real .ts since this PR's port, so Typecheck now actually opens and type-checks them, unlike when they were .mjs and invisible to tsc. "Build miner CLI" ran well after Typecheck (down by "Miner package check"), so a clean CI checkout hit Cannot find module on both scripts' dist imports every run. Same class of gap the contract/engine build-order fixes already cover, just for the miner package's own dist rather than a workspace dependency's -- moved the step ahead of Typecheck and widened its trigger to match Typecheck's exactly, for the same reason those two already match: any trigger that typechecks must also have built what typechecking reads. Refs #9527 --- .github/workflows/ci.yml | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92022f8e88..f31ee23de4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -499,6 +499,16 @@ jobs: - name: Engine package check if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.ui == 'true' }} run: npm run test:engine-pack + # Moved ahead of Typecheck (was originally positioned much later, alongside "Miner package check"): + # benchmark.ts and cross-repo-evaluation.ts (packages/loopover-miner/scripts/, real .ts since #9527's + # port) import from this package's OWN ../dist/lib/*.js, so Typecheck cannot resolve them until this + # has run -- the identical class of gap the contract/engine build-order comments above already + # describe, just for the miner package's own dist rather than a workspace dependency's. Trigger + # condition matches Typecheck's exactly (not just miner/engine) for the same reason those two match: + # any trigger that typechecks must also have built what typechecking reads. + - name: Build miner CLI + if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.miner == 'true' }} + run: npx turbo run build:tsc build:verify --filter=@loopover/miner # .tsbuildinfo mutates every run (tsc's own incremental state), unlike node_modules above which is # immutable per lockfile -- so this needs the run_id-suffixed-key + restore-keys-prefix pattern (always # creates a new cache entry to save into, restore falls back to the most recent matching prefix) rather @@ -567,25 +577,6 @@ jobs: - name: MCP package check if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' }} run: npm run test:mcp-pack - # Same "ships .ts only, this build is for the published npm tarball + the pack-check below, not for - # tests" story as MCP's own "Build MCP" step above. Invokes turbo.json's @loopover/miner#build:tsc - # (real caching win since the 2026-07-24 dist/ migration -- previously cache: false, historically to - # avoid a cache restore stomping this package's hand-written .js files while the #7290 migration was - # still in-flight; #7317 closed that out, and the dist/ split removed the original in-place-emit - # hazard entirely, so caching was finally enabled) and @loopover/miner#build:verify (real caching - # win: skips re-`node --check`-ing all bin/lib files when neither changed) DIRECTLY as their own task names, - # deliberately NOT via the aggregate `@loopover/miner#build` task (which exists for standalone/local - # `npm run build` callers) -- that aggregate's own script is `npm run build:tsc && npm run - # build:verify`, i.e. it re-invokes both of these exact scripts a second time even when turbo already - # ran them as cached prerequisite tasks (declaring a task as a dependsOn prerequisite doesn't replace - # a parent task's own script body). Calling the aggregate here would silently double both the tsc - # compile and the syntax check on every run, negating the caching win this comment describes -- - # confirmed by reproducing the double execution locally before switching to this direct form. This - # also finally activates the build:tsc/build:verify split from Phase 0, which no ci.yml step - # exercised until now. - - name: Build miner CLI - if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.engine == 'true' }} - run: npx turbo run build:tsc build:verify --filter=@loopover/miner # loopover-miner depends on @loopover/engine for real too (same relationship as loopover-mcp above # -- packages/loopover-miner/package.json lists it as a dependency), so this and "Build miner CLI" # above now also trigger on an engine-only PR, matching "Build MCP"/"MCP package check"'s existing