Files
gstack/test/pr-shared-input-selection.test.ts
T
Garry Tanandcapy-ai[bot] ef99eb5a55 feat: retire /investigate after a no-gain eval (GST-1717) (#4110)
* feat: retire /investigate after a no-gain eval (GST-1717)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(skill-start): core routing offer drops every non-core rule line (GST-1717)

The retired /investigate rule became 'Bugs/errors → fix them directly', which no
longer matches '→ invoke /', so proactive=core leaked it into the offered section.
Match any '- … → ' rule line.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* revert(review): keep review/checklist.md at its no-loss-pinned bytes (GST-1717)

The pin names replay GSTA-224 H1; a reviewer checklist edit merges only after a
passing no-loss replay (crew rule 15). The /investigate flake line stays until a
replayed repin.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: repair free-suite fallout from retiring /investigate (GST-1717)

CI run 42340956269 at 83a8a9a: drop fixtures, ledger rows, allowlist entries and
counts for the retired skill and its deleted paid files; record the harness
change as non-behavioral; the test-of-test detector accepts path.join(ROOT, 'bin');
uncovered-prompt fixtures use a tracked prompt, since an untracked path now
reads as a deleted one.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(relink): back up a retired skill's real SKILL.md before cleaning it (GST-1717)

With the skill's source gone, the customized-file check never ran, so a
banner-carrying local copy of investigate/SKILL.md was deleted instead of moved
to the backup root (red-team review, pass 1).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(qm): restore the review checklist's flake-line rewrite (GST-1717)

review/checklist.md keeps its pinned /investigate line, so the QM edition needs
its replaceText rule back; qm/ stays fresh against the generator.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): name the setup flag break, the relink backup and the pinned checklist line (GST-1717)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(setup,relink): back up a retired skill's real SKILL.md instead of deleting it (GST-1717)

Scoped fix round under grant a-360dd62bd46a (outside-voice blocking finding and
adversarial #1, #4). setup's prune compares a copied host entry with the render
before removing that render, and backs up a copy that differs or whose render is
gone; relink no longer takes the marker's rm -rf for a skill whose source is
gone, so a real SKILL.md is backed up and the user's files stay. setup reports a
failed remove-source of the retired gate hooks on stderr.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pr-profile): a changed prompt counts as deleted only when it is gone from disk (GST-1717)

An untracked prompt file on disk still needs prompt validation (targeted review
finding); harness change recorded as non-behavioral.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): exact backup wording, the hook-removal warning and GST-1744 (GST-1717)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(setup): one backup folder per host for a retired skill's copies (GST-1717)

Under grant a-c95576b8b1f9 (fix-check#1): every copy host moved its
gstack-investigate/SKILL.md to the same backup path, so a later host's copy
overwrote an earlier host's edits. A sandboxed ./setup --host factory run showed
gen-skill-docs removes the retired render before the prune, so every real copy
is backed up; each host now gets its own folder.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): say exactly what setup backs up for a retired skill (GST-1717)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): end the two colon sentences prose-lint flagged (GST-1717)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(retired-skills): name plan-eng-review once behind a pregate-literal waiver (GST-1717)

pregate's literals check read the GSTACK_RETIRED_SKILLS change as an old
value still in the tests. plan-eng-review is still retired; investigate was
appended. The fixture names it once, with the waiver.

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>
2026-11-07 06:52:04 +00:00

172 lines
11 KiB
TypeScript

import { expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { computePaidCaseSelection } from '../scripts/test-paid-shards';
import { PR_PROFILE_CASE_IDS, selectPrProfile, type PrProfileMaps } from '../scripts/test-pr-profile';
import { E2E_TIERS, E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles-data';
const ROOT = path.resolve(import.meta.dir, '..');
const sharedInputs = [
'package.json', 'bun.lock', '.github/docker/Dockerfile.ci',
'scripts/host-config.ts', 'scripts/discover-skills.ts', 'hosts/index.ts',
];
const skipId = 'ship-skipped-queued-finding';
const gateIds = Object.keys(E2E_TOUCHFILES).filter(id => E2E_TIERS[id] === 'gate').sort();
// Periodic and marathon cases are both deferred by the PR gate; only their lanes run them.
const deferredIds = Object.keys(E2E_TOUCHFILES).filter(id => E2E_TIERS[id] === 'periodic' || E2E_TIERS[id] === 'marathon').sort();
const judgeIds = Object.keys(LLM_JUDGE_TOUCHFILES).sort();
// Every selection names its base and package.json history explicitly: an
// omitted base runs detectBaseBranch and an omitted packageVersionOnly diffs
// package.json against the checkout's merge-base, so the verdict would follow
// whatever branch CI checked out (main runs 36572856858 and 36594314027).
const select = (profile: 'pr' | 'full', changedFiles: string[], packageVersionOnly = false) =>
computePaidCaseSelection({ profile, env: { EVALS_BASE: 'fixture-base' }, changedFiles, packageVersionOnly });
test.each(sharedInputs)('%s retains the full gate after native dependency registration', file => {
const result = select('pr', [file]);
expect(result.coverage?.mode).toBe('full-fallback');
expect(result.selection.e2e).toEqual(gateIds);
expect(result.selection.judges).toEqual(judgeIds);
expect(result.coverage?.deferred.map(({ id }) => id).sort()).toEqual(deferredIds);
expect(result.coverage?.reasons).toContain(`Shared runtime/build inputs restore every gate case and judge: ${file}`);
expect(result.coverage?.needsFullValidation).toBe(false);
});
test('a version-only package.json change does not restore the full gate', () => {
const result = select('pr', ['package.json'], true);
expect(result.coverage?.mode).not.toBe('full-fallback');
expect(result.coverage?.reasons.join('\n')).not.toContain('Shared runtime/build inputs restore every gate case and judge: package.json');
});
test.each(sharedInputs)('%s broad policy is independent of native, judge and global maps', file => {
for (const registration of ['none', 'broad', 'fast', 'judge', 'global']) {
const maps: PrProfileMaps = {
e2eTouchfiles: { fast: [], broad: [], periodic: [] },
judgeTouchfiles: { quality: [] },
tiers: { fast: 'gate', broad: 'gate', periodic: 'periodic' },
globalTouchfiles: [],
};
if (registration === 'broad' || registration === 'fast') maps.e2eTouchfiles[registration].push(file);
if (registration === 'judge') maps.judgeTouchfiles.quality.push(file);
if (registration === 'global') maps.globalTouchfiles = [file];
const result = selectPrProfile({ maps, profile: ['fast'], changedFiles: [file.replaceAll('/', '\\')],
selectedE2E: [], selectedJudges: [] });
expect(result.mode, registration).toBe('full-fallback');
expect(result.e2e, registration).toEqual(['broad', 'fast']);
expect(result.judges, registration).toEqual(['quality']);
expect(result.deferred.map(({ id }) => id), registration).toEqual(['periodic']);
expect(result.unknownFiles, registration).toEqual(registration === 'none' ? [file] : []);
}
});
test.each(['test/helpers/ship-skip-actor.ts'])
('%s remains explicitly deferred by the fast profile, not promoted', file => {
expect(PR_PROFILE_CASE_IDS as readonly string[]).not.toContain(skipId);
const result = select('pr', [file]);
expect(result.coverage?.mode).toBe('pr');
expect(result.selection).toEqual({ e2e: [], judges: [] });
expect(result.coverage?.deferred).toEqual([{
id: skipId, tier: 'gate', reason: 'Broad gate census/release coverage; outside the fast PR profile',
}]);
const full = select('full', [file]);
expect(full.selection).toEqual({ e2e: [skipId], judges: [] });
});
test('editing the case\'s own test file runs it in the PR lane (DX-11)', () => {
const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: ['test/skill-e2e-ship-skip.test.ts'] });
expect(result.coverage?.mode).toBe('pr');
expect(result.selection).toEqual({ e2e: [skipId], judges: [] });
expect(result.coverage?.directCases).toEqual([skipId]);
});
test('cumulative shared, native and prompt edits retain every gate case and judge', () => {
const result = select('pr', [...sharedInputs, 'test/helpers/ship-skip-actor.ts', 'qa-only/SKILL.md.tmpl']);
expect(result.coverage?.mode).toBe('full-fallback');
expect(result.selection.e2e).toEqual(gateIds);
expect(result.selection.judges).toEqual(judgeIds);
expect(result.coverage?.deferred.map(({ id }) => id).sort()).toEqual(deferredIds);
expect(result.coverage?.needsFullValidation).toBe(false);
});
// GST-1575: no paid run executes ./setup (evals.yml only builds), so a narrow screen derives it like any
// other file. Diffs of #4045 (9e809ab) and #4047 (4743132), whose narrow screens (runs 42093259982 and
// 42103721891) fell back to all gate cases on setup alone. GST-1615: setup now runs bin/gstack-skill-gates, so
// that file derives setup's consumers and the screen runs in dependents mode, which also keeps the gate case
// whose touchfiles name bin/gstack-config (ship-skipped-queued-finding) instead of intersecting with the profile.
const setupHookCases = ['gstack-upgrade-happy-path', 'ship-local-hook-preservation', 'ship-managed-hook-refresh',
'ship-skipped-queued-finding', 'ship-unmanaged-hook-consent', 'skillmd-setup-discovery'];
const setupScreens: Record<string, string[]> = {
'#4045': ['bin/gstack-config', 'bin/gstack-settings-hook', 'bin/gstack-skill-gates', 'bin/gstack-skill-route',
'changelog.d/capy-gst-1461-investigate-gate.md', 'docs/skill-gates.md', 'docs/skill-routing-and-contracts.md',
'hosts/claude/hooks/investigate-gate-post', 'hosts/claude/hooks/investigate-gate-post-failure',
'hosts/claude/hooks/investigate-gate-pre', 'hosts/claude/hooks/investigate-gate-stop',
'hosts/claude/hooks/investigate-gate.ts', 'lib/investigate-gate.ts', 'lib/skill-contract.ts', 'lib/skill-gates.ts',
'lib/skill-route.ts', 'scripts/skill-gates-smoke.ts', 'setup', 'test/investigate-gate.test.ts',
'test/setup-hook-syntax-gate.test.ts', 'test/setup-skill-hooks-gate.test.ts',
'test/skill-gates-advisory-bytes.test.ts', 'test/skill-gates.test.ts'],
'#4047': ['bin/gstack-config', 'bin/gstack-settings-hook', 'bin/gstack-skill-gates', 'bin/gstack-skill-route',
'changelog.d/capy-gst-1461-ceo-review-gate.md', 'docs/skill-gates.md', 'docs/skill-routing-and-contracts.md',
'hosts/claude/hooks/ceo-review-gate-stop', 'hosts/claude/hooks/ceo-review-gate-subagent-stop',
'hosts/claude/hooks/ceo-review-gate.ts', 'hosts/claude/hooks/investigate-gate.ts',
'hosts/claude/hooks/skill-contract-stop-hook.ts', 'hosts/claude/hooks/spawn-bin.ts', 'lib/ceo-review-gate.ts',
'lib/investigate-gate.ts', 'lib/skill-contract.ts', 'lib/skill-gates.ts', 'lib/skill-route.ts',
'scripts/skill-gates-smoke.ts', 'setup', 'test/ceo-review-gate.test.ts', 'test/investigate-gate.test.ts',
'test/setup-hook-syntax-gate.test.ts', 'test/setup-skill-hooks-gate.test.ts',
'test/skill-gates-advisory-bytes.test.ts', 'test/skill-gates.test.ts'],
};
const screen = (policy: 'narrow' | 'broad', changedFiles: string[]) => computePaidCaseSelection({
profile: 'pr', env: { EVALS_BASE: 'fixture-base', EVALS_SCREEN_POLICY: policy }, changedFiles, packageVersionOnly: false });
// GST-1717 deleted the /investigate gate files: a deleted hook under hosts/claude/hooks/ is still "referenced" by setup's
// hook paths and restores the full gate, so the replay screens only the files that still exist.
test.each(Object.entries(setupScreens))('a narrow screen of %s derives setup and selects its six cases', (_pr, files) => {
const result = screen('narrow', files.filter(file => fs.existsSync(path.join(ROOT, file))));
expect(result.coverage?.mode).not.toBe('full-fallback');
expect(result.selection.e2e).toEqual(setupHookCases);
expect(result.coverage?.reasons.join('\n')).not.toContain('Shared runtime/build inputs');
});
test('a narrow screen of setup alone runs only the gate cases that invoke it', () => {
const result = screen('narrow', ['setup']);
expect(result.coverage?.mode).not.toBe('full-fallback');
expect(result.selection.e2e).toEqual(['gstack-upgrade-happy-path', 'skillmd-setup-discovery']);
});
test.each(Object.entries(setupScreens))('%s keeps the full gate on a broad screen, and --profile full ignores the screen policy', (_pr, files) => {
for (const changed of [files, ['setup']]) {
const broad = screen('broad', changed);
expect(broad.coverage?.mode).toBe('full-fallback');
expect(broad.selection.e2e).toEqual(gateIds);
expect(broad.selection.judges).toEqual(judgeIds);
expect(broad.coverage?.reasons).toContain('Shared runtime/build inputs restore every gate case and judge: setup');
const full = (policy: string, all = '') => computePaidCaseSelection({ profile: 'full',
env: { EVALS_BASE: 'fixture-base', EVALS_SCREEN_POLICY: policy, EVALS_ALL: all }, changedFiles: changed, packageVersionOnly: false });
expect(full('narrow')).toEqual(full('broad'));
expect(full('narrow', '1')).toEqual({ selection: { e2e: null, judges: null }, reason: 'run-all' });
}
});
test('narrow screens still restore the full gate for every other shared input', () => {
for (const file of sharedInputs) {
const result = screen('narrow', [file]);
expect(result.coverage?.mode, file).toBe('full-fallback');
expect(result.selection.e2e, file).toEqual(gateIds);
}
});
// GST-1575: modules/loadouts.json is read through scripts/modules.ts (LOADOUTS_FILE), whose static closure
// reaches every gate case; no paid case passes --loadout. Derived like that reader instead of an unknown file.
test('modules/loadouts.json is derived through its reader instead of restoring the full gate', () => {
const narrow = screen('narrow', ['modules/loadouts.json']);
expect(narrow.coverage?.mode).toBe('pr');
expect(narrow.selection).toEqual({ e2e: [], judges: [] });
expect(narrow.coverage?.hubFiles).toEqual(['modules/loadouts.json']);
const broad = screen('broad', ['modules/loadouts.json']);
expect(broad.coverage?.mode).toBe('dependents');
expect(broad.selection.e2e).toEqual(gateIds);
expect(broad.coverage?.unknownFiles).toEqual([]);
});