fix(timeline): reject a missing or malformed --limit with exit 2 (#1723)

gstack-timeline-read parsed --limit with parseInt and fell back to 20, so
'--limit 1abc' became 1, 'nope', 0 and -5 silently became the default, and a
bare --limit crashed on an unbound $2. Validate the argument as a positive
integer before the no-timeline early exit and exit 2 with an example. No flag
still means 20.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-08 06:20:39 +00:00
1 parent f67c478f05
commit f9796eef8e
2 files changed
+60 -6

No files matched your search

+13 -5
View File
@@ -5,7 +5,8 @@
# Session timeline: local-only, never sent anywhere.
# Reads ~/.gstack/projects/$SLUG/timeline.jsonl, filters, formats.
# 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).
# exits 127 and a failed read exits non-zero with its stderr (#2790). A missing
# or non-positive-integer --limit exits 2 before any read (#1723).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
@@ -20,7 +21,16 @@ BRANCH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
--limit) LIMIT="$2"; shift 2 ;;
--limit)
if [[ $# -lt 2 ]]; then
echo "gstack-timeline-read: --limit must be a positive integer (got no value); example: --limit 20" >&2
exit 2
fi
if [[ ! "$2" =~ ^[1-9][0-9]*$ ]]; then
echo "gstack-timeline-read: --limit must be a positive integer (got '$2'); example: --limit 20" >&2
exit 2
fi
LIMIT="$2"; shift 2 ;;
--branch) BRANCH="$2"; shift 2 ;;
*) shift ;;
esac
@@ -41,9 +51,7 @@ cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE="$SINCE" GSTACK_TIMELINE_BRANCH="$B
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 || '';
const limitRaw = process.env.GSTACK_TIMELINE_LIMIT || '20';
const parsedLimit = Number.parseInt(limitRaw, 10);
const limit = Number.isSafeInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20;
const limit = Number(process.env.GSTACK_TIMELINE_LIMIT);
let sinceMs = 0;
if (since) {
+47 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { execFileSync, execSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
import { execFileSync, execSync, spawnSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ -56,6 +56,16 @@ function runReadArgs(args: string[] = []): string {
}
}
function runReadStatus(args: string[]): { status: number | null; stdout: string; stderr: string } {
const result = spawnSync(path.join(BIN, 'gstack-timeline-read'), args, {
cwd: ROOT,
env: { ...process.env, GSTACK_HOME: tmpDir },
encoding: 'utf-8',
timeout: 15000,
});
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-timeline-'));
slugDir = path.join(tmpDir, 'projects');
@@ -176,4 +186,40 @@ describe('gstack-timeline-read', () => {
expect(unlimitedEvents).toBe(5);
expect(limitedEvents).toBe(2);
});
test('no --limit still shows the 20 most recent events', () => {
for (let i = 0; i < 25; i++) {
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', ts: `2026-03-01T10:${String(i).padStart(2, '0')}:00Z` }));
}
const result = runReadStatus([]);
expect(result.status).toBe(0);
const events = result.stdout.split('\n').filter(l => l.startsWith('- '));
expect(events.length).toBe(20);
expect(events[0]).toContain('10:05:00Z');
});
// #1723: a malformed --limit used to be parsed leniently (1abc -> 1) or
// silently replaced by the default; a bare --limit crashed on unbound $2.
const badLimits: Array<[string, string[], string]> = [
['missing value', ['--limit'], 'got no value'],
['zero', ['--limit', '0'], "got '0'"],
['negative', ['--limit', '-5'], "got '-5'"],
['non-numeric', ['--limit', 'abc'], "got 'abc'"],
['trailing garbage', ['--limit', '1abc'], "got '1abc'"],
];
for (const history of ['empty', 'populated'] as const) {
for (const [label, args, got] of badLimits) {
test(`--limit ${label} exits 2 with an example on ${history} history`, () => {
if (history === 'populated') {
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', ts: '2026-03-28T10:00:00Z' }));
}
const result = runReadStatus(args);
expect(result.status).toBe(2);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('--limit must be a positive integer');
expect(result.stderr).toContain(got);
expect(result.stderr).toContain('example: --limit 20');
});
}
}
});