diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecac98ad0b..f31ee23de4 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. @@ -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 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-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/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/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/package.json b/package.json index b57ddb4563..5a985977e0 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", @@ -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", @@ -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-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.mjs b/packages/loopover-engine/scripts/load-test-iterate-loop.ts similarity index 74% rename from packages/loopover-engine/scripts/load-test-iterate-loop.mjs rename to packages/loopover-engine/scripts/load-test-iterate-loop.ts index 395c6c78ed..e420914781 100644 --- a/packages/loopover-engine/scripts/load-test-iterate-loop.mjs +++ 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-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/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/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 efc7b2209f..1c9668d880 100644 --- a/packages/loopover-miner/package.json +++ b/packages/loopover-miner/package.json @@ -40,11 +40,11 @@ "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 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/benchmark.mjs b/packages/loopover-miner/scripts/benchmark.ts similarity index 73% rename from packages/loopover-miner/scripts/benchmark.mjs rename to packages/loopover-miner/scripts/benchmark.ts index 0a7e3689fc..dc16df4c32 100644 --- a/packages/loopover-miner/scripts/benchmark.mjs +++ 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/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/packages/loopover-miner/scripts/cross-repo-evaluation.mjs b/packages/loopover-miner/scripts/cross-repo-evaluation.ts similarity index 74% rename from packages/loopover-miner/scripts/cross-repo-evaluation.mjs rename to packages/loopover-miner/scripts/cross-repo-evaluation.ts index 42285ef3d7..db5e122394 100644 --- a/packages/loopover-miner/scripts/cross-repo-evaluation.mjs +++ 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/packages/loopover-miner/scripts/generate-env-reference.mjs b/packages/loopover-miner/scripts/generate-env-reference.ts similarity index 88% rename from packages/loopover-miner/scripts/generate-env-reference.mjs rename to packages/loopover-miner/scripts/generate-env-reference.ts index 7b4e250bcd..145750ca1d 100644 --- a/packages/loopover-miner/scripts/generate-env-reference.mjs +++ 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,19 +129,19 @@ 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 .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; @@ -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/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..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.mjs"; +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/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/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/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"); diff --git a/test/unit/miner-env-reference-script.test.ts b/test/unit/miner-env-reference-script.test.ts index 81c5a82ab1..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.mjs"; +} 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/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/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/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(), 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([]); + }); +});