diff --git a/.changeset/steady-goats-chatter.md b/.changeset/steady-goats-chatter.md new file mode 100644 index 000000000..1f031f1e6 --- /dev/null +++ b/.changeset/steady-goats-chatter.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4753 +--- +**The four translated READMEs no longer advertise a retired runtime** — Japanese, Korean, Brazilian Portuguese and Simplified Chinese each still listed `Gemini CLI` among the supported runtimes and in the installer's runtime prompt, a year after #1928 removed it; all three sites per locale now match English. `VERSIONING.md` also listed a manifest that #1928 deleted, and omitted the VS Code manifest that replaced it. A new `lint-retired-runtime-name` check now fails CI if a retired runtime is named as live in shipped Markdown. (#4729) diff --git a/README.ja-JP.md b/README.ja-JP.md index e852c33b0..8e606182b 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -6,7 +6,7 @@ [English](README.md) · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · **日本語** · [한국어](README.ko-KR.md) -**Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Cursor、Windsurf などに対応した、軽量なメタプロンプティング・コンテキストエンジニアリング・仕様駆動開発システムです。** +**Claude Code、OpenCode、Antigravity CLI、Kimi CLI、Kilo、Codex、Copilot、Cursor、Windsurf などに対応した、軽量なメタプロンプティング・コンテキストエンジニアリング・仕様駆動開発システムです。** [![npm version](https://img.shields.io/npm/v/%40opengsd%2Fgsd-core?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@opengsd/gsd-core) [![npm downloads](https://img.shields.io/npm/dm/%40opengsd%2Fgsd-core?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@opengsd/gsd-core) @@ -21,7 +21,7 @@ ## GSD Core とは -GSD Core は、コンテキストエンジニアリングと仕様駆動開発のフレームワークです。AI コーディングエージェント(Claude Code、Codex、Gemini CLI、Copilot、Cursor など)を規律あるフェーズループで動かします。[コンテキストの腐敗](docs/ja-JP/explanation/context-engineering.md)—AI がコンテキストウィンドウを埋めるにつれて出力品質が低下する問題—を解決するために、重いリサーチ・計画・実行作業をすべて新鮮なコンテキストのサブエージェントで実行し、メインセッションをスリムに保ちます。 +GSD Core は、コンテキストエンジニアリングと仕様駆動開発のフレームワークです。AI コーディングエージェント(Claude Code、Codex、Antigravity CLI、Kimi CLI、Copilot、Cursor など)を規律あるフェーズループで動かします。[コンテキストの腐敗](docs/ja-JP/explanation/context-engineering.md)—AI がコンテキストウィンドウを埋めるにつれて出力品質が低下する問題—を解決するために、重いリサーチ・計画・実行作業をすべて新鮮なコンテキストのサブエージェントで実行し、メインセッションをスリムに保ちます。 --- @@ -43,7 +43,7 @@ GSD Core は、コンテキストエンジニアリングと仕様駆動開発 npx @opengsd/gsd-core@latest ``` -インストーラーはランタイム(Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Cursor、Windsurf など)とグローバルインストールかローカルインストールかを尋ねます。クロスランタイム互換性のためにインストーラーが必要です。`agents/` や `commands/` からファイルを直接コピーしないでください。 +インストーラーはランタイム(Claude Code、OpenCode、Antigravity CLI、Kimi CLI、Kilo、Codex、Copilot、Cursor、Windsurf など)とグローバルインストールかローカルインストールかを尋ねます。クロスランタイム互換性のためにインストーラーが必要です。`agents/` や `commands/` からファイルを直接コピーしないでください。 別のランタイムをお使いの場合や Node.js がない場合は [ランタイムへのインストール](docs/ja-JP/how-to/install-on-your-runtime.md) を参照してください。 diff --git a/README.ko-KR.md b/README.ko-KR.md index 2d90f1a6e..65a7e2a85 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -6,7 +6,7 @@ [English](README.md) · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · **한국어** -**Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf 등을 위한 경량 메타 프롬프팅, 컨텍스트 엔지니어링, 스펙 기반 개발 시스템.** +**Claude Code, OpenCode, Antigravity CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf 등을 위한 경량 메타 프롬프팅, 컨텍스트 엔지니어링, 스펙 기반 개발 시스템.** [![npm version](https://img.shields.io/npm/v/%40opengsd%2Fgsd-core?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@opengsd/gsd-core) [![npm downloads](https://img.shields.io/npm/dm/%40opengsd%2Fgsd-core?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@opengsd/gsd-core) @@ -21,7 +21,7 @@ ## GSD Core란 -GSD Core는 컨텍스트 엔지니어링 및 스펙 기반 개발 프레임워크로, AI 코딩 에이전트(Claude Code, Codex, Gemini CLI, Copilot, Cursor 등)를 엄격한 단계 루프로 운용합니다. AI가 컨텍스트 창을 채워 나가면서 발생하는 품질 저하인 [컨텍스트 rot](docs/ko-KR/explanation/context-engineering.md) 문제를 해결합니다. 무거운 리서치, 기획, 실행 작업은 새로운 컨텍스트의 서브에이전트에서 처리하고, 메인 세션은 가볍게 유지됩니다. +GSD Core는 컨텍스트 엔지니어링 및 스펙 기반 개발 프레임워크로, AI 코딩 에이전트(Claude Code, Codex, Antigravity CLI, Kimi CLI, Copilot, Cursor 등)를 엄격한 단계 루프로 운용합니다. AI가 컨텍스트 창을 채워 나가면서 발생하는 품질 저하인 [컨텍스트 rot](docs/ko-KR/explanation/context-engineering.md) 문제를 해결합니다. 무거운 리서치, 기획, 실행 작업은 새로운 컨텍스트의 서브에이전트에서 처리하고, 메인 세션은 가볍게 유지됩니다. --- @@ -43,7 +43,7 @@ GSD Core는 컨텍스트 엔지니어링 및 스펙 기반 개발 프레임워 npx @opengsd/gsd-core@latest ``` -설치 프로그램이 런타임(Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf 등)과 전역/로컬 설치 여부를 묻습니다. 크로스 런타임 호환성을 위해 설치 프로그램을 사용해야 합니다 — `agents/` 또는 `commands/`에서 파일을 직접 복사하지 마세요. +설치 프로그램이 런타임(Claude Code, OpenCode, Antigravity CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf 등)과 전역/로컬 설치 여부를 묻습니다. 크로스 런타임 호환성을 위해 설치 프로그램을 사용해야 합니다 — `agents/` 또는 `commands/`에서 파일을 직접 복사하지 마세요. 다른 런타임이나 Node.js가 없는 환경은 [런타임에 설치하기](docs/ko-KR/how-to/install-on-your-runtime.md)를 참조하세요. diff --git a/README.pt-BR.md b/README.pt-BR.md index 96c0c9ada..4b64c9fbf 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -6,7 +6,7 @@ [English](README.md) · **Português** · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md) -**Um sistema leve de meta-prompting, engenharia de contexto e desenvolvimento orientado a especificações para Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf e muito mais.** +**Um sistema leve de meta-prompting, engenharia de contexto e desenvolvimento orientado a especificações para Claude Code, OpenCode, Antigravity CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf e muito mais.** [![npm version](https://img.shields.io/npm/v/%40opengsd%2Fgsd-core?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@opengsd/gsd-core) [![npm downloads](https://img.shields.io/npm/dm/%40opengsd%2Fgsd-core?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@opengsd/gsd-core) @@ -21,7 +21,7 @@ ## O que é o GSD Core -GSD Core é um framework de engenharia de contexto e desenvolvimento orientado a especificações que conduz agentes de codificação com IA (Claude Code, Codex, Gemini CLI, Copilot, Cursor e mais) por meio de um ciclo de fases disciplinado. Ele resolve o [context rot](docs/pt-BR/explanation/context-engineering.md) — a degradação de qualidade que se acumula à medida que uma IA preenche sua janela de contexto — executando todo o trabalho pesado de pesquisa, planejamento e execução em subagentes com contexto limpo, mantendo sua sessão principal enxuta. +GSD Core é um framework de engenharia de contexto e desenvolvimento orientado a especificações que conduz agentes de codificação com IA (Claude Code, Codex, Antigravity CLI, Kimi CLI, Copilot, Cursor e mais) por meio de um ciclo de fases disciplinado. Ele resolve o [context rot](docs/pt-BR/explanation/context-engineering.md) — a degradação de qualidade que se acumula à medida que uma IA preenche sua janela de contexto — executando todo o trabalho pesado de pesquisa, planejamento e execução em subagentes com contexto limpo, mantendo sua sessão principal enxuta. --- @@ -43,7 +43,7 @@ Cada marco repete o mesmo ciclo de cinco etapas, uma fase por vez: npx @opengsd/gsd-core@latest ``` -O instalador solicita seu ambiente de execução (Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf e mais) e se deseja instalar globalmente ou localmente. O instalador é necessário para compatibilidade entre runtimes — não copie arquivos diretamente de `agents/` ou `commands/`. +O instalador solicita seu ambiente de execução (Claude Code, OpenCode, Antigravity CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf e mais) e se deseja instalar globalmente ou localmente. O instalador é necessário para compatibilidade entre runtimes — não copie arquivos diretamente de `agents/` ou `commands/`. Em outro runtime ou sem Node.js? Consulte [Instalar no seu runtime](docs/pt-BR/how-to/install-on-your-runtime.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 993167e57..f94dd76d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ [English](README.md) · [Português](README.pt-BR.md) · **简体中文** · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md) -**一套轻量级的元提示、上下文工程与规范驱动开发系统,适用于 Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Cursor、Windsurf 等 AI 编程工具。** +**一套轻量级的元提示、上下文工程与规范驱动开发系统,适用于 Claude Code、OpenCode、Antigravity CLI、Kimi CLI、Kilo、Codex、Copilot、Cursor、Windsurf 等 AI 编程工具。** [![npm version](https://img.shields.io/npm/v/%40opengsd%2Fgsd-core?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@opengsd/gsd-core) [![npm downloads](https://img.shields.io/npm/dm/%40opengsd%2Fgsd-core?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@opengsd/gsd-core) @@ -21,7 +21,7 @@ ## 什么是 GSD Core -GSD Core 是一套上下文工程与规范驱动开发框架,能够引导 AI 编程智能体(Claude Code、Codex、Gemini CLI、Copilot、Cursor 等)按照严格的阶段循环推进工作。它解决了[上下文腐化](docs/zh-CN/explanation/context-engineering.md)问题——即随着 AI 填满上下文窗口而逐渐累积的质量下降——通过在全新上下文的子智能体中运行所有繁重的研究、规划和执行工作,同时保持主会话的精简。 +GSD Core 是一套上下文工程与规范驱动开发框架,能够引导 AI 编程智能体(Claude Code、Codex、Antigravity CLI、Kimi CLI、Copilot、Cursor 等)按照严格的阶段循环推进工作。它解决了[上下文腐化](docs/zh-CN/explanation/context-engineering.md)问题——即随着 AI 填满上下文窗口而逐渐累积的质量下降——通过在全新上下文的子智能体中运行所有繁重的研究、规划和执行工作,同时保持主会话的精简。 --- @@ -43,7 +43,7 @@ GSD Core 是一套上下文工程与规范驱动开发框架,能够引导 AI npx @opengsd/gsd-core@latest ``` -安装程序会提示选择运行时(Claude Code、OpenCode、Gemini CLI、Kilo、Codex、Copilot、Cursor、Windsurf 等)以及是全局安装还是本地安装。跨运行时兼容性需要使用安装程序——请勿直接从 `agents/` 或 `commands/` 目录复制文件。 +安装程序会提示选择运行时(Claude Code、OpenCode、Antigravity CLI、Kimi CLI、Kilo、Codex、Copilot、Cursor、Windsurf 等)以及是全局安装还是本地安装。跨运行时兼容性需要使用安装程序——请勿直接从 `agents/` 或 `commands/` 目录复制文件。 使用其他运行时或没有 Node.js?请参阅[在你的运行时上安装](docs/zh-CN/how-to/install-on-your-runtime.md)。 diff --git a/VERSIONING.md b/VERSIONING.md index dd821c209..6c9e59a05 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -130,10 +130,10 @@ Certain runtime-integration manifests carry a `version` field that must always match `package.json`: - `.claude-plugin/plugin.json` — Claude Code plugin manifest (issue #766) -- `gemini-extension.json` — Gemini CLI extension manifest (issue #775) - `.claude-plugin/marketplace.json` — Claude plugin marketplace manifest; its version lives at `plugins[0].version` and is stamped via a nested versionKey descriptor (issue #1855) +- `vscode/package.json` — VS Code extension manifest (issue #1942) The `version` npm lifecycle script (`scripts/sync-manifest-versions.cjs --stage`) stamps these files automatically on every `npm version` call, and stages them so diff --git a/docs/ja-JP/FEATURES.md b/docs/ja-JP/FEATURES.md index 5cbe68180..87fe6ccf5 100644 --- a/docs/ja-JP/FEATURES.md +++ b/docs/ja-JP/FEATURES.md @@ -1166,7 +1166,7 @@ fix(03-01): correct auth token expiry **コマンド:** `/gsd-review --phase N [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--agy] [--antigravity] [--ollama] [--lm-studio] [--llama-cpp] [--kimi-code] [--all]` -**目的:** 外部の AI CLI(Gemini、Claude、Codex、CodeRabbit、OpenCode、Qwen Code、Cursor、Antigravity、Kimi Code)とローカルの OpenAI 互換サーバー(Ollama、LM Studio、llama.cpp)を呼び出して、フェーズプランを独立してレビューします。レビュアーごとのフィードバックを含む構造化された REVIEWS.md を生成します。 +**目的:** 外部の AI CLI(Claude、Codex、CodeRabbit、OpenCode、Qwen Code、Cursor、Antigravity、Kimi Code)とローカルの OpenAI 互換サーバー(Ollama、LM Studio、llama.cpp)を呼び出して、フェーズプランを独立してレビューします。レビュアーごとのフィードバックを含む構造化された REVIEWS.md を生成します。 **要件:** - REQ-REVIEW-01: システムはシステム上で利用可能な AI CLI を検出しなければならない @@ -1430,7 +1430,7 @@ Claude が GSD ワークフローコンテキスト外でファイル編集を **目的:** 1回のインタラクティブなインストールセッションで複数のランタイムを選択します。 **要件:** -- REQ-MULTI-RT-01: インタラクティブプロンプトはマルチセレクトをサポートしなければならない(例: Claude Code + Gemini) +- REQ-MULTI-RT-01: インタラクティブプロンプトはマルチセレクトをサポートしなければならない(例: Claude Code + Antigravity) - REQ-MULTI-RT-02: CLI フラグは非インタラクティブインストールで引き続き動作しなければならない **プロセス:** @@ -1672,13 +1672,13 @@ Claude が GSD ワークフローコンテキスト外でファイル編集を **要件:** - REQ-SKILLS-01: インストーラーは Claude Code 2.1.88+ 向けに `skills/gsd-*/SKILL.md` を書き込まなければならない - REQ-SKILLS-02: インストーラーはレガシー `commands/gsd/` ディレクトリを自動クリーンしなければならない -- REQ-SKILLS-03: Gemini パスを通じて古い Claude Code バージョンとの後方互換性を維持しなければならない +- REQ-SKILLS-03: レガシー `commands/gsd/` パスを通じて古い Claude Code バージョンとの後方互換性を維持しなければならない **プロセス:** 1. **検出** — Claude Code のバージョンをチェックしてスキルサポートを判定 2. **マイグレーション** — 各 GSD コマンドに対して `skills/gsd-*/SKILL.md` ファイルを書き込み 3. **クリーン** — スキルがインストールされた場合、レガシー `commands/gsd/` ディレクトリを削除 -4. **フォールバック** — 古い Claude Code バージョンのために Gemini パス互換性を維持 +4. **フォールバック** — 古い Claude Code バージョンのためにレガシー `commands/gsd/` パス互換性を維持 --- @@ -2952,7 +2952,7 @@ Source commit: abc1234 (3 commits behind HEAD) **要件:** - REQ-QUOTA-01: クォータ失敗は即時再試行を主要な回復として提供してはならない。 -- REQ-QUOTA-02: 分類は Claude、Copilot、Codex、Gemini、および汎用プロバイダーセンチネルをカバーしなければならない。 +- REQ-QUOTA-02: 分類は Claude、Copilot、Codex、および汎用プロバイダーセンチネルをカバーしなければならない。 - REQ-QUOTA-03: 非クォータ失敗は通常の実行失敗パスを継続しなければならない。 **参照:** [プロバイダーレート制限シグナル](../research/provider-rate-limit-signals.md) diff --git a/docs/ja-JP/how-to/execute-a-phase.md b/docs/ja-JP/how-to/execute-a-phase.md index ae799b862..903e9ba2e 100644 --- a/docs/ja-JP/how-to/execute-a-phase.md +++ b/docs/ja-JP/how-to/execute-a-phase.md @@ -85,7 +85,7 @@ Git の履歴には、各エグゼキュータからのタスクごとのコミ ## クロス AI 実行 -`workflow.cross_ai_command` で設定された外部 AI CLI(Codex、Gemini など)に実行を委任するには: +`workflow.cross_ai_command` で設定された外部 AI CLI(Codex、Antigravity など)に実行を委任するには: ```bash /gsd-execute-phase 2 --cross-ai diff --git a/docs/ja-JP/how-to/verify-and-ship.md b/docs/ja-JP/how-to/verify-and-ship.md index fb9e876a9..d270e2fa5 100644 --- a/docs/ja-JP/how-to/verify-and-ship.md +++ b/docs/ja-JP/how-to/verify-and-ship.md @@ -86,7 +86,7 @@ PR の本文はプランニング成果物から自動的に組み立てられ /gsd-code-review 1 --depth=deep # インポートグラフを含むクロスファイル分析 ``` -サイクルの早い段階でのプランレビューに Gemini、Codex、その他のレビュアーを設定するには [クロス AI レビューの設定](set-up-cross-ai-review.md) を参照してください。 +サイクルの早い段階でのプランレビューに Antigravity、Codex、その他のレビュアーを設定するには [クロス AI レビューの設定](set-up-cross-ai-review.md) を参照してください。 --- diff --git a/docs/ko-KR/FEATURES.md b/docs/ko-KR/FEATURES.md index 940a5dc4c..22797dd91 100644 --- a/docs/ko-KR/FEATURES.md +++ b/docs/ko-KR/FEATURES.md @@ -1070,7 +1070,7 @@ fix(03-01): correct auth token expiry **명령어:** `/gsd-review --phase N [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--agy] [--antigravity] [--ollama] [--lm-studio] [--llama-cpp] [--kimi-code] [--all]` -**목적:** 외부 AI CLI(Gemini, Claude, Codex, CodeRabbit, OpenCode, Qwen Code, Cursor, Antigravity, Kimi Code)와 로컬 OpenAI 호환 서버(Ollama, LM Studio, llama.cpp)를 호출하여 페이즈 계획을 독립적으로 검토합니다. 검토자별 피드백이 담긴 구조화된 REVIEWS.md를 생성합니다. +**목적:** 외부 AI CLI(Claude, Codex, CodeRabbit, OpenCode, Qwen Code, Cursor, Antigravity, Kimi Code)와 로컬 OpenAI 호환 서버(Ollama, LM Studio, llama.cpp)를 호출하여 페이즈 계획을 독립적으로 검토합니다. 검토자별 피드백이 담긴 구조화된 REVIEWS.md를 생성합니다. **요구사항.** - REQ-REVIEW-01: 시스템에서 사용 가능한 AI CLI를 감지해야 합니다. @@ -1334,7 +1334,7 @@ Claude가 GSD 워크플로우 컨텍스트 밖에서 파일 편집을 시도하 **목적:** 단일 대화형 설치 세션에서 여러 런타임을 선택합니다. **요구사항.** -- REQ-MULTI-RT-01: 대화형 프롬프트는 다중 선택을 지원해야 합니다(예: Claude Code + Gemini). +- REQ-MULTI-RT-01: 대화형 프롬프트는 다중 선택을 지원해야 합니다(예: Claude Code + Antigravity). - REQ-MULTI-RT-02: CLI 플래그는 비대화형 설치에서 계속 작동해야 합니다. **프로세스.** @@ -1576,13 +1576,13 @@ Claude가 GSD 워크플로우 컨텍스트 밖에서 파일 편집을 시도하 **요구사항.** - REQ-SKILLS-01: 설치 프로그램은 Claude Code 2.1.88+ 용 `skills/gsd-*/SKILL.md`를 작성해야 합니다. - REQ-SKILLS-02: 설치 프로그램은 레거시 `commands/gsd/` 디렉토리를 자동 정리해야 합니다. -- REQ-SKILLS-03: Gemini 경로를 통해 이전 Claude Code 버전과의 하위 호환성을 유지해야 합니다. +- REQ-SKILLS-03: 레거시 `commands/gsd/` 경로를 통해 이전 Claude Code 버전과의 하위 호환성을 유지해야 합니다. **프로세스.** 1. **감지** — Claude Code 버전을 확인하여 스킬 지원 여부 판단 2. **마이그레이션** — 각 GSD 명령어에 대해 `skills/gsd-*/SKILL.md` 파일 작성 3. **정리** — 스킬이 설치되면 레거시 `commands/gsd/` 디렉토리 제거 -4. **폴백** — 이전 Claude Code 버전을 위한 Gemini 경로 호환성 유지 +4. **폴백** — 이전 Claude Code 버전을 위한 레거시 `commands/gsd/` 경로 호환성 유지 --- diff --git a/docs/ko-KR/how-to/execute-a-phase.md b/docs/ko-KR/how-to/execute-a-phase.md index 227031f5a..cf61c65d2 100644 --- a/docs/ko-KR/how-to/execute-a-phase.md +++ b/docs/ko-KR/how-to/execute-a-phase.md @@ -85,7 +85,7 @@ Git 히스토리에는 각 실행자의 태스크당 커밋 하나와 오케스 ## 크로스 AI 실행 -`workflow.cross_ai_command`에 설정된 외부 AI CLI(Codex, Gemini 등)에 실행을 위임하려면: +`workflow.cross_ai_command`에 설정된 외부 AI CLI(Codex, Antigravity 등)에 실행을 위임하려면: ```bash /gsd-execute-phase 2 --cross-ai diff --git a/docs/ko-KR/how-to/verify-and-ship.md b/docs/ko-KR/how-to/verify-and-ship.md index fddaff542..da7f2990f 100644 --- a/docs/ko-KR/how-to/verify-and-ship.md +++ b/docs/ko-KR/how-to/verify-and-ship.md @@ -86,7 +86,7 @@ PR 본문은 기획 산출물에서 자동으로 조합됩니다: /gsd-code-review 1 --depth=deep # 임포트 그래프를 포함한 파일 간 분석 ``` -주기 초반의 계획 리뷰를 위해 Gemini, Codex 또는 다른 리뷰어를 설정하려면 [크로스 AI 리뷰 설정](set-up-cross-ai-review.md)을 참고하세요. +주기 초반의 계획 리뷰를 위해 Antigravity, Codex 또는 다른 리뷰어를 설정하려면 [크로스 AI 리뷰 설정](set-up-cross-ai-review.md)을 참고하세요. --- diff --git a/docs/pt-BR/how-to/execute-a-phase.md b/docs/pt-BR/how-to/execute-a-phase.md index 8f3b3156e..380e722ec 100644 --- a/docs/pt-BR/how-to/execute-a-phase.md +++ b/docs/pt-BR/how-to/execute-a-phase.md @@ -85,7 +85,7 @@ O histórico git exibirá um commit por tarefa (de cada executor), seguido de co ## Execução Cross-AI -Para delegar a execução a uma CLI de IA externa (Codex, Gemini, etc.) configurada em `workflow.cross_ai_command`: +Para delegar a execução a uma CLI de IA externa (Codex, Antigravity, etc.) configurada em `workflow.cross_ai_command`: ```bash /gsd-execute-phase 2 --cross-ai diff --git a/docs/pt-BR/how-to/verify-and-ship.md b/docs/pt-BR/how-to/verify-and-ship.md index 53486a655..87acb021b 100644 --- a/docs/pt-BR/how-to/verify-and-ship.md +++ b/docs/pt-BR/how-to/verify-and-ship.md @@ -86,7 +86,7 @@ Não é necessário escrever o corpo manualmente. /gsd-code-review 1 --depth=deep # Análise entre arquivos incluindo grafos de importação ``` -Consulte [Configurar revisão entre IAs](set-up-cross-ai-review.md) para configurar o Gemini, Codex ou outros revisores para revisão de planos mais cedo no ciclo. +Consulte [Configurar revisão entre IAs](set-up-cross-ai-review.md) para configurar o Antigravity, Codex ou outros revisores para revisão de planos mais cedo no ciclo. --- diff --git a/docs/zh-CN/FEATURES.md b/docs/zh-CN/FEATURES.md index cd25f42c0..32a91284d 100644 --- a/docs/zh-CN/FEATURES.md +++ b/docs/zh-CN/FEATURES.md @@ -1176,7 +1176,7 @@ GSD update available: 1.39.0 → 1.40.0. Run /gsd-update. **命令:** `/gsd-review --phase N [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--agy] [--antigravity] [--ollama] [--lm-studio] [--llama-cpp] [--kimi-code] [--all]` -**目的:** 调用外部 AI CLI(Gemini、Claude、Codex、CodeRabbit、OpenCode、Qwen Code、Cursor、Antigravity、Kimi Code)和本地 OpenAI 兼容服务器(Ollama、LM Studio、llama.cpp)独立审查阶段计划。生成包含每位审查者反馈的结构化 REVIEWS.md。 +**目的:** 调用外部 AI CLI(Claude、Codex、CodeRabbit、OpenCode、Qwen Code、Cursor、Antigravity、Kimi Code)和本地 OpenAI 兼容服务器(Ollama、LM Studio、llama.cpp)独立审查阶段计划。生成包含每位审查者反馈的结构化 REVIEWS.md。 **需求:** - REQ-REVIEW-01:系统必须检测系统上可用的 AI CLI @@ -1447,7 +1447,7 @@ PreToolUse 钩子,检测 Claude 在 GSD 工作流上下文之外尝试文件 **目的:** 在单个交互式安装会话中选择多个运行时。 **需求:** -- REQ-MULTI-RT-01:交互式提示必须支持多选(例如 Claude Code + Gemini) +- REQ-MULTI-RT-01:交互式提示必须支持多选(例如 Claude Code + Antigravity) - REQ-MULTI-RT-02:CLI 标志必须继续适用于非交互式安装 **流程:** @@ -1688,13 +1688,13 @@ PreToolUse 钩子,检测 Claude 在 GSD 工作流上下文之外尝试文件 **需求:** - REQ-SKILLS-01:安装器必须为 Claude Code 2.1.88+ 写入 `skills/gsd-*/SKILL.md` - REQ-SKILLS-02:安装器必须自动清理旧版 `commands/gsd/` 目录 -- REQ-SKILLS-03:安装器必须通过 Gemini 路径维护与旧版 Claude Code 的向后兼容性 +- REQ-SKILLS-03:安装器必须通过旧版 `commands/gsd/` 路径维护与旧版 Claude Code 的向后兼容性 **流程:** 1. **检测** — 检查 Claude Code 版本以确定技能支持情况 2. **迁移** — 为每个 GSD 命令写入 `skills/gsd-*/SKILL.md` 文件 3. **清理** — 如果已安装技能,则删除旧版 `commands/gsd/` 目录 -4. **回退** — 为旧版 Claude Code 维护 Gemini 路径兼容性 +4. **回退** — 为旧版 Claude Code 维护旧版 `commands/gsd/` 路径兼容性 --- @@ -2968,7 +2968,7 @@ explicit reviewer flags -> --all -> review.default_reviewers -> all detected rev **需求:** - REQ-QUOTA-01:配额失败不得将立即重试作为主要恢复选项。 -- REQ-QUOTA-02:分类必须涵盖 Claude、Copilot、Codex、Gemini 和通用提供商哨兵。 +- REQ-QUOTA-02:分类必须涵盖 Claude、Copilot、Codex 和通用提供商哨兵。 - REQ-QUOTA-03:非配额失败必须继续通过正常的执行失败路径。 **参考:** [提供商速率限制信号](../research/provider-rate-limit-signals.md) diff --git a/docs/zh-CN/how-to/execute-a-phase.md b/docs/zh-CN/how-to/execute-a-phase.md index 75fc90596..b99617dd2 100644 --- a/docs/zh-CN/how-to/execute-a-phase.md +++ b/docs/zh-CN/how-to/execute-a-phase.md @@ -85,7 +85,7 @@ Git 历史记录中每个任务会有一个提交(来自各执行器),随 ## 跨 AI 执行 -要将执行委托给在 `workflow.cross_ai_command` 中配置的外部 AI CLI(Codex、Gemini 等): +要将执行委托给在 `workflow.cross_ai_command` 中配置的外部 AI CLI(Codex、Antigravity 等): ```bash /gsd-execute-phase 2 --cross-ai diff --git a/docs/zh-CN/how-to/verify-and-ship.md b/docs/zh-CN/how-to/verify-and-ship.md index c7c66dba7..22d9094db 100644 --- a/docs/zh-CN/how-to/verify-and-ship.md +++ b/docs/zh-CN/how-to/verify-and-ship.md @@ -86,7 +86,7 @@ PR 正文由规划产物自动组装: /gsd-code-review 1 --depth=deep # 包含导入图的跨文件分析 ``` -请参阅[配置跨 AI 审查](set-up-cross-ai-review.md),了解如何在周期早期为计划审查配置 Gemini、Codex 或其他审查工具。 +请参阅[配置跨 AI 审查](set-up-cross-ai-review.md),了解如何在周期早期为计划审查配置 Antigravity、Codex 或其他审查工具。 --- diff --git a/package.json b/package.json index 92966ad7c..292dba2af 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs", "lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs", "lint:response-language": "node scripts/lint-response-language-coverage.cjs", - "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/lint-portable-grep.cjs && node scripts/lint-allowed-tools-parity.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-slug-derivation-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs && node scripts/lint-seam-enforcement.cjs && node scripts/lint-workflow-shellcheck.cjs && npm run lint:response-language", + "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && npm run lint:retired-runtime-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/lint-portable-grep.cjs && node scripts/lint-allowed-tools-parity.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-slug-derivation-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs && node scripts/lint-seam-enforcement.cjs && node scripts/lint-workflow-shellcheck.cjs && npm run lint:response-language", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", @@ -136,6 +136,7 @@ "lint:docs": "node scripts/lint-docs-required.cjs", "lint:qa-smells": "node scripts/qa-smell-ratchet.cjs", "lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs", + "lint:retired-runtime-name": "node scripts/lint-retired-runtime-name.cjs", "lint:seam-enforcement": "node scripts/lint-seam-enforcement.cjs", "lint:docs-command-form": "node scripts/lint-docs-command-form.cjs", "lint:hooks-runtime-build-seam": "node scripts/lint-hooks-runtime-build-seam.cjs", diff --git a/scripts/lib/macos-conformance-tier.generated.cjs b/scripts/lib/macos-conformance-tier.generated.cjs index 7af1d93a0..8c95dd360 100644 --- a/scripts/lib/macos-conformance-tier.generated.cjs +++ b/scripts/lib/macos-conformance-tier.generated.cjs @@ -113,6 +113,7 @@ module.exports = { "tests/issue-version-gate.test.cjs", "tests/kimi-upgrades.test.cjs", "tests/lint-docs-command-form.test.cjs", + "tests/lint-retired-runtime-name.test.cjs", "tests/lint-workflow-shellcheck-fetch.test.cjs", "tests/list-seeds.test.cjs", "tests/markdown-sectionizer.test.cjs", diff --git a/scripts/lint-docs-guard-registration.exempt-baseline.cjs b/scripts/lint-docs-guard-registration.exempt-baseline.cjs index d5a96ed27..7b1195a28 100644 --- a/scripts/lint-docs-guard-registration.exempt-baseline.cjs +++ b/scripts/lint-docs-guard-registration.exempt-baseline.cjs @@ -68,6 +68,7 @@ const DOCS_GUARD_EXEMPT_BASELINE = [ 'lint-allow-test-rule-refs.test.cjs', 'lint-docs-command-form.test.cjs', 'lint-docs-required.test.cjs', + 'lint-retired-runtime-name.test.cjs', 'manifest-version-sync.test.cjs', 'milestone-archive.test.cjs', 'model-resolver.test.cjs', @@ -178,6 +179,26 @@ const DOCS_GUARD_EXEMPT_DOCS_PATHS = { 'docs/COMMANDS.md', 'docs/USER-GUIDE.md', 'docs/adr', 'docs/adr/0001-foo.md', 'docs/adr/0099-new.md', 'docs/agents', 'docs/agents/triage-labels.md', ], + // #4729: `writeFile('docs/...', ...)` calls WRITE fabricated fixture + // content into a throwaway mkdtemp repo; the file never reads real shipped + // docs/ content — every path here is fixture data for the guard under + // test. + // Re-confirmed: every path below is WRITTEN as fabricated fixture content + // into a throwaway mkdtemp repo; none reads real shipped docs. The a*/b*/c*/ + // d* entries are the adversarial-regression fixtures (dialect-plus-runtime- + // word, adjacency laundering, version laundering, bare-marker abuse). + 'lint-retired-runtime-name.test.cjs': [ + 'docs/adr', 'docs/adr/999-retired-runtime-record.md', 'docs/filler/note-', + 'docs/guides/a1.md', 'docs/guides/a2.md', 'docs/guides/b1.md', + 'docs/guides/b2.md', 'docs/guides/b3.md', 'docs/guides/c3.md', + 'docs/guides/c4.md', 'docs/guides/config-homes.md', 'docs/guides/d1.md', + 'docs/guides/hooks.md', 'docs/guides/one.md', 'docs/guides/runtime-claim.md', + 'docs/guides/setup.md', 'docs/history/migration-note.md', + 'docs/ja-JP/guides/runtimes.md', 'docs/pt-BR/guides/a3.md', + 'docs/pt-BR/guides/hooks.md', 'docs/pt-BR/guides/policy.md', + 'docs/reference/client-models.md', 'docs/reference/models.md', + 'docs/zh-CN/guides/a4.md', 'docs/zh-CN/reference/models.md', + ], 'manifest-version-sync.test.cjs': [], // #3884: re-confirmed — the added docs/CLI-TOOLS.md:458 reference is the // same class as the existing docs/TESTING-SUITES.md one (a placement-note diff --git a/scripts/lint-retired-runtime-name.cjs b/scripts/lint-retired-runtime-name.cjs new file mode 100644 index 000000000..6f3aa1ab0 --- /dev/null +++ b/scripts/lint-retired-runtime-name.cjs @@ -0,0 +1,612 @@ +#!/usr/bin/env node +/** + * lint-retired-runtime-name.cjs + * + * Prevents a RETIRED GSD runtime name from being presented as if it were a + * live runtime (#1928 retired the "Gemini CLI" runtime lane in favor of + * Antigravity). Mirrors scripts/lint-legacy-dir-name.cjs's structure and + * conventions (same problem shape: forbid a retired token, allowlist + * frozen/legitimate content, self-exempt, inline marker, `GSD_LINT_*_REPO_ROOT` + * test seam, ./lib/cli-exit.cjs, binary-file skip). + * + * THE PREDICATE (load-bearing — do not "simplify"): + * The forbidden token is matched CASE-SENSITIVELY, standalone (word + * boundaries), capitalised: /\bGemini\b/ — NO `i` flag. + * + * Case sensitivity IS the mechanism. Every LEGITIMATE reference to the + * "gemini" string in this repo is spelled differently and therefore cannot + * match a case-sensitive `Gemini`: + * - Antigravity's config homes are lowercase with a slash: + * ~/.gemini/antigravity, ~/.gemini/antigravity-ide, ~/.gemini/config + * - Google's model ids are lowercase and hyphenated: + * gemini-3.1-pro-preview, gemini-2.5-flash-lite + * - env vars are uppercase: GEMINI_CONFIG_DIR, GEMINI_SESSION_ID, GEMINI_API_KEY + * - the instruction file is GEMINI.md + * A bare capitalised `Gemini` therefore means the retired RUNTIME (or its + * retired reviewer lane) is being named as if live — which is the defect + * this guard exists to catch. + * + * RETIRED_RUNTIMES is a table, not a hardcoded single name, so the guard + * generalises to the next retired runtime for free. The name is built via + * split-string concatenation (same trick the legacy-dir-name precedent + * uses) so this guard script cannot flag itself. + * + * THREE TIERS OF EXEMPTION, deliberately ordered narrowest-first. The + * allowlist is the entire risk surface of this guard, so each tier states + * what it can and cannot see: + * + * 1. GENERAL RULES (apply anywhere, no per-file registration) — the two + * spellings that are legitimate wherever they appear: + * (a) the hook DIALECT Antigravity genuinely inherits: `Gemini-style`, + * `Gemini-compatible`, `Gemini スタイル`; + * (b) a model-display version on the provider/model axis + * (`Gemini 2.5 Pro`), REFUSED when a runtime word also appears on + * the line so a version can never launder a runtime claim. + * 2. PINNED OCCURRENCES (`ALLOWLIST_OCCURRENCES`) — per-file, per-line + * approved snippets. This is what makes a NEW occurrence in an + * otherwise-legitimate file FAIL: the file is not blanket-trusted, only + * the enumerated lines are. A pin that stops matching is reported as a + * stale allowlist entry, so the allowlist cannot silently rot. + * 3. FILE / DIR ALLOWLIST — blanket trust, and therefore reserved for + * content that is append-only by policy (ADRs, dated research) or + * generated-and-locked (CHANGELOG.md). A new occurrence inside these + * IS invisible to this guard; that is the accepted cost of not + * re-litigating frozen history on every run, and it is why the tier is + * kept as small as it is. + * + * Plus an anti-vacuity floor on files actually READ (not merely listed), so + * an empty or failed walk can never report a false clean. + * + * Exit 0 if no violations; exit 1 if any are found (with stderr diagnostics). + */ + +'use strict'; + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); +const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); + +// Table of retired runtimes. Each name is constructed via split-string +// concatenation so this guard script cannot flag itself when scanned. +const RETIRED_RUNTIMES = [ + { + name: 'Gem' + 'ini', + retiredBy: '#1928', + sunset: '2026-06-18', + successor: 'Antigravity', + }, +]; + +const RETIRED_RUNTIME_MATCHERS = RETIRED_RUNTIMES.map((r) => ({ + runtime: r, + // Case-sensitive, no 'i' flag — see module header for why. + re: new RegExp('\\b' + r.name + '\\b', 'g'), +})); + +// Minimum plausible number of files actually READ. An empty or failed walk +// must never silently report "clean" (anti-vacuity requirement). +const MIN_EXPECTED_FILES = 150; + +const ALLOW_MARKER = 'gsd-allow-retired-runtime-name'; +// The escape hatch must carry a justification. Bare-marker abuse was raised in +// review: the marker is checked first, excuses the whole line, and the failure +// message advertises it, so an unexplained one is indistinguishable from a +// defect somebody silenced. Require `gsd-allow-retired-runtime-name: `. +const ALLOW_MARKER_RE = new RegExp(escapeRegex(ALLOW_MARKER) + ':\\s*\\S{3,}'); +const SELF_PATH = path.resolve(__filename); +// GSD_LINT_RETIRED_RUNTIME_REPO_ROOT is used by tests to redirect the guard +// to a temporary fixture git repo without touching the real working tree. +const REPO_ROOT = process.env.GSD_LINT_RETIRED_RUNTIME_REPO_ROOT + ? path.resolve(process.env.GSD_LINT_RETIRED_RUNTIME_REPO_ROOT) + : path.resolve(__dirname, '..'); + +// Every tracked *.md file is in scope. An earlier prefix list +// (docs/ gsd-core/ commands/ agents/ skills/ + root) left `.changeset/`, +// `.github/`, `capabilities/`, `playbooks/` and `references/` invisible — +// and `.changeset/*.md` RENDERS INTO CHANGELOG.md, which is blanket-trusted, +// so a live claim introduced there was invisible at both ends. + +// TIER 3 — blanket trust. Append-only by policy, or generated-and-locked. +// A new occurrence inside these is invisible to this guard; see the module +// header for why that cost is accepted here and nowhere else. +const ALLOWLIST_FILES = new Set([ + // Locked by CLAUDE.md ("CHANGELOG.md is Locked") and generated from + // changeset fragments: frozen release history, one entry per shipped + // release, including the releases that removed the runtime. + 'CHANGELOG.md', +]); + +const ALLOWLIST_DIR_PREFIXES = [ + // `.changeset/` is CHANGELOG.md before rendering — the same surface, one + // step upstream — so it gets the same treatment. Measured: all 21 hits are + // fragments DESCRIBING the retirement or a fix to it (e.g. "Retired the + // Gemini CLI reviewer lane", "Gemini install output is valid on Windows + // PowerShell"), which is what a release note is for. 464 of the 479 live + // under `archived/`. Pinning them would add friction with no signal: a + // fragment can only describe what already shipped, and it is deleted at + // release. The cost is stated rather than hidden — a fragment introducing a + // live claim is invisible here, as it is in CHANGELOG.md itself. + '.changeset/', + // Architecture decision records are append-only history. + 'docs/adr/', + // Frozen historical release notes. + 'docs/RELEASE-NOTES-LEGACY.md', + // Dated research records, incl. the gemini-to-antigravity migration note. + 'docs/research/', + // Dated records. + 'docs/issueevidence/', + 'docs/discussions/', + 'docs/superpowers/', +]; + +// TIER 2 — pinned occurrences. relPath -> [{ snippet, reason }]. A match is +// excused only when its LINE contains one of that file's snippets, so a new, +// unrelated occurrence in the same file still fails. Every snippet is a +// verbatim fragment of the line it approves. +const ALLOWLIST_OCCURRENCES = new Map([ + ['CONTEXT.md', [ + // The name precedes the annotation on this line, so the snippet must span + // both for span containment to cover the match. + { snippet: 'Gemini [runtime removed #1928]', reason: 'session log annotates the removal inline' }, + ]], + ['GEMINI.md', [ + { snippet: 'Gemini CLI was sunset by Google on 2026-06-18', reason: 'states the sunset' }, + { snippet: 'shared Gemini 3 backend', reason: 'names the shared model backend Antigravity uses' }, + ]], + ['docs/whats-new-1.7.0.md', [ + // Two occurrences on one line, so two pins: span containment deliberately + // refuses to let the first excuse the second. + { snippet: 'Gemini CLI removed** (#1928)', reason: 'release note announcing the removal' }, + { snippet: 'Google discontinued Gemini CLI on 2026-06-18', reason: 'states the sunset date' }, + ]], + ['docs/explanation/embeddable-orchestration-system.md', [ + { snippet: 'Gemini CLI was retired in', reason: 'records the retirement' }, + ]], + // The fragment is the SOURCE; docs/FEATURES.md is generated from it by + // scripts/gen-features.cjs, so both carry the same approved line and the + // same pin covers each. A marker written into FEATURES.md by hand would be + // dropped on the next regeneration. + ['docs/features/embeddable-orchestration-system-host-integration-interface.md', [ + { snippet: 'retired Gemini CLI now redirects to Antigravity CLI', reason: 'records the redirect' }, + ]], + ['docs/FEATURES.md', [ + { snippet: 'retired Gemini CLI now redirects to Antigravity CLI', reason: 'generated from the fragment above' }, + ]], + ['docs/reference/host-integration-capability-matrix.md', [ + { snippet: '(Gemini CLI\'s `thinkingConfig`) was removed as a sunset runtime', reason: 'explains why no config-file member exists' }, + { snippet: 'successor to the sunset Gemini CLI per #1928', reason: 'records the sunset and the inherited contract' }, + ]], + // --- The inherited hook dialect, where the line ALSO names a runtime ----- + // Tier 1(a) requires no runtime word on the line, because the tight + // "immediately after the name" veto let real laundering through. These ten + // lines legitimately pair a dialect compound with a runtime word (usually + // `~/.gemini/antigravity-cli` in a table cell, or "runtime files" in the + // same sentence), so each is pinned explicitly instead of loosening the + // veto for everyone. + ['docs/ARCHITECTURE.md', [ + { snippet: 'Gemini-style `settings.json` hook entries', reason: 'hook dialect Antigravity inherits' }, + ]], + ['docs/ja-JP/ARCHITECTURE.md', [ + { snippet: 'Gemini スタイル `settings.json` フックエントリ', reason: 'hook dialect Antigravity inherits' }, + ]], + ['docs/ko-KR/ARCHITECTURE.md', [ + { snippet: 'Gemini 스타일 `settings.json` 훅 항목', reason: 'hook dialect Antigravity inherits' }, + ]], + ['docs/pt-BR/ARCHITECTURE.md', [ + { snippet: 'no estilo Gemini quando instalado', reason: 'hook dialect Antigravity inherits' }, + ]], + ['docs/zh-CN/ARCHITECTURE.md', [ + { snippet: 'Gemini 风格 `settings.json` hook 条目', reason: 'hook dialect Antigravity inherits' }, + ]], + ['docs/how-to/install-on-your-runtime.md', [ + { snippet: 'Uses Gemini-compatible settings policy', reason: 'settings dialect Antigravity inherits' }, + ]], + ['docs/ja-JP/how-to/install-on-your-runtime.md', [ + { snippet: 'Gemini 互換', reason: 'settings dialect Antigravity inherits' }, + ]], + ['docs/ko-KR/how-to/install-on-your-runtime.md', [ + { snippet: 'Gemini 호환 설정 정책을 사용합니다', reason: 'settings dialect Antigravity inherits' }, + ]], + ['docs/pt-BR/how-to/install-on-your-runtime.md', [ + { snippet: 'compatível com Gemini', reason: 'settings dialect Antigravity inherits' }, + ]], + ['docs/zh-CN/how-to/install-on-your-runtime.md', [ + { snippet: '与 Gemini 兼容的设置策略', reason: 'settings dialect Antigravity inherits' }, + ]], + ['gsd-core/workflows/reapply-patches.md', [ + { snippet: 'pre-#1928 Gemini CLI install', reason: 'legacy-install patch location, explicitly pre-retirement' }, + ]], + ['gsd-core/workflows/settings-advanced.md', [ + { snippet: '(Claude / OpenAI / Gemini / Qwen)', reason: 'model-provider menu, not a GSD runtime' }, + ]], + ['gsd-core/references/ai-frameworks.md', [ + { snippet: 'Google Cloud / Gemini-committed teams', reason: 'provider axis (Google ADK)' }, + { snippet: 'Optimized for Gemini; supports other models', reason: 'model axis' }, + { snippet: 'teams already committed to Gemini', reason: 'model axis' }, + { snippet: 'Model flexibility is required beyond Gemini', reason: 'model axis' }, + { snippet: 'Gemini vendor lock-in in practice', reason: 'model axis' }, + { snippet: 'Google/Gemini-committed', reason: 'provider axis (Google ADK)' }, + ]], + ['agents/gsd-framework-selector.md', [ + // Two occurrences on one line: the menu label and its description. + { snippet: 'Google (Gemini)', reason: 'model-provider choice, not a GSD runtime' }, + { snippet: 'Committed to Gemini / Google Cloud / Vertex AI', reason: 'the same menu entry\'s description' }, + ]], + ['agents/gsd-framework-selector.compact.md', [ + { snippet: 'Google (Gemini)', reason: 'model-provider choice, not a GSD runtime' }, + ]], +]); + +// Every pin must contain a retired runtime name. Span containment means a +// snippet that does not contain the name can never cover a match, so such a +// pin is dead config that would also be reported stale forever — fail loud at +// load rather than mislead later. +for (const [relPath, pins] of ALLOWLIST_OCCURRENCES) { + for (const pin of pins) { + const names = RETIRED_RUNTIMES.filter((r) => pin.snippet.includes(r.name)); + if (names.length === 0) { + throw new Error( + 'lint-retired-runtime-name: pin for ' + relPath + ' does not contain a retired ' + + 'runtime name and can never match: ' + JSON.stringify(pin.snippet), + ); + } + } +} + +// docs/RELEASE-NOTES-LEGACY.md is a file, not a directory, but the brief +// lists it alongside the dir prefixes — treat both files and dirs the same +// way via a "startsWith" prefix check. +function isAllowlisted(relPath) { + if (ALLOWLIST_FILES.has(relPath)) return true; + for (const prefix of ALLOWLIST_DIR_PREFIXES) { + if (relPath === prefix || relPath.startsWith(prefix)) return true; + } + return false; +} + +function isInScanSet(relPath) { + return relPath.endsWith('.md'); +} + +function isBinary(fullPath) { + // Read a small chunk and check for NUL bytes. + let fd; + try { + fd = fs.openSync(fullPath, 'r'); + const buf = Buffer.allocUnsafe(512); + const bytesRead = fs.readSync(fd, buf, 0, 512, 0); + return buf.subarray(0, bytesRead).includes(0); + } catch { + return false; + } finally { + if (fd != null) { + try { fs.closeSync(fd); } catch { /* best-effort */ } + } + } +} + +// --- TIER 1(a): the hook dialect Antigravity genuinely inherits ------------- +// Antigravity reads Gemini-shaped `settings.json` hook entries, and every +// locale names that dialect. Marker POSITION is language-dependent and was +// measured: en `Gemini-style` / `Gemini-compatible`, ja `Gemini スタイル`, +// ko `Gemini 스타일` / `호환`, zh `Gemini 风格` / `与 Gemini 兼容的`, and pt +// `no estilo Gemini` / `compatível com Gemini`, where it PRECEDES the name. +// +// The marker must form an ADJACENT COMPOUND with the name. An earlier version +// searched a ±24-character window, which let one legitimate dialect reference +// launder a second, unrelated live claim on the same line — a reviewer +// demonstrated `| Antigravity | Gemini-style hooks | Gemini support is live |` +// exiting 0, because the second occurrence sat 21 characters from `style`. +// Adjacency is the property that actually distinguishes a dialect NAME from a +// runtime CLAIM, so it is what is tested. +const DIALECT_MARKERS = [ + 'style', 'compatible', // en + 'スタイル', '互換', // ja + '스타일', '호환', // ko + '风格', '兼容', // zh + 'estilo', 'compatível', 'compatibilidade', // pt +]; +const DIALECT_ALT = DIALECT_MARKERS.map(escapeRegex).join('|'); +// Marker immediately follows: "Gemini-style", "Gemini スタイル", "Gemini 兼容". +const DIALECT_AFTER_RE = new RegExp('^[-\\s]?(?:' + DIALECT_ALT + ')', 'i'); +// Marker immediately precedes, optionally through ONE short connective: +// "no estilo Gemini", "compatível com Gemini". +const DIALECT_BEFORE_RE = new RegExp( + '(?:' + DIALECT_ALT + ')\\s*(?:com|with|de|da|do|and)?\\s*$', + 'i', +); + +function isDialectReference(line, matchIndex, afterMatch) { + const adjacent = DIALECT_AFTER_RE.test(afterMatch) + || DIALECT_BEFORE_RE.test(line.slice(0, matchIndex)); + if (!adjacent) return false; + // A dialect marker is NOT a licence when the line also asserts a runtime. + // This veto is line-global on purpose: the tight "immediately after the + // name" form let `Suportamos Gemini, no estilo padrao, como runtime de + // instalacao.` and `Gemini 兼容,并且是受支持的运行时之一。` both exit 0 while + // literally containing `runtime` / `运行时`. Real lines that legitimately + // pair a dialect compound with a runtime word are handled by an explicit + // Tier-2 pin, not by loosening this. + return !RUNTIME_WORD_RE.test(line); +} + +// --- TIER 1(b): the provider/model axis ------------------------------------ +// Matches the display-name spelling of a model version: a version number +// optionally followed by a model qualifier, or one that ends the list item. +// Deliberately NOT "space then a digit" — that blanket rule also matched +// "Gemini 2.5 CLI as a supported runtime.", laundering a genuine stale-runtime +// claim through an attached version number. Full-width digits and CJK +// punctuation are included because the translated docs use them. +const DIGIT = '0-9\\uFF10-\\uFF19'; +const SP = ' \\u3000'; // ASCII space + ideographic space +const QUALIFIER = 'Pro|Flash|Ultra|Nano|Lite|Preview|Exp|Thinking'; +const PUNCT = ',;.:)\\]\\u3001\\u3002\\uFF0C\\uFF09\\uFF3D\\uFF1A'; +const MODEL_DISPLAY_AFTER = new RegExp( + '^[' + SP + ']' + + '[' + DIGIT + ']+(?:[.\\uFF0E][' + DIGIT + ']+)*' + + '(?:' + + '[' + SP + ']+(?:' + QUALIFIER + ')\\b[\\w-]*' + + '|[' + SP + ']*[' + PUNCT + ']' + + '|[' + SP + ']*$' + + ')', +); + +// Words that indicate a RUNTIME (not a model) is being named on the line, in +// every language this repo ships translated docs for. If any appears +// alongside a model-display version, the version does NOT get to launder the +// runtime claim — the line is still a violation. +// +// Latin terms are anchored with \b so "CLI" cannot match inside "client" and +// "host" cannot match inside "ghost"; CJK terms are matched raw because \b is +// ASCII-word-based and would never fire next to a non-word ideograph. +// +// Deliberately EXCLUDED: "agent" and "target". Both occur throughout ordinary +// prose ("AI coding agents (Claude Code, Codex, Gemini 2.5 Pro)"), so vetoing +// on them would red legitimate model lists rather than catch runtime claims. +const RUNTIME_WORDS = [ + 'CLI', + 'runtime', + 'runtimes', + 'IDE', + 'extension', + 'host', + 'ランタイム', // ja + '実行環境', // ja + '런타임', // ko + '运行时', // zh + '运行环境', // zh + 'tempo de execução', // pt +]; +const RUNTIME_WORD_RE = new RegExp( + RUNTIME_WORDS.map((w) => { + const esc = escapeRegex(w); + // Anchor only when the term begins/ends with an ASCII word character; + // \b is meaningless beside an ideograph. + return /^[A-Za-z0-9]/.test(w) ? '\\b' + esc + '\\b' : esc; + }).join('|'), + 'i', +); + +// Positive evidence that the line is on the MODEL axis. Required, not merely +// the absence of a runtime word: a reviewer demonstrated that "absence of a +// veto word" is not evidence, with `The installer now offers Gemini 3.`, +// `GSD installs cleanly on Gemini 3, Kimi, and Codex.` and +// `Supported agents include Gemini 3, Kimi, and Cursor.` all exiting 0 — the +// exact laundering class this guard exists to catch. Every real model-axis +// line in this repo names a model explicitly, so requiring it costs nothing +// and inverts the failure direction from "silently allow" to "flag". +const MODEL_WORDS = [ + 'model', 'models', // en + 'モデル', // ja + '모델', // ko + '模型', // zh + 'modelo', 'modelos', // pt +]; +const MODEL_WORD_RE = new RegExp( + MODEL_WORDS.map((w) => { + const esc = escapeRegex(w); + return /^[A-Za-z0-9]/.test(w) ? '\\b' + esc + '\\b' : esc; + }).join('|'), + 'i', +); + +/** + * Is `pin` satisfied AT this match? The match must fall INSIDE an occurrence + * of the pin's snippet, not merely share a line with it. + * + * Line-level containment was too weak: a reviewer showed + * `Known provider menu update: Gemini CLI is once again a selectable GSD + * runtime.` and `Install target: Google (Gemini) — choose Gemini CLI as your + * GSD runtime.` both exiting 0, because a short snippet elsewhere on the line + * pre-approved a brand-new claim. Span containment means a pin can only ever + * excuse the occurrence its own text covers. + */ +function pinCoversMatch(line, matchIndex, matchedText, pin) { + const end = matchIndex + matchedText.length; + let from = 0; + for (;;) { + const at = line.indexOf(pin.snippet, from); + if (at === -1) return false; + if (matchIndex >= at && end <= at + pin.snippet.length) return true; + from = at + 1; + } +} + +/** + * Decide whether one MATCH (not the whole line) is legitimate. + * + * Returns the matched pin, `true` for a general-rule exemption, or `false` + * for a violation. + */ +function classifyMatch(line, matchIndex, matchedText, pins) { + // The explicit escape hatch, which must carry a reason. + if (ALLOW_MARKER_RE.test(line)) return true; + + const afterMatch = line.slice(matchIndex + matchedText.length); + + // TIER 1(a) — inherited hook dialect, as an adjacent compound. + if (isDialectReference(line, matchIndex, afterMatch)) return true; + + // TIER 1(b) — provider/model axis. Needs positive model evidence AND no + // runtime word; a version number alone never launders a claim. + if (MODEL_DISPLAY_AFTER.test(afterMatch) + && MODEL_WORD_RE.test(line) + && !RUNTIME_WORD_RE.test(line)) { + return true; + } + + // TIER 2 — a pinned occurrence for this specific file, span-scoped. + for (const pin of pins) { + if (pinCoversMatch(line, matchIndex, matchedText, pin)) return pin; + } + + return false; +} + +function main() { + // Enumerate tracked files via git ls-files so only committed/staged source is checked. + let trackedFiles; + try { + trackedFiles = execFileSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf8' }) + .split('\n') + .map((f) => f.trim()) + .filter(Boolean); + } catch (err) { + throw new ExitError(1, 'ERROR lint-retired-runtime-name: git ls-files failed: ' + err.message); + } + + const scanFiles = trackedFiles.filter(isInScanSet); + + const violations = []; + const usedPins = new Set(); + // Files present in the scan set, so a stale-pin check can distinguish + // "this pin's file is gone/not in this tree" from "this pin matched nothing". + const scannedRelPaths = new Set(); + // Anti-vacuity counts files actually READ, not merely listed: if every read + // failed, a candidate count would still look healthy. + let filesRead = 0; + + for (const relPath of scanFiles) { + if (isAllowlisted(relPath)) continue; + + const fullPath = path.join(REPO_ROOT, relPath); + + // Skip this guard script itself (path-based, though it is not a .md file + // and would never be in-scan-set anyway — defense in depth). + if (path.resolve(fullPath) === SELF_PATH) continue; + + if (isBinary(fullPath)) continue; + + let content; + try { + content = fs.readFileSync(fullPath, 'utf8'); + } catch { + // Unreadable files (permissions, etc.) — skip silently. + continue; + } + filesRead += 1; + scannedRelPaths.add(relPath); + + const pins = ALLOWLIST_OCCURRENCES.get(relPath) || []; + + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Mark pins used by PRESENCE on the line, independent of which tier + // ultimately excused the match. Previously this was recorded only on + // the Tier-2 branch, so a pinned line that a general rule also matched + // never marked its pin used and the pin was reported stale — a reviewer + // demonstrated a provably false "no line matches pinned snippet" whose + // printed remedy told the maintainer to delete a still-needed pin. + for (const pin of pins) { + if (line.includes(pin.snippet)) usedPins.add(relPath + ' ' + pin.snippet); + } + + for (const { runtime, re } of RETIRED_RUNTIME_MATCHERS) { + re.lastIndex = 0; + let match; + while ((match = re.exec(line)) !== null) { + const verdict = classifyMatch(line, match.index, match[0], pins); + if (verdict) continue; + violations.push({ + file: relPath, + line: i + 1, + col: match.index + 1, + text: line.trim(), + runtime, + }); + } + } + } + } + + // Anti-vacuity: the scan must have READ a plausible number of files. + if (filesRead < MIN_EXPECTED_FILES) { + throw new ExitError( + 1, + 'ERROR lint-retired-runtime-name: only ' + filesRead + ' file(s) read ' + + '(expected at least ' + MIN_EXPECTED_FILES + '); the walk is empty or broken — refusing ' + + 'to report a false clean.', + ); + } + + // A pin that no longer matches anything is a stale allowlist entry: the + // content it approved was edited or removed, and leaving it in place would + // pre-approve a future occurrence nobody reviewed. Only checked for files + // actually present in this tree, so fixture roots are unaffected. + const stalePins = []; + for (const [relPath, pins] of ALLOWLIST_OCCURRENCES) { + if (!scannedRelPaths.has(relPath)) continue; + for (const pin of pins) { + if (!usedPins.has(relPath + ' ' + pin.snippet)) { + stalePins.push({ file: relPath, snippet: pin.snippet }); + } + } + } + + if (violations.length === 0 && stalePins.length === 0) { + process.stdout.write( + 'ok lint-retired-runtime-name: ' + filesRead + ' files scanned, 0 violations\n', + ); + return 0; + } + + if (violations.length > 0) { + process.stderr.write('\nERROR lint-retired-runtime-name: ' + violations.length + ' violation(s) found\n\n'); + for (const v of violations) { + process.stderr.write(' ' + v.file + ':' + v.line + ': ' + v.text + '\n'); + } + process.stderr.write('\n'); + for (const runtime of RETIRED_RUNTIMES) { + process.stderr.write( + 'Fix: `' + runtime.name + '` was retired by ' + runtime.retiredBy + ' (sunset ' + runtime.sunset + ') ' + + 'in favor of `' + runtime.successor + '`. Either correct the reference to name the live runtime, ' + + 'or, if the use is a legitimate historical/provider reference, add the `' + ALLOW_MARKER + '` ' + + 'marker on the line.\n', + ); + } + process.stderr.write('\n'); + } + + if (stalePins.length > 0) { + process.stderr.write( + 'ERROR lint-retired-runtime-name: ' + stalePins.length + ' stale allowlist entr' + + (stalePins.length === 1 ? 'y' : 'ies') + ' in ALLOWLIST_OCCURRENCES\n\n', + ); + for (const s of stalePins) { + process.stderr.write(' ' + s.file + ': no line matches pinned snippet ' + JSON.stringify(s.snippet) + '\n'); + } + process.stderr.write( + '\nFix: the approved line was edited or removed. Delete the pin, or update its ' + + 'snippet to the new wording — an unmatched pin pre-approves a future occurrence ' + + 'nobody reviewed.\n\n', + ); + } + + return 1; +} + +runMain(main); diff --git a/tests/lint-retired-runtime-name.test.cjs b/tests/lint-retired-runtime-name.test.cjs new file mode 100644 index 000000000..53d39b41d --- /dev/null +++ b/tests/lint-retired-runtime-name.test.cjs @@ -0,0 +1,852 @@ +'use strict'; + +/** + * TDD tests for scripts/lint-retired-runtime-name.cjs. + * + * Mirrors tests/lint-legacy-dir-name.test.cjs's structure and conventions + * closely: a temp git-fixture repo, driven via the process seam, with the + * guard's REPO_ROOT overridden through GSD_LINT_RETIRED_RUNTIME_REPO_ROOT + * (the sibling seam to that precedent's GSD_LINT_LEGACY_REPO_ROOT). + */ +// docs-guard-exempt: this file's `writeFile('docs/...', ...)` calls WRITE +// fabricated fixture content into a throwaway mkdtemp repo; none of it reads +// real shipped docs/ content. The `writeFile` callee name only incidentally +// matches lint-docs-guard-registration.cjs's reader-name heuristic (contains +// "file"). + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { gitOrThrow } = require('./helpers/git-fixture.cjs'); +const { GENERATOR_SCRIPT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + +const GUARD_SCRIPT = path.resolve(__dirname, '..', 'scripts', 'lint-retired-runtime-name.cjs'); + +// scripts/lint-retired-runtime-name.cjs is exactly the shape +// GENERATOR_SCRIPT_TIMEOUT_MS describes: a single scripts/*.cjs lint script, +// spawned directly and once against a small temp fixture repo, no fan-out. +// Reuse the shared class norm (tests/helpers/timeouts.cjs) rather than +// restating its literal locally. +const GUARD_TIMEOUT_MS = GENERATOR_SCRIPT_TIMEOUT_MS; + +// The guard's own anti-vacuity floor (MIN_EXPECTED_FILES in scripts/lint-retired-runtime-name.cjs). +const MIN_EXPECTED_FILES = 150; + +// The retired runtime name, built via split-string concatenation so this +// test file cannot itself trip the guard (or any other regression-name +// lint) when scanned. +const RETIRED_NAME = 'Gem' + 'ini'; + +function createTempRepo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-retired-runtime-test-')); + gitOrThrow(['init', '--initial-branch=main'], { cwd: dir }); + gitOrThrow(['config', 'user.email', 'test@example.com'], { cwd: dir }); + gitOrThrow(['config', 'user.name', 'Test'], { cwd: dir }); + return dir; +} + +function writeFile(dir, relPath, content) { + const fullPath = path.join(dir, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); +} + +function gitAdd(dir, relPath) { + gitOrThrow(['add', relPath], { cwd: dir }); +} + +function gitAddAll(dir) { + gitOrThrow(['add', '-A'], { cwd: dir }); +} + +function cleanup(dir) { + // eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup in lint test; no helpers import available + fs.rmSync(dir, { recursive: true, force: true }); +} + +// Generates enough tracked, in-scan-set (docs/**/*.md) filler files to clear +// the guard's MIN_EXPECTED_FILES anti-vacuity floor, none of which mention +// the retired runtime name at all. +function seedFillerFiles(dir, count) { + for (let i = 0; i < count; i++) { + writeFile(dir, `docs/filler/note-${i}.md`, `# Filler note ${i}\n\nNothing retired here.\n`); + } +} + +function runGuard(cwd) { + const r = runNode([GUARD_SCRIPT], { + cwd, + env: { ...process.env, GSD_LINT_RETIRED_RUNTIME_REPO_ROOT: cwd }, + timeoutMs: GUARD_TIMEOUT_MS, + }); + return { status: r.exitCode, stdout: r.stdout, stderr: r.stderr }; +} + +describe('lint-retired-runtime-name — clean tree', () => { + test('exits 0 when no tracked .md file names the retired runtime', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('0 violations'), `stdout: ${result.stdout}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — proves it can fail', () => { + test('exits 1 with a path:line diagnostic when a bare capitalised retired name is scanned', () => { + // Load-bearing case: a guard never observed failing is not a guard. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/guides/setup.md', + `# Setup\n\nInstall ${RETIRED_NAME} as your runtime.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes('docs/guides/setup.md:3:'), + `stderr should carry a path:line diagnostic: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — case sensitivity is the mechanism', () => { + test('exits 0 for every legitimate lowercase/uppercase spelling, none of which are the bare capitalised name', () => { + // The regex is deliberately case-sensitive (no `i` flag): every + // legitimate reference to the "gemini" string in this repo is spelled + // differently from the bare capitalised `Gemini` token, so none of + // these lines can match. See the guard's own module header. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/guides/config-homes.md', + [ + '# Config homes', + '', + 'Antigravity config lives at ~/.gemini/antigravity.', + 'It also reads ~/.gemini/config.', + 'Model id: gemini-2.5-flash-lite.', + 'Set GEMINI_CONFIG_DIR to override.', + 'See GEMINI.md for the instruction file.', + '', + ].join('\n'), + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — gsd-allow-retired-runtime-name marker', () => { + test('exits 0 when the violating line also carries the inline allow marker', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/history/migration-note.md', + `# Migration note\n\nInstall ${RETIRED_NAME} CLI for legacy testing. \n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0 with allow marker, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — model-display versions are allowed', () => { + test('exits 0 for a prose model list naming a version, not a runtime', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/reference/models.md', + `# Supported models\n\nSupported model families: (o3, o4-mini, ${RETIRED_NAME} 2.5 Pro).\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — a version must NOT launder a runtime claim', () => { + test('exits 1 when a model-display version is attached to runtime-claiming wording', () => { + // This pins the fix for the earlier blanket `/^ \d/` ("space then a + // digit") exclusion, which was too broad: it also matched a genuine + // stale-runtime claim like "Gemini 2.5 CLI as a supported runtime.", + // laundering the false positive fix into a false negative on the same + // line. A version number must never launder a runtime claim. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/guides/runtime-claim.md', + `# Runtimes\n\nInstall for ${RETIRED_NAME} 2.5 CLI as a supported runtime.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes('docs/guides/runtime-claim.md:3:'), + `stderr should carry a path:line diagnostic: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — -style dialect wording is allowed', () => { + test('exits 0 for a genuine hook-dialect reference ("Gemini-style")', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/guides/hooks.md', + `# Hooks\n\nAntigravity uses ${RETIRED_NAME}-style settings.json hooks.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — path allowlist works', () => { + test('exits 0 when the same violating content sits under an allowlisted path (docs/adr/)', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/adr/999-retired-runtime-record.md', + `# ADR 999\n\nInstall ${RETIRED_NAME} CLI as a supported runtime.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0 under docs/adr/, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — anti-vacuity floor', () => { + test('refuses to report clean when far too few files are scanned', () => { + // The guard errors out (non-zero exit) rather than silently reporting + // "0 violations" on an empty/broken walk — see its MIN_EXPECTED_FILES check. + const dir = createTempRepo(); + try { + writeFile(dir, 'docs/guides/one.md', '# Just one file\n\nNothing retired here.\n'); + gitAdd(dir, 'docs/guides/one.md'); + + const result = runGuard(dir); + assert.notEqual(result.status, 0, `expected non-zero exit, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes('only 1 file'), + `stderr should explain the anti-vacuity refusal: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — root-level *.md is in the scan set', () => { + test('exits 1 for a violating root README.md', () => { + // Regression test for the defect that made this guard necessary AND + // initially unable to see it: four locale READMEs advertised the retired + // runtime as supported at README..md:9/:24/:46 while the scan set + // covered only docs/ gsd-core/ commands/ agents/ skills/. The repo's + // most-read runtime-advertising surface was structurally invisible. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile(dir, 'README.md', `# Project\n\nInstall ${RETIRED_NAME} CLI as your runtime.\n`); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes('README.md:3:'), + `stderr should carry a path:line diagnostic for the root README: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — pinned occurrences are occurrence-scoped', () => { + test('an approved line in a pinned file does NOT licence a second stale occurrence in the same file', () => { + // This is the whole point of pinning occurrences instead of allowlisting + // whole files. Under a file-level allowlist both lines below would pass + // and a genuinely new stale claim would be absorbed silently. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'CONTEXT.md', + [ + '# Context', + '', + `Session log: ${RETIRED_NAME} [runtime removed #1928] noted inline.`, + `Install ${RETIRED_NAME} CLI as a supported runtime.`, + '', + ].join('\n'), + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes('CONTEXT.md:4:'), + `the UNAPPROVED line 4 must be reported: ${result.stderr}`, + ); + assert.ok( + !result.stderr.includes('CONTEXT.md:3:'), + `the pinned line 3 must NOT be reported: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); + + test('exits 0 when a pinned file carries only its approved occurrence', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'CONTEXT.md', + `# Context\n\nSession log: ${RETIRED_NAME} [runtime removed #1928] noted inline.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — the allowlist cannot silently rot', () => { + test('exits 1 reporting a stale pin when an approved line no longer matches', () => { + // An unmatched pin pre-approves a future occurrence nobody reviewed, so + // it is reported rather than ignored. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile(dir, 'CONTEXT.md', '# Context\n\nThe annotated session log line was rewritten.\n'); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes('stale allowlist entr'), + `stderr should name the stale allowlist entry: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — file-level allowlist', () => { + test('exits 0 for violating content inside generated-and-locked CHANGELOG.md', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'CHANGELOG.md', + `# Changelog\n\n- Removed the sunset ${RETIRED_NAME} CLI runtime — use Antigravity instead. (#1928)\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — anti-vacuity floor boundaries', () => { + // CLAUDE.md requires limit-1 / limit / limit+1 coverage. The "limit" here + // is how many in-scan-set files the walk actually READ. + test(`refuses to report clean at MIN_EXPECTED_FILES-1 (${MIN_EXPECTED_FILES - 1})`, () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES - 1); + gitAddAll(dir); + + const result = runGuard(dir); + assert.notEqual(result.status, 0, `expected non-zero exit, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes(`only ${MIN_EXPECTED_FILES - 1} file`), + `stderr should state the count it refused on: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); + + test(`reports clean exactly at MIN_EXPECTED_FILES (${MIN_EXPECTED_FILES})`, () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + assert.ok( + result.stdout.includes(`${MIN_EXPECTED_FILES} files scanned`), + `stdout should report the read count: ${result.stdout}`, + ); + } finally { + cleanup(dir); + } + }); + + test(`reports clean at MIN_EXPECTED_FILES+1 (${MIN_EXPECTED_FILES + 1})`, () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES + 1); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — the runtime-word veto is multilingual', () => { + test('exits 1 when a Japanese runtime word accompanies a model-display version', () => { + // The model-display SHAPE matches here (" 2.5 Pro"), so this is a real + // test of the veto rather than of the shape rule failing to match. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/ja-JP/guides/runtimes.md', + `# ランタイム\n\n${RETIRED_NAME} 2.5 Pro をランタイムとして使用します。\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + } finally { + cleanup(dir); + } + }); + + test('exits 0 when "client" appears — the veto is word-anchored, so CLI must not match inside it', () => { + // Without \b anchors, the case-insensitive "CLI" term matched inside + // "client"/"clip", vetoing legitimate model lists. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/reference/client-models.md', + `# Models\n\nThe client library supports these models: (o3, ${RETIRED_NAME} 2.5 Pro).\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — model display across locale punctuation', () => { + test('exits 0 for full-width digits and CJK punctuation', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/zh-CN/reference/models.md', + `# 模型\n\n思考类模型(o3、o4-mini、${RETIRED_NAME} 2.5 Pro)。\n` + + `另见 ${RETIRED_NAME} 3、以及其他模型。\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — dialect marker may PRECEDE the name', () => { + test('exits 0 for the Portuguese word order ("no estilo Gemini")', () => { + // Measured, not assumed: pt-BR puts the qualifier before the name, so a + // suffix-only dialect rule cannot express this locale at all. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/pt-BR/guides/hooks.md', + `# Hooks\n\nEntradas de hook no estilo ${RETIRED_NAME} quando instalado pelo GSD.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); + + test('exits 1 when a dialect word is present but the name is still followed by a runtime word', () => { + // "compatível com CLI" is a runtime claim wearing a dialect word, + // so the dialect exemption is refused when a runtime word sits + // immediately after the name. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/pt-BR/guides/policy.md', + `# Politica\n\nUsa a politica compatível com ${RETIRED_NAME} CLI e mais.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes('docs/pt-BR/guides/policy.md:3:'), + `stderr should carry a path:line diagnostic: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — a dialect marker does not survive a runtime word', () => { + // Reviewer finding (BLOCKER): the dialect exemption originally applied only + // an "immediately after the name" veto, while the model rule applied its + // veto line-globally. A dialect marker anywhere in a +/-24-char window then + // excused an occurrence on a line that literally asserted a live runtime. + const CASES = [ + ['pt dialect word plus the literal "runtime"', 'docs/pt-BR/guides/a3.md', + `Suportamos ${RETIRED_NAME}, no estilo padrao, como runtime de instalacao.`], + ['zh dialect word plus 運行時', 'docs/zh-CN/guides/a4.md', + `${RETIRED_NAME} 兼容,并且是受支持的运行时之一。`], + ['a non-adjacent "style" that is not a dialect reference at all', 'docs/guides/a2.md', + `${RETIRED_NAME} is style-agnostic and fully supported by the installer.`], + ]; + + for (const [label, relPath, body] of CASES) { + test(`exits 1 — ${label}`, () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile(dir, relPath, `# t\n\n${body}\n`); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes(`${relPath}:3:`), + `stderr should carry a path:line diagnostic: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); + } +}); + +describe('lint-retired-runtime-name — one legitimate occurrence does not license the next', () => { + test('exits 1 when a dialect reference and a fresh live claim share a line', () => { + // Reviewer finding (BLOCKER): with a +/-24-char window, + // `| Antigravity | Gemini-style hooks | Gemini support is live |` exited 0 + // — the second occurrence sat 21 chars from `style`. This is the exact + // locale-table row shape the window was tuned for, so it was the most + // likely real-world regression vector. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/guides/a1.md', + `# t\n\n| Antigravity | ${RETIRED_NAME}-style hooks | ${RETIRED_NAME} support is live |\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes('docs/guides/a1.md:3:'), + `stderr should carry a path:line diagnostic: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — a version number never launders a claim', () => { + // Reviewer finding (MAJOR): the model-display escape accepted + // and whenever no RUNTIME_WORDS term + // happened to appear, so five unqualified live-runtime claims exited 0. + // "Absence of a veto word" is not evidence: the rule now requires POSITIVE + // model-axis evidence, which inverts the failure direction from silently + // allowing to flagging. + const CASES = [ + ['a version ending the sentence', 'docs/guides/b1.md', + `The installer now offers ${RETIRED_NAME} 3.`], + ['a version inside a runtime list', 'docs/guides/b2.md', + `GSD installs cleanly on ${RETIRED_NAME} 3, Kimi, and Codex.`], + ['a version in parentheses', 'docs/guides/b3.md', + `Pick your coding tool (${RETIRED_NAME} 3) during setup.`], + ['a "plugins" list — "plugin" is not a runtime word', 'docs/guides/c3.md', + `Supported plugins: Claude Code, Codex, ${RETIRED_NAME} 3.`], + ['an "agents" list — "agent" is deliberately not a runtime word', 'docs/guides/c4.md', + `Supported agents include ${RETIRED_NAME} 3, Kimi, and Cursor.`], + ]; + + for (const [label, relPath, body] of CASES) { + test(`exits 1 — ${label}`, () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile(dir, relPath, `# t\n\n${body}\n`); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes(`${relPath}:3:`), + `stderr should carry a path:line diagnostic: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); + } +}); + +describe('lint-retired-runtime-name — pins are span-scoped, not line-scoped', () => { + // Reviewer finding (MAJOR): with line-level containment, a short snippet + // anywhere on a line pre-approved a brand-new claim on that same line. + // A pin now only ever excuses the occurrence its own text covers. + test('exits 1 when a new claim shares a line with a short pinned snippet', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'gsd-core/workflows/settings-advanced.md', + `# t\n\nKnown provider menu update: ${RETIRED_NAME} CLI is once again a selectable GSD runtime.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + } finally { + cleanup(dir); + } + }); + + test('exits 1 when a parenthetical provider pin shares a line with a new claim', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'agents/gsd-framework-selector.md', + `# t\n\nInstall target: Google (${RETIRED_NAME}) — choose ${RETIRED_NAME} CLI as your GSD runtime.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + } finally { + cleanup(dir); + } + }); + + test('exits 0 when every occurrence falls inside a pinned span', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'agents/gsd-framework-selector.md', + `# t\n\n{ label: "Google (${RETIRED_NAME})", description: "Committed to ${RETIRED_NAME} / Google Cloud / Vertex AI" },\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); + + test('exits 1 for a new claim added to a fully-pinned file', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'agents/gsd-framework-selector.md', + `# t\n\n{ label: "Google (${RETIRED_NAME})", description: "Committed to ${RETIRED_NAME} / Google Cloud / Vertex AI" },\n` + + `Install target: choose ${RETIRED_NAME} CLI as your GSD runtime.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes('agents/gsd-framework-selector.md:4:'), + `the NEW line 4 must be reported: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — the stale-pin report must never be false', () => { + test('does not report a pin stale when its line is present but excused by another rule', () => { + // Reviewer finding (MAJOR): `usedPins` was recorded only on the Tier-2 + // branch, and the general rules run first — so a pinned line that a + // general rule also matched never marked its pin used, producing a + // provably false "no line matches pinned snippet" whose printed remedy + // told the maintainer to delete a still-needed pin. + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'CONTEXT.md', + `# C\n\n${RETIRED_NAME}-style dialect, and ${RETIRED_NAME} [runtime removed #1928] in the log.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.ok( + !result.stderr.includes('stale allowlist'), + `the pin IS present on that line, so no stale report may be emitted: ${result.stderr}`, + ); + // Guard against a vacuous pass: the run must actually have flagged the + // unpinned dialect occurrence, not skipped the file. + assert.ok( + result.stderr.includes('CONTEXT.md:3:'), + `expected the unpinned occurrence to be flagged: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); + + test('still reports a genuinely stale pin', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile(dir, 'CONTEXT.md', '# C\n\nThe annotated session log line was rewritten.\n'); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + assert.ok( + result.stderr.includes('stale allowlist entr'), + `stderr should name the stale allowlist entry: ${result.stderr}`, + ); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — the escape hatch must carry a reason', () => { + test('exits 1 for a bare marker with no justification', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + 'docs/guides/d1.md', + `# t\n\n${RETIRED_NAME} CLI is a fully supported GSD runtime. \n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 1, `expected exit 1, got ${result.status}; stdout: ${result.stdout}`); + } finally { + cleanup(dir); + } + }); +}); + +describe('lint-retired-runtime-name — .changeset/ shares CHANGELOG.md\'s tier', () => { + test('exits 0 for a changeset fragment describing the retirement', () => { + const dir = createTempRepo(); + try { + seedFillerFiles(dir, MIN_EXPECTED_FILES); + writeFile( + dir, + '.changeset/foo.md', + `---\ntype: Fixed\npr: 0\n---\n**Retired the ${RETIRED_NAME} CLI reviewer lane** — Google stopped serving it.\n`, + ); + gitAddAll(dir); + + const result = runGuard(dir); + assert.equal(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`); + } finally { + cleanup(dir); + } + }); +});