* feat(#3415): ship local/no-unbounded-quantifier, burn down ReDoS class Phase 4 of epic #3212 (ADR-3212 §5/§7, the final phase). New rule flags an unbounded */+/{n,} quantifier over a broad character class ([\s\S], dotAll ., or a 1-2-unit negated class like [^\n]/[^)\n] — the exact #2128-fixed shape) applied to a regex whose match target is data-flow-traced to readFileSync content. eslint-rules/lib/readfilesync-trace.cjs extracts the data-flow tracer shared with no-crlf-fragile-split (Phase 2) rather than a second copy — no-crlf-fragile-split refactored onto it with zero behavior change, parity-tested. Real triage, not 798 mechanical edits: the ADR's census (2026-08-08) screened every unbounded quantifier in the tree unscoped. Correctly scoped to readFileSync-derived content (matching Phase 2's own G2/G3 scoping), the rule found 162 real hits across two detection waves — the second wave (93) surfaced only after a genuine off-by-one bug in this rule's own first draft was caught while writing its RuleTester tests and fixed (the bug silently missed every directly-quantified [\s\S]* with no gap before the quantifier — exactly the class this rule exists to catch). 3 hits landed in production src/ (commands.cts, milestone.cts, roadmap.cts) and were each empirically timed against adversarial input (matching #2128's own measured-not-assumed precedent) — all confirmed linear-time/benign, left unbounded with a measured-evidence comment rather than mechanically bounded. The remaining 159 are test-file fixture parsing (test-author-controlled, fixed-size content, not adversarial input) — each suppressed with a specific, non-generic reason. Zero functional behavior changed anywhere in this diff. tests/no-pending-3212-markers.test.cjs locks the epic's own closing invariant (ADR §7: "assert zero pending #3212 markers remain") — ground truth confirmed trivially true today (no phase left any such marker behind), now regression-locked going forward. Design: .gsd/phase/chore-3415-prohibition-with-teeth/40-design.md Test matrix: .gsd/phase/chore-3415-prohibition-with-teeth/50-test-matrix.md Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3415): correct rule category mislabel, add CI test-scope entry An orthogonal Standards-axis review found eslint-rules/no-unbounded-quantifier.cjs mistakenly carried meta.docs.category: 'Portability', copied from a sibling rule without realizing what that implied: docs/contributing/cross-platform- portability-rules.md governs an ADR-1703 rule family under a hard "zero escape hatches" contract (tests/portability-rule-disable-ban.test.cjs's PROTECTED_RULES bans eslint-disable for those rules entirely). This rule is not part of that family — it's ADR-3212 (ReDoS/CWE-1333), a different epic — and its eslint-disable-next-line suppressions (159 of them, added earlier this same phase after empirical benign-verification) are an intentional, correct design, not a bypass. Corrected to category: 'Best Practices', matching the actual precedent (no-adhoc-regex-escape.cjs, Phase 1 of the same epic, which is also correctly outside PROTECTED_RULES), and the rule's own docstring now states this explicitly so a future reader doesn't have to re-derive it. Also registers a new scripts/ci-test-scope.cjs bucket so editing this rule or the shared eslint-rules/lib/readfilesync-trace.cjs helper re-runs their own test suites under targeted CI selection — was previously unregistered and invisible to that fast-path (this PR's own gsd-test checkpoint runs the full suite regardless, so this only affects future narrowly-scoped PRs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3415): bound no-unbounded-quantifier's own scanner (CWE-1333, ironic) Security review found the rule meant to catch algorithmic-complexity bugs had one of its own: hasUnboundedBroadQuantifier's negated-class inner scan walked from each `[^` occurrence to the next `]` (or EOF) with no bound, while the outer loop only ever advanced by one character — O(n²) total work on a pattern with many unclosed `[^` runs. Runs unconditionally inside checkPattern on any `new RegExp('literal string')` argument in any linted file, before the (cheap) readFileSync data-flow gate — so a single crafted string literal, no valid regex syntax required, could make `npm run lint` / CI hang. Empirically confirmed both the bug and the fix: pre-fix, n=4000/8000/ 16000/32000 chars took 30.8/115.6/463.8/1874.3ms (~4x work per 2x n, quadratic); extrapolated, the 300000-char repro from the finding would run ~165s. Post-fix (bail the inner scan once units exceeds the rule's own 1-2-unit scope, rather than continuing to hunt for a closing `]`), the same 300000-char input runs in 8.7ms via the real rule module, independently reconfirmed at 18ms via a fresh Linter.verify() call. New regression row in tests/no-unbounded-quantifier.rule.test.cjs asserts the RuleTester run on a 50000-char adversarial pattern completes and returns a defined result — no wall-clock assertion (CLAUDE.md Clock Seams / local/no-elapsed-assertion). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3415): triage 3 new sites, re-raise ceiling after upstream batch next merged 12 more PRs during this PR's review. Two consequences: - tests/edit-phase.test.cjs (fix #3262, unrelated) added 3 new content.match(/<tag>([\s\S]*?)<\/tag>/) reads of this repo's own workflow .md content — the same Class A pattern as the ~159 sites already triaged elsewhere in this PR. Suppressed with the same established reason. - lint-allow-test-rule-refs' ratchet ceiling needed re-raising again (301 -> 303) for the same reason as the two prior bumps: organic growth from unrelated, already-reviewed PRs landing concurrently, not a defect in this branch's own diff. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
477 lines
24 KiB
JavaScript
477 lines
24 KiB
JavaScript
// allow-test-rule: structural-regression-guard — AC2 requires asserting no `runtime === 'antigravity'` string-equality branch (nor an `isAntigravity` helper, nor a `canonical === 'antigravity'` branch) remains in bin/install.js, src/runtime-artifact-conversion.cts, src/shell-command-projection.cts, and src/runtime-name-policy.cts — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2096)
|
|
'use strict';
|
|
|
|
/**
|
|
* Declarative reference host — Antigravity (#1682 Slice 2 / ADR-1239 Phase D).
|
|
*
|
|
* Locks in Antigravity as the Declarative-CLI reference host driven through the
|
|
* PUBLIC Host-Integration Interface (the declarative adapter), per #1682 AC:
|
|
* "invoke a gsd command in the Declarative-CLI reference host (Antigravity)
|
|
* driven by the embedded engine through the public interface, golden-parity
|
|
* vs Claude."
|
|
*
|
|
* Byte-identity of adapter output vs today's install is gated globally by
|
|
* golden-install-parity (all 16 runtimes) + adapter-declarative-equivalence.
|
|
* THIS test is the reference-host dogfood: it (1) classifies Antigravity's
|
|
* profile via profileOf, (2) confirms the public adapter classifies it as
|
|
* declarative, and (3) round-trips a real install proving a gsd command surface
|
|
* is emitted through the same engine the adapter delegates to.
|
|
*
|
|
* #2096 (EoS/antigravity) additions: negotiation fails CLOSED on a corrupted
|
|
* descriptor, the 4 still-`undocumented` dispatch sub-axes (namedDispatch/
|
|
* nested/maxDepth/backgroundDispatch) degrade to the most-restrictive known
|
|
* value (never their optimistic value), the validator accepts the negotiated
|
|
* subagentToolkit:'full' + permissionWriter:'antigravity' upgrade, and the
|
|
* hardcoded `runtime === 'antigravity'` / `isAntigravity` / `canonical ===
|
|
* 'antigravity'` branches are retired from the folded modules (folded into
|
|
* descriptor-driven `runtime.hostBehaviors` + `runtime.hostIntegration`).
|
|
* UPGRADE 1 (permission-writer) + UPGRADE 2 (MCP companion) live-install
|
|
* coverage is in tests/antigravity-upgrades.test.cjs — not duplicated here.
|
|
*/
|
|
|
|
const { test, before } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
|
|
|
const {
|
|
profileOf,
|
|
negotiateHostCapabilities,
|
|
PROFILE_BASELINES,
|
|
UNDOCUMENTED,
|
|
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
|
const { validateCapability } = require('../gsd-core/bin/lib/capability-validator.cjs');
|
|
const { createDeclarativeAdapter } = require('../gsd-core/bin/lib/adapter-declarative.cjs');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const { walk, runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs');
|
|
|
|
const DESC = path.join(__dirname, '..', 'capabilities', 'antigravity', 'capability.json');
|
|
const ANTIGRAVITY_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8'));
|
|
const ANTIGRAVITY_AXES = ANTIGRAVITY_CAP.runtime.hostIntegration;
|
|
|
|
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
|
const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
// hooks/dist is gitignored and built (mirrors golden-install-parity harness).
|
|
before(() => {
|
|
throwIfFailed(runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), `node ${BUILD_SCRIPT}`);
|
|
});
|
|
|
|
test('Antigravity classifies as the declarative-cli reference profile (profileOf)', () => {
|
|
const desc = JSON.parse(fs.readFileSync(DESC, 'utf8'));
|
|
const axes = desc.runtime.hostIntegration;
|
|
assert.ok(axes && axes.embeddingMode, 'antigravity descriptor declares hostIntegration axes');
|
|
assert.equal(profileOf(axes), 'declarative-cli',
|
|
'Antigravity is the Declarative-CLI reference host');
|
|
});
|
|
|
|
test('the public declarative adapter classifies Antigravity as a declarative host', () => {
|
|
const adapter = createDeclarativeAdapter({ runtime: 'antigravity' });
|
|
assert.equal(adapter.kind, 'declarative');
|
|
assert.equal(adapter.runtime, 'antigravity');
|
|
assert.equal(typeof adapter.install, 'function');
|
|
assert.equal(typeof adapter.uninstall, 'function');
|
|
});
|
|
|
|
test('a real Antigravity install emits a gsd command/skill surface (invocable)', () => {
|
|
const { configDir, root } = runMinimalInstall({ runtime: 'antigravity', scope: 'global' });
|
|
try {
|
|
const files = walk(configDir);
|
|
assert.ok(files.length > 0, 'install must emit artifacts');
|
|
// Antigravity uses the nested gsd-ns-* router skill layout as its command
|
|
// surface (CONTEXT.md installer module). Assert a gsd skill/router is present.
|
|
const gsdSurface = files.filter((f) => /gsd/i.test(path.relative(configDir, f)));
|
|
assert.ok(gsdSurface.length > 0,
|
|
'install must emit a gsd command/skill surface (declarative reference)');
|
|
} finally {
|
|
cleanup(root);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #2096 EoS/antigravity — AC3/AC5: fail-closed negotiation + validator
|
|
// acceptance + the folded descriptor (mirrors kimi/codex reference tests).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
|
|
|
|
test('negotiateHostCapabilities never throws for antigravity, even fully corrupted', () => {
|
|
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
|
assert.doesNotThrow(() => negotiateHostCapabilities({ ...ANTIGRAVITY_AXES, embeddingMode: UNDOCUMENTED }));
|
|
assert.doesNotThrow(() => negotiateHostCapabilities({ ...ANTIGRAVITY_AXES, embeddingMode: 'future-unknown' }));
|
|
assert.doesNotThrow(() => negotiateHostCapabilities({ ...ANTIGRAVITY_AXES, dispatch: 'corrupted-not-an-object' }));
|
|
assert.doesNotThrow(() => negotiateHostCapabilities({ ...ANTIGRAVITY_AXES, dispatch: { ...ANTIGRAVITY_AXES.dispatch, maxDepth: 'not-a-number' } }));
|
|
});
|
|
|
|
test('a partial/empty antigravity descriptor degrades to the safe floor, not the declarative-cli baseline', () => {
|
|
const result = negotiateHostCapabilities({});
|
|
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
|
|
assert.equal(result.effective.hookBus, 'none');
|
|
assert.notDeepEqual(result.effective, PROFILE_BASELINES['declarative-cli']);
|
|
assert.ok(result.warnings.length > 0);
|
|
});
|
|
|
|
// AC-specific: the 4 still-undocumented dispatch sub-axes must degrade to the
|
|
// most-restrictive KNOWN value, not their optimistic value. Real values below
|
|
// were confirmed via:
|
|
// node -e "const {negotiateHostCapabilities}=require('./gsd-core/bin/lib/host-integration.cjs');
|
|
// const cap=require('./capabilities/antigravity/capability.json');
|
|
// console.log(negotiateHostCapabilities(cap.runtime.hostIntegration).effective.dispatch)"
|
|
// -> { namedDispatch:false, nested:false, maxDepth:0, background:false, subagentToolkit:'full', backgroundDispatch:false }
|
|
test("antigravity's 4 still-undocumented dispatch sub-axes (namedDispatch/nested/maxDepth/backgroundDispatch) degrade to the most-restrictive known value, not their optimistic value", () => {
|
|
// Sanity: the descriptor itself still declares these 4 as the undocumented
|
|
// sentinel. subagentToolkit is the ONE dispatch axis Context7 confirmed as
|
|
// 'full' (antigravity.google/docs/cli/features) — it is deliberately NOT
|
|
// part of this still-undocumented set.
|
|
assert.equal(ANTIGRAVITY_AXES.dispatch.namedDispatch, 'undocumented');
|
|
assert.equal(ANTIGRAVITY_AXES.dispatch.nested, 'undocumented');
|
|
assert.equal(ANTIGRAVITY_AXES.dispatch.maxDepth, 'undocumented');
|
|
assert.equal(ANTIGRAVITY_AXES.dispatch.backgroundDispatch, 'undocumented');
|
|
assert.equal(ANTIGRAVITY_AXES.dispatch.subagentToolkit, 'full', 'sanity: subagentToolkit is documented, not part of the undocumented set');
|
|
|
|
const { effective, warnings } = negotiateHostCapabilities(ANTIGRAVITY_AXES);
|
|
|
|
assert.equal(effective.dispatch.namedDispatch, false, 'undocumented namedDispatch must degrade to false, never true');
|
|
assert.equal(effective.dispatch.nested, false, 'undocumented nested must degrade to false, never true');
|
|
assert.equal(effective.dispatch.maxDepth, 0, 'undocumented maxDepth must degrade to 0, never -1/unbounded');
|
|
assert.equal(effective.dispatch.backgroundDispatch, false, 'undocumented backgroundDispatch must degrade to false, never true');
|
|
|
|
// subagentToolkit is documented 'full' (not undocumented) — it is trusted
|
|
// and survives negotiation, in contrast to the 4 sub-axes above.
|
|
assert.equal(effective.dispatch.subagentToolkit, 'full', "documented 'full' subagentToolkit is trusted, unlike the undocumented sub-axes");
|
|
|
|
// background is declared `true` (documented, not undocumented) but the
|
|
// struct-consistency cap in negotiateHostCapabilities still zeroes it
|
|
// because its sibling namedDispatch degraded to false — a host-declared
|
|
// `true` never overrides the fail-closed floor forced by a degraded axis.
|
|
assert.equal(effective.dispatch.background, false, 'background is capped to false once namedDispatch degrades closed');
|
|
|
|
for (const axis of ['namedDispatch', 'nested', 'backgroundDispatch']) {
|
|
assert.ok(
|
|
warnings.some((w) => w.includes(`dispatch.${axis}`) && w.includes('undocumented')),
|
|
`a warning must be raised for the undocumented dispatch.${axis} axis`,
|
|
);
|
|
}
|
|
assert.ok(
|
|
warnings.some((w) => w.includes('dispatch.maxDepth')),
|
|
'a warning must be raised for the undocumented dispatch.maxDepth axis (reported as missing/non-number)',
|
|
);
|
|
});
|
|
|
|
// -- AC3: the validator accepts the negotiated/folded descriptor values ------
|
|
|
|
test('capabilities/antigravity/capability.json validates — subagentToolkit "full" + permissionWriter "antigravity" are accepted', () => {
|
|
const errors = validateCapability(ANTIGRAVITY_CAP, 'antigravity');
|
|
assert.deepEqual(errors, [], `validateCapability must return no errors, got: ${JSON.stringify(errors)}`);
|
|
assert.equal(ANTIGRAVITY_AXES.dispatch.subagentToolkit, 'full');
|
|
assert.equal(ANTIGRAVITY_CAP.runtime.permissionWriter, 'antigravity');
|
|
});
|
|
|
|
// -- AC2: the folded-in hostBehaviors + subagentToolkit upgrade --------------
|
|
|
|
test('antigravity descriptor declares runtime.hostBehaviors (the folded-in behaviors) + the subagentToolkit upgrade', () => {
|
|
const hb = ANTIGRAVITY_CAP.runtime.hostBehaviors;
|
|
assert.ok(hb && typeof hb === 'object');
|
|
// `reviewerCli` was one of the folded-in behaviors originally; ADR-2782
|
|
// Phase 7 (#2801) removed the alias — antigravity's reviewer lane is declared
|
|
// by its `reviewer` body now, not by a boolean in the open hostBehaviors bag.
|
|
assert.equal(Object.prototype.hasOwnProperty.call(hb, 'reviewerCli'), false);
|
|
assert.equal(hb.projectInstructionFile, 'GEMINI.md');
|
|
assert.equal(hb.noPathRewrite, true);
|
|
assert.equal(hb.hookPathStyle, 'raw');
|
|
assert.equal(ANTIGRAVITY_AXES.dispatch.subagentToolkit, 'full',
|
|
'subagentToolkit flipped undocumented -> full (antigravity.google/docs/cli/features)');
|
|
});
|
|
|
|
// -- AC2: the hardcoded branches are retired across all folded modules -------
|
|
|
|
test('no `runtime === "antigravity"` string-equality branch (nor `isAntigravity` / `canonical === "antigravity"`) remains in the descriptor-migrated modules (AC2)', () => {
|
|
const strip = (src) => src
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/\/\/[^\r\n]*/g, '')
|
|
.replace(/`[^`]*`/g, '');
|
|
const repoRoot = path.join(__dirname, '..');
|
|
const files = [
|
|
path.join(repoRoot, 'bin', 'install.js'),
|
|
path.join(repoRoot, 'src', 'runtime-artifact-conversion.cts'),
|
|
path.join(repoRoot, 'src', 'shell-command-projection.cts'),
|
|
path.join(repoRoot, 'src', 'runtime-name-policy.cts'),
|
|
];
|
|
for (const file of files) {
|
|
const src = fs.readFileSync(file, 'utf8');
|
|
const stripped = strip(src);
|
|
|
|
const eqOffenders = stripped.match(/runtime\s*[!=]==\s*'antigravity'/g) || [];
|
|
assert.deepEqual(eqOffenders, [],
|
|
`AC2: no hardcoded runtime==='antigravity' branch may remain in ${path.relative(repoRoot, file)}; found: ${eqOffenders.join(', ')}`);
|
|
|
|
const isAntigravityHits = stripped.match(/\bisAntigravity\b/g) || [];
|
|
assert.deepEqual(isAntigravityHits, [],
|
|
`AC2: no isAntigravity helper may remain in ${path.relative(repoRoot, file)}; found ${isAntigravityHits.length} occurrence(s)`);
|
|
|
|
const canonicalOffenders = stripped.match(/canonical\s*===\s*'antigravity'/g) || [];
|
|
assert.deepEqual(canonicalOffenders, [],
|
|
`AC2: no canonical==='antigravity' branch may remain in ${path.relative(repoRoot, file)}; found: ${canonicalOffenders.join(', ')}`);
|
|
}
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3608-antigravity-update-runtime-classification.test.cjs — consolidation epic #1969 (B3 #1972)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3608-antigravity-update-runtime-classification (consolidation epic #1969 B3 #1972)", () => {
|
|
/**
|
|
* Bug #3608: /gsd:update must model Antigravity as a first-class runtime.
|
|
*
|
|
* The installer (bin/install.js) and SDK already treat Antigravity as a distinct
|
|
* runtime with its own config dirs, env var (ANTIGRAVITY_CONFIG_DIR), and CLI
|
|
* flag (--antigravity). The update flow must agree.
|
|
*
|
|
* Relocation (#498): the update flow's runtime/scope detection moved out of
|
|
* ~280 lines of inline bash in update.md into the tested projection
|
|
* `gsd-core/bin/lib/update-context.cjs` (resolveUpdateContext). The
|
|
* antigravity-first-class contract now lives there as data + behavior, so this
|
|
* test asserts it on the projection. The only piece still authored in update.md
|
|
* is the execution_context path classification (prose the agent applies).
|
|
*
|
|
* #1928: Gemini CLI was sunset by Google on 2026-06-18 and the `gemini` runtime
|
|
* was removed from GSD entirely (Antigravity CLI is the successor). Antigravity
|
|
* still resolves under the shared `.gemini/antigravity*` dirs (it runs on the
|
|
* Gemini 3 backend), so those probe entries and the ANTIGRAVITY_CONFIG_DIR env
|
|
* var remain — but there is no longer a competing bare-`gemini` runtime to
|
|
* disambiguate against.
|
|
*/
|
|
|
|
'use strict';
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const {
|
|
RUNTIME_DIRS,
|
|
inferPreferredRuntime,
|
|
envRuntimeDirs,
|
|
resolveUpdateContext,
|
|
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'update-context.cjs'));
|
|
const UPDATE_MD = path.join(ROOT, 'gsd-core', 'workflows', 'update.md');
|
|
|
|
function runtimeOrder() {
|
|
return RUNTIME_DIRS.map(([rt]) => rt);
|
|
}
|
|
function firstIndex(arr, token) {
|
|
return arr.indexOf(token);
|
|
}
|
|
|
|
describe('bug #3608 / #498: update-context models Antigravity as a first-class runtime', () => {
|
|
test('RUNTIME_DIRS lists antigravity (no gemini runtime remains — #1928)', () => {
|
|
const order = runtimeOrder();
|
|
const antIdx = firstIndex(order, 'antigravity');
|
|
assert.notStrictEqual(antIdx, -1, 'RUNTIME_DIRS missing antigravity');
|
|
assert.strictEqual(firstIndex(order, 'gemini'), -1,
|
|
'gemini runtime was removed (#1928) — RUNTIME_DIRS must not list it');
|
|
});
|
|
|
|
test('RUNTIME_DIRS includes antigravity 2.x (ide/cli) + legacy dirs', () => {
|
|
const dirs = RUNTIME_DIRS.filter(([rt]) => rt === 'antigravity').map(([, d]) => d);
|
|
assert.ok(dirs.includes('.gemini/antigravity-ide'), 'missing .gemini/antigravity-ide');
|
|
assert.ok(dirs.includes('.gemini/antigravity-cli'), 'missing .gemini/antigravity-cli');
|
|
assert.ok(dirs.includes('.gemini/antigravity'), 'missing legacy .gemini/antigravity fallback');
|
|
// The bare .gemini probe (former gemini-runtime dir) no longer exists (#1928).
|
|
const order = RUNTIME_DIRS.map(([, d]) => d);
|
|
assert.strictEqual(order.indexOf('.gemini'), -1,
|
|
'bare .gemini probe entry was removed with the gemini runtime (#1928)');
|
|
});
|
|
|
|
test('env inference recognizes ANTIGRAVITY_CONFIG_DIR', () => {
|
|
const rt = inferPreferredRuntime({
|
|
fs: { exists: () => false },
|
|
env: { ANTIGRAVITY_CONFIG_DIR: '/x' },
|
|
preferredConfigDir: '',
|
|
});
|
|
assert.equal(rt, 'antigravity');
|
|
});
|
|
|
|
test('envRuntimeDirs emits an antigravity entry when ANTIGRAVITY_CONFIG_DIR is set', () => {
|
|
const entries = envRuntimeDirs({ env: { ANTIGRAVITY_CONFIG_DIR: '/x/ag' }, home: '/home/u' });
|
|
const order = entries.map(([rt]) => rt);
|
|
assert.ok(order.includes('antigravity'), 'expected an antigravity env candidate');
|
|
assert.strictEqual(order.indexOf('gemini'), -1,
|
|
'GEMINI_CONFIG_DIR is no longer recognized (#1928) — no gemini env candidate should ever appear');
|
|
});
|
|
|
|
test('behavioral: an Antigravity install resolves to runtime "antigravity", not "gemini"', () => {
|
|
// Normalize paths so the fake fs matches the resolver's path.join/resolve
|
|
// lookups on Windows (backslash + drive) as well as POSIX.
|
|
const normKey = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
|
|
const HOME = '/home/u';
|
|
const agDir = path.join(HOME, '.gemini', 'antigravity');
|
|
const verFile = normKey(path.join(agDir, 'gsd-core', 'VERSION'));
|
|
const markerFile = normKey(path.join(agDir, 'gsd-core', 'workflows', 'update.md'));
|
|
const fakeFs = {
|
|
exists: (p) => normKey(p) === verFile || normKey(p) === markerFile,
|
|
readFile: (p) => (normKey(p) === verFile ? '1.40.0\n' : null),
|
|
};
|
|
const r = resolveUpdateContext({ home: HOME, cwd: path.resolve('/work'), env: {}, fs: fakeFs });
|
|
assert.equal(r.runtime, 'antigravity');
|
|
assert.equal(normKey(r.gsdDir), normKey(agDir));
|
|
});
|
|
|
|
test('update.md execution_context classification lists antigravity paths and no longer classifies bare /.gemini/ as gemini (#1928)', () => {
|
|
const content = fs.readFileSync(UPDATE_MD, 'utf-8');
|
|
const antIde = content.indexOf('/.gemini/antigravity-ide/');
|
|
assert.notStrictEqual(antIde, -1, 'update.md must document the antigravity-ide execution_context path');
|
|
assert.strictEqual(content.indexOf('`/.gemini/` -> `gemini`'), -1,
|
|
'gemini runtime was removed (#1928) — update.md must not classify bare /.gemini/ as gemini');
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-503-update-agent-antigravity-detection.test.cjs — consolidation epic #1969 (B3 #1972)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-503-update-agent-antigravity-detection (consolidation epic #1969 B3 #1972)", () => {
|
|
'use strict';
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
// allow-test-rule: source-text-is-the-product (see #503)
|
|
// update.md's embedded classifier + cache-clear loop are workflow text the
|
|
// runtime loads and executes, so asserting on that text tests deployed
|
|
// behavior. The runtime/scope detection cascade itself moved out of inline
|
|
// bash into the update-context projection (issue #498), so the core guarantee
|
|
// is exercised behaviorally against resolveUpdateContext rather than by
|
|
// matching a `RUNTIME_DIRS=(...)` literal that no longer lives in update.md.
|
|
// Per CONTRIBUTING.md exception matrix.
|
|
|
|
/**
|
|
* Bug #503: /gsd:update misclassifies local Antigravity (.agent) installs as claude
|
|
*
|
|
* The installer places a LOCAL Antigravity install in ./.agent/
|
|
* (bin/install.js: getDirName('antigravity') === '.agent'). The /gsd:update
|
|
* detection cascade must map .agent -> antigravity across three surfaces:
|
|
* 1. the execution_context path classifier (update.md prose),
|
|
* 2. the RUNTIME_DIRS candidate table (now in the update-context projection),
|
|
* 3. the post-update cache-clear `for dir in` loop (update.md).
|
|
*
|
|
* Surface (2) is the original root cause and is now verified behaviorally: a
|
|
* LOCAL .agent install must resolve to the antigravity runtime. Before the fix
|
|
* (.agent absent from RUNTIME_DIRS) it fell through to UNKNOWN/claude.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const UPDATE_MD = fs.readFileSync(
|
|
path.join(ROOT, 'gsd-core', 'workflows', 'update.md'),
|
|
'utf-8',
|
|
);
|
|
const { resolveUpdateContext } = require(
|
|
path.join(ROOT, 'gsd-core', 'bin', 'lib', 'update-context.cjs'),
|
|
);
|
|
|
|
function normKey(p) { return path.resolve(p).replace(/\\/g, '/').toLowerCase(); }
|
|
function fakeFs(files) {
|
|
const set = new Map();
|
|
for (const [k, v] of Object.entries(files)) set.set(normKey(k), v);
|
|
return {
|
|
exists: (p) => set.has(normKey(p)),
|
|
readFile: (p) => { const k = normKey(p); return set.has(k) ? set.get(k) : null; },
|
|
};
|
|
}
|
|
|
|
describe('/gsd:update detects local Antigravity (.agent / .agents) installs (#503 / #791)', () => {
|
|
test('projection resolves a LOCAL ./.agents install to the antigravity runtime (#791 canonical)', () => {
|
|
const HOME = '/home/u';
|
|
const CWD = '/work/proj';
|
|
const agentsDir = `${CWD}/.agents`;
|
|
const ffs = fakeFs({
|
|
[`${agentsDir}/gsd-core/VERSION`]: '1.50.0\n',
|
|
[`${agentsDir}/gsd-core/workflows/update.md`]: 'x',
|
|
});
|
|
const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs: ffs });
|
|
assert.equal(
|
|
r.runtime,
|
|
'antigravity',
|
|
`a local .agents install must map to the antigravity runtime, got "${r.runtime}"`,
|
|
);
|
|
assert.equal(r.scope, 'LOCAL');
|
|
assert.equal(r.installedVersion, '1.50.0');
|
|
});
|
|
|
|
test('projection resolves a LOCAL ./.agent install to the antigravity runtime (#503 backward-compat)', () => {
|
|
const HOME = '/home/u';
|
|
const CWD = '/work/proj';
|
|
const agentDir = `${CWD}/.agent`;
|
|
const ffs = fakeFs({
|
|
[`${agentDir}/gsd-core/VERSION`]: '1.40.0\n',
|
|
[`${agentDir}/gsd-core/workflows/update.md`]: 'x',
|
|
});
|
|
const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs: ffs });
|
|
assert.equal(
|
|
r.runtime,
|
|
'antigravity',
|
|
`a legacy .agent install must still map to the antigravity runtime, got "${r.runtime}"`,
|
|
);
|
|
assert.equal(r.scope, 'LOCAL');
|
|
assert.equal(r.installedVersion, '1.40.0');
|
|
});
|
|
|
|
test('execution_context classifier maps /.agents/ and /.agent/ paths to antigravity (update.md)', () => {
|
|
const hasAgentsClassifierRule =
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses maintainer-authored update.md workflow, bounded prose, not adversarial input
|
|
/\/\.agents\/[^\r\n]*->[^\r\n]*antigravity/.test(UPDATE_MD);
|
|
assert.ok(
|
|
hasAgentsClassifierRule,
|
|
'update.md classifier must map a `/.agents/` path to the `antigravity` runtime',
|
|
);
|
|
const hasAgentClassifierRule =
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses maintainer-authored update.md workflow, bounded prose, not adversarial input
|
|
/\/\.agent\/[^\r\n]*->[^\r\n]*antigravity/.test(UPDATE_MD);
|
|
assert.ok(
|
|
hasAgentClassifierRule,
|
|
'update.md classifier must still map a `/.agent/` path to the `antigravity` runtime (backward-compat)',
|
|
);
|
|
});
|
|
|
|
test('every runtime-dir `for dir in` loop in update.md includes .agents and .agent', () => {
|
|
// The LOCAL-scope discovery loop moved into the projection (#498); the
|
|
// post-update cache-clear loop remains inline and still enumerates the
|
|
// runtime config dirs as a literal `.claude ... .codex` list, so it must
|
|
// include both .agents (canonical, #791) and .agent (legacy, #503) or
|
|
// stale indicators could linger.
|
|
const runtimeDirLoops = UPDATE_MD
|
|
.split(/\r?\n/)
|
|
.filter((l) => /for dir in .*\.claude.*\.codex/.test(l));
|
|
assert.ok(
|
|
runtimeDirLoops.length >= 1,
|
|
`expected at least 1 runtime-dir loop in update.md, found ${runtimeDirLoops.length}`,
|
|
);
|
|
for (const loop of runtimeDirLoops) {
|
|
assert.ok(
|
|
/(^|\s)\.agents(\s|$)/.test(loop),
|
|
`every runtime-dir loop must include .agents (canonical), got: ${loop.trim()}`,
|
|
);
|
|
assert.ok(
|
|
/(^|\s)\.agent(\s|$)/.test(loop),
|
|
`every runtime-dir loop must include .agent (legacy backward-compat), got: ${loop.trim()}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|