Skip to content

Commit a194b4e

Browse files
fix(scripts): an injected tree hashes apart from an installed one, so a local Spec Main Shape Gate reproduction cannot leave verdicts a sibling worktree replays (objectui#12114) (#12125)
Fixes #12114 Dispatched implementation, PM seat `domain:devx` round R83; session `https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W`. ## What changed - **`turbo.json`** gains `"globalDependencies": ["node_modules/@objectstack/spec/.spec-main-shape-gate.json"]`: the marker `inject` already writes into every replaced spec copy, reached by a literal path through the repository root's own link to the spec (the root manifest declares `@objectstack/spec`). - **`scripts/spec-main-shape-gate.mjs`** exports `TURBO_HASH_MARKER`. `inject` now logs that turbo will hash the marker, or warns when there is no root link to hash. The header gains the section "...and an injected tree must not leave a verdict an INSTALLED tree replays", and the existing "The turbo cache is a FALSE GREEN here" section is reworded so it stays true. - **`scripts/__tests__/spec-main-shape-gate.test.ts`**: three new pins (below). - **Four `scripts/__tests__/turbo-*-inputs.test.ts` headers** said "`globalDependencies` is unset", which this change makes false. They now say it "holds no source file". These are comment-only edits outside the claim's declared file surface (see Acceptance notes). - `.github/workflows/spec-main-shape-gate.yml` is **not touched**. CI still runs `TURBO_FORCE=true pnpm type-check`, and its existing pin is unchanged. No flag to remember. An installed tree has no marker and hashes exactly as before. An injected tree hashes apart from every installed tree, in both directions. Two injections never share an entry, because the marker carries the sha and the injection time. A reinstall replaces the copy, so the marker goes with it. ## Mechanism readings (turbo 2.10.9, from the lockfile) - **The cache is shared across worktrees (H1).** The first turbo dry run in this linked worktree created the main checkout's `.turbo/cache` (the worktree got none of its own). Every run logs `Remote caching disabled, using shared worktree cache`. - **`--force` still writes.** `turbo run --help` defines `--force` as "Equivalent to `--cache=local:w,remote:w`". The forced CI-recipe run in the injected worktree wrote entry `e02dab71f230fcc0` into that shared directory. So adding `TURBO_FORCE=true` to a local reproduction protects that run's reads and does nothing about its writes. - **Injection does not move the hash on main (H2).** Same worktree, before and after `inject` with main's script: the filtered `@object-ui/types#type-check` hashes `e02dab71f230fcc0` both times. On the whole `turbo run type-check` graph (dry run), 83 of 83 task hashes are equal before and after injecting (46 of 46 type-check tasks). - **What turbo 2.10 hashes from `globalDependencies` (H4, measured with dry runs).** - A literal path into `node_modules` IS hashed, although git ignores it, including a path through the root symlink. - A wildcard inside `node_modules` (for example `node_modules/.pnpm/@objectstack+spec@*/node_modules/@objectstack/spec/...`) hashes **nothing**, with no warning. That is why the entry is a literal path, and the root link is what makes a literal path version-free. - An absent file hashes as nothing. Build task hashes are equal with and without the entry. - **Undoing an injection.** - A plain `pnpm install --frozen-lockfile` keeps both the injection and the marker. - `pnpm install --frozen-lockfile --force` replaces the copy and removes the marker with it. The hash went back to `95eb3fecb9111eb5`, the same as the sibling worktree's installed hash. ## Live two-worktree reproduction, before and after **Setup.** - Worktree A is this branch. Worktree B is a second worktree at the same commit. - Both have an identical untracked probe in `packages/types/src` that imports a type named `SpecGateProbe12114` from `@objectstack/spec`. - The tarball handed to `inject` is the installed published spec plus that one export. Deviation: it is not built from objectstack `main`. The thing under test is turbo's cache key, which never reads the spec's bytes, and the probe makes the false verdict visible. - Every step runs `turbo run type-check --filter=@object-ui/types`. **Before** (`29b99490`, main's script and `turbo.json`): | step | turbo output | verdict | |---|---|---| | A: `inject`, then `TURBO_FORCE=true` (the CI recipe) | `cache bypass, force executing e02dab71f230fcc0` | green, correct for the injected spec | | B (installed spec), unforced | `cache hit, replaying logs e02dab71f230fcc0`, `Cached: 1 cached, 2 total`; the replayed log even names worktree A's path | **exit 0: FALSE GREEN** | | B, forced | `src/__gate_probe_12114__.ts(2,15): error TS2305: Module '"@objectstack/spec"' has no exported member 'SpecGateProbe12114'.` | red, the true verdict | **After** (`81dea3ff`; the head `a1b9bd68` differs only by a header comment): | step | turbo output | verdict | |---|---|---| | A and B installed, dry run | both `95eb3fecb9111eb5`; whole graph 83 of 83 equal | installed trees still share | | A: `inject` | logs `turbo hashes node_modules/@objectstack/spec/.spec-main-shape-gate.json, ...`; hash now `67c390de3fefe78f`; whole graph 0 of 83 equal to installed | injected tree hashes apart | | A, forced | `cache bypass, force executing 67c390de3fefe78f` | green | | B (installed), unforced | `cache miss, executing 95eb3fecb9111eb5` then TS2305, `Cached: 0 cached, 2 total` | **red, the true verdict** | | A, unforced again | `cache hit, replaying logs 67c390de3fefe78f` | reuses only its own injected entry | The full 83-task graph was dry-run, not executed: executing every build would overrun the container's foreground limit. The dry-run counts cover the cache key of every task. ## Pins, and proof that they can fail The new `describe` block, titled "a local reproduction cannot leave a verdict an installed tree replays (objectui#12114)", holds three pins: 1. **Wiring.** `turbo.json` names `TURBO_HASH_MARKER`, which is `node_modules/` + spec + `MARKER_FILE`. The path has no glob characters, the root manifest declares the spec, and `node_modules` is git-ignored. 2. **Real turbo.** It runs `--dry=json` over a fixture git repository that copies this repository's `turbo.json`, with pnpm-shaped relative links and the real `inject` CLI. An injection moves every task hash, a second injection moves them again, and a simulated reinstall puts the hashes back exactly. 3. **Firing control.** With no root link, the same injection leaves the hashes unchanged and `inject` prints the warning. The marker inside the virtual store alone is invisible to turbo. Red runs, each made from a committed state with the paths restored from `HEAD` afterwards (`git diff HEAD` empty, and each path's blob hash equal to HEAD's): - **On main's code** (script and `turbo.json` at `29b99490`, new test file): `Tests 3 failed | 28 passed (31)`. - **Ablation of the `turbo.json` entry alone**, script kept: `Tests 2 failed | 29 passed (31)`. The wiring pin fails on the missing entry. The real-turbo pin fails with `expected [ 'package.json' ] to include 'node_modules/@objectstack/spec/.spec-…'`. The firing control stays green. ## Gates (at `a1b9bd68`) | command | exit | reading | |---|---|---| | `pnpm exec vitest run` over the 13 test files that read `turbo.json` or the gate script (`git grep -l -e turbo.json -e spec-main-shape-gate` under `scripts/__tests__`) | 0 | `Test Files 13 passed (13)`, `Tests 625 passed (625)` | | `pnpm lint:root` | 0 | `0 errors`; none of the warnings is in a file this PR touches | | `pnpm check:new-line-citations` | 0 | `0 new citation(s)` | | `pnpm check:control-bytes` | 0 | OK | | `node scripts/check-changeset-presence.mjs` | 0 | "no changeset is owed" (7 files, none a published source), so no changeset is added | | `node scripts/spec-main-shape-gate.mjs --self-test` | 0 | `26/26 passed` | | `node scripts/check-governed-queue-guard.mjs --test` on the 7 paths | 0 | `NOT GOVERNED` | ## Cost The `turbo.json` edit moves the hash of the root task `//#type-check:e2e`, and every type-check task depends on it. Compared on an installed tree (whole-graph dry run, `29b99490` against this branch), 0 of 46 type-check hashes carry over and 36 of 36 build hashes carry over. That is a one-time cache miss, the same as any `turbo.json` edit. ## Acceptance notes - **File surface.** The four `turbo-*-inputs.test.ts` comment lines are outside the claim's declared surface. They were edited because this change would otherwise leave their prose false. No open PR touches them. - **Prose still true but not extended.** `content/docs/guide/ci-cd-pipeline.md` (Spec Main Shape Gate section) and the fenced workflow header still say turbo's hash does not cover the content of `node_modules`. That remains true: the marker is the injection's identity, not the spec's content. Neither one describes the local reproduction recipe, which lives only in the script header. Carrier: none. - **Leftover cache entries.** The reproduction left two inert entries in the main checkout's `.turbo/cache`: `e02dab71f230fcc0` and `67c390de3fefe78f`. The first is keyed on a probe file that no longer exists; the second on a unique injection marker. My attempt to delete them was refused by the sandbox as a shared-resource write. - **CI behaviour.** Unchanged, except that the inject step prints one more log line. --- _Generated by [Claude Code](https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5a65f7d commit a194b4e

7 files changed

Lines changed: 226 additions & 11 deletions

‎scripts/__tests__/spec-main-shape-gate.test.ts‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
satisfiesRange,
1212
parseWorkspaceGlobs,
1313
MARKER_FILE,
14+
SPEC_PACKAGE_NAME,
15+
TURBO_HASH_MARKER,
1416
} from '../spec-main-shape-gate.mjs';
1517
import { pullRequestTrigger, readWorkflows, repoRoot, subscribesMergeGroup } from './workflow-checks.js';
1618

