# Check 3 — Task caching

Question: will a cache hit ever replay a wrong result, and are tasks re-running when they should hit?
Output: `findings/caching.json`. If no orchestrator is detected, only `caching/uncached-root-script` applies; set status `ok` with reason `no task orchestrator`.

## Facts the rules rely on

- **Turborepo**: a task with no `outputs` caches only logs — on a hit, `dist/` is NOT restored, so downstream tasks see stale or missing files. A package's hash covers its own files, its `package.json`, `turbo.json` and the lockfile; changes in workspace dependencies reach a task's hash **only** through `dependsOn` entries with `^` (e.g. `^build`). Env vars affect the hash only when listed in `env`, `globalEnv` (or `passThroughEnv` for runtime-only); framework prefixes such as `NEXT_PUBLIC_`, `VITE_` are auto-included. Turbo 2 strict env mode can also restrict which env vars are visible to tasks, so undeclared env is both a cache-key and runtime-visibility risk.
- **Nx**: when `outputs` is omitted, Nx caches `{workspaceRoot}/dist/{projectRoot}`, `{projectRoot}/dist`, `{projectRoot}/build`, `{projectRoot}/public`. Env vars affect the hash only through an `{ "env": "NAME" }` entry in `inputs`. Without a `production` named input that excludes tests, editing a spec file invalidates the build cache.

## Rules

