Files
gstack/test/skill-bin-drift.test.ts
Garry Tanandcapy-ai[bot] 7204a39a27 v1.91.287.0 feat(write-skill): write, change and diet skills; /learn at half its template size (#3524)
* test(skill-evals): /learn before/after tasks, written before any cut (GSTA-133)

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

* fix(learnings): --help, unknown-flag errors and --stats on the learnings bins; cross-project search no longer drops every project when the state root path contains the slug

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

* refactor(learn): diet /learn onto the levels: tool flags replace the inline stats program, one mode table, prune rules kept

Skill-Diet: learn
Skill-Size: 5797→2586 bytes (SKILL.md.tmpl)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(write-skill): /write-skill skill, gstack-skill-bill, static skill-to-bin drift ratchet, shared skill eval contract

Skill-Size: 0→3245 bytes (write-skill/SKILL.md.tmpl)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(learn): guard the state-root lookup in Prune; re-measure the learn ceiling

Skill-Size: 2586→2707 bytes (learn/SKILL.md.tmpl)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (drift scanner, learnings flags, skill-bill baselines, eval fixtures)

- skill-bin-drift: a redirect no longer ends a command, so `bin/x 2>/dev/null --bogus` is checked
- gstack-learnings-search: a missing value or a non-positive --limit exits 2 like an unknown flag
- gstack-skill-bill: the gstack root skill is a checkout skill; Skill-Diet trailers match the exact name; the baseline counts the committed bytes untrimmed
- skill-evals task files get a free contract test and are registered in FREE_FIXTURES, with the drift allowlist
- learn eval: the no-code-change safety task no longer asks the model to fix code (its check penalised obeying the user)

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

* fix(learn,write-skill): full bin paths in the mode table; prune asks per entry unless the request names the action

Skill-Diet: learn
Skill-Size: 2707→2955 bytes (learn/SKILL.md.tmpl)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): /write-skill, gstack-skill-bill, drift ratchet, eval contract, /learn diet

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

* docs: drift ratchet in CONTRIBUTING's static tests; /learn prune example asks per entry

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

* fix(skill-bill): report a checkout skill's eval task file and check it against the contract

The skill-evals task-file test now exercises lib/skill-bill.ts's checkEvalTasks, with a negative control, instead of a test-only validator (test-of-test ratchet).

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

* fix(skill-bill): EVAL TASKS path uses forward slashes on every platform; a non-object task entry is a problem, not a crash

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

* fix(learn): BASH_COMPAT guard on in-window heredocs; count skill-e2e-write-skill in the paid census

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-10-28 02:58:29 +00:00

236 lines
12 KiB
TypeScript

