Files
msd-core/tests/bug-3608-antigravity-update-runtime-classification.test.cjs
Tom Boucher ca3be82f71 fix(3597): clear residual Windows test failures + add ratchet lint guard
Two more diagnostic passes (clusters J: residuals in already-touched files,
K: 12 untouched files) plus a production-code path fix and a new
ratchet-style lint guard.

## Test-only fixes (14 files)

bug-1736, bug-2248, bug-2698 — replace inline 1s-budget rmSync with the
shared cleanup() helper (5s budget, 20×250ms retries). The earlier
inline maxRetries:10 / retryDelay:100 wasn't enough to absorb Windows
Defender's deferred-scan handle hold on cold runners.

bug-2256, skill-manifest — also override USERPROFILE alongside HOME in
beforeEach/runGsdTools calls. os.homedir() reads USERPROFILE on win32,
so HOME-only stubs leak the runner's real home into the SUT.

bug-2784, bug-3608, enh-2500, enh-2790, few-shot-calibration,
gsd-settings-advanced — CRLF tolerance: literal \n in regexes against
file content (frontmatter anchors, bash-fence regex, multi-line
numbered-list captures, awk-block extractors) becomes \r?\n; split('\n')
becomes split(/\r?\n/). Windows checkout with autocrlf=true puts \r
before every \n; .+ doesn't match \r in JS regex by default.

bug-2966 — three-part fix to extractStepRun (CRLF split), awk regex
(\r?\n), and conflict-marker parser (rawLine + \r$ strip).

bug-2969, config — normalize separators on test assertions where the
SUT correctly emits \ on win32 but the test compares against /.

prompt-injection-scan — normalize relPath via replace(/\\/g, '/') before
ALLOWLIST.has() lookup. ALLOWLIST keys are POSIX; path.relative returns
backslashes on win32 → falsely scans allowlisted security module → trips
the boundary-tag detector on its own legitimate detection code.

prune-orphaned-worktrees — use the existing canonicalPath +
listedWorktreePaths(repoDir).has(...) helpers instead of substring
matching the raw path. git stores long-form canonical paths
(runneradmin), but mkdtempSync returns 8.3 short-form (RUNNER~1) on
Windows runners; plain string compare misses every entry.

## Production-code fix (1 file)

get-shit-done/bin/lib/init.cjs — bug-3491 in_nested_subdir computation
canonicalizes both worktreeRoot and cwd via fs.realpathSync.native +
path.relative before declaring "nested." Windows runner cwd (8.3 short
name) vs git's --show-toplevel (long form, forward slashes) made the
raw string compare always say true even at the worktree root, breaking
the "init new-project at worktree root" subtest.

## New ratchet lint guard

tests/windows-test-parity-guard.test.cjs — scans tests/ for 7
anti-patterns that drove the Windows failure clusters. Each rule has a
baseline count snapshot from this PR; the test fails when a new
occurrence appears (count grows above baseline), ratcheting down as
existing offenders are fixed. Patterns covered:

  G1 split('\n') after readFileSync (use /\r?\n/)
  G2 ```bash\n fence regex (use ```bash\r?\n)
  G3 ^---\n frontmatter anchor (use ^---\r?\n)
  G4 hardcoded "/tmp/..." literal passed to fs.* (use os.tmpdir())
  G5 bare 'npm' to execFileSync without {shell:true} on win32
  G6 process.env.HOME stub with no USERPROFILE
  G7 fs.rmSync({recursive,force}) without maxRetries

Future Windows-parity regressions get caught at PR time rather than
five iterations into a CI loop.

Validated: holodeck (ubuntu docker) 11232/0 pass (count +8 = the 7
new ratchet tests + parent describe).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:39:02 -04:00

171 lines
7.1 KiB
JavaScript

