fix(#1441): antigravity resolver prefers GSD-owned dir over first-existing (#1442)

* fix(#1441): antigravity resolver prefers GSD-owned dir over first-existing

resolveConfigHomeFromDescriptor's dot-home-nested probe returned the first
bare-existing candidate, so a CLI user (~/.gemini/antigravity-cli) who also
had the IDE's ~/.gemini/antigravity dir was silently shadowed to the legacy
dir (probed first). Regression from #217 — pre-#217 returned
~/.gemini/antigravity unconditionally.

Add an optional probeMarker (gsd-core/VERSION) to the dot-home-nested
descriptor: a two-pass probe prefers the candidate GSD installed into, then
bare existence, then probe[0]. Behavior is byte-identical when probeMarker is
absent (windsurf etc. unaffected). Adds detectAntigravityDirAmbiguity() for
installer/operator guidance on already-misinstalled users (auto-relocation
ruled out per ADR-0008's single-configDir migration bound).

Regression tests fail before / pass after: coexistence + marker-priority
cases, end-to-end through the registry descriptor.

Claude-Session: https://claude.ai/code/session_01JU2WB23JLE3QTysPXuFJjB

* chore(#1441): add changeset for antigravity resolver fix

Claude-Session: https://claude.ai/code/session_01JU2WB23JLE3QTysPXuFJjB
This commit is contained in:
Rezolv
2026-06-18 21:47:18 -04:00
committed by GitHub
parent 9219af3360
commit dcceb1a004
10 changed files with 439 additions and 7 deletions

View File

@@ -159,6 +159,46 @@ describe('getGlobalConfigDir/getConfigDirFromHome — antigravity 2.x layout det
cleanup(home);
}
});
// #213/#217 coexistence regression (end-to-end through the registry descriptor).
// A CLI user who ALSO has the Antigravity-IDE's ~/.gemini/antigravity dir was
// previously shadowed to the legacy dir because it is probed first. The
// The probeExists marker (gsd-core/VERSION) makes the dir GSD installed into win.
test('coexistence: legacy antigravity + GSD-marked antigravity-cli both present → resolves to antigravity-cli', (t) => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-coexist-'));
t.after(() => cleanup(home));
// Both dirs exist on disk...
fs.mkdirSync(path.join(home, '.gemini', 'antigravity'), { recursive: true });
fs.mkdirSync(path.join(home, '.gemini', 'antigravity-cli', 'gsd-core'), { recursive: true });
// ...but only the cli dir carries the GSD marker.
fs.writeFileSync(path.join(home, '.gemini', 'antigravity-cli', 'gsd-core', 'VERSION'), '1.6.0\n');
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalConfigDir('antigravity'),
path.join(home, '.gemini', 'antigravity-cli'),
'GSD-marked antigravity-cli must win over the bare-existing legacy antigravity dir',
);
assert.strictEqual(
getConfigDirFromHome('antigravity', true),
"'.gemini', 'antigravity-cli'",
);
});
test('coexistence: legacy antigravity carries the GSD marker (real 1.x install) → resolves to legacy even when cli dir exists bare', (t) => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-legacy-marked-'));
t.after(() => cleanup(home));
fs.mkdirSync(path.join(home, '.gemini', 'antigravity', 'gsd-core'), { recursive: true });
fs.writeFileSync(path.join(home, '.gemini', 'antigravity', 'gsd-core', 'VERSION'), '1.5.0\n');
fs.mkdirSync(path.join(home, '.gemini', 'antigravity-cli'), { recursive: true });
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalConfigDir('antigravity'),
path.join(home, '.gemini', 'antigravity'),
'a genuine GSD install in the legacy dir must not be abandoned for a bare sibling',
);
});
});
describe('getGlobalConfigDir — explicit configDir overrides env for all runtimes', () => {

View File

@@ -29,6 +29,7 @@ const {
resolveKimiGlobalDir,
resolveConfigHomeFromDescriptor,
resolveSkillsBaseFromDescriptor,
detectAntigravityDirAmbiguity,
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'));
const HOME = os.homedir();
@@ -393,6 +394,109 @@ describe('descriptor-driven equivalence: dot-home-nested antigravity probe hit/m
}
});
// ── #213/#217 coexistence regression: probeExists disambiguation ──────────
// Before probeExists on dot-home-nested, first-bare-existing-wins meant a CLI
// user (antigravity-cli) who also had the IDE's ~/.gemini/antigravity dir
// present was shadowed to the legacy dir (probed first). probeExists =
// 'gsd-core/VERSION' makes the dir GSD actually owns win, regardless of order.
const AG_PROBE = ['antigravity', 'antigravity-ide', 'antigravity-cli'];
const AG_MARKER = path.join('gsd-core', 'VERSION');
function antigravityDescriptor(withMarker) {
const d = {
kind: 'dot-home-nested',
name: 'antigravity',
parent: '.gemini',
env: ['ANTIGRAVITY_CONFIG_DIR'],
probe: AG_PROBE,
};
if (withMarker) d.probeExists = AG_MARKER;
return d;
}
test('coexistence: legacy antigravity + antigravity-cli both exist, only cli is GSD-marked → returns antigravity-cli', () => {
const home = '/home/u';
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
const legacyDir = path.join(home, '.gemini', 'antigravity');
const markerPath = path.join(cliDir, AG_MARKER);
// Both dirs exist on disk; only the cli dir carries gsd-core/VERSION.
const existsSync = (p) =>
p === markerPath || p === cliDir || p === legacyDir;
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
env: {},
home,
existsSync,
});
assert.strictEqual(result, cliDir, 'GSD-marked cli dir must win over bare-existing legacy dir');
});
test('coexistence WITHOUT probeExists still shadows to legacy (documents the pre-fix behavior)', () => {
const home = '/home/u';
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
const legacyDir = path.join(home, '.gemini', 'antigravity');
const existsSync = (p) => p === cliDir || p === legacyDir;
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(false), {
env: {},
home,
existsSync,
});
// No marker → legacy first-bare-existing wins. This is exactly the #217 bug
// and proves probeExists is the load-bearing fix.
assert.strictEqual(result, legacyDir);
});
test('coexistence: legacy + ide both exist, only ide is GSD-marked → returns antigravity-ide', () => {
const home = '/home/u';
const ideDir = path.join(home, '.gemini', 'antigravity-ide');
const legacyDir = path.join(home, '.gemini', 'antigravity');
const markerPath = path.join(ideDir, AG_MARKER);
const existsSync = (p) => p === markerPath || p === ideDir || p === legacyDir;
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
env: {},
home,
existsSync,
});
assert.strictEqual(result, ideDir);
});
test('marker on legacy dir: GSD lives in legacy antigravity (a real 1.x install) → returns legacy even when cli dir exists bare', () => {
const home = '/home/u';
const legacyDir = path.join(home, '.gemini', 'antigravity');
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
const markerPath = path.join(legacyDir, AG_MARKER);
// Legacy carries the marker; cli dir exists but is not GSD's. Legacy wins.
const existsSync = (p) => p === markerPath || p === legacyDir || p === cliDir;
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
env: {},
home,
existsSync,
});
assert.strictEqual(result, legacyDir);
});
test('no marker anywhere (dirs exist but no GSD installed yet): falls back to bare-existence first match', () => {
const home = '/home/u';
const ideDir = path.join(home, '.gemini', 'antigravity-ide');
// Only ide dir exists, no gsd-core/VERSION anywhere → pass 2 returns ide.
const existsSync = (p) => p === ideDir;
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
env: {},
home,
existsSync,
});
assert.strictEqual(result, ideDir, 'with no marker, bare-existence pass still resolves the single existing 2.x dir');
});
test('probeExists present but nothing exists → fallback to probe[0] (legacy default preserved)', () => {
const home = '/home/u';
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
env: {},
home,
existsSync: () => false,
});
assert.strictEqual(result, path.join(home, '.gemini', 'antigravity'));
});
test('antigravity: ANTIGRAVITY_CONFIG_DIR env override wins over any probe', () => {
const result = resolveConfigHomeFromDescriptor(
{
@@ -421,6 +525,67 @@ describe('descriptor-driven equivalence: dot-home-nested antigravity probe hit/m
});
});
// ── #213/#217 thread-4: existing-install ambiguity detector ───────────────────
describe('detectAntigravityDirAmbiguity (migration/operator-guidance signal)', () => {
const HOMEU = '/home/u';
const dir = (name) => path.join(HOMEU, '.gemini', name);
const markerOf = (name) => path.join(dir(name), 'gsd-core', 'VERSION');
test('single dir present → not ambiguous', () => {
const cli = dir('antigravity-cli');
const r = detectAntigravityDirAmbiguity({
env: {},
home: HOMEU,
existsSync: (p) => p === cli || p === markerOf('antigravity-cli'),
});
assert.strictEqual(r.ambiguous, false);
assert.strictEqual(r.resolved, cli);
assert.deepStrictEqual(r.presentDirs, [cli]);
assert.deepStrictEqual(r.gsdMarkedDirs, [cli]);
assert.strictEqual(r.envOverridden, false);
});
test('legacy + cli both present, GSD marked in cli → ambiguous, resolves to cli', () => {
const legacy = dir('antigravity');
const cli = dir('antigravity-cli');
const r = detectAntigravityDirAmbiguity({
env: {},
home: HOMEU,
existsSync: (p) => p === legacy || p === cli || p === markerOf('antigravity-cli'),
});
assert.strictEqual(r.ambiguous, true, 'two probe dirs present must flag ambiguity');
assert.strictEqual(r.resolved, cli, 'marker disambiguates resolution to cli');
assert.deepStrictEqual(r.presentDirs.sort(), [legacy, cli].sort());
assert.deepStrictEqual(r.gsdMarkedDirs, [cli]);
});
test('misinstall surface: legacy + cli present but GSD marked ONLY in legacy → ambiguous, resolves to legacy', () => {
// This is exactly the #217 victim: GSD was written into the legacy/IDE dir,
// so the marker is in legacy and the resolver keeps it there. The detector
// flags ambiguity so the installer/update can prompt the operator.
const legacy = dir('antigravity');
const cli = dir('antigravity-cli');
const r = detectAntigravityDirAmbiguity({
env: {},
home: HOMEU,
existsSync: (p) => p === legacy || p === cli || p === markerOf('antigravity'),
});
assert.strictEqual(r.ambiguous, true);
assert.strictEqual(r.resolved, legacy);
assert.deepStrictEqual(r.gsdMarkedDirs, [legacy]);
});
test('env override short-circuits: envOverridden flag set when ANTIGRAVITY_CONFIG_DIR present', () => {
const r = detectAntigravityDirAmbiguity({
env: { ANTIGRAVITY_CONFIG_DIR: '/custom/ag' },
home: HOMEU,
existsSync: () => true,
});
assert.strictEqual(r.envOverridden, true);
assert.strictEqual(r.resolved, '/custom/ag', 'env override wins over probe entirely');
});
});
// ── GOLDEN GENERIC-AGENTS-ROOT (kimi probe) ───────────────────────────────────
describe('descriptor-driven equivalence: generic-agents-root kimi probe hit/miss', () => {

View File

@@ -0,0 +1,127 @@
'use strict';
/**
* Property-based tests for runtime-homes.cjs dot-home-nested probe resolution.
*
* Module: gsd-core/bin/lib/runtime-homes.cjs
* Exported: resolveConfigHomeFromDescriptor(descriptor, opts)
*
* `resolveConfigHomeFromDescriptor` (dot-home-nested kind) is a deterministic
* transformation: (descriptor + filesystem-existence state) → resolved path.
* Per RULESET.TESTS.property-based-testing it carries an invariant worth
* pinning across randomized existence/marker combinations — especially the
* #213/#217 `probeExists` marker-priority branch.
*
* Properties tested:
* (a) Membership: the resolved dir is ALWAYS one of `base/<candidate>` for
* some candidate in `probe` (never an off-list path).
* (b) Precedence: resolution follows the documented order —
* first marked candidate (when probeExists set) → first bare-existing
* candidate → `probe[0]` fallback.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fc = require('./helpers/fast-check-setup.cjs');
const { resolveConfigHomeFromDescriptor } = require(
path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'),
);
const MARKER = 'gsd-core/VERSION';
const CANDIDATE_POOL = ['antigravity', 'antigravity-ide', 'antigravity-cli', 'foo', 'bar'];
describe('runtime-homes: dot-home-nested probe resolution properties', () => {
test('property: resolved dir is always a probe candidate, in documented precedence', () => {
fc.assert(
fc.property(
fc.record({
home: fc.constantFrom('/home/u', '/Users/x', '/root', '/srv/app'),
probe: fc.uniqueArray(fc.constantFrom(...CANDIDATE_POOL), { minLength: 1, maxLength: 5 }),
useMarker: fc.boolean(),
existMask: fc.array(fc.boolean(), { minLength: 5, maxLength: 5 }),
markMask: fc.array(fc.boolean(), { minLength: 5, maxLength: 5 }),
}),
({ home, probe, useMarker, existMask, markMask }) => {
const parent = '.gemini';
const base = path.join(home, parent);
const candDir = (c) => path.join(base, c);
// Which candidate dirs exist on disk, and which carry the marker.
// A marker only matters where the dir itself exists (realistic install).
const exists = new Set();
const marked = new Set();
probe.forEach((c, i) => {
if (existMask[i]) exists.add(candDir(c));
if (useMarker && markMask[i] && existMask[i]) marked.add(candDir(c));
});
const existsSync = (p) =>
exists.has(p) || [...marked].some((d) => p === path.join(d, MARKER));
const descriptor = {
kind: 'dot-home-nested',
name: 'antigravity',
parent,
env: ['ANTIGRAVITY_CONFIG_DIR'],
probe,
};
if (useMarker) descriptor.probeExists = MARKER;
const result = resolveConfigHomeFromDescriptor(descriptor, {
env: {},
home,
existsSync,
});
// (a) Membership invariant.
const allCandidateDirs = probe.map(candDir);
assert.ok(
allCandidateDirs.includes(result),
`result ${result} must be one of ${JSON.stringify(allCandidateDirs)}`,
);
// (b) Precedence oracle: first marked → first bare-existing → probe[0].
const firstMarked = allCandidateDirs.find((d) => marked.has(d));
const firstExisting = allCandidateDirs.find((d) => exists.has(d));
const expected =
(useMarker && firstMarked) || firstExisting || candDir(probe[0]);
assert.equal(result, expected);
},
),
);
});
test('property: an env override always wins over any probe/marker state', () => {
fc.assert(
fc.property(
fc.record({
home: fc.constantFrom('/home/u', '/root'),
// Absolute overrides only: the resolver's env branch tilde-expands
// against the real os.homedir(), so a '~/' case would not be hermetic.
override: fc.constantFrom('/custom/ag', '/opt/x', '/var/data/ag'),
probe: fc.uniqueArray(fc.constantFrom(...CANDIDATE_POOL), { minLength: 1, maxLength: 5 }),
useMarker: fc.boolean(),
}),
({ home, override, probe, useMarker }) => {
const descriptor = {
kind: 'dot-home-nested',
name: 'antigravity',
parent: '.gemini',
env: ['ANTIGRAVITY_CONFIG_DIR'],
probe,
};
if (useMarker) descriptor.probeExists = MARKER;
const result = resolveConfigHomeFromDescriptor(descriptor, {
env: { ANTIGRAVITY_CONFIG_DIR: override },
home,
existsSync: () => true, // every dir + marker "exists" — override must still win
});
assert.equal(result, override);
},
),
);
});
});