/**
* GSTA-133 ratchet: every gstack bin a skill invokes exists, and every --flag a
* skill passes to it appears in that bin's parser. scripts/skill-bin-drift.ts
* reads templates, carved sections and Claude-host renders statically (it never
* executes a bin). Today's misses live in test/fixtures/skill-bin-drift-allowlist.json,
* which may only shrink: a new finding fails, and so does an entry that no
* longer reproduces. PERMISSIVE and NO_HELP bins are reported, never failed.
*/
import { afterAll, 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 { bunIsolationArgs } from '../lib/bun-isolation';
import {
ALLOWLIST_FILE,
FIX_LINE,
compareAllowlist,
formatFinding,
loadAllowlist,
scanRepo,
scanText,
} from '../scripts/skill-bin-drift';
const ROOT = path.resolve(import.meta.dir, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'skill-bin-drift.ts');
describe('skill-bin-drift: repository ratchet', () => {
const report = scanRepo(ROOT);
const allowlist = loadAllowlist(path.join(ROOT, ALLOWLIST_FILE));
test('reads templates, carved sections and Claude-host renders', () => {
expect(report.inputs).toEqual(expect.arrayContaining(['learn/SKILL.md.tmpl', 'learn/SKILL.md', 'ship/sections/pr-body.md.tmpl', 'ship/sections/pr-body.md']));
expect(report.calls.length).toBeGreaterThan(500);
expect(report.binsInvoked.length).toBeGreaterThan(50);
});
test('no drift outside the shrink-only allowlist', () => {
const { unlisted } = compareAllowlist(report.findings, allowlist);
if (unlisted.length > 0) throw new Error(`${unlisted.length} skill-to-bin drift finding(s):\n${unlisted.map(formatFinding).join('\n')}`);
});
test('every allowlist entry still reproduces, so the list only shrinks', () => {
const { stale } = compareAllowlist(report.findings, allowlist);
if (stale.length > 0) throw new Error(`delete these entries from ${ALLOWLIST_FILE}; they no longer reproduce:\n${stale.join('\n')}`);
});
test('allowlist is sorted, unique and explains itself', () => {
expect(allowlist._comment).toContain('Shrink-only');
expect(allowlist.entries).toEqual([...new Set(allowlist.entries)].sort());
});
test('help backlog and permissive parsers are listed, not failed', () => {
for (const bin of [...report.noHelp, ...report.permissive]) expect(report.binsInvoked).toContain(bin);
for (const bin of ['gstack-learnings-search', 'gstack-learnings-log']) {
expect(report.permissive).not.toContain(bin);
expect(report.noHelp).not.toContain(bin);
}
});
});
describe('skill-bin-drift: fixtures', () => {
const roots: string[] = [];
afterAll(() => { for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); });
function fixture(files: Record<string, string>): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skill-bin-drift-'));
roots.push(root);
for (const [rel, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), text);
}
return root;
}
const BASH_BIN = ['#!/usr/bin/env bash',
'# gstack-notes-search — read notes',
'# Usage: gstack-notes-search [--type TYPE] [--limit N]',
'set -euo pipefail',
'TYPE=""; LIMIT=10',
'while [[ $# -gt 0 ]]; do',
' case "$1" in',
' --type) TYPE="$2"; shift 2 ;;',
' --limit|--max) LIMIT="$2"; shift 2 ;;',
' -h|--help) sed -n 2,3p "$0"; exit 0 ;;',
' -*) echo "gstack-notes-search: unknown flag $1" >&2; exit 2 ;;',
' *) shift ;;',
' esac',
'done',
'echo "$TYPE $LIMIT"',
''].join('\n');
const PERMISSIVE_BIN = ['#!/usr/bin/env bash',
'while [[ $# -gt 0 ]]; do',
' case "$1" in',
' --type) TYPE="$2"; shift 2 ;;',
' *) shift ;;',
' esac',
'done',
''].join('\n');
const skill = (body: string) => `---\nname: demo\n---\n# Demo\n\n${body}\n`;
const fence = (cmd: string) => ['```bash', cmd, '```'].join('\n');
test('a real-shaped bash bin with case arms and a valid flag passes', () => {
const root = fixture({
'bin/gstack-notes-search': BASH_BIN,
'demo/SKILL.md.tmpl': skill(fence('~/.claude/skills/gstack/bin/gstack-notes-search --type pitfall --max 5 2>/dev/null || true')),
});
const report = scanRepo(root);
expect(report.calls).toEqual([{ file: 'demo/SKILL.md.tmpl', line: 7, bin: 'gstack-notes-search', flags: ['--type', '--max'] }]);
expect(report.findings).toEqual([]);
expect(report.permissive).toEqual([]);
expect(report.noHelp).toEqual([]);
});
test('negative control: an invented flag on a real bin fails with file:line and the fix line', () => {
const root = fixture({
'bin/gstack-notes-search': BASH_BIN,
'demo/SKILL.md.tmpl': skill(fence('~/.claude/skills/gstack/bin/gstack-notes-search --type pitfall --invented-flag')),
});
const [finding, ...rest] = scanRepo(root).findings;
expect(rest).toEqual([]);
expect(finding).toMatchObject({ code: 'DRIFT_UNKNOWN_FLAG', file: 'demo/SKILL.md.tmpl', line: 7, bin: 'gstack-notes-search', flag: '--invented-flag', key: 'demo/SKILL.md.tmpl:gstack-notes-search:--invented-flag', fix: FIX_LINE });
expect(formatFinding(finding!)).toContain('DRIFT_UNKNOWN_FLAG demo/SKILL.md.tmpl:7');
});
test('flags after a pipe, ;, && or $( ) belong to the next command', () => {
const cmds = [
'bin/gstack-notes-search --type a | grep --count --fixed-strings x',
'bin/gstack-notes-search --limit 3; git log --oneline',
'bin/gstack-notes-search && jq --raw-output .x',
'bin/gstack-notes-search --limit 2 2>&1 | grep --count x',
'X=$(bin/gstack-notes-search --type a) || sort --unique',
].join('\n');
const root = fixture({ 'bin/gstack-notes-search': BASH_BIN, 'demo/SKILL.md.tmpl': skill(fence(cmds)) });
const report = scanRepo(root);
expect(report.calls.map(c => c.flags)).toEqual([['--type'], ['--limit'], [], ['--limit'], ['--type']]);
expect(report.findings).toEqual([]);
});
test('a redirect does not end the command: flags after it are still checked', () => {
const cmds = [
'bin/gstack-notes-search --type a 2>/dev/null --bogus',
'bin/gstack-notes-search >> out.txt --max 3',
'bin/gstack-notes-search 2> err.log --limit 1',
].join('\n');
const root = fixture({ 'bin/gstack-notes-search': BASH_BIN, 'demo/SKILL.md.tmpl': skill(fence(cmds)) });
const report = scanRepo(root);
expect(report.calls.map(c => c.flags)).toEqual([['--type', '--bogus'], ['--max'], ['--limit']]);
expect(report.findings.map(f => f.flag)).toEqual(['--bogus']);
});
test('a TS bin with string-literal flags one relative import deep passes; two deep does not count', () => {
const root = fixture({
'bin/gstack-ts-tool': `#!/usr/bin/env -S bun --no-env-file --config=/dev/null\nimport { parse } from '../lib/tool-args';\nparse(process.argv.slice(2));\n`,
'lib/tool-args.ts': `import { deeper } from './tool-deeper';\nexport function parse(argv: string[]) { return argv.includes('--dry-run') || argv.includes("--json") || deeper(argv); }\n`,
'lib/tool-deeper.ts': `export function deeper(argv: string[]) { return argv.includes('--too-deep'); }\n`,
'demo/SKILL.md.tmpl': skill('Run `bin/gstack-ts-tool --dry-run --json`, then `bin/gstack-ts-tool --too-deep`.'),
});
const report = scanRepo(root);
expect(report.findings.map(f => f.key)).toEqual(['demo/SKILL.md.tmpl:gstack-ts-tool:--too-deep']);
});
test('a bash bin that hands off to a .ts file gains its flags', () => {
const root = fixture({
'bin/gstack-wrapper': '#!/usr/bin/env bash\n# Usage: gstack-wrapper [--fast]\nexec bun run "$(dirname "$0")/../lib/wrapper-impl.ts" "$@"\n',
'lib/wrapper-impl.ts': "const fast = process.argv.includes('--fast');\n",
'demo/SKILL.md.tmpl': skill(fence('$GSTACK_BIN/gstack-wrapper --fast')),
});
expect(scanRepo(root).findings).toEqual([]);
});
test('a catch-all arg loop is PERMISSIVE, and its unknown flags still fail', () => {
const root = fixture({
'bin/gstack-loose': PERMISSIVE_BIN,
'demo/SKILL.md.tmpl': skill(fence('{{BIN_DIR}}/gstack-loose --type x --ignored')),
});
const report = scanRepo(root);
expect(report.permissive).toEqual(['gstack-loose']);
expect(report.noHelp).toEqual(['gstack-loose']);
expect(report.findings.map(f => f.key)).toEqual(['demo/SKILL.md.tmpl:gstack-loose:--ignored']);
});
test('a missing bin is DRIFT_UNKNOWN_BIN keyed without a flag', () => {
const root = fixture({ 'bin/.keep': '', 'demo/SKILL.md.tmpl': skill(fence('~/.claude/skills/gstack/bin/gstack-gone --x')) });
expect(scanRepo(root).findings.map(f => [f.code, f.key])).toEqual([['DRIFT_UNKNOWN_BIN', 'demo/SKILL.md.tmpl:gstack-gone']]);
});
test('prose and placeholders do not false-positive', () => {
const text = [
'Pass bin/gstack-notes-search --nope <VALUE> in prose: not code, not scanned.',
'`bin/gstack-notes-search --type <TYPE> --limit {{LIMIT}}`',
fence('bin/gstack-notes-search --type <kind of note> --limit "$N" # --not-a-flag in a comment'),
].join('\n');
const calls = scanText(text, 'demo/SKILL.md.tmpl');
expect(calls.map(c => [c.line, c.flags])).toEqual([[2, ['--type', '--limit']], [4, ['--type', '--limit']]]);
});
test('a render finding its own template already reports is dropped; render-only findings stay', () => {
const root = fixture({
'bin/gstack-notes-search': BASH_BIN,
'demo/SKILL.md.tmpl': skill(fence('bin/gstack-notes-search --bad')),
'demo/SKILL.md': `<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n${fence('bin/gstack-notes-search --bad')}\n${fence('bin/gstack-notes-search --resolver-made')}\n`,
});
expect(scanRepo(root).findings.map(f => f.key)).toEqual(['demo/SKILL.md:gstack-notes-search:--resolver-made', 'demo/SKILL.md.tmpl:gstack-notes-search:--bad']);
});
test('allowlist comparison reports unlisted findings and stale entries', () => {
const root = fixture({ 'bin/gstack-notes-search': BASH_BIN, 'demo/SKILL.md.tmpl': skill(fence('bin/gstack-notes-search --bad')) });
const { findings } = scanRepo(root);
expect(compareAllowlist(findings, { entries: [] }).unlisted.map(f => f.key)).toEqual(['demo/SKILL.md.tmpl:gstack-notes-search:--bad']);
const result = compareAllowlist(findings, { entries: ['demo/SKILL.md.tmpl:gstack-notes-search:--bad', 'other/SKILL.md.tmpl:gstack-x:--y'] });
expect(result).toEqual({ unlisted: [], stale: ['other/SKILL.md.tmpl:gstack-x:--y'] });
expect(compareAllowlist(findings, { entries: ['other/SKILL.md.tmpl:gstack-x:--y'] }, ['demo/SKILL.md.tmpl']).stale).toEqual([]);
});
test('CLI: --skill scans one skill, --json reports, exit codes follow the allowlist', () => {
const root = fixture({
'bin/gstack-notes-search': BASH_BIN,
'demo/SKILL.md.tmpl': skill(fence('bin/gstack-notes-search --bad')),
'other/SKILL.md.tmpl': skill(fence('bin/gstack-notes-search --type ok')),
});
const run = (...args: string[]) => {
const child = Bun.spawnSync([process.execPath, ...bunIsolationArgs(), SCRIPT, '--root', root, ...args], { stdout: 'pipe', stderr: 'pipe', timeout: 20_000 });
return { code: child.exitCode, out: child.stdout.toString() + child.stderr.toString() };
};
const other = run('--skill', 'other', '--json');
expect(other.code).toBe(0);
expect(JSON.parse(other.out)).toMatchObject({ inputs: 1, calls: 1, unknownFlags: 0, unlisted: [] });
const demo = run('--skill', 'demo');
expect(demo.code).toBe(1);
expect(demo.out).toContain('DRIFT_UNKNOWN_FLAG demo/SKILL.md.tmpl:7 bin=gstack-notes-search flag=--bad');
expect(demo.out).toContain(FIX_LINE);
expect(run('--skill', 'missing').code).toBe(2);
expect(run('--help').code).toBe(0);
});
});