// allow-test-rule: source-text-is-the-product
// update.md is loaded verbatim by the runtime as the /gsd-update workflow.
// The bash blocks inside it ARE the deployed program — the agent runs them.
// Asserting on the structural shape of those bash arrays is asserting on the
// deployed contract, identical to asserting on a workflow's instructions.
/**
* Bug #3608: get-shit-done/workflows/update.md does not model Antigravity as
* a first-class runtime, so /gsd-update invoked from an Antigravity install
* (~/.gemini/antigravity) classifies the runtime as base Gemini.
*
* The installer (bin/install.js) and SDK already treat Antigravity as a
* distinct runtime with its own config dir (~/.gemini/antigravity), env var
* (ANTIGRAVITY_CONFIG_DIR), and CLI flag (--antigravity). update.md must
* agree, or /gsd-update routes Antigravity installs through the base Gemini
* path.
*
* Order matters: every bash array / env-var ladder / scan list that contains
* a Gemini entry MUST list the more-specific Antigravity entry first.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const UPDATE_MD = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md');
function readUpdateMd() {
return fs.readFileSync(UPDATE_MD, 'utf-8');
}
// Parse a single bash array literal from a line like:
// RUNTIME_DIRS=( "claude:.claude" "gemini:.gemini" ... )
// Returns the entries as an ordered list of "runtime:dir" strings.
function parseBashArray(content, varName) {
const re = new RegExp(`${varName}=\\(\\s*([^)]*?)\\s*\\)`, 'm');
const m = content.match(re);
if (!m) return null;
return [...m[1].matchAll(/"([^"]+)"/g)].map((mm) => mm[1]);
}
// Extract the runtime tokens (the part before ':') in declaration order.
function runtimeTokens(entries) {
return entries.map((e) => e.split(':')[0]);
}
function firstIndex(arr, token) {
return arr.indexOf(token);
}
describe('bug #3608: update.md models Antigravity as a first-class runtime', () => {
const content = readUpdateMd();
test('RUNTIME_DIRS contains antigravity before gemini', () => {
const entries = parseBashArray(content, 'RUNTIME_DIRS');
assert.ok(entries, 'RUNTIME_DIRS array literal not found in update.md');
const tokens = runtimeTokens(entries);
const antIdx = firstIndex(tokens, 'antigravity');
const gemIdx = firstIndex(tokens, 'gemini');
assert.notStrictEqual(antIdx, -1, 'RUNTIME_DIRS missing antigravity entry');
assert.notStrictEqual(gemIdx, -1, 'RUNTIME_DIRS missing gemini entry');
assert.ok(
antIdx < gemIdx,
`antigravity must precede gemini in RUNTIME_DIRS (got antigravity@${antIdx}, gemini@${gemIdx}). ` +
`Order matters: classification iterates this list and the first match wins.`,
);
});
test('RUNTIME_DIRS antigravity entry points at .gemini/antigravity', () => {
const entries = parseBashArray(content, 'RUNTIME_DIRS');
assert.ok(entries);
const ant = entries.find((e) => e.startsWith('antigravity:'));
assert.ok(ant, 'antigravity entry missing');
assert.strictEqual(
ant,
'antigravity:.gemini/antigravity',
`antigravity entry must be exactly "antigravity:.gemini/antigravity" to match the installer ` +
`(bin/install.js line ~404: ~/.gemini/antigravity)`,
);
});
test('PREFERRED_RUNTIME env-var inference recognizes ANTIGRAVITY_CONFIG_DIR before GEMINI_CONFIG_DIR', () => {
// Extract the inference block — the if/elif ladder that maps env vars to runtime.
// Match from the comment marker through the closing `fi` of the inference block.
const blockMatch = content.match(
/If runtime is still unknown, infer from runtime env vars[\s\S]*?\r?\nfi\r?\n/,
);
assert.ok(blockMatch, 'env-var inference block not found');
const block = blockMatch[0];
const antPos = block.indexOf('ANTIGRAVITY_CONFIG_DIR');
const gemPos = block.indexOf('GEMINI_CONFIG_DIR');
assert.notStrictEqual(
antPos,
-1,
'env-var inference must check ANTIGRAVITY_CONFIG_DIR (used by bin/install.js)',
);
assert.notStrictEqual(gemPos, -1, 'env-var inference must check GEMINI_CONFIG_DIR');
assert.ok(
antPos < gemPos,
`ANTIGRAVITY_CONFIG_DIR must be checked before GEMINI_CONFIG_DIR ` +
`(got antigravity@${antPos}, gemini@${gemPos}) — otherwise an Antigravity ` +
`install with both env vars set falls through to gemini.`,
);
});
test('ENV_RUNTIME_DIRS appends an antigravity entry when ANTIGRAVITY_CONFIG_DIR is set', () => {
// Match the `if [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then` block and require an
// `ENV_RUNTIME_DIRS+=( "antigravity:..." )` push inside it. The pushed value
// may contain nested `"$VAR"` quotes (bash command substitution), so we
// don't try to match the full string literal — just the leading runtime
// tag `"antigravity:`.
const re = /if \[ -n "\$ANTIGRAVITY_CONFIG_DIR" \];\s*then\s+ENV_RUNTIME_DIRS\+=\(\s*"antigravity:/;
assert.match(
content,
re,
'expected `if [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then ENV_RUNTIME_DIRS+=( "antigravity:..." )`',
);
});
test('local-scope scan dir list includes .gemini/antigravity before .gemini', () => {
// Lines like: for dir in .claude .config/opencode .opencode .gemini ...
// The first iteration of the local scan ranges over a hardcoded list.
const forLoops = [...content.matchAll(/for dir in ([^;]+); do/g)];
assert.ok(forLoops.length > 0, 'no `for dir in ...; do` scan loops found');
for (const m of forLoops) {
const tokens = m[1].trim().split(/\s+/);
const antIdx = tokens.indexOf('.gemini/antigravity');
const gemIdx = tokens.indexOf('.gemini');
if (gemIdx === -1) continue; // scan loop without .gemini — irrelevant
assert.notStrictEqual(
antIdx,
-1,
`scan loop "for dir in ${m[1].trim()}" mentions .gemini but not .gemini/antigravity — ` +
`the more-specific Antigravity dir must be present`,
);
assert.ok(
antIdx < gemIdx,
`scan loop "for dir in ${m[1].trim()}": .gemini/antigravity (idx ${antIdx}) must precede .gemini (idx ${gemIdx})`,
);
}
});
test('path-to-runtime classification documents /.gemini/antigravity/ before /.gemini/', () => {
// The markdown bullet list near the top of get_installed_version step.
// We assert the antigravity bullet exists and appears before the gemini bullet
// in the file (textual order = evaluation order in the prose contract).
const antBullet = content.search(/Path contains `\/\.gemini\/antigravity\/?` -> `antigravity`/);
const gemBullet = content.search(/Path contains `\/\.gemini\/` -> `gemini`/);
assert.notStrictEqual(
antBullet,
-1,
'classification bullet for /.gemini/antigravity/ -> antigravity is missing',
);
assert.notStrictEqual(gemBullet, -1, 'classification bullet for /.gemini/ -> gemini is missing');
assert.ok(
antBullet < gemBullet,
'antigravity bullet must precede gemini bullet in path-classification list',
);
});
});