* chore(#2800): derive reviewer flag lists and gate reviewer lane docs across locales The reviewer lane roster was hand-enumerated across five documentation surfaces and three workflow files that had drifted apart: --kimi-code was missing from all four translated COMMANDS.md mirrors, --coderabbit from every workflow forwarding list, and --antigravity from FEATURES.md. Adds checkReviewerDocsParity, a second pure gate deliberately separate from checkReviewerLaneParity so a stale doc cannot make the runtime checker look red. Workflows now derive their flag lists from a new review-lane flags query instead of hand-enumerating them, which also retires the unanchored grep that matched --agy inside --antigravity. Documents the previously absent reviewer body and hostBehaviors field in the capability manifest reference. Closes #2800 Closes #2781 Closes #2272 * fix(#2800): key the docs parity table arm on first-cell position Review found the flag arm was file-scoped, so the forwarding row that lists every flag in its third cell satisfied it on its own. Deleting a lane's own reviewer-table row -- the #2781 regression this gate exists to prevent -- therefore passed undetected. Arm 4 keys on the FIRST table cell, which separates a lane row from the forwarding row structurally and in every locale. Regression test included. * fix(#2800): shape-filter the flags subcommand output All three consumers read review-lane flags through an unquoted command substitution so the output word-splits into loop items. Phase 2 admits third-party overlay lanes, so an overlay flag containing whitespace would inject a second loop item and one containing a glob would expand against the cwd. Emit only well-formed flags so neither reaches the shell. * fix(#2800): remove the regex length ceiling and count only prose mentions Review found two real defects in the docs parity gate. The never-throws contract was false: building a RegExp from a declared flag or section title throws SyntaxError past ~100k chars, and Phase 2 admits overlay lanes whose declared strings are untrusted in length. Every one of these matches is literal, so String.includes replaces the regex outright, which also deletes escapeLiteral and the llama.cpp escaping it existed for. Arm 1 was context-blind: a flag mentioned only inside a fenced example or a commented-out row counted as documented. Both are stripped before matching. Also advertises all 13 lane flags in the argument-hint and corrects a stale eleven-lane count in the slug grammar note. * test(#2800): repoint the convergence suite off deleted workflow text The derived flag loop deleted the literal per-flag grep lines four tests matched on. Two of those failed loudly. The behavioral and property tests failed SILENTLY instead: their end marker no longer resolved, so the parse block extracted empty and both passed vacuously, and the property test's gsd_run stub had a no-op default that hid it. All now share one extractor and execute the real deployed block through a gsd_run shim backed by the actual binary. The whitelist assertions become an anti-parity check: re-adding a hand-written flag list must fail. Also repairs two vacuous cases in the docs parity suite. The unreadable-doc test called its own mock rather than the reader, and the integration test bounded nothing, so a doc losing its marker would have been silently skipped and still passed green. * fix(#2800): run the derived flag loop after the launcher preamble The remote matrix caught a real runtime bug, not a test artifact. In autonomous.md and plan-review-convergence.md the launcher preamble that defines gsd_run lives in a separate, LATER bash fence than the derived loop. Each fence is its own shell, so gsd_run was undefined where the loop ran: the command substitution yielded nothing and zero reviewer flags would have been forwarded. Worse than the drift this epic fixes, and silent. The whole CONVERGENCE_ARGS construction moves as one unit, because the --max-cycles append sits between the loop and the preamble and would otherwise have run against an uninitialized variable and then been dropped by the relocated initializer. Also documents all 13 lane flags in help/modes/full.md, which the repo gates bidirectionally against each command's argument-hint. * test(#2800): repoint the two converge suites off deleted flag literals Both asserted workflow.includes('--codex') against the hand-enumerated list the derived loop removed. They now assert the derivation itself, keep --all and --text (convergence controls, still literal), and add an anti-parity guard so re-adding a hardcoded list fails. The lost pass-through proof is replaced with a real one: every flag the tests used to hardcode is asserted present in the actual roster emitted by the binary, which is the property the old assertion was protecting. * test(#2800): acknowledge the workflow byte growth from the derived flag loop * chore(#2800): backfill changeset pr number to 2882 * fix(#2800): strip HTML comments to a fixed point in the parity gate CodeQL js/incomplete-multi-character-sanitization (high) on PR #2882: the single-pass <!--...--> strip can leave a live <!-- behind, so a join-trick construction smuggles a commented-out row past the gate and it counts as documented. Not an injection risk here since nothing is rendered, but it is the exact false pass this helper exists to prevent. Strips to a fixed point, then treats any surviving opener as unterminated so the multi-line branch closes it on a later line. Terminates because every pass strictly shortens the string. * test(#2800): pin the comment-smuggling regression with a real reproducer The obvious fixture for this class does not reproduce it: <!--<!---->--> leaves a dangling --> rather than a live <!--, and is caught either way, so it would have passed with and without the fix. The join-trick construction (<!- + <!--DUMMY--> + -...-->), the <scr<script>ipt> shape, genuinely regresses on the single-pass strip and is what the test now uses. --------- Co-authored-by: Test <test@example.com>
This commit is contained in:
5
.changeset/gallant-pandas-greet.md
Normal file
5
.changeset/gallant-pandas-greet.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 2882
|
||||
---
|
||||
**Reviewer lane flags and section titles are now gated across every documentation surface** — `/gsd:review` reviewer flags were hand-enumerated in five docs and three workflow files that had silently drifted apart: `--kimi-code` was missing from all four translated `COMMANDS.md` mirrors, `--coderabbit` from every workflow forwarding list, and `--antigravity` from `FEATURES.md` entirely. The lane roster is now the single declared source: workflows derive their flag lists from a new `review-lane flags` query, and a parity gate fails the build when any documented flag or reviewer section title diverges from it. The capability manifest reference also gains the previously undocumented `reviewer` body and `hostBehaviors` field. (#2800)
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: gsd:plan-review-convergence
|
||||
description: "Cross-AI plan convergence - replan until review concerns are resolved."
|
||||
argument-hint: "<phase> [--codex] [--gemini] [--claude] [--opencode] [--ollama] [--lm-studio] [--llama-cpp] [--agy] [--text] [--ws <name>] [--all] [--max-cycles N]"
|
||||
argument-hint: "<phase> [--gemini] [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--antigravity] [--agy] [--ollama] [--lm-studio] [--llama-cpp] [--kimi-code] [--text] [--ws <name>] [--all] [--max-cycles N]"
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
@@ -44,10 +44,14 @@ Phase number: extracted from $ARGUMENTS (required)
|
||||
- `--gemini` — Use Gemini CLI as reviewer
|
||||
- `--agy` / `--antigravity` — Use Antigravity CLI as reviewer (successor to the discontinued Gemini CLI)
|
||||
- `--claude` — Use Claude CLI as reviewer (separate session)
|
||||
- `--coderabbit` — Use CodeRabbit as reviewer (reviews the working-tree diff, not the source tree)
|
||||
- `--opencode` — Use OpenCode as reviewer
|
||||
- `--qwen` — Use Qwen Code CLI as reviewer (Alibaba Qwen models)
|
||||
- `--cursor` — Use Cursor agent as reviewer
|
||||
- `--ollama` — Use local Ollama server as reviewer (OpenAI-compatible, default host `http://localhost:11434`; configure model via `review.models.ollama`)
|
||||
- `--lm-studio` — Use local LM Studio server as reviewer (OpenAI-compatible, default host `http://localhost:1234`; configure model via `review.models.lm_studio`)
|
||||
- `--llama-cpp` — Use local llama.cpp server as reviewer (OpenAI-compatible, default host `http://localhost:8080`; configure model via `review.models.llama_cpp`)
|
||||
- `--kimi-code` — Use Kimi Code CLI as reviewer (Moonshot AI)
|
||||
- `--all` — Use all available CLIs and running local model servers
|
||||
- `--max-cycles N` — Maximum replan→review cycles (default: 3)
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ Cross-AI plan convergence loop — replan with review feedback until no HIGH con
|
||||
| Argument / Flag | Required | Description |
|
||||
|-----------------|----------|-------------|
|
||||
| `N` | **Yes** | Phase number to plan and review |
|
||||
| `--codex` / `--gemini` / `--claude` / `--opencode` | No | Single-reviewer selection |
|
||||
| Reviewer flags | No | Pass through every reviewer lane flag: `--gemini`, `--claude`, `--codex`, `--coderabbit`, `--opencode`, `--qwen`, `--cursor`, `--agy` / `--antigravity`, `--ollama`, `--lm-studio`, `--llama-cpp`, `--kimi-code` |
|
||||
| `--all` | No | Run every configured reviewer in parallel |
|
||||
| `--max-cycles N` | No | Override cycle cap (default 3) |
|
||||
|
||||
@@ -628,7 +628,7 @@ Show status, next steps, and automatically advance to the next logical workflow
|
||||
| `--next --auto` | Like `--next`, but chains steps automatically until milestone completion or a blocking decision |
|
||||
| `--next --converge` | When the next action is planning, route it through `/gsd-plan-review-convergence`; requires `workflow.plan_review_convergence=true` |
|
||||
| `--cross-ai` | Alias for `--converge` |
|
||||
| Reviewer flags | With `--converge`, pass through `--codex`, `--gemini`, `--claude`, `--opencode`, `--ollama`, `--lm-studio`, `--llama-cpp`, `--all`, and `--max-cycles N` |
|
||||
| Reviewer flags | With `--converge`, pass through every reviewer lane flag: `--gemini`, `--claude`, `--codex`, `--coderabbit`, `--opencode`, `--qwen`, `--cursor`, `--agy` / `--antigravity`, `--ollama`, `--lm-studio`, `--llama-cpp`, `--kimi-code`, `--all`, and `--max-cycles N` |
|
||||
| `--do "task description"` | Analyze freeform intent and dispatch to the most appropriate GSD command |
|
||||
| `--forensic` | Append a 6-check integrity audit after the standard report (STATE consistency, orphaned handoffs, deferred scope drift, memory-flagged pending work, blocking todos, uncommitted code) |
|
||||
|
||||
@@ -858,7 +858,7 @@ Run all remaining phases autonomously.
|
||||
| `--interactive` | Lean context with user input |
|
||||
| `--converge` | Route each planning step through `/gsd-plan-review-convergence`; requires `workflow.plan_review_convergence=true` |
|
||||
| `--cross-ai` | Alias for `--converge` |
|
||||
| Reviewer flags | With `--converge`, pass through `--codex`, `--gemini`, `--claude`, `--opencode`, `--ollama`, `--lm-studio`, `--llama-cpp`, `--all`, and `--max-cycles N` |
|
||||
| Reviewer flags | With `--converge`, pass through every reviewer lane flag: `--gemini`, `--claude`, `--codex`, `--coderabbit`, `--opencode`, `--qwen`, `--cursor`, `--agy` / `--antigravity`, `--ollama`, `--lm-studio`, `--llama-cpp`, `--kimi-code`, `--all`, and `--max-cycles N` |
|
||||
| `--text` | Replace `AskUserQuestion` prompts with plain numbered lists |
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1242,7 +1242,7 @@ When verification returns `human_needed`, items are persisted as a trackable HUM
|
||||
|
||||
### 42. Cross-AI Peer Review
|
||||
|
||||
**Command:** `/gsd-review --phase N [--gemini] [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--agy] [--ollama] [--lm-studio] [--llama-cpp] [--all]`
|
||||
**Command:** `/gsd-review --phase N [--gemini] [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--agy] [--antigravity] [--ollama] [--lm-studio] [--llama-cpp] [--kimi-code] [--all]`
|
||||
|
||||
**Purpose:** Invoke external AI CLIs (Gemini, Claude, Codex, CodeRabbit, OpenCode, Qwen Code, Cursor, Antigravity, Kimi Code) and local OpenAI-compatible servers (Ollama, LM Studio, llama.cpp) to independently review phase plans. Produces structured REVIEWS.md with per-reviewer feedback.
|
||||
|
||||
|
||||
@@ -507,6 +507,9 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `research-store.cjs` | Content-addressed research cache: sha256 keys, per-source TTL staleness, two-tier (user ~/.gsd / project .planning) store |
|
||||
| `probe-core.cjs` | Generic spec-phase probe resolution model (compiled from `src/probe-core.cts`, gitignored; ADR-550 Decision 7) — the status×verification re-cut (`status: resolved/dismissed/unresolved` × per-probe `verification`), `validateResolution`/`validateRequirement`, `analyzeCoverage(items, resolutions?, validators)` merge/rollup/orphan-reject, the `byVerification` rollup, and the `runProbeCli` I/O scaffold; the shared seam consumed by `edge-probe` (and the prohibition probe #644); exports `VALID_STATUS`, `validateResolution`, `validateRequirement`, `analyzeCoverage`, `runProbeCli` (#550) |
|
||||
| `prohibition-enforcement.cjs` | Deterministic test-tier prohibition PRODUCER/gate (compiled from `src/prohibition-enforcement.cts`, gitignored; #1259, ADR-550 D5d "heavy half") — locates the wired mechanical check (`node-test` or `lint-rule`), confirms it is fail-first, runs it via an injectable runner, builds typed `enforcementEvidence`, and emits the `dispositionForProhibition` verdict; a passing wired check disposes green, a missing/failing/non-fail-first check hard-gates (flagged, non-green) in both interactive and autonomous modes; exports `runProhibitionEnforcement`, `routeProhibitionEnforcement`; CLI surface `gsd_run check prohibition-enforcement <request.json>` |
|
||||
| `review-lane-descriptor.cjs` | Declared reviewer-lane contract (compiled from `src/review-lane-descriptor.cts`, gitignored; ADR-2782) — the frozen `REVIEWER_LANES` roster, the lane slug grammar, and two pure parity gates: `checkReviewerLaneParity` (descriptor ↔ roster ↔ registry, plus anti-parity against re-added bespoke workflow legs) and `checkReviewerDocsParity` (declared flags and section titles ↔ `docs/COMMANDS.md`, `docs/FEATURES.md` and their locale mirrors; #2800, closes #2781/#2272); exports `REVIEWER_LANES`, `PARITY_VIOLATION`, `DOCS_PARITY_VIOLATION`, `LANE_SLUG_RE` |
|
||||
| `review-lane-invocation.cjs` | Pure projection from a declared reviewer lane plus resolved config to a concrete invocation plan (compiled from `src/review-lane-invocation.cts`, gitignored; ADR-2782 Phase 5b) — no filesystem, network or clock; config arrives through a `configGet` seam; exports `resolveLanePlan`, `LANE_UNAVAILABLE` |
|
||||
| `review-lane-runner.cjs` | Execution of a reviewer-lane invocation plan (compiled from `src/review-lane-runner.cts`, gitignored; ADR-2782 Phase 5b) — probe, spawn or HTTP call, empty-output policy, egress-host check, and dispatch of the three first-party `handler` modules; exports `runLane`, `probeLane`, `checkEgressHost`, `writeReviewOrStub` |
|
||||
| `review-reviewer-selection.cjs` | Reviewer selection/normalization helpers for `/gsd-review` default reviewer policy and precedence |
|
||||
| `roadmap-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools roadmap` |
|
||||
| `roadmap-parser.cjs` | ROADMAP.md parsing — milestone slicing, current-milestone extraction, phase/milestone lookups, milestone-phase filter (extracted from `core.cjs`, ADR-857) |
|
||||
|
||||
@@ -218,7 +218,7 @@ WebSearch から取得したパッケージは `[ASSUMED]`(`[VERIFIED]` では
|
||||
| 引数 / フラグ | 必須 | 説明 |
|
||||
|-----------------|----------|-------------|
|
||||
| `N` | **Yes** | 計画およびレビューするフェーズ番号 |
|
||||
| `--codex` / `--gemini` / `--claude` / `--opencode` | No | 単一レビュアーの選択 |
|
||||
| レビュアーフラグ | No | すべてのレビュアーレーンフラグをそのまま渡す: `--gemini`、`--claude`、`--codex`、`--coderabbit`、`--opencode`、`--qwen`、`--cursor`、`--agy` / `--antigravity`、`--ollama`、`--lm-studio`、`--llama-cpp`、`--kimi-code` |
|
||||
| `--all` | No | 設定済みのすべてのレビュアーを並列で実行 |
|
||||
| `--max-cycles N` | No | サイクル上限を上書き(デフォルト3) |
|
||||
|
||||
@@ -1244,6 +1244,7 @@ AI システムの構築を含むフェーズの AI-SPEC.md デザインコン
|
||||
| `--qwen` | Qwen Code レビューを含める(Alibaba Qwen モデル) |
|
||||
| `--cursor` | Cursor エージェントレビューを含める |
|
||||
| `--agy` / `--antigravity` | Antigravity CLI レビューを含める(Google 認証情報で無料) |
|
||||
| `--kimi-code` | Kimi Code CLI レビューを含める(Moonshot AI) |
|
||||
| `--ollama` | Ollama サーバーレビューを含める |
|
||||
| `--lm-studio` | LM Studio サーバーレビューを含める |
|
||||
| `--llama-cpp` | llama.cpp サーバーレビューを含める |
|
||||
|
||||
@@ -1163,9 +1163,9 @@ fix(03-01): correct auth token expiry
|
||||
|
||||
### 42. クロス AI ピアレビュー
|
||||
|
||||
**コマンド:** `/gsd-review --phase N [--gemini] [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--agy] [--all]`
|
||||
**コマンド:** `/gsd-review --phase N [--gemini] [--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)を呼び出して、フェーズプランを独立してレビューします。レビュアーごとのフィードバックを含む構造化された REVIEWS.md を生成します。
|
||||
**目的:** 外部の AI CLI(Gemini、Claude、Codex、CodeRabbit、OpenCode、Qwen Code、Cursor、Antigravity、Kimi Code)とローカルの OpenAI 互換サーバー(Ollama、LM Studio、llama.cpp)を呼び出して、フェーズプランを独立してレビューします。レビュアーごとのフィードバックを含む構造化された REVIEWS.md を生成します。
|
||||
|
||||
**要件:**
|
||||
- REQ-REVIEW-01: システムはシステム上で利用可能な AI CLI を検出しなければならない
|
||||
|
||||
@@ -218,7 +218,7 @@ WebSearch에서 가져온 패키지는 `[ASSUMED]`(`[VERIFIED]`가 아님)로
|
||||
| 인수 / 플래그 | 필수 | 설명 |
|
||||
|-----------------|----------|-------------|
|
||||
| `N` | **예** | 계획 및 리뷰할 단계 번호 |
|
||||
| `--codex` / `--gemini` / `--claude` / `--opencode` | 아니요 | 단일 리뷰어 선택 |
|
||||
| 리뷰어 플래그 | 아니요 | 모든 리뷰어 레인 플래그를 그대로 전달: `--gemini`, `--claude`, `--codex`, `--coderabbit`, `--opencode`, `--qwen`, `--cursor`, `--agy` / `--antigravity`, `--ollama`, `--lm-studio`, `--llama-cpp`, `--kimi-code` |
|
||||
| `--all` | 아니요 | 구성된 모든 리뷰어를 병렬로 실행 |
|
||||
| `--max-cycles N` | 아니요 | 사이클 상한 재정의 (기본값 3) |
|
||||
|
||||
@@ -1250,6 +1250,7 @@ AI 시스템 구축을 포함하는 단계에 대한 AI-SPEC.md 디자인 계약
|
||||
| `--qwen` | Qwen Code 검토 포함 (Alibaba Qwen 모델) |
|
||||
| `--cursor` | Cursor 에이전트 검토 포함 |
|
||||
| `--agy` / `--antigravity` | Antigravity CLI 검토 포함 (Google 자격증명으로 무료) |
|
||||
| `--kimi-code` | Kimi Code CLI 검토 포함 (Moonshot AI) |
|
||||
| `--ollama` | Ollama 서버 검토 포함 |
|
||||
| `--lm-studio` | LM Studio 서버 검토 포함 |
|
||||
| `--llama-cpp` | llama.cpp 서버 검토 포함 |
|
||||
|
||||
@@ -1067,9 +1067,9 @@ fix(03-01): correct auth token expiry
|
||||
|
||||
### 42. Cross-AI Peer Review
|
||||
|
||||
**명령어:** `/gsd-review --phase N [--gemini] [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--agy] [--all]`
|
||||
**명령어:** `/gsd-review --phase N [--gemini] [--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)를 호출하여 페이즈 계획을 독립적으로 검토합니다. 검토자별 피드백이 담긴 구조화된 REVIEWS.md를 생성합니다.
|
||||
**목적:** 외부 AI CLI(Gemini, Claude, Codex, CodeRabbit, OpenCode, Qwen Code, Cursor, Antigravity, Kimi Code)와 로컬 OpenAI 호환 서버(Ollama, LM Studio, llama.cpp)를 호출하여 페이즈 계획을 독립적으로 검토합니다. 검토자별 피드백이 담긴 구조화된 REVIEWS.md를 생성합니다.
|
||||
|
||||
**요구사항.**
|
||||
- REQ-REVIEW-01: 시스템에서 사용 가능한 AI CLI를 감지해야 합니다.
|
||||
|
||||
@@ -218,7 +218,7 @@ Loop de convergência de planos cross-AI — replaneja com feedback de revisão
|
||||
| Argumento / Flag | Obrigatório | Descrição |
|
||||
|------------------|-------------|-----------|
|
||||
| `N` | **Sim** | Número da fase a planejar e revisar |
|
||||
| `--codex` / `--gemini` / `--claude` / `--opencode` | Não | Seleção de revisor único |
|
||||
| Flags de revisor | Não | Repassa todas as flags de lane de revisor: `--gemini`, `--claude`, `--codex`, `--coderabbit`, `--opencode`, `--qwen`, `--cursor`, `--agy` / `--antigravity`, `--ollama`, `--lm-studio`, `--llama-cpp`, `--kimi-code` |
|
||||
| `--all` | Não | Executa todos os revisores configurados em paralelo |
|
||||
| `--max-cycles N` | Não | Substitui o limite de ciclos (padrão 3) |
|
||||
|
||||
@@ -1247,6 +1247,7 @@ Revisão por pares cross-AI de planos de fase a partir de CLIs de IA externas.
|
||||
| `--qwen` | Inclui revisão pelo Qwen Code (modelos Alibaba Qwen) |
|
||||
| `--cursor` | Inclui revisão pelo agente Cursor |
|
||||
| `--agy` / `--antigravity` | Inclui revisão pelo Antigravity CLI (gratuito com credenciais Google) |
|
||||
| `--kimi-code` | Inclui revisão pelo Kimi Code CLI (Moonshot AI) |
|
||||
| `--ollama` | Inclui revisão pelo servidor Ollama |
|
||||
| `--lm-studio` | Inclui revisão pelo servidor LM Studio |
|
||||
| `--llama-cpp` | Inclui revisão pelo servidor llama.cpp |
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
> **See also:** [How to develop a capability](../how-to/develop-a-capability.md) · [Capability Command Reference](gsd-capability-command.md)
|
||||
|
||||
Each capability is a folder `capabilities/<id>/` (or an overlay root `~/.gsd/capabilities/<id>/` / `.gsd/capabilities/<id>/`) containing one `capability.json` declaration.
|
||||
The file is schema-validated JSON with a common **envelope** plus a **role-typed body** (`role: "feature"` or `role: "runtime"`).
|
||||
The file is schema-validated JSON with a common **envelope** plus a **role-typed body** (`role: "feature"`, `role: "runtime"`, or `role: "reviewer"`).
|
||||
|
||||
---
|
||||
|
||||
## Envelope fields
|
||||
|
||||
These fields are present for both `role: "feature"` and `role: "runtime"` capabilities.
|
||||
These fields are present for `role: "feature"`, `role: "runtime"`, and `role: "reviewer"` capabilities.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | string (kebab-case) | Yes | Unique identifier; **must equal the folder name**. The prefix `gsd-`, `gsd-core-`, and `anthropic-` are reserved for first-party use. |
|
||||
| `role` | `"feature"` \| `"runtime"` | Yes | Discriminator that selects the body schema. |
|
||||
| `role` | `"feature"` \| `"runtime"` \| `"reviewer"` | Yes | Discriminator that selects the body schema. `"reviewer"` is for lane-only capabilities that ship a `reviewer` body and nothing else — see [Reviewer body](#reviewer-body-role-reviewer-or-on-any-role) below. |
|
||||
| `version` | semver string | Yes (1.6.0+) | Semantic version of this capability. The registry rejects a manifest without one. |
|
||||
| `title` | string | Yes | Short human-readable label. Must be a non-empty string. |
|
||||
| `description` | string | Yes | Longer summary sentence. Must be a non-empty string. |
|
||||
@@ -155,10 +155,107 @@ Runtime capabilities describe how GSD projects its artefacts onto one host CLI.
|
||||
| Permission writer | `runtime.permissionWriter` | `null` \| `"opencode"` \| `"kilo"` \| `"antigravity"`. The finish-time permissions-sidecar writer. |
|
||||
| Extended hook events | `runtime.extendedHookEvents` | string[] over a closed vocabulary: `SubagentStop`, `Stop`, `PreCompact`, `FileChanged`, `BeforeAgent`, `AfterAgent`, `BeforeModel`, `SubagentStart`. |
|
||||
|
||||
### `hostBehaviors`
|
||||
|
||||
`runtime.hostBehaviors` is an **open, unvalidated bag** of per-host behavior switches consumed directly by installer and runtime-adaptation code. Unlike every axis in the table above, it is **not covered by any schema**: the key `hostBehaviors` appears zero times in `scripts/gen-capability-registry.cjs` and zero times in `scripts/registry-schema.cjs`. An unknown key inside `hostBehaviors` is neither rejected nor warned about — it is simply ignored by any code path that does not look for it by name.
|
||||
|
||||
58 distinct keys are declared across the shipped runtime manifests; most are set by exactly one capability. This table is not exhaustive — it lists the keys with the widest reuse so a reader can pattern-match new ones against the same shape:
|
||||
|
||||
| Key | Capabilities declaring it |
|
||||
|---|---|
|
||||
| `reapplyCommand` | 9 |
|
||||
| `skipSharedHooksInstall` | 8 |
|
||||
| `reviewerCli` | 6 |
|
||||
| `frontmatterDialect` | 5 |
|
||||
| `hyphenNameAgentBody` | 3 |
|
||||
| `legacyCommandsGsdInstallMigration` | 3 |
|
||||
| `skipUpdateBannerCommand` | 3 |
|
||||
| `verificationStyle` | 3 |
|
||||
|
||||
**`reviewerCli` is deprecated.** It is a boolean that historically marked a runtime capability as also being a reviewer lane. It is now a **derived legacy alias**, retained for one release so an out-of-tree runtime descriptor that still sets it keeps working. A declared `reviewer` body (see below) takes precedence over the alias, and a capability declaring both contributes **one** slug, not two. `reviewerCli` is superseded by the `reviewer` body; its removal is tracked by issue #2801. It is currently set by 6 capabilities: `antigravity`, `claude`, `codex`, `cursor`, `opencode`, `qwen`.
|
||||
|
||||
See [ADR-1016](../adr/1016-runtime-capability-descriptor.md) (the runtime body is a closed 8-axis plus 4 install-surface vocabulary; `hostBehaviors` is the deliberate open seam beside it) and [ADR-2782](../adr/2782-reviewer-lane-capability-surface.md) (introduces the `reviewer` body and the `reviewerCli` alias's deprecation).
|
||||
|
||||
For a minimal `role: "runtime"` example, see [ADR-1016 §Decision 8](../adr/1016-runtime-capability-descriptor.md).
|
||||
|
||||
---
|
||||
|
||||
## Reviewer body (`role: "reviewer"`, or on any role)
|
||||
|
||||
[ADR-2782](../adr/2782-reviewer-lane-capability-surface.md) introduces the *reviewer lane*: one external CLI or model endpoint that `/gsd:review` hands a plan to for independent review.
|
||||
|
||||
The `reviewer` body is **optional and absent-safe at every layer**. A capability with no `reviewer` body is simply not a lane — that is never a validation error. This is a normative forward/backward-compatibility invariant, not a nicety: a plugin, a runtime, or a future GSD version may omit `reviewer` entirely with no consequence.
|
||||
|
||||
The shape is **hybrid**:
|
||||
|
||||
- A `reviewer` body is admissible on `role: "runtime"`, so an existing runtime capability — `codex`, `antigravity` — keeps **one** manifest that is both an installable runtime and a reviewer lane.
|
||||
- A third role, `role: "reviewer"`, exists for lane-only CLIs that GSD never installs into. There are currently 5: `coderabbit`, `gemini`, `llama-cpp`, `lm-studio`, `ollama`.
|
||||
|
||||
Current role counts across `capabilities/`: `feature` 20, `runtime` 19, `reviewer` 5.
|
||||
|
||||
All 12 shipped lane declarations carry all 13 fields below.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `slug` | string | Lane identity; grammar `^[a-z0-9][a-z0-9_-]*$`. May use `_` (`lm_studio`, `llama_cpp`) even where the capability *folder id* is kebab-case (`lm-studio`). |
|
||||
| `flags` | string[] | User-facing CLI flags that select this lane. A lane may declare more than one — `antigravity` declares `--antigravity` and `--agy`. 12 lanes declare 13 flags in total. |
|
||||
| `transport` | closed enum | `spawn` \| `openai-http`. |
|
||||
| `probe` | object | Availability check. `probe.kind` is a closed enum: `command-exists` \| `command-capability` \| `http-reachable`. `command-capability` additionally takes `binary`, `needle`, and a **required** `timeoutMs` — it exists because a bare binary name can be ambiguous (`kimi` is claimed by both the Kimi Code CLI and the legacy Python `kimi-cli`), and the timeout bound is mandatory because an unbounded `--help \| grep` probe is this repo's named Unbounded Subprocesses defect. |
|
||||
| `invoke` | object | `binary`, `args[]`, `promptChannel` (`stdin` \| `argv-file-ref` \| `none`), `outputChannel` (`stdout` \| `file-arg`), `modelArg` (string or `null`), `effortChannel` (`argv` \| `none`). `args` supports the `{{model}}` and `{{prompt}}` placeholders. |
|
||||
| `timeoutFloorMs` | number | Measured per-lane floor. Lane divergence here is real and correct — the descriptor's job is to declare divergence in one place, not to promise uniformity. |
|
||||
| `emptyOutput` | closed enum | `stub-with-stderr` \| `handler-owned`. |
|
||||
| `reviewsSection` | string | The `REVIEWS.md` heading this lane renders under. Must be unique across the merged roster. |
|
||||
| `evidenceClass` | closed enum | `source-grounded` \| `diff-only` (diff-only findings are down-weighted in consensus). |
|
||||
| `requiresBinaries` | string[] | Extra binaries the lane needs beyond `invoke.binary`. |
|
||||
| `promptBudgetKey` | string or `null` | Federated config key bounding prompt size. |
|
||||
| `modelConfigKey` | string or `null` | Federated config key naming the model, e.g. `review.models.kimi-code`. |
|
||||
| `handler` | closed enum or `null` | `antigravity` \| `openai-compatible` \| `opencode` \| `null`. |
|
||||
|
||||
**`handler` is a closed enum of first-party handler names, not an open escape hatch.** [ADR-1016](../adr/1016-runtime-capability-descriptor.md) explicitly rejected "arbitrary code in the descriptor"; hard shapes are absorbed by adding a named primitive that is reviewed first-party. The consequence, stated plainly: **a third-party reviewer lane is strictly data-only.** A plugin can ship a lane, but not a quirky lane that needs imperative code — a lane requiring behavior beyond the closed `handler` set is not expressible and must be proposed and merged first-party.
|
||||
|
||||
Uniqueness is enforced across the merged first-party ∪ overlay set: duplicate `slug`, duplicate `flags` entry, and duplicate `reviewsSection` are each build-time violations. Two lanes sharing a `reviewsSection` heading would silently merge their output in `REVIEWS.md`, producing apparent consensus that does not exist.
|
||||
|
||||
An unknown field inside a `reviewer` body is a **non-fatal warning on stderr, never a build failure** ([ADR-2782](../adr/2782-reviewer-lane-capability-surface.md) D4), so a manifest built against a newer GSD degrades visibly rather than crashing.
|
||||
|
||||
### Example — lane-only `role: "reviewer"` capability
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "coderabbit",
|
||||
"role": "reviewer",
|
||||
"version": "1.8.0",
|
||||
"title": "CodeRabbit",
|
||||
"description": "CodeRabbit CLI — cross-AI /gsd:review reviewer lane only; not a GSD install target (no runtime body, no artifacts).",
|
||||
"tier": "full",
|
||||
"requires": [],
|
||||
"engines": { "gsd": ">=1.8.0" },
|
||||
"reviewer": {
|
||||
"slug": "coderabbit",
|
||||
"flags": ["--coderabbit"],
|
||||
"transport": "spawn",
|
||||
"probe": { "kind": "command-exists", "binary": "coderabbit" },
|
||||
"invoke": {
|
||||
"binary": "coderabbit",
|
||||
"args": ["review", "--prompt-only"],
|
||||
"promptChannel": "none",
|
||||
"outputChannel": "stdout",
|
||||
"modelArg": null,
|
||||
"effortChannel": "none"
|
||||
},
|
||||
"timeoutFloorMs": 360000,
|
||||
"emptyOutput": "stub-with-stderr",
|
||||
"reviewsSection": "CodeRabbit",
|
||||
"evidenceClass": "diff-only",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": null,
|
||||
"handler": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conformance invariants
|
||||
|
||||
The following invariants are enforced at **build time** by `scripts/gen-capability-registry.cjs` and at **install time** by the runtime-callable `validateCapability()` / `validateCrossCapability()` over the merged first-party ∪ overlay set.
|
||||
|
||||
@@ -19,7 +19,7 @@ See also: [ADR-1244](../adr/1244-capability-ecosystem.md) —
|
||||
| Column | Description |
|
||||
|---|---|
|
||||
| **id** | Canonical capability identifier; unique across first- and third-party capabilities. Reserved prefixes: `gsd-`, `gsd-core-`, `anthropic-`. |
|
||||
| **role** | `feature` — extends what the loop does; `runtime` — adapts GSD to a specific AI runtime/IDE. |
|
||||
| **role** | `feature` — extends what the loop does; `runtime` — adapts GSD to a specific AI runtime/IDE; `reviewer` — declares a cross-AI reviewer lane (ADR-2782). A capability may be both a runtime and a reviewer. |
|
||||
| **tier** | `core` — always active; `standard` — active when the runtime supports it; `full` — opt-in or runtime-specific. |
|
||||
| **engines.gsd** | Semver RANGE expressing host-version compatibility. A hard gate at install and at load. `—` means the capability declares no range. |
|
||||
| **extension points** | The loop points this capability registers hooks into (from the registry's `byLoopPoint` index). `—` means it registers none (typical for runtime capabilities, whose job is surface emission). |
|
||||
@@ -144,7 +144,7 @@ fields described below, with `source` = `third-party`.
|
||||
| Column | Value |
|
||||
|---|---|
|
||||
| **id** | As declared in `capability.json`. Must not use reserved prefixes (`gsd-`, `gsd-core-`, `anthropic-`). |
|
||||
| **role** | `feature` or `runtime`, as declared. |
|
||||
| **role** | `feature`, `runtime`, or `reviewer`, as declared. |
|
||||
| **tier** | `core`, `standard`, or `full`, as declared. |
|
||||
| **engines.gsd** | Range from `capability.json`; verified at install and at each load. |
|
||||
| **extension points** | The loop points the capability registers into, validated against the known 12 identifiers. |
|
||||
|
||||
@@ -218,7 +218,7 @@ v1.40 中,六个命名空间路由器作为第一阶段入口点随附发布
|
||||
| 参数 / 标志 | 必填 | 描述 |
|
||||
|-----------------|----------|-------------|
|
||||
| `N` | **是** | 要规划和审查的阶段编号 |
|
||||
| `--codex` / `--gemini` / `--claude` / `--opencode` | 否 | 单一审查者选择 |
|
||||
| 审查者标志 | 否 | 原样传递所有审查者通道标志:`--gemini`、`--claude`、`--codex`、`--coderabbit`、`--opencode`、`--qwen`、`--cursor`、`--agy` / `--antigravity`、`--ollama`、`--lm-studio`、`--llama-cpp`、`--kimi-code` |
|
||||
| `--all` | 否 | 并行运行所有已配置的审查者 |
|
||||
| `--max-cycles N` | 否 | 覆盖循环上限(默认 3) |
|
||||
|
||||
@@ -1244,6 +1244,7 @@ node gsd-tools.cjs intel api-surface # 渲染 api-map.json → API-
|
||||
| `--qwen` | 包含 Qwen Code 审查(阿里巴巴 Qwen 模型) |
|
||||
| `--cursor` | 包含 Cursor 代理审查 |
|
||||
| `--agy` / `--antigravity` | 包含 Antigravity CLI 审查(使用 Google 凭证免费) |
|
||||
| `--kimi-code` | 包含 Kimi Code CLI 审查(Moonshot AI) |
|
||||
| `--ollama` | 包含 Ollama 服务器审查 |
|
||||
| `--lm-studio` | 包含 LM Studio 服务器审查 |
|
||||
| `--llama-cpp` | 包含 llama.cpp 服务器审查 |
|
||||
|
||||
@@ -1173,9 +1173,9 @@ GSD update available: 1.39.0 → 1.40.0. Run /gsd-update.
|
||||
|
||||
### 42. 跨 AI 同行评审
|
||||
|
||||
**命令:** `/gsd-review --phase N [--gemini] [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--agy] [--ollama] [--lm-studio] [--llama-cpp] [--all]`
|
||||
**命令:** `/gsd-review --phase N [--gemini] [--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)独立审查阶段计划。生成包含每位审查者反馈的结构化 REVIEWS.md。
|
||||
**目的:** 调用外部 AI CLI(Gemini、Claude、Codex、CodeRabbit、OpenCode、Qwen Code、Cursor、Antigravity、Kimi Code)和本地 OpenAI 兼容服务器(Ollama、LM Studio、llama.cpp)独立审查阶段计划。生成包含每位审查者反馈的结构化 REVIEWS.md。
|
||||
|
||||
**需求:**
|
||||
- REQ-REVIEW-01:系统必须检测系统上可用的 AI CLI
|
||||
|
||||
@@ -1188,6 +1188,30 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 6 (#2800, closes #2272). The reviewer-flag lists in
|
||||
// plan-review-convergence.md, autonomous.md and next.md were hand-enumerated in three places
|
||||
// and had drifted apart: `--coderabbit` was missing from all three and had been silently
|
||||
// falling back to `--codex`. One declared source, three consumers.
|
||||
//
|
||||
// Emits FLAGS, not slugs: `antigravity` declares two (`--antigravity`, `--agy`), so the flag
|
||||
// count (13) is deliberately not the lane count (12). Same output contract as `sections` —
|
||||
// one token per line, descriptor order, and no trailing newline on an empty result.
|
||||
if (sub === 'flags') {
|
||||
const rows = chosen
|
||||
.map((s) => laneBySlug.get(s))
|
||||
.filter(Boolean)
|
||||
.flatMap((l) => (Array.isArray(l.flags) ? l.flags : []))
|
||||
// Shape-filtered, not merely non-empty. All three consumers read this through an
|
||||
// UNQUOTED `$(gsd_run review-lane flags)` so the newline-separated output word-splits
|
||||
// into loop items — which is the intent. Phase 2 (#2795) admits third-party overlay
|
||||
// lanes into this same descriptor, so an overlay declaring `--foo bar` would inject a
|
||||
// second loop item, and one declaring a glob character would expand against the cwd.
|
||||
// Emitting only well-formed flags keeps that from reaching the shell at all.
|
||||
.filter((f) => typeof f === 'string' && /^--[a-z0-9][a-z0-9-]*$/.test(f));
|
||||
process.stdout.write(rows.join('\n') + (rows.length ? '\n' : ''));
|
||||
return;
|
||||
}
|
||||
|
||||
// Effort argv is resolved per lane by the host's own execution policy, exactly as the legs did
|
||||
// via `resolve-execution … --pick effort_argv_string`. A lane whose slug is not a known host
|
||||
// simply gets none.
|
||||
@@ -1267,7 +1291,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
||||
}
|
||||
|
||||
if (sub !== 'invoke') {
|
||||
error("Usage: review-lane <plan|invoke|sections> [--selected a,b] [--run-dir D] [--repo-root R]");
|
||||
error("Usage: review-lane <plan|invoke|sections|flags> [--selected a,b] [--run-dir D] [--repo-root R]");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,19 +44,6 @@ PLAN_STRATEGY="local"
|
||||
if echo "$ARGUMENTS" | grep -qE '(^|[[:space:]])\-\-(converge|cross-ai)([[:space:]]|$)'; then
|
||||
PLAN_STRATEGY="converge"
|
||||
fi
|
||||
|
||||
CONVERGENCE_ARGS=""
|
||||
for REVIEW_FLAG in --codex --gemini --claude --opencode --ollama --lm-studio --llama-cpp --all --text; do
|
||||
if echo "$ARGUMENTS" | grep -qE "(^|[[:space:]])${REVIEW_FLAG}([[:space:]]|$)"; then
|
||||
CONVERGENCE_ARGS="${CONVERGENCE_ARGS} ${REVIEW_FLAG}"
|
||||
fi
|
||||
done
|
||||
|
||||
MAX_CYCLES_ARG=""
|
||||
if echo "$ARGUMENTS" | grep -qE '\-\-max-cycles\s+[0-9]+'; then
|
||||
MAX_CYCLES_ARG=$(echo "$ARGUMENTS" | grep -oE '\-\-max-cycles\s+[0-9]+' | awk '{print $2}')
|
||||
CONVERGENCE_ARGS="${CONVERGENCE_ARGS} --max-cycles ${MAX_CYCLES_ARG}"
|
||||
fi
|
||||
```
|
||||
|
||||
When `--only` is set, also set `FROM_PHASE` to the same value so existing filter logic applies.
|
||||
@@ -76,6 +63,23 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
If `PLAN_STRATEGY` is `converge`, fail fast unless the existing convergence feature gate is enabled:
|
||||
|
||||
```bash
|
||||
# Lane flags derived from the declared roster (#2800/#2272); --all and --text are convergence
|
||||
# controls, not reviewer lanes, so they stay literal.
|
||||
# This block must stay AFTER the launcher preamble (above) because it calls `gsd_run` —
|
||||
# do not move it back above the preamble in a future edit.
|
||||
CONVERGENCE_ARGS=""
|
||||
for REVIEW_FLAG in $(gsd_run review-lane flags) --all --text; do
|
||||
if echo "$ARGUMENTS" | grep -qE "(^|[[:space:]])${REVIEW_FLAG}([[:space:]]|$)"; then
|
||||
CONVERGENCE_ARGS="${CONVERGENCE_ARGS} ${REVIEW_FLAG}"
|
||||
fi
|
||||
done
|
||||
|
||||
MAX_CYCLES_ARG=""
|
||||
if echo "$ARGUMENTS" | grep -qE '\-\-max-cycles\s+[0-9]+'; then
|
||||
MAX_CYCLES_ARG=$(echo "$ARGUMENTS" | grep -oE '\-\-max-cycles\s+[0-9]+' | awk '{print $2}')
|
||||
CONVERGENCE_ARGS="${CONVERGENCE_ARGS} --max-cycles ${MAX_CYCLES_ARG}"
|
||||
fi
|
||||
|
||||
if [ "$PLAN_STRATEGY" = "converge" ]; then
|
||||
CONVERGENCE_ENABLED=$(gsd_run query config-get workflow.plan_review_convergence 2>/dev/null || echo "false")
|
||||
if [ "$CONVERGENCE_ENABLED" != "true" ]; then
|
||||
|
||||
@@ -624,7 +624,7 @@ The commands above cover the most common day-to-day flows. Every command listed
|
||||
|
||||
- **`/gsd:mvp-phase <phase-number>`** — Plan a phase as a vertical MVP slice (user story + SPIDR splitting) before handing off to plan-phase. Same end-state as `/gsd:plan-phase --mvp`, with a guided MVP-shaping intro.
|
||||
- **`/gsd:ultraplan-phase [phase]`** — [BETA] Offload plan phase to Claude Code's ultraplan cloud; review in browser and import back.
|
||||
- **`/gsd:plan-review-convergence <phase> [--codex] [--gemini] [--claude] [--opencode] [--ollama] [--lm-studio] [--llama-cpp] [--all] [--text] [--ws <name>] [--max-cycles N]`** — Cross-AI plan convergence loop — replan with review feedback until no HIGH concerns remain. Supports both cloud reviewers (Codex/Gemini/Claude/OpenCode) and local model runtimes (Ollama, LM Studio, llama.cpp).
|
||||
- **`/gsd:plan-review-convergence <phase> [--gemini] [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--agy/--antigravity] [--ollama] [--lm-studio] [--llama-cpp] [--kimi-code] [--all] [--text] [--ws <name>] [--max-cycles N]`** — Cross-AI plan convergence loop — replan with review feedback until no HIGH concerns remain. Supports both cloud reviewers (Gemini/Claude/Codex/CodeRabbit/OpenCode/Qwen/Cursor/Antigravity/Kimi Code) and local model runtimes (Ollama, LM Studio, llama.cpp).
|
||||
- **`/gsd:autonomous [--from N] [--to N] [--only N] [--interactive] [--converge]`** — Run all remaining phases autonomously: discuss → plan → execute per phase. `--converge` routes planning through plan-review convergence; `--cross-ai` is an alias.
|
||||
|
||||
### Quality, Review & Verification
|
||||
|
||||
@@ -264,7 +264,9 @@ if echo "$ARGUMENTS" | grep -qE '(^|[[:space:]])\-\-(converge|cross-ai)([[:space
|
||||
fi
|
||||
|
||||
CONVERGENCE_ARGS=""
|
||||
for REVIEW_FLAG in --codex --gemini --claude --opencode --ollama --lm-studio --llama-cpp --all --text; do
|
||||
# Lane flags derived from the declared roster (#2800/#2272); --all and --text are convergence
|
||||
# controls, not reviewer lanes, so they stay literal.
|
||||
for REVIEW_FLAG in $(gsd_run review-lane flags) --all --text; do
|
||||
if echo "$ARGUMENTS" | grep -qE "(^|[[:space:]])${REVIEW_FLAG}([[:space:]]|$)"; then
|
||||
CONVERGENCE_ARGS="${CONVERGENCE_ARGS} ${REVIEW_FLAG}"
|
||||
fi
|
||||
|
||||
@@ -18,22 +18,11 @@ Read all files referenced by the invoking prompt's execution_context before star
|
||||
|
||||
## 1. Parse and Normalize Arguments
|
||||
|
||||
Extract from $ARGUMENTS: phase number, reviewer flags (`--codex`, `--gemini`, `--agy`/`--antigravity`, `--claude`, `--opencode`, `--ollama`, `--lm-studio`, `--llama-cpp`, `--all`), `--max-cycles N`, `--text`, `--ws`.
|
||||
Extract from $ARGUMENTS: phase number, reviewer flags (the declared reviewer lane flags, plus `--all`), `--max-cycles N`, `--text`, `--ws`.
|
||||
|
||||
```bash
|
||||
PHASE=$(echo "$ARGUMENTS" | grep -oE '[0-9]+\.?[0-9]*' | head -1)
|
||||
|
||||
REVIEWER_FLAGS=""
|
||||
echo "$ARGUMENTS" | grep -q '\-\-codex' && REVIEWER_FLAGS="$REVIEWER_FLAGS --codex"
|
||||
echo "$ARGUMENTS" | grep -q '\-\-gemini' && REVIEWER_FLAGS="$REVIEWER_FLAGS --gemini"
|
||||
echo "$ARGUMENTS" | grep -q '\-\-agy' && REVIEWER_FLAGS="$REVIEWER_FLAGS --agy"
|
||||
echo "$ARGUMENTS" | grep -q '\-\-antigravity' && REVIEWER_FLAGS="$REVIEWER_FLAGS --antigravity"
|
||||
echo "$ARGUMENTS" | grep -q '\-\-claude' && REVIEWER_FLAGS="$REVIEWER_FLAGS --claude"
|
||||
echo "$ARGUMENTS" | grep -q '\-\-opencode' && REVIEWER_FLAGS="$REVIEWER_FLAGS --opencode"
|
||||
echo "$ARGUMENTS" | grep -q '\-\-ollama' && REVIEWER_FLAGS="$REVIEWER_FLAGS --ollama"
|
||||
echo "$ARGUMENTS" | grep -q '\-\-lm-studio' && REVIEWER_FLAGS="$REVIEWER_FLAGS --lm-studio"
|
||||
echo "$ARGUMENTS" | grep -q '\-\-llama-cpp' && REVIEWER_FLAGS="$REVIEWER_FLAGS --llama-cpp"
|
||||
echo "$ARGUMENTS" | grep -q '\-\-all' && REVIEWER_FLAGS="$REVIEWER_FLAGS --all"
|
||||
# #2315: do NOT default REVIEWER_FLAGS to --codex here. The default is resolved
|
||||
# against review.default_reviewers in step 1.5 (after the config gate) so a bare
|
||||
# invocation respects the configured reviewer lineup per ADR-0011 / ADR-0015.
|
||||
@@ -66,6 +55,20 @@ Then re-run: /gsd:plan-review-convergence {PHASE}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Reviewer flags are DERIVED from the declared lane roster (#2800/#2272), never hand-listed.
|
||||
# Three surfaces used to enumerate them independently and had drifted: --coderabbit was missing
|
||||
# from all three, --qwen/--cursor/--kimi-code from this one, and the old unanchored
|
||||
# `grep -q '\-\-agy'` matched INSIDE --antigravity, appending both for one user flag.
|
||||
# `--all` is a selection control, not a lane, so it stays literal.
|
||||
# This block must stay AFTER the launcher preamble (below) because it calls `gsd_run` —
|
||||
# do not move it back above the preamble in a future edit.
|
||||
REVIEWER_FLAGS=""
|
||||
for REVIEW_FLAG in $(gsd_run review-lane flags) --all; do
|
||||
if echo "$ARGUMENTS" | grep -qE "(^|[[:space:]])${REVIEW_FLAG}([[:space:]]|$)"; then
|
||||
REVIEWER_FLAGS="$REVIEWER_FLAGS $REVIEW_FLAG"
|
||||
fi
|
||||
done
|
||||
|
||||
# #2315: Resolve reviewer selection when no explicit flag was given.
|
||||
# The pre-fix bug unconditionally set REVIEWER_FLAGS="--codex" in step 1, BEFORE
|
||||
# the config gate — silently overriding any configured review.default_reviewers
|
||||
|
||||
@@ -145,7 +145,7 @@ See also: [ADR-1244](../adr/1244-capability-ecosystem.md) —
|
||||
| Column | Description |
|
||||
|---|---|
|
||||
| **id** | Canonical capability identifier; unique across first- and third-party capabilities. Reserved prefixes: \`gsd-\`, \`gsd-core-\`, \`anthropic-\`. |
|
||||
| **role** | \`feature\` — extends what the loop does; \`runtime\` — adapts GSD to a specific AI runtime/IDE. |
|
||||
| **role** | \`feature\` — extends what the loop does; \`runtime\` — adapts GSD to a specific AI runtime/IDE; \`reviewer\` — declares a cross-AI reviewer lane (ADR-2782). A capability may be both a runtime and a reviewer. |
|
||||
| **tier** | \`core\` — always active; \`standard\` — active when the runtime supports it; \`full\` — opt-in or runtime-specific. |
|
||||
| **engines.gsd** | Semver RANGE expressing host-version compatibility. A hard gate at install and at load. \`—\` means the capability declares no range. |
|
||||
| **extension points** | The loop points this capability registers hooks into (from the registry's \`byLoopPoint\` index). \`—\` means it registers none (typical for runtime capabilities, whose job is surface emission). |
|
||||
@@ -223,7 +223,7 @@ fields described below, with \`source\` = \`third-party\`.
|
||||
| Column | Value |
|
||||
|---|---|
|
||||
| **id** | As declared in \`capability.json\`. Must not use reserved prefixes (\`gsd-\`, \`gsd-core-\`, \`anthropic-\`). |
|
||||
| **role** | \`feature\` or \`runtime\`, as declared. |
|
||||
| **role** | \`feature\`, \`runtime\`, or \`reviewer\`, as declared. |
|
||||
| **tier** | \`core\`, \`standard\`, or \`full\`, as declared. |
|
||||
| **engines.gsd** | Range from \`capability.json\`; verified at install and at each load. |
|
||||
| **extension points** | The loop points the capability registers into, validated against the known 12 identifiers. |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: gsd-plan-review-convergence
|
||||
description: "Cross-AI plan convergence - replan until review concerns are resolved."
|
||||
argument-hint: "<phase> [--codex] [--gemini] [--claude] [--opencode] [--ollama] [--lm-studio] [--llama-cpp] [--agy] [--text] [--ws <name>] [--all] [--max-cycles N]"
|
||||
argument-hint: "<phase> [--gemini] [--claude] [--codex] [--coderabbit] [--opencode] [--qwen] [--cursor] [--antigravity] [--agy] [--ollama] [--lm-studio] [--llama-cpp] [--kimi-code] [--text] [--ws <name>] [--all] [--max-cycles N]"
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
@@ -44,10 +44,14 @@ Phase number: extracted from $ARGUMENTS (required)
|
||||
- `--gemini` — Use Gemini CLI as reviewer
|
||||
- `--agy` / `--antigravity` — Use Antigravity CLI as reviewer (successor to the discontinued Gemini CLI)
|
||||
- `--claude` — Use Claude CLI as reviewer (separate session)
|
||||
- `--coderabbit` — Use CodeRabbit as reviewer (reviews the working-tree diff, not the source tree)
|
||||
- `--opencode` — Use OpenCode as reviewer
|
||||
- `--qwen` — Use Qwen Code CLI as reviewer (Alibaba Qwen models)
|
||||
- `--cursor` — Use Cursor agent as reviewer
|
||||
- `--ollama` — Use local Ollama server as reviewer (OpenAI-compatible, default host `http://localhost:11434`; configure model via `review.models.ollama`)
|
||||
- `--lm-studio` — Use local LM Studio server as reviewer (OpenAI-compatible, default host `http://localhost:1234`; configure model via `review.models.lm_studio`)
|
||||
- `--llama-cpp` — Use local llama.cpp server as reviewer (OpenAI-compatible, default host `http://localhost:8080`; configure model via `review.models.llama_cpp`)
|
||||
- `--kimi-code` — Use Kimi Code CLI as reviewer (Moonshot AI)
|
||||
- `--all` — Use all available CLIs and running local model servers
|
||||
- `--max-cycles N` — Maximum replan→review cycles (default: 3)
|
||||
|
||||
|
||||
@@ -627,7 +627,7 @@ const LEG_MARKER_RE = /<!--\s*reviewer-lane:\s*([a-z0-9_-]+)\s*-->/g;
|
||||
* `LEG_MARKER_RE` can only capture `[a-z0-9_-]`. A lane whose slug falls outside
|
||||
* that class is therefore UNMATCHABLE in the workflow: its marker can be present
|
||||
* and correct and the scan will still never see it, so the lane reports
|
||||
* `LEG_MARKER_MISSING` forever with no indication why. All eleven shipped slugs
|
||||
* `LEG_MARKER_MISSING` forever with no indication why. All twelve shipped slugs
|
||||
* sit inside the class (`lm_studio`, `llama_cpp` use the underscore), so this
|
||||
* never bites today — but Phase 2 (#2795) admits third-party overlay lanes, and
|
||||
* a slug like `acme.reviewer` would silently vanish from a review.
|
||||
@@ -786,3 +786,364 @@ export function checkReviewerLaneParity(input: ParityInput): ParityResult {
|
||||
|
||||
return { ok: violations.length === 0, violations };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Documentation parity (ADR-2782 Phase 6, #2800 — closes #2781/#2272)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Frozen reason enum for the DOCUMENTATION arm.
|
||||
*
|
||||
* Deliberately separate from `PARITY_VIOLATION` rather than an extension of it. The two functions
|
||||
* answer different questions — `checkReviewerLaneParity` asks *what runs* (descriptor ↔ roster ↔
|
||||
* registry), this asks *what is documented*. Fusing them would change the signature of a function
|
||||
* with seven dependents and would let a stale doc make the runtime checker look red.
|
||||
*
|
||||
* Same three-coordinated-changes rule as its sibling: this enum, the emitting site, and the test
|
||||
* locking `Object.keys(...).sort()`.
|
||||
*/
|
||||
export const DOCS_PARITY_VIOLATION = Object.freeze({
|
||||
MALFORMED_LANE: 'malformed_lane',
|
||||
DOC_FLAG_MISSING: 'doc_flag_missing',
|
||||
DOC_FLAG_UNDECLARED: 'doc_flag_undeclared',
|
||||
DOC_TITLE_MISSING: 'doc_title_missing',
|
||||
SIGNATURE_FLAG_MISSING: 'signature_flag_missing',
|
||||
DOC_UNREADABLE: 'doc_unreadable',
|
||||
TABLE_ROW_MISSING: 'table_row_missing',
|
||||
} as const);
|
||||
|
||||
export type DocsParityViolationReason =
|
||||
(typeof DOCS_PARITY_VIOLATION)[keyof typeof DOCS_PARITY_VIOLATION];
|
||||
|
||||
export interface DocsParityViolation {
|
||||
reason: DocsParityViolationReason;
|
||||
/** The document the violation was found in. */
|
||||
doc: string;
|
||||
/** The flag or section title at fault. */
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface DocsParityResult {
|
||||
ok: boolean;
|
||||
violations: DocsParityViolation[];
|
||||
/**
|
||||
* Documents that carry no `/gsd-review` surface at all and were therefore skipped.
|
||||
*
|
||||
* Reported rather than silently dropped: `docs/pt-BR/FEATURES.md` is a 77-line stub, and a
|
||||
* caller that cannot tell "this mirror does not document the command" from "this mirror agrees"
|
||||
* has the same blindness this whole gate exists to remove.
|
||||
*/
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
export interface DocsParityInput {
|
||||
descriptor: ReadonlyArray<ReviewerLane>;
|
||||
/** Map of document label (repo-relative path) to its full text. */
|
||||
docs: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-lane tokens that legitimately share the `/gsd-review` signature line.
|
||||
*
|
||||
* `--all` is a selection control, not a reviewer lane. Without this allow-list the undeclared-flag
|
||||
* arm would fire on correct documentation — the gate failing on a doc that is right is the fastest
|
||||
* way to get a gate deleted.
|
||||
*/
|
||||
const NON_LANE_SIGNATURE_FLAGS: ReadonlySet<string> = new Set(['--all']);
|
||||
|
||||
/**
|
||||
* Is `flag` documented in `text` under one of the two structural shapes docs actually use?
|
||||
*
|
||||
* Backticked is the `COMMANDS.md` table-cell shape; bracketed (`[--gemini]`) is the
|
||||
* `FEATURES.md` signature shape. Requiring one of those two delimiters — rather than a bare
|
||||
* substring — is what keeps prose and fenced examples from satisfying the gate, and it bounds the
|
||||
* token for free: a backticked `--claude` demands its closing backtick, so a backticked
|
||||
* `--claude-foo` cannot satisfy it.
|
||||
*
|
||||
* Literal `includes`, deliberately NOT a RegExp. `new RegExp` throws `SyntaxError` on a pattern
|
||||
* beyond ~100k chars, and Phase 2 (#2795) admits third-party overlay lanes whose declared strings
|
||||
* are untrusted in length — a parity gate that throws on its own input is indistinguishable from
|
||||
* one that never ran. Literal matching also removes the need to escape metacharacters, which is
|
||||
* what `llama.cpp` needed.
|
||||
*/
|
||||
function flagIsDocumented(text: string, flag: string): boolean {
|
||||
return text.includes('`' + flag + '`') || text.includes('[' + flag + ']');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip fenced code blocks and HTML comments before parity matching.
|
||||
*
|
||||
* Both are places a flag can be *mentioned* without being *documented*: a fenced block showing
|
||||
* markdown syntax, or a commented-out row left behind by an edit. Counting either is a false pass
|
||||
* — the gate would report a lane as documented when a reader never sees it.
|
||||
*
|
||||
* Lines are replaced with empty strings rather than removed so that every downstream line index
|
||||
* (the signature line, the Purpose paragraph beneath it) still refers to the same physical line.
|
||||
*/
|
||||
function stripNonProse(text: string): string {
|
||||
const lines = text.split('\n');
|
||||
const out: string[] = [];
|
||||
let inFence = false;
|
||||
let inComment = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!inComment && /^(`{3,}|~{3,})/.test(trimmed)) {
|
||||
inFence = !inFence;
|
||||
out.push('');
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
out.push('');
|
||||
continue;
|
||||
}
|
||||
let working = line;
|
||||
if (!inComment && working.includes('<!--') && !working.includes('-->')) {
|
||||
inComment = true;
|
||||
working = working.slice(0, working.indexOf('<!--'));
|
||||
out.push(working);
|
||||
continue;
|
||||
}
|
||||
if (inComment) {
|
||||
if (working.includes('-->')) {
|
||||
inComment = false;
|
||||
working = working.slice(working.indexOf('-->') + 3);
|
||||
out.push(working);
|
||||
} else {
|
||||
out.push('');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Single-line comments: strip to a FIXED POINT, then honor any unterminated opener.
|
||||
//
|
||||
// One pass is not enough. `<!--<!---->-->` strips the inner span and leaves a live `<!--`
|
||||
// behind, so a commented-out row could survive and be counted as documented — the exact
|
||||
// false pass this helper exists to prevent. (CodeQL flags the single-pass form as
|
||||
// js/incomplete-multi-character-sanitization; here the consequence is a wrong verdict rather
|
||||
// than an injection, but the fix is the same.) Iterating to a fixed point terminates because
|
||||
// every pass strictly shortens the string.
|
||||
let previous: string;
|
||||
do {
|
||||
previous = working;
|
||||
working = working.replace(/<!--[\s\S]*?-->/g, '');
|
||||
} while (working !== previous);
|
||||
// Whatever `<!--` remains after that is unterminated, so it opens a multi-line comment that
|
||||
// the `inComment` branch above will close on a later line.
|
||||
const danglingOpen = working.indexOf('<!--');
|
||||
if (danglingOpen !== -1) {
|
||||
inComment = true;
|
||||
working = working.slice(0, danglingOpen);
|
||||
}
|
||||
out.push(working);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/** Every bracketed `--token` on a signature line — the line's claimed reviewer flag set. */
|
||||
const BRACKETED_FLAG_RE = /\[(--[a-z0-9][a-z0-9-]*)\]/g;
|
||||
|
||||
/**
|
||||
* Declared flags that appear in the FIRST cell of a markdown table row.
|
||||
*
|
||||
* The distinction is load-bearing, not cosmetic. `docs/COMMANDS.md` and every locale mirror carry
|
||||
* BOTH a per-lane reviewer table (flag in cell 1) and a forwarding row that lists all 13 flags in
|
||||
* cell 3. A file-scoped check is therefore satisfied by the forwarding row alone, so deleting a
|
||||
* lane's actual table row — the exact #2781 regression — passes. Keying on cell 1 separates the
|
||||
* two: `| `--agy` / `--antigravity` | … |` is one lane row declaring two flags, while
|
||||
* `| Reviewer flags | No | … `--gemini`, `--claude` … |` is not a lane row at all.
|
||||
*/
|
||||
function flagsInFirstTableCell(lines: string[], declared: ReadonlySet<string>): Set<string> {
|
||||
const found = new Set<string>();
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('|')) continue;
|
||||
const cells = trimmed.split('|');
|
||||
// split('|') on "| a | b |" yields ['', ' a ', ' b ', ''] — cell 1 is index 1.
|
||||
if (cells.length < 2) continue;
|
||||
const firstCell = cells[1];
|
||||
for (const flag of declared) {
|
||||
if (flagIsDocumented(firstCell, flag)) found.add(flag);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `**Command:** ... /gsd-review ... [--flag]` signature line, if the doc carries one.
|
||||
*
|
||||
* Anchored on `/gsd-review` plus a bracketed flag rather than on the bolded label, because the
|
||||
* label is translated in every mirror. Structure survives translation; prose does not.
|
||||
*/
|
||||
function findSignatureLine(lines: string[]): number {
|
||||
return lines.findIndex((l) => /\/gsd[-:]review\b/.test(l) && l.includes('[--'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Documentation parity for the declared reviewer roster (#2800; closes #2781, #2272).
|
||||
*
|
||||
* Gates `docs/COMMANDS.md`, its four locale mirrors, `docs/FEATURES.md` and its mirrors against
|
||||
* the one declared source of truth. This is the `DEFECT.GENERATIVE-FIX` parity assertion
|
||||
* `CONTEXT.md:806` requires for this roster and which has never existed for it — only the Cursor
|
||||
* lane ever had one (`tests/cursor-reviewer.test.cjs`).
|
||||
*
|
||||
* Three arms, deliberately independent so that gaming one does not green the build:
|
||||
*
|
||||
* 1. **flag** — every declared flag appears, delimited, somewhere in the doc. This is the arm
|
||||
* that catches #2781 (`--kimi-code` present in `docs/COMMANDS.md` and absent from all four
|
||||
* mirrors).
|
||||
* 2. **signature** — where a `Command:` signature line exists it is held to the FULL roster, and
|
||||
* any bracketed non-lane token on it is reported. This restores per-site strictness the
|
||||
* file-wide arm cannot provide.
|
||||
* 3. **title** — every declared `reviewsSection` appears in the Purpose paragraph. This is the
|
||||
* arm that catches the class #2800 names: the `Command:` line updated and the `Purpose:` line
|
||||
* one row below not.
|
||||
* 4. **table row** — a doc carrying a per-lane table must carry a row for EVERY lane, keyed on
|
||||
* the FIRST cell. Without this the file-wide flag arm is satisfied by the forwarding row that
|
||||
* lists all flags in cell 3, and deleting a lane's own row — the #2781 regression itself —
|
||||
* goes undetected.
|
||||
*
|
||||
* A doc carrying no `/gsd-review` surface is **skipped, not failed** — a partial mirror that never
|
||||
* claimed to document the command is not drift.
|
||||
*
|
||||
* Pure and total: no filesystem, no clock, never throws. A non-string document is reported as
|
||||
* `DOC_UNREADABLE` rather than coerced or skipped; an empty or absent `docs` map degrades to
|
||||
* violations rather than a clean bill of health, because a checker that cannot distinguish
|
||||
* "nothing to read" from "everything agrees" is worse than no checker.
|
||||
*
|
||||
* CRLF-insensitive: a Windows `autocrlf` checkout must produce an identical verdict.
|
||||
*/
|
||||
export function checkReviewerDocsParity(input: DocsParityInput): DocsParityResult {
|
||||
const violations: DocsParityViolation[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
const add = (reason: DocsParityViolationReason, doc: string, subject: string): void => {
|
||||
violations.push({ reason, doc, subject });
|
||||
};
|
||||
|
||||
// Trust boundary: narrow from `unknown` rather than believing the annotation. Phase 2 (#2795)
|
||||
// admits third-party overlay lanes into this same descriptor.
|
||||
const declaredFlags: string[] = [];
|
||||
const declaredTitles: string[] = [];
|
||||
const rawLanes: unknown[] = Array.isArray(input?.descriptor)
|
||||
? (input.descriptor as unknown[])
|
||||
: [];
|
||||
for (const raw of rawLanes) {
|
||||
if (raw === null || typeof raw !== 'object') {
|
||||
add(DOCS_PARITY_VIOLATION.MALFORMED_LANE, '(descriptor)', String(raw));
|
||||
continue;
|
||||
}
|
||||
const lane = raw as Record<string, unknown>;
|
||||
const flags: unknown[] = Array.isArray(lane.flags) ? (lane.flags as unknown[]) : [];
|
||||
for (const flag of flags) {
|
||||
if (typeof flag === 'string' && flag.length > 0) declaredFlags.push(flag);
|
||||
}
|
||||
if (typeof lane.reviewsSection === 'string' && lane.reviewsSection.length > 0) {
|
||||
declaredTitles.push(lane.reviewsSection);
|
||||
}
|
||||
}
|
||||
const declaredFlagSet = new Set(declaredFlags);
|
||||
|
||||
// An absent or non-object map is not "no docs to check" — it is every doc missing. Reported as
|
||||
// such so the caller cannot read silence as success.
|
||||
const rawDocs =
|
||||
input?.docs !== null && typeof input?.docs === 'object'
|
||||
? (input.docs as Record<string, unknown>)
|
||||
: {};
|
||||
const docLabels = Object.keys(rawDocs);
|
||||
if (docLabels.length === 0) {
|
||||
for (const flag of declaredFlagSet) {
|
||||
add(DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING, '(no documents supplied)', flag);
|
||||
}
|
||||
return { ok: violations.length === 0, violations, skipped };
|
||||
}
|
||||
|
||||
for (const label of docLabels) {
|
||||
// A document value that is not a string is REPORTED, not coerced and not skipped.
|
||||
//
|
||||
// The earlier version called `String(rawValue)`, which (a) throws outright on an object with
|
||||
// an own non-callable `toString` and (b) — worse — turns any other object into
|
||||
// `[object Object]`, a "document" with no `/gsd-review` marker that then lands in `skipped`.
|
||||
// That is a silent pass: the gate would report success for input it never actually read.
|
||||
// `@typescript-eslint/no-base-to-string` flags exactly this, and it is right to.
|
||||
const rawValue = rawDocs[label];
|
||||
if (typeof rawValue !== 'string') {
|
||||
add(DOCS_PARITY_VIOLATION.DOC_UNREADABLE, label, typeof rawValue);
|
||||
continue;
|
||||
}
|
||||
const text = rawValue.replace(/\r\n/g, '\n');
|
||||
|
||||
// Fenced examples and commented-out rows mention a flag without documenting it. Stripped
|
||||
// BEFORE the `/gsd-review` skip check too, so a doc whose only mention is inside a fence is
|
||||
// correctly treated as not documenting the command at all.
|
||||
const prose = stripNonProse(text);
|
||||
|
||||
// Skip-if-absent. A mirror that does not document /gsd-review is a partial translation, not
|
||||
// drift — gating it would make this change responsible for authoring one.
|
||||
if (!/\/gsd[-:]review\b/.test(prose)) {
|
||||
skipped.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = prose.split('\n');
|
||||
|
||||
// --- arm 1: every declared flag is documented somewhere in the doc ---
|
||||
for (const flag of declaredFlagSet) {
|
||||
if (!flagIsDocumented(prose, flag)) {
|
||||
add(DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING, label, flag);
|
||||
}
|
||||
}
|
||||
|
||||
// --- arm 4: a doc that carries a per-lane table must carry a row for EVERY lane ---
|
||||
//
|
||||
// Only applies when the doc actually has a lane table (>=1 declared flag in a first cell);
|
||||
// FEATURES-shaped docs carry a signature line instead and are covered by arms 2 and 3.
|
||||
const rowFlags = flagsInFirstTableCell(lines, declaredFlagSet);
|
||||
if (rowFlags.size > 0) {
|
||||
for (const flag of declaredFlagSet) {
|
||||
if (!rowFlags.has(flag)) {
|
||||
add(DOCS_PARITY_VIOLATION.TABLE_ROW_MISSING, label, flag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sigIdx = findSignatureLine(lines);
|
||||
if (sigIdx === -1) continue; // COMMANDS.md-shaped docs carry a table, not a signature.
|
||||
|
||||
// --- arm 2: the signature line carries the FULL roster and nothing undeclared ---
|
||||
const sigLine = lines[sigIdx];
|
||||
for (const flag of declaredFlagSet) {
|
||||
if (!flagIsDocumented(sigLine, flag)) {
|
||||
add(DOCS_PARITY_VIOLATION.SIGNATURE_FLAG_MISSING, label, flag);
|
||||
}
|
||||
}
|
||||
// Fresh RegExp per line — a module-level /g literal carries `lastIndex` between calls and
|
||||
// would silently skip matches on the second document scanned.
|
||||
const scanner = new RegExp(BRACKETED_FLAG_RE.source, BRACKETED_FLAG_RE.flags);
|
||||
const seenUndeclared = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = scanner.exec(sigLine)) !== null) {
|
||||
const token = m[1];
|
||||
if (typeof token !== 'string') continue;
|
||||
if (declaredFlagSet.has(token) || NON_LANE_SIGNATURE_FLAGS.has(token)) continue;
|
||||
if (seenUndeclared.has(token)) continue;
|
||||
seenUndeclared.add(token);
|
||||
add(DOCS_PARITY_VIOLATION.DOC_FLAG_UNDECLARED, label, token);
|
||||
}
|
||||
|
||||
// --- arm 3: the Purpose paragraph names every declared section title ---
|
||||
//
|
||||
// The Purpose paragraph is the next non-blank line after the signature. Structural, so it
|
||||
// survives every translated label. Absent (signature is the last line) => title arm skipped,
|
||||
// the flag arms still stand.
|
||||
let p = sigIdx + 1;
|
||||
while (p < lines.length && lines[p].trim() === '') p += 1;
|
||||
if (p >= lines.length) continue;
|
||||
const purpose = lines[p];
|
||||
for (const title of declaredTitles) {
|
||||
if (!purpose.includes(title)) {
|
||||
add(DOCS_PARITY_VIOLATION.DOC_TITLE_MISSING, label, title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: violations.length === 0, violations, skipped };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const COMMAND_PATH = path.join(REPO_ROOT, 'commands', 'gsd', 'progress.md');
|
||||
@@ -15,6 +16,7 @@ const WORKFLOW_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'next.md');
|
||||
const FULL_MD_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'help', 'modes', 'full.md');
|
||||
const COMMANDS_DOC_PATH = path.join(REPO_ROOT, 'docs', 'COMMANDS.md');
|
||||
const HOW_TO_PATH = path.join(REPO_ROOT, 'docs', 'how-to', 'run-phases-autonomously.md');
|
||||
const TOOLS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
function read(filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
@@ -94,7 +96,11 @@ describe('ADR-15: /gsd:progress --next --auto --converge (#1190)', () => {
|
||||
|
||||
test('next workflow forwards reviewer flags and max cycles to convergence', () => {
|
||||
const workflow = read(WORKFLOW_PATH);
|
||||
const reviewerFlags = [
|
||||
// Non-lane convergence controls remain hand-written literals in the workflow.
|
||||
const convergenceControls = ['--all', '--text'];
|
||||
// Reviewer lane flags that were formerly hand-enumerated in the workflow text.
|
||||
// They must now be DERIVED at runtime via `gsd_run review-lane flags`, not listed.
|
||||
const formerlyHardcodedLaneFlags = [
|
||||
'--codex',
|
||||
'--gemini',
|
||||
'--claude',
|
||||
@@ -102,15 +108,46 @@ describe('ADR-15: /gsd:progress --next --auto --converge (#1190)', () => {
|
||||
'--ollama',
|
||||
'--lm-studio',
|
||||
'--llama-cpp',
|
||||
'--all',
|
||||
'--text',
|
||||
];
|
||||
// The literal-absence guard below excludes '--claude': the runtime-launcher
|
||||
// preamble legitimately contains an unrelated "npx ... --claude --local"
|
||||
// install-runtime flag, so a substring match on '--claude' would false-positive
|
||||
// against that literal, not against a re-added reviewer-flag list.
|
||||
const antiParityLaneFlags = formerlyHardcodedLaneFlags.filter((flag) => flag !== '--claude');
|
||||
|
||||
assert.match(workflow, /CONVERGENCE_ARGS/, 'workflow should build convergence pass-through args');
|
||||
for (const flag of reviewerFlags) {
|
||||
assert.match(
|
||||
workflow,
|
||||
/gsd_run review-lane flags/,
|
||||
'workflow should derive reviewer flags from the review-lane roster instead of hand-listing them',
|
||||
);
|
||||
for (const flag of convergenceControls) {
|
||||
assert.ok(workflow.includes(flag), `workflow should pass through ${flag}`);
|
||||
}
|
||||
assert.match(workflow, /--max-cycles/, 'workflow should pass through --max-cycles N');
|
||||
|
||||
// Anti-parity guard (deliberately inverted polarity): the whole point of the
|
||||
// review-lane-flags derivation is that reviewer lane flags are declared ONCE
|
||||
// (in the review-lane roster) and never hand-listed again in workflow prose.
|
||||
// If a future edit re-adds a hardcoded reviewer-flag list here, that is the
|
||||
// regression this test exists to catch — so this assertion must FAIL when
|
||||
// any of these flags reappear as literals in the workflow text.
|
||||
for (const flag of antiParityLaneFlags) {
|
||||
assert.ok(
|
||||
!workflow.includes(flag),
|
||||
`workflow should NOT hand-enumerate reviewer lane flag ${flag}; it must be derived via review-lane flags`,
|
||||
);
|
||||
}
|
||||
|
||||
// Behavioral coverage: prove the roster the workflow derives from actually
|
||||
// yields the flags this test used to hardcode, so the derivation is not vacuous.
|
||||
const laneFlags = cp
|
||||
.execFileSync(process.execPath, [TOOLS, 'review-lane', 'flags'], { encoding: 'utf8' })
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
for (const flag of formerlyHardcodedLaneFlags) {
|
||||
assert.ok(laneFlags.includes(flag), `review-lane flags should include ${flag}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('next workflow preserves --auto re-invocation chaining', () => {
|
||||
|
||||
@@ -8,12 +8,14 @@ const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const COMMAND_PATH = path.join(REPO_ROOT, 'commands', 'gsd', 'autonomous.md');
|
||||
const WORKFLOW_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'autonomous.md');
|
||||
const COMMANDS_DOC_PATH = path.join(REPO_ROOT, 'docs', 'COMMANDS.md');
|
||||
const HOW_TO_PATH = path.join(REPO_ROOT, 'docs', 'how-to', 'run-phases-autonomously.md');
|
||||
const TOOLS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
function read(filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
@@ -81,7 +83,11 @@ describe('autonomous --converge flag (#711)', () => {
|
||||
|
||||
test('workflow forwards reviewer flags and max cycles to convergence', () => {
|
||||
const workflow = read(WORKFLOW_PATH);
|
||||
const reviewerFlags = [
|
||||
// Non-lane convergence controls remain hand-written literals in the workflow.
|
||||
const convergenceControls = ['--all', '--text'];
|
||||
// Reviewer lane flags that were formerly hand-enumerated in the workflow text.
|
||||
// They must now be DERIVED at runtime via `gsd_run review-lane flags`, not listed.
|
||||
const formerlyHardcodedLaneFlags = [
|
||||
'--codex',
|
||||
'--gemini',
|
||||
'--claude',
|
||||
@@ -89,15 +95,46 @@ describe('autonomous --converge flag (#711)', () => {
|
||||
'--ollama',
|
||||
'--lm-studio',
|
||||
'--llama-cpp',
|
||||
'--all',
|
||||
'--text',
|
||||
];
|
||||
// The literal-absence guard below excludes '--claude': the runtime-launcher
|
||||
// preamble legitimately contains an unrelated "npx ... --claude --local"
|
||||
// install-runtime flag, so a substring match on '--claude' would false-positive
|
||||
// against that literal, not against a re-added reviewer-flag list.
|
||||
const antiParityLaneFlags = formerlyHardcodedLaneFlags.filter((flag) => flag !== '--claude');
|
||||
|
||||
assert.match(workflow, /CONVERGENCE_ARGS/, 'workflow should build convergence pass-through args');
|
||||
for (const flag of reviewerFlags) {
|
||||
assert.match(
|
||||
workflow,
|
||||
/gsd_run review-lane flags/,
|
||||
'workflow should derive reviewer flags from the review-lane roster instead of hand-listing them',
|
||||
);
|
||||
for (const flag of convergenceControls) {
|
||||
assert.ok(workflow.includes(flag), `workflow should pass through ${flag}`);
|
||||
}
|
||||
assert.match(workflow, /--max-cycles/, 'workflow should pass through --max-cycles N');
|
||||
|
||||
// Anti-parity guard (deliberately inverted polarity): the whole point of the
|
||||
// review-lane-flags derivation is that reviewer lane flags are declared ONCE
|
||||
// (in the review-lane roster) and never hand-listed again in workflow prose.
|
||||
// If a future edit re-adds a hardcoded reviewer-flag list here, that is the
|
||||
// regression this test exists to catch — so this assertion must FAIL when
|
||||
// any of these flags reappear as literals in the workflow text.
|
||||
for (const flag of antiParityLaneFlags) {
|
||||
assert.ok(
|
||||
!workflow.includes(flag),
|
||||
`workflow should NOT hand-enumerate reviewer lane flag ${flag}; it must be derived via review-lane flags`,
|
||||
);
|
||||
}
|
||||
|
||||
// Behavioral coverage: prove the roster the workflow derives from actually
|
||||
// yields the flags this test used to hardcode, so the derivation is not vacuous.
|
||||
const laneFlags = cp
|
||||
.execFileSync(process.execPath, [TOOLS, 'review-lane', 'flags'], { encoding: 'utf8' })
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
for (const flag of formerlyHardcodedLaneFlags) {
|
||||
assert.ok(laneFlags.includes(flag), `review-lane flags should include ${flag}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('docs show autonomous convergence usage', () => {
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
},
|
||||
"discuss-phase-assumptions.md": {
|
||||
"reason": "#2772: re-synced <answer_validation> to the parent canonical block (had drifted — lost the 'Other' empty-text branch) + fixed the auto_advance→confirm_creation circularity (end the workflow). discuss-phase.md is net -11 (condensed); auto.md shrank -4650 (removed a dead MAX_PASSES resolver shim)."
|
||||
},
|
||||
"autonomous.md": {
|
||||
"reason": "#2800 — the hand-enumerated reviewer-flag list is replaced by a loop over `gsd_run review-lane flags`, and the whole CONVERGENCE_ARGS construction is relocated below the runtime-launcher preamble because it now calls gsd_run and each bash fence is its own shell. Growth is the explanatory comments carrying that constraint plus the loop body, against nine deleted flag literals. Deliberate: the byte delta is the cost of the flags no longer being hand-maintained in three places that had drifted apart."
|
||||
},
|
||||
"next.md": {
|
||||
"reason": "#2800 — same derived-flag-loop change as autonomous.md. next.md needed no relocation (its launcher preamble already precedes the loop), so the growth here is only the loop plus the comment recording that --all and --text stay literal because they are convergence controls, not reviewer lanes."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,33 @@ const PLAN_CHECKER_PATH = path.join(__dirname, '..', 'agents', 'gsd-plan-checker
|
||||
let jqAvailable = false;
|
||||
try { execFileSync('jq', ['--version'], { stdio: 'ignore', timeout: 10000, killSignal: 'SIGKILL' }); jqAvailable = true; } catch { /* no jq on PATH */ }
|
||||
|
||||
// #2800: the hand-written per-flag `grep -q '\-\-<flag>'` whitelist lines were
|
||||
// replaced by a loop deriving REVIEWER_FLAGS from `gsd_run review-lane flags`
|
||||
// (the declared lane roster). These helpers extract the REAL deployed parse
|
||||
// block (`REVIEWER_FLAGS=""` through the closing `done` of the for-loop) and
|
||||
// execute it under a real `gsd_run` shim backed by the actual gsd-tools.cjs
|
||||
// binary, so behavioral coverage proves pass-through against the real
|
||||
// implementation rather than a stale hand-written literal. POSIX-only —
|
||||
// callers must skip on win32.
|
||||
const GSD_TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
function extractReviewerFlagsParseBlock(workflowText) {
|
||||
const startIdx = workflowText.indexOf('REVIEWER_FLAGS=""');
|
||||
const doneIdx = workflowText.indexOf('\ndone\n', startIdx);
|
||||
if (startIdx === -1 || doneIdx === -1) return null;
|
||||
return workflowText.slice(startIdx, doneIdx + '\ndone'.length);
|
||||
}
|
||||
|
||||
function runReviewerFlagsParseBlock(block, args) {
|
||||
const stub = 'gsd_run() { node "$GSD_TOOLS_PATH" "$@"; }';
|
||||
const script = `${stub}\n${block}\nprintf "%s" "$REVIEWER_FLAGS"`;
|
||||
return execFileSync('bash', ['-c', script], {
|
||||
env: { ...process.env, ARGUMENTS: args, GSD_TOOLS_PATH },
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
// ─── Command source ────────────────────────────────────────────────────────
|
||||
|
||||
describe('plan-review-convergence command source (#2306)', () => {
|
||||
@@ -161,11 +188,28 @@ describe('plan-review-convergence: --agy/--antigravity reviewer whitelist (#2293
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const SKILL_PATH = path.join(__dirname, '..', 'skills', 'gsd-plan-review-convergence', 'SKILL.md');
|
||||
|
||||
test('workflow REVIEWER_FLAGS extraction whitelists --agy and --antigravity', () => {
|
||||
// The runtime contract is the grep-accumulation block: each recognized flag
|
||||
// has its own `grep -q '\\-\\-<flag>'` line. Absence = the flag is silently dropped.
|
||||
assert.ok(/grep -q '\\-\\-agy'/.test(workflow), 'workflow must whitelist --agy');
|
||||
assert.ok(/grep -q '\\-\\-antigravity'/.test(workflow), 'workflow must whitelist --antigravity');
|
||||
test('workflow REVIEWER_FLAGS extraction derives its whitelist from review-lane flags, not a hand-written list (#2800)', () => {
|
||||
// #2800: the runtime contract used to be a hand-written grep-accumulation
|
||||
// block — one `grep -q '\-\-<flag>'` line per recognized flag. Absence meant
|
||||
// the flag was silently dropped, and that whitelist drifted three separate
|
||||
// times (--coderabbit, then --qwen/--cursor/--kimi-code, then the unanchored
|
||||
// --agy pattern matching inside --antigravity). The fix derives the whitelist
|
||||
// structurally from the declared lane roster via `gsd_run review-lane flags`,
|
||||
// so --agy/--antigravity (and every other lane flag) are guaranteed reachable
|
||||
// without being named individually here.
|
||||
assert.ok(
|
||||
workflow.includes('gsd_run review-lane flags'),
|
||||
'workflow must derive REVIEWER_FLAGS by iterating `gsd_run review-lane flags` (the declared lane roster), not a hand-written list'
|
||||
);
|
||||
// Anti-parity guard: re-introducing a hand-enumerated per-flag whitelist line
|
||||
// (the exact shape of the pre-#2800 bug) must fail this test, even though the
|
||||
// structural loop above would still make --agy/--antigravity work — a FUTURE
|
||||
// flag added only to a resurrected hand list, and never to the descriptor,
|
||||
// would otherwise silently regress to the #2293 failure mode.
|
||||
assert.ok(
|
||||
!/grep -q '\\-\\-[a-zA-Z0-9-]+'\s*&&\s*REVIEWER_FLAGS=/.test(workflow),
|
||||
'workflow must NOT contain a hand-written per-flag `grep -q \'--<flag>\' && REVIEWER_FLAGS=...` whitelist line — flags must be derived from the declared lane roster (#2800), never re-enumerated by hand'
|
||||
);
|
||||
});
|
||||
|
||||
test('generated SKILL.md mirrors the --agy flag (argument-hint parity)', () => {
|
||||
@@ -187,17 +231,9 @@ describe('plan-review-convergence: --agy/--antigravity reviewer whitelist (#2293
|
||||
// respecting review.default_reviewers.
|
||||
test('[behavioral] the deployed parse block passes --antigravity through (parse block no longer applies a --codex default — #2315)', (t) => {
|
||||
if (process.platform === 'win32') { t.skip('POSIX shell extraction; not run on Windows'); return; }
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const startIdx = workflow.indexOf('REVIEWER_FLAGS=""');
|
||||
const endMarker = "echo \"$ARGUMENTS\" | grep -q '\\-\\-all' && REVIEWER_FLAGS=\"$REVIEWER_FLAGS --all\"";
|
||||
const endIdx = workflow.indexOf(endMarker);
|
||||
assert.ok(startIdx !== -1 && endIdx !== -1, 'the REVIEWER_FLAGS parse block must exist in the workflow');
|
||||
const block = workflow.slice(startIdx, endIdx + endMarker.length) + '\nprintf "%s" "$REVIEWER_FLAGS"';
|
||||
const run = (args) => execFileSync('bash', ['-c', block], {
|
||||
env: { ...process.env, ARGUMENTS: args },
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
const block = extractReviewerFlagsParseBlock(workflow);
|
||||
assert.ok(block, 'the REVIEWER_FLAGS parse block must exist in the workflow');
|
||||
const run = (args) => runReviewerFlagsParseBlock(block, args);
|
||||
|
||||
const agy = run('5 --antigravity');
|
||||
assert.ok(agy.split(/\s+/).includes('--antigravity'), `--antigravity must pass through, got: "${agy}"`);
|
||||
@@ -313,12 +349,12 @@ describe('plan-review-convergence: #2315 respects review.default_reviewers (no-f
|
||||
if (!jqAvailable) { t.skip('jq not on PATH — workflow resolution block pipes through jq (production dependency, review.md:244); structural tests above still validate the fix'); return; }
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
// Parse block: from REVIEWER_FLAGS="" to the last grep line (--all).
|
||||
const parseStart = workflow.indexOf('REVIEWER_FLAGS=""');
|
||||
const parseEndMarker = "echo \"$ARGUMENTS\" | grep -q '\\-\\-all' && REVIEWER_FLAGS=\"$REVIEWER_FLAGS --all\"";
|
||||
const parseEnd = workflow.indexOf(parseEndMarker);
|
||||
assert.ok(parseStart !== -1 && parseEnd !== -1, 'parse block must exist');
|
||||
const parseBlock = workflow.slice(parseStart, parseEnd + parseEndMarker.length);
|
||||
// Parse block: the REAL deployed REVIEWER_FLAGS derivation loop (#2800), from
|
||||
// `REVIEWER_FLAGS=""` through the closing `done`. It calls `gsd_run review-lane
|
||||
// flags`, so the stub below delegates that specific call to the real gsd-tools.cjs
|
||||
// binary rather than a hand-maintained flag list.
|
||||
const parseBlock = extractReviewerFlagsParseBlock(workflow);
|
||||
assert.ok(parseBlock, 'parse block must exist');
|
||||
|
||||
// Resolution block: the `if [ -z "$REVIEWER_FLAGS" ]; then` that appears
|
||||
// AFTER the config gate (CONVERGENCE_ENABLED=). This is the #2315 fix.
|
||||
@@ -333,18 +369,20 @@ describe('plan-review-convergence: #2315 respects review.default_reviewers (no-f
|
||||
const resolutionBlock = workflow.slice(resolutionStart, closingFence);
|
||||
|
||||
const run = ({ args, defaultReviewers }) => {
|
||||
// Stub gsd_run: only `query config-get review.default_reviewers` is exercised.
|
||||
// Empty/default → unset key (gsd_run returns nothing → empty stdout).
|
||||
// Stub gsd_run: `query config-get review.default_reviewers` returns the
|
||||
// stubbed value; `review-lane flags` delegates to the REAL gsd-tools.cjs
|
||||
// binary (so the parse block's loop is genuinely exercised, not stubbed
|
||||
// into a no-op); anything else is a no-op.
|
||||
//
|
||||
// The default_reviewers value is passed via env var ($GSD_TEST_DEFAULT_REVIEWERS)
|
||||
// rather than inline-interpolated into the bash script. This avoids a quoting
|
||||
// fragility: a future test input containing a single quote would otherwise
|
||||
// break the bash single-quoted string and execute as bash. With env-var
|
||||
// handoff, the value never crosses an interpreting shell context.
|
||||
const stub = `gsd_run() { case "$*" in *"config-get review.default_reviewers"*) printf '%s' "$GSD_TEST_DEFAULT_REVIEWERS";; *) return 0;; esac; }`;
|
||||
const stub = `gsd_run() { case "$*" in *"config-get review.default_reviewers"*) printf '%s' "$GSD_TEST_DEFAULT_REVIEWERS";; *"review-lane flags"*) node "$GSD_TOOLS_PATH" review-lane flags;; *) return 0;; esac; }`;
|
||||
const script = `${stub}\n${parseBlock}\n${resolutionBlock}\nprintf 'REVIEWER_FLAGS=[%s] REVIEWER_DISPLAY=[%s]' "$REVIEWER_FLAGS" "$REVIEWER_DISPLAY"`;
|
||||
return execFileSync('bash', ['-c', script], {
|
||||
env: { ...process.env, ARGUMENTS: args, GSD_TEST_DEFAULT_REVIEWERS: defaultReviewers ?? '' },
|
||||
env: { ...process.env, ARGUMENTS: args, GSD_TEST_DEFAULT_REVIEWERS: defaultReviewers ?? '', GSD_TOOLS_PATH },
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -388,20 +426,17 @@ describe('plan-review-convergence: #2315 respects review.default_reviewers (no-f
|
||||
|
||||
// Re-extract the blocks (the test above proved extraction works; we re-use
|
||||
// the same logic rather than promoting to a helper to keep the test scope local).
|
||||
const parseStart = workflow.indexOf('REVIEWER_FLAGS=""');
|
||||
const parseEndMarker = "echo \"$ARGUMENTS\" | grep -q '\\-\\-all' && REVIEWER_FLAGS=\"$REVIEWER_FLAGS --all\"";
|
||||
const parseEnd = workflow.indexOf(parseEndMarker);
|
||||
const parseBlock = workflow.slice(parseStart, parseEnd + parseEndMarker.length);
|
||||
const parseBlock = extractReviewerFlagsParseBlock(workflow);
|
||||
const configGateIdx = workflow.indexOf('CONVERGENCE_ENABLED=$(gsd_run query config-get workflow.plan_review_convergence');
|
||||
const resolutionStart = workflow.indexOf('if [ -z "$REVIEWER_FLAGS" ]; then', configGateIdx);
|
||||
const closingFence = workflow.indexOf('\n```\n', resolutionStart);
|
||||
const resolutionBlock = workflow.slice(resolutionStart, closingFence);
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const run = ({ args, defaultReviewers }) => {
|
||||
const stub = `gsd_run() { case "$*" in *"config-get review.default_reviewers"*) printf '%s' "$GSD_TEST_DEFAULT_REVIEWERS";; *) return 0;; esac; }`;
|
||||
const stub = `gsd_run() { case "$*" in *"config-get review.default_reviewers"*) printf '%s' "$GSD_TEST_DEFAULT_REVIEWERS";; *"review-lane flags"*) node "$GSD_TOOLS_PATH" review-lane flags;; *) return 0;; esac; }`;
|
||||
const script = `${stub}\n${parseBlock}\n${resolutionBlock}\nprintf 'REVIEWER_FLAGS=[%s] REVIEWER_DISPLAY=[%s]' "$REVIEWER_FLAGS" "$REVIEWER_DISPLAY"`;
|
||||
return execFileSync('bash', ['-c', script], {
|
||||
env: { ...process.env, ARGUMENTS: args, GSD_TEST_DEFAULT_REVIEWERS: defaultReviewers ?? '' },
|
||||
env: { ...process.env, ARGUMENTS: args, GSD_TEST_DEFAULT_REVIEWERS: defaultReviewers ?? '', GSD_TOOLS_PATH },
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -943,25 +978,22 @@ describe('plan-review-convergence reviews-mode incorporation contract (#724)', (
|
||||
describe('plan-review-convergence local model reviewer flags (#2306-local)', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
|
||||
test('workflow parses --ollama flag into REVIEWER_FLAGS', () => {
|
||||
assert.ok(
|
||||
workflow.includes('--ollama'),
|
||||
'workflow must parse --ollama flag so it is forwarded to the review agent'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow parses --lm-studio flag into REVIEWER_FLAGS', () => {
|
||||
assert.ok(
|
||||
workflow.includes('--lm-studio'),
|
||||
'workflow must parse --lm-studio flag so it is forwarded to the review agent'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow parses --llama-cpp flag into REVIEWER_FLAGS', () => {
|
||||
assert.ok(
|
||||
workflow.includes('--llama-cpp'),
|
||||
'workflow must parse --llama-cpp flag so it is forwarded to the review agent'
|
||||
);
|
||||
// #2800: local-model flags are no longer hand-listed in the workflow — they
|
||||
// reach REVIEWER_FLAGS via the derived `gsd_run review-lane flags` loop (see
|
||||
// the #2293 describe block above). Prove pass-through behaviorally against
|
||||
// the REAL deployed parse block instead of grepping for a literal string
|
||||
// that no longer exists in the workflow source.
|
||||
test('[behavioral] --ollama, --lm-studio, --llama-cpp pass through the derived REVIEWER_FLAGS loop', (t) => {
|
||||
if (process.platform === 'win32') { t.skip('POSIX shell extraction; not run on Windows'); return; }
|
||||
const block = extractReviewerFlagsParseBlock(workflow);
|
||||
assert.ok(block, 'the REVIEWER_FLAGS parse block must exist in the workflow');
|
||||
for (const flag of ['--ollama', '--lm-studio', '--llama-cpp']) {
|
||||
const out = runReviewerFlagsParseBlock(block, `5 ${flag}`);
|
||||
assert.ok(
|
||||
out.split(/\s+/).includes(flag),
|
||||
`${flag} must pass through the derived REVIEWER_FLAGS loop, got: "${out}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
798
tests/reviewer-docs-parity.test.cjs
Normal file
798
tests/reviewer-docs-parity.test.cjs
Normal file
@@ -0,0 +1,798 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Documentation parity for the declared reviewer roster (#2800; closes #2781, #2272).
|
||||
*
|
||||
* `checkReviewerDocsParity` answers a different question than its sibling
|
||||
* `checkReviewerLaneParity`: not "what runs" but "what is documented". #2781 was a real
|
||||
* regression this gate exists to prevent — `--kimi-code` landed in `docs/COMMANDS.md` and never
|
||||
* reached its four locale mirrors. Every synthetic-divergence row below feeds a targeted defect
|
||||
* to the pure checker and asserts the specific typed violation, per CONTRIBUTING.md "Tests assert
|
||||
* on typed structured values" — never on rendered prose.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const fc = require('fast-check');
|
||||
|
||||
const {
|
||||
REVIEWER_LANES,
|
||||
DOCS_PARITY_VIOLATION,
|
||||
checkReviewerDocsParity,
|
||||
} = require('../gsd-core/bin/lib/review-lane-descriptor.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
/** Every declared flag, in descriptor order (13 across 12 lanes — antigravity carries two). */
|
||||
const ALL_FLAGS = REVIEWER_LANES.flatMap((l) => l.flags);
|
||||
/** Every declared reviewsSection title. */
|
||||
const ALL_TITLES = REVIEWER_LANES.map((l) => l.reviewsSection);
|
||||
|
||||
/** Backtick every flag — the `COMMANDS.md` table-cell shape. */
|
||||
function backtickAll(flags) {
|
||||
return flags.map((f) => `\`${f}\``).join(' ');
|
||||
}
|
||||
|
||||
/** Bracket every flag — the `FEATURES.md` signature-line shape. */
|
||||
function bracketAll(flags) {
|
||||
return flags.map((f) => `[${f}]`).join(' ');
|
||||
}
|
||||
|
||||
/** A COMMANDS.md-shaped fixture: table cell listing flags, no signature line. */
|
||||
function commandsDoc(flags) {
|
||||
return [
|
||||
'### `/gsd-review`',
|
||||
'',
|
||||
`Reviewer flags: ${backtickAll(flags)}`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** A FEATURES.md-shaped fixture: signature line + Purpose line naming every title. */
|
||||
function featuresDoc({ sigFlags = ALL_FLAGS, titles = ALL_TITLES, blanksBetween = 1 } = {}) {
|
||||
const lines = [
|
||||
`**Command:** \`/gsd-review --phase N ${bracketAll(sigFlags)} [--all]\``,
|
||||
];
|
||||
for (let i = 0; i < blanksBetween; i += 1) lines.push('');
|
||||
lines.push(`**Purpose:** Invoke ${titles.join(', ')} to independently review phase plans.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
describe('reviewer docs parity — flag arm', () => {
|
||||
test('a doc listing every declared flag is clean', () => {
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: commandsDoc(ALL_FLAGS) } });
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.deepStrictEqual(r.violations, []);
|
||||
});
|
||||
|
||||
test('the shipped locale drift is detected', () => {
|
||||
const missing = ALL_FLAGS.filter((f) => f !== '--kimi-code');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: commandsDoc(missing) } });
|
||||
assert.deepStrictEqual(
|
||||
r.violations,
|
||||
[{ reason: DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING, doc: 'd', subject: '--kimi-code' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('every missing flag is named, not just the first', () => {
|
||||
const missing = ALL_FLAGS.filter((f) => f !== '--gemini' && f !== '--qwen');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: commandsDoc(missing) } });
|
||||
assert.strictEqual(r.violations.length, 2);
|
||||
const subjects = r.violations.map((v) => v.subject).sort();
|
||||
assert.deepStrictEqual(subjects, ['--gemini', '--qwen']);
|
||||
for (const v of r.violations) {
|
||||
assert.strictEqual(v.reason, DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING);
|
||||
}
|
||||
});
|
||||
|
||||
test('a section with no flags at all reports every lane', () => {
|
||||
const doc = ['### `/gsd-review`', '', 'Some prose with no flags.', ''].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.strictEqual(r.violations.length, ALL_FLAGS.length);
|
||||
assert.ok(r.violations.every((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING));
|
||||
});
|
||||
|
||||
test('boundary: one flag short of the full roster', () => {
|
||||
const missing = ALL_FLAGS.slice(0, ALL_FLAGS.length - 1);
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: commandsDoc(missing) } });
|
||||
assert.strictEqual(r.violations.length, 1);
|
||||
});
|
||||
|
||||
test('a dual-flag lane requires both tokens present', () => {
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: commandsDoc(ALL_FLAGS) } });
|
||||
assert.strictEqual(r.ok, true);
|
||||
});
|
||||
|
||||
test('a dual-flag lane missing one alias is caught', () => {
|
||||
const missing = ALL_FLAGS.filter((f) => f !== '--agy');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: commandsDoc(missing) } });
|
||||
assert.deepStrictEqual(
|
||||
r.violations,
|
||||
[{ reason: DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING, doc: 'd', subject: '--agy' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('bracketed flags satisfy the gate too', () => {
|
||||
const rest = ALL_FLAGS.filter((f) => f !== '--gemini');
|
||||
const doc = [
|
||||
'### `/gsd-review`',
|
||||
'',
|
||||
`Reviewer flags: [--gemini] ${backtickAll(rest)}`,
|
||||
'',
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.strictEqual(r.ok, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewer docs parity — signature arm', () => {
|
||||
test('a signature line is held to the full roster', () => {
|
||||
const doc = [
|
||||
backtickAll(ALL_FLAGS),
|
||||
'',
|
||||
'**Command:** `/gsd-review --phase N [--gemini] [--all]`',
|
||||
'',
|
||||
`**Purpose:** ${ALL_TITLES.join(', ')}.`,
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
const omitted = ALL_FLAGS.filter((f) => f !== '--gemini');
|
||||
assert.strictEqual(r.violations.length, omitted.length);
|
||||
assert.ok(r.violations.every((v) => v.reason === DOCS_PARITY_VIOLATION.SIGNATURE_FLAG_MISSING));
|
||||
assert.deepStrictEqual(
|
||||
r.violations.map((v) => v.subject).sort(),
|
||||
[...omitted].sort(),
|
||||
);
|
||||
assert.ok(
|
||||
!r.violations.some((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING),
|
||||
'body already backticks every flag, so no DOC_FLAG_MISSING should fire',
|
||||
);
|
||||
});
|
||||
|
||||
test('an undeclared bracketed flag on the signature is reported', () => {
|
||||
const doc = [
|
||||
backtickAll(ALL_FLAGS),
|
||||
'',
|
||||
`**Command:** \`/gsd-review --phase N ${bracketAll(ALL_FLAGS)} [--acme] [--all]\``,
|
||||
'',
|
||||
`**Purpose:** ${ALL_TITLES.join(', ')}.`,
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.deepStrictEqual(
|
||||
r.violations,
|
||||
[{ reason: DOCS_PARITY_VIOLATION.DOC_FLAG_UNDECLARED, doc: 'd', subject: '--acme' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('the --all control flag is inert on the signature', () => {
|
||||
const doc = [
|
||||
backtickAll(ALL_FLAGS),
|
||||
'',
|
||||
`**Command:** \`/gsd-review --phase N ${bracketAll(ALL_FLAGS)} [--all]\``,
|
||||
'',
|
||||
`**Purpose:** ${ALL_TITLES.join(', ')}.`,
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.ok(
|
||||
!r.violations.some((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_FLAG_UNDECLARED && v.subject === '--all'),
|
||||
);
|
||||
});
|
||||
|
||||
test('a doc with no signature line skips the signature arm', () => {
|
||||
const doc = [
|
||||
'| Command | Description |',
|
||||
'| --- | --- |',
|
||||
`| /gsd-review | ${backtickAll(ALL_FLAGS)} |`,
|
||||
'',
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.ok(!r.violations.some((v) => v.reason === DOCS_PARITY_VIOLATION.SIGNATURE_FLAG_MISSING));
|
||||
assert.ok(!r.violations.some((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_TITLE_MISSING));
|
||||
assert.strictEqual(r.ok, true, 'flag arm alone should be satisfied by the table cell');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewer docs parity — title arm', () => {
|
||||
test('a Purpose line naming every title is clean', () => {
|
||||
const doc = featuresDoc();
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.ok(!r.violations.some((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_TITLE_MISSING));
|
||||
});
|
||||
|
||||
test('the purpose-line drift is detected', () => {
|
||||
const titles = ALL_TITLES.filter((t) => t !== 'Kimi Code');
|
||||
const doc = featuresDoc({ titles });
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
const titleViolations = r.violations.filter((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_TITLE_MISSING);
|
||||
assert.deepStrictEqual(titleViolations, [
|
||||
{ reason: DOCS_PARITY_VIOLATION.DOC_TITLE_MISSING, doc: 'd', subject: 'Kimi Code' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('titles are matched literally, not as a regex', () => {
|
||||
// Proves the `.` in `llama.cpp` is escaped: `llamaXcpp` must NOT satisfy it.
|
||||
const titles = ALL_TITLES.map((t) => (t === 'llama.cpp' ? 'llamaXcpp' : t));
|
||||
const doc = featuresDoc({ titles });
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
const titleViolations = r.violations.filter((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_TITLE_MISSING);
|
||||
assert.deepStrictEqual(titleViolations, [
|
||||
{ reason: DOCS_PARITY_VIOLATION.DOC_TITLE_MISSING, doc: 'd', subject: 'llama.cpp' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('blank lines between signature and purpose are skipped', () => {
|
||||
const doc = featuresDoc({ blanksBetween: 3 });
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.ok(!r.violations.some((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_TITLE_MISSING));
|
||||
});
|
||||
|
||||
test('a signature as the last line skips the title arm only', () => {
|
||||
const doc = [
|
||||
backtickAll(ALL_FLAGS),
|
||||
'',
|
||||
`**Command:** \`/gsd-review --phase N ${bracketAll(ALL_FLAGS)} [--all]\``,
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.ok(!r.violations.some((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_TITLE_MISSING));
|
||||
assert.ok(!r.violations.some((v) => v.reason === DOCS_PARITY_VIOLATION.SIGNATURE_FLAG_MISSING));
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewer docs parity — table row arm', () => {
|
||||
test('deletingALaneTableRowIsCaughtEvenWhenAForwardingRowListsEveryFlag', () => {
|
||||
// Regression fixture for #2781: `docs/COMMANDS.md` and its locale mirrors carry a forwarding
|
||||
// row that lists every flag in its THIRD cell, satisfying the file-wide flag arm on its own.
|
||||
// The actual per-lane table row is what documents a lane, and deleting it — here `--kimi-code`
|
||||
// — is exactly the regression this arm exists to catch.
|
||||
const laneRows = REVIEWER_LANES.filter((l) => l.slug !== 'kimi-code').map(
|
||||
(l) => `| ${l.flags.map((f) => `\`${f}\``).join(' / ')} | ${l.reviewsSection} review |`,
|
||||
);
|
||||
const doc = [
|
||||
'### `/gsd-review`',
|
||||
'',
|
||||
'| Flag | Description | Notes |',
|
||||
'| --- | --- | --- |',
|
||||
`| Reviewer flags | No | ${backtickAll(ALL_FLAGS)} |`,
|
||||
...laneRows,
|
||||
'',
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
const tableRowViolations = r.violations.filter(
|
||||
(v) => v.reason === DOCS_PARITY_VIOLATION.TABLE_ROW_MISSING,
|
||||
);
|
||||
assert.deepStrictEqual(tableRowViolations, [
|
||||
{ reason: DOCS_PARITY_VIOLATION.TABLE_ROW_MISSING, doc: 'd', subject: '--kimi-code' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('aDualFlagLaneRowSatisfiesBothItsFlags', () => {
|
||||
// A single row can declare TWO flags in its first cell — `--agy` / `--antigravity` — and must
|
||||
// satisfy both without a second, separate row.
|
||||
const laneRows = REVIEWER_LANES.map(
|
||||
(l) => `| ${l.flags.map((f) => `\`${f}\``).join(' / ')} | ${l.reviewsSection} review |`,
|
||||
);
|
||||
const doc = [
|
||||
'### `/gsd-review`',
|
||||
'',
|
||||
'| Flag | Description |',
|
||||
'| --- | --- |',
|
||||
...laneRows,
|
||||
'',
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.ok(
|
||||
!r.violations.some((v) => v.reason === DOCS_PARITY_VIOLATION.TABLE_ROW_MISSING),
|
||||
'a two-flag first cell must satisfy both flags, including antigravity/agy',
|
||||
);
|
||||
});
|
||||
|
||||
test('aForwardingRowAloneDoesNotSatisfyTheTableArm', () => {
|
||||
const doc = [
|
||||
'### `/gsd-review`',
|
||||
'',
|
||||
'| Flag | Description | Notes |',
|
||||
'| --- | --- | --- |',
|
||||
`| Reviewer flags | No | ${backtickAll(ALL_FLAGS)} |`,
|
||||
'',
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
// The forwarding row's flags sit in cell 3, never cell 1, so the doc has NO per-lane table at
|
||||
// all (rowFlags.size === 0) and arm 4 must not fire. Pinned as zero violations of this reason
|
||||
// rather than "no violations at all": if the guard were dropped and the arm fired
|
||||
// unconditionally, every one of the 13 declared flags would report TABLE_ROW_MISSING (none of
|
||||
// them sit in a first cell here), so this assertion would immediately fail.
|
||||
const tableRowViolations = r.violations.filter(
|
||||
(v) => v.reason === DOCS_PARITY_VIOLATION.TABLE_ROW_MISSING,
|
||||
);
|
||||
assert.deepStrictEqual(tableRowViolations, []);
|
||||
});
|
||||
|
||||
test('aFeaturesShapedDocIsUnaffectedByTheTableArm', () => {
|
||||
const doc = featuresDoc();
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.ok(!r.violations.some((v) => v.reason === DOCS_PARITY_VIOLATION.TABLE_ROW_MISSING));
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewer docs parity — combination', () => {
|
||||
test('a clean doc does not mask a dirty one', () => {
|
||||
const clean = commandsDoc(ALL_FLAGS);
|
||||
const dirty = commandsDoc(ALL_FLAGS.filter((f) => f !== '--codex'));
|
||||
const r = checkReviewerDocsParity({
|
||||
descriptor: REVIEWER_LANES,
|
||||
docs: { 'docs/COMMANDS.md': clean, 'docs/ja-JP/COMMANDS.md': dirty },
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
r.violations,
|
||||
[{ reason: DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING, doc: 'docs/ja-JP/COMMANDS.md', subject: '--codex' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('flag and title violations coexist', () => {
|
||||
const titles = ALL_TITLES.filter((t) => t !== 'Qwen');
|
||||
const doc = featuresDoc({ sigFlags: ALL_FLAGS.filter((f) => f !== '--qwen'), titles });
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
const reasons = r.violations.map((v) => v.reason);
|
||||
assert.ok(reasons.includes(DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING));
|
||||
assert.ok(reasons.includes(DOCS_PARITY_VIOLATION.DOC_TITLE_MISSING));
|
||||
});
|
||||
|
||||
test('an absent section skips without disabling others', () => {
|
||||
const noSection = ['# Some other doc', '', 'Nothing about reviewers here.', ''].join('\n');
|
||||
const clean = commandsDoc(ALL_FLAGS);
|
||||
const dirty = commandsDoc(ALL_FLAGS.filter((f) => f !== '--cursor'));
|
||||
const r = checkReviewerDocsParity({
|
||||
descriptor: REVIEWER_LANES,
|
||||
docs: { skip: noSection, clean, dirty },
|
||||
});
|
||||
assert.deepStrictEqual(r.skipped, ['skip']);
|
||||
assert.deepStrictEqual(
|
||||
r.violations,
|
||||
[{ reason: DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING, doc: 'dirty', subject: '--cursor' }],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewer docs parity — not-corruption (must NOT fire)', () => {
|
||||
test('a flag outside the section does not satisfy the gate', () => {
|
||||
const rest = ALL_FLAGS.filter((f) => f !== '--codex');
|
||||
const doc = [
|
||||
'### `/gsd-review`',
|
||||
'',
|
||||
`Reviewer flags: ${backtickAll(rest)}`,
|
||||
'',
|
||||
'```bash',
|
||||
'/gsd-plan-review-convergence 3 --codex',
|
||||
'```',
|
||||
'',
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.deepStrictEqual(
|
||||
r.violations,
|
||||
[{ reason: DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING, doc: 'd', subject: '--codex' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('token matching is bounded', () => {
|
||||
const rest = ALL_FLAGS.filter((f) => f !== '--claude');
|
||||
const doc = commandsDoc(rest) + '\n`--claude-foo`\n';
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.deepStrictEqual(
|
||||
r.violations,
|
||||
[{ reason: DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING, doc: 'd', subject: '--claude' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('translated descriptions do not fail', () => {
|
||||
const doc = [
|
||||
'### `/gsd-review`',
|
||||
'',
|
||||
`Invoca CLIs de IA externas: ${backtickAll(ALL_FLAGS)}`,
|
||||
'',
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
assert.strictEqual(r.ok, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewer docs parity — HTML comment stripping (CodeQL js/incomplete-multi-character-sanitization)', () => {
|
||||
// stripNonProse's single-line-comment branch used to run `working.replace(/<!--[\s\S]*?-->/g,
|
||||
// '')` exactly ONCE per line. A single `.replace(/g, '')` pass only removes matches found in the
|
||||
// ORIGINAL string; it never rescans the text IT JUST PRODUCED. So when removing one self-
|
||||
// contained `<!--...-->` span joins the fragments on either side of it into a brand-new,
|
||||
// complete `<!--...-->` span, that new span survives the pass verbatim — including whatever
|
||||
// reviewer-flag row is wrapped inside it — and the row is (wrongly) counted as documented. This
|
||||
// is the classic "<scr<script>ipt>" class of defect (CodeQL js/incomplete-multi-character-
|
||||
// sanitization) applied to HTML comments instead of script tags. The fix iterates the same
|
||||
// regex to a fixed point, then treats anything still starting with a bare `<!--` as an
|
||||
// unterminated opener carried forward via `inComment`.
|
||||
const NON_KIMI_ROWS = ALL_FLAGS.filter((f) => f !== '--kimi-code').map((f) => `| \`${f}\` | d |`);
|
||||
|
||||
test('aNestedCommentCannotSmuggleAFlagPastTheStrip', () => {
|
||||
// `<!-` + `<!--DUMMY-->` + `-| `--kimi-code` | d |-->`: one pass strips the self-contained
|
||||
// `<!--DUMMY-->` in the middle, which joins the leftover `<!-` and `-` into a brand-new
|
||||
// `<!--` opener immediately followed by the kimi-code row and a real `-->` closer. A
|
||||
// single-pass strip leaves that whole new span — row included — untouched in the output, so
|
||||
// the row reads as documented. Regressed exactly here: verified against the pre-fix
|
||||
// single-pass implementation, only `TABLE_ROW_MISSING` fired and `DOC_FLAG_MISSING` did not.
|
||||
const joinLine = '<!-<!--DUMMY-->-| `--kimi-code` | d |-->';
|
||||
const doc = ['### `/gsd-review`', '', ...NON_KIMI_ROWS, '', joinLine, ''].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
const kimi = r.violations.filter((v) => v.subject === '--kimi-code');
|
||||
assert.ok(
|
||||
kimi.some((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING)
|
||||
|| kimi.some((v) => v.reason === DOCS_PARITY_VIOLATION.TABLE_ROW_MISSING),
|
||||
'--kimi-code must still be reported missing once the join-trick comment is fully stripped',
|
||||
);
|
||||
});
|
||||
|
||||
test('anUnterminatedCommentOpenerSwallowsTheRestOfTheDocument', () => {
|
||||
const doc = [
|
||||
'### `/gsd-review`',
|
||||
'',
|
||||
...NON_KIMI_ROWS,
|
||||
'',
|
||||
'<!-- open, never closed',
|
||||
'| `--kimi-code` | d |',
|
||||
'',
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
const kimi = r.violations.filter((v) => v.subject === '--kimi-code');
|
||||
assert.ok(kimi.length > 0, '--kimi-code sits inside an open comment and must be reported missing');
|
||||
});
|
||||
|
||||
test('aCommentClosedOnALaterLineResumesCorrectly', () => {
|
||||
// Pins that the fix does not over-strip: prose AFTER a comment's real close is real prose
|
||||
// again, not swallowed along with the comment.
|
||||
const doc = [
|
||||
'### `/gsd-review`',
|
||||
'',
|
||||
...NON_KIMI_ROWS,
|
||||
'',
|
||||
'<!-- opening',
|
||||
'hidden line',
|
||||
'-->',
|
||||
'| `--kimi-code` | d |',
|
||||
'',
|
||||
].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
const kimi = r.violations.filter((v) => v.subject === '--kimi-code');
|
||||
assert.deepStrictEqual(kimi, [], 'content after a real comment close must not be treated as commented out');
|
||||
});
|
||||
|
||||
test('multipleIndependentCommentsOnOneLineAreAllStripped', () => {
|
||||
const line = 'A <!-- first --> B <!-- | `--kimi-code` | d | -->';
|
||||
const doc = ['### `/gsd-review`', '', ...NON_KIMI_ROWS, '', line, ''].join('\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
const kimi = r.violations.filter((v) => v.subject === '--kimi-code');
|
||||
assert.ok(kimi.length > 0, 'both independent comment spans on one line must be stripped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewer docs parity — hostile and malformed input', () => {
|
||||
test('an empty docs map is not a clean bill of health', () => {
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: {} });
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.strictEqual(r.violations.length, ALL_FLAGS.length);
|
||||
assert.ok(r.violations.every((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING));
|
||||
});
|
||||
|
||||
test('an absent docs map degrades to violations', () => {
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES });
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.ok(Array.isArray(r.violations));
|
||||
});
|
||||
|
||||
test('non-string doc values are reported, not silently skipped', () => {
|
||||
for (const bad of [null, 0, [], {}, true]) {
|
||||
assert.doesNotThrow(() => {
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { bad } });
|
||||
assert.deepStrictEqual(
|
||||
r.violations,
|
||||
[{ reason: DOCS_PARITY_VIOLATION.DOC_UNREADABLE, doc: 'bad', subject: typeof bad }],
|
||||
);
|
||||
assert.ok(!r.skipped.includes('bad'), `expected ${JSON.stringify(bad)} NOT to be silently skipped`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty string doc is skipped, unlike a non-string doc', () => {
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { empty: '' } });
|
||||
assert.deepStrictEqual(r.skipped, ['empty']);
|
||||
assert.ok(!r.violations.some((v) => v.reason === DOCS_PARITY_VIOLATION.DOC_UNREADABLE));
|
||||
|
||||
const nonString = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { bad: null } });
|
||||
assert.deepStrictEqual(nonString.skipped, []);
|
||||
assert.deepStrictEqual(
|
||||
nonString.violations,
|
||||
[{ reason: DOCS_PARITY_VIOLATION.DOC_UNREADABLE, doc: 'bad', subject: 'object' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('a DOC_UNREADABLE violation never masks a real one', () => {
|
||||
const missing = ALL_FLAGS.filter((f) => f !== '--kimi-code');
|
||||
const r = checkReviewerDocsParity({
|
||||
descriptor: REVIEWER_LANES,
|
||||
docs: { bad: null, good: commandsDoc(missing) },
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
r.violations,
|
||||
[
|
||||
{ reason: DOCS_PARITY_VIOLATION.DOC_UNREADABLE, doc: 'bad', subject: 'object' },
|
||||
{ reason: DOCS_PARITY_VIOLATION.DOC_FLAG_MISSING, doc: 'good', subject: '--kimi-code' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a non-array descriptor is reported, not thrown', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
checkReviewerDocsParity({ descriptor: 'nope', docs: { d: commandsDoc(ALL_FLAGS) } });
|
||||
});
|
||||
});
|
||||
|
||||
test('a malformed lane is named', () => {
|
||||
const r = checkReviewerDocsParity({
|
||||
descriptor: [null, 'not-an-object', ...REVIEWER_LANES],
|
||||
docs: { d: commandsDoc(ALL_FLAGS) },
|
||||
});
|
||||
const malformed = r.violations.filter((v) => v.reason === DOCS_PARITY_VIOLATION.MALFORMED_LANE);
|
||||
assert.strictEqual(malformed.length, 2);
|
||||
});
|
||||
|
||||
test('a prototype-key slug is inert', () => {
|
||||
const protoLane = { ...REVIEWER_LANES[0], slug: '__proto__', flags: ['--proto-lane'] };
|
||||
assert.doesNotThrow(() => {
|
||||
checkReviewerDocsParity({
|
||||
descriptor: [...REVIEWER_LANES, protoLane],
|
||||
docs: { d: commandsDoc([...ALL_FLAGS, '--proto-lane']) },
|
||||
});
|
||||
});
|
||||
assert.strictEqual(({}).polluted, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewer docs parity — cross-platform (CRLF)', () => {
|
||||
test('parity is CRLF-insensitive', () => {
|
||||
const doc = commandsDoc(ALL_FLAGS);
|
||||
const crlf = doc.split('\n').join('\r\n');
|
||||
const lf = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: doc } });
|
||||
const cr = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: crlf } });
|
||||
assert.deepStrictEqual(cr, lf);
|
||||
});
|
||||
|
||||
test('a divergence is still caught under CRLF', () => {
|
||||
const missing = ALL_FLAGS.filter((f) => f !== '--kimi-code');
|
||||
const crlf = commandsDoc(missing).split('\n').join('\r\n');
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { d: crlf } });
|
||||
assert.strictEqual(r.violations.length, 1);
|
||||
assert.strictEqual(r.violations[0].subject, '--kimi-code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewer docs parity — independence and properties', () => {
|
||||
const FC = { seed: 20260730, numRuns: 200 };
|
||||
|
||||
test('repeated evaluation is stable', () => {
|
||||
const input = { descriptor: REVIEWER_LANES, docs: { d: commandsDoc(ALL_FLAGS) } };
|
||||
const a = checkReviewerDocsParity(input);
|
||||
const b = checkReviewerDocsParity(input);
|
||||
assert.deepStrictEqual(a, b);
|
||||
});
|
||||
|
||||
test('verdict is independent of doc key insertion order', () => {
|
||||
const dirty = commandsDoc(ALL_FLAGS.filter((f) => f !== '--gemini'));
|
||||
const clean = commandsDoc(ALL_FLAGS);
|
||||
const forward = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { a: dirty, b: clean } });
|
||||
const reversed = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: { b: clean, a: dirty } });
|
||||
const sortViolations = (r) =>
|
||||
r.violations.map((v) => `${v.reason}:${v.doc}:${v.subject}`).sort();
|
||||
assert.deepStrictEqual(sortViolations(forward), sortViolations(reversed));
|
||||
});
|
||||
|
||||
test('ok always agrees with violations and the function never throws', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// Widened from the default (~10 chars) so the generator can occasionally reach
|
||||
// pathologically long keys/values, not just short ones — see the dedicated
|
||||
// pathological-length case below for the deterministic ~100k-char regression guard.
|
||||
fc.dictionary(fc.string({ maxLength: 5000 }), fc.anything()),
|
||||
fc.anything(),
|
||||
(docs, descriptor) => {
|
||||
let r;
|
||||
try {
|
||||
r = checkReviewerDocsParity({ descriptor, docs });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof r.ok === 'boolean' &&
|
||||
Array.isArray(r.violations) &&
|
||||
Array.isArray(r.skipped) &&
|
||||
r.ok === (r.violations.length === 0)
|
||||
);
|
||||
},
|
||||
),
|
||||
FC,
|
||||
);
|
||||
});
|
||||
|
||||
// #2800 review finding: the "never throws" property above uses generators that top out
|
||||
// around 5,000 chars, so it could never structurally reach the ~100k-char threshold that,
|
||||
// until fixed, made the implementation throw `SyntaxError` from `new RegExp` (the fix
|
||||
// switched to literal `String.includes`). This case pins that specific regression with a
|
||||
// deterministic, explicit pathologically-long descriptor rather than relying on the property
|
||||
// generator to stumble into it.
|
||||
test('neverThrowsOnPathologicallyLongDeclaredStrings', () => {
|
||||
const pathologicalLane = {
|
||||
slug: 'pathological',
|
||||
flags: ['--' + 'a'.repeat(200000)],
|
||||
reviewsSection: 'T'.repeat(200000),
|
||||
};
|
||||
const doc = '/gsd-review some prose mentioning the command.';
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = checkReviewerDocsParity({ descriptor: [pathologicalLane], docs: { d: doc } });
|
||||
});
|
||||
assert.strictEqual(typeof result.ok, 'boolean');
|
||||
assert.ok(Array.isArray(result.violations));
|
||||
assert.ok(Array.isArray(result.skipped));
|
||||
});
|
||||
|
||||
test('the reason enum is locked', () => {
|
||||
assert.deepStrictEqual(Object.keys(DOCS_PARITY_VIOLATION).sort(), [
|
||||
'DOC_FLAG_MISSING',
|
||||
'DOC_FLAG_UNDECLARED',
|
||||
'DOC_TITLE_MISSING',
|
||||
'DOC_UNREADABLE',
|
||||
'MALFORMED_LANE',
|
||||
'SIGNATURE_FLAG_MISSING',
|
||||
'TABLE_ROW_MISSING',
|
||||
]);
|
||||
assert.ok(Object.isFrozen(DOCS_PARITY_VIOLATION));
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewer docs parity — the shipped repo', () => {
|
||||
const DOC_PATHS = [
|
||||
'docs/COMMANDS.md',
|
||||
'docs/ja-JP/COMMANDS.md',
|
||||
'docs/ko-KR/COMMANDS.md',
|
||||
'docs/pt-BR/COMMANDS.md',
|
||||
'docs/zh-CN/COMMANDS.md',
|
||||
'docs/FEATURES.md',
|
||||
'docs/ja-JP/FEATURES.md',
|
||||
'docs/ko-KR/FEATURES.md',
|
||||
'docs/pt-BR/FEATURES.md',
|
||||
'docs/zh-CN/FEATURES.md',
|
||||
];
|
||||
|
||||
function loadShippedDocs() {
|
||||
const docs = {};
|
||||
for (const rel of DOC_PATHS) {
|
||||
docs[rel] = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
|
||||
test('the shipped descriptor is non-empty', () => {
|
||||
// Guards the vacuous-truth failure mode: an empty roster trivially satisfies every check below.
|
||||
assert.ok(REVIEWER_LANES.length >= 12, 'expected at least the 12 shipped lanes');
|
||||
});
|
||||
|
||||
test('the shipped docs satisfy reviewer lane parity', () => {
|
||||
const r = checkReviewerDocsParity({ descriptor: REVIEWER_LANES, docs: loadShippedDocs() });
|
||||
assert.deepStrictEqual(
|
||||
r.violations,
|
||||
[],
|
||||
`shipped docs must satisfy reviewer docs parity; got: ${JSON.stringify(r.violations)}`,
|
||||
);
|
||||
assert.strictEqual(r.ok, true);
|
||||
// Bounded, not merely `.includes(...)`: an unbounded skipped set is exactly how a doc
|
||||
// silently drops out of coverage — if a real shipped doc lost its `/gsd-review` marker
|
||||
// (accidentally or via a bad edit), `checkReviewerDocsParity` would add it to `skipped`
|
||||
// instead of gating it, and an `.includes()`-only assertion would still pass green with
|
||||
// that doc no longer checked at all. Asserting the exact set closes that gap.
|
||||
assert.deepStrictEqual(
|
||||
r.skipped.slice().sort(),
|
||||
['docs/pt-BR/FEATURES.md'].sort(),
|
||||
'the skipped set must be EXACTLY this one known stub (a 77-line doc with no /gsd-review ' +
|
||||
'section) — any other entry means a real doc silently dropped out of coverage',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unreadable doc fails loudly', (t) => {
|
||||
// Exercises the REAL doc-loading path (`loadShippedDocs`, the same helper the integration
|
||||
// test above drives) rather than invoking the monkeypatched mock directly — calling the mock
|
||||
// proves nothing about the actual reader and would pass regardless of behavior.
|
||||
const original = fs.readFileSync;
|
||||
t.after(() => {
|
||||
fs.readFileSync = original;
|
||||
});
|
||||
const targetPath = path.join(ROOT, 'docs', 'COMMANDS.md');
|
||||
fs.readFileSync = (p, ...rest) => {
|
||||
if (p === targetPath) throw new Error('injected read failure');
|
||||
return original(p, ...rest);
|
||||
};
|
||||
assert.throws(() => {
|
||||
loadShippedDocs();
|
||||
}, /injected read failure/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review-lane flags — emitted shape', () => {
|
||||
const cp = require('node:child_process');
|
||||
const TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const runFlags = (args = []) =>
|
||||
cp.spawnSync(process.execPath, [TOOLS, 'review-lane', 'flags', ...args], { encoding: 'utf8' });
|
||||
|
||||
test('emitsEveryDeclaredFlagInDescriptorOrder', () => {
|
||||
const r = runFlags();
|
||||
assert.strictEqual(r.status, 0);
|
||||
const lines = r.stdout.split('\n').filter(Boolean);
|
||||
assert.deepStrictEqual(lines, REVIEWER_LANES.flatMap((l) => l.flags));
|
||||
});
|
||||
|
||||
test('everyEmittedTokenIsAWellFormedFlag', () => {
|
||||
const r = runFlags();
|
||||
const lines = r.stdout.split('\n').filter(Boolean);
|
||||
assert.ok(lines.length > 0, 'expected at least one emitted flag');
|
||||
for (const line of lines) {
|
||||
assert.match(line, /^--[a-z0-9][a-z0-9-]*$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('emitsNoTokenContainingWhitespaceOrGlobCharacters', () => {
|
||||
const r = runFlags();
|
||||
const lines = r.stdout.split('\n').filter(Boolean);
|
||||
const hostileChars = [' ', '\t', '*', '?', '[', ']', '$', '`', ';', '&', '|', '(', ')'];
|
||||
for (const line of lines) {
|
||||
for (const ch of hostileChars) {
|
||||
assert.ok(!line.includes(ch), `expected ${JSON.stringify(line)} not to contain ${JSON.stringify(ch)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('honorsSelected', () => {
|
||||
const r = runFlags(['--selected', 'antigravity']);
|
||||
assert.strictEqual(r.status, 0);
|
||||
const lines = r.stdout.split('\n').filter(Boolean);
|
||||
assert.deepStrictEqual(lines, ['--antigravity', '--agy']);
|
||||
});
|
||||
|
||||
test('dropsUnknownSlugsAndExitsZero', () => {
|
||||
const r = runFlags(['--selected', 'nosuchlane']);
|
||||
assert.strictEqual(r.status, 0);
|
||||
assert.deepStrictEqual(r.stdout.split('\n').filter(Boolean), []);
|
||||
});
|
||||
|
||||
test('emitsNoTrailingNewlineWhenEmpty', () => {
|
||||
const r = runFlags(['--selected', 'nosuchlane']);
|
||||
assert.strictEqual(r.stdout, '');
|
||||
});
|
||||
|
||||
test('defaultsToEveryLaneWhenSelectedIsEmpty', () => {
|
||||
const withEmpty = runFlags(['--selected', '']);
|
||||
const withNone = runFlags();
|
||||
assert.strictEqual(withEmpty.status, 0);
|
||||
assert.strictEqual(withEmpty.stdout, withNone.stdout);
|
||||
});
|
||||
|
||||
test('doesNotInterpolateHostileSelectors', () => {
|
||||
const hostile = ';echo pwned;`id`;$(id)';
|
||||
const r = runFlags(['--selected', hostile]);
|
||||
assert.strictEqual(r.status, 0);
|
||||
assert.strictEqual(r.stdout, '');
|
||||
assert.ok(!r.stdout.includes('pwned'));
|
||||
assert.ok(!r.stdout.includes('uid='));
|
||||
});
|
||||
|
||||
test('anUnknownSubcommandErrorsWithoutAStackTrace', () => {
|
||||
const r = cp.spawnSync(process.execPath, [TOOLS, 'review-lane', 'bogus'], { encoding: 'utf8' });
|
||||
const combined = `${r.stdout || ''}${r.stderr || ''}`;
|
||||
assert.match(combined, /flags/);
|
||||
assert.ok(!combined.includes('at Object.'));
|
||||
assert.ok(!combined.includes(' at '));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user