From 33bb14c99a78fd2fe0053896b4b7a5632e539eb6 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 19 Sep 2026 03:28:29 +0000 Subject: [PATCH 1/4] fix(scripts): give every throwaway-repo git child an environment of its own Git exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE into every child it runs and those outrank `cwd`, so a `git init` / `add -A` / `ls-files` aimed at a temp directory lands on the repository those variables name. Measured on #16624: 8,190 paths staged as deleted in a shared index and `core.bare = true` written into the `.git/config` every linked worktree reads, from a self-test that printed ticks throughout. Applies the landed `gitFreeEnv()` strip (scripts/git-env.mjs, #16624, applied in Tier A by #16753) to the Tier B spawn points, per spawn point rather than per file, and converges the two `packages/spec` fixtures onto the same spelling by retiring their hand-maintained GIT_* allowlist. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- .../scripts/build-schemas-check-mode.test.ts | 31 +++++++-------- .../spec/scripts/sharded-artifacts.test.ts | 32 +++++++-------- scripts/ablation-dist-preflight.mjs | 12 +++++- scripts/check-adr-0087-registration.mjs | 10 ++++- scripts/check-bash32-floor.mjs | 15 +++++-- scripts/check-changeset-no-major.mjs | 8 ++++ scripts/check-empty-changeset.mjs | 8 +++- scripts/check-engine-split-ratio.mjs | 29 ++++++++++++-- scripts/check-nul-bytes.mjs | 27 +++++++++---- scripts/check-skill-frame-freshness.mjs | 25 +++++++++++- scripts/check-type-check-coverage.mjs | 10 ++++- scripts/collect-release-notes.sh | 21 ++++++++++ scripts/docs-audit/check-drift-comment.mjs | 8 +++- scripts/objectui-changeset-digest.mjs | 39 +++++++++++++++---- scripts/objectui-range.mjs | 11 +++++- 15 files changed, 221 insertions(+), 65 deletions(-) diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 69c3f5e3699..18100c70f82 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -54,6 +54,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gitFreeEnv } from '../../../scripts/git-env.mjs'; import { schemaStamp } from '../../../scripts/check-regen-pending.mjs'; import { RENAMED_DEFS } from './lib/renamed-defs'; import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry'; @@ -232,25 +233,23 @@ let surfaceBaseDescription: string; // are already serial — vitest runs a file's tests one at a time, and every repo // here is an `fs.mkdtemp`, so no concurrently running test FILE can name one. -/** `GIT_*` variables that would point a fixture's git at a different repository - * (or a different index/object store) than the directory it was handed. */ -const LEAKED_GIT_ENV = [ - 'GIT_DIR', - 'GIT_WORK_TREE', - 'GIT_COMMON_DIR', - 'GIT_INDEX_FILE', - 'GIT_OBJECT_DIRECTORY', - 'GIT_ALTERNATE_OBJECT_DIRECTORIES', - 'GIT_NAMESPACE', - 'GIT_CEILING_DIRECTORIES', - 'GIT_TEMPLATE_DIR', - 'GIT_CONFIG', -] as const; +/* #16644 -- a hand-maintained allowlist of ten GIT_* location variables used to stand + * here. It is retired in favour of the blanket strip in `scripts/git-env.mjs`, and the + * constant is not re-spelled anywhere in this file so that a census of the retired shape + * does not match this paragraph. + * + * The reason the allowlist goes rather than gets one more entry: it had to be kept level + * with git's own list of location variables, and its failure mode is that THE KEY IT + * MISSES IS THE KEY THAT BITES. `gitFreeEnv()` removes every GIT_-prefixed key instead, + * which is the shape #16624 landed and #16753 applied. ⛔ One spelling in the repo, not + * two -- no "either is fine" transition state. + * + * Every git this file spawns is LOCAL-ONLY: it operates on the `fs.mkdtemp` fixture named + * by its `cwd`, so the blanket strip takes no transport configuration away from it. */ /** The environment every fixture git — and every generator run inside one — gets. */ const HERMETIC_ENV: NodeJS.ProcessEnv = (() => { - const env = { ...process.env }; - for (const key of LEAKED_GIT_ENV) delete env[key]; + const env = gitFreeEnv(); // git's own documented "read no config file" spellings. `/dev/null` parses as // an empty config, which is what makes `init.templateDir`, `core.hooksPath` // and any ambient `[gc]` block unable to reach a fixture. diff --git a/packages/spec/scripts/sharded-artifacts.test.ts b/packages/spec/scripts/sharded-artifacts.test.ts index 7bdeceb6468..41fc51535b3 100644 --- a/packages/spec/scripts/sharded-artifacts.test.ts +++ b/packages/spec/scripts/sharded-artifacts.test.ts @@ -39,6 +39,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { gitFreeEnv } from '../../../scripts/git-env.mjs'; + import { API_SURFACE_DIR_NAME, AUTHORABLE_SURFACE_DIR_NAME, @@ -411,25 +413,23 @@ describe('sharded artifacts — entry-point shard naming (#5837)', () => { // Nothing about what these cases assert changes: the fixtures are built from the // same writes and read by the same `readShardedKeysAtRev`, only insulated. -/** `GIT_*` variables that would point a fixture's git at a different repository - * (or a different index/object store) than the directory it was handed. */ -const LEAKED_GIT_ENV = [ - 'GIT_DIR', - 'GIT_WORK_TREE', - 'GIT_COMMON_DIR', - 'GIT_INDEX_FILE', - 'GIT_OBJECT_DIRECTORY', - 'GIT_ALTERNATE_OBJECT_DIRECTORIES', - 'GIT_NAMESPACE', - 'GIT_CEILING_DIRECTORIES', - 'GIT_TEMPLATE_DIR', - 'GIT_CONFIG', -] as const; +/* #16644 -- a hand-maintained allowlist of ten GIT_* location variables used to stand + * here. It is retired in favour of the blanket strip in `scripts/git-env.mjs`, and the + * constant is not re-spelled anywhere in this file so that a census of the retired shape + * does not match this paragraph. + * + * The reason the allowlist goes rather than gets one more entry: it had to be kept level + * with git's own list of location variables, and its failure mode is that THE KEY IT + * MISSES IS THE KEY THAT BITES. `gitFreeEnv()` removes every GIT_-prefixed key instead, + * which is the shape #16624 landed and #16753 applied. ⛔ One spelling in the repo, not + * two -- no "either is fine" transition state. + * + * Every git this file spawns is LOCAL-ONLY: it operates on the `fs.mkdtemp` fixture named + * by its `cwd`, so the blanket strip takes no transport configuration away from it. */ /** The environment every fixture git in this file gets. */ const HERMETIC_ENV: NodeJS.ProcessEnv = (() => { - const env = { ...process.env }; - for (const key of LEAKED_GIT_ENV) delete env[key]; + const env = gitFreeEnv(); // git's own documented "read no config file" spellings. `/dev/null` parses as // an empty config, which is what makes `init.templateDir`, `core.hooksPath` // and any ambient `[gc]` block unable to reach a fixture. diff --git a/scripts/ablation-dist-preflight.mjs b/scripts/ablation-dist-preflight.mjs index fe9b0f151ba..73159e28b13 100644 --- a/scripts/ablation-dist-preflight.mjs +++ b/scripts/ablation-dist-preflight.mjs @@ -211,6 +211,7 @@ import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import process from 'node:process'; +import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; import { WORKSPACE_FILE, parseWorkspaceGlobs, workspacePackageDirs } from './workspace-enumerator.mjs'; @@ -450,6 +451,11 @@ function readTreeStatus(repoRoot) { try { const out = execFileSync('git', ['status', '--porcelain', '-z'], { cwd: repoRoot, + // The tree under test is the one `repoRoot` names and nothing else (#16644). + // An inherited GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE outranks `cwd`, so under a + // hook this would certify SOME OTHER tree as restored -- the one direction this + // preflight exists to make impossible. + env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], @@ -475,6 +481,8 @@ function markerPresence(repoRoot, entries, marker) { try { head = execFileSync('git', ['show', `HEAD:${e.path}`], { cwd: repoRoot, + env: gitFreeEnv(), // #16644: the HEAD blob of THIS tree, never a hook's + maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], }); @@ -714,7 +722,9 @@ function selfTest() { // verdict mean anything. This leg replays the measured incident end to end. const repo = mkdtempSync(join(tmpdir(), 'ablation-preflight-git-')); try { - const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }); + // #16644: a throwaway corpus, so every child is spawned with GIT_* stripped -- + // `git init` here under an inherited GIT_DIR writes core.bare into the SHARED config. + const git = (...args) => execFileSync('git', args, { cwd: repo, env: gitFreeEnv(), stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }); const srcPath = join(repo, 'source.ts'); const genPath = join(repo, 'generated-baseline.json'); const MARK = 'OS_ABLATION_LEAK_MARK'; diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index fdbe5c06e55..9032dc6860d 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -365,6 +365,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; import { maskComments, maskCommentsAndLiterals } from './js-comment-mask.mjs'; // #16421 — the DIRECTION ARM, read through the fleet's one declaration reader. @@ -2002,11 +2003,16 @@ export function projectedMigrationIds(specChangesJson) { // --------------------------------------------------------------------------- function git(args, cwd) { + // `env: gitFreeEnv()` (#16644): every caller of this helper -- the real checkout on a + // gate run, a mkdtemp fixture in the self-test -- names its repository by `cwd`. An + // inherited GIT_DIR outranks `cwd`, so without the strip the fixture legs read and + // write THE REAL REPOSITORY under a hook, silently and with `ok` printed throughout. + // // stderr is PIPED, not inherited: `showOrNull` probes paths that legitimately do // not exist at a rev (a ledger file added mid-history, a changeset deleted), and // git's "fatal: path ... does not exist" would otherwise print as though the gate // had failed while it is in fact answering the question it asked. - return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + return execFileSync('git', args, { cwd, env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); } /** File contents at a rev, or `null` when the path does not exist there. */ @@ -2035,7 +2041,7 @@ function showManyOrNull(rev, paths, cwd) { let out; try { out = execFileSync('git', ['cat-file', '--batch'], { - cwd, input, maxBuffer: 512 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], + cwd, env: gitFreeEnv(), input, maxBuffer: 512 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], }); } catch { return found; } diff --git a/scripts/check-bash32-floor.mjs b/scripts/check-bash32-floor.mjs index 8c8eda638a1..612d4017f1f 100644 --- a/scripts/check-bash32-floor.mjs +++ b/scripts/check-bash32-floor.mjs @@ -212,6 +212,7 @@ import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import process from 'node:process'; +import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const REPO_ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -847,6 +848,11 @@ function unreadableReason(err) { */ export function listPopulation(root) { const out = spawnSync('git', ['-C', root, 'ls-files', '-z', '--', ...WALK_ROOTS], { + // #16644: `-C root` is the ONLY thing that may decide which index is read. An + // inherited GIT_DIR outranks it, and this function is called with a mkdtemp fixture + // as `root` in every end-to-end leg below -- under a hook those legs would census + // the real repository and report a number about the wrong tree. + env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, }); @@ -953,12 +959,15 @@ function reportUnreadable(unreadable, population, findings) { */ function fixtureRepo(files) { const dir = mkdtempSync(join(tmpdir(), 'bash32-floor-')); - spawnSync('git', ['-C', dir, 'init', '-q'], { encoding: 'utf8' }); + // #16644: `init` and `add -A` are the two commands the measured incident ran. With an + // inherited GIT_DIR the `init` writes core.bare into the SHARED .git/config and the + // `add -A` stages the real tree as deleted, both silently. + spawnSync('git', ['-C', dir, 'init', '-q'], { encoding: 'utf8', env: gitFreeEnv() }); for (const [rel, body] of Object.entries(files)) { mkdirSync(join(dir, dirname(rel)), { recursive: true }); writeFileSync(join(dir, rel), body); } - spawnSync('git', ['-C', dir, 'add', '-A'], { encoding: 'utf8' }); + spawnSync('git', ['-C', dir, 'add', '-A'], { encoding: 'utf8', env: gitFreeEnv() }); return dir; } @@ -1402,7 +1411,7 @@ function selfTest() { // a fixture whose index also lost the path would make every case below pass // by testing nothing. rmSync(join(partialRepo, 'scripts/absent.sh')); - const stillIndexed = spawnSync('git', ['-C', partialRepo, 'ls-files', '--', ...WALK_ROOTS], { encoding: 'utf8' }); + const stillIndexed = spawnSync('git', ['-C', partialRepo, 'ls-files', '--', ...WALK_ROOTS], { encoding: 'utf8', env: gitFreeEnv() }); t( 'the fixture really is INDEX-vs-DISK: the index still lists the removed path', stillIndexed.stdout.includes('scripts/absent.sh'), diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs index 4d72a0f731d..baa38917fe8 100644 --- a/scripts/check-changeset-no-major.mjs +++ b/scripts/check-changeset-no-major.mjs @@ -383,6 +383,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; // #16055, the level axis below. Both are IMPORTED rather than restated: the // clause-② declaration has exactly one legal spelling and exactly one label @@ -469,6 +470,13 @@ const isChangesetFile = (p) => p.startsWith('.changeset/') && p.endsWith('.md') function git(args, cwd, { quiet = false } = {}) { return execFileSync('git', args, { cwd, + // #16644: `cwd` is the only thing that may name the repository here, and the + // self-test hands it mkdtemp fixtures. GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE + // outrank `cwd`, so an inherited one redirects `init`, `add -A` and `commit` + // onto the real checkout. ⭐ This helper is also the one that runs `fetch` in the + // #4690 leg -- its remote there is another LOCAL mkdtemp repository passed by + // path, so no transport configuration is in play and the strip is safe. + env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, // `execFileSync` inherits the child's stderr by default. That is right for diff --git a/scripts/check-empty-changeset.mjs b/scripts/check-empty-changeset.mjs index ad1f15d2fb5..b95f9aefd8e 100644 --- a/scripts/check-empty-changeset.mjs +++ b/scripts/check-empty-changeset.mjs @@ -231,6 +231,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -294,8 +295,13 @@ const isChangesetFile = (p) => p.startsWith('.changeset/') && p.endsWith('.md') // ── git helpers ────────────────────────────────────────────────────────────── +// #16644: `cwd` is the only thing that may name the repository, and the self-test +// hands this helper mkdtemp fixtures -- `init`, `add -A`, `commit`, `fetch` from a +// sibling temp repo. An inherited GIT_DIR outranks `cwd` and redirects all of them +// onto the real checkout. The `fetch` legs name their remote by local PATH, so the +// strip costs them no transport configuration. function git(args, cwd) { - return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + return execFileSync('git', args, { cwd, env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); } /** File contents at a rev, or `null` when the path does not exist there. */ diff --git a/scripts/check-engine-split-ratio.mjs b/scripts/check-engine-split-ratio.mjs index d92d218666d..77d8721f54e 100644 --- a/scripts/check-engine-split-ratio.mjs +++ b/scripts/check-engine-split-ratio.mjs @@ -73,6 +73,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gitFreeEnv, withoutGitEnv } from './git-env.mjs'; import { historyHorizon } from './pm/git-history.mjs'; import { isEntrypoint } from './invoked-as.mjs'; @@ -166,14 +167,22 @@ function main(argv) { return EXIT_CANNOT_COMPUTE; } + // LOCAL-ONLY (#16644). Every `git` below is `log` / `rev-list` against the checkout + // `--cwd` names -- the self-test drives this same entry point with a mkdtemp fixture as + // `--cwd`, so an inherited GIT_DIR would have it measure the real repository and report + // a ratio about the wrong tree. No child here reaches a remote. const git = (...args) => - execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + execFileSync('git', args, { cwd: repoRoot, env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); // ── the horizon comes FIRST: nothing below may run on a window this // checkout cannot see all of. const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000; const sinceIso = new Date(sinceMs).toISOString(); - const horizon = historyHorizon({ cwd: repoRoot, ref: 'HEAD', sinceMs }); + // `withoutGitEnv`, not `gitFreeEnv`: the git child is spawned one frame down inside + // `scripts/pm/git-history.mjs`, which passes no environment of its own, so the only way + // to reach it is to detach the PROCESS for the call (#16644, the second half of the + // rule in scripts/git-env.mjs). Restored in a `finally` by that helper. + const horizon = withoutGitEnv(() => historyHorizon({ cwd: repoRoot, ref: 'HEAD', sinceMs })); if (!horizon.covered) { console.error(renderRefusal({ horizon, days, sinceIso })); return EXIT_CANNOT_COMPUTE; @@ -298,7 +307,11 @@ function selfTest() { // ── real repos: the defect, then both legs of the guard ─────────────────── battery('real repos: the defect, then both legs of the guard'); const root = mkdtempSync(join(tmpdir(), 'engine-split-selftest-')); - const g = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + // LOCAL-ONLY (#16644): `init`, `config`, `add`, `log`, and two `clone`s whose source is + // a `file://` URL under this battery's own mkdtemp root -- a local object transfer that + // needs none of the GIT_CONFIG_* / GIT_SSL_* transport configuration this container + // carries, so the blanket strip costs them nothing and keeps `init` off the shared repo. + const g = (args, cwd) => execFileSync('git', args, { cwd, env: gitFreeEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); const self = fileURLToPath(import.meta.url); // spawnSync, not execFileSync: this script writes to stderr on SUCCESS too // (the zero-scan warning), and execFileSync surfaces stderr only when it @@ -306,6 +319,10 @@ function selfTest() { const runAllowFail = (args, cwd) => { const r = spawnSync(process.execPath, [self, ...args, '--cwd', cwd], { cwd, + // #16644: the child re-enters this file against a FIXTURE. Its own helpers strip, + // but the strip is applied here too so nothing the child spawns -- including the + // shared `historyHorizon` -- can be redirected by a variable this process inherited. + env: gitFreeEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); @@ -330,7 +347,11 @@ function selfTest() { execFileSync('git', ['commit', '--quiet', '-m', `c${i}`], { cwd: up, encoding: 'utf8', - env: { ...process.env, GIT_AUTHOR_DATE: d, GIT_COMMITTER_DATE: d }, + // ⭐ NOT a blanket replace: the two date variables are set DELIBERATELY and are + // what dates the fixture, so they are re-applied ON TOP of the strip. Spreading + // `process.env` here instead would carry an inherited GIT_DIR straight into a + // `commit` -- the one command in this battery that writes refs (#16644). + env: { ...gitFreeEnv(), GIT_AUTHOR_DATE: d, GIT_COMMITTER_DATE: d }, }); } // `--days` is relative to now, so re-date the whole fixture to end today. diff --git a/scripts/check-nul-bytes.mjs b/scripts/check-nul-bytes.mjs index 93c1958adf5..cbda5f8f798 100644 --- a/scripts/check-nul-bytes.mjs +++ b/scripts/check-nul-bytes.mjs @@ -271,6 +271,7 @@ import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, w import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; /** @@ -459,10 +460,14 @@ function locate(buf, offset) { return { line, column }; } -/** One `git ls-files` invocation, NUL-split, with EXCLUDED applied. */ +/** One `git ls-files` invocation, NUL-split, with EXCLUDED applied. LOCAL-ONLY. */ function lsFiles(root, args) { return execFileSync('git', ['ls-files', '-z', ...args], { cwd: root, + // #16644: `root` is the real checkout on a gate run and a mkdtemp fixture in every + // self-test leg. GIT_DIR outranks `cwd`, so without the strip the fixture legs + // enumerate the real repository and the scan set is about the wrong tree. + env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, }) @@ -562,7 +567,11 @@ export function scan(root) { } function repoRoot() { - return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); + // LOCAL-ONLY (#16644), and the strip changes the ANSWER under a hook rather than only + // the blast radius: `pre-commit` exports GIT_DIR as this worktree's private + // `.git/worktrees/` directory, and `--show-toplevel` derived from that names the + // wrong tree. Derived from `cwd` alone it is the checkout the gate was invoked in. + return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', env: gitFreeEnv() }).trim(); } /** @@ -785,7 +794,11 @@ function selfTest() { }; try { - execFileSync('git', ['init', '-q'], { cwd: dir }); + // #16644: every `git` in this self-test is LOCAL-ONLY against `dir`, a mkdtemp + // fixture, and is spawned with GIT_* stripped. The `init` right here is the measured + // incident's first command -- under an inherited GIT_DIR it writes `core.bare = true` + // into the SHARED .git/config that every linked worktree on the box reads. + execFileSync('git', ['init', '-q'], { cwd: dir, env: gitFreeEnv() }); // Hermetic ignore rules (#6984). The untracked half of the scan set is // decided by `--exclude-standard`, which reads the USER's global excludes // file as well as this repo's .gitignore. A developer whose global excludes @@ -795,7 +808,7 @@ function selfTest() { // INSIDE .git (never in the working tree, which is the scan surface) leaves // the temp repo's own .gitignore as the only ignore rule in play. writeFileSync(join(dir, '.git', 'empty-excludes'), ''); - execFileSync('git', ['config', 'core.excludesFile', join(dir, '.git', 'empty-excludes')], { cwd: dir }); + execFileSync('git', ['config', 'core.excludesFile', join(dir, '.git', 'empty-excludes')], { cwd: dir, env: gitFreeEnv() }); // The #4890 case itself: agent instructions under .claude/, markdown, NUL // well past git's 8000-byte sniff window. @@ -890,7 +903,7 @@ function selfTest() { // Excluded artifact directory. write('packages/x/dist/bundle.js', Buffer.concat([Buffer.from('var a='), NUL, Buffer.from(';\n')])); - execFileSync('git', ['add', '-A', '-f'], { cwd: dir }); + execFileSync('git', ['add', '-A', '-f'], { cwd: dir, env: gitFreeEnv() }); // ── #6984: everything BELOW this line is deliberately left unstaged ─────── // @@ -1051,7 +1064,7 @@ function selfTest() { // green on it for a reason unrelated to its bytes. Stated as the enumeration // fact rather than as a re-run, because that IS the change: same classifier, // same byte table, different list of paths. - const indexOnlyEnumeration = execFileSync('git', ['ls-files', '-z'], { cwd: dir, encoding: 'utf8' }) + const indexOnlyEnumeration = execFileSync('git', ['ls-files', '-z'], { cwd: dir, encoding: 'utf8', env: gitFreeEnv() }) // Split on the NUL delimiter built from its byte value: this file is in // its own scan surface, so the delimiter is never written as a literal. .split(String.fromCharCode(0)) @@ -1184,7 +1197,7 @@ function selfTest() { // test here, and it would turn the offender-set comparison below red for a // reason that has nothing to do with the enumeration widening. battery('#6984, the CI direction: on a fully tracked tree the widening is a no-op'); - execFileSync('git', ['add', '-A'], { cwd: dir }); + execFileSync('git', ['add', '-A'], { cwd: dir, env: gitFreeEnv() }); const staged = scan(dir); assert(staged.untracked === 0, `#6984: a fully tracked tree has an empty untracked half, got ${staged.untracked}`); assert( diff --git a/scripts/check-skill-frame-freshness.mjs b/scripts/check-skill-frame-freshness.mjs index 5f66469b2cd..c27ef304ff2 100644 --- a/scripts/check-skill-frame-freshness.mjs +++ b/scripts/check-skill-frame-freshness.mjs @@ -127,6 +127,7 @@ import { frameCountMentions, runAllChecks, } from './check-skill-frame-sync.mjs'; +import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -158,9 +159,22 @@ const SAMPLE_FRAME_FILE_RX = new RegExp(SAMPLE_FRAME_FILE.replace(/[.*+?^${}()|[ // git plumbing // --------------------------------------------------------------------------- -function git(args, { cwd = REPO_ROOT, timeoutMs } = {}) { +/** + * @param {string[]} args + * @param {{ cwd?: string, timeoutMs?: number, network?: boolean }} [opts] + * `network` marks a child that TALKS TO A REMOTE, and it is the one thing that keeps + * the GIT_* strip off (#16644). Every other call this helper serves -- `rev-parse`, + * `init`, `config`, `remote add`, `update-ref`, `commit` -- operates on the repository + * its `cwd` names, and the self-test hands it mkdtemp fixtures, so those get + * `gitFreeEnv()`. ⛔ The strip is NOT a default that may be applied blindly to a + * remote-touching child: this container carries the remote rewriting and credential + * settings in GIT_CONFIG_COUNT / GIT_CONFIG_KEY_* / GIT_CONFIG_VALUE_* and the proxy + * CA bundle in GIT_SSL_CAINFO, and a fetch stripped of those loses its transport. + */ +function git(args, { cwd = REPO_ROOT, timeoutMs, network = false } = {}) { const r = spawnSync('git', args, { cwd, + ...(network ? {} : { env: gitFreeEnv() }), encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, @@ -200,7 +214,14 @@ export function resolveReference({ if (noFetch) { why = '--no-fetch was passed, so nothing was fetched'; } else { - const fetched = git(['fetch', '--quiet', '--no-tags', 'origin', 'main'], { cwd: root, timeoutMs }); + // ⛔ AMBIENT ENVIRONMENT ON PURPOSE -- never `gitFreeEnv()` here (#16644). + // NETWORK-TOUCHING: this is the one child in this file that reaches `origin`, and on + // this repo's agent containers the ambient environment is what makes that possible -- + // GIT_CONFIG_COUNT with GIT_CONFIG_KEY_* / GIT_CONFIG_VALUE_* pairs rewriting the + // GitHub remote and disabling interactive credentials, plus GIT_SSL_CAINFO naming the + // proxy CA bundle. Stripped, the fetch loses its transport configuration and this + // gate degrades to rung 1 on every run -- a warning where a verdict belongs. + const fetched = git(['fetch', '--quiet', '--no-tags', 'origin', 'main'], { cwd: root, timeoutMs, network: true }); if (fetched.ok) { const sha = git(['rev-parse', '--verify', '--quiet', 'FETCH_HEAD^{commit}'], { cwd: root }); if (sha.ok) { diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index 0172bd0836f..299ea1ccd7f 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -528,6 +528,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, import { tmpdir, totalmem } from 'node:os'; import { join, posix, resolve } from 'node:path'; import { getHeapStatistics } from 'node:v8'; +import { gitFreeEnv } from './git-env.mjs'; import { selfTest as workspaceEnumeratorSelfTest, workspaceEnumeratorFloorFailures, @@ -1740,6 +1741,7 @@ function gitIgnoredPaths(rels) { if (rels.length === 0) return new Set(); const res = spawnSync('git', ['-c', 'core.excludesFile=', 'check-ignore', '--stdin', '-z'], { cwd: ROOT, + env: gitFreeEnv(), // LOCAL-ONLY (#16644): the ignore rules of the tree at `cwd`, never a hook's input: rels.map((r) => `${r}\0`).join(''), encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, @@ -1828,7 +1830,10 @@ function readIgnoredPaths(cwd) { '--directory', '-z', ], - { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, + // LOCAL-ONLY (#16644): `cwd` is a package directory on a gate run and a mkdtemp + // fixture in the self-test battery below; an inherited GIT_DIR outranks it and this + // would answer with the real repository's ignored paths for a fixture. + { cwd, env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, ); if (res.error) { refusePrerequisite( @@ -5914,7 +5919,8 @@ function selfTest() { const ignoreRepo = mkdtempSync(join(tmpdir(), 'objectstack-type-check-ignore-')); let ignoreSourceCases = []; try { - const g = (args) => spawnSync('git', args, { cwd: ignoreRepo, encoding: 'utf8' }); + // LOCAL-ONLY (#16644): `init` / `config` / `add` against a mkdtemp fixture. + const g = (args) => spawnSync('git', args, { cwd: ignoreRepo, encoding: 'utf8', env: gitFreeEnv() }); g(['init', '-q', '.']); g(['config', 'user.email', 'self-test@objectstack.invalid']); g(['config', 'user.name', 'check-type-check-coverage self-test']); diff --git a/scripts/collect-release-notes.sh b/scripts/collect-release-notes.sh index 55650fdceea..e6944036aa7 100755 --- a/scripts/collect-release-notes.sh +++ b/scripts/collect-release-notes.sh @@ -90,6 +90,27 @@ set -euo pipefail +# ⛔ #16644 -- every `git` this script runs is LOCAL-ONLY, so the ambient GIT_* pointers go +# before any of them do. +# +# Git exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE into every child it runs and those +# OUTRANK both `cwd` and `-C`. The self-test below builds throwaway repositories with +# `git init`, `git add -A`, `git commit` and three `git clone`s; with one of those +# variables in the environment they land on the repository it names instead. Measured on +# #16624: 8,190 paths staged as deleted in a shared index and `core.bare = true` written +# into the `.git/config` every linked worktree of that clone reads, from a self-test that +# printed ticks throughout. +# +# LOCAL-ONLY is the whole population here, so nothing loses transport configuration: the +# four report sections read checkouts by path and never reach a remote, and the self-test's +# `clone`/`fetch` name a `file://` URL under its own mktemp root. ⛔ The per-command +# `GIT_AUTHOR_DATE=... GIT_COMMITTER_DATE=... git commit` prefixes in the fixture loop are +# applied per invocation, AFTER this line, and are deliberately unaffected. +# +# The node counterpart of this line is `gitFreeEnv()` in `scripts/git-env.mjs`, whose +# header carries the measurement and the one boundary. +unset $(env | sed -n 's/^\(GIT_[A-Za-z0-9_]*\)=.*/\1/p') + FRAMEWORK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OBJECTUI_ROOT="${OBJECTUI_ROOT:-$(cd "${FRAMEWORK_ROOT}/../objectui" 2>/dev/null && pwd || true)}" CLOUD_ROOT="${CLOUD_ROOT:-$(cd "${FRAMEWORK_ROOT}/../cloud" 2>/dev/null && pwd || true)}" diff --git a/scripts/docs-audit/check-drift-comment.mjs b/scripts/docs-audit/check-drift-comment.mjs index 2063410edc4..22b44742edd 100755 --- a/scripts/docs-audit/check-drift-comment.mjs +++ b/scripts/docs-audit/check-drift-comment.mjs @@ -69,6 +69,8 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import process from 'node:process'; +import { gitFreeEnv } from '../git-env.mjs'; + const HERE = dirname(new URL(import.meta.url).pathname); const REPO_ROOT = join(HERE, '..', '..'); const WORKFLOW = join(REPO_ROOT, '.github', 'workflows', 'docs-drift-check.yml'); @@ -163,8 +165,12 @@ async function renderComment(scriptText, affectedJson, { headSha = 'f'.repeat(40 // the sdk route bridge, so these repos (which declare no route ledger) do not trip the // bridge's broken-scan verdicts and the fixture stays about the headline. // --------------------------------------------------------------------------- +// LOCAL-ONLY (#16644): every `git` and every mapper run below is aimed at a throwaway +// fixture repository under `workdir`. `gitFreeEnv()` is the BASE so no inherited GIT_DIR / +// GIT_WORK_TREE / GIT_INDEX_FILE can outrank that `cwd` -- the identity and config-file +// pins that follow are deliberate and are re-applied ON TOP of the strip. const GIT_ENV = { - ...process.env, + ...gitFreeEnv(), GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@objectstack.ai', GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@objectstack.ai', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index 3bca1d83825..e50a21bf649 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -210,6 +210,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; // #16421 — the `fw-gate` sandbox below copies `check-adr-0087-registration.mjs` // in and runs it. Its staging manifest is DERIVED from that gate's module graph, @@ -316,6 +317,13 @@ const LEVEL_RANK = { patch: 1, minor: 2, major: 3 }; */ function git(cwd, args, { captureStderr = false } = {}) { return execFileSync('git', ['-C', cwd, ...args], { + // LOCAL-ONLY, every caller (#16644). This helper serves `rev-parse`, `log`, `show`, + // `cat-file`, `merge-base`, `update-ref`, `init`, `add` and `commit` -- all against + // the repository `-C cwd` names, which is the objectui checkout on a real run and a + // mkdtemp fixture throughout the self-test. ⛔ No caller fetches, clones or pushes + // through it, so no transport configuration is lost; the two `clone`s in this file + // are spawned separately and labelled where they stand. + env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...(captureStderr ? { stdio: ['ignore', 'pipe', 'pipe'] } : {}), @@ -1797,7 +1805,9 @@ function selfTest() { const unwalkableRun = spawnSync( 'bash', [join(fwDegraded, 'scripts', 'bump-objectui.sh'), '--no-commit', head], - { encoding: 'utf8', env: { ...process.env, OBJECTUI_ROOT: ui, GIT_TERMINAL_PROMPT: '0' } }, + // #16644: `gitFreeEnv()` as the BASE -- `bump-objectui.sh` spawns `git` one frame + // down against the fixture, where an inherited GIT_DIR outranks its `cwd`. + { encoding: 'utf8', env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui, GIT_TERMINAL_PROMPT: '0' } }, ); const unwalkableCs = join(fwDegraded, '.changeset', `console-${head.slice(0, 12)}.md`); check( @@ -1823,7 +1833,9 @@ function selfTest() { const initialRun = spawnSync( 'bash', [join(fwInitial, 'scripts', 'bump-objectui.sh'), '--no-commit', head], - { encoding: 'utf8', env: { ...process.env, OBJECTUI_ROOT: ui, GIT_TERMINAL_PROMPT: '0' } }, + // #16644: `gitFreeEnv()` as the BASE -- `bump-objectui.sh` spawns `git` one frame + // down against the fixture, where an inherited GIT_DIR outranks its `cwd`. + { encoding: 'utf8', env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui, GIT_TERMINAL_PROMPT: '0' } }, ); const initialCs = join(fwInitial, '.changeset', `console-${head.slice(0, 12)}.md`); const initialBody = existsSync(initialCs) ? readFileSync(initialCs, 'utf8') : ''; @@ -2711,7 +2723,7 @@ function selfTest() { // and this run is also the opt-out's only coverage. const truncBump = spawnSync('bash', [join(fwTrunc, 'scripts', 'bump-objectui.sh'), '--no-commit', c6to], { encoding: 'utf8', - env: { ...process.env, OBJECTUI_ROOT: ui6, OBJECTUI_NO_DEEPEN: '1' }, + env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui6, OBJECTUI_NO_DEEPEN: '1' }, // #16644: see above }); const truncCsPath = join(fwTrunc, '.changeset', `console-${c6to.slice(0, 12)}.md`); check( @@ -2759,7 +2771,7 @@ function selfTest() { stageBumpDriver(fwTrunc2); const noopDeepen = spawnSync('bash', [join(fwTrunc2, 'scripts', 'bump-objectui.sh'), '--no-commit', c6to], { encoding: 'utf8', - env: { ...process.env, OBJECTUI_ROOT: ui6, GIT_TERMINAL_PROMPT: '0' }, + env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui6, GIT_TERMINAL_PROMPT: '0' }, // #16644: see above }); const noopDeepenCsPath = join(fwTrunc2, '.changeset', `console-${c6to.slice(0, 12)}.md`); check( @@ -2837,7 +2849,11 @@ function selfTest() { const args = ['clone', '-q']; if (depth) args.push('--depth', String(depth)); args.push(pathToFileURL(uiUp).href, dir); - execFileSync('git', args, { encoding: 'utf8' }); + // LOCAL-ONLY despite being a `clone` (#16644): the source is a `file://` URL under + // this battery's own mkdtemp root, so the transport settings this container carries + // in GIT_CONFIG_* / GIT_SSL_* are not in play -- while an inherited GIT_DIR would + // still decide where the clone lands. + execFileSync('git', args, { encoding: 'utf8', env: gitFreeEnv() }); // A real checkout has this ref, and #10495's reachability report keys on // it — without it these cases would bury their assertions under warnings. git(dir, ['update-ref', 'refs/remotes/origin/main', upTo]); @@ -2933,7 +2949,7 @@ function selfTest() { const runShellBump = (fwDir, uiRoot, extraEnv = {}) => spawnSync('bash', [join(fwDir, 'scripts', 'bump-objectui.sh'), '--no-commit', upTo], { encoding: 'utf8', - env: { ...process.env, OBJECTUI_ROOT: uiRoot, GIT_TERMINAL_PROMPT: '0', ...extraEnv }, + env: { ...gitFreeEnv(), OBJECTUI_ROOT: uiRoot, GIT_TERMINAL_PROMPT: '0', ...extraEnv }, // #16644: see above }); const csName = `console-${upTo.slice(0, 12)}.md`; @@ -3036,7 +3052,11 @@ function selfTest() { spawnSync('bash', [join(fwDir, 'scripts', 'bump-objectui.sh'), ...args], { encoding: 'utf8', env: { - ...process.env, + // #16644: `gitFreeEnv()` rather than `process.env` as the BASE. This child is + // `bash`, not `git`, but `bump-objectui.sh` spawns `git` one frame down against + // the fixture repositories below, and an inherited GIT_DIR outranks their `cwd` + // there exactly as it would here. The deliberate keys are re-applied ON TOP. + ...gitFreeEnv(), OBJECTUI_ROOT: uiRoot, OBJECTUI_NO_DEEPEN: '1', GIT_TERMINAL_PROMPT: '0', @@ -3190,6 +3210,7 @@ function selfTest() { }); const revParseMissing = spawnSync('git', ['-C', uiMiss.dir, 'rev-parse', 'HEAD'], { encoding: 'utf8', + env: gitFreeEnv(), // LOCAL-ONLY (#16644): the fixture `-C` names, never a hook's repo }); check( '#10495 R5a `git rev-parse HEAD` exits 0 for a commit whose OBJECT is gone — so NEW_SHA arriving is no proof of presence', @@ -3199,7 +3220,9 @@ function selfTest() { const isAncestorMissing = spawnSync( 'git', ['-C', uiMiss.dir, 'merge-base', '--is-ancestor', missHead, 'origin/main'], - { encoding: 'utf8' }, + // LOCAL-ONLY (#16644): `origin/main` here is a ref this fixture wrote with + // `update-ref`, not a remote to reach. + { encoding: 'utf8', env: gitFreeEnv() }, ); check( '#10495 R5b `merge-base --is-ancestor` exits 128 on an absent object — an ERROR, not the "no" that 1 means', diff --git a/scripts/objectui-range.mjs b/scripts/objectui-range.mjs index 90f25874fca..3b1dede8fd5 100644 --- a/scripts/objectui-range.mjs +++ b/scripts/objectui-range.mjs @@ -78,6 +78,7 @@ import { dirname, join } from 'node:path'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { classifyRange, clampSummary } from './objectui-changeset-digest.mjs'; +import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const FRAMEWORK_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); @@ -134,8 +135,12 @@ function die(msg) { process.exit(1); } +// LOCAL-ONLY, every caller (#16644): `show`, `log` and `rev-parse` against the +// repository `-C cwd` names -- the objectui checkout on a real run, a mkdtemp fixture in +// the self-test. ⛔ Nothing here fetches, clones or pushes, so the blanket strip takes no +// transport configuration away; an inherited GIT_DIR would otherwise outrank `-C`. function git(cwd, args) { - return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim(); + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitFreeEnv() }).trim(); } // Resolve the objectui SHA pinned at a given framework rev (or the working tree). @@ -658,7 +663,9 @@ function selfTest() { const run = (args) => execFileSync('node', [cli, ...args], { encoding: 'utf8', - env: { ...process.env, OBJECTUI_ROOT: ui }, + // #16644: `gitFreeEnv()` as the BASE. The child re-enters this file against the + // fixture at `ui`; its own helper strips too, and this closes the frame above it. + env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui }, }); const cliMd = run(['--from', base, '--to', head]); check( From cf11a466178fa9a4521172870c3c22ea9d121ccd Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 19 Sep 2026 03:42:35 +0000 Subject: [PATCH 2/4] fix(scripts): strip GIT_* for the shell-driver leg of objectui-changeset-digest too The `execFileSync('bash', [bump-objectui.sh, ...])` end-to-end leg still spread `process.env`, so under a leaked GIT_DIR the driver's own `git rev-parse ^{commit}` resolved against the leaked repository and the self-test died. Measured: with this spawn left ambient the `--self-test` exited 1 under the leak probe while every other file exited 0. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- scripts/objectui-changeset-digest.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index e50a21bf649..2a25ff19e90 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -1762,7 +1762,9 @@ function selfTest() { [join(fwRun, 'scripts', 'bump-objectui.sh'), '--no-commit', head], { encoding: 'utf8', - env: { ...process.env, OBJECTUI_ROOT: ui }, + // #16644: `gitFreeEnv()` as the BASE -- `bump-objectui.sh` spawns `git` one frame + // down against the fixture, where an inherited GIT_DIR outranks its `cwd`. + env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui }, }, ); // #5960: the pin bump is the ONLY trigger of ADR-0082 D4's declaration-parity From 6b398c3d48c95b2498f0f7e2ba7fb4ce925ad9ad Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 19 Sep 2026 04:00:31 +0000 Subject: [PATCH 3/4] chore(spec): declare sharded-artifacts.test.ts as a repo-scope test Converging its fixture environment onto `gitFreeEnv()` makes the file import `scripts/git-env.mjs`, so it now reads outside its package. `check:cross-package -test-inputs` said so (exit 1, naming the file): an escaping test left in the `local` project runs under a task whose hash never moves with what it reads, so it can go red on main while every PR reports green. Verified after: the gate prints OK, and `vitest list` puts the file in `repo` and no longer in `local`. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- packages/spec/vitest.repo-tests.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index c3cda990caa..ff7bf00674f 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -20,6 +20,7 @@ "scripts/references-banner.test.ts", "scripts/root-index.test.ts", "scripts/schema-tree-freshness.test.ts", + "scripts/sharded-artifacts.test.ts", "scripts/solution-blueprint-header-row.test.ts", "scripts/strictness-ledger-doc.test.ts", "scripts/strictness-ledger.test.ts", From 7992d19d5d6a77f09554c29debb1d7272e164c10 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 19 Sep 2026 04:23:02 +0000 Subject: [PATCH 4/4] fix(scripts): add the git-env declaration mirror the spec TS lane needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI red on 6b398c3d4: `packages/spec` `pnpm run typecheck` exited 2 with TS7016 at both converted fixtures -- "Could not find a declaration file for module '../../../scripts/git-env.mjs'". Reproduced locally against `check:scripts-typecheck`, the lane that owns it: `tsconfig.scripts.json` includes `scripts/**/*`, inherits `strict` from the root config and sets no `allowJs`, so an untyped `.mjs` import from `packages/spec/scripts/` is TS7016. The mechanical difference from the live precedent: `scripts/js-comment-mask.mjs` and `scripts/check-regen-pending.mjs` each ship a hand-written `.d.mts` sibling (#5475, #10398) -- nine of them existed before this commit -- and `scripts/git-env.mjs` did not. That is also why the pre-existing `check-regen-pending.mjs` import in the very same test file type-checks. So this copies that shape and nothing else: one sibling declaration, PARTIAL on purpose per `invoked-as.d.mts`, declaring only the export TypeScript consumes. `check:declaration-mirrors` discovers the corpus rather than listing it and now reports "OK: 10 hand-written declaration(s) agree with their modules on name, kind and required arity" -- `gitFreeEnv`'s `Function.length` is 0 and the declaration's required arity is 0. ⚠️ This is one file beyond the declared surface, and it is declared as such: it is the mandatory accompaniment of the import triage ordered, and the two remedies that avoid a new file are the two the dispatch refuses. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- scripts/git-env.d.mts | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 scripts/git-env.d.mts diff --git a/scripts/git-env.d.mts b/scripts/git-env.d.mts new file mode 100644 index 00000000000..5adb39b66a2 --- /dev/null +++ b/scripts/git-env.d.mts @@ -0,0 +1,43 @@ +// Types for the git-environment strip `git-env.mjs` publishes to the gates and +// fixtures that import it (#16644). +// +// The module itself stays `.mjs`, for the reason its three sibling mirrors +// state: `pre-commit`, the merge driver and every `check:*` gate invoke these +// root scripts with bare `node`, and every root script here is authored that +// way. What needed the declaration is the other direction — two TypeScript +// fixtures under `packages/spec/scripts/` now import the strip +// (`build-schemas-check-mode.test.ts`, `sharded-artifacts.test.ts`), and since +// #5475 that directory is inside a tsc program (`tsconfig.scripts.json`), where +// an untyped `.mjs` import is TS7016. Left untyped, `gitFreeEnv` silently +// becomes `any`, and the mistake that costs the most here — `env: gitFreeEnv` +// instead of `env: gitFreeEnv()`, which hands the child a FUNCTION where an +// environment belongs — type-checks clean. +// +// Declared rather than inferred (no `allowJs`) because the module sits at the +// repo root, outside the consuming program's `rootDir`. +// +// PARTIAL on purpose, the `check-regen-pending.d.mts` / `invoked-as.d.mts` +// shape: the module also exports `withoutGitEnv`, `gitEnvKeys`, +// `sharedGitConfigVerdict`, `formatSharedGitConfigAlarm`, `selfTest` and two +// string constants, and omitting them cannot fail green — a consumer importing +// an undeclared name gets TS2305, which is loud and immediate. Every one of +// those is reached today from `.mjs` callers, which need no declaration at all, +// so declaring them would grow a hand-maintained surface nothing is asking for. +// Keep this file in step with the module by hand; `check:declaration-mirrors` +// asserts the name, kind and required arity of each entry — never the types, +// which stay yours. + +/** + * A copy of `base` with every `GIT_`-prefixed key removed. + * + * Returns a NEW object; `process.env` is not mutated. Pass the result as the + * `env` option of every `git` child that must operate on the repository its + * `cwd` and arguments name ALONE. + * + * ⛔ Not for a child that talks to a remote: `GIT_CONFIG_*` and `GIT_SSL_*` + * carry the transport configuration, and the module's header carries the + * measurement behind that boundary. + * + * @param base Defaults to `process.env`. + */ +export function gitFreeEnv(base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;