diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index 4e3d8ba46..888d61cbf 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -4,7 +4,9 @@ # # Reads ~/.gstack/projects/$SLUG/learnings.jsonl, applies confidence decay, # resolves duplicates (latest winner per key+type), and outputs formatted text. -# Exit 0 silently if no learnings file exists. +# Exit 0 silently if no learnings file exists or nothing matches. Missing bun +# exits 127 and a failed read exits non-zero with its stderr (#2790): "could +# not read" must never look like "nothing recorded". set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" @@ -45,6 +47,11 @@ if [ ! -f "$LEARNINGS_FILE" ] && [ ${#CROSS_FILES[@]} -eq 0 ]; then exit 0 fi +command -v bun >/dev/null 2>&1 || { + echo "gstack-learnings-search: bun not found on PATH, so learnings could not be read. Fix: install Bun (https://bun.sh), then re-run ./setup." >&2 + exit 127 +} + emit_tagged_file() { local tag="$1" local file="$2" @@ -161,4 +168,4 @@ for (const [t, arr] of Object.entries(byType)) { } console.log(''); } -" 2>/dev/null || exit 0 +" diff --git a/bin/gstack-timeline-read b/bin/gstack-timeline-read index be2dc1d60..e57d039cb 100755 --- a/bin/gstack-timeline-read +++ b/bin/gstack-timeline-read @@ -4,7 +4,8 @@ # # Session timeline: local-only, never sent anywhere. # Reads ~/.gstack/projects/$SLUG/timeline.jsonl, filters, formats. -# Exit 0 silently if no timeline file exists. +# Exit 0 silently if no timeline file exists or nothing matches. Missing bun +# exits 127 and a failed read exits non-zero with its stderr (#2790). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" @@ -31,7 +32,12 @@ if [ ! -f "$TIMELINE_FILE" ]; then exit 0 fi -cat "$TIMELINE_FILE" 2>/dev/null | GSTACK_TIMELINE_SINCE="$SINCE" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun -e " +command -v bun >/dev/null 2>&1 || { + echo "gstack-timeline-read: bun not found on PATH, so the timeline could not be read. Fix: install Bun (https://bun.sh), then re-run ./setup." >&2 + exit 127 +} + +cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE="$SINCE" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun -e " const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); const since = process.env.GSTACK_TIMELINE_SINCE || ''; const branch = process.env.GSTACK_TIMELINE_BRANCH || ''; @@ -95,4 +101,4 @@ for (const e of recent) { const outcome = e.outcome ? ' [' + e.outcome + ']' : ''; console.log('- ' + ts + ' /' + e.skill + ' ' + e.event + outcome + dur + (e.branch ? ' on ' + e.branch : '')); } -" 2>/dev/null || exit 0 +" diff --git a/test/gstack-learnings-search.test.ts b/test/gstack-learnings-search.test.ts index ffa0227ae..1d11514b2 100644 --- a/test/gstack-learnings-search.test.ts +++ b/test/gstack-learnings-search.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execFileSync } from 'child_process'; +import { execFileSync, spawnSync } from 'child_process'; const ROOT = path.resolve(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin', 'gstack-learnings-search'); @@ -91,3 +91,53 @@ describe('gstack-learnings-search cross-project trust gating', () => { expect(out).not.toContain('foreign-legacy'); }); }); + +// B5 (#2790): both query scripts ended in `2>/dev/null || exit 0`, so a +// missing bun or a crashed embedded script printed nothing and exited 0, +// exactly like "nothing recorded". +describe('B5: query scripts never report a failed read as an empty result', () => { + const TOOLS = ['bash', 'sh', 'env', 'dirname', 'basename', 'git', 'tr', 'sed', 'cat', 'head', 'tail', 'grep', 'find', + 'ls', 'mkdir', 'mktemp', 'mv', 'rm', 'cp', 'awk', 'wc', 'sort', 'uname', 'date', 'cut', 'readlink', 'realpath', 'stat', 'touch', 'id', 'printf']; + const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-nobun-')); + const failDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-failbun-')); + for (const tool of TOOLS) { + const r = spawnSync('bash', ['-c', `command -v ${tool}`], { encoding: 'utf-8', timeout: 5000 }); + const where = (r.stdout || '').trim(); + if (where.startsWith('/')) fs.symlinkSync(where, path.join(shimDir, tool)); + } + fs.writeFileSync(path.join(failDir, 'bun'), '#!/bin/sh\necho "embedded script exploded" >&2\nexit 3\n', { mode: 0o755 }); + const timelineDir = path.join(tmpHome, 'projects', slug); + afterAll(() => { + fs.rmSync(shimDir, { recursive: true, force: true }); + fs.rmSync(failDir, { recursive: true, force: true }); + }); + + function runWith(script: string, pathValue: string, args: string[] = []) { + return spawnSync('bash', [path.join(ROOT, 'bin', script), ...args], { + timeout: 30_000, env: { ...process.env, GSTACK_HOME: tmpHome, PATH: pathValue }, cwd: tmpCwd, encoding: 'utf-8', + }); + } + + for (const script of ['gstack-learnings-search', 'gstack-timeline-read']) { + test(`${script}: bun missing exits 127 with the fix line`, () => { + fs.writeFileSync(path.join(timelineDir, 'timeline.jsonl'), JSON.stringify({ ts: '2026-05-01T00:00:00Z', skill: 'ship', event: 'started', branch: 'main' }) + '\n'); + const r = runWith(script, shimDir); + expect(r.status).toBe(127); + expect(r.stdout).toBe(''); + expect(r.stderr).toContain(`${script}: bun not found on PATH`); + expect(r.stderr).toContain('Fix: install Bun (https://bun.sh), then re-run ./setup.'); + }); + + test(`${script}: a failing embedded script exits non-zero and keeps its stderr`, () => { + const r = runWith(script, `${failDir}:${process.env.PATH}`); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('embedded script exploded'); + }); + } + + test('a successful search with no matches still exits 0 with empty output', () => { + const r = runWith('gstack-learnings-search', process.env.PATH!, ['--query', 'zzz-no-such-token']); + expect(r.status).toBe(0); + expect(r.stdout).toBe(''); + }); +});