* chore(#4729): guard the retired-runtime name, and finish the locale residue Phase 5 of 5 on epic #4709, and the phase that closes it. Two parts, one concern: make the tree clean, and keep it clean. The guard is inert until the tree is clean, and shipping the cleanup without the guard is the one-bug-at-a-time pattern this epic exists to end. WHY A GUARD, AND WHY LAST Nothing in CI answered "does any shipped surface still present a retired runtime as live?", and the two gates that look like they should cannot. checkReviewerDocsParity is one-directional: it asserts the PRESENCE of every declared reviewer flag and never the ABSENCE of a retired one, so in #4716 it reported 0 violations while all four locale mirrors still documented --gemini as a live reviewer flag, with usage examples. And tests/gemini-runtime-removed.test.cjs is scoped by construction - its own docblock limits it to the installer CLI contract and the runtime-name-policy exports; it never reads docs/**, gsd-core/workflows/**, commands/** or agents/**. Every extension to it during this epic was a hand-added assertion for a surface somebody had already noticed. A guard written earlier would have red-flagged the very references phases 1b-4b were removing, which is why it lands last. PART A - THE RESIDUE, INCLUDING WORK I SHIPPED INCOMPLETE Each site was judged against its ENGLISH counterpart, not on its own: README.{ja-JP,ko-KR,pt-BR,zh-CN}.md :9 :24 :46 English README.md has ZERO occurrences -> substituted "Antigravity CLI, Kimi CLI" how-to/execute-a-phase.md:88 x4 locales fixed in #4728 -> substitute how-to/verify-and-ship.md:89 x4 locales fixed in #4728 -> substitute FEATURES.md cross-AI CLI list :1419 no Gemini -> DELETE FEATURES.md REQ-MULTI-RT-01 :1709 -> substitute FEATURES.md REQ-SKILLS-03 :1952 -> rewrite FEATURES.md REQ-QUOTA-02 :3256 deleted upstream -> delete VERSIONING.md:133 stale manifest -> see below The twelve README occurrences were an adversarial reviewer's BLOCKER, and the reason they survived my own sweep is structural: root-level *.md was outside the guard's scan set, so the repo's most-read runtime-advertising surface was invisible to the guard meant to police it. :46 is a live installer-runtime claim - it tells the reader the installer will offer a runtime that no longer exists. Checked for the duplicate-name trap before substituting: neither Antigravity nor Kimi appears anywhere in those four files. Two of these are mine to own: I fixed the ENGLISH execute-a-phase.md and verify-and-ship.md in #4728 and left all four mirrors behind. Unfinished work, not a deferral. Two more show why "substitute Gemini -> Antigravity" is the wrong default: in the cross-AI list and REQ-QUOTA-02 English DELETES the name, because Antigravity was already in the list or the classifier had dropped it. Substituting would have duplicated a name - the identical trap ARCHITECTURE.md:24 set in #4728, where English holds Kimi CLI in that slot. VERSIONING.md:133 is a different and worse defect than translation lag. Under "Manifest Version Sync" it listed gemini-extension.json as a version-synced manifest. That file is ABSENT from the repo, and scripts/sync-manifest-versions.cjs says so in its own comment - "#1928: gemini-extension.json was removed with the gemini runtime ... it is no longer a registered manifest" - while VERSIONED_MANIFESTS holds plugin.json, marketplace.json and vscode/package.json. So the doc named a manifest that does not exist AND omitted the one that replaced it. Both fixed, verified against the owning code rather than inferred from the name. The replacement bullet cites #1942, the issue that actually registered vscode/package.json, matching the convention of its neighbours. pt-BR/FEATURES.md is a 77-line stub genuinely lacking two sites, and ko-KR has no REQ-QUOTA-02 line. Skipped and recorded, never invented. PART B - THE GUARD scripts/lint-retired-runtime-name.cjs, modelled on scripts/lint-legacy-dir-name.cjs - the repo's own precedent for this problem shape (forbid a retired token, allowlist frozen content, self-exempt via a split literal, a REPO_ROOT test seam, lib/cli-exit.cjs, exit 0/1). Case sensitivity IS the mechanism, not an accident. The naive guard - "the string gemini must not appear" - is WRONG, not merely noisy: that string is load-bearing across Antigravity's real on-disk contract. A case-sensitive, standalone, capitalised name works because every legitimate reference is spelled differently and therefore cannot match: lowercase config homes (~/.gemini/antigravity, ~/.gemini/config, #3738), lowercase hyphenated model ids (gemini-2.5-flash-lite), uppercase env vars (GEMINI_API_KEY), and GEMINI.md. Table-driven, so the next retired runtime costs one row. THE ALLOWLIST IS THE ENTIRE RISK SURFACE, so it is three tiers, not one. Two rounds of isolated adversarial review reshaped it; both are recorded in .gsd/bug/chore-4729-gemini-drift-guard/60-review.json. ROUND 2 FOUND ONE ROOT CAUSE BEHIND TWO SEPARATE HOLES, and it was mine: both Tier-1 rules treated the ABSENCE of a runtime word as a GRANT. A veto list can never be complete, so "no runtime word found" silently exempted every phrasing nobody had enumerated. Demonstrated: `The installer now offers Gemini 3.`, `Supported agents include Gemini 3, Kimi, and Cursor.` and three more exited 0, as did `Suportamos Gemini, no estilo padrao, como runtime de instalacao.` and `Gemini 兼容,并且是受支持的运行时之一。`, both of which literally contain `runtime` or `运行时`. The fix was to stop enumerating exceptions and invert the evidence direction: Tier 1(a) - the hook DIALECT Antigravity inherits. Position is language-dependent and MEASURED: en Gemini-style/-compatible, ja Gemini スタイル, ko Gemini 스타일/호환, zh Gemini 风格 / 与 Gemini 兼容的, pt "no estilo Gemini" / "compatível com Gemini" where the qualifier PRECEDES the name. The marker must now form an ADJACENT COMPOUND with the name, not merely sit in a +/-24-character window - that window let `| Antigravity | Gemini-style hooks | Gemini support is live |` exit 0, one legitimate reference licensing a fresh live claim 21 characters later. The runtime-word veto is now LINE-GLOBAL. Ten real lines legitimately pair a dialect compound with a runtime word (`~/.gemini/antigravity-cli` in a table cell, "runtime files" in the same sentence); each is an explicit pin rather than a reason to loosen the veto for everyone. Measured: widening it surfaced exactly those ten and no others. Tier 1(b) - the provider/model axis. A version optionally followed by a qualifier, including full-width digits and CJK punctuation, AND positive model-axis evidence on the line, AND no runtime word. The positive requirement is the part that matters: all eight real model-axis lines in the repo name a model explicitly, so requiring it costs nothing on the real tree while flagging every laundering attempt. It is also the honest resolution of the agent/target tension below - rather than guess at an exhaustive veto list, stop treating an empty veto as evidence. Tier 2 - PINNED OCCURRENCES, now SPAN-SCOPED. A pin excuses only a match falling INSIDE an occurrence of its own snippet. Line-level containment let `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 exit 0, because a short snippet elsewhere on the line pre-approved a brand-new claim. Span scoping makes short snippets safe: `Google (Gemini)` can only ever excuse the match inside those 15 characters. A LOAD-TIME validator now requires every pin to contain a retired name, and it immediately caught five of MY OWN pins whose snippets sat BESIDE the name rather than covering it - each would have shipped permanently inert and permanently reported stale. All pins were then reconciled in one pass. A pin is also marked used by PRESENCE on the line now, rather than only on the Tier-2 branch. Previously 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 pin that was still needed. Tier 3 - blanket trust, and a new occurrence inside it IS invisible. CHANGELOG.md and `.changeset/` - the rendered changelog and its source, one surface - plus six append-only directories. All 21 `.changeset/` hits were measured to be fragments DESCRIBING the retirement or a fix to it, 464 of them under archived/; a fragment can only describe what already shipped and is deleted at release, so pinning them would be friction with no signal. The cost is stated in the guard's own header rather than hidden. THE SCAN SET IS NOW EVERY TRACKED *.md FILE (1165 read). The original prefix list left `.github/`, `.changeset/`, `capabilities/`, `playbooks/` and `references/` invisible - and `.changeset/*.md` renders into CHANGELOG.md, so a live claim introduced there was invisible at BOTH ends. The escape hatch must now carry a justification (`gsd-allow-retired-runtime-name: <reason>`). A bare marker is rejected: it is checked first, excuses the whole line, and the failure message advertises it, so an unexplained one is indistinguishable from a silenced defect. Plus an anti-vacuity floor counting files actually READ, not files listed - a candidate count stays healthy-looking even if every read failed. A FALSE NEGATIVE I INTRODUCED, AND CLOSED The model-display escape began as a blanket /^ \d/ - "space then a digit" - which also matched "Install for Gemini 2.5 CLI as a supported runtime.", laundering a genuine stale-runtime claim through an attached version number. That was the THIRD appearance of one failure shape in this epic: an exclusion added to suppress false positives creating a false negative. #4716's sweep excluded lines matching gemini-[0-9] to spare Google's model ids, and thereby hid a stale review.models.gemini row whose example value was "gemini-2.5-pro" ON THE SAME LINE. Round 2 then produced the FOURTH and FIFTH instances, which is why the fix this time was to invert the rule's evidence direction rather than to enumerate more exceptions. The veto is word-anchored for Latin terms - unanchored, case-insensitive "CLI" matched inside "client" and would have vetoed legitimate model lists - and raw for CJK terms, where \b is ASCII-word-based and would never fire beside an ideograph, so anchoring them would silently disable the veto in ja/ko/zh. "agent" and "target" were deliberately left OUT: both occur throughout ordinary prose ("AI coding agents (Claude Code, Codex, Gemini 2.5 Pro)"), so vetoing on them would red correct content instead of catching runtime claims. The reasoning is in the guard's comment, not just the omission - and Tier 1(b)'s positive-evidence requirement is what makes that omission safe, since the rule no longer depends on the veto list being complete. COVERAGE tests/lint-retired-runtime-name.test.cjs drives the guard through its GSD_LINT_RETIRED_RUNTIME_REPO_ROOT seam against fixture repos, mirroring tests/lint-legacy-dir-name.test.cjs. A guard never observed failing is not a guard, and this epic already shipped one that was vacuous for 2 of its 5 files, so properties are paired against BOTH failure modes - too broad silently absorbs a future defect, too narrow reds on legitimate content. Floor boundaries are covered at 149/150/151. The round-2 reviewer's sharpest point was about that claim, and it was right: the first matrix's pairing was "true of the properties chosen, not of the predicate's actual surface" - not one of its twenty properties could see the dialect adjacency hole, a non-adjacent runtime word, pin shadowing, or an over-broad pin colliding with a new line. Every one of those is now a committed regression using the reviewer's own attack line verbatim, and the local fixture harness went from 14 cases to 35 (PASS=35 FAIL=0). That harness earned a finding of its own. Its first run reported PASS=2 FAIL=12 with BOTH passes VACUOUS: `git add` has no -q flag on this build, so nothing staged, every fixture hit the empty-walk error path, and the two checks that assert an ABSENCE passed off that error path rather than off real guard logic. A staging failure is now fatal and every absence-asserting check first proves the walk ran and the expected violation was flagged. Later, one case failed because its fixture supplied only one of a pinned file's two approved lines, so the stale-pin check fired correctly - the expectation was wrong, not the guard. Telling those two apart is the whole value of running a matrix rather than reasoning about one. On the two orthogonal reviews: the isolated adversarial pass executed a great deal of code, across two rounds, against its own fixture repos. The security pass did NOT - it self-discloses that it verified by reading only, because node --test is hard-blocked here. Saying so plainly, because "two orthogonal reviews" without that caveat overstates what the second one established. It also raised, and I cleared by measurement, a concern that importing escapeRegex from a gitignored build artifact would break lint:ci on an unbuilt clone: six other tracked scripts already require that exact path, three of them already in lint:ci, and .github/workflows/test.yml:192-193 runs `npm run build:lib` immediately before it for exactly this reason. Part A has no new test deliberately - those edits are covered by the guard itself inside lint:ci, and a separate per-locale assertion would duplicate it and then drift from it. The one exception is the root README case, which IS pinned: that residue was invisible to the guard rather than merely unasserted, so the fix is a scan-set change and needs its own regression test. No mode-bit read-failure fixture was added on purpose: the benches run as root, where chmod-based IO injection is vacuous, so such a test would assert nothing. The test's fixture helpers write throwaway docs/ paths, which trips lint-docs-guard-registration's reader-name heuristic. Resolved the way that lint documents - a header `// docs-guard-exempt:` marker plus a baseline entry - because the fixtures only WRITE scratch data and never read shipped docs; the baseline was re-confirmed, not merely extended, each time locale and adversarial fixtures were added. scripts/lib/macos-conformance-tier.generated.cjs regenerated through its own --write path, since a new test file changes the count lint:generated-sync reads. Fixes #4729 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4729): backfill changeset PR number (#4753) --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/steady-goats-chatter.md
Normal file
5
.changeset/steady-goats-chatter.md
Normal file
@@ -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)
|
||||
@@ -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 などに対応した、軽量なメタプロンプティング・コンテキストエンジニアリング・仕様駆動開発システムです。**
|
||||
|
||||
[](https://www.npmjs.com/package/@opengsd/gsd-core)
|
||||
[](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) を参照してください。
|
||||
|
||||
|
||||
@@ -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 등을 위한 경량 메타 프롬프팅, 컨텍스트 엔지니어링, 스펙 기반 개발 시스템.**
|
||||
|
||||
[](https://www.npmjs.com/package/@opengsd/gsd-core)
|
||||
[](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)를 참조하세요.
|
||||
|
||||
|
||||
@@ -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.**
|
||||
|
||||
[](https://www.npmjs.com/package/@opengsd/gsd-core)
|
||||
[](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).
|
||||
|
||||
|
||||
@@ -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 编程工具。**
|
||||
|
||||
[](https://www.npmjs.com/package/@opengsd/gsd-core)
|
||||
[](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)。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) を参照してください。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/` 경로 호환성 유지
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)을 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 或其他审查工具。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
612
scripts/lint-retired-runtime-name.cjs
Normal file
612
scripts/lint-retired-runtime-name.cjs
Normal file
@@ -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: <reason>`.
|
||||
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);
|
||||
852
tests/lint-retired-runtime-name.test.cjs
Normal file
852
tests/lint-retired-runtime-name.test.cjs
Normal file
@@ -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. <!-- gsd-allow-retired-runtime-name: legacy-install note, see #1928 -->\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.<locale>.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 <name> 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
|
||||
// <version><punctuation> and <version><EOL> 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. <!-- gsd-allow-retired-runtime-name -->\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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user