From 1a22ae96520b99d3119ce9d3e5c76b43e60ccc3c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 01:26:46 -0800 Subject: [PATCH] v1.92.68.0 feat(health): Step 1 detects tools with gstack-checks, no check lost (GST-116 PR 2) (#3691) * feat(health): Step 1 detects tools with gstack-checks detect --for health, no check lost (GST-116 PR 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: register the frozen /health detection fixture as free Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: Health Stack normalization and --for refusals (review pass 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(health): keep Health Stack command bytes, honor an empty section, detect from the working directory (review pass 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(health): a multi-line test script is reported, not printed into the line protocol Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(health): Health Stack rows stay markdown like /verify reads them; an empty section names why (fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the PATH-stub cases of the /health no-loss test run on Linux and macOS (windows-latest cannot launch sh stubs) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-checks | 14 +- docs/skills.md | 2 +- health/SKILL.md | 41 ++---- health/SKILL.md.tmpl | 41 ++---- lib/checks.ts | 56 +++++++- package.json | 2 +- test/fixtures/health-detect-legacy.sh | 31 +++++ test/health-detect-no-loss.test.ts | 183 ++++++++++++++++++++++++++ test/helpers/free-fixtures-data.ts | 1 + 12 files changed, 312 insertions(+), 73 deletions(-) create mode 100644 test/fixtures/health-detect-legacy.sh create mode 100644 test/health-detect-no-loss.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cff9b4b5..654ab2b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.92.68.0] - 2026-11-02 + +**`/health` now finds its tools with gstack's detector, the same one `/verify` uses, and a test proves it still finds every check it found before.** /health Step 1 used to carry its own shell block for detecting a type checker, linter, test runner, knip, shellcheck and gbrain, a second copy of the list in `lib/checks.ts`. It now runs `gstack-checks detect --for health`. Scoring, the gbrain sub-score and history are unchanged. GST-116, PR 2 of 2. + +### Changed +- **`gstack-checks detect --for health`** prints one `DIMENSION ()` line per tool, or `DIMENSION none ()` so Step 2 can record the category as skipped with its reason. A `## Health Stack` section in CLAUDE.md still wins row for row, test rows included. Otherwise it applies /health's own rules, including a knip or shellcheck found on PATH and gbrain when `~/.gbrain/config.json` exists. It reads files and PATH and runs nothing. + +### Tests +- `test/health-detect-no-loss.test.ts` runs the old Step 1 block, frozen verbatim in `test/fixtures/health-detect-legacy.sh`, beside the new detector on 16 fixture repos and two PATH and HOME setups. Every check the old block printed must still be found. Dropping the knip or gbrain rule fails the test. + ## [1.92.67.0] - 2026-11-02 **`./setup` no longer starts a handful of processes per skill just to read its name (#2120).** On Windows 11 on ARM64, Git Bash runs under x64 emulation, and its `fork()` can deadlock when a script starts hundreds of short-lived processes in a row. Setup did exactly that: `gstack-patch-names` and setup's skill-linking loops ran `grep | sed | tr` and `basename` for every skill. The reporter counted about 220 process starts on a full install. Thanks to @habassa5, who traced the hang to those loops. diff --git a/VERSION b/VERSION index 61c63e5b8..75a21384c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.67.0 +1.92.68.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 4bcbb97c3..f898c2f84 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.67.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.68.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-checks b/bin/gstack-checks index ffe6ff6c4..574743a6c 100755 --- a/bin/gstack-checks +++ b/bin/gstack-checks @@ -1,14 +1,16 @@ #!/usr/bin/env -S bun --no-env-file --config=/dev/null /** - * gstack-checks — the build-and-verify loop's checks for /verify. Logic: lib/checks.ts. + * gstack-checks — the build-and-verify loop's checks for /verify, and /health's tool detection. Logic: lib/checks.ts. */ import { spawnSync } from 'node:child_process'; import { cancelActive } from '../lib/pregate/supervise'; -import { MAX_FAIL_LINES, TESTS, isTrusted, ledgerPath, load, nothingToRun, readLedger, recordTrust, runChecks, status, type Loaded } from '../lib/checks'; +import { MAX_FAIL_LINES, TESTS, healthDimensions, isTrusted, ledgerPath, load, nothingToRun, readLedger, recordTrust, runChecks, status, type Loaded } from '../lib/checks'; const USAGE = `Usage: gstack-checks [options] detect list the checks run would execute and whether they are trusted (reads files only) + detect --for health /health's dimensions: DIMENSION (), or DIMENSION none + (); a CLAUDE.md Health Stack section wins (reads files and PATH only) trust trust the current command set for this repo (checks, the pregate runner and its selection manifest commands); any change to one of them needs trust again run [--only a,b] [--allow-repo-commands] [--timeout ] @@ -43,6 +45,14 @@ async function main(argv: string[]): Promise { const [verb, ...args] = argv; if (!verb || verb === '--help' || verb === '-h') { process.stdout.write(USAGE); return verb ? 0 : 64; } if (!['detect', 'trust', 'run', 'status'].includes(verb)) { process.stderr.write(USAGE); return 64; } + const mode = flag(args, '--for'); + if (args.includes('--for')) { + if (verb !== 'detect' || mode !== 'health') { process.stderr.write(USAGE); return 64; } + const d = healthDimensions(process.cwd()); + for (const c of d.checks) process.stdout.write(`DIMENSION ${c.dimension} ${c.command} (${c.source})\n`); + for (const m of d.missing) process.stdout.write(`DIMENSION ${m.dimension} none (${m.reason})\n`); + return 0; + } const root = repoRoot(); if (!root) { process.stderr.write('gstack-checks: not inside a git repository\n'); return 3; } const timeoutS = Number(flag(args, '--timeout') ?? 600); diff --git a/docs/skills.md b/docs/skills.md index c121fc89d..335da1795 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -662,7 +662,7 @@ Instead of guessing and patching, it traces data flow, matches against known bug ## `/verify` -`/verify` is the loop you run while you build: change something, run the repo's checks, fix what broke, run again. `gstack-checks detect` finds the checks the repo already declares (a `gstack:verify:` line or a `## Health Stack` section in CLAUDE.md, else package.json scripts, tsconfig, eslint or biome, pyproject, Cargo.toml, go.mod) and never makes one up. `gstack-checks run` runs them plus the tests `/ship`'s pre-gate selects for your change, using pregate's own selector, so a green `/verify` predicts a green pre-gate. +`/verify` is the loop you run while you build: change something, run the repo's checks, fix what broke, run again. `gstack-checks detect` finds the checks the repo already declares (a `gstack:verify:` line or a `## Health Stack` section in CLAUDE.md, else package.json scripts, tsconfig, eslint or biome, pyproject, Cargo.toml, go.mod) and never makes one up. `gstack-checks run` runs them plus the tests `/ship`'s pre-gate selects for your change, using pregate's own selector, so a green `/verify` predicts a green pre-gate. `/health` finds its tools with the same detector (`gstack-checks detect --for health`), which adds its test, knip, shellcheck and gbrain dimensions. It asks once before running any repo command (`gstack-checks trust`) and asks again when a command changes. A touched file no test covers is `UNMAPPED`, a selection that ran zero tests is `ZERO_RUN`, and neither counts as green. A missing tool or service is reported, not worked around. It stops after five runs or when the same failure comes back twice, and sends a stubborn red to `/investigate`. When everything passes, `gstack-checks status` prints `READY_FOR_SHIP tree=` without rerunning anything, and you run `/ship`. It never reviews, bumps versions, pushes or opens a PR. diff --git a/health/SKILL.md b/health/SKILL.md index b6c84d87d..3b1ee34ca 100644 --- a/health/SKILL.md +++ b/health/SKILL.md @@ -115,45 +115,22 @@ When the user types `/health`, run this skill. ## Step 1: Detect Health Stack -Read CLAUDE.md and look for a `## Health Stack` section. If found, parse the tools -listed there and skip auto-detection. - -If no `## Health Stack` section exists, auto-detect available tools: +Detect the tools with gstack's detector: ```bash -# Type checker -[ -f tsconfig.json ] && echo "TYPECHECK: tsc --noEmit" - -# Linter -[ -f biome.json ] || [ -f biome.jsonc ] && echo "LINT: biome check ." -setopt +o nomatch 2>/dev/null || true -ls eslint.config.* .eslintrc.* .eslintrc 2>/dev/null | head -1 | xargs -I{} echo "LINT: eslint ." -[ -f .pylintrc ] || [ -f pyproject.toml ] && grep -q "pylint\|ruff" pyproject.toml 2>/dev/null && echo "LINT: ruff check ." - -# Test runner -[ -f package.json ] && grep -q '"test"' package.json 2>/dev/null && echo "TEST: $(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts.test)" 2>/dev/null)" -[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml 2>/dev/null && echo "TEST: pytest" -[ -f Cargo.toml ] && echo "TEST: cargo test" -[ -f go.mod ] && echo "TEST: go test ./..." - -# Dead code -command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip" -[ -f package.json ] && grep -q '"knip"' package.json 2>/dev/null && echo "DEADCODE: npx knip" - -# Shell linting -command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck" - -# GBrain presence — only report as a dimension if gbrain is actually -# set up; otherwise skip so machines without gbrain aren't penalized. -if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then - echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)" -fi +C="$HOME/.claude/skills/gstack/bin/gstack-checks"; "$C" detect --for health ``` +It reads files and PATH only and runs nothing. A `## Health Stack` section in +CLAUDE.md wins: its rows come back as they are, and auto-detection is skipped. +Each `DIMENSION ()` line is a tool to run in Step 2. +`DIMENSION none ()` means that category is unavailable: record it +as `SKIPPED` with the reason. A `gbrain` command keeps its `timeout 5s`. + Use Glob to search for shell scripts: - `**/*.sh` (shell scripts in the repo) -After auto-detection, present the detected tools via AskUserQuestion: +After auto-detection (no line's source is `CLAUDE.md Health Stack`), present the detected tools via AskUserQuestion: "I detected these health check tools for this project: diff --git a/health/SKILL.md.tmpl b/health/SKILL.md.tmpl index 47f83551e..da8c9c700 100644 --- a/health/SKILL.md.tmpl +++ b/health/SKILL.md.tmpl @@ -42,45 +42,22 @@ When the user types `/health`, run this skill. ## Step 1: Detect Health Stack -Read CLAUDE.md and look for a `## Health Stack` section. If found, parse the tools -listed there and skip auto-detection. - -If no `## Health Stack` section exists, auto-detect available tools: +Detect the tools with gstack's detector: ```bash -# Type checker -[ -f tsconfig.json ] && echo "TYPECHECK: tsc --noEmit" - -# Linter -[ -f biome.json ] || [ -f biome.jsonc ] && echo "LINT: biome check ." -setopt +o nomatch 2>/dev/null || true -ls eslint.config.* .eslintrc.* .eslintrc 2>/dev/null | head -1 | xargs -I{} echo "LINT: eslint ." -[ -f .pylintrc ] || [ -f pyproject.toml ] && grep -q "pylint\|ruff" pyproject.toml 2>/dev/null && echo "LINT: ruff check ." - -# Test runner -[ -f package.json ] && grep -q '"test"' package.json 2>/dev/null && echo "TEST: $(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts.test)" 2>/dev/null)" -[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml 2>/dev/null && echo "TEST: pytest" -[ -f Cargo.toml ] && echo "TEST: cargo test" -[ -f go.mod ] && echo "TEST: go test ./..." - -# Dead code -command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip" -[ -f package.json ] && grep -q '"knip"' package.json 2>/dev/null && echo "DEADCODE: npx knip" - -# Shell linting -command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck" - -# GBrain presence — only report as a dimension if gbrain is actually -# set up; otherwise skip so machines without gbrain aren't penalized. -if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then - echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)" -fi +C="{{RUNTIME_ROOT}}/bin/gstack-checks"; "$C" detect --for health ``` +It reads files and PATH only and runs nothing. A `## Health Stack` section in +CLAUDE.md wins: its rows come back as they are, and auto-detection is skipped. +Each `DIMENSION ()` line is a tool to run in Step 2. +`DIMENSION none ()` means that category is unavailable: record it +as `SKIPPED` with the reason. A `gbrain` command keeps its `timeout 5s`. + Use Glob to search for shell scripts: - `**/*.sh` (shell scripts in the repo) -After auto-detection, present the detected tools via AskUserQuestion: +After auto-detection (no line's source is `CLAUDE.md Health Stack`), present the detected tools via AskUserQuestion: "I detected these health check tools for this project: diff --git a/lib/checks.ts b/lib/checks.ts index a73c192d8..5396ba09e 100644 --- a/lib/checks.ts +++ b/lib/checks.ts @@ -53,8 +53,8 @@ export function declaredVerify(claudeMd: string): string | null { return null; } -/** The '## Health Stack' section (/health's own format): '- key: command' rows. 'test' and 'tests' are left to pregate; a repeated key gets -2, -3 so every check has its own name. */ -export function healthStack(claudeMd: string): Check[] { +/** The '## Health Stack' section (/health's own format): '- key: command' rows. 'test' and 'tests' are left to pregate unless keepTests (/health scores them); a repeated key gets -2, -3 so every check has its own name. */ +export function healthStack(claudeMd: string, keepTests = false): Check[] { const lines = claudeMd.split('\n'); const start = lines.findIndex(l => /^##\s+Health Stack\s*$/.test(l)); if (start < 0) return []; @@ -64,7 +64,7 @@ export function healthStack(claudeMd: string): Check[] { for (const line of lines.slice(start + 1)) { if (/^#{1,6}\s/.test(line)) break; const m = /^\s*[-*]\s*([A-Za-z0-9_-]+):\s*(.+?)\s*$/.exec(line); - if (!m || ['test', TESTS].includes(m[1].toLowerCase())) continue; + if (!m || (!keepTests && ['test', TESTS].includes(m[1].toLowerCase()))) continue; const key = m[1].toLowerCase(); let n = seen.get(key) ?? 0; let name = key; @@ -127,6 +127,56 @@ export function detectChecks(root: string): Check[] { }); } +export const HEALTH_DIMENSIONS = ['typecheck', 'lint', 'test', 'deadcode', 'shell', 'gbrain'] as const; +export interface HealthDimensions { checks: Array<{ dimension: string; command: string; source: string }>; missing: Array<{ dimension: string; reason: string }> } + +/** + * /health Step 1, in the directory /health runs from: a Health Stack section row for row (test rows kept; a section with no + * rows still turns auto-detection off), else /health's own presence rules, + * which may use a tool found on PATH (unlike /verify's declared-only manifestChecks). Reads files and PATH; runs nothing. + * test/health-detect-no-loss.test.ts holds these rules to the shell block they replaced. + */ +export function healthDimensions(root: string, env: NodeJS.ProcessEnv = process.env): HealthDimensions { + const claude = read(path.join(root, 'CLAUDE.md')) ?? ''; + const stack = healthStack(claude, true); + if (/^##\s+Health Stack\s*$/m.test(claude)) { + const keys = new Set(stack.map(c => c.name.replace(/-\d+$/, '').replace(/^tests$/, 'test'))); + return { checks: stack.map(c => ({ dimension: c.name, command: c.command, source: c.source })), missing: HEALTH_DIMENSIONS.filter(d => !keys.has(d)).map(dimension => ({ dimension, reason: stack.length ? 'not in the CLAUDE.md Health Stack section' : 'the CLAUDE.md Health Stack section has no "- key: command" rows' })) }; + } + const has = (file: string) => fs.existsSync(path.join(root, file)); + const which = (bin: string) => Bun.which(bin, { PATH: env.PATH ?? '' }) !== null; + const pkgText = read(path.join(root, 'package.json')); + const pyproject = read(path.join(root, 'pyproject.toml')); + let testScript: unknown; + try { testScript = pkgText?.includes('"test"') ? JSON.parse(pkgText).scripts?.test : undefined; } catch { /* an unreadable package.json declares nothing */ } + const listDir = (dir: string) => { try { return fs.readdirSync(dir); } catch { return []; } }; + const shellFiles = ['.', 'scripts', 'bin'].some(dir => listDir(path.join(root, dir)).some(f => f.endsWith('.sh'))); + const found: Array<[string, string, string] | false> = [ + has('tsconfig.json') && ['typecheck', 'tsc --noEmit', 'tsconfig.json'], + (has('biome.json') || has('biome.jsonc')) && ['lint', 'biome check .', 'biome.json'], + listDir(root).some(f => /^(eslint\.config\..+|\.eslintrc(\..+)?)$/.test(f)) && ['lint', 'eslint .', 'eslint config'], + !!pyproject && /pylint|ruff/.test(pyproject) && ['lint', 'ruff check .', 'pyproject.toml'], + typeof testScript === 'string' && testScript !== '' && !/[\r\n]/.test(testScript) && ['test', testScript, 'package.json'], + !!pyproject && pyproject.includes('pytest') && ['test', 'pytest', 'pyproject.toml'], + has('Cargo.toml') && ['test', 'cargo test', 'Cargo.toml'], + has('go.mod') && ['test', 'go test ./...', 'go.mod'], + which('knip') && ['deadcode', 'knip', 'PATH'], + !!pkgText?.includes('"knip"') && ['deadcode', 'npx knip', 'package.json'], + which('shellcheck') && shellFiles && ['shell', 'shellcheck', 'PATH, *.sh in the root, scripts/ or bin/'], + which('gbrain') && !!env.HOME && fs.existsSync(path.join(env.HOME, '.gbrain', 'config.json')) && ['gbrain', 'timeout 5s gbrain doctor --json', 'PATH, ~/.gbrain/config.json'], + ]; + const checks = found.filter((c): c is [string, string, string] => !!c).map(([dimension, command, source]) => ({ dimension, command, source })); + const reasons: Record = { + typecheck: 'no tsconfig.json', + lint: 'no biome.json, eslint config, or pyproject.toml naming ruff or pylint', + test: typeof testScript === 'string' && /[\r\n]/.test(testScript) ? 'the package.json test script spans lines; list it in a CLAUDE.md Health Stack section' : 'no package.json test script, pytest in pyproject.toml, Cargo.toml or go.mod', + deadcode: 'knip is not on PATH or in package.json', + shell: which('shellcheck') ? 'no *.sh file in the root, scripts/ or bin/' : 'shellcheck is not on PATH', + gbrain: 'gbrain is not on PATH or ~/.gbrain/config.json is absent', + }; + return { checks, missing: HEALTH_DIMENSIONS.filter(d => !checks.some(c => c.dimension === d)).map(dimension => ({ dimension, reason: reasons[dimension] })) }; +} + /** Every command a run could execute, read without executing any: the checks, the pregate runner and each selection manifest command. */ export function commandSet(root: string, checks: readonly Check[], config: Pick): string { return sha256(JSON.stringify({ checks: checks.map(c => [c.name, c.command, c.body ?? '']), scripts: packageScripts(root), runner: config.runner ?? detectedRunnerInputs(root), selection: Object.entries(config.selection ?? {}).sort(([a], [b]) => a.localeCompare(b)) })); diff --git a/package.json b/package.json index ba562a6cd..883d131b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.67", + "version": "1.92.68", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/fixtures/health-detect-legacy.sh b/test/fixtures/health-detect-legacy.sh new file mode 100644 index 000000000..6d925861b --- /dev/null +++ b/test/fixtures/health-detect-legacy.sh @@ -0,0 +1,31 @@ +# /health Step 1 auto-detection, frozen verbatim from health/SKILL.md.tmpl at a305302 +# (v1.91.365.0) before /health moved onto gstack-checks detect --for health (GST-116 PR 2). +# test/health-detect-no-loss.test.ts runs it beside the new detector: every line it prints +# must still be found. Do not edit; it is the baseline, not live code. +# Type checker +[ -f tsconfig.json ] && echo "TYPECHECK: tsc --noEmit" + +# Linter +[ -f biome.json ] || [ -f biome.jsonc ] && echo "LINT: biome check ." +setopt +o nomatch 2>/dev/null || true +ls eslint.config.* .eslintrc.* .eslintrc 2>/dev/null | head -1 | xargs -I{} echo "LINT: eslint ." +[ -f .pylintrc ] || [ -f pyproject.toml ] && grep -q "pylint\|ruff" pyproject.toml 2>/dev/null && echo "LINT: ruff check ." + +# Test runner +[ -f package.json ] && grep -q '"test"' package.json 2>/dev/null && echo "TEST: $(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts.test)" 2>/dev/null)" +[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml 2>/dev/null && echo "TEST: pytest" +[ -f Cargo.toml ] && echo "TEST: cargo test" +[ -f go.mod ] && echo "TEST: go test ./..." + +# Dead code +command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip" +[ -f package.json ] && grep -q '"knip"' package.json 2>/dev/null && echo "DEADCODE: npx knip" + +# Shell linting +command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck" + +# GBrain presence — only report as a dimension if gbrain is actually +# set up; otherwise skip so machines without gbrain aren't penalized. +if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then + echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)" +fi diff --git a/test/health-detect-no-loss.test.ts b/test/health-detect-no-loss.test.ts new file mode 100644 index 000000000..9e6f11c59 --- /dev/null +++ b/test/health-detect-no-loss.test.ts @@ -0,0 +1,183 @@ +/** + * /health Step 1 moved onto gstack-checks detect --for health (GST-116 PR 2). The acceptance bar: every check the old + * Step 1 found is still found. The old shell block is frozen verbatim in test/fixtures/health-detect-legacy.sh; each + * fixture repo runs both under the same PATH and HOME, and every legacy (category, command) pair must appear in the + * detector's DIMENSION lines. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { HEALTH_DIMENSIONS, healthDimensions } from '../lib/checks'; +import { runScriptSync } from '../lib/script-invocation'; +import { BIN } from './helpers/pregate-fixture'; + +const LEGACY = path.join(import.meta.dir, 'fixtures', 'health-detect-legacy.sh'); +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'health-detect-')); +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + +const stubs = path.join(tmp, 'stubs'); +fs.mkdirSync(stubs); +for (const tool of ['knip', 'shellcheck', 'gbrain']) { + fs.writeFileSync(path.join(stubs, tool), '#!/bin/sh\nexit 0\n'); + fs.chmodSync(path.join(stubs, tool), 0o755); +} +const gbrainHome = path.join(tmp, 'home-gbrain'); +fs.mkdirSync(path.join(gbrainHome, '.gbrain'), { recursive: true }); +fs.writeFileSync(path.join(gbrainHome, '.gbrain', 'config.json'), '{}\n'); +const bareHome = path.join(tmp, 'home-bare'); +fs.mkdirSync(bareHome); +// The knip, shellcheck and gbrain stubs are extensionless #!/bin/sh files, which windows-latest cannot find or launch +// (windows-free-shard 3, run 41204425554); the cases that need them run on Linux and macOS. +const stubbed = test.skipIf(process.platform === 'win32'); + +const PKG = JSON.stringify({ name: 'fx', scripts: { test: 'vitest run', lint: 'eslint .' }, devDependencies: { knip: '^5.0.0' } }); +const FIXTURES: Record> = { + empty: {}, + tsconfig: { 'tsconfig.json': '{}' }, + 'biome-json': { 'biome.json': '{}' }, + 'biome-jsonc': { 'biome.jsonc': '{}' }, + 'eslint-flat': { 'eslint.config.mjs': 'export default [];' }, + 'eslintrc-json': { '.eslintrc.json': '{}' }, + 'eslintrc-bare': { '.eslintrc': '{}' }, + 'pyproject-ruff-pytest': { 'pyproject.toml': '[tool.ruff]\n[tool.pytest.ini_options]\n' }, + 'pyproject-pylint': { 'pyproject.toml': '[tool.pylint]\n' }, + 'pylintrc-only': { '.pylintrc': '[MASTER]\n' }, + 'package-test-knip': { 'package.json': PKG }, + cargo: { 'Cargo.toml': '[package]\nname = "fx"\n' }, + go: { 'go.mod': 'module fx\n' }, + 'shell-scripts-dir': { 'scripts/build.sh': 'echo hi\n' }, + 'shell-root-and-bin': { 'run.sh': 'echo hi\n', 'bin/tool.sh': 'echo hi\n' }, + everything: { 'tsconfig.json': '{}', 'biome.json': '{}', 'eslint.config.js': '', 'pyproject.toml': 'ruff pytest\n', 'package.json': PKG, 'Cargo.toml': '', 'go.mod': 'module fx\n', 'scripts/a.sh': '' }, +}; + +function repo(name: string, files: Record): string { + const dir = path.join(tmp, 'repos', name); + for (const [file, body] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), body); + } + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +const NORMALIZE: Record = { 'gbrain doctor --json (wrapped in timeout 5s)': 'timeout 5s gbrain doctor --json' }; +const pairs = (lines: Array<[string, string]>) => lines.map(([d, c]) => `${d}: ${c}`).sort(); + +function legacy(dir: string, env: NodeJS.ProcessEnv): string[] { + const r = spawnSync('bash', [LEGACY], { cwd: dir, env, encoding: 'utf8', timeout: 30_000 }); + expect(r.status).toBe(0); + return pairs(r.stdout.split('\n').flatMap(line => { + const m = /^(TYPECHECK|LINT|TEST|DEADCODE|SHELL|GBRAIN): (.+)$/.exec(line); + return m ? [[m[1].toLowerCase(), NORMALIZE[m[2]] ?? m[2]] as [string, string]] : []; + })); +} + +function detector(dir: string, env: NodeJS.ProcessEnv): { found: string[]; none: string[] } { + const r = runScriptSync(BIN('gstack-checks'), ['detect', '--for', 'health'], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }); + expect(r.status).toBe(0); + const lines = (r.stdout ?? '').split('\n').filter(Boolean).map(line => /^DIMENSION (\S+) (.+?) \((.*)\)$/.exec(line)!); + expect(lines.every(Boolean)).toBe(true); + return { found: pairs(lines.filter(m => m[2] !== 'none').map(m => [m[1], m[2]])), none: lines.filter(m => m[2] === 'none').map(m => m[1]) }; +} + +const ENVS = { + 'tools on PATH, gbrain configured': { PATH: `${stubs}${path.delimiter}${process.env.PATH}`, HOME: gbrainHome }, + 'machine PATH, no gbrain config': { PATH: process.env.PATH, HOME: bareHome }, +}; + +describe('/health detection: no check lost (GST-116 PR 2)', () => { + // Value: protects=/health still finds every tool its old Step 1 shell block found; fails_when=a ported rule drops a + // category or command (knip, shellcheck, gbrain, a lint or test rule) for any fixture; why_new=/health moved onto the + // detector in this change; seam=the frozen legacy block run beside the bin; no_claim=that the found tools succeed + for (const [envName, envVars] of Object.entries(ENVS)) { + (envName.startsWith('tools on PATH') ? stubbed : test)(`every legacy line is found: ${envName}`, () => { + const env = { ...process.env, ...envVars }; + let legacyTotal = 0; + for (const [name, files] of Object.entries(FIXTURES)) { + const dir = repo(name, files); + const old = legacy(dir, env); + const now = detector(dir, env); + legacyTotal += old.length; + expect({ name, lost: old.filter(p => !now.found.includes(p)) }).toEqual({ name, lost: [] }); + expect(new Set([...now.found.map(p => p.split(':')[0]), ...now.none])).toEqual(new Set(HEALTH_DIMENSIONS)); + } + expect(legacyTotal).toBeGreaterThan(20); + }); + } + + // Value: protects=the matrix exercises knip, shellcheck and gbrain, not just the manifest rules; fails_when=a stub stops + // being found so the no-loss comparison above runs with those categories empty; why_new=new fixture; seam=none + stubbed('the stubbed run covers every category on both sides', () => { + const env = { ...process.env, ...ENVS['tools on PATH, gbrain configured'] }; + const dir = repo('everything', FIXTURES.everything); + expect(new Set(legacy(dir, env).map(p => p.split(':')[0]))).toEqual(new Set(HEALTH_DIMENSIONS)); + expect(detector(dir, env).none).toEqual([]); + }); + + // Value: protects=a Health Stack section wins row for row, test rows included, and nothing is auto-detected beside it; + // fails_when=health mode drops the test row /verify leaves to pregate, or mixes manifest detection in; why_new=keepTests + // is new; seam=none + test('a Health Stack section is returned as written', () => { + const dir = repo('health-stack', { 'tsconfig.json': '{}', 'CLAUDE.md': '# x\n\n## Health Stack\n\n- typecheck: tsc --noEmit -p app\n- test: bun test\n- deadcode: knip --production\n- shell: shellcheck *.sh scripts/*.sh\n\n## Other\n' }); + const d = healthDimensions(dir, { PATH: stubs, HOME: gbrainHome }); + expect(d.checks.map(c => [c.dimension, c.command])).toEqual([['typecheck', 'tsc --noEmit -p app'], ['test', 'bun test'], ['deadcode', 'knip --production'], ['shell', 'shellcheck *.sh scripts/*.sh']]); + expect(d.missing).toEqual([{ dimension: 'lint', reason: 'not in the CLAUDE.md Health Stack section' }, { dimension: 'gbrain', reason: 'not in the CLAUDE.md Health Stack section' }]); + }); + + // Value: protects=a 'tests' row and repeated keys count as present, so Step 2 never skips a category it runs; + // fails_when=the -N suffix or tests→test normalization regresses and a listed category also prints none; why_new=new; seam=none + test('a tests row and repeated keys are not reported missing', () => { + const dir = repo('health-stack-dupes', { 'CLAUDE.md': '## Health Stack\n\n- tests: bun test\n- lint: eslint .\n- lint: biome check .\n' }); + const d = healthDimensions(dir, { PATH: '', HOME: bareHome }); + expect(d.checks.map(c => c.dimension)).toEqual(['tests', 'lint', 'lint-2']); + expect(d.missing.map(m => m.dimension)).toEqual(['typecheck', 'deadcode', 'shell', 'gbrain']); + }); + + // Value: protects=--for is accepted only as detect --for health; fails_when=run --for health falls through into the run + // path, or a missing or unknown mode is accepted; why_new=new flag; seam=the bin + test('--for is refused outside detect --for health', () => { + for (const args of [['run', '--for', 'health'], ['detect', '--for'], ['detect', '--for', 'verify']]) { + const r = runScriptSync(BIN('gstack-checks'), args, { cwd: tmp, encoding: 'utf8', timeout: 30_000 }); + expect(r.status).toBe(64); + expect(r.stderr ?? '').toContain('Usage: gstack-checks'); + } + }); + + // Value: protects=Health Stack rows read as markdown like gstack:verify: (code-span backticks dropped), an empty section + // still turns auto-detection off and says so, and detection runs where /health runs (a monorepo package); + // fails_when=a section with no rows falls through to auto-detection, or the bin detects from the git top-level; + // why_new=review pass 1 voices; seam=the bin from a subdirectory + test('Health Stack spans, an empty section, and the working directory', () => { + const tick = String.fromCharCode(96); + const spans = healthDimensions(repo('hs-spans', { 'CLAUDE.md': `## Health Stack\n\n- lint: ${tick}eslint .${tick} && ${tick}prettier --check .${tick}\n` }), { PATH: '', HOME: bareHome }); + expect(spans.checks.map(c => c.command)).toEqual(['eslint . && prettier --check .']); + const empty = healthDimensions(repo('hs-empty', { 'tsconfig.json': '{}', 'CLAUDE.md': '## Health Stack\n\nNo health tools are configured.\n' }), { PATH: stubs, HOME: gbrainHome }); + expect(empty.checks).toEqual([]); + expect(empty.missing.map(m => m.dimension)).toEqual([...HEALTH_DIMENSIONS]); + expect(empty.missing[0].reason).toBe('the CLAUDE.md Health Stack section has no "- key: command" rows'); + const mono = repo('monorepo', { 'tsconfig.json': '{}', 'packages/app/package.json': JSON.stringify({ scripts: { test: 'vitest run' } }) }); + expect(spawnSync('git', ['init', '-q'], { cwd: mono, timeout: 30_000 }).status).toBe(0); + const env = { ...process.env, PATH: process.env.PATH, HOME: bareHome }; + const pkg = detector(path.join(mono, 'packages', 'app'), env); + expect(pkg.found).toContain('test: vitest run'); + expect(pkg.none).toContain('typecheck'); + const multi = healthDimensions(repo('multiline-test', { 'package.json': JSON.stringify({ scripts: { test: 'vitest\nDIMENSION lint x (CLAUDE.md Health Stack)' } }) }), { PATH: '', HOME: bareHome }); + expect(multi.checks).toEqual([]); + expect(multi.missing.find(m => m.dimension === 'test')?.reason).toBe('the package.json test script spans lines; list it in a CLAUDE.md Health Stack section'); + const noHome = repo('no-home', { '.gbrain/config.json': '{}' }); + expect(healthDimensions(noHome, { PATH: stubs, HOME: '' }).checks.map(c => c.dimension)).not.toContain('gbrain'); + }); + + // Value: protects=an unavailable category says why, so Step 2 records SKIPPED with a reason; fails_when=a none line + // loses its reason or shellcheck's two causes collapse; why_new=new output; seam=none + stubbed('none lines name the reason', () => { + const empty = healthDimensions(repo('empty', {}), { PATH: '', HOME: bareHome }); + expect(empty.checks).toEqual([]); + expect(empty.missing.find(m => m.dimension === 'shell')?.reason).toBe('shellcheck is not on PATH'); + const noScripts = healthDimensions(repo('tsconfig', FIXTURES.tsconfig), { PATH: stubs, HOME: bareHome }); + expect(noScripts.missing.find(m => m.dimension === 'shell')?.reason).toBe('no *.sh file in the root, scripts/ or bin/'); + expect(noScripts.missing.find(m => m.dimension === 'gbrain')?.reason).toBe('gbrain is not on PATH or ~/.gbrain/config.json is absent'); + }); +}); diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 7f7f14f1b..9d9131d47 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -150,6 +150,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/factory-scorecard/**': ['test/factory-scorecard.test.ts'], 'test/fixtures/free-shard-capture.ts': ['test/test-free-shards-capture.test.ts'], 'test/fixtures/golden/**': ['test/host-config.test.ts', 'test/ship-review-loop.test.ts'], + 'test/fixtures/health-detect-legacy.sh': ['test/health-detect-no-loss.test.ts'], 'test/fixtures/impeccable-antipatterns.json': ['test/design-catalog.test.ts', 'test/impeccable-fixtures.test.ts'], 'test/fixtures/impeccable-captures.meta.json': ['test/design-detect-contract.test.ts', 'test/impeccable-fixtures.test.ts'], 'test/fixtures/impeccable-detect-dom-sample.json': ['test/impeccable-fixtures.test.ts'],