Files
gstack/test/helpers/health-eval-fixture.ts
f30b7b788a v1.91.13.0 fix: severe bug wave (autoplan guard, land-and-deploy CI gate, project identity, gbrain, review gates) (#3013)
* fix(land-and-deploy): never merge over red, pending or missing CI (#2995)

/land-and-deploy gated only on required checks, so a repo with none
(every GitHub Free private repo, any repo without branch protection)
skipped the CI wait and could merge over red CI. The verdict now comes
from bin/gstack-ci-gate, a tested helper bound to the head SHA: all
checks by default, cancel/unknown buckets fail, NO_CHECKS needs explicit
per-head approval, and both merge commands (including --auto) run only
after a PASS for the same SHA they pin with --match-head-commit.

Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>

* fix(slug): give nested-group projects their own state bucket and verify checkpoint identity (#3003, #2901)

gstack-slug kept only the last two remote path segments, so
customer-a/product/repo and customer-b/product/repo shared one bucket
and /context-restore could present another project's checkpoint.

- One canonical-remote rule (bash + TS, golden vectors) used by all six
  resolvers: lowercased host, no userinfo/port/.git, Azure DevOps and
  Bitbucket Server normalized. 3+ segment remotes get
  <last-two>-<16 hex>; two-segment remotes (all of GitHub) are unchanged.
- Versioned sticky cache (v2:) keeps cache hits git-free.
- context-save stamps the credential-free remote and root;
  context-restore only treats this project's checkpoints as latest,
  reports foreign/unverified ones first, and treats a different root
  with the same remote (Conductor handoff) as information.
- gstack-slug --adopt-legacy copies only files the user confirms.
- question-preference-hook reads the same versioned cache, so never-ask
  preferences fire; unknown identity asks instead of auto-deciding.

* fix(gbrain): brain context loads, managed sync works, memory sync stops duplicating (#2883, #2985, #2922)

- brain-context-load called 'gbrain list_pages' (an MCP tool name), so
  every skill's brain context was 0 bytes. It now uses 'gbrain list'
  with gbrain's real filter flags, and prints one line when queries fail.
- The code-stage sync always passes --no-pull (managed gbrain >= 0.51
  refuses without it); a gbrain that rejects the flag fails with an
  upgrade message and is never retried without it. stderr is streamed
  with a bounded tail and quoted in failure summaries.
- /sync-gbrain gains --sources (and GSTACK_MEMORY_INGEST_SOURCES); by
  default it skips types already synced through a federated curated
  source, so curated pages stop being re-duplicated.

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>

* fix(codex,cso): clean Codex reviews stop failing; semgrep and trivy SARIF results import (#2769, #3011)

- Codex review gate is PASS / FAIL / UNVERIFIED. Untagged output (what
  a clean 'codex review --base' prints) is UNVERIFIED, not FAIL, and
  never a false PASS; P2/P3-only output passes.
- import-sarif resolves semgrep's undeclared %SRCROOT% and trivy's
  ROOTPATH (including git-URL targets) through explicit, captured
  mappings; declared bases must sit inside the source root or the
  run's checkout, URIs are percent-decoded, and rejected results are
  counted with rule, base and path instead of silently dropped.

* fix: version strings are not IPs, failed update checks are not 'up to date', private security reporting (#2856, #2786, #2984)

- The pre-push redact hook exempts a root VERSION file that is only a
  version string and the version token in CHANGELOG release headings;
  the engine allowlist is untouched.
- gstack-update-check caches an unreadable remote as CHECK_FAILED with
  a short TTL instead of UP_TO_DATE; /gstack-upgrade never reports
  'latest' after a failed check.
- SECURITY.md points reporters at GitHub private vulnerability
  reporting.
- pr-title-sync (pull_request_target, write token) pins actions/checkout
  to a reviewed SHA.

* fix(autoplan): publication guard accepts real Claude Code journals (#3007, #3009, #2968, #2977, #2986, #3006)

The guard required a session journal to start with a user message, but
Claude Code starts it with SessionStart hook attachments (and a forked
compacted session starts with a compact_boundary), so every phase was
denied for anyone with a SessionStart hook. Windows path spellings
(C:/, /c/, drive case) were rejected too.

- Reader accepts the captured startup, compact, clear and fork shapes
  (claude 2.1.284 fixture), computes root uniqueness over every
  candidate, and returns typed reason codes instead of one string.
- Hard denials (competing root, foreign cwd, sidechain, agent, cycle,
  malformed, identity) say what happened and how to run the reviews by
  hand; unflushed journals still retry.
- A well-formed, stable journal whose root shape is unrecognized now
  warns ('phase publication was NOT verified') and continues, without
  overriding Claude Code's own permission check; no bypass switch.
- Windows paths fold before validation, so '..' is still rejected.
- Periodic drift canary runs the reader on fresh journals from the
  pinned claude.

Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com>
Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com>
Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com>
Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>

* v1.91.13.0: severe bug fix wave release notes, stale gbrain verbs in docs

* test: seed the versioned slug cache in the memory-injection deny case (#2901)

The preference hook now resolves the project bucket from gstack-slug's
v2 slug-cache entry instead of basename(cwd); this fixture still wrote
preferences under the basename bucket without the entry the skill
preamble always writes first.

* fix: full-suite follow-ups for the fix wave

- gstack-slug's --help heredoc is over 512B, so it gets the BASH_COMPAT=50
  pipe-deadlock guard like the other bins.
- Refresh the codex/factory ship goldens for the plan-gates slug change.
- The periodic lane gains the autoplan journal drift canary (107 paid
  files); raise the eval-slices cap to 25 so every slice still starts
  at once until its recorded duration lets the planner pack it.

* fix(plan-eng-review): clearer Step 0 sequencing, write routes and section outputs

The workflow judge scored plan-eng-review's instructions at
actionability 3.67 (bar 4) on text unchanged from main. Resolve the
ambiguities it named together, without changing any gate or output:
Step 0 names the Read's order and keeps one STOP; the pending-call,
read-only, forbidden-write and failed-save rules become one Write
routes block; header/label limits and grid checks are concrete;
Performance gets a checklist; each section has a stated output shape;
Lake Score is defined. Same total length (parity cap).

---------

Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>
Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com>
Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com>
Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com>
Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>
2026-10-02 11:11:00 -07:00

212 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Isolated fixtures and assertions for the single periodic /health capture. */
import * as fs from 'node:fs';
import * as path from 'node:path';
import { extractSkillSections } from './skill-fixture';
import type { SkillTestResult } from './session-runner';
import type { EvalTestEntry } from './eval-store';
export const HEALTH_EVAL_ID = 'health-reporting';
export const HEALTH_EVAL_SECTIONS = [
'Step 1: Detect Health Stack',
'Step 2: Run Tools',
'Step 3: Score Each Category',
'Step 4: Present Dashboard',
'Step 5: Persist to Health History',
'Step 6: Trend Analysis + Recommendations',
'Important Rules',
];
const PRIOR_HISTORY = JSON.stringify({
ts: '2026-01-01T00:00:00Z', branch: 'unknown', score: 10,
typecheck: null, lint: null, test: 10, deadcode: null, shell: null,
gbrain: null, duration_s: 1,
}) + '\n';
export interface HealthEvalFixture {
dir: string;
gstackHome: string;
receipts: string;
prompt: string;
projectFiles: Record<string, string>;
}
function projectSnapshot(dir: string): Record<string, string> {
const files: Record<string, string> = {};
const walk = (relative: string) => {
for (const entry of fs.readdirSync(path.join(dir, relative), { withFileTypes: true })) {
const name = path.join(relative, entry.name);
if (entry.isDirectory()) { files[name + '/'] = '<directory>'; walk(name); }
else files[name] = entry.isSymbolicLink() ? `<symlink:${fs.readlinkSync(path.join(dir, name))}>` : fs.readFileSync(path.join(dir, name), 'utf-8');
}
};
for (const project of ['partial', 'no-tools']) walk(project);
return files;
}
export function createHealthEvalFixture(dir: string, repoRoot: string): HealthEvalFixture {
const gstackHome = path.join(dir, 'gstack-state');
const receipts = path.join(dir, 'checker-runs.txt');
// Instrumentation belongs to the fixture, not to the model's shell setup.
// Single-quote escaping also covers temporary paths containing apostrophes.
const quotedReceipts = "'" + receipts.replaceAll("'", "'\"'\"'") + "'";
fs.mkdirSync(path.join(dir, 'bin'), { recursive: true });
fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-slug'), path.join(dir, 'bin', 'gstack-slug'));
fs.chmodSync(path.join(dir, 'bin', 'gstack-slug'), 0o755);
fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-state-root.sh'), path.join(dir, 'bin', 'gstack-state-root.sh'));
fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-remote-identity.sh'), path.join(dir, 'bin', 'gstack-remote-identity.sh'));
// Only redirect installed paths. The workflow and score/history rules come
// from the real generated skill, without its unrelated shared preamble.
const skill = extractSkillSections(path.join(repoRoot, 'health'), HEALTH_EVAL_SECTIONS)
.replaceAll('~/.claude/skills/gstack/bin/gstack-slug', path.join(dir, 'bin', 'gstack-slug'))
.replaceAll('~/.gstack', gstackHome);
fs.writeFileSync(path.join(dir, 'health-SKILL.md'), skill);
for (const project of ['partial', 'no-tools']) {
const projectDir = path.join(dir, project);
fs.mkdirSync(projectDir);
fs.writeFileSync(path.join(projectDir, '.project.yaml'), `name: ${project}\n`);
const historyDir = path.join(gstackHome, 'projects', project);
fs.mkdirSync(historyDir, { recursive: true });
fs.writeFileSync(path.join(historyDir, 'health-history.jsonl'), PRIOR_HISTORY);
}
fs.writeFileSync(path.join(dir, 'partial', 'CLAUDE.md'), `# Partial project
## Health Stack
- typecheck: bash ./check-typecheck.sh
- test: bash ./check-tests.sh
Only the listed tools are available. Lint, dead-code, shell-lint, and GBrain
tools are not installed. This configuration is final; do not install tools.
`);
fs.writeFileSync(path.join(dir, 'no-tools', 'CLAUDE.md'), `# No-tools project
## Health Stack
No health tools are configured or installed for any category.
This configuration is final; do not install tools or substitute other checks.
`);
fs.writeFileSync(path.join(dir, 'partial', 'check-typecheck.sh'), `#!/usr/bin/env bash
printf 'typecheck\\n' >> ${quotedReceipts}
for ((i = 1; i <= 60; i++)); do
printf 'src/file%s.ts(1,1): error TS2322: Type mismatch.\\n' "$i" >&2
done
for ((i = 1; i <= 80; i++)); do
printf 'Additional diagnostic context %s\\n' "$i"
done
exit 2
`);
fs.writeFileSync(path.join(dir, 'partial', 'check-tests.sh'), `#!/usr/bin/env bash
printf 'test\\n' >> ${quotedReceipts}
printf '5 pass\\n0 fail\\n'
`);
return {
dir, gstackHome, receipts, projectFiles: projectSnapshot(dir),
prompt: `Read health-SKILL.md and run its /health workflow, Steps 1–6, for
the partial project first and the no-tools project second. Each has its own
CLAUDE.md with the final Health Stack configuration. Run commands from the
corresponding project directory. These are local fixtures without Git remotes.
Save each complete dashboard, details, trends, and recommendations as
partial-report.md or no-tools-report.md in ${dir}. Keep the dashboard's
COMPOSITE SCORE label. Existing health histories are available under
${gstackHome}/projects/<project>/health-history.jsonl; apply the skill's normal
history rules. GSTACK_HOME already points at this isolated state directory.
Do not modify project files, install tools, or ask to change either Health Stack.
Only write the reports and any history updates required by the supplied skill.
Finish after producing both reports.`,
};
}
/** Validate behavior, allowing ordinary Markdown/wording variation in reports. */
export function healthReportingFailures(fixture: HealthEvalFixture): string[] {
const failures: string[] = [];
const check = (ok: boolean, message: string) => { if (!ok) failures.push(message); };
const read = (file: string) => fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
const plain = (text: string) => text.replace(/[*_`]/g, '');
const partial = plain(read(path.join(fixture.dir, 'partial-report.md')));
const empty = plain(read(path.join(fixture.dir, 'no-tools-report.md')));
const composite = (report: string) => report.match(/composite\s+score\s*[:|]?\s*(N\/A|\d+(?:\.\d+)?)/i)?.[1];
check(composite(partial) === '5.6', 'partial coverage must score 5.6, preserving the failing typecheck');
check(/partial\s+coverage/i.test(partial), 'numeric score must be labeled as partial coverage');
const typecheckRows = partial.split('\n').filter(line => /\btype\s*check\b/i.test(line));
check(typecheckRows.some(line => /(?:\b0\s*\/\s*10\b|\|\s*0\s*\|)/.test(line)
&& /\b(?:critical|fail(?:ed|ure)?|error)\b/i.test(line) && !/\bclean\b/i.test(line)),
'dashboard must report the failing typecheck as 0/10, not clean');
check(/\b60\s+(?:\w+\s+){0,2}(?:errors|diagnostics|findings)\b/i.test(partial)
|| /(?:errors|diagnostics|findings)[^\n]{0,20}\b60\b/i.test(partial),
'report must count all 60 errors before the final 50 output lines');
check(/(?:coverage|checked|executed)/i.test(partial), 'partial report must disclose checked coverage');
check(/type\s*check/i.test(partial) && /tests?/i.test(partial), 'checked category names must be visible');
for (const category of ['lint', 'dead[ -]?code', 'shell(?:[ -]?lint)?', 'gbrain']) {
const unavailable = '(?:unavailable|skipped|not (?:installed|configured|available|found))';
check(new RegExp(`${unavailable}[\\s\\S]{0,240}${category}|${category}[^\\n]{0,120}${unavailable}`, 'i').test(partial),
`partial report must name unavailable ${category}`);
}
check(/(?:coverage|categor(?:y|ies)|checks)[\s\S]{0,160}(?:chang|differ|not compar)/i.test(partial)
|| /(?:chang|differ|not compar)[\s\S]{0,160}(?:coverage|categor(?:y|ies)|checks)/i.test(partial),
'partial report must flag changed coverage instead of comparing unlike histories');
check(!/[+-]\s*4\.4\b|trend\s*:\s*(?:improving|regressing|worsening)/i.test(partial),
'partial report must not calculate a trend delta against different coverage');
check(composite(empty)?.toUpperCase() === 'N/A', 'no-tools composite must be N/A');
check(/(?:no|zero|0)\s+(?:health\s+)?checks?\s+(?:ran|run|executed|available)|no\s+tools/i.test(empty),
'no-tools report must explain that no checks ran');
const history = read(path.join(fixture.gstackHome, 'projects', 'partial', 'health-history.jsonl'));
check(history.startsWith(PRIOR_HISTORY), 'partial run must preserve its prior history row');
const rows = history.trim().split('\n').filter(Boolean);
check(rows.length === 2, 'partial run must append exactly one history row');
try {
const row = JSON.parse(rows.at(-1) || '{}');
check(row.score === 5.6 && row.typecheck === 0 && row.test === 10,
'persisted partial scores must reflect all diagnostics and the actual exit status');
check(['lint', 'deadcode', 'shell', 'gbrain'].every(category => row[category] === null),
'unavailable categories must persist as null');
} catch {
failures.push('partial history must remain valid JSONL');
}
check(read(path.join(fixture.gstackHome, 'projects', 'no-tools', 'health-history.jsonl')) === PRIOR_HISTORY,
'no-tools run must leave its history unchanged');
const runs = read(fixture.receipts).trim().split('\n');
check(runs.includes('typecheck') && runs.includes('test'), 'both configured checkers must actually run');
check(JSON.stringify(projectSnapshot(fixture.dir)) === JSON.stringify(fixture.projectFiles),
'health must leave project files unchanged');
return failures;
}
/** One attempt records once, after all assertions; throws remain failures. */
export async function recordHealthAttempt(
record: (entry: EvalTestEntry) => void,
capture: () => Promise<SkillTestResult>,
verify: (result: SkillTestResult) => void,
): Promise<void> {
const started = Date.now();
let result: SkillTestResult | undefined;
let passed = false;
let failure: unknown;
try {
result = await capture();
if (result.exitReason !== 'success') throw new Error(`health capture ended: ${result.exitReason}`);
verify(result);
passed = true;
} catch (error) {
failure = error;
throw error;
} finally {
record({
name: HEALTH_EVAL_ID, suite: 'health', tier: 'e2e', passed,
duration_ms: result?.duration ?? Date.now() - started,
cost_usd: result?.costEstimate.estimatedCost ?? 0,
turns_used: result?.costEstimate.turnsUsed,
tokens_used: result?.costEstimate.estimatedTokens,
transcript: result?.transcript,
output: [result?.output, failure === undefined ? '' : String(failure)].filter(Boolean).join('\n').slice(-4000),
exit_reason: result?.exitReason === 'success' && !passed ? 'assertion_failed' : result?.exitReason ?? 'runner_error',
model: result?.model,
});
}
}