mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
8115951284
* refactor: remove dead contributor mode, replace with operational self-improvement slot Contributor mode never fired in 18 days of heavy use (required manual opt-in via gstack-config, gated behind _CONTRIB=true, wrote disconnected markdown). Removes: generateContributorMode(), _CONTRIB bash var, 2 E2E tests, touchfile entry, doc references. Cleans up skip-lists in plan-ceo-review, autoplan, review resolver, and document-release templates. The operational self-improvement system (next commit) replaces this slot with automatic learning capture that requires no opt-in. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: operational self-improvement — every skill learns from failures Adds universal operational learning capture to the preamble completion protocol. At the end of every skill session, the agent reflects on CLI failures, wrong approaches, and project quirks, logging them as type "operational" to the learnings JSONL. Future sessions surface these automatically. - generateCompletionStatus(ctx) now includes operational capture section - Preamble bash shows top 3 learnings inline when count > 5 - New "operational" type in generateLearningsLog alongside pattern/pitfall/etc - Updated unit tests + operational seed entry in learnings E2E Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: wire learnings into all insight-producing skills Adds LEARNINGS_SEARCH and/or LEARNINGS_LOG to 10 skill templates that produce reusable insights but were previously disconnected from the learning system: - office-hours, plan-ceo-review, plan-eng-review: add LOG (had SEARCH) - plan-design-review: add both SEARCH + LOG (had neither) - design-review, design-consultation, cso, qa, qa-only: add both - retro: add SEARCH (had LOG) 13 skills now fully participate in the learning loop (read + write). Every review, QA, investigation, and design session both consults prior learnings and contributes new ones. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add operational-learning E2E test (gate-tier) Validates the write path: agent encounters a CLI failure, logs an operational learning to JSONL via gstack-learnings-log. Replaces the removed contributor-mode E2E test. Setup: temp git repo, copy bin scripts, set GSTACK_HOME. Prompt: simulated npm test failure needing --experimental-vm-modules. Assert: learnings.jsonl exists with type=operational entry. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: learnings-show E2E slug mismatch — seed at computed slug, not hardcoded The test seeded learnings at projects/test-project/ but gstack-slug computes the slug from basename(workDir) when no git remote exists. The agent's search looked at the wrong path and found nothing. Fix: compute slug the same way gstack-slug does (basename + sanitize) and seed the learnings there. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.13.8.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
/**
|
|
* Unit tests for diff-based test selection.
|
|
* Free (no API calls), runs with `bun test`.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import {
|
|
matchGlob,
|
|
selectTests,
|
|
detectBaseBranch,
|
|
E2E_TOUCHFILES,
|
|
E2E_TIERS,
|
|
LLM_JUDGE_TOUCHFILES,
|
|
GLOBAL_TOUCHFILES,
|
|
} from './helpers/touchfiles';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
|
|
// --- matchGlob ---
|
|
|
|
describe('matchGlob', () => {
|
|
test('** matches any depth of path segments', () => {
|
|
expect(matchGlob('browse/src/commands.ts', 'browse/src/**')).toBe(true);
|
|
expect(matchGlob('browse/src/deep/nested/file.ts', 'browse/src/**')).toBe(true);
|
|
expect(matchGlob('browse/src/cli.ts', 'browse/src/**')).toBe(true);
|
|
});
|
|
|
|
test('** does not match unrelated paths', () => {
|
|
expect(matchGlob('browse/src/commands.ts', 'qa/**')).toBe(false);
|
|
expect(matchGlob('review/SKILL.md', 'qa/**')).toBe(false);
|
|
});
|
|
|
|
test('exact match works', () => {
|
|
expect(matchGlob('SKILL.md', 'SKILL.md')).toBe(true);
|
|
expect(matchGlob('SKILL.md.tmpl', 'SKILL.md')).toBe(false);
|
|
expect(matchGlob('qa/SKILL.md', 'SKILL.md')).toBe(false);
|
|
});
|
|
|
|
test('* matches within a single segment', () => {
|
|
expect(matchGlob('test/fixtures/review-eval-enum.rb', 'test/fixtures/review-eval-enum*.rb')).toBe(true);
|
|
expect(matchGlob('test/fixtures/review-eval-enum-diff.rb', 'test/fixtures/review-eval-enum*.rb')).toBe(true);
|
|
expect(matchGlob('test/fixtures/review-eval-vuln.rb', 'test/fixtures/review-eval-enum*.rb')).toBe(false);
|
|
});
|
|
|
|
test('dots in patterns are escaped correctly', () => {
|
|
expect(matchGlob('SKILL.md', 'SKILL.md')).toBe(true);
|
|
expect(matchGlob('SKILLxmd', 'SKILL.md')).toBe(false);
|
|
});
|
|
|
|
test('** at end matches files in the directory', () => {
|
|
expect(matchGlob('qa/SKILL.md', 'qa/**')).toBe(true);
|
|
expect(matchGlob('qa/SKILL.md.tmpl', 'qa/**')).toBe(true);
|
|
expect(matchGlob('qa/templates/report.md', 'qa/**')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// --- selectTests ---
|
|
|
|
describe('selectTests', () => {
|
|
test('browse/src change selects browse and qa tests', () => {
|
|
const result = selectTests(['browse/src/commands.ts'], E2E_TOUCHFILES);
|
|
expect(result.selected).toContain('browse-basic');
|
|
expect(result.selected).toContain('browse-snapshot');
|
|
expect(result.selected).toContain('qa-quick');
|
|
expect(result.selected).toContain('qa-fix-loop');
|
|
expect(result.selected).toContain('design-review-fix');
|
|
expect(result.reason).toBe('diff');
|
|
// Should NOT include unrelated tests
|
|
expect(result.selected).not.toContain('plan-ceo-review');
|
|
expect(result.selected).not.toContain('retro');
|
|
expect(result.selected).not.toContain('document-release');
|
|
});
|
|
|
|
test('skill-specific change selects only that skill and related tests', () => {
|
|
const result = selectTests(['plan-ceo-review/SKILL.md'], E2E_TOUCHFILES);
|
|
expect(result.selected).toContain('plan-ceo-review');
|
|
expect(result.selected).toContain('plan-ceo-review-selective');
|
|
expect(result.selected).toContain('plan-ceo-review-benefits');
|
|
expect(result.selected).toContain('autoplan-core');
|
|
expect(result.selected).toContain('codex-offered-ceo-review');
|
|
expect(result.selected.length).toBe(5);
|
|
expect(result.skipped.length).toBe(Object.keys(E2E_TOUCHFILES).length - 5);
|
|
});
|
|
|
|
test('global touchfile triggers ALL tests', () => {
|
|
const result = selectTests(['test/helpers/session-runner.ts'], E2E_TOUCHFILES);
|
|
expect(result.selected.length).toBe(Object.keys(E2E_TOUCHFILES).length);
|
|
expect(result.skipped.length).toBe(0);
|
|
expect(result.reason).toContain('global');
|
|
});
|
|
|
|
test('gen-skill-docs.ts is a scoped touchfile, not global', () => {
|
|
const result = selectTests(['scripts/gen-skill-docs.ts'], E2E_TOUCHFILES);
|
|
// Should select tests that list gen-skill-docs.ts in their touchfiles, not ALL tests
|
|
expect(result.selected.length).toBeGreaterThan(0);
|
|
expect(result.selected.length).toBeLessThan(Object.keys(E2E_TOUCHFILES).length);
|
|
expect(result.reason).toBe('diff');
|
|
// Should include tests that depend on gen-skill-docs.ts
|
|
expect(result.selected).toContain('skillmd-setup-discovery');
|
|
expect(result.selected).toContain('session-awareness');
|
|
expect(result.selected).toContain('journey-ideation');
|
|
// Should NOT include tests that don't depend on it
|
|
expect(result.selected).not.toContain('retro');
|
|
expect(result.selected).not.toContain('cso-full-audit');
|
|
});
|
|
|
|
test('unrelated file selects nothing', () => {
|
|
const result = selectTests(['README.md'], E2E_TOUCHFILES);
|
|
expect(result.selected).toEqual([]);
|
|
expect(result.skipped.length).toBe(Object.keys(E2E_TOUCHFILES).length);
|
|
});
|
|
|
|
test('empty changed files selects nothing', () => {
|
|
const result = selectTests([], E2E_TOUCHFILES);
|
|
expect(result.selected).toEqual([]);
|
|
});
|
|
|
|
test('multiple changed files union their selections', () => {
|
|
const result = selectTests(
|
|
['plan-ceo-review/SKILL.md', 'retro/SKILL.md.tmpl'],
|
|
E2E_TOUCHFILES,
|
|
);
|
|
expect(result.selected).toContain('plan-ceo-review');
|
|
expect(result.selected).toContain('plan-ceo-review-selective');
|
|
expect(result.selected).toContain('retro');
|
|
expect(result.selected).toContain('retro-base-branch');
|
|
// Also selects journey routing tests (*/SKILL.md.tmpl matches retro/SKILL.md.tmpl)
|
|
expect(result.selected.length).toBeGreaterThanOrEqual(4);
|
|
});
|
|
|
|
test('works with LLM_JUDGE_TOUCHFILES', () => {
|
|
const result = selectTests(['qa/SKILL.md'], LLM_JUDGE_TOUCHFILES);
|
|
expect(result.selected).toContain('qa/SKILL.md workflow');
|
|
expect(result.selected).toContain('qa/SKILL.md health rubric');
|
|
expect(result.selected).toContain('qa/SKILL.md anti-refusal');
|
|
expect(result.selected.length).toBe(3);
|
|
});
|
|
|
|
test('SKILL.md.tmpl root template selects root-dependent tests and routing tests', () => {
|
|
const result = selectTests(['SKILL.md.tmpl'], E2E_TOUCHFILES);
|
|
// Should select the 7 tests that depend on root SKILL.md
|
|
expect(result.selected).toContain('skillmd-setup-discovery');
|
|
expect(result.selected).toContain('session-awareness');
|
|
expect(result.selected).toContain('session-awareness');
|
|
// Also selects journey routing tests (SKILL.md.tmpl in their touchfiles)
|
|
expect(result.selected).toContain('journey-ideation');
|
|
// Should NOT select unrelated non-routing tests
|
|
expect(result.selected).not.toContain('plan-ceo-review');
|
|
expect(result.selected).not.toContain('retro');
|
|
});
|
|
|
|
test('global touchfiles work for LLM-judge tests too', () => {
|
|
const result = selectTests(['test/helpers/session-runner.ts'], LLM_JUDGE_TOUCHFILES);
|
|
expect(result.selected.length).toBe(Object.keys(LLM_JUDGE_TOUCHFILES).length);
|
|
});
|
|
});
|
|
|
|
// --- detectBaseBranch ---
|
|
|
|
describe('detectBaseBranch', () => {
|
|
test('detects local main branch', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'touchfiles-test-'));
|
|
const run = (cmd: string, args: string[]) =>
|
|
spawnSync(cmd, args, { cwd: dir, stdio: 'pipe', timeout: 5000 });
|
|
|
|
run('git', ['init']);
|
|
run('git', ['config', 'user.email', 'test@test.com']);
|
|
run('git', ['config', 'user.name', 'Test']);
|
|
fs.writeFileSync(path.join(dir, 'test.txt'), 'hello\n');
|
|
run('git', ['add', '.']);
|
|
run('git', ['commit', '-m', 'init']);
|
|
|
|
const result = detectBaseBranch(dir);
|
|
// Should find 'main' (or 'master' depending on git default)
|
|
expect(result).toMatch(/^(main|master)$/);
|
|
|
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
});
|
|
|
|
test('returns null for empty repo with no branches', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'touchfiles-test-'));
|
|
const run = (cmd: string, args: string[]) =>
|
|
spawnSync(cmd, args, { cwd: dir, stdio: 'pipe', timeout: 5000 });
|
|
|
|
run('git', ['init']);
|
|
// No commits = no branches
|
|
const result = detectBaseBranch(dir);
|
|
expect(result).toBeNull();
|
|
|
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
});
|
|
|
|
test('returns null for non-git directory', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'touchfiles-test-'));
|
|
const result = detectBaseBranch(dir);
|
|
expect(result).toBeNull();
|
|
|
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
});
|
|
});
|
|
|
|
// --- Completeness: every testName in skill-e2e-*.test.ts has a TOUCHFILES entry ---
|
|
|
|
describe('TOUCHFILES completeness', () => {
|
|
test('every E2E testName has a TOUCHFILES entry', () => {
|
|
// Read all split E2E test files
|
|
const testDir = path.join(ROOT, 'test');
|
|
const e2eFiles = fs.readdirSync(testDir).filter(f => f.startsWith('skill-e2e-') && f.endsWith('.test.ts'));
|
|
let e2eContent = '';
|
|
for (const f of e2eFiles) {
|
|
e2eContent += fs.readFileSync(path.join(testDir, f), 'utf-8') + '\n';
|
|
}
|
|
|
|
// Extract all testName: 'value' entries
|
|
const testNameRegex = /testName:\s*['"`]([^'"`]+)['"`]/g;
|
|
const testNames: string[] = [];
|
|
let match;
|
|
while ((match = testNameRegex.exec(e2eContent)) !== null) {
|
|
let name = match[1];
|
|
// Handle template literals like `qa-${label}` — these expand to
|
|
// qa-b6-static, qa-b7-spa, qa-b8-checkout
|
|
if (name.includes('${')) continue; // skip template literals, check expanded forms below
|
|
testNames.push(name);
|
|
}
|
|
|
|
// Add the template-expanded testNames from runPlantedBugEval calls
|
|
const plantedBugRegex = /runPlantedBugEval\([^,]+,\s*[^,]+,\s*['"`]([^'"`]+)['"`]\)/g;
|
|
while ((match = plantedBugRegex.exec(e2eContent)) !== null) {
|
|
testNames.push(`qa-${match[1]}`);
|
|
}
|
|
|
|
expect(testNames.length).toBeGreaterThan(0);
|
|
|
|
const missing = testNames.filter(name => !(name in E2E_TOUCHFILES));
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`E2E tests missing TOUCHFILES entries: ${missing.join(', ')}\n` +
|
|
`Add these to E2E_TOUCHFILES in test/helpers/touchfiles.ts`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('E2E_TIERS covers exactly the same tests as E2E_TOUCHFILES', () => {
|
|
const touchfileKeys = new Set(Object.keys(E2E_TOUCHFILES));
|
|
const tierKeys = new Set(Object.keys(E2E_TIERS));
|
|
|
|
const missingFromTiers = [...touchfileKeys].filter(k => !tierKeys.has(k));
|
|
const extraInTiers = [...tierKeys].filter(k => !touchfileKeys.has(k));
|
|
|
|
if (missingFromTiers.length > 0) {
|
|
throw new Error(
|
|
`E2E tests missing TIER entries: ${missingFromTiers.join(', ')}\n` +
|
|
`Add these to E2E_TIERS in test/helpers/touchfiles.ts`,
|
|
);
|
|
}
|
|
if (extraInTiers.length > 0) {
|
|
throw new Error(
|
|
`E2E_TIERS has extra entries not in E2E_TOUCHFILES: ${extraInTiers.join(', ')}\n` +
|
|
`Remove these from E2E_TIERS or add to E2E_TOUCHFILES`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('E2E_TIERS only contains valid tier values', () => {
|
|
const validTiers = ['gate', 'periodic'];
|
|
for (const [name, tier] of Object.entries(E2E_TIERS)) {
|
|
if (!validTiers.includes(tier)) {
|
|
throw new Error(`E2E_TIERS['${name}'] has invalid tier '${tier}'. Valid: ${validTiers.join(', ')}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('every LLM-judge test has a TOUCHFILES entry', () => {
|
|
const llmContent = fs.readFileSync(
|
|
path.join(ROOT, 'test', 'skill-llm-eval.test.ts'),
|
|
'utf-8',
|
|
);
|
|
|
|
// Extract test names from addTest({ name: '...' }) calls
|
|
const nameRegex = /name:\s*['"`]([^'"`]+)['"`]/g;
|
|
const testNames: string[] = [];
|
|
let match;
|
|
while ((match = nameRegex.exec(llmContent)) !== null) {
|
|
testNames.push(match[1]);
|
|
}
|
|
|
|
// Deduplicate (some tests call addTest with the same name)
|
|
const unique = [...new Set(testNames)];
|
|
expect(unique.length).toBeGreaterThan(0);
|
|
|
|
const missing = unique.filter(name => !(name in LLM_JUDGE_TOUCHFILES));
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`LLM-judge tests missing TOUCHFILES entries: ${missing.join(', ')}\n` +
|
|
`Add these to LLM_JUDGE_TOUCHFILES in test/helpers/touchfiles.ts`,
|
|
);
|
|
}
|
|
});
|
|
});
|