diff --git a/.changeset/plucky-ibex-gather.md b/.changeset/plucky-ibex-gather.md new file mode 100644 index 000000000..86d2d5bf7 --- /dev/null +++ b/.changeset/plucky-ibex-gather.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2998 +--- +gsd-pristine/ is now populated by the installer when local patches are detected — saveLocalPatches calls a new populatePristineDir helper that runs the install transform pipeline into a tmp staging dir and copies modified files into pristineDir. The reapply-patches Step 5 verifier no longer falls back to its over-broad heuristic. See #2998. diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d818b4d..090491174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`gsd-pristine/` is now populated by the installer when local patches are detected** — `saveLocalPatches` declared a `pristineDir` variable and JSDoc'd "saves pristine copies (from manifest) to gsd-pristine/ to enable three-way merge during reapply-patches", but no code ever wrote to that directory. Effect: the `/gsd-reapply-patches` Step 5 verifier (#2972) silently degraded to its over-broad fallback heuristic ("every significant backup line"), exactly the silent-success-on-lost-content failure mode #2969 was designed to prevent. Fix: new `populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathPrefix, isGlobal })` helper runs the install transform pipeline (`copyWithPathReplacement`) into a tmp staging dir, then copies out only the modified-file paths into `gsd-pristine/`. `saveLocalPatches` now accepts a `pristineCtx` and calls the helper when local patches are detected; the install entry point passes the package source root, runtime, pathPrefix, and isGlobal so transforms produce byte-identical output to what `copyWithPathReplacement` would have written under normal install. Soft-fails on transform errors (logs a warning, continues with empty pristine — no worse than pre-fix behavior). Pristine reflects the about-to-install version's content, which is what the verifier needs as the "what would survive without the user's modifications" baseline. Regression covered by `tests/bug-2998-pristine-dir-populated.test.cjs` (6 tests across two suites): asserts the helper is exported, returns 0 for empty modified list, writes one pristine file per source-existing path, skips ghost paths without corrupting pristine, and produces deterministic output (two runs with same inputs yield byte-identical pristine — the property `pristine_hashes` in `backup-meta.json` depends on). (#2998) + + - **`release-sdk` hotfix re-run no longer fails at `Dry-run publish validation` when the version is already on npm** — the `Detect prior publish (reconciliation mode)` step sets `skip_publish=true` when the package version is already on the registry, and the actual publish step honors that gate. The `Dry-run publish validation` step was missing the same guard, so any operator re-run of an already-published hotfix (the typical recovery path when later steps fail mid-flight) hit `npm publish --dry-run` first and got `npm error You cannot publish over the previously published versions: X.Y.Z` — `npm publish --dry-run` contacts the registry and rejects existing-version targets even though it doesn't actually publish. The dry-run validation step is now gated on the same `steps.prior_publish.outputs.skip_publish != 'true'` condition as the publish step. The rehearsal still runs on first publishes (where it has value); it skips only in the specific reconciliation case where the publish itself would be skipped. Trigger run: [25233855236](https://github.com/gsd-build/get-shit-done/actions/runs/25233855236/job/73995605643). Regression covered by `tests/bug-2987-dry-run-validation-skip-on-reconciliation.test.cjs`. (#2987) - **`release-sdk` hotfix flow hardened against silent classifier failures, missing-classifier-at-base-tag, and a vestigial merge-back PR step** — three issues surfaced by CodeRabbit's post-merge review of #2981 plus a production failure on the v1.39.1 release run. **(1)** `scripts/diff-touches-shipped-paths.cjs` reused exit code `1` for both the legitimate "no shipped paths" classifier result and Node's default uncaught-throw exit, so any tooling failure was indistinguishable from a normal skip. The script now uses `0` (shipped), `1` (not shipped), `2` (classifier error) with `try`/`catch` + `uncaughtException`/`unhandledRejection` handlers routing all failure paths to exit `2`. **(2)** The workflow's `git checkout -b "$BRANCH" "$BASE_TAG"` overwrote the working tree with the base tag's contents *before* the cherry-pick loop ran the classifier — but base tags predating the classifier's introduction (notably v1.39.0) don't have the file in their tree, so `node scripts/diff-touches-shipped-paths.cjs` would exit non-zero and silently drop every commit, producing an empty hotfix release. The classifier is now staged into `$RUNNER_TEMP` at the top of `Prepare hotfix branch` (before any working-tree-mutating git command), and the loop references that staged copy. The cherry-pick loop snapshots `$PIPESTATUS` into a local array (`PIPE_RC=("${PIPESTATUS[@]}")`) immediately after the classifier pipeline — under bracketed `set +e`/`set -e` — and dispatches via explicit `case`: `0` proceeds, `1` skips into `NON_SHIPPED_SKIPPED`, anything else emits `::error::shipped-paths classifier failed for $SHA (exit N)` and fails the workflow. CodeRabbit on PR #2984 caught a subtler bug in the first iteration: `pipeline \|\| true; RC=${PIPESTATUS[1]}` is broken because `\|\| true` runs `true` as its own one-command pipeline on the failure paths, overwriting `PIPESTATUS` to `(0)` and leaving `${PIPESTATUS[1]}` unset. The array-snapshot form is invariant against this. The same hardening also surfaces `git diff-tree`'s exit code (via `PIPE_RC[0]`); a non-zero diff-tree result now also fails the workflow rather than feeding partial input to the classifier. **(3)** Removed the `Open merge-back PR (hotfix only)` step. The auto-cherry-pick hotfix flow only picks commits already on main (`git cherry HEAD origin/main` outputs the unmerged ones), so by construction every code commit on the hotfix branch is already on main. The only hotfix-branch-only commit is the version-bump chore, which would either no-op against main or rewind main's in-progress version. The step also failed in production with `GitHub Actions is not permitted to create or approve pull requests (createPullRequest)` (org policy) on run [25232968975](https://github.com/gsd-build/get-shit-done/actions/runs/25232968975). The `pull-requests: write` permission previously granted to the release job has been dropped in line with least-privilege. The run-summary line that previously echoed `Merge-back PR opened against main` has been replaced with `No merge-back PR (auto-picked commits are already on main)` so operators reading the summary see an accurate non-action statement (CodeRabbit on PR #2984). Regression covered by `tests/bug-2983-classifier-exit-codes-and-base-tag-staging.test.cjs` (15 assertions across exit-code semantics, classifier staging, error dispatch, PIPESTATUS-snapshot hardening, diff-tree fail-fast, merge-back removal, and run-summary accuracy). (#2983) - **`release-sdk` hotfix only cherry-picks commits that change what actually ships** — the `fix:`/`chore:` filter in `Prepare hotfix branch` was too broad: it picked any commit with that conventional-commit type regardless of whether the diff could affect the published npm package. CI-only fixes (release-sdk.yml itself, hotfix tooling, test-only commits) were getting cherry-picked into hotfix branches even though they cannot change the tarball — and the subset touching `.github/workflows/*` then caused the prepare job's `git push` to be rejected by GitHub because the default `GITHUB_TOKEN` lacks the `workflow` scope, aborting the run. v1.39.1 hit this on PR #2977 (run [25232010071](https://github.com/gsd-build/get-shit-done/actions/runs/25232010071)). The loop now pre-skips any candidate commit whose `git diff-tree` output doesn't intersect the npm tarball's shipped paths (entries in `package.json` `files`, plus `package.json` itself, which `npm pack` always includes). Skipped commits land in a new `NON_SHIPPED_SKIPPED` summary bucket framed as informational — non-shipping commits cannot affect the package, so the skip needs no operator action. The shipped-paths classifier lives in `scripts/diff-touches-shipped-paths.cjs` so its rules (file-OR-directory prefix matching `npm pack` semantics, the always-shipped rule for `package.json`, the lockfile-not-shipped rule) are unit-testable. Regression covered by `tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs`. (#2980) diff --git a/bin/install.js b/bin/install.js index 1b5ab14a1..0426fd583 100755 --- a/bin/install.js +++ b/bin/install.js @@ -6798,13 +6798,98 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { return manifest; } +/** + * Populate gsd-pristine/ with the transformed pristine versions of every + * `modified` file, derived from the current package's source tree by + * running the install transform pipeline (`copyWithPathReplacement`) + * into a tmp directory, then copying out only the relevant paths. + * + * Pristine semantically represents "what the install would write to + * configDir/ if the user had not modified it." This is what the + * /gsd-reapply-patches Step 5 verifier (#2972) uses as the diff base + * for "user-added lines" — lines in the user's backup that are NOT in + * the pristine baseline. Without this dir, the verifier degrades to its + * over-broad fallback ("every significant backup line"), exactly the + * silent-success-on-lost-content failure mode #2969 was designed to + * prevent (#2998). + * + * Implementation note: we run the FULL transform pipeline against a tmp + * staging dir (one-time, only when modified.length > 0), then copy out + * just the modified paths. This re-uses the existing transform code + * exactly — pristine is byte-identical to what `copyWithPathReplacement` + * would have written under normal install. Cost: one extra full transform + * pass per install where local patches were detected; acceptable. + */ +function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathPrefix, isGlobal }) { + if (!modified || modified.length === 0) return 0; + // Modified paths come from manifest.files which can live under several + // install roots: get-shit-done/, commands/gsd/, command/, skills/, agents/, + // hooks/, plus runtime-specific root files (#3004 CR). Stage every + // top-level dir that actually contains a modified path; root-level files + // are copied directly without the transform pipeline (they don't need + // path replacement). + const stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pristine-stage-')); + let written = 0; + try { + const topLevels = new Set(); + for (const relPath of modified) { + const norm = relPath.replace(/\\/g, '/'); + const slash = norm.indexOf('/'); + topLevels.add(slash === -1 ? '' : norm.slice(0, slash)); + } + + for (const top of topLevels) { + if (top === '') { + // Root-level files — copy directly from package source. The transform + // pipeline is directory-oriented; root files don't need path-prefix + // substitution (they're not markdown content with embedded paths). + for (const relPath of modified) { + const norm = relPath.replace(/\\/g, '/'); + if (norm.includes('/')) continue; + const src = path.join(packageSrc, relPath); + if (!fs.existsSync(src)) continue; + const stagedFile = path.join(stageRoot, relPath); + fs.mkdirSync(path.dirname(stagedFile), { recursive: true }); + fs.copyFileSync(src, stagedFile); + } + continue; + } + const srcDir = path.join(packageSrc, top); + const stageDir = path.join(stageRoot, top); + if (!fs.existsSync(srcDir)) continue; + copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal); + } + + for (const relPath of modified) { + // Only populate pristine for paths we successfully staged. If a path's + // source dir does not exist (obsolete manifest entry), skip silently + // rather than corrupting pristine with stale data. + const stagedPath = path.join(stageRoot, relPath); + if (!fs.existsSync(stagedPath)) continue; + const out = path.join(pristineDir, relPath); + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.copyFileSync(stagedPath, out); + written++; + } + } finally { + try { fs.rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + } + return written; +} + /** * Detect user-modified GSD files by comparing against install manifest. * Backs up modified files to gsd-local-patches/ for reapply after update. * Also saves pristine copies (from manifest) to gsd-pristine/ to enable * three-way merge during reapply-patches (pristine vs user vs new). + * + * The optional `pristineCtx` parameter (set by the install entry point) + * carries the source package root, runtime, pathPrefix, and isGlobal + * needed to populate gsd-pristine/. If omitted (legacy callers), pristine + * stays empty — the verifier falls back to its over-broad heuristic, same + * behavior as before #2998. */ -function saveLocalPatches(configDir) { +function saveLocalPatches(configDir, pristineCtx) { const manifestPath = path.join(configDir, MANIFEST_NAME); if (!fs.existsSync(manifestPath)) return []; @@ -6836,16 +6921,12 @@ function saveLocalPatches(configDir) { } } - // Save pristine copies of modified files from the CURRENT install (before wipe) - // These represent the original GSD distribution files that the user then modified. - // The reapply-patches workflow uses these for three-way merge: - // pristine (original) → user's version (what they changed) → new version (after update) + // Save pristine copies of modified files from the CURRENT install (before wipe). + // Pristine semantically represents "what the install would write to configDir + // if the user had not modified it" — used by /gsd-reapply-patches Step 5 + // (#2972) as the diff baseline for the user-added-lines computation. Without + // this dir the verifier degrades to its over-broad fallback heuristic (#2998). if (modified.length > 0) { - // We need the pristine originals, but the current files on disk are user-modified. - // The manifest records SHA-256 hashes but not content. However, we can reconstruct - // the pristine version from the npm package cache or git history. - // As a practical approach: save the manifest's version info so the reapply workflow - // knows which GSD version these files came from, enabling npm-based reconstruction. const meta = { backed_up_at: new Date().toISOString(), from_version: manifest.version, @@ -6863,6 +6944,37 @@ function saveLocalPatches(configDir) { for (const f of modified) { console.log(' ' + dim + f + reset); } + + // #2998: populate gsd-pristine/ via the install transform pipeline so the + // reapply-patches verifier (#2972) gets a real diff baseline instead of + // falling back to its over-broad "every significant backup line" heuristic. + if (pristineCtx) { + // #3004 CR: wipe any pre-existing pristine content BEFORE populating + // (and again in the catch path). Without this, a previous run's stale + // pristine could be picked up by the verifier as if it were the + // baseline for THIS modified set, causing a misleading three-way diff. + try { fs.rmSync(pristineDir, { recursive: true, force: true }); } catch { /* not present */ } + try { + const written = populatePristineDir({ + packageSrc: pristineCtx.packageSrc, + pristineDir, + modified, + runtime: pristineCtx.runtime, + pathPrefix: pristineCtx.pathPrefix, + isGlobal: pristineCtx.isGlobal, + }); + if (written > 0) { + console.log(' ' + green + '✓' + reset + ' Populated ' + cyan + 'gsd-pristine/' + reset + ' (' + written + ' file(s)) for three-way merge'); + } + } catch (err) { + // Soft failure: keep the install moving even if the transform pipeline + // throws on an unusual configuration. Wipe the partial pristine so the + // verifier falls back cleanly to its pre-#2998 heuristic instead of + // reading half-populated data (#3004 CR). + try { fs.rmSync(pristineDir, { recursive: true, force: true }); } catch { /* best-effort */ } + console.log(' ' + yellow + 'i' + reset + ' Could not populate gsd-pristine/ (' + (err && err.message ? err.message : 'unknown') + '). Falls back to over-broad verify heuristic.'); + } + } } return modified; } @@ -6974,8 +7086,16 @@ function install(isGlobal, runtime = 'claude') { // Track installation failures const failures = []; - // Save any locally modified GSD files before they get wiped - saveLocalPatches(targetDir); + // Save any locally modified GSD files before they get wiped. + // The pristine context lets saveLocalPatches populate gsd-pristine/ via + // the install transform pipeline, giving the reapply-patches Step 5 + // verifier a real diff baseline (#2998). + saveLocalPatches(targetDir, { + packageSrc: src, + runtime, + pathPrefix, + isGlobal, + }); // Clean up orphaned files from previous versions cleanupOrphanedFiles(targetDir); @@ -9071,6 +9191,7 @@ if (process.env.GSD_TEST_MODE) { validateHookFields, preserveUserArtifacts, restoreUserArtifacts, + populatePristineDir, USER_OWNED_ARTIFACTS, finishInstall, trySelfLinkGsdSdk, diff --git a/tests/bug-2998-pristine-dir-populated.test.cjs b/tests/bug-2998-pristine-dir-populated.test.cjs new file mode 100644 index 000000000..ed594ae53 --- /dev/null +++ b/tests/bug-2998-pristine-dir-populated.test.cjs @@ -0,0 +1,202 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Bug #2998: gsd-pristine/ snapshot is documented but never populated by + * the installer. saveLocalPatches declared a pristineDir variable and + * promised "saves pristine copies (from manifest) to gsd-pristine/ to + * enable three-way merge during reapply-patches" -- but no code ever + * wrote to that directory. Effect: the /gsd-reapply-patches Step 5 + * verifier (#2972) silently degrades to its over-broad fallback heuristic + * ("every significant backup line"), exactly the silent-success-on-lost- + * content failure mode #2969 was designed to prevent. + * + * Fix: new populatePristineDir({...}) helper runs the install transform + * pipeline (copyWithPathReplacement) into a tmp staging dir, then copies + * out the modified-file paths into gsd-pristine/. saveLocalPatches now + * accepts a pristineCtx and calls the helper when local patches are + * detected. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); + +const ROOT = path.join(__dirname, '..'); +const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); + +function sha256(content) { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +describe('Bug #2998: populatePristineDir is exported and writes pristine for modified files', () => { + test('exported as a function', () => { + assert.equal(typeof INSTALL.populatePristineDir, 'function', + 'expected populatePristineDir in install.js exports (#2998)'); + }); + + test('returns 0 when no files are modified (no-op)', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); + try { + const written = INSTALL.populatePristineDir({ + packageSrc: ROOT, + pristineDir: path.join(tmp, 'gsd-pristine'), + modified: [], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + assert.equal(written, 0); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('writes one pristine file per modified path that exists in source', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); + const pristineDir = path.join(tmp, 'gsd-pristine'); + try { + // Pick a real installed-side relPath from the package source. The + // install transforms map source `get-shit-done/` to installed + // `get-shit-done/` for skills-aware runtimes (like claude), + // so the relPath is the same on both sides. + const candidate = path.join('get-shit-done', 'workflows', 'reapply-patches.md'); + const sourcePath = path.join(ROOT, candidate); + assert.equal(fs.existsSync(sourcePath), true, + `precondition: source file exists at ${candidate}`); + const written = INSTALL.populatePristineDir({ + packageSrc: ROOT, + pristineDir, + modified: [candidate], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + assert.equal(written, 1, 'expected exactly one pristine file written'); + const out = path.join(pristineDir, candidate); + assert.equal(fs.existsSync(out), true, `expected pristine file at ${out}`); + // The pristine content should be the transformed version (not raw source): + // copyWithPathReplacement substitutes ~/.claude/ for the runtime path prefix. + // For claude+global, the prefix is $HOME/.claude/ which equals the original, + // so the transform is effectively identity here. We assert the content is a + // non-empty markdown file rather than asserting on transform specifics. + const content = fs.readFileSync(out, 'utf-8'); + assert.ok(content.length > 0, 'pristine file should be non-empty'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('skips paths not present in source (does not corrupt pristine with stale data)', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-')); + const pristineDir = path.join(tmp, 'gsd-pristine'); + try { + const written = INSTALL.populatePristineDir({ + packageSrc: ROOT, + pristineDir, + modified: ['get-shit-done/this-path-does-not-exist.md'], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + assert.equal(written, 0, 'expected zero pristine files for non-existent source paths'); + const out = path.join(pristineDir, 'get-shit-done/this-path-does-not-exist.md'); + assert.equal(fs.existsSync(out), false, 'pristine should not contain ghost paths'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('pristine files have stable content (transformations are deterministic)', () => { + // Determinism is what makes the verifier's hash check meaningful: + // backup-meta.json records pristine_hashes computed at this same step, + // so re-running with the same inputs must yield byte-identical files. + const tmp1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d1-')); + const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d2-')); + try { + const candidate = path.join('get-shit-done', 'workflows', 'reapply-patches.md'); + const ctx = { + packageSrc: ROOT, + modified: [candidate], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }; + INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp1, 'gsd-pristine') }, ctx)); + INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp2, 'gsd-pristine') }, ctx)); + const a = fs.readFileSync(path.join(tmp1, 'gsd-pristine', candidate)); + const b = fs.readFileSync(path.join(tmp2, 'gsd-pristine', candidate)); + assert.equal(sha256(a), sha256(b), 'two runs of the same inputs must yield identical pristine content'); + } finally { + fs.rmSync(tmp1, { recursive: true, force: true }); + fs.rmSync(tmp2, { recursive: true, force: true }); + } + }); +}); + +// ─── #3004 CR follow-up: multi-root pristine expansion ───────────────────── + +describe('Bug #2998 (#3004 CR): pristine expansion covers every manifest install root', () => { + test('paths under agents/ are staged via copyWithPathReplacement, not silently skipped', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-multi-')); + const pristineDir = path.join(tmp, 'gsd-pristine'); + try { + const candidate = path.join('agents', 'gsd-planner.md'); + const sourcePath = path.join(ROOT, candidate); + assert.equal(fs.existsSync(sourcePath), true, + `precondition: source file exists at ${candidate}`); + const written = INSTALL.populatePristineDir({ + packageSrc: ROOT, + pristineDir, + modified: [candidate], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + assert.equal(written, 1, 'expected agents/ path to be staged and copied to pristine'); + assert.equal(fs.existsSync(path.join(pristineDir, candidate)), true); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('a mix of get-shit-done/ and agents/ paths in modified list are all staged', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-mix-')); + const pristineDir = path.join(tmp, 'gsd-pristine'); + try { + const a = path.join('get-shit-done', 'workflows', 'reapply-patches.md'); + const b = path.join('agents', 'gsd-planner.md'); + assert.equal(fs.existsSync(path.join(ROOT, a)), true); + assert.equal(fs.existsSync(path.join(ROOT, b)), true); + const written = INSTALL.populatePristineDir({ + packageSrc: ROOT, + pristineDir, + modified: [a, b], + runtime: 'claude', + pathPrefix: '$HOME/.claude/', + isGlobal: true, + }); + assert.equal(written, 2, 'expected both top-level dirs to be staged'); + assert.equal(fs.existsSync(path.join(pristineDir, a)), true); + assert.equal(fs.existsSync(path.join(pristineDir, b)), true); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +describe('Bug #2998: saveLocalPatches no longer leaves the pristineDir variable unused', () => { + test('saveLocalPatches accepts a pristineCtx and exposes the helper for direct testing', () => { + // Structural assertion: the function exists with the new signature shape. + // Behavioral end-to-end is covered by the populatePristineDir tests above + // (that helper is what saveLocalPatches calls internally). + assert.equal(typeof INSTALL.populatePristineDir, 'function'); + // The signature for saveLocalPatches isn't exported, but the helper IS, + // and it's the unit of behavior the bug is about. Asserting on the helper + // is the structural-IR equivalent of the no-source-grep convention. + }); +});