@@ -564,3 +566,162 @@ describe('the injection reads the spec against the dependencies it was built wit
564566
expect(() => satisfiesRange('4.6.1', 'workspace:*')).toThrow(/does not read/);
565567
});
566568
});
569+
570+
/**
571+
* objectui#12114 — a linked git worktree shares the main checkout's turbo
572+
* cache, and `--force` skips cache reads but still WRITES. So a local
573+
* reproduction of this gate recorded main-spec verdicts that a sibling
574+
* worktree's ordinary, installed-spec `pnpm type-check` replayed as its own: a
575+
* false green. The fix puts the injection inside turbo's hash, through
576+
* `turbo.json`'s `globalDependencies`; the script header carries the why.
577+
*
578+
* The second case drives a REAL turbo (`--dry=json`, which computes the hashes
579+
* and runs nothing) over a fixture that copies this repository's `turbo.json`,
580+
* so it fails the day that file stops hashing the marker.
581+
*/
582+
describe('a local reproduction cannot leave a verdict an installed tree replays (objectui#12114)', () => {
583+
const made: string[] = [];
584+
afterEach(() => {
585+
for (const dir of made.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
586+
});
587+
588+
const STORE_SPEC = 'node_modules/.pnpm/@objectstack+spec@17.4.0/node_modules/@objectstack/spec';
589+
590+
/**
591+
* An installed tree, linked the way pnpm links it (RELATIVE symlinks into the
592+
* virtual store), in a git repository that ignores `node_modules` as this one
593+
* does: the file turbo has to hash is an ignored one.
594+
*/
595+
function installedTree({ rootLink }: { rootLink: boolean }) {
596+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-gate-turbo-'));
597+
made.push(root);
598+
const repo = path.join(root, 'objectui');
599+
fs.mkdirSync(path.join(repo, 'scripts'), { recursive: true });
600+
for (const file of ['spec-main-shape-gate.mjs', 'invoked-as.mjs']) {
601+
fs.copyFileSync(path.join(repoRoot, 'scripts', file), path.join(repo, 'scripts', file));
602+
}
603+
fs.copyFileSync(path.join(repoRoot, 'turbo.json'), path.join(repo, 'turbo.json'));
604+
605+
const store = path.join(repo, STORE_SPEC);
606+
// What a reinstall does to an injected copy: the directory is replaced.
607+
const install = () => {
608+
fs.rmSync(store, { recursive: true, force: true });
609+
fs.mkdirSync(store, { recursive: true });
610+
fs.writeFileSync(path.join(store, 'package.json'), JSON.stringify({ name: SPEC_PACKAGE_NAME, version: '17.4.0' }));
611+
};
612+
install();
613+
const devDependencies = rootLink ? { [SPEC_PACKAGE_NAME]: '^17.4.0' } : {};
614+
if (rootLink) {
615+
fs.mkdirSync(path.join(repo, 'node_modules', '@objectstack'), { recursive: true });
616+
fs.symlinkSync(`../.pnpm/@objectstack+spec@17.4.0/node_modules/@objectstack/spec`, path.join(repo, 'node_modules', '@objectstack', 'spec'));
617+
}
618+
const app = path.join(repo, 'packages', 'app');
619+
fs.mkdirSync(path.join(app, 'node_modules', '@objectstack'), { recursive: true });
620+
fs.symlinkSync(`../../../../${STORE_SPEC}`, path.join(app, 'node_modules', '@objectstack', 'spec'));
621+
fs.writeFileSync(
622+
path.join(app, 'package.json'),
623+
JSON.stringify({ name: 'app', scripts: { 'type-check': 'tsc --noEmit' }, dependencies: { [SPEC_PACKAGE_NAME]: '^17.4.0' } }),
624+
);
625+
fs.writeFileSync(
626+
path.join(repo, 'package.json'),
627+
JSON.stringify({ name: 'root', private: true, packageManager: 'pnpm@10.31.0', devDependencies }),
628+
);
629+
fs.writeFileSync(path.join(repo, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n");
630+
fs.writeFileSync(path.join(repo, '.gitignore'), 'node_modules\n.turbo\n');
631+
execFileSync('git', ['init', '-q'], { cwd: repo });
632+
633+
const upstream = path.join(root, 'objectstack');
634+
fs.mkdirSync(path.join(upstream, 'packages', 'spec'), { recursive: true });
635+
fs.writeFileSync(path.join(upstream, 'packages', 'spec', 'package.json'), JSON.stringify({ name: SPEC_PACKAGE_NAME }));
636+
const pack = path.join(root, 'pack', 'package');
637+
fs.mkdirSync(path.join(pack, 'dist'), { recursive: true });
638+
fs.writeFileSync(path.join(pack, 'dist', 'index.d.ts'), 'export {};\n');
639+
fs.writeFileSync(
640+
path.join(pack, 'package.json'),
641+
JSON.stringify({ name: SPEC_PACKAGE_NAME, version: '17.4.0', exports: { '.': { types: './dist/index.d.ts' } } }),
642+
);
643+
const tarball = path.join(root, 'spec.tgz');
644+
execFileSync('tar', ['-czf', tarball, '-C', path.join(root, 'pack'), 'package']);
645+
646+
const inject = () =>
647+
spawnSync(
648+
'node',
649+
[path.join(repo, 'scripts', 'spec-main-shape-gate.mjs'), 'inject', '--tarball', tarball, '--sha', 'f'.repeat(40), '--upstream-checkout', upstream],
650+
{ encoding: 'utf8' },
651+
);
652+
// The global files turbo hashed, and every task's hash.
653+
const hashes = () => {
654+
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('TURBO_')));
655+
const dry = JSON.parse(
656+
execFileSync(path.join(repoRoot, 'node_modules', '.bin', 'turbo'), ['run', 'type-check', '--dry=json'], {
657+
cwd: repo,
658+
encoding: 'utf8',
659+
env: { ...env, TURBO_TELEMETRY_DISABLED: '1' },
660+
// turbo warns that the fixture has no lockfile; a failure still throws with it.
661+
stdio: ['ignore', 'pipe', 'pipe'],
662+
}),
663+
);
664+
const tasks: Record<string, string> = {};
665+
for (const task of dry.tasks) tasks[task.taskId] = task.hash;
666+
return { files: Object.keys(dry.globalCacheInputs.files), tasks };
667+
};
668+
return { install, inject, hashes };
669+
}
670+
671+
it('turbo.json hashes the marker inject writes, by a literal path through the root link', () => {
672+
expect(TURBO_HASH_MARKER).toBe(`node_modules/${SPEC_PACKAGE_NAME}/${MARKER_FILE}`);
673+
const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), 'utf8'));
674+
expect(turbo.globalDependencies ?? [], 'without it an injected tree and an installed one hash alike').toContain(
675+
TURBO_HASH_MARKER,
676+
);
677+
// turbo does not expand a wildcard inside node_modules: a glob there would
678+
// hash nothing, silently.
679+
expect(TURBO_HASH_MARKER).not.toMatch(/[*?[{!]/);
680+
// pnpm creates the root link the path goes through only because the root
681+
// manifest declares the spec.
682+
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
683+
expect(
684+
['dependencies', 'devDependencies', 'optionalDependencies'].some((field) => manifest[field]?.[SPEC_PACKAGE_NAME]),
685+
`the root manifest no longer declares ${SPEC_PACKAGE_NAME}, so ${TURBO_HASH_MARKER} never exists`,
686+
).toBe(true);
687+
// The marker can never be committed.
688+
expect(spawnSync('git', ['check-ignore', '-q', 'node_modules'], { cwd: repoRoot }).status).toBe(0);
689+
});
690+
691+
it('real turbo: an injected tree hashes apart from an installed one, and back once the injection is undone', () => {
692+
const tree = installedTree({ rootLink: true });
693+
const installed = tree.hashes();
694+
expect(installed.tasks).toHaveProperty(['app#type-check']);
695+
expect(installed.files).not.toContain(TURBO_HASH_MARKER);
696+
697+
const first = tree.inject();
698+
expect(first.status, first.stderr).toBe(0);
699+
expect(first.stdout).toContain(`turbo hashes ${TURBO_HASH_MARKER}`);
700+
const injected = tree.hashes();
701+
expect(injected.files).toContain(TURBO_HASH_MARKER);
702+
for (const [task, hash] of Object.entries(installed.tasks)) {
703+
expect(injected.tasks[task], `${task} hashes as installed after an injection`).not.toBe(hash);
704+
}
705+
706+
// A second injection at the same sha shares no entry with the first.
707+
expect(tree.inject().status).toBe(0);
708+
const again = tree.hashes();
709+
for (const [task, hash] of Object.entries(injected.tasks)) expect(again.tasks[task]).not.toBe(hash);
710+
711+
// Undone the way a reinstall undoes it, the tree hashes as installed again:
712+
// the marker cannot outlive the injection.
713+
tree.install();
714+
expect(tree.hashes()).toEqual(installed);
715+
});
716+
717+
it('the firing control: without the root link the same injection hashes as installed, and inject says so', () => {
718+
// The marker inside the virtual store alone is invisible to turbo, so the
719+
// hash above moved because of the root-linked path and nothing else.
720+
const tree = installedTree({ rootLink: false });
721+
const installed = tree.hashes();
722+
const result = tree.inject();
723+
expect(result.status, result.stderr).toBe(0);
724+
expect(result.stdout).toContain(`⚠️ ${TURBO_HASH_MARKER} is absent`);
725+
expect(tree.hashes()).toEqual(installed);
726+
});
727+
});

