From 624011da51c14eaede2f472a7bc4a577f8b0f005 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sat, 4 Jul 2026 20:44:28 +0200 Subject: [PATCH] Initial import --- .gitignore | 8 +++ AGENTS.md | 40 +++++++++++ REQUIREMENTS.md | 15 ++++ build.zig | 16 +++++ facts.zig | 178 ++++++++++++++++++++++++++++++++++++++++++++++++ smoke.sh | 117 +++++++++++++++++++++++++++++++ 6 files changed, 374 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 REQUIREMENTS.md create mode 100644 build.zig create mode 100644 facts.zig create mode 100755 smoke.sh 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..9e714c0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# AGENTS.md + +## Overview + +`facts` is a tiny compiled Zig CLI for turning file paths into stable TSV file +metadata. It exists as an agent-safe replacement for the safe subset of `stat`, +`du`, and `find -printf` when allowlist policy needs argv-obvious behavior. + +## How to build + +```sh +zig build +``` + +## How to test + +```sh +zig build test +``` + +## Constraints + +- One source file: `facts.zig`. +- No runtime dependencies, no shell execution, no mutation. +- stdout is TSV with a header; diagnostics go to stderr. +- No filtering, sorting, paging, JSON, color, expression language, callbacks, or + hidden state. +- This is an inspection primitive. Keep filtering in adjacent stream tools such + as `grep`/`pick`, not in `facts`. + +## 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 durable behavior, update `REQUIREMENTS.md` first or stop and ask. +For behavior intended to stick, add or update a failing smoke/contract proof +before implementation unless that would be fake or disproportionate. Before +handoff, compare the user request and diff against `REQUIREMENTS.md`; report +`REQUIREMENT IMPACT: updated ` or exactly +`REQUIREMENT IMPACT: none - `. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md new file mode 100644 index 0000000..5cdfdff --- /dev/null +++ b/REQUIREMENTS.md @@ -0,0 +1,15 @@ +# REQUIREMENTS + +## facts + +ring id requirement +0 001 `facts` must be a compiled single-file Zig CLI with no runtime dependencies beyond the OS. +0 002 `facts` must inspect file metadata only; it must not mutate files, execute callbacks, access the network, read hidden config, prompt, page, colorize, or emit JSON by default. +1 003 `facts PATH...` must emit one TSV record per path plus a header row with stable columns `path`, `kind`, `size`, `mode`, and `mtime_ns`. +1 003.tsv-safety `facts` must preserve one-line TSV records by rejecting unsupported path bytes that cannot be emitted raw in TSV fields, including tab, carriage return, and newline, before writing a path record to stdout. +1 004 With no path arguments, `facts` must read one path per stdin line and emit the same TSV contract. +1 004.streaming Stdin mode must stream with bounded memory: emit the TSV header before waiting for stdin EOF, process completed input lines independently, flush each completed output record before EOF, and never buffer all stdin. +1 005 Per-path runtime errors must be diagnostics on stderr, must not corrupt stdout records for other paths, and must make the process exit nonzero after flushing valid records. +1 006 `facts` must not filter, sort, glob, or provide an expression language; callers compose with `files`, `grep`, `pick`, `sort`, or similar stream tools. +1 007 `zig build test` must prove argv input, stdin input, streaming stdin behavior, TSV header shape, TSV safety rejection, size reporting, directory reporting, help output, and per-path error handling. +1 008 `facts --help` and `facts -h` must emit usage text to stdout without a TSV header and exit zero. diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..7fcf3b6 --- /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 = "facts", .root_module = b.createModule(.{ .root_source_file = b.path("facts.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 facts").dependOn(&run_cmd.step); + const smoke = b.addSystemCommand(&.{ "sh", "smoke.sh", "zig-out/bin/facts" }); + smoke.setCwd(b.path(".")); + smoke.step.dependOn(b.getInstallStep()); + b.step("test", "Run smoke test").dependOn(&smoke.step); +} diff --git a/facts.zig b/facts.zig new file mode 100644 index 0000000..f06eb22 --- /dev/null +++ b/facts.zig @@ -0,0 +1,178 @@ +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: facts [PATH...] + \\ + \\Print file metadata as TSV. With no PATH args, reads one path per stdin line. + \\Columns: path kind size mode mtime_ns + \\ + \\stdout is records only; diagnostics go to stderr. + \\No filtering, sorting, deletion, exec, expression language, JSON, color, or hidden writes. + \\ + \\examples: + \\ files src | facts | pick --header path size + \\ facts README.md build.zig + \\ + ); + 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 isHelp(arg: []const u8) bool { + return std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"); +} + +fn kindName(kind: std.Io.File.Kind) []const u8 { + return switch (kind) { + .file => "file", + .directory => "dir", + .sym_link => "symlink", + .block_device => "block", + .character_device => "char", + .named_pipe => "fifo", + .unix_domain_socket => "socket", + .whiteout => "whiteout", + .door => "door", + .event_port => "eventport", + .unknown => "unknown", + }; +} + +fn modeValue(stat: std.Io.File.Stat) u64 { + if (@hasDecl(std.Io.File.Permissions, "toMode")) return @intCast(stat.permissions.toMode()); + return 0; +} + +fn printError(io: std.Io, path: []const u8, err: anyerror) !void { + var err_buf: [512]u8 = undefined; + var errw = std.Io.File.stderr().writer(io, &err_buf); + try errw.interface.print("facts: {s}: {s}\n", .{ path, @errorName(err) }); + try errw.interface.flush(); +} + +fn unsupportedPathByte(path: []const u8) ?[]const u8 { + for (path) |byte| switch (byte) { + '\t' => return "tab", + '\n' => return "newline", + '\r' => return "carriage return", + else => {}, + }; + return null; +} + +fn printUnsupportedPath(io: std.Io, byte_name: []const u8) !void { + var err_buf: [512]u8 = undefined; + var errw = std.Io.File.stderr().writer(io, &err_buf); + try errw.interface.print("facts: unsupported path: contains {s}\n", .{byte_name}); + try errw.interface.flush(); +} + +fn printFact(io: std.Io, writer: *std.Io.Writer, path: []const u8) !bool { + if (unsupportedPathByte(path)) |byte_name| { + try printUnsupportedPath(io, byte_name); + return false; + } + const st = std.Io.Dir.cwd().statFile(io, path, .{}) catch |err| { + try printError(io, path, err); + return false; + }; + try writer.print("{s}\t{s}\t{}\t{o}\t{}\n", .{ path, kindName(st.kind), st.size, modeValue(st), st.mtime.nanoseconds }); + return true; +} + +fn printStdinError(io: std.Io, comptime message: []const u8) !void { + var err_buf: [512]u8 = undefined; + var errw = std.Io.File.stderr().writer(io, &err_buf); + try errw.interface.writeAll("facts: stdin: " ++ message ++ "\n"); + try errw.interface.flush(); +} + +fn printStdinLine(io: std.Io, writer: *std.Io.Writer, raw: []const u8) !bool { + if (raw.len == 0) return true; + const ok = try printFact(io, writer, raw); + try writer.flush(); + return ok; +} + +fn readStdinLines(io: std.Io, writer: *std.Io.Writer) !bool { + var had_error = false; + var read_buf: [8192]u8 = undefined; + var line_buf: [std.Io.Dir.max_path_bytes]u8 = undefined; + var line_len: usize = 0; + var dropping_long_line = false; + + while (true) { + const n = try std.posix.read(std.posix.STDIN_FILENO, &read_buf); + if (n == 0) break; + for (read_buf[0..n]) |byte| { + if (byte == '\n') { + if (!dropping_long_line) { + if (!try printStdinLine(io, writer, line_buf[0..line_len])) had_error = true; + } + line_len = 0; + dropping_long_line = false; + continue; + } + if (dropping_long_line) continue; + if (line_len == line_buf.len) { + try printStdinError(io, "input path exceeds max path length"); + had_error = true; + line_len = 0; + dropping_long_line = true; + continue; + } + line_buf[line_len] = byte; + line_len += 1; + } + } + + if (!dropping_long_line and line_len != 0) { + if (!try printStdinLine(io, writer, line_buf[0..line_len])) had_error = true; + } + return had_error; +} + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + + var help_args = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer help_args.deinit(); + _ = help_args.skip(); + while (help_args.next()) |arg| { + if (isHelp(arg)) { + try usage(std.Io.File.stdout(), init.io); + return; + } + } + + var args = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer args.deinit(); + _ = args.skip(); + + var out_buf: [8192]u8 = undefined; + var out = std.Io.File.stdout().writer(init.io, &out_buf); + try out.interface.writeAll("path\tkind\tsize\tmode\tmtime_ns\n"); + + var saw_arg = false; + var had_error = false; + while (args.next()) |arg| { + saw_arg = true; + if (!try printFact(init.io, &out.interface, arg)) had_error = true; + } + if (!saw_arg) { + try out.interface.flush(); + had_error = readStdinLines(init.io, &out.interface) catch |err| die(init.io, "facts: stdin: {s}\n", .{@errorName(err)}); + } + try out.interface.flush(); + if (had_error) std.process.exit(1); +} diff --git a/smoke.sh b/smoke.sh new file mode 100755 index 0000000..65f55a6 --- /dev/null +++ b/smoke.sh @@ -0,0 +1,117 @@ +#!/bin/sh +set -eu +tool=${1:-zig-out/bin/facts} +work=.zig-cache/facts-smoke +mkdir -p "$work/dir" +printf abc > "$work/a.txt" + +wait_for_line() { + pattern=$1 + file=$2 + label=$3 + i=0 + while [ "$i" -lt 50 ]; do + if grep -q "$pattern" "$file" 2>/dev/null; then + return 0 + fi + i=$((i + 1)) + sleep 0.1 + done + echo "facts smoke: timed out waiting for $label" >&2 + if [ -n "${stream_pid:-}" ]; then + kill "$stream_pid" 2>/dev/null || true + fi + exit 1 +} + +"$tool" "$work/a.txt" "$work/dir" > "$work/arg.out" +head -n 1 "$work/arg.out" | grep -q '^path kind size mode mtime_ns$' +grep -q "^$work/a.txt file 3 " "$work/arg.out" +grep -q "^$work/dir dir " "$work/arg.out" + +printf '%s\n' "$work/a.txt" | "$tool" > "$work/stdin.out" +grep -q "^$work/a.txt file 3 " "$work/stdin.out" + +tab_path="$work/tab name" +printf x > "$tab_path" +if "$tool" "$tab_path" > "$work/tab-argv.out" 2> "$work/tab-argv.err"; then + echo 'facts: argv tab path unexpectedly succeeded' >&2 + exit 1 +fi +test "$(wc -l < "$work/tab-argv.out" | tr -d ' ')" -eq 1 +grep -q '^path kind size mode mtime_ns$' "$work/tab-argv.out" +grep -q 'contains tab' "$work/tab-argv.err" +if printf '%s\n' "$tab_path" | "$tool" > "$work/tab-stdin.out" 2> "$work/tab-stdin.err"; then + echo 'facts: stdin tab path unexpectedly succeeded' >&2 + exit 1 +fi +test "$(wc -l < "$work/tab-stdin.out" | tr -d ' ')" -eq 1 +grep -q '^path kind size mode mtime_ns$' "$work/tab-stdin.out" +grep -q 'contains tab' "$work/tab-stdin.err" + +newline_path=$(printf '%s/new\nname' "$work") +printf y > "$newline_path" +if "$tool" "$newline_path" > "$work/newline-argv.out" 2> "$work/newline-argv.err"; then + echo 'facts: argv newline path unexpectedly succeeded' >&2 + exit 1 +fi +test "$(wc -l < "$work/newline-argv.out" | tr -d ' ')" -eq 1 +grep -q '^path kind size mode mtime_ns$' "$work/newline-argv.out" +grep -q 'contains newline' "$work/newline-argv.err" + +cr_path=$(printf '%s/cr\rname' "$work") +printf z > "$cr_path" +if "$tool" "$cr_path" > "$work/cr-argv.out" 2> "$work/cr-argv.err"; then + echo 'facts: argv carriage-return path unexpectedly succeeded' >&2 + exit 1 +fi +test "$(wc -l < "$work/cr-argv.out" | tr -d ' ')" -eq 1 +grep -q '^path kind size mode mtime_ns$' "$work/cr-argv.out" +grep -q 'contains carriage return' "$work/cr-argv.err" +if printf '%s\r\n' "$work/a.txt" | "$tool" > "$work/cr-stdin.out" 2> "$work/cr-stdin.err"; then + echo 'facts: stdin carriage-return path unexpectedly succeeded' >&2 + exit 1 +fi +test "$(wc -l < "$work/cr-stdin.out" | tr -d ' ')" -eq 1 +grep -q '^path kind size mode mtime_ns$' "$work/cr-stdin.out" +grep -q 'contains carriage return' "$work/cr-stdin.err" + +stream_in=$work/stream.in +rm -f "$stream_in" +mkfifo "$stream_in" +stream_out=$work/stream.out +stream_err=$work/stream.err +: > "$stream_out" +: > "$stream_err" +stream_pid= +"$tool" < "$stream_in" > "$stream_out" 2> "$stream_err" & +stream_pid=$! +exec 3> "$stream_in" +wait_for_line '^path kind size mode mtime_ns$' "$stream_out" 'streaming header before stdin EOF' +printf '%s\n' "$work/a.txt" >&3 +wait_for_line "^$work/a.txt file 3 " "$stream_out" 'first streaming record before stdin EOF' +kill -0 "$stream_pid" +printf '%s\n' "$work/dir" >&3 +wait_for_line "^$work/dir dir " "$stream_out" 'second streaming record before stdin EOF' +exec 3>&- +wait "$stream_pid" +stream_pid= +rm -f "$stream_in" +if [ -s "$stream_err" ]; then + cat "$stream_err" >&2 + exit 1 +fi + +"$tool" --help > "$work/help.out" +grep -q '^usage: facts \[PATH\.\.\.\]' "$work/help.out" +! grep -q '^path kind size mode mtime_ns$' "$work/help.out" +"$tool" -h > "$work/help-short.out" +grep -q '^usage: facts \[PATH\.\.\.\]' "$work/help-short.out" +! grep -q '^path kind size mode mtime_ns$' "$work/help-short.out" + +if "$tool" "$work/a.txt" "$work/missing" > "$work/error.out" 2> "$work/error.err"; then + echo 'facts: missing path unexpectedly succeeded' >&2 + exit 1 +fi +grep -q "^$work/a.txt file 3 " "$work/error.out" +grep -q "^facts: $work/missing: " "$work/error.err"