diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e18ce2aa4..1cac3e9f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,13 @@ on: pull_request: branches: [main] +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + HUSKY: "0" + jobs: pr-title: name: Conventional PR title @@ -23,77 +30,78 @@ jobs: process.exit(1); }' - typecheck: + changes: + name: Classify changes + runs-on: ubuntu-latest + outputs: + package: ${{ steps.package.outputs.required }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Select package and Windows lanes + id: package + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if [[ "${{ github.event_name }}" != "pull_request" ]]; then + echo "required=true" >> "${GITHUB_OUTPUT}" + elif git diff --quiet "${BASE_SHA}" "${HEAD_SHA}" -- \ + . \ + ':(exclude)docs/**' \ + ':(exclude)AGENTS.md' \ + ':(exclude)CONTRIBUTING.md'; then + echo "required=false" >> "${GITHUB_OUTPUT}" + else + echo "required=true" >> "${GITHUB_OUTPUT}" + fi + + static: + name: Static checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run check:ci + + typecheck-compatibility: name: Typecheck + needs: static + if: ${{ always() }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: npm - - run: npm ci - - run: npm run typecheck + - name: Require successful static checks + env: + STATIC_RESULT: ${{ needs.static.result }} + run: test "${STATIC_RESULT}" = "success" - lint: + lint-compatibility: name: Lint (oxlint) + needs: static + if: ${{ always() }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: npm - - run: npm ci - - run: npm run lint + - name: Require successful static checks + env: + STATIC_RESULT: ${{ needs.static.result }} + run: test "${STATIC_RESULT}" = "success" - format: + format-compatibility: name: Format Check + needs: static + if: ${{ always() }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: npm - - run: npm ci - - run: npm run format:check - - dead-code: - name: Dead Code (knip) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: npm - - run: npm ci - - run: npm run lint:dead - - duplicates: - name: Duplicate Code (jscpd) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: npm - - run: npm ci - - run: npm run jscpd - - tech-debt: - name: Tech Debt Scan - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: npm - - run: npm ci - - run: npm run scan:todos + - name: Require successful static checks + env: + STATIC_RESULT: ${{ needs.static.result }} + run: test "${STATIC_RESULT}" = "success" test: name: Test (coverage + timing) @@ -105,7 +113,7 @@ jobs: node-version-file: .nvmrc cache: npm - run: npm ci - - run: npm run test:coverage -- --reporter=verbose --reporter=junit --outputFile.junit=test-results.xml + - run: npm run test:ci - uses: actions/upload-artifact@v4 if: always() with: @@ -115,6 +123,8 @@ jobs: package-e2e: name: Package E2E (${{ matrix.os }}) + needs: changes + if: github.event_name == 'push' || needs.changes.outputs.package == 'true' strategy: fail-fast: false matrix: @@ -131,6 +141,8 @@ jobs: windows-curated: name: Windows Ghidra P0 and package lane + needs: changes + if: github.event_name == 'push' || needs.changes.outputs.package == 'true' runs-on: windows-latest timeout-minutes: 25 steps: @@ -140,7 +152,7 @@ jobs: node-version-file: .nvmrc cache: npm - run: npm ci - - run: npm run build + - run: npm run build:cached - run: npm run typecheck - name: Record named Windows host capabilities run: node scripts/windows-capabilities.mjs | Out-File -Encoding utf8NoBOM windows-capabilities.json diff --git a/.github/workflows/real-browser.yml b/.github/workflows/real-browser.yml index f57b1a401..6244e2e86 100644 --- a/.github/workflows/real-browser.yml +++ b/.github/workflows/real-browser.yml @@ -13,6 +13,13 @@ on: - "package-lock.json" workflow_dispatch: +concurrency: + group: real-browser-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + HUSKY: "0" + permissions: contents: read diff --git a/.github/workflows/real-ghidra-windows.yml b/.github/workflows/real-ghidra-windows.yml index 27f9e65c7..af6a5819e 100644 --- a/.github/workflows/real-ghidra-windows.yml +++ b/.github/workflows/real-ghidra-windows.yml @@ -4,6 +4,9 @@ on: repository_dispatch: types: [real-ghidra-windows] +env: + HUSKY: "0" + permissions: contents: read diff --git a/.github/workflows/real-hopper-linux.yml b/.github/workflows/real-hopper-linux.yml index cf8b6bd8b..2355229de 100644 --- a/.github/workflows/real-hopper-linux.yml +++ b/.github/workflows/real-hopper-linux.yml @@ -3,6 +3,9 @@ name: Real Hopper Linux E2E on: workflow_dispatch: +env: + HUSKY: "0" + permissions: contents: read diff --git a/.github/workflows/real-hopper.yml b/.github/workflows/real-hopper.yml index a750f36e9..51135f4b6 100644 --- a/.github/workflows/real-hopper.yml +++ b/.github/workflows/real-hopper.yml @@ -3,6 +3,9 @@ name: Real Hopper E2E on: workflow_dispatch: +env: + HUSKY: "0" + permissions: contents: read diff --git a/.github/workflows/real-javascript-replay.yml b/.github/workflows/real-javascript-replay.yml index 2fe450e41..9181555f4 100644 --- a/.github/workflows/real-javascript-replay.yml +++ b/.github/workflows/real-javascript-replay.yml @@ -13,6 +13,13 @@ on: - "package-lock.json" workflow_dispatch: +concurrency: + group: real-replay-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + HUSKY: "0" + permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10590c360..4d094001d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: branches: [main] +env: + HUSKY: "0" + permissions: contents: write pull-requests: write diff --git a/AGENTS.md b/AGENTS.md index 9112d5e10..1ec6b4142 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,14 +50,20 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec - `npm ci`: install exact lockfile dependencies. - `npm run deps:check`: verify that installed direct dependency versions match the root lockfile; on failure, run `npm ci`, then retry. - `npm run metadata:generate`: refresh checked-in package metadata after package, lockfile, or skill-version changes. +- `npm run metadata:check`: verify checked-in package metadata without rewriting it. - `npm run build`: compile `src/` into `dist/`. - `npm run build:cached`: compile through Turbo and reuse a valid local build cache entry. -- `npm test`: build, then run the Vitest suite once. +- `npm test`: restore or build `dist/`, then run the Vitest suite once without coverage or retries locally. +- `npm run test:fast`: run the parallel test project without the serial integration group. +- `npm run test:integration`: run the serial filesystem, process, and CLI integration group. +- `npm run test:watch`: watch tests affected by working-tree changes in the parallel project, without coverage. +- `npm run test:coverage`: run the complete suite with coverage; CI owns verbose and JUnit reporting. - `npm run typecheck`: run strict TypeScript checks without emitting files. - `npm run lint`: apply oxlint rules (complexity, max-lines, unused vars, and TypeScript-specific checks). - `npm run lint:fix`: auto-fix oxlint violations where possible. - `npm run format:check`: verify Prettier formatting. -- `npm run check`: run cached typecheck, lint, format:check, and knip tasks. +- `npm run check`: run cached typecheck, lint, format:check, knip, and package-metadata freshness tasks. +- `npm run check:ci`: run the static gate plus duplicate-code and debt-marker scans. - `npm run check:test`: run the cached static tasks plus the uncached complete test suite. - `npm run check:pr`: run the test gate plus uncached generated-document validation for PR preparation. - `npm run knip`: detect unused files, dependencies, and exports. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ff335430..8673efacb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,10 +12,10 @@ npm test ``` `npm ci` installs the exact dependencies and prepares the Husky hooks without -building the project. Run `npm run build` when you need the standalone CLI or -MCP server. Turbo caches deterministic local builds and static checks across -Git worktrees; use `npm run build:cached` when you want that cache. After a -package, lockfile, or managed-skill version change, run +building the project. Run `npm run build:cached` when you need the standalone +CLI or MCP server; `npm run build` remains the uncached compiler leaf used by +Turbo. Turbo caches deterministic builds and static checks across Git +worktrees. After a package, lockfile, or managed-skill version change, run `npm run metadata:generate` before building. Keep dependencies flowing inward through the existing domain, contracts, provider, application, server, and adapter layers. Parse unknown values at process and protocol boundaries, model expected failures with `Result`, and preserve the canonical tool inventory defined by `TOOL_CONTRACTS` unless a deliberate contract change updates every verifier, generated catalog artifact, and snapshot. Prefer capability- and session-scoped tool advertisement over schema truncation. @@ -28,15 +28,26 @@ npm run verify:package npm pack --dry-run ``` -`npm run check` runs the cached typecheck, lint, formatting, and dead-code tasks; -`check:fast` remains as an alias. Use `npm run check:test` when the complete test -suite is relevant. Pre-commit formats and lints only staged files, and pre-push -runs `check`. `check:pr` additionally renders API documentation and checks all -committed generated metadata. Real provider and replay verifiers remain outside -Turbo's cache. CI uploads the rendered TypeDoc site as an `api-docs` artifact; -the generated HTML is not committed. The normal test and CI coverage runs -enforce the thresholds in `vitest.config.ts`; `npm run lint:dead` rejects unused -files, exports, and dependencies. +`npm run check` runs cached typecheck, lint, formatting, dead-code, and package +metadata freshness tasks; `check:fast` remains as an alias. Use `npm run +check:test` when the complete test suite is relevant. Pre-commit formats and +lints only staged files, and pre-push runs `check`. `check:pr` additionally +renders API documentation and checks all committed generated metadata. Real +provider and replay execution remains uncached, but their deterministic build +prerequisite uses Turbo. CI uploads the rendered TypeDoc site as an `api-docs` +artifact; the generated HTML is not committed. + +Local `npm test` runs without coverage, retries, or verbose output. Use `npm run +test:fast` for the parallel group, `npm run test:integration` for serial +filesystem/process/CLI cases, and `npm run test:watch` to watch tests affected by +working-tree changes. CI runs `npm run test:ci`, which adds coverage, retries, +verbose output, and JUnit results. Coverage thresholds remain in +`vitest.config.ts`. + +CI installs dependencies once for all static checks, cancels superseded PR +runs, and skips package and Windows lanes when a PR changes only files outside +the package/runtime/test boundary. TypeDoc renders only in pull-request CI; it +does not run in local commit or pre-push hooks, or in post-merge `main` CI. Tests that need a temporary directory must use `createTestTempDirectory` from `tests/fixtures/temporaryDirectory.ts`. The diff --git a/package.json b/package.json index 4219c0488..74133f2d1 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "bridge/ghidra/ReaGhidraBridge.java", "scripts/rea.mjs", "scripts/hopper-demo-x11.py", + "scripts/prepack.mjs", + "scripts/prepare.mjs", "skills", "README.md", "LICENSE" @@ -51,16 +53,18 @@ "scripts": { "deps:check": "node scripts/check-dependency-install.mjs", "metadata:generate": "node scripts/generate-package-metadata.mjs", - "prebuild": "npm run deps:check && node scripts/generate-package-metadata.mjs --check", + "metadata:check": "node scripts/generate-package-metadata.mjs --check", + "prebuild": "npm run deps:check && npm run metadata:check", "build": "tsc -p tsconfig.build.json", - "postbuild": "node scripts/record-build-cache.mjs", "precheck": "npm run deps:check", - "check": "turbo run typecheck lint format:check knip", + "check": "turbo run typecheck lint format:check knip metadata:check", "check:fast": "npm run check", "precheck:test": "npm run deps:check", - "check:test": "turbo run typecheck lint format:check knip test:run", + "check:test": "turbo run typecheck lint format:check knip metadata:check test:run", "precheck:pr": "npm run deps:check", - "check:pr": "turbo run typecheck lint format:check knip test:run docs:check:built", + "check:pr": "turbo run typecheck lint format:check knip metadata:check test:run docs:check:built", + "precheck:ci": "npm run deps:check", + "check:ci": "turbo run typecheck lint format:check knip metadata:check jscpd scan:todos", "build:cached": "turbo run build", "format": "prettier --write .", "format:check": "prettier --check .", @@ -71,42 +75,49 @@ "jscpd": "jscpd src/ tests/", "scan:todos": "node scripts/scan-todos.mjs", "start": "node scripts/rea.mjs mcp", - "pretest": "npm run build", + "pretest": "npm run build:cached", "test": "vitest run", "test:run": "vitest run", - "pretest:coverage": "npm run build", + "pretest:fast": "npm run build:cached", + "test:fast": "vitest run --project parallel", + "pretest:integration": "npm run build:cached", + "test:integration": "vitest run --project serial-integration", + "pretest:coverage": "npm run build:cached", "test:coverage": "vitest run --coverage.enabled --coverage.provider=v8", + "test:ci": "npm run test:coverage -- --reporter=verbose --reporter=junit --outputFile.junit=test-results.xml", + "pretest:verbose": "npm run build:cached", "test:verbose": "vitest run --reporter=verbose", - "test:watch": "vitest", + "pretest:watch": "npm run build:cached", + "test:watch": "vitest --project parallel --changed", "typecheck": "tsc --noEmit", - "verify:test-temp-hygiene": "npm run build && node scripts/verify-test-temp-hygiene.mjs", - "verify:hopper": "npm run verify:fixtures && npm run build && node scripts/verify-real-hopper.mjs", - "verify:ghidra": "npm run build && node scripts/verify-real-ghidra.mjs", - "verify:ghidra:windows": "npm run build && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs", - "verify:browser": "npm run build && node scripts/verify-real-browser.mjs", - "verify:javascript-runtime": "npm run build && node scripts/verify-local-javascript-runtime.mjs", - "verify:application-workflows": "npm run build && node scripts/verify-local-application-workflows.mjs", - "verify:managed": "npm run build && node scripts/verify-managed-conformance.mjs", - "verify:replay": "npm run build && node scripts/verify-real-javascript-replay.mjs", - "verify:agent": "npm run build && node scripts/verify-agent-experience.mjs", - "verify:hopper:linux": "npm run verify:fixtures && npm run build && node scripts/verify-real-hopper-linux.mjs", - "verify:setup:linux": "npm run build && node scripts/verify-linux-setup-e2e.mjs", + "verify:test-temp-hygiene": "npm run build:cached && node scripts/verify-test-temp-hygiene.mjs", + "verify:hopper": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper.mjs", + "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", + "verify:ghidra:windows": "npm run build:cached && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs", + "verify:browser": "npm run build:cached && node scripts/verify-real-browser.mjs", + "verify:javascript-runtime": "npm run build:cached && node scripts/verify-local-javascript-runtime.mjs", + "verify:application-workflows": "npm run build:cached && node scripts/verify-local-application-workflows.mjs", + "verify:managed": "npm run build:cached && node scripts/verify-managed-conformance.mjs", + "verify:replay": "npm run build:cached && node scripts/verify-real-javascript-replay.mjs", + "verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs", + "verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs", + "verify:setup:linux": "npm run build:cached && node scripts/verify-linux-setup-e2e.mjs", "build:fixtures": "node scripts/build-conformance-fixtures.mjs", "build:ghidra-windows-fixture": "node scripts/create-ghidra-windows-fixture.mjs", "verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs", - "verify:package": "npm run build && node scripts/verify-package.mjs", - "verify:package:windows": "npm run build && node scripts/verify-windows-package.mjs", - "probe:windows-capabilities": "npm run build && node scripts/windows-capabilities.mjs", + "verify:package": "npm run build:cached && node scripts/verify-package.mjs", + "verify:package:windows": "npm run build:cached && node scripts/verify-windows-package.mjs", + "probe:windows-capabilities": "npm run build:cached && node scripts/windows-capabilities.mjs", "verify:published": "node scripts/verify-published-package.mjs", "config:print": "node scripts/print-mcp-config.mjs", "docs:api": "typedoc", - "docs:generate": "npm run metadata:generate && npm run build && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && npm run docs:api && node scripts/generate-error-schema.mjs", - "docs:check": "npm run build && npm run docs:check:built", + "docs:generate": "npm run metadata:generate && npm run build:cached && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && npm run docs:api && node scripts/generate-error-schema.mjs", + "docs:check": "turbo run docs:check:built", "docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/check-doc-facts.mjs && npm run docs:api && node scripts/generate-error-schema.mjs --check", - "docs:error-schema": "npm run build && node scripts/generate-error-schema.mjs", - "prepack": "npm run build && node scripts/generate-skill-metadata.mjs --check", + "docs:error-schema": "npm run build:cached && node scripts/generate-error-schema.mjs", + "prepack": "node scripts/prepack.mjs", "prepublishOnly": "npm run deps:check && node scripts/generate-skill-metadata.mjs --check", - "prepare": "husky" + "prepare": "node scripts/prepare.mjs" }, "lint-staged": { "*.{ts,mjs,json,md,yml,yaml}": "prettier --write", diff --git a/scripts/build-cache.mjs b/scripts/build-cache.mjs deleted file mode 100644 index 9ab4bfc9d..000000000 --- a/scripts/build-cache.mjs +++ /dev/null @@ -1,103 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -const CACHE_DIRECTORY = ["node_modules", ".cache"]; -const BUILD_INFO_NAME = "rea-tsconfig-build.tsbuildinfo"; -const MANIFEST_NAME = "rea-tsconfig-build-outputs.json"; - -const isMissing = (cause) => - typeof cause === "object" && - cause !== null && - "code" in cause && - cause.code === "ENOENT"; - -const outputFiles = async (root) => { - const sourceRoot = join(root, "src"); - const entries = await readdir(sourceRoot, { recursive: true }); - return entries - .filter((entry) => entry.endsWith(".ts") && !entry.endsWith(".d.ts")) - .sort() - .map((entry) => { - const output = `${entry.slice(0, -3)}.js`; - return { output, path: join(root, "dist", output) }; - }); -}; - -const digest = async (path) => - createHash("sha256") - .update(await readFile(path)) - .digest("hex"); - -const cachePath = (root, name) => join(root, ...CACHE_DIRECTORY, name); - -const invalidate = async (root) => { - await Promise.all([ - rm(cachePath(root, BUILD_INFO_NAME), { force: true }), - rm(cachePath(root, MANIFEST_NAME), { force: true }), - ]); -}; - -const readManifest = async (root) => { - try { - const parsed = JSON.parse( - await readFile(cachePath(root, MANIFEST_NAME), "utf8"), - ); - if ( - typeof parsed !== "object" || - parsed === null || - parsed.version !== 1 || - typeof parsed.outputs !== "object" || - parsed.outputs === null || - Array.isArray(parsed.outputs) - ) - return undefined; - return parsed.outputs; - } catch (cause) { - if (isMissing(cause) || cause instanceof SyntaxError) return undefined; - throw cause; - } -}; - -/** Invalidates incremental state when generated JavaScript was removed or changed. */ -export const validateBuildCache = async (root) => { - const [files, manifest] = await Promise.all([ - outputFiles(root), - readManifest(root), - ]); - if (manifest === undefined || Object.keys(manifest).length !== files.length) { - await invalidate(root); - return; - } - const matches = await Promise.all( - files.map(async ({ output, path }) => { - if (typeof manifest[output] !== "string") return false; - try { - return (await digest(path)) === manifest[output]; - } catch (cause) { - if (isMissing(cause)) return false; - throw cause; - } - }), - ); - if (!matches.every(Boolean)) await invalidate(root); -}; - -/** Records generated JavaScript so a later incremental build can verify its cache. */ -export const recordBuildCache = async (root) => { - const files = await outputFiles(root); - const entries = await Promise.all( - files.map(async ({ output, path }) => [output, await digest(path)]), - ); - const source = `${JSON.stringify({ version: 1, outputs: Object.fromEntries(entries) })}\n`; - const directory = join(root, ...CACHE_DIRECTORY); - await mkdir(directory, { recursive: true }); - const path = cachePath(root, MANIFEST_NAME); - let existing; - try { - existing = await readFile(path, "utf8"); - } catch (cause) { - if (!isMissing(cause)) throw cause; - } - if (existing !== source) await writeFile(path, source, "utf8"); -}; diff --git a/scripts/generate-package-metadata.mjs b/scripts/generate-package-metadata.mjs index 9e17dfe5f..2a2336839 100644 --- a/scripts/generate-package-metadata.mjs +++ b/scripts/generate-package-metadata.mjs @@ -2,7 +2,6 @@ import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { validateBuildCache } from "./build-cache.mjs"; import { ensureGeneratedFile } from "./lib/generated-file.mjs"; const arguments_ = new Set(process.argv.slice(2)); @@ -47,4 +46,3 @@ await ensureGeneratedFile({ check: arguments_.has("--check"), generateCommand: "npm run metadata:generate", }); -await validateBuildCache(root); diff --git a/scripts/prepack.mjs b/scripts/prepack.mjs new file mode 100644 index 000000000..c085449eb --- /dev/null +++ b/scripts/prepack.mjs @@ -0,0 +1,55 @@ +import { spawnSync } from "node:child_process"; +import { access } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +const pathExists = async (path) => { + try { + await access(path); + return true; + } catch (cause) { + if ( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ENOENT" + ) + return false; + throw cause; + } +}; + +const run = (command, args) => { + const result = spawnSync(command, args, { + cwd: root, + encoding: "utf8", + maxBuffer: 4 * 1_024 * 1_024, + }); + if (result.error !== undefined) throw result.error; + if (result.status !== 0) { + if (result.stdout.length > 0) process.stdout.write(result.stdout); + if (result.stderr.length > 0) process.stderr.write(result.stderr); + throw new Error( + `Prepack command failed with status ${String(result.status)}: ${command} ${args.join(" ")}`, + ); + } +}; + +if (await pathExists(join(root, "src"))) { + const packageManagerPath = process.env.npm_execpath; + if (packageManagerPath === undefined) + throw new Error("Prepack could not resolve the active package manager"); + run(process.execPath, [packageManagerPath, "run", "build:cached"]); + run(process.execPath, ["scripts/generate-skill-metadata.mjs", "--check"]); +} else { + for (const path of [ + "dist/main.js", + "dist/mcpDoctor.js", + "dist/cli.js", + "dist/cliOutput.js", + "skills/reverse-engineer-anything/SKILL.md", + ]) + await access(join(root, path)); +} diff --git a/scripts/prepare.mjs b/scripts/prepare.mjs new file mode 100644 index 000000000..9cea840b4 --- /dev/null +++ b/scripts/prepare.mjs @@ -0,0 +1,28 @@ +import { access } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +const sourceHuskyIsAvailable = async () => { + try { + await access(join(root, "src")); + await access(join(root, "node_modules", "husky", "index.js")); + return true; + } catch (cause) { + if ( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ENOENT" + ) + return false; + throw cause; + } +}; + +if (process.env.HUSKY !== "0" && (await sourceHuskyIsAvailable())) { + const { default: installHusky } = await import("husky"); + const message = installHusky(); + if (message.length > 0) process.stdout.write(`${message}\n`); +} diff --git a/scripts/record-build-cache.mjs b/scripts/record-build-cache.mjs deleted file mode 100644 index 06b0ed8ad..000000000 --- a/scripts/record-build-cache.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { recordBuildCache } from "./build-cache.mjs"; - -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -await recordBuildCache(root); diff --git a/scripts/verify-package-install.mjs b/scripts/verify-package-install.mjs index aed805e02..da2e79f24 100644 --- a/scripts/verify-package-install.mjs +++ b/scripts/verify-package-install.mjs @@ -100,5 +100,17 @@ export async function verifyPackageInstall({ throw new Error( `packaged CLI did not degrade without the optional PTY binary: ${JSON.stringify(noOptionalCapability)}`, ); + await verifyInstalledPackageCanRepack(prefix, environment); return { cli, packageRunnerCli }; } + +const verifyInstalledPackageCanRepack = async (prefix, environment) => { + const installedPackageRoot = join(prefix, "lib/node_modules/rea-agents"); + const repackedTarball = ( + await exec("npm", ["pack", "--silent"], { + cwd: installedPackageRoot, + env: environment, + }) + ).stdout.trim(); + await rm(join(installedPackageRoot, repackedTarball), { force: true }); +}; diff --git a/tests/identity.test.ts b/tests/identity.test.ts index c7ad54362..db3f43b8d 100644 --- a/tests/identity.test.ts +++ b/tests/identity.test.ts @@ -13,6 +13,7 @@ describe("package identity", () => { bin: z.record(z.string(), z.string()), scripts: z.object({ start: z.string(), + "metadata:check": z.string(), prebuild: z.string(), prepare: z.string(), prepack: z.string(), @@ -42,13 +43,14 @@ describe("package identity", () => { `npx -y ${packageJson.name}@${PRODUCT_IDENTITY.packageVersion} mcp`, ); expect(packageJson.scripts.start).toBe("node scripts/rea.mjs mcp"); + expect(packageJson.scripts["metadata:check"]).toBe( + "node scripts/generate-package-metadata.mjs --check", + ); expect(packageJson.scripts.prebuild).toBe( - "npm run deps:check && node scripts/generate-package-metadata.mjs --check", - ); - expect(packageJson.scripts.prepare).toBe("husky"); - expect(packageJson.scripts.prepack).toBe( - "npm run build && node scripts/generate-skill-metadata.mjs --check", + "npm run deps:check && npm run metadata:check", ); + expect(packageJson.scripts.prepare).toBe("node scripts/prepare.mjs"); + expect(packageJson.scripts.prepack).toBe("node scripts/prepack.mjs"); expect(packageJson.scripts.prepublishOnly).toBe( "npm run deps:check && node scripts/generate-skill-metadata.mjs --check", ); diff --git a/tests/packageInstallWorkflow.test.ts b/tests/packageInstallWorkflow.test.ts index a1b845d41..b1ed2a5a1 100644 --- a/tests/packageInstallWorkflow.test.ts +++ b/tests/packageInstallWorkflow.test.ts @@ -18,6 +18,12 @@ describe("package installation workflows", () => { expect(continuousIntegration).toContain("os: [ubuntu-latest, macos-14]"); expect(continuousIntegration).toContain("npm run verify:package"); + expect(continuousIntegration).toContain("name: Static checks"); + expect(continuousIntegration).toContain("npm run check:ci"); + expect(continuousIntegration).toContain("needs: changes"); + expect(continuousIntegration).toContain( + "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", + ); for (const workflow of [ continuousIntegration, realHopperLinux, diff --git a/tests/vitestConfiguration.test.ts b/tests/vitestConfiguration.test.ts index 4cee6b15b..ea0d6943f 100644 --- a/tests/vitestConfiguration.test.ts +++ b/tests/vitestConfiguration.test.ts @@ -1,15 +1,29 @@ import { execFile } from "node:child_process"; import { readdir } from "node:fs/promises"; +import { availableParallelism } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { SERIAL_INTEGRATION_TESTS } from "../vitest.config.js"; +import vitestConfiguration, { + SERIAL_INTEGRATION_TESTS, +} from "../vitest.config.js"; const execute = promisify(execFile); describe("Vitest project configuration", () => { + it("keeps coverage and noisy reporting out of the local test loop", () => { + const continuousIntegration = process.env.CI === "true"; + + expect(vitestConfiguration.test?.coverage?.enabled).toBe(false); + expect(vitestConfiguration.test?.reporters).toEqual(["default"]); + expect(vitestConfiguration.test?.retry).toBe(continuousIntegration ? 2 : 0); + expect(vitestConfiguration.test?.maxWorkers).toBe( + Math.min(2, availableParallelism()), + ); + }); + it("collects exact disjoint parallel and serial test sets", async () => { const { stdout } = await execute( process.execPath, diff --git a/tsconfig.build.json b/tsconfig.build.json index f58161bd5..49b57cbfe 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,9 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "incremental": true, "rootDir": "src", - "tsBuildInfoFile": "node_modules/.cache/rea-tsconfig-build.tsbuildinfo", "types": ["node"] }, "include": ["src/**/*.ts"], diff --git a/turbo.json b/turbo.json index e4fcfb224..edd4091f1 100644 --- a/turbo.json +++ b/turbo.json @@ -4,10 +4,11 @@ "build": { "inputs": [ ".nvmrc", - "bridge/**", "package.json", "package-lock.json", - "scripts/**", + "scripts/check-dependency-install.mjs", + "scripts/generate-package-metadata.mjs", + "scripts/lib/generated-file.mjs", "skills/reverse-engineer-anything/SKILL.md", "src/**/*.ts", "tsconfig.json", @@ -27,9 +28,21 @@ "vitest.config.ts" ] }, + "metadata:check": { + "inputs": [ + "package.json", + "package-lock.json", + "scripts/generate-package-metadata.mjs", + "scripts/lib/generated-file.mjs", + "skills/reverse-engineer-anything/SKILL.md", + "src/generatedPackageMetadata.ts" + ] + }, "lint": {}, "format:check": {}, "knip": {}, + "jscpd": {}, + "scan:todos": {}, "test:run": { "dependsOn": ["build"], "cache": false diff --git a/vitest.config.ts b/vitest.config.ts index cb26f76ed..f7c53f04b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,9 @@ import { join } from "node:path"; import { defineConfig } from "vitest/config"; export const TEST_FILES = ["tests/**/*.test.ts"]; +const IS_CONTINUOUS_INTEGRATION = process.env.CI === "true"; +// Subprocess-heavy parallel tests exceed their existing deadlines above two workers. +const MAX_TEST_WORKERS = 2; const PROCESS_CAPTURE_TEST = "tests/processCapture.test.ts"; const CANONICAL_TEMPORARY_DIRECTORY = realpathSync(tmpdir()); export const SERIAL_INTEGRATION_TESTS = [ @@ -17,7 +20,7 @@ export const SERIAL_INTEGRATION_TESTS = [ "tests/runtime.test.ts", ]; const isolatedProjects = { - maxWorkers: Math.min(2, availableParallelism()), + maxWorkers: Math.min(MAX_TEST_WORKERS, availableParallelism()), projects: [ { extends: true as const, @@ -44,10 +47,10 @@ export default defineConfig({ test: { env: { TMPDIR: CANONICAL_TEMPORARY_DIRECTORY }, ...isolatedProjects, - retry: 2, - reporters: ["default", "verbose"], + retry: IS_CONTINUOUS_INTEGRATION ? 2 : 0, + reporters: ["default"], coverage: { - enabled: true, + enabled: false, provider: "v8", reportsDirectory: join( tmpdir(),