‎scripts/__tests__/turbo-build-inputs.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { CONFIG_FILES as VITEST_CONFIG_FILES } from './helpers/vitest-config-pro
1919
* the one with the widest blast radius.
2020
*
2121
* Turbo hashes a task from its `inputs`. `$TURBO_DEFAULT$` covers only files
22-
* inside the package directory, and `globalDependencies` is unset — so any file
22+
* inside the package directory, and `globalDependencies` holds no source file — so any file
2323
* the task reads from elsewhere in the repo is invisible to the cache key. When
2424
* such a file changes, turbo does not re-run the task; it REPLAYS the previous
2525
* verdict. `build` declared no `inputs` AT ALL, so it ran on that default,

‎scripts/__tests__/turbo-lint-inputs.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
* `test`, and it is the starkest of the three.
2424
*
2525
* Turbo hashes a task from its `inputs`. `$TURBO_DEFAULT$` covers only files
26-
* inside the package directory, and `globalDependencies` is unset — so any file
26+
* inside the package directory, and `globalDependencies` holds no source file — so any file
2727
* the task reads from elsewhere in the repo is invisible to the cache key. When
2828
* such a file changes, turbo does not re-run the task; it REPLAYS the previous
2929
* verdict. `lint` declared no `inputs` AT ALL, so it ran on that default:

