Files
millionco__react-doctor/scripts/normalize-changed-files.mjs
Ray Arayilakath 2cadd3fe2c perf(action,core): CI speedups — install cache, persistent scan caches, local diff scope (plans 09–11) (#908)
* feat(action,core): CI speedups — install cache, persistent scan caches, local diff scope (plans 09–11)

The GitHub Action's dominant cost on a PR run is the uncached install (~15s of
an ~18s step), not the scan. These three plans target the CI experience:

Plan 09 — cache the install (biggest CI win). A resolve-version step pins the
concrete published version (so the cache key is stable even for `latest`;
scripts/resolve-package-spec.mjs), an actions/cache step restores the install
keyed on version+node+os+arch (no fuzzy fallback — native ABI safety), and the
scan installs into the cached `--prefix` only on a miss. A non-cacheable
local-path spec keeps the npx path. ~15s install → ~1-2s restore on a hit.

Plan 11 — derive PR changed files locally + lock the diff fast path. The base
step now runs `git diff --name-only --diff-filter=AMR <base>...HEAD` (faster, no
API rate limit, works on forks), falling back to the GitHub API only when the
base isn't reachable; both share scripts/normalize-changed-files.mjs. A
regression test locks that diff mode skips dead-code + supply-chain (the
fast-path guarantee). A clearer degraded-mode warning points at `fetch-depth: 0`.

Plan 10 — persist scan caches across CI runs. `REACT_DOCTOR_CACHE_DIR` lets the
action point the engine's caches at a stable `${runner.temp}` path an
actions/cache step persists, so the per-file content-addressed lint cache (#900)
restores across commits — a PR re-lints only its changed files. A new
supply-chain per-PURL on-disk cache (24h TTL, fail-open, NO_CACHE-bypassed) skips
the Socket network for unchanged deps.

Unit/integration tests cover all three (version classification, changed-file
normalization, diff-fast-path skip, cache-dir override, supply-chain cache
hit/bypass); changeset for the npm-facing surface (REACT_DOCTOR_CACHE_DIR +
supply-chain cache). Action releases (tags) + the self-test cacheable-install
job (a workflow-file change needing `workflow` push scope) + R3 sparse-checkout
docs + plan-09's `--print-cache-key` follow-up are noted for a workflow-scoped
push / dogfooding pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(action): self-test the cacheable install path (plan 09)

Adds an `action-cacheable-install` job that runs the action with a published
`version:` (latest) so CI exercises the resolve-version + actions/cache +
prefix-install branch (the local-path job covers the npx branch). Advisory
(`blocking: none`). Split out from the main plan-09 commit because pushing a
`.github/workflows/` change requires `workflow` token scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): migrate mutable @main action refs to @v2 (action change → migration)

Since plans 09-11 change the action, register a once-per-repo project migration
(the framework's "action updates" path) that pins a mutable `@main` / `@master`
React Doctor action reference in `.github/workflows/*.yml` to the recommended
floating major `@v2`.

An unpinned `@main` runs whatever the action's HEAD points to with the
workflow's write permissions — a supply-chain risk (#299) — and the rewrite also
moves the workflow onto the install- and scan-cached release. Only mutable refs
are rewritten; pinned tags / SHAs are deliberate and untouched, a different
action on `@main` is ignored, and only the ref changes (owner, comments, and the
`version:` input are preserved). Runs once per repo like the legacy-config
migration and logs the change for review/commit (or revert if intentionally
tracking main). No-op (stays pending) when there's no mutable ref.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(action): update the action contract test for the plans 09-11 restructure

github-action.test.ts asserts the literal content of action.yml's steps; plans
09/11 moved that content into shared scripts, so two assertions broke:
- the inline `directoryPrefix` prefix-stripping is now in the shared
  normalize-changed-files.mjs (used by both the local-diff base step and the API
  fallback) — assert the wiring instead, with the behavior locked by
  normalize-changed-files.test.ts.
- the inline `PACKAGE_SPEC="react-doctor@$INPUT_VERSION"` derivation moved to the
  resolve-version step (resolve-package-spec.mjs) — assert it's read from that
  step's output.
Adds a test for the new contract (resolve-version + the toolchain/scan
actions/cache steps + the cached prefix-install). Behavior unchanged; the
contract test now matches the reworked action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:38:48 -04:00

53 lines
2.1 KiB
JavaScript

import fs from "node:fs";
import { pathToFileURL } from "node:url";
/**
* Map repo-root-relative changed-file paths (from `git diff --name-only` or the
* GitHub `pulls.listFiles` API) to the SCAN-relative paths the CLI's
* `--changed-files-from` expects. The CLI resolves changed-file entries relative
* to the scanned `directory` (its diff detection runs `git diff --relative`), so
* strip the `directory` prefix and drop files outside it — otherwise a
* subdirectory scan (`directory: UI`) doubles up to `UI/UI/src/...`, misses every
* base read, and reports pre-existing issues as newly introduced.
*
* Shared by the action's local-`git diff` path (the cheap default) and its
* GitHub-API fallback so the two derive the same set from one implementation.
*
* @param {ReadonlyArray<string>} files repo-root-relative changed-file paths
* @param {string | undefined} directory the scanned `directory` input
* @returns {string[]} scan-relative paths
*/
export const normalizeChangedFiles = (files, directory) => {
const directoryPrefix = String(directory ?? ".")
.replace(/^\.\/?/, "")
.replace(/\/$/, "");
return files
.map((file) => String(file).trim())
.filter(Boolean)
.flatMap((filename) => {
if (!directoryPrefix) return [filename];
const scopedPrefix = `${directoryPrefix}/`;
return filename.startsWith(scopedPrefix) ? [filename.slice(scopedPrefix.length)] : [];
});
};
// CLI: read newline-separated repo-root paths on stdin (from `git diff
// --name-only`), write the scan-relative set to argv[3] (or stdout). argv[2] is
// the scanned directory.
const main = () => {
const directory = process.argv[2];
const rawInput = fs.readFileSync(0, "utf8");
const normalized = normalizeChangedFiles(rawInput.split("\n"), directory);
const rendered = normalized.length > 0 ? `${normalized.join("\n")}\n` : "";
const outputPath = process.argv[3];
if (outputPath) {
fs.writeFileSync(outputPath, rendered);
} else {
process.stdout.write(rendered);
}
};
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}