commit 1ed0d9667d2500d7fe025afb19d44a52c418e2c0 Author: slhx agent Date: Sat Jul 4 20:44:28 2026 +0200 Initial import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d23ccf --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Zig build artifacts +.zig-cache/ +zig-out/ + +# Local editor/OS noise +*.swp +*~ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f87cbbc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# AGENTS.md + +## Overview + +`pick` is a tiny compiled Zig CLI for selecting TSV fields from stdin. It exists +as an agent-safe replacement for the safe subset of `awk '{print $N}'` and +`cut -f` when allowlist policy needs argv-obvious behavior. + +## How to build + +```sh +zig build +``` + +## How to test + +```sh +zig build test +``` + +## Constraints + +- One source file: `pick.zig`. +- No runtime dependencies, no shell execution, no filesystem reads/writes in the + tool. +- stdout is TSV/plain records only; diagnostics go to stderr. +- No regex, expressions, arithmetic, sorting, paging, JSON, color, or hidden + state. +- This is an inspection primitive. Do not add mutation or policy workflow. + +## Requirement Governance + +`REQUIREMENTS.md` is the requirement authority for this repository and is checked +with `redgate`. Before implementation work, read the applicable requirement row; +if none covers the durable behavior, update `REQUIREMENTS.md` first or stop and +ask for authority. When adding or changing rows, choose the ring by product +foundation, not implementation size: lower rings capture stable kernel +obligations and core contracts; higher rings compose on lower-ring obligations +and must not weaken, redefine, or bypass them. If one row mixes foundational and +optional behavior, split it before implementation. + +If a change alters durable product obligations, acceptance behavior, +safety/recovery behavior, public interfaces, or verification duties, update +`REQUIREMENTS.md` in the same slice and run the relevant `redgate` checks. For +behavior intended to stick, write or update a failing BDD/TDD test, contract +test, or executable proof before implementation code; run it and record the RED +result. Implement only after the requirement row and RED proof exist. If RED +proof would be fake, unsafe, or disproportionate, state that exception before +implementation and use the strongest cheaper check. + +Before final handoff, compare the user request and actual diff against +`REQUIREMENTS.md`. If there is no requirement impact, the handoff must include +exactly `REQUIREMENT IMPACT: none - `. If code, tests, or docs +changed but `REQUIREMENTS.md` did not, explicitly justify why no requirement row +changed. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md new file mode 100644 index 0000000..5c4e6cd --- /dev/null +++ b/REQUIREMENTS.md @@ -0,0 +1,13 @@ +# REQUIREMENTS + +## pick +0 001 `pick` must be a compiled single-file Zig CLI with no runtime dependencies beyond the OS. [core] +0 002 `pick` must read TSV records from stdin and write selected TSV fields to stdout only. [stream] +0 003 `pick` must have no filesystem mutation, network access, shell execution, expression language, regex evaluation, hidden config, prompts, color, pager, JSON default, or daemon behavior. [agent-safe] +1 004 `pick FIELD...` must select 1-based numeric fields from headerless TSV input and preserve input record order. +1 005 `pick --header FIELD...` must resolve field names from the first TSV record, emit the selected header, and then emit selected fields from subsequent records. +1 006 Missing fields must emit empty TSV fields rather than failing a pipeline. +1 007 Usage errors must exit nonzero and write diagnostics to stderr, never stdout. +1 008 `zig build test` must prove numeric selection, header selection, missing fields, help/error stdout-stderr separation, CRLF input tolerance, invalid field rejection, and one forbidden near miss. [verification] +1 009 Project smoke tests must run under POSIX `sh` and exercise `pick` through pipe-oriented stdin/stdout/stderr behavior without leaving required generated artifacts. [posix] +1 010 `pick` must process stdin incrementally for pipeline-sized streams and must not print noisy diagnostics when downstream consumers close stdout early. [pipeline] diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..1fe6c9a --- /dev/null +++ b/build.zig @@ -0,0 +1,16 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const exe = b.addExecutable(.{ .name = "pick", .root_module = b.createModule(.{ .root_source_file = b.path("pick.zig"), .target = target, .optimize = optimize }) }); + b.installArtifact(exe); + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + b.step("run", "Run pick").dependOn(&run_cmd.step); + const smoke = b.addSystemCommand(&.{ "sh", "smoke.sh", "zig-out/bin/pick" }); + smoke.setCwd(b.path(".")); + smoke.step.dependOn(b.getInstallStep()); + b.step("test", "Run smoke test").dependOn(&smoke.step); +} diff --git a/pick.zig b/pick.zig new file mode 100644 index 0000000..5ee6a58 --- /dev/null +++ b/pick.zig @@ -0,0 +1,181 @@ +const std = @import("std"); + +fn usage(file: std.Io.File, io: std.Io) !void { + var buf: [1024]u8 = undefined; + var w = file.writer(io, &buf); + try w.interface.writeAll( + \\usage: pick [--header] FIELD... + \\ + \\Select tab-separated fields from stdin. FIELD is a 1-based column number, + \\or a header name when --header is set. Output is TSV/plain records only. + \\ + \\options: + \\ --header resolve FIELD names from the first input record + \\ --help show this help on stdout and exit 0 + \\ + \\stdout: selected records only + \\stderr: usage errors and diagnostics only + \\exits: 0 on success, nonzero on invalid fields or I/O failure + \\ + \\No regex, expressions, arithmetic, shell, mutation, files, JSON, color, + \\prompts, config, network, daemon behavior, or hidden state. + \\ + \\examples: + \\ printf 'path\\tsize\\nREADME\\t120\\n' | pick --header path size + \\ printf 'a\\tb\\n' | pick 2 + \\ + ); + try w.interface.flush(); +} + +fn die(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn { + var buf: [512]u8 = undefined; + var w = std.Io.File.stderr().writer(io, &buf); + w.interface.print(fmt, args) catch {}; + w.interface.flush() catch {}; + std.process.exit(1); +} + +fn fieldAt(line: []const u8, index: usize) []const u8 { + var it = std.mem.splitScalar(u8, line, '\t'); + var i: usize = 0; + while (it.next()) |field| : (i += 1) { + if (i == index) return field; + } + return ""; +} + +fn stripRecordNewlineCr(raw: []const u8) []const u8 { + if (raw.len > 0 and raw[raw.len - 1] == '\r') return raw[0 .. raw.len - 1]; + return raw; +} + +fn headerIndex(header: []const u8, name: []const u8, io: std.Io) usize { + var it = std.mem.splitScalar(u8, header, '\t'); + var i: usize = 0; + while (it.next()) |field| : (i += 1) { + if (std.mem.eql(u8, field, name)) return i; + } + die(io, "pick: header not found: {s}\n", .{name}); +} + +fn writeAll(out: *std.Io.File.Writer, bytes: []const u8) std.Io.File.Writer.Error!void { + out.interface.writeAll(bytes) catch |err| switch (err) { + error.WriteFailed => return out.err.?, + }; +} + +fn writeByte(out: *std.Io.File.Writer, byte: u8) std.Io.File.Writer.Error!void { + out.interface.writeByte(byte) catch |err| switch (err) { + error.WriteFailed => return out.err.?, + }; +} + +fn writePicked(out: *std.Io.File.Writer, line: []const u8, cols: []const usize) !void { + for (cols, 0..) |col, i| { + if (i > 0) try writeByte(out, '\t'); + try writeAll(out, fieldAt(line, col)); + } + try writeByte(out, '\n'); +} + +fn handleLine( + allocator: std.mem.Allocator, + io: std.Io, + has_header: bool, + names: []const []const u8, + cols: *std.ArrayList(usize), + header_done: *bool, + out: *std.Io.File.Writer, + raw: []const u8, +) !void { + const line = stripRecordNewlineCr(raw); + if (has_header and !header_done.*) { + for (names) |name| try cols.append(allocator, headerIndex(line, name, io)); + try writePicked(out, line, cols.items); + header_done.* = true; + } else { + try writePicked(out, line, cols.items); + } +} + +fn pickStream( + allocator: std.mem.Allocator, + io: std.Io, + has_header: bool, + names: []const []const u8, + cols: *std.ArrayList(usize), +) !void { + var out_buf: [8192]u8 = undefined; + var out = std.Io.File.stdout().writer(io, &out_buf); + var carry = std.ArrayList(u8).empty; + defer carry.deinit(allocator); + + var header_done = !has_header; + var buf: [8192]u8 = undefined; + while (true) { + const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch |err| die(io, "pick: stdin: {s}\n", .{@errorName(err)}); + if (n == 0) break; + + var start: usize = 0; + for (buf[0..n], 0..) |byte, i| { + if (byte != '\n') continue; + if (carry.items.len == 0) { + try handleLine(allocator, io, has_header, names, cols, &header_done, &out, buf[start..i]); + } else { + try carry.appendSlice(allocator, buf[start..i]); + try handleLine(allocator, io, has_header, names, cols, &header_done, &out, carry.items); + carry.clearRetainingCapacity(); + } + start = i + 1; + } + if (start < n) try carry.appendSlice(allocator, buf[start..n]); + } + + if (carry.items.len > 0) { + try handleLine(allocator, io, has_header, names, cols, &header_done, &out, carry.items); + } else if (has_header and !header_done) { + try handleLine(allocator, io, has_header, names, cols, &header_done, &out, ""); + } + try out.flush(); +} + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + var args = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer args.deinit(); + _ = args.skip(); + + var has_header = false; + var names = std.ArrayList([]const u8).empty; + defer names.deinit(allocator); + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--help")) { + try usage(std.Io.File.stdout(), init.io); + return; + } else if (std.mem.eql(u8, arg, "--header")) { + has_header = true; + } else { + try names.append(allocator, arg); + } + } + if (names.items.len == 0) { + try usage(std.Io.File.stderr(), init.io); + std.process.exit(2); + } + + var cols = std.ArrayList(usize).empty; + defer cols.deinit(allocator); + if (!has_header) { + for (names.items) |name| { + const n = std.fmt.parseInt(usize, name, 10) catch die(init.io, "pick: invalid field: {s}\n", .{name}); + if (n == 0) die(init.io, "pick: fields are 1-based\n", .{}); + try cols.append(allocator, n - 1); + } + } + + pickStream(allocator, init.io, has_header, names.items, &cols) catch |err| switch (err) { + error.BrokenPipe => return, + else => die(init.io, "pick: output: {s}\n", .{@errorName(err)}), + }; +} diff --git a/smoke.sh b/smoke.sh new file mode 100644 index 0000000..9125f87 --- /dev/null +++ b/smoke.sh @@ -0,0 +1,101 @@ +#!/bin/sh +set -eu + +tool=${1:-zig-out/bin/pick} +tmp=${TMPDIR:-.zig-cache}/pick-smoke.$$ +mkdir -p "$tmp" +trap 'rm -rf "$tmp"' EXIT HUP INT TERM + +# req: pick/008 +# req: pick/009 +# req: pick/010 + +assert_eq() { + want=$1 + got=$2 + cmp "$want" "$got" +} + +assert_empty() { + file=$1 + if [ -s "$file" ]; then + echo "pick: expected empty file: $file" >&2 + exit 1 + fi +} + +assert_fails() { + if "$@"; then + echo "pick: command unexpectedly succeeded: $*" >&2 + exit 1 + fi +} + +# Numeric field selection preserves records and stdout contains only selected TSV. +printf 'a\tb\tc\n1\t2\t3\n' | "$tool" 2 > "$tmp/pick-one.out" +printf 'b\n2\n' > "$tmp/pick-one.want" +assert_eq "$tmp/pick-one.want" "$tmp/pick-one.out" + +# Header selection emits the selected header and selected data columns. +printf 'path\tsize\tkind\na\t10\tfile\n' | "$tool" --header path size > "$tmp/pick-header.out" +printf 'path\tsize\na\t10\n' > "$tmp/pick-header.want" +assert_eq "$tmp/pick-header.want" "$tmp/pick-header.out" + +# Missing fields are empty fields, not pipeline failures. +printf 'a\tb\n1\n' | "$tool" 1 2 > "$tmp/pick-missing.out" +printf 'a\tb\n1\t\n' > "$tmp/pick-missing.want" +assert_eq "$tmp/pick-missing.want" "$tmp/pick-missing.out" + +# CRLF input is accepted and normalized to LF records. +printf 'a\tb\r\n1\t2\r\n' | "$tool" 2 > "$tmp/pick-crlf.out" +printf 'b\n2\n' > "$tmp/pick-crlf.want" +assert_eq "$tmp/pick-crlf.want" "$tmp/pick-crlf.out" + +# A record longer than the internal read buffer still streams correctly across +# chunk boundaries without whole-input buffering. +: > "$tmp/pick-long.in" +i=0 +while [ "$i" -lt 9000 ]; do + printf x >> "$tmp/pick-long.in" + i=$((i + 1)) +done +printf '\tb\n' >> "$tmp/pick-long.in" +"$tool" 2 < "$tmp/pick-long.in" > "$tmp/pick-long.out" +printf 'b\n' > "$tmp/pick-long.want" +assert_eq "$tmp/pick-long.want" "$tmp/pick-long.out" + +# Help is stdout-only and successful. +"$tool" --help > "$tmp/pick-help.out" 2> "$tmp/pick-help.err" +grep -q '^usage: pick' "$tmp/pick-help.out" +assert_empty "$tmp/pick-help.err" + +# Usage errors are stderr-only and nonzero. +assert_fails "$tool" > "$tmp/pick-noargs.out" 2> "$tmp/pick-noargs.err" +assert_empty "$tmp/pick-noargs.out" +grep -q '^usage: pick' "$tmp/pick-noargs.err" + +assert_fails "$tool" 0 > "$tmp/pick-zero.out" 2> "$tmp/pick-zero.err" +assert_empty "$tmp/pick-zero.out" +grep -q 'fields are 1-based' "$tmp/pick-zero.err" + +assert_fails "$tool" nope > "$tmp/pick-invalid.out" 2> "$tmp/pick-invalid.err" +assert_empty "$tmp/pick-invalid.out" +grep -q 'invalid field' "$tmp/pick-invalid.err" + +# Forbidden near miss: option-looking regex/expression input is rejected as data, +# not interpreted as another command language. +assert_fails "$tool" --regex '.*' > "$tmp/pick-regex.out" 2> "$tmp/pick-regex.err" +assert_empty "$tmp/pick-regex.out" +grep -q 'invalid field' "$tmp/pick-regex.err" + +# Head-style consumers may close stdout early; that must not pollute stderr. +i=0 +{ + while [ "$i" -lt 20000 ]; do + printf 'a\tb\n' + i=$((i + 1)) + done +} | "$tool" 1 2> "$tmp/pick-head.err" | sed 1q > "$tmp/pick-head.out" +printf 'a\n' > "$tmp/pick-head.want" +assert_eq "$tmp/pick-head.want" "$tmp/pick-head.out" +assert_empty "$tmp/pick-head.err"