mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 09:55:29 +02:00
v1.87.4.0 fix: preserve health failures and disclose coverage (#2882)
* fix: report health failures and coverage accurately * chore: prepare health reporting release 1.87.4.0 * test: restrict routing evaluations to installed project skills * test: stabilize health selection and terminal fixtures
This commit is contained in:
@@ -29,7 +29,7 @@ describe('AO completed manual DX handoff preserves report freshness',()=>{
|
||||
expect(E2E_TOUCHFILES[owner]).toContain('test/fixtures/dx-manual-handoff-ao.json');
|
||||
}
|
||||
const arrays=[...Object.values(E2E_TOUCHFILES),...Object.values(LLM_JUDGE_TOUCHFILES),GLOBAL_TOUCHFILES];
|
||||
expect(arrays).toHaveLength(210);
|
||||
expect(arrays).toHaveLength(211);
|
||||
for(const values of arrays)for(let i=0;i<values.length;i++)expect(typeof values[i]).toBe('string');
|
||||
});
|
||||
test('exact owned report precedes navigation only, with the current Exit gate recognized',()=>{
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Execute the documented capture, so the example cannot drift from its tests.
|
||||
const template = readFileSync(join(import.meta.dir, '../health/SKILL.md.tmpl'), 'utf8');
|
||||
const capture = template.split('## Step 2: Run Tools')[1].match(/```bash\n([\s\S]*?)```/)![1];
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runCapture(checker: string, setup = '') {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'health-capture-test-'));
|
||||
temporaryDirectories.push(directory);
|
||||
const logs = join(directory, 'logs');
|
||||
mkdirSync(logs);
|
||||
const executable = join(directory, 'tsc');
|
||||
writeFileSync(executable, '#!/usr/bin/env bash\n' + checker + '\n');
|
||||
chmodSync(executable, 0o755);
|
||||
const result = spawnSync('/bin/bash', ['-euc', setup + '\n' + capture], {
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
env: { ...process.env, PATH: directory + ':' + process.env.PATH, TMPDIR: logs },
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(readdirSync(logs)).toEqual([]);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('/health command capture', () => {
|
||||
test('a successful empty checker reports zero matches under set -e', () => {
|
||||
const result = runCapture('exit 0');
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/TOOL:typecheck EXIT:0 DURATION:\d+s ERRORS:0/);
|
||||
});
|
||||
|
||||
test('failure survives an earlier success, stderr capture, and display', () => {
|
||||
const result = runCapture('echo "source.ts: error TS2322: incorrect type" >&2\nexit 2', 'true');
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stdout).toContain('incorrect type');
|
||||
expect(result.stdout).toMatch(/EXIT:2 DURATION:\d+s ERRORS:1/);
|
||||
});
|
||||
|
||||
test('counts findings outside the displayed tail and prints only fifty log lines', () => {
|
||||
const result = runCapture([
|
||||
'for ((i=1; i<=60; i++)); do echo "source.ts: error TS2322: finding $i"; done',
|
||||
'for ((i=1; i<=80; i++)); do echo "detail $i"; done',
|
||||
'exit 2',
|
||||
].join('\n'));
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stdout).toMatch(/EXIT:2 DURATION:\d+s ERRORS:60/);
|
||||
const lines = result.stdout.trimEnd().split('\n');
|
||||
expect(lines.length).toBe(51);
|
||||
expect(lines[0]).toBe('detail 31');
|
||||
expect(lines[49]).toBe('detail 80');
|
||||
});
|
||||
|
||||
test('an executed checker returning 127 remains a failure', () => {
|
||||
const result = runCapture('echo "a checker dependency failed" >&2\nexit 127');
|
||||
expect(result.status).toBe(127);
|
||||
expect(result.stdout).toContain('EXIT:127');
|
||||
expect(result.stdout).not.toContain('SKIPPED');
|
||||
});
|
||||
|
||||
test('empty failing output does not inherit a successful parser status', () => {
|
||||
const result = runCapture('exit 1');
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toMatch(/EXIT:1 DURATION:\d+s ERRORS:0/);
|
||||
});
|
||||
|
||||
test('capture log permissions are private even with a permissive caller umask', () => {
|
||||
const result = runCapture('stat -c %a "$TMPDIR"/gstack-health.* 2>/dev/null || stat -f %Lp "$TMPDIR"/gstack-health.*', 'umask 000');
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.split('\n')[0]).toBe('600');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['log_creation', 'mktemp() { return 1; }'],
|
||||
['redirection', 'mktemp() { printf "%s/missing/log\\n" "$TMPDIR"; }'],
|
||||
['parsing', 'awk() { return 2; }'],
|
||||
['display', 'tail() { return 1; }'],
|
||||
])('%s failure reports an error instead of a clean result', (phase, setup) => {
|
||||
const result = runCapture('exit 0', setup);
|
||||
expect(result.status).toBe(125);
|
||||
expect(result.stderr).toContain('ERROR:typecheck CAPTURE:' + phase);
|
||||
expect(result.stdout).not.toContain('TOOL:typecheck EXIT:0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/** Free fixture/recording checks; never import or invoke the paid runner. */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
createHealthEvalFixture, healthReportingFailures, recordHealthAttempt,
|
||||
} from './helpers/health-eval-fixture';
|
||||
import type { SkillTestResult } from './helpers/session-runner';
|
||||
import type { EvalTestEntry } from './helpers/eval-store';
|
||||
import { E2E_TIERS, E2E_TOUCHFILES, GLOBAL_TOUCHFILES, selectTests } from './helpers/touchfiles';
|
||||
import { isPaidTestFile } from './helpers/paid-test-set';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
test('health behavior is selected as periodic and paid while capture regressions stay free', () => {
|
||||
const selectedBy = (file: string) => selectTests([file], E2E_TOUCHFILES, GLOBAL_TOUCHFILES).selected;
|
||||
expect(selectedBy('health/SKILL.md.tmpl')).toContain('health-reporting');
|
||||
expect(selectedBy('test/helpers/health-eval-fixture.ts')).toEqual(['health-reporting']);
|
||||
expect(E2E_TIERS['health-reporting']).toBe('periodic');
|
||||
expect(isPaidTestFile('test/skill-e2e-health.test.ts')).toBe(true);
|
||||
expect(isPaidTestFile('test/health-capture.test.ts')).toBe(false);
|
||||
});
|
||||
|
||||
function fixture(prefix = 'health-fixture-test-') {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
dirs.push(dir);
|
||||
return createHealthEvalFixture(dir, ROOT);
|
||||
}
|
||||
|
||||
function writePassingEvidence(f: ReturnType<typeof fixture>) {
|
||||
fs.writeFileSync(path.join(f.dir, 'partial-report.md'), `Type check: 0/10 CRITICAL, 60 errors.
|
||||
Tests: 10/10 CLEAN, 5 passed.
|
||||
Checked: type check, tests. Unavailable: lint, dead code, shell lint, GBrain (not installed).
|
||||
COMPOSITE SCORE: **5.6 / 10 — partial coverage**
|
||||
Coverage changed; prior test-only history is not comparable.
|
||||
`);
|
||||
fs.writeFileSync(path.join(f.dir, 'no-tools-report.md'), 'COMPOSITE SCORE: N/A — no checks ran.\n');
|
||||
fs.appendFileSync(path.join(f.gstackHome, 'projects', 'partial', 'health-history.jsonl'), JSON.stringify({
|
||||
score: 5.6, typecheck: 0, test: 10, lint: null, deadcode: null, shell: null, gbrain: null,
|
||||
}) + '\n');
|
||||
fs.writeFileSync(f.receipts, 'typecheck\ntest\n');
|
||||
}
|
||||
|
||||
describe('/health eval fixtures', () => {
|
||||
test('extracts all six real workflow steps and redirects persistent paths', () => {
|
||||
const f = fixture();
|
||||
const skill = fs.readFileSync(path.join(f.dir, 'health-SKILL.md'), 'utf-8');
|
||||
expect(skill).not.toContain('## Preamble (run first)');
|
||||
expect(skill).not.toContain('~/.gstack');
|
||||
expect(skill).not.toContain('~/.claude/skills/gstack/bin/gstack-slug');
|
||||
for (let step = 1; step <= 6; step++) expect(skill).toContain(`## Step ${step}:`);
|
||||
for (const project of ['partial', 'no-tools']) {
|
||||
const result = spawnSync(path.join(f.dir, 'bin', 'gstack-slug'), [], {
|
||||
cwd: path.join(f.dir, project), encoding: 'utf-8', timeout: 10_000,
|
||||
env: { ...process.env, GSTACK_HOME: f.gstackHome, GSTACK_PROJECT_SLUG: '' },
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain(`SLUG=${project}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
test('real checker exits nonzero and its final 50 lines hide all 60 errors', () => {
|
||||
const f = fixture();
|
||||
const result = spawnSync('bash', ['-c', 'bash ./check-typecheck.sh 2>&1'], {
|
||||
cwd: path.join(f.dir, 'partial'), encoding: 'utf-8', timeout: 10_000,
|
||||
});
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stdout.match(/error TS/g)).toHaveLength(60);
|
||||
expect(result.stdout.trimEnd().split('\n').slice(-50).join('\n')).not.toContain('error TS');
|
||||
expect(fs.readFileSync(f.receipts, 'utf-8')).toBe('typecheck\n');
|
||||
});
|
||||
|
||||
test('both checkers record to their fixed path without shell environment setup', () => {
|
||||
const f = fixture("health-fixture-test-' space-");
|
||||
for (const [script, status] of [['check-typecheck.sh', 2], ['check-tests.sh', 0]] as const) {
|
||||
const result = spawnSync('bash', [script], {
|
||||
cwd: path.join(f.dir, 'partial'), encoding: 'utf-8', timeout: 10_000,
|
||||
});
|
||||
expect(result.status).toBe(status);
|
||||
}
|
||||
expect(fs.readFileSync(f.receipts, 'utf-8')).toBe('typecheck\ntest\n');
|
||||
});
|
||||
|
||||
test('accepts truthful reports and history, rejects absent evidence', () => {
|
||||
const f = fixture();
|
||||
expect(healthReportingFailures(f).length).toBeGreaterThan(0);
|
||||
writePassingEvidence(f);
|
||||
expect(healthReportingFailures(f)).toEqual([]);
|
||||
});
|
||||
|
||||
test.each(['false-clean', 'false-clean-category', 'tail-count', 'numeric-empty', 'hidden-coverage', 'unlike-trend', 'contradictory-trend', 'extra-empty-history', 'missing-receipts', 'project-edit'])('%s is a failed behavior eval', failure => {
|
||||
const f = fixture();
|
||||
writePassingEvidence(f);
|
||||
const report = path.join(f.dir, 'partial-report.md');
|
||||
if (failure === 'false-clean') fs.writeFileSync(report, fs.readFileSync(report, 'utf-8').replace('5.6', '10'));
|
||||
if (failure === 'false-clean-category') fs.writeFileSync(report, fs.readFileSync(report, 'utf-8').replace('0/10 CRITICAL', '10/10 CLEAN'));
|
||||
if (failure === 'tail-count') fs.writeFileSync(report, fs.readFileSync(report, 'utf-8').replace('60 errors', '0 errors'));
|
||||
if (failure === 'numeric-empty') fs.writeFileSync(path.join(f.dir, 'no-tools-report.md'), 'COMPOSITE SCORE: 10 — no checks ran.');
|
||||
if (failure === 'hidden-coverage') fs.writeFileSync(report, fs.readFileSync(report, 'utf-8').replace(' — partial coverage', ''));
|
||||
if (failure === 'unlike-trend') fs.writeFileSync(report, fs.readFileSync(report, 'utf-8').replace('Coverage changed; prior test-only history is not comparable.', 'Health worsened from 10 to 5.6.'));
|
||||
if (failure === 'contradictory-trend') fs.appendFileSync(report, 'Trend: REGRESSING (-4.4 since last run)\n');
|
||||
if (failure === 'extra-empty-history') fs.appendFileSync(path.join(f.gstackHome, 'projects', 'no-tools', 'health-history.jsonl'), '{"score":10}\n');
|
||||
if (failure === 'missing-receipts') fs.rmSync(f.receipts);
|
||||
if (failure === 'project-edit') fs.appendFileSync(path.join(f.dir, 'partial', 'check-typecheck.sh'), '# attempted fix\n');
|
||||
expect(healthReportingFailures(f).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/health eval recording', () => {
|
||||
const success = {
|
||||
exitReason: 'success', duration: 5, output: 'report', transcript: [],
|
||||
costEstimate: { estimatedCost: 0.1, turnsUsed: 2, estimatedTokens: 100 }, model: 'fixture',
|
||||
} as SkillTestResult;
|
||||
|
||||
test.each(['success', 'assertion', 'timeout', 'throw'])('%s records exactly once with matching pass status', async scenario => {
|
||||
const entries: EvalTestEntry[] = [];
|
||||
let verified = false;
|
||||
const attempt = recordHealthAttempt(
|
||||
entry => entries.push(entry),
|
||||
async () => {
|
||||
if (scenario === 'throw') throw new Error('runner failed');
|
||||
return { ...success, exitReason: scenario === 'timeout' ? 'timeout' : 'success' };
|
||||
},
|
||||
() => { verified = true; if (scenario === 'assertion') throw new Error('false dashboard'); },
|
||||
);
|
||||
if (scenario === 'success') await attempt;
|
||||
else await expect(attempt).rejects.toThrow();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].passed).toBe(scenario === 'success');
|
||||
expect(entries[0].tier).toBe('e2e');
|
||||
expect(verified).toBe(scenario === 'success' || scenario === 'assertion');
|
||||
if (scenario === 'assertion') expect(entries[0].output).toContain('false dashboard');
|
||||
if (scenario === 'timeout') expect(entries[0].exit_reason).toBe('timeout');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/** 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);
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -582,6 +582,9 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
// Document-release
|
||||
'document-release': ['document-release/**', 'test/skill-e2e-workflow.test.ts'],
|
||||
|
||||
// /health result capture, coverage, and comparable history (model behavior).
|
||||
'health-reporting': ['health/**', 'test/skill-e2e-health.test.ts', 'test/helpers/health-eval-fixture.ts'],
|
||||
|
||||
// Codex (Claude E2E — tests /codex skill via Claude)
|
||||
'codex-review': ['codex/**', 'test/skill-e2e-workflow.test.ts'],
|
||||
|
||||
@@ -1186,6 +1189,7 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
|
||||
// Document-release — gate (CHANGELOG guardrail)
|
||||
'document-release': 'gate',
|
||||
'health-reporting': 'periodic',
|
||||
|
||||
// Codex — periodic (Opus, requires codex CLI)
|
||||
'codex-review': 'periodic',
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('native repeated report permission identity',()=>{
|
||||
|
||||
for (const variant of ['basic', 'intervening', 'cropped', 'same-basename', 'path-cropped']) test.skipIf(process.platform==='win32')(`real fake CLI grants each current request once: ${variant}`,async()=>{
|
||||
const intervening = variant === 'intervening';
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'count-edit-pty-'));const fake=path.join(dir,'fake-claude');const worker=path.join(dir,'worker.ts');const events=path.join(dir,'events.jsonl');const output=path.join(dir,'output.json');const expected=path.join(dir,variant==='same-basename'?'PLAN.md':'report.md');fs.writeFileSync(expected,'original');
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'ce-'));const fake=path.join(dir,'fake-claude');const worker=path.join(dir,'worker.ts');const events=path.join(dir,'events.jsonl');const output=path.join(dir,'output.json');const expected=path.join(dir,variant==='same-basename'?'PLAN.md':'report.md');fs.writeFileSync(expected,'original');
|
||||
const cropped=capturedAc.rows.find(row=>row.job===5)!;
|
||||
let screen=variant==='path-cropped' ? capturedPath.screen.replace(capturedPath.screen.split('\n')[0]!,expected).replaceAll(path.dirname(capturedPath.expected),path.dirname(expected)).replaceAll(path.basename(capturedPath.expected),'report.md')
|
||||
: variant==='cropped' ? cropped.screen.replaceAll(path.dirname(cropped.hook.expected),path.dirname(expected)).replaceAll(path.basename(cropped.hook.expected),'report.md')
|
||||
|
||||
@@ -10,6 +10,26 @@ import owned from './fixtures/plan-count-owned-permission-v.json';
|
||||
import {E2E_TOUCHFILES,selectTests} from './helpers/touchfiles';
|
||||
const quote=(s:string)=>s.split('\n').map(row=>'> '+row).join('\n');
|
||||
|
||||
// A PTY transports bytes, not command-sized stdin events. Share the framing
|
||||
// code with the fake CLI so fragmented grants exercise the same receiver.
|
||||
function commandBuffer(){
|
||||
let pending='';
|
||||
return (chunk:string)=>{
|
||||
pending+=chunk;const commands:string[]=[];let end:number;
|
||||
while((end=pending.indexOf('\r'))!==-1){commands.push(pending.slice(0,end+1));pending=pending.slice(end+1);}
|
||||
return commands;
|
||||
};
|
||||
}
|
||||
test('fake CLI preserves command bytes across fragmented and coalesced PTY input',()=>{
|
||||
const expected=['/plan-ceo-review\r','1\r'];
|
||||
for(const chunks of [expected,['/plan-ceo-review\r','1','\r'],[...expected.join('')],[expected.join('')]]){
|
||||
const receive=commandBuffer();expect(chunks.flatMap(receive)).toEqual(expected);
|
||||
}
|
||||
const receive=commandBuffer();
|
||||
expect(receive('1')).toEqual([]);expect(receive('\r2\rtrailing')).toEqual(['1\r','2\r']);
|
||||
expect(receive('\r')).toEqual(['trailing\r']); // no unexpected bytes are discarded
|
||||
});
|
||||
|
||||
test('the exact wholly quoted AK pane is handled so the dispatcher sends no fallback',()=>{
|
||||
const screen=quote(exact.screen);
|
||||
expect(classifyPlanCountFrame(screen)).toBe('permission');
|
||||
@@ -44,7 +64,7 @@ test('the regression selects exactly the existing permission consumers',()=>{
|
||||
test.skipIf(process.platform==='win32')('real dispatcher ignores quoted pane then grants the fresh owned native request once',async()=>{
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'count-quoted-frame-')),fake=path.join(dir,'fake-claude'),worker=path.join(dir,'worker.ts'),events=path.join(dir,'events.jsonl'),output=path.join(dir,'result.json'),report=path.join(dir,'report.md');
|
||||
fs.writeFileSync(report,'original');
|
||||
fs.writeFileSync(fake,`#!${process.execPath}\n`+String.raw`
|
||||
fs.writeFileSync(fake,`#!${process.execPath}\nconst receive=(${commandBuffer.toString()})();\n`+String.raw`
|
||||
import fs from 'node:fs';import path from 'node:path';
|
||||
const item=JSON.parse(process.env.QUOTED_FRAME_CASE),sid='quoted-frame-main',log=e=>fs.appendFileSync(item.events,JSON.stringify(e)+'\n');
|
||||
const transcript=path.join(process.env.CLAUDE_CONFIG_DIR,'projects','owned',sid+'.jsonl');fs.mkdirSync(path.dirname(transcript),{recursive:true});
|
||||
@@ -56,14 +76,14 @@ const hook=async name=>{for(const entry of settings.hooks[name]??[]){if(entry.ma
|
||||
const p=Bun.spawn(['bash','-c',entry.hooks[0].command],{stdin:new Blob([JSON.stringify(event)]),stdout:'pipe',stderr:'pipe'});
|
||||
const [code,out,err]=await Promise.all([p.exited,new Response(p.stdout).text(),new Response(p.stderr).text()]);if(code||out||err)throw Error('Hook failed');}};
|
||||
const pane=item.screen.replaceAll('PLAN.md',item.report),paint=s=>process.stdout.write('\x1b[2J\x1b[H'+s.replaceAll('\n','\r\n'));
|
||||
let stage='startup';process.stdin.setRawMode?.(true);process.stdin.on('data',async data=>{
|
||||
const input=data.toString();log({type:'input',stage,input});
|
||||
let stage='startup';process.stdin.setRawMode?.(true);const dispatch=async input=>{
|
||||
log({type:'input',stage,input});
|
||||
if(stage==='startup'){stage='quoted';paint(item.quotedScreen);setTimeout(async()=>{await hook('PreToolUse');stage='current';paint(pane);},4200);return;}
|
||||
if(stage!=='current'){log({type:'unexpected'});return;}
|
||||
if(input!=='1\r')throw Error('One-time grant changed');stage='done';await hook('PostToolUse');
|
||||
const q={header:'Finding',question:'Apply the reviewed fix?',options:[{label:'Fix'},{label:'Keep'}]};
|
||||
native('assistant',[{type:'tool_use',name:'AskUserQuestion',id:'finding',input:{questions:[q]}}]);native('user',[{type:'tool_result',tool_use_id:'finding',content:'Answered'}],{toolUseResult:{answers:{[q.question]:'Fix'}}});paint('Done.\n');
|
||||
});process.on('SIGINT',()=>process.exit(0));process.stdin.resume();
|
||||
};process.stdin.on('data',async data=>{const chunk=data.toString();log({type:'chunk',stage,input:chunk});for(const input of receive(chunk))await dispatch(input);});process.on('SIGINT',()=>process.exit(0));process.stdin.resume();
|
||||
`);fs.chmodSync(fake,0o755);
|
||||
// Keep every physical terminal row inside the quote; adding a prefix to an
|
||||
// already120-column capture would otherwise wrap an unquoted continuation.
|
||||
@@ -73,7 +93,10 @@ let stage='startup';process.stdin.setRawMode?.(true);process.stdin.on('data',asy
|
||||
const child=Bun.spawn([process.execPath,worker],{env:{...process.env,BROWSE_TERMINAL_BINARY:fake,EVALS_HERMETIC:'1'},stdout:'pipe',stderr:'pipe'}),timer=setTimeout(()=>child.kill('SIGKILL'),30000);
|
||||
try{const [code,out,err]=await Promise.all([child.exited,new Response(child.stdout).text(),new Response(child.stderr).text()]);expect(code,out+err).toBe(0);
|
||||
const result=JSON.parse(fs.readFileSync(output,'utf8')),rows=fs.readFileSync(events,'utf8').trim().split('\n').map(s=>JSON.parse(s));
|
||||
expect(result.outcome,JSON.stringify(result)).toBe('ceiling_reached');expect(result.reviewCount).toBe(1);
|
||||
expect(result.outcome,JSON.stringify({result,rows})).toBe('ceiling_reached');expect(result.reviewCount).toBe(1);
|
||||
const chunks=rows.filter(r=>r.type==='chunk');
|
||||
expect(chunks.every(r=>['startup','current'].includes(r.stage))).toBe(true);
|
||||
expect(chunks.map(r=>r.input).join('')).toBe('/plan-ceo-review\r1\r');
|
||||
expect(rows.filter(r=>r.type==='input').map(r=>[r.stage,r.input])).toEqual([['startup','/plan-ceo-review\r'],['current','1\r']]);expect(rows.some(r=>r.type==='unexpected')).toBe(false);
|
||||
expect(()=>process.kill(rows[0].pid,0)).toThrow();expect(fs.existsSync(rows[0].cwd)).toBe(false);
|
||||
}finally{clearTimeout(timer);child.kill('SIGKILL');await child.exited;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Periodic /health behavior: full-log failure counts, visible partial coverage,
|
||||
* comparable histories, and an unscored no-tools run. One bounded capture;
|
||||
* wording follows the model, while process receipts and history are asserted.
|
||||
*/
|
||||
import { afterAll, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { CAPTURE_MS, CAPTURE_LONG_MS } from './helpers/eval-budgets';
|
||||
import { describeE2ETier, e2eTierEnabled } from './helpers/e2e-gate';
|
||||
import { EvalCollector } from './helpers/eval-store';
|
||||
import {
|
||||
createHealthEvalFixture, healthReportingFailures, recordHealthAttempt,
|
||||
} from './helpers/health-eval-fixture';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const describeE2E = describeE2ETier('periodic');
|
||||
const collector = e2eTierEnabled('periodic') ? new EvalCollector('e2e') : null;
|
||||
|
||||
describeE2E('/health trustworthy reporting (periodic)', () => {
|
||||
test('health-reporting', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-health-eval-'));
|
||||
try {
|
||||
let fixture: ReturnType<typeof createHealthEvalFixture>;
|
||||
await recordHealthAttempt(
|
||||
entry => collector!.addTest(entry),
|
||||
async () => {
|
||||
fixture = createHealthEvalFixture(dir, ROOT);
|
||||
// The runner resolves its transcript directory on import. Keep a
|
||||
// skipped paid file free of that operator-state lookup/write.
|
||||
const { runSkillTest } = await import('./helpers/session-runner');
|
||||
return runSkillTest({
|
||||
prompt: fixture.prompt,
|
||||
workingDirectory: dir,
|
||||
maxTurns: 18,
|
||||
allowedTools: ['Bash', 'Read', 'Write', 'Glob', 'Grep'],
|
||||
timeout: CAPTURE_MS,
|
||||
testName: 'health-reporting',
|
||||
// The collector retains the transcript in GSTACK_EVAL_DIR. Omit
|
||||
// runId so the runner does not write a global heartbeat/run log.
|
||||
env: { GSTACK_HOME: fixture.gstackHome },
|
||||
});
|
||||
},
|
||||
result => {
|
||||
expect(result.browseErrors).toEqual([]);
|
||||
expect(healthReportingFailures(fixture)).toEqual([]);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, CAPTURE_LONG_MS);
|
||||
});
|
||||
|
||||
afterAll(async () => { await collector?.finalize(); });
|
||||
@@ -79,6 +79,7 @@ function installSkills(tmpDir: string) {
|
||||
];
|
||||
|
||||
const targetBase = path.join(tmpDir, '.claude', 'skills');
|
||||
const installedSkills: string[] = [];
|
||||
|
||||
for (const skill of skillDirs) {
|
||||
const srcPath = path.join(ROOT, skill, 'SKILL.md');
|
||||
@@ -88,8 +89,11 @@ function installSkills(tmpDir: string) {
|
||||
const destDir = path.join(targetBase, skillName);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), extractSkillHead(srcPath));
|
||||
installedSkills.push(skillName);
|
||||
}
|
||||
|
||||
// The names-only catalog keeps new CLI built-ins from changing the candidate
|
||||
// set. Descriptions still choose the skill; no request-to-skill answer key.
|
||||
// Write a CLAUDE.md with a GENERIC invoke-skills nudge — deliberately NO
|
||||
// per-skill routing table. These journey tests exist to catch skill
|
||||
// DESCRIPTION regressions (their touchfiles key on */SKILL.md.tmpl), and
|
||||
@@ -102,7 +106,11 @@ function installSkills(tmpDir: string) {
|
||||
|
||||
## Skill routing
|
||||
|
||||
When the user's request matches an available skill, ALWAYS invoke it using the Skill
|
||||
This project uses the following installed gstack skills: ${installedSkills.join(', ')}.
|
||||
Choose among this project catalog by matching the request to the skill descriptions.
|
||||
The CLI's built-in skills are outside this project's workflow.
|
||||
|
||||
When the user's request matches an available project skill, ALWAYS invoke it using the Skill
|
||||
tool as your FIRST action. Do NOT answer directly, do NOT use other tools first.
|
||||
The skill has specialized workflows that produce better results than ad-hoc answers.
|
||||
Choose the skill by matching the request against each skill's description.
|
||||
|
||||
Reference in New Issue
Block a user