Files
msd-core/tests/gemini-namespacing.test.cjs
Tom Boucher 33afb4f6eb chore(#452): add ESLint 9 flat-config harness with three custom AST rules (#460)
Install eslint@9 + typescript-eslint@8 + globals@16 + eslint-plugin-n@17 +
eslint-plugin-no-only-tests@3 + typescript as devDependencies.

eslint.config.mjs (flat config):
- Global ignores: node_modules, dist, .worktrees, .claude, coverage, the
  12 generated get-shit-done/bin/lib/*.cjs files
- Block for get-shit-done/bin/**/*.cjs + scripts/**/*.cjs: js.recommended +
  eslint-plugin-n + local plugin; generic quality rules (no-var, prefer-const,
  no-unused-vars, no-empty, n/no-process-exit)
- Block for tests/**/*.test.cjs: no-only-tests (error), local timing rules,
  no-restricted-syntax timing bans

eslint-rules/ local plugin (three AST rules, all at warn pending cleanup):
- no-source-grep: flag readFileSync on source .cjs/.js/.ts + text methods
- no-magic-sleep-in-tests: flag Atomics.wait and await-new-Promise(setTimeout)
- no-elapsed-assertion: flag assert*() on timing props (elapsed/duration/took/ms)

tsconfig.lint.json: allowJs + checkJs + noEmit for future type-aware passes.

tests/eslint-rules.test.cjs: 15 RuleTester unit tests (all pass, 0 fail).

package.json: add lint/lint:fix scripts; remove lint:tests (subsumed by ESLint
local/no-source-grep). Rules that produced pre-existing errors downgraded to
warn: no-useless-escape, no-unsafe-finally, no-regex-spaces, no-control-regex,
no-irregular-whitespace. ESLint exits 0 (warnings ok).

.github/workflows/test.yml lint-tests job: add npm ci + ESLint step; remove
"Lint — no source-grep tests" step (now covered by ESLint); bump timeout 3→5
min. .gitignore: add node_modules/.cache/eslint/ entry.

eslint --fix auto-cleaned: no-regex-spaces in tests, prefer-const in state.cjs,
redundant eslint-disable-next-line comments.

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:54:49 -04:00

218 lines
9.3 KiB
JavaScript

/**
* Regression tests for Gemini namespacing (PR #2768)
*
* Verifies that slash commands are correctly converted to colon format (/gsd:)
* while preserving URLs, file paths, and agent names.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
convertSlashCommandsToGeminiMentions,
convertClaudeToGeminiMarkdown,
convertClaudeToGeminiAgent,
_resetGsdCommandRoster,
install
} = require('../bin/install.js');
/**
* Minimal parser for the simple TOML emitted by convertClaudeToGeminiToml —
* exactly two top-level keys (`description` and `prompt`), each a JSON-quoted
* string. Throws on unparseable lines so a regression in the emitter shape
* fails loudly rather than silently mis-parsing.
*/
function parseGeminiCommandToml(toml) {
const result = {};
for (const rawLine of toml.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
const match = line.match(/^([a-z_]+)\s*=\s*(.*)$/);
if (!match) throw new Error(`Unparseable TOML line: ${rawLine}`);
const [, key, value] = match;
// Values are JSON-encoded strings — JSON.parse handles all escapes.
result[key] = JSON.parse(value);
}
return result;
}
describe('Gemini Slash Command Namespacing (Regex)', () => {
test('converts simple slash commands', () => {
const input = 'Run /gsd-plan-phase to start.';
const expected = 'Run /gsd:plan-phase to start.';
assert.strictEqual(convertSlashCommandsToGeminiMentions(input), expected);
});
test('preserves URLs with /gsd- in them', () => {
const input = 'Documentation: https://example.com/gsd-tools/info';
assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input);
});
// The roster check is the safety property: a token like /gsd-plan-phase IS
// a known command name, but when it appears inside a URL path it must NOT
// be converted. This pins that the roster check actually fires — a regex-only
// approach without a roster would convert this incorrectly.
test('preserves URLs even when path contains a KNOWN command name', () => {
const input = 'See https://example.com/gsd-plan-phase for context.';
assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input);
});
test('preserves sub-paths: bin/gsd-tools.cjs', () => {
const input = 'See bin/gsd-tools.cjs for details.';
assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input);
});
test('preserves sub-paths even when leaf is a KNOWN command name', () => {
// bin/gsd-plan-phase looks like a known command but is a file path.
// The leading / on a sub-path follows a non-slash char so the regex
// boundary is the safety net here, not the roster.
const input = 'Reference bin/gsd-plan-phase for details.';
assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input);
});
test('preserves root-relative paths with extensions: /gsd-tools.cjs', () => {
const input = 'Load /gsd-tools.cjs now.';
assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input);
});
test('preserves agent names: gsd-planner', () => {
const input = 'The gsd-planner agent will help you.';
assert.strictEqual(convertSlashCommandsToGeminiMentions(input), input);
});
test('converts commands in backticks', () => {
const input = 'Run `/gsd-new-project` in a terminal.';
const expected = 'Run `/gsd:new-project` in a terminal.';
assert.strictEqual(convertSlashCommandsToGeminiMentions(input), expected);
});
test('converts commands ending with punctuation', () => {
// Use two stable, foundational commands so this test doesn't drift when
// the roster gets consolidated (cf. #2790, which removed `scan`). `help`
// and `health` are both bedrock; if either ever gets removed, swap to
// any other entry currently in commands/gsd/.
const input = 'Run /gsd-help. Or /gsd-health!';
const expected = 'Run /gsd:help. Or /gsd:health!';
assert.strictEqual(convertSlashCommandsToGeminiMentions(input), expected);
});
test('roster has loaded — non-empty (would otherwise silently no-op all conversions)', () => {
_resetGsdCommandRoster();
// First conversion call lazily populates the roster. If it returned an
// empty Set (because commands/gsd/ was not found), every conversion
// becomes a no-op — exactly the bug this code exists to prevent.
const result = convertSlashCommandsToGeminiMentions('Run /gsd-plan-phase.');
assert.strictEqual(result, 'Run /gsd:plan-phase.',
'Roster failed to load — all /gsd- conversions would silently no-op');
});
});
describe('Gemini Markdown Processor', () => {
test('handles command to TOML conversion', () => {
const input = '---\ndescription: Test\n---\nRun /gsd-help.';
const result = convertClaudeToGeminiMarkdown(input, { isCommand: true });
const parsed = parseGeminiCommandToml(result);
assert.equal(parsed.description, 'Test', 'description must round-trip through TOML');
assert.match(parsed.prompt, /\/gsd:help/, 'prompt must contain namespaced command');
assert.doesNotMatch(parsed.prompt, /\/gsd-help/, 'prompt must not retain hyphen form');
});
test('strips <sub> tags from Gemini markdown output (#2768 regression)', () => {
// The pre-refactor command path called stripSubTags before TOML conversion.
// After centralizing through convertClaudeToGeminiMarkdown, sub tags must
// still be stripped — terminals can't render HTML subscript.
const input = 'Run <sub>tiny</sub> /gsd-help now.';
const result = convertClaudeToGeminiMarkdown(input, { isCommand: false });
assert.doesNotMatch(result, /<sub>|<\/sub>/, '<sub> tags must be stripped');
assert.match(result, /\/gsd:help/, 'slash command must still be converted');
});
test('removes AskUserQuestion and ask_user from Gemini agent tools and body (#3362)', () => {
const input = [
'---',
'name: tester',
'tools: Read, AskUserQuestion, ask_user',
'---',
'Use AskUserQuestion or ask_user to ask the user.'
].join('\n');
const result = convertClaudeToGeminiAgent(input);
assert.match(result, /^ {2}- read_file$/m, 'Read should still map to Gemini read_file');
assert.doesNotMatch(result, /^ {2}- ask_user$/m, 'ask_user must not be emitted as a Gemini tool');
assert.doesNotMatch(result, /\bAskUserQuestion\b/, 'Claude tool references must be neutralized');
assert.doesNotMatch(result, /\bask_user\b/, 'lowercase ask_user references must be neutralized');
assert.match(result, /conversational prompting/, 'body should use runtime-neutral wording');
});
});
describe('Gemini Install (Behavioral)', () => {
let tmpDir;
let tmpHome;
let previousCwd;
let previousHome;
let previousUserprofile;
beforeEach(() => {
tmpDir = createTempDir('gsd-gemini-test-');
tmpHome = createTempDir('gsd-gemini-home-');
previousCwd = process.cwd();
previousHome = process.env.HOME;
previousUserprofile = process.env.USERPROFILE;
process.chdir(tmpDir);
// #3037: isolate HOME so the developer's real ~/.gemini/commands/gsd/
// doesn't trigger the local-install conflict-avoidance skip path. This
// test wants to assert that the local install populates commands/gsd/
// when no global GSD is present at the user scope.
process.env.HOME = tmpHome;
process.env.USERPROFILE = tmpHome;
});
afterEach(() => {
process.chdir(previousCwd);
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousUserprofile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = previousUserprofile;
cleanup(tmpDir);
cleanup(tmpHome);
});
test('install creates correct directory structure for Gemini', () => {
// Run install in silent mode
const oldLog = console.log;
console.log = () => {};
try {
install(false, 'gemini');
} finally {
console.log = oldLog;
}
const commandsDir = path.join(tmpDir, '.gemini', 'commands', 'gsd');
assert.ok(fs.existsSync(commandsDir), `Commands should be in ${commandsDir}`);
const agentsDir = path.join(tmpDir, '.gemini', 'agents');
assert.ok(fs.existsSync(agentsDir), 'Agents should be installed');
// Structurally verify a real installed command artifact: parse the TOML
// and assert the prompt body has been namespaced. A directory-only check
// would pass even if every conversion silently no-op'd.
const planPhaseToml = path.join(commandsDir, 'plan-phase.toml');
assert.ok(fs.existsSync(planPhaseToml), 'plan-phase.toml must be installed');
const parsed = parseGeminiCommandToml(fs.readFileSync(planPhaseToml, 'utf8'));
assert.equal(typeof parsed.prompt, 'string', 'plan-phase.toml must have a prompt');
// The plan-phase prompt cross-references other GSD commands; pin that at
// least one of those references survived as a colon-namespaced mention.
assert.match(parsed.prompt, /\/gsd:[a-z][a-z0-9-]*/,
'installed plan-phase.toml prompt must contain at least one /gsd: reference');
assert.doesNotMatch(parsed.prompt, /(?<![A-Za-z0-9./])\/gsd-plan-phase\b/,
'installed plan-phase.toml prompt must not retain unconverted /gsd-plan-phase');
});
});