mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 07:29:00 +02:00
Specialist army findings, all quote-verified before fixing:
Security: careful force-push guard now catches git's plus-refspec force
syntax (git push origin +main carried force with no flag — silently allowed
before) and refspec-form targets (HEAD:main); default-branch matching is
tokenized FIXED-STRING comparison on the full branch path (slashed defaults
like release/2.0 work; no ERE interpolation), glob-safe via noglob. HIGH rm
tier is tokenized too: trailing long options (--no-preserve-root) and /* are
root-class. Stored evidence fingerprints are 40-hex re-validated before
reaching git argv. normalizeForDetection sweeps ALL Unicode format chars
(\p{Cf}: soft hyphens, bidi marks, tag chars) instead of five enumerated
zero-widths. The wiring scanner gains flagless gh pr/issue view patterns. The
release-body banner tripwire diffs against the fetched original so a hostile
pre-existing banner string can't permanently DoS doc updates. Ship/land
evidence checks now pass --expect-cmd (a green `echo ok` recorded under the
label can never mint FRESH); package.json stays allow-listed with the
residual documented.
Performance: gstack-wtree seeds its temp index by COPYING the real index
(stat cache preserved — measured 40x faster than read-tree seeding, identical
hash) with read-tree fallback; evidence uses findLast and one gstack-slug
spawn; the stream pump honors backpressure via drain; careful's pattern block
short-circuits before slug resolution when no pattern file exists.
Testing: the gh-failure envelope test was VACUOUS (killing PATH killed the
bun shebang before the code under test ran) — replaced with a PATH gh shim
that exercises the real branch, plus shimmed happy paths (issue/pr-body/
unparseable JSON); evidence check --all + empty ledger + non-numeric
--max-age (now a usage error, was silent fail-open) covered; HIGH-tier
variants pinned; hook analytics respect GSTACK_HOME so tests stop writing the
operator's real skill-usage.jsonl.
Maintainability: dead exit ternary removed; flagValue deduped into
bin-context; sentinel defusal derived from the banner constants (no invisible
literals — \u escapes only); scratch-repo git fixture extracted to
test/helpers/scratch-repo.ts (one hermetic incantation, three consumers);
shared gstack_hook_log_fire in hook-extract.sh; the dashboard/land diff-scoped
row lists are aligned (codex-review) and drift-pinned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
213 lines
8.2 KiB
TypeScript
213 lines
8.2 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { execSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { gitIn } from './helpers/scratch-repo';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin');
|
|
|
|
let tmpDir: string;
|
|
let slugDir: string;
|
|
|
|
function run(input: string, opts: { expectFail?: boolean } = {}): { stdout: string; exitCode: number } {
|
|
const execOpts: ExecSyncOptionsWithStringEncoding = {
|
|
cwd: ROOT,
|
|
env: { ...process.env, GSTACK_HOME: tmpDir },
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
};
|
|
try {
|
|
const stdout = execSync(`${BIN}/gstack-review-log '${input.replace(/'/g, "'\\''")}'`, execOpts).trim();
|
|
return { stdout, exitCode: 0 };
|
|
} catch (e: any) {
|
|
if (opts.expectFail) {
|
|
return { stdout: e.stderr?.toString() || '', exitCode: e.status || 1 };
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-revlog-'));
|
|
// gstack-review-log uses gstack-slug which needs a git repo — create the projects dir
|
|
// with a predictable slug by pre-creating the directory structure
|
|
slugDir = path.join(tmpDir, 'projects');
|
|
fs.mkdirSync(slugDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('gstack-review-log', () => {
|
|
test('appends valid JSON to review JSONL file', () => {
|
|
const input = '{"skill":"plan-eng-review","status":"clean"}';
|
|
const result = run(input);
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
// Find the JSONL file that was written
|
|
const projectDirs = fs.readdirSync(slugDir);
|
|
expect(projectDirs.length).toBeGreaterThan(0);
|
|
const projectDir = path.join(slugDir, projectDirs[0]);
|
|
const jsonlFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|
|
expect(jsonlFiles.length).toBeGreaterThan(0);
|
|
|
|
const content = fs.readFileSync(path.join(projectDir, jsonlFiles[0]), 'utf-8').trim();
|
|
const parsed = JSON.parse(content);
|
|
expect(parsed.skill).toBe('plan-eng-review');
|
|
expect(parsed.status).toBe('clean');
|
|
});
|
|
|
|
test('rejects non-JSON input with non-zero exit code', () => {
|
|
const result = run('not json at all', { expectFail: true });
|
|
expect(result.exitCode).not.toBe(0);
|
|
|
|
// Verify nothing was written
|
|
const projectDirs = fs.readdirSync(slugDir);
|
|
if (projectDirs.length > 0) {
|
|
const projectDir = path.join(slugDir, projectDirs[0]);
|
|
const jsonlFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|
|
if (jsonlFiles.length > 0) {
|
|
const content = fs.readFileSync(path.join(projectDir, jsonlFiles[0]), 'utf-8').trim();
|
|
expect(content).toBe('');
|
|
}
|
|
}
|
|
});
|
|
|
|
function readNewestRecord(): any {
|
|
const projectDirs = fs.readdirSync(slugDir);
|
|
const projectDir = path.join(slugDir, projectDirs[0]);
|
|
const jsonlFiles = fs.readdirSync(projectDir).filter((f) => f.endsWith('.jsonl'));
|
|
const content = fs.readFileSync(path.join(projectDir, jsonlFiles[0]), 'utf-8').trim();
|
|
const lines = content.split('\n');
|
|
return JSON.parse(lines[lines.length - 1]);
|
|
}
|
|
|
|
test('stamps authoritative binding fields (commit_full, tree, wtree, dirty) in a git repo', () => {
|
|
const result = run('{"skill":"review","status":"clean"}');
|
|
expect(result.exitCode).toBe(0);
|
|
const rec = readNewestRecord();
|
|
expect(rec.commit_full).toMatch(/^[0-9a-f]{40}$/);
|
|
expect(rec.tree).toMatch(/^[0-9a-f]{40}$/);
|
|
expect(rec.wtree).toMatch(/^[0-9a-f]{40}$/);
|
|
expect(typeof rec.dirty).toBe('boolean');
|
|
// Non-binding caller fields pass through untouched.
|
|
expect(rec.skill).toBe('review');
|
|
expect(rec.status).toBe('clean');
|
|
});
|
|
|
|
test('caller-supplied binding fields are IGNORED, never trusted', () => {
|
|
const forged = '{"skill":"review","status":"clean","wtree":"forged","tree":"forged","commit_full":"forged","dirty":"forged"}';
|
|
const result = run(forged);
|
|
expect(result.exitCode).toBe(0);
|
|
const rec = readNewestRecord();
|
|
expect(rec.wtree).not.toBe('forged');
|
|
expect(rec.tree).not.toBe('forged');
|
|
expect(rec.commit_full).not.toBe('forged');
|
|
expect(rec.dirty).not.toBe('forged');
|
|
expect(rec.wtree).toMatch(/^[0-9a-f]{40}$/);
|
|
});
|
|
|
|
test('append still succeeds outside a git repo (binding fields omitted)', () => {
|
|
const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-nongit-'));
|
|
try {
|
|
const execOpts: ExecSyncOptionsWithStringEncoding = {
|
|
cwd: nonGit,
|
|
env: { ...process.env, GSTACK_HOME: tmpDir },
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
};
|
|
execSync(`${BIN}/gstack-review-log '{"skill":"review","status":"clean"}'`, execOpts);
|
|
// A record landed somewhere under projects/ without a wtree stamp.
|
|
const found: string[] = [];
|
|
const walk = (d: string) => {
|
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
const p = path.join(d, e.name);
|
|
if (e.isDirectory()) walk(p);
|
|
else if (e.name.endsWith('-reviews.jsonl')) found.push(p);
|
|
}
|
|
};
|
|
walk(slugDir);
|
|
expect(found.length).toBeGreaterThan(0);
|
|
const rec = JSON.parse(fs.readFileSync(found[0], 'utf-8').trim().split('\n').pop()!);
|
|
expect(rec.skill).toBe('review');
|
|
expect(rec.wtree).toBeUndefined();
|
|
expect(rec.commit_full).toBeUndefined();
|
|
} finally {
|
|
fs.rmSync(nonGit, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('gstack-wtree', () => {
|
|
function withScratchRepo(fn: (repoDir: string, wtree: () => string) => void) {
|
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-wtree-'));
|
|
try {
|
|
const git = (args: string) => gitIn(repoDir, args);
|
|
git('init -q -b main');
|
|
fs.writeFileSync(path.join(repoDir, 'a.txt'), 'hello\n');
|
|
fs.writeFileSync(path.join(repoDir, '.gitignore'), 'scratch.txt\n');
|
|
git('add a.txt .gitignore');
|
|
git('commit -q -m init');
|
|
const wtree = () => execSync(`${BIN}/gstack-wtree`, { cwd: repoDir, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
fn(repoDir, wtree);
|
|
} finally {
|
|
fs.rmSync(repoDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test('an UNTRACKED source file changes the fingerprint; a gitignored file does not', () => {
|
|
withScratchRepo((repoDir, wtree) => {
|
|
const clean = wtree();
|
|
expect(clean).toMatch(/^[0-9a-f]{40}$/);
|
|
|
|
// Gitignored scratch: invisible to the fingerprint (Conductor scratch stays out).
|
|
fs.writeFileSync(path.join(repoDir, 'scratch.txt'), 'noise\n');
|
|
expect(wtree()).toBe(clean);
|
|
|
|
// Untracked NEW source file: visible (new files can never be invisible to freshness).
|
|
fs.writeFileSync(path.join(repoDir, 'new-source.ts'), 'export {}\n');
|
|
expect(wtree()).not.toBe(clean);
|
|
});
|
|
});
|
|
|
|
test('committing identical content does NOT change the fingerprint', () => {
|
|
withScratchRepo((repoDir, wtree) => {
|
|
fs.writeFileSync(path.join(repoDir, 'a.txt'), 'edited\n');
|
|
const dirtyFingerprint = wtree();
|
|
gitIn(repoDir, 'commit -q -am edit');
|
|
expect(wtree()).toBe(dirtyFingerprint);
|
|
});
|
|
});
|
|
|
|
test('exits non-zero outside a git repo', () => {
|
|
const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-wtree-nongit-'));
|
|
try {
|
|
expect(() => execSync(`${BIN}/gstack-wtree`, { cwd: nonGit, timeout: 10000, stdio: 'pipe' })).toThrow();
|
|
} finally {
|
|
fs.rmSync(nonGit, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('gstack-review-read', () => {
|
|
test('emits ---WTREE---, ---TREE--- and ---DIRTY--- sections', () => {
|
|
const out = execSync(`${BIN}/gstack-review-read`, {
|
|
cwd: ROOT,
|
|
env: { ...process.env, GSTACK_HOME: tmpDir },
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
expect(out).toContain('---HEAD---');
|
|
expect(out).toContain('---WTREE---');
|
|
expect(out).toContain('---TREE---');
|
|
expect(out).toContain('---DIRTY---');
|
|
const wtreeLine = out.split('---WTREE---')[1].trim().split('\n')[0].trim();
|
|
expect(wtreeLine).toMatch(/^([0-9a-f]{40}|unknown)$/);
|
|
const dirtyLine = out.split('---DIRTY---')[1].trim().split('\n')[0].trim();
|
|
expect(['true', 'false']).toContain(dirtyLine);
|
|
});
|
|
});
|