‎scripts/__tests__/turbo-test-inputs.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
* that objectui#3514 closed for `type-check`, and it has it worse.
2020
*
2121
* Turbo hashes a task from its `inputs`. `$TURBO_DEFAULT$` covers only files
22-
* inside the package directory, and `globalDependencies` is unset — so any file
22+
* inside the package directory, and `globalDependencies` holds no source file — so any file
2323
* the task reads from elsewhere in the repo is invisible to the cache key. When
2424
* such a file changes, turbo does not re-run the task; it REPLAYS the previous
2525
* verdict. Before this guard, EVERY entry in the `test` task's list was

‎scripts/__tests__/turbo-type-check-inputs.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { outOfPackageFiles } from './helpers/tsc-program';
1414
* program reaches OUTSIDE its own package directory.
1515
*
1616
* Turbo hashes a task from its `inputs`. `$TURBO_DEFAULT$` covers only files
17-
* inside the package directory, and `globalDependencies` is unset — so any file
17+
* inside the package directory, and `globalDependencies` holds no source file — so any file
1818
* the program reads from elsewhere in the repo is invisible to the cache key.
1919
* When such a file changes, turbo does not re-run the task; it REPLAYS the
2020
* previous verdict. That is a gate whose answer depends on cache state rather

‎scripts/spec-main-shape-gate.mjs‎

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,45 @@
158158
*
159159
* `turbo`'s `type-check` task is cached, and its hash covers this repository's
160160
* sources, its lockfile and a declared env list -- it does NOT cover the CONTENT
161-
* of `node_modules`. An injected spec therefore changes nothing turbo hashes:
162-
* a second run after an injection can replay the verdict recorded BEFORE it, and
163-
* a run at a new objectstack sha can replay the verdict from the old one. Both
164-
* are green answers to a question that was never asked. The workflow runs the
165-
* typecheck with the cache bypassed for exactly this reason, and
166-
* `scripts/__tests__/spec-main-shape-gate.test.ts` pins that it keeps doing so.
161+
* of `node_modules`. The injected spec's own files therefore change nothing
162+
* turbo hashes: a second run after an injection could replay the verdict
163+
* recorded BEFORE it, and a run at a new objectstack sha the verdict from the
164+
* old one. Both are green answers to a question that was never asked. The
165+
* workflow runs the typecheck with the cache bypassed for exactly this reason,
166+
* and `scripts/__tests__/spec-main-shape-gate.test.ts` pins that it keeps doing
167+
* so. The marker below closes both cases too; the bypass does not lean on it.
168+
*
169+
* ## ...and an injected tree must not leave a verdict an INSTALLED tree replays (objectui#12114)
170+
*
171+
* The reverse direction is local, and the bypass does not close it: `--force`
172+
* (`TURBO_FORCE=true`) skips cache READS and still WRITES. A linked git worktree
173+
* shares the main checkout's `.turbo/cache` (turbo logs "using shared worktree
174+
* cache"), so a reproduction in one worktree recorded main-spec verdicts that a
175+
* later ordinary `pnpm type-check` in a sibling -- same sources, installed spec
176+
* -- replayed as its own. The measurement is on this issue's pull request.
177+
*
178+
* So the injection is part of turbo's hash: `turbo.json`'s `globalDependencies`
179+
* names `TURBO_HASH_MARKER`, the `MARKER_FILE` `inject` writes into every
180+
* replaced copy, reached through the root link to the spec. Measured on turbo
181+
* 2.10:
182+
*
183+
* - Absent, a global dependency hashes as nothing: installed trees share
184+
* entries with each other as before, and an injected tree hashes apart from
185+
* all of them in both directions, with no flag to remember. The marker holds
186+
* the sha and the injection time, so two injections never share one either.
187+
* - The path is LITERAL because turbo does not expand a wildcard inside
188+
* `node_modules`: a glob over the virtual store hashes nothing, silently. The
189+
* root link exists because the root manifest declares the spec; the consumer
190+
* proof below requires it to resolve to a marked copy, and `inject` says so
191+
* out loud when there is no root link to hash.
192+
* - The marker lives INSIDE the replaced copy, so it cannot outlive the
193+
* injection: `pnpm install --force` or a fresh install replaces that
194+
* directory, and the tree hashes as installed again. A plain `pnpm install`
195+
* leaves the injection and its marker both in place.
196+
*
197+
* A local reproduction is therefore `inject`, then `pnpm type-check --continue`
198+
* with no flag, then `pnpm install --frozen-lockfile --force` to undo it. The
199+
* test file above pins the wiring and drives a real turbo over a fixture.
167200
*/
168201