| id | severity | detect |
|---|---|---|
| `caching/missing-outputs` | high | **Turbo**: a cacheable task named `build`/`compile`/`bundle`/`*:build` (or whose package script runs `tsc`, `vite build`, `tsup`, `rollup`, `webpack`, `next build`, `esbuild`) has no `outputs`. Package-level `turbo.json` overrides count. **Nx**: cacheable build-like target without `outputs` whose real out dir (`outDir` in the build tsconfig, `outDir`/`--out-dir` flags, `build.outDir` in vite config) is not one of Nx's defaults — one finding per affected project, with `package`. Turbo findings are per task: `location.file: "turbo.json"`, no `package`. |
| `caching/build-inputs-include-tests` | low | **Turbo**: build-like task has no `inputs`, or `inputs` that don't exclude test globs (`!**/*.test.*`, `!**/*.spec.*`, `!**/__tests__/**`). **Nx**: no `production` named input, or `build` inputs don't reference it, or `production` doesn't negate test globs. Low severity because `$TURBO_DEFAULT$` already covers all git-tracked files; the finding is about avoiding rebuilds when tests change, not correctness. Follow `dependsOn` chains: tests hashed by a dependency task (e.g. a `transit` task, or `^build` of a package whose build inputs include tests) also invalidate the build. Measure impact (step 3) and put it in `evidence`. |
| `caching/undeclared-env` | medium | for each `scan.env` entry that can affect a cacheable task output. Flag build tasks only when the build inlines env (Next/Vite framework bundlers, webpack/Rspack/Rsbuild `define`, esbuild/tsup `define`, Rollup/plugin-replace, or similar bundlers), not runtime-only reads in a plain `tsc` service. Always flag cacheable test tasks when tests execute code that reads the env var (`scan.env.isTest`, or a test file in the package imports the module with the env read), because a cached pass can be replayed under a different value. **Turbo**: name not matched by the task's `env` or `globalEnv` (`*` wildcards; `!` negation) and not framework-prefixed. `passThroughEnv` / `globalPassThroughEnv` do **not** count: they make the var available without adding it to the hash, so a cached result can still be replayed under a different value. **Nx**: no `{ "env": NAME }` in the target's inputs (resolve named inputs). Ignore `CI`, `PATH`, `HOME`, `TZ`, `PWD`. Ignore `NODE_ENV` only when the scan confirms it cannot affect the output (e.g. the task's script or bundler sets it to a fixed value); otherwise treat it like any other name. One finding per task config (Turbo task, or Nx target/targetDefault) that is missing declarations: list every missing name with its packages and up to 5 locations across all names, and set `location.package` only when a single package is affected. |
| `caching/turbo-cache-disabled` | low | Turbo task with `cache: false` that is deterministic by name: `build`, `compile`, `bundle`, `lint`, `typecheck`, `check-types`, `format:check`, or a unit `test` (`test`, `test:unit`, `*:build`, `*:lint`). Treat `cache: false` as deliberate — and do **not** flag — when the task is `persistent: true`; its name contains `dev`, `start`, `serve`, `watch`, `deploy`, `release`, `publish`, `clean`, `db`, `migrat`, `seed`, `generate`, `extract`, `sync`, `upload`, `e2e`, `integration`, or ends in `:ci`; or its `env`/`passThroughEnv` lists external-state vars (`*DATABASE*`, `*DB_*`, `*REDIS*`, `*_URL`, `*TOKEN*`, `*SECRET*`). Mention deliberately uncached tasks in `reason`, not as findings. |
| `caching/unstable-hash` | high | turbo installed: run `TURBO_TELEMETRY_DISABLED=1 npx turbo run build --dry=json` twice; any task whose `hash` differs between runs. Report the task and the `inputs`/`environmentVariables` that differ. |
| `caching/volatile-input` | high | non-negated task `inputs` or `globalDependencies` include build artifacts or machine-local files: `dist`, `build`, `.turbo`, `.nx`, `*.tsbuildinfo`, `**/*.log`, `node_modules`. Ignore negated globs (`!…`). Do not flag `.env*` files: including env files usually makes caches more correct. |
| `caching/uncached-root-script` | low | a root `package.json` script that runs tasks across packages bypassing the orchestrator: `-r`/`--recursive`, `--workspaces`/`-ws`, `yarn workspaces foreach`, `lerna run`. In repos **without** an orchestrator, say "consider adding Nx or Turborepo" in `fix`. |

## Steps

1. Read `turbo.json` (Turbo v2 `tasks` and Turbo v1 `pipeline`, plus `//#task` root tasks and `<pkg>/turbo.json` overrides with `"extends": ["//"]`) and/or `nx.json` + every `project.json` / `package.json#nx`. For Nx, also read `targetDefaults` (including defaults keyed by executor), `namedInputs`, and legacy `tasksRunnerOptions.*.options.cacheableOperations`, and remember that Nx plugins (`plugins` in `nx.json`) can infer targets — if a target exists only via plugin inference, report rules that depend on its config as `partial` and say so.
2. Apply the rules above. Quote the exact JSON in `evidence` (task name + its config object). Config-level findings (Turbo tasks, Nx `targetDefaults`/`namedInputs`) omit `location.package`; only set `package` when the problem is specific to one package (e.g. its own `turbo.json`/`project.json`, or an env read in its source).
3. For `build-inputs-include-tests`, measure how often it matters with read-only git: `git log --since="6 months ago" --name-only --format=%x00%H` and count commits whose changed files are all test files (`TEST` pattern from scan.md). Report `N of M commits (P%) changed only tests`. Skip if the repo has no git history.
4. `unstable-hash` runs only when turbo is installed; otherwise mark the check `partial` with reason `turbo not installed; dry-run hash comparison skipped`. Never run real tasks — only `--dry=json`.
5. Write `findings/caching.json`.

## Fix guidance

- missing-outputs (Turbo): `"outputs": ["dist/**"]` (add `".next/**", "!.next/cache/**"` for Next.js).
- missing-outputs (Nx): `"outputs": ["{projectRoot}/lib"]` matching the real out dir.
- build-inputs-include-tests (Nx): `"namedInputs": { "production": ["default", "!{projectRoot}/**/*.spec.ts", "!{projectRoot}/**/*.test.ts"] }` and `"build": { "inputs": ["production", "^production"] }`.
- build-inputs-include-tests (Turbo): `"inputs": ["$TURBO_DEFAULT$", "!**/*.{test,spec}.*", "!**/__tests__/**"]` (`$TURBO_DEFAULT$` means all git-tracked files in the package). Apply it to every task in the build's `dependsOn` chain that hashes sources (e.g. `transit`), or the build still rebuilds on test edits. Safe only if no package imports another package's test files. Optional when the measured share of test-only commits is small.
- undeclared-env: Turbo `"env": ["API_URL"]`; Nx `"inputs": ["production", { "env": "API_URL" }]`.
