Agentic coding CLI on Bun, Ink, and the AI SDK. Core: streamText loop with SDK-level tool approval so a denied call provably never executes; endpoint fallback for OpenAI reasoning models; retry with backoff. Tools: read/write/edit/glob/grep/bash, path-jailed, gitignore-aware, ripgrep with a JS fallback, binary rejection, live bash streaming. Agents: five variants crossing thinking level with tool restriction; plan and review withhold mutating tools from the model. Extensibility: frontmatter skills with on-demand bodies, plugin host with blocking hooks, MCP stdio and HTTP, read-only subagents. State: durable per-project memory, session task lists, session persistence, compaction that repairs provider-item dependencies. Distribution: five-platform cross-compiled binaries with checksums, install scripts, CI on three operating systems. 404 tests, typecheck clean.
119 lines
4.1 KiB
TypeScript
119 lines
4.1 KiB
TypeScript
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { walk } from '../src/ignore';
|
|
|
|
let dir: string;
|
|
let orig: string;
|
|
|
|
beforeEach(() => {
|
|
orig = process.cwd();
|
|
dir = mkdtempSync(join(tmpdir(), 'shiro-ignore-'));
|
|
process.chdir(dir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(orig);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
const collect = async (opts: Parameters<typeof walk>[0] = {}) => {
|
|
const out: string[] = [];
|
|
for await (const p of walk(opts)) out.push(p);
|
|
return out.sort();
|
|
};
|
|
|
|
test('walks nested files and returns posix relative paths', async () => {
|
|
await Bun.write('a.ts', '');
|
|
await Bun.write('src/deep/b.ts', '');
|
|
expect(await collect()).toEqual(['a.ts', 'src/deep/b.ts']);
|
|
});
|
|
|
|
test('.git and node_modules are always skipped, even with no ignore file', async () => {
|
|
await Bun.write('keep.ts', '');
|
|
await Bun.write('node_modules/pkg/index.js', '');
|
|
mkdirSync(join(dir, '.git'), { recursive: true });
|
|
await Bun.write('.git/HEAD', 'ref: refs/heads/main');
|
|
expect(await collect()).toEqual(['keep.ts']);
|
|
});
|
|
|
|
test('a plain gitignore entry excludes a file', async () => {
|
|
await Bun.write('.gitignore', 'secret.txt\n');
|
|
await Bun.write('secret.txt', '');
|
|
await Bun.write('public.txt', '');
|
|
expect(await collect()).toEqual(['.gitignore', 'public.txt']);
|
|
});
|
|
|
|
test('a directory rule excludes everything beneath it', async () => {
|
|
await Bun.write('.gitignore', 'dist/\n');
|
|
await Bun.write('dist/app.js', '');
|
|
await Bun.write('dist/nested/app.js', '');
|
|
await Bun.write('src/app.ts', '');
|
|
expect(await collect()).toEqual(['.gitignore', 'src/app.ts']);
|
|
});
|
|
|
|
test('an unanchored name matches at any depth', async () => {
|
|
await Bun.write('.gitignore', '*.log\n');
|
|
await Bun.write('top.log', '');
|
|
await Bun.write('deep/nested/inner.log', '');
|
|
await Bun.write('deep/keep.ts', '');
|
|
expect(await collect()).toEqual(['.gitignore', 'deep/keep.ts']);
|
|
});
|
|
|
|
test('a leading slash anchors the rule to the root', async () => {
|
|
await Bun.write('.gitignore', '/build\n');
|
|
await Bun.write('build/out.js', '');
|
|
await Bun.write('src/build/out.js', '');
|
|
expect(await collect()).toEqual(['.gitignore', 'src/build/out.js']);
|
|
});
|
|
|
|
test('a negation re-includes a file the previous rule excluded', async () => {
|
|
await Bun.write('.gitignore', '*.env\n!keep.env\n');
|
|
await Bun.write('secret.env', '');
|
|
await Bun.write('keep.env', '');
|
|
expect(await collect()).toEqual(['.gitignore', 'keep.env']);
|
|
});
|
|
|
|
test('** spans directories', async () => {
|
|
await Bun.write('.gitignore', 'a/**/c.ts\n');
|
|
await Bun.write('a/b/c.ts', '');
|
|
await Bun.write('a/b/d/c.ts', '');
|
|
await Bun.write('a/keep.ts', '');
|
|
expect(await collect()).toEqual(['.gitignore', 'a/keep.ts']);
|
|
});
|
|
|
|
test('a nested gitignore applies only inside its own directory', async () => {
|
|
await Bun.write('pkg/.gitignore', 'out.js\n');
|
|
await Bun.write('pkg/out.js', '');
|
|
await Bun.write('other/out.js', '');
|
|
expect(await collect()).toEqual(['other/out.js', 'pkg/.gitignore']);
|
|
});
|
|
|
|
test('.shiroignore is honoured alongside .gitignore', async () => {
|
|
await Bun.write('.shiroignore', 'notes/\n');
|
|
await Bun.write('notes/todo.md', '');
|
|
await Bun.write('src/app.ts', '');
|
|
expect(await collect()).toEqual(['.shiroignore', 'src/app.ts']);
|
|
});
|
|
|
|
test('comments and blank lines are skipped', async () => {
|
|
await Bun.write('.gitignore', '# a comment\n\n \nreal.txt\n');
|
|
await Bun.write('real.txt', '');
|
|
await Bun.write('# a comment', '');
|
|
expect(await collect()).toContain('# a comment');
|
|
expect(await collect()).not.toContain('real.txt');
|
|
});
|
|
|
|
test('noIgnore returns everything except .git and node_modules', async () => {
|
|
await Bun.write('.gitignore', 'dist/\n');
|
|
await Bun.write('dist/app.js', '');
|
|
await Bun.write('src/app.ts', '');
|
|
expect(await collect({ noIgnore: true })).toEqual(['.gitignore', 'dist/app.js', 'src/app.ts']);
|
|
});
|
|
|
|
test('limit stops the walk early', async () => {
|
|
for (let i = 0; i < 10; i++) await Bun.write(`f${i}.ts`, '');
|
|
expect((await collect({ limit: 3 })).length).toBe(3);
|
|
});
|