169202
import fs from 'node:fs';
@@ -183,6 +216,13 @@ export const UPSTREAM_REPO = 'objectstack-ai/objectstack';
183216
/** The marker `inject` leaves inside every replaced copy. */
184217
export const MARKER_FILE = '.spec-main-shape-gate.json';
185218

219+
/**
220+
* That marker as `turbo.json`'s `globalDependencies` names it: a LITERAL path
221+
* through the repository root's own link to the spec. See "...and an injected
222+
* tree must not leave a verdict" in the header.
223+
*/
224+
export const TURBO_HASH_MARKER = ['node_modules', SPEC_PACKAGE_NAME, MARKER_FILE].join('/');
225+
186226
const repoRootDefault = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
187227

188228
/**
@@ -833,7 +873,20 @@ export function inject({ tarball, sha, upstreamCheckout, repoRoot = repoRootDefa
833873
);
834874
}
835875

836-
return { targets, consumers, version: manifest.version, sha, substitutions, resolutions, oneCopy };
876+
// The marker turbo hashes (objectui#12114). The root link, when there is one,
877+
// already passed the consumer proof above, so this reads where turbo will look.
878+
const hashMarker = fs.existsSync(path.join(repoRoot, TURBO_HASH_MARKER)) ? TURBO_HASH_MARKER : null;
879+
log(
880+
hashMarker
881+
? `spec-main-shape-gate: turbo hashes ${hashMarker}, so this tree's task hashes no longer match an ` +
882+
`installed tree's: a type-check here neither replays an installed verdict nor leaves one for a sibling ` +
883+
`worktree. \`pnpm install --frozen-lockfile --force\` undoes the injection and the marker together.`
884+
: `spec-main-shape-gate: ⚠️ ${TURBO_HASH_MARKER} is absent -- no root link to the spec -- so turbo hashes ` +
885+
`this tree like an installed one, and a type-check here leaves verdicts a sibling worktree's installed ` +
886+
`type-check can replay.`,
887+
);
888+
889+
return { targets, consumers, version: manifest.version, sha, substitutions, resolutions, oneCopy, hashMarker };
837890
}
838891

839892
/**

‎turbo.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"$schema": "https://turbo.build/schema.json",
33
"globalEnv": ["NODE_ENV"],
4+
"globalDependencies": ["node_modules/@objectstack/spec/.spec-main-shape-gate.json"],
45
"tasks": {
56
"build": {
67
"dependsOn": ["^build"],

0 commit comments

Comments
 (0)