Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 68 additions & 78 deletions cmd/fas/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@ func run(stdin io.Reader, stdout, stderr io.Writer, args []string) int {
// state into the next invocation.
evaluator.SetExplainEnabled(opts.explain.set)

response, matches, diags, err := evaluate(ad, raw, globalRules, projectRules, opts.failClosed)
response, matches, diags, input, err := evaluate(ad, raw, globalRules, projectRules, opts.failClosed)
if input != nil {
rec.SetInput(input)
}
if err != nil {
errorf(stderr, "render response: %v\n", err)
return exitWithLog(rec, 1)
Expand Down Expand Up @@ -207,30 +210,15 @@ func runExplain(stdin io.Reader, stdout, stderr io.Writer, args []string) int {
fs.SetOutput(stderr)
fs.Usage = func() { writeUsage(stderr) }

defaultGlobal, err := defaultGlobalConfigDir()
if err != nil {
defaultGlobal = defaultGlobalRulesSubpath
}
harness := "claude"
projectConfig := defaultProjectRulesDir
globalConfig := defaultGlobal
fs.StringVar(&harness, "harness", harness,
"vendor harness whose hook protocol to speak (e.g. claude)")
fs.StringVar(&projectConfig, "config", projectConfig,
"path to the project rules directory")
fs.StringVar(&globalConfig, "global-config", globalConfig,
"path to the user-global rules directory")
var format formatFlag
common := registerCommonFlags(fs)
var color colorFlag
fs.Var(&format, "format",
"diagnostic output format: text|json|sarif (default text)")
fs.Var(&color, "color",
"color mode for text diagnostics: auto|always|never (default auto)")

if err := fs.Parse(rest); err != nil {
return 2
}
resolvedFormat, ferr := resolveFormat(format, os.Getenv("FAS_FORMAT"))
resolvedFormat, ferr := resolveFormat(common.format, os.Getenv("FAS_FORMAT"))
if ferr != nil {
errorln(stderr, ferr)
return 2
Expand All @@ -241,19 +229,19 @@ func runExplain(stdin io.Reader, stdout, stderr io.Writer, args []string) int {
return 2
}

ad, ok := selectAdapter(harness)
ad, ok := selectAdapter(common.harness)
if !ok {
errorf(stderr, "unknown harness %q; supported: %s\n",
harness, strings.Join(supportedHarnesses(), ", "))
common.harness, strings.Join(supportedHarnesses(), ", "))
return 2
}

globalRules, err := loadRulesDir(globalConfig)
globalRules, err := loadRulesDir(common.globalConfig)
if err != nil {
errorln(stderr, err)
return 2
}
projectRules, err := loadRulesDir(projectConfig)
projectRules, err := loadRulesDir(common.projectConfig)
if err != nil {
errorln(stderr, err)
return 2
Expand Down Expand Up @@ -664,37 +652,48 @@ func paletteFor(mode colorMode, tty bool, noColorEnv string) diag.Palette {

// parseFlags reads args into a cliOptions. The returned bool is true when the
// user asked for help; callers should print usage and exit 0.
func parseFlags(args []string, stderr io.Writer) (cliOptions, bool, error) {
fs := flag.NewFlagSet("fas eval", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() { writeUsage(stderr) }
type commonFlags struct {
harness string
projectConfig string
globalConfig string
format formatFlag
}

func registerCommonFlags(fs *flag.FlagSet) *commonFlags {
defaultGlobal, err := defaultGlobalConfigDir()
if err != nil {
// Fall back to a bare relative path so flag parsing still works even
// when HOME is unset; loadRulesDir will treat it as "does not exist".
defaultGlobal = defaultGlobalRulesSubpath
}

opts := cliOptions{
cf := &commonFlags{
harness: "claude",
projectConfig: defaultProjectRulesDir,
globalConfig: defaultGlobal,
}
fs.StringVar(&opts.harness, "harness", opts.harness,
fs.StringVar(&cf.harness, "harness", cf.harness,
"vendor harness whose hook protocol to speak (e.g. claude)")
fs.StringVar(&opts.projectConfig, "config", opts.projectConfig,
fs.StringVar(&cf.projectConfig, "config", cf.projectConfig,
"path to the project rules directory")
fs.StringVar(&opts.globalConfig, "global-config", opts.globalConfig,
fs.StringVar(&cf.globalConfig, "global-config", cf.globalConfig,
"path to the user-global rules directory")
fs.Var(&cf.format, "format",
"diagnostic output format: text|json|sarif (default text)")
return cf
}

func parseFlags(args []string, stderr io.Writer) (cliOptions, bool, error) {
fs := flag.NewFlagSet("fas eval", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() { writeUsage(stderr) }

common := registerCommonFlags(fs)
var opts cliOptions
fs.BoolVar(&opts.failClosed, "fail-closed", false,
"on engine error, emit a Blocking envelope instead of Allowing")
fs.Var(&opts.explain, "explain",
"emit diagnostics to stderr: fired|missed|both (default missed when bare)")
var format formatFlag
var color colorFlag
fs.Var(&format, "format",
"diagnostic output format: text|json|sarif (default text)")
fs.Var(&color, "color",
"color mode for text diagnostics: auto|always|never (default auto)")

Expand All @@ -704,9 +703,12 @@ func parseFlags(args []string, stderr io.Writer) (cliOptions, bool, error) {
}
return opts, false, err
}
opts.harness = common.harness
opts.projectConfig = common.projectConfig
opts.globalConfig = common.globalConfig
opts.format = formatText
opts.color = colorAuto
f, rerr := resolveFormat(format, os.Getenv("FAS_FORMAT"))
f, rerr := resolveFormat(common.format, os.Getenv("FAS_FORMAT"))
if rerr != nil {
errorln(stderr, rerr)
return opts, false, rerr
Expand Down Expand Up @@ -798,31 +800,31 @@ func evaluate(
raw []byte,
globalRules, projectRules []config.Rule,
failClosed bool,
) ([]byte, []evaluator.Match, []diag.Diagnostic, error) {
) ([]byte, []evaluator.Match, []diag.Diagnostic, *envelope.Input, error) {
input, hookEventName, engineErr := prepareInput(ad, raw)
if engineErr != nil {
out := fallbackEnvelope(engineErr, failClosed)
resp, err := ad.RenderOutput(out, hookEventName)
return resp, nil, nil, err
return resp, nil, nil, nil, err
}

cueInput, err := encodeInput(input)
if err != nil {
out := fallbackEnvelope(err, failClosed)
resp, rerr := ad.RenderOutput(out, hookEventName)
return resp, nil, nil, rerr
return resp, nil, nil, input, rerr
}

matches, diags, err := pipeline.EvaluatePhases(globalRules, projectRules, cueInput)
if err != nil {
out := fallbackEnvelope(err, failClosed)
resp, rerr := ad.RenderOutput(out, hookEventName)
return resp, nil, nil, rerr
return resp, nil, nil, input, rerr
}

out := synthesis.Synthesize(matches, defaultSizeBudget)
resp, err := ad.RenderOutput(out, hookEventName)
return resp, matches, diags, err
return resp, matches, diags, input, err
}

// prepareInput runs the adapter parse and preprocessor. It returns the
Expand Down Expand Up @@ -1129,7 +1131,11 @@ Environment:
directory path to enable; "1" or "true" uses the
default (~/.local/state/fas/logs/). Each
invocation writes one JSON file recording raw
input, loaded rules, matches, and rendered output.
input, preprocessed input, loaded rules, matches,
and rendered output. These payloads are written
unredacted (mode 0600) and may contain secrets,
prompts, or other sensitive data — point FAS_LOG
only at a trusted directory.
FAS_LOG_TTL Max age for debug log files (default: "1h").
Files older than this are garbage-collected at
the start of each invocation. Accepts Go duration
Expand All @@ -1148,49 +1154,30 @@ func runVet(stdout, stderr io.Writer, args []string) int {
fs.SetOutput(stderr)
fs.Usage = func() { writeUsage(stderr) }

defaultGlobal, err := defaultGlobalConfigDir()
if err != nil {
defaultGlobal = defaultGlobalRulesSubpath
}

harness := "claude"
projectConfig := defaultProjectRulesDir
globalConfig := defaultGlobal
fs.StringVar(&harness, "harness", harness,
"vendor harness whose hook protocol to speak (e.g. claude)")
fs.StringVar(&projectConfig, "config", projectConfig,
"path to the project rules directory")
fs.StringVar(&globalConfig, "global-config", globalConfig,
"path to the user-global rules directory")
var format formatFlag
var color colorFlag
fs.Var(&format, "format",
"diagnostic output format: text|json|sarif (default text)")
fs.Var(&color, "color",
"color mode for text diagnostics: auto|always|never (default auto)")
common := registerCommonFlags(fs)

if err := fs.Parse(args); err != nil {
return 2
}
resolvedFormat, ferr := resolveFormat(format, os.Getenv("FAS_FORMAT"))
if ferr != nil {
errorln(stderr, ferr)
if fs.NArg() != 0 {
errorf(stderr, "fas vet takes no positional arguments; got %q\n", fs.Arg(0))
return 2
}
if _, cerr := resolveColor(color, os.Getenv("FAS_COLOR"), os.Getenv("NO_COLOR")); cerr != nil {
errorln(stderr, cerr)
resolvedFormat, ferr := resolveFormat(common.format, os.Getenv("FAS_FORMAT"))
if ferr != nil {
errorln(stderr, ferr)
return 2
}

ad, ok := selectAdapter(harness)
ad, ok := selectAdapter(common.harness)
if !ok {
errorf(stderr, "unknown harness %q; supported: %s\n",
harness, strings.Join(supportedHarnesses(), ", "))
common.harness, strings.Join(supportedHarnesses(), ", "))
return 2
}

globalRules, globalErr := loadRulesDir(globalConfig)
projectRules, projectErr := loadRulesDir(projectConfig)
globalRules, globalErr := loadRulesDir(common.globalConfig)
projectRules, projectErr := loadRulesDir(common.projectConfig)

loadErr := errors.Join(globalErr, projectErr)
if loadErr != nil {
Expand All @@ -1199,7 +1186,7 @@ func runVet(stdout, stderr io.Writer, args []string) int {
}

if err := checkAdapterCapabilities(ad, globalRules, projectRules); err != nil {
errorln(stderr, err)
renderVetErrors(stderr, err, resolvedFormat)
return 1
}

Expand All @@ -1210,14 +1197,16 @@ func runVet(stdout, stderr io.Writer, args []string) int {
switch resolvedFormat {
case formatJSON:
renderVetSummaryJSON(stdout, globalIDs, projectIDs)
case formatSARIF:
_, _ = stdout.Write(diag.RenderSARIF(nil))
default:
errorf(stdout, "ok: %d rules loaded (global: %d, project: %d)\n",
_, _ = fmt.Fprintf(stdout, "ok: %d rules loaded (global: %d, project: %d)\n",
total, len(globalIDs), len(projectIDs))
for _, id := range globalIDs {
errorf(stdout, " global: %s\n", id)
_, _ = fmt.Fprintf(stdout, " global: %s\n", id)
}
for _, id := range projectIDs {
errorf(stdout, " project: %s\n", id)
_, _ = fmt.Fprintf(stdout, " project: %s\n", id)
}
}
return 0
Expand Down Expand Up @@ -1251,14 +1240,15 @@ func collectDiagErrors(err error, dst *[]diag.Diagnostic) {
if err == nil {
return
}
var de *diag.DiagError
if errors.As(err, &de) {
*dst = append(*dst, de.D)
}
if joined, ok := err.(interface{ Unwrap() []error }); ok {
for _, child := range joined.Unwrap() {
collectDiagErrors(child, dst)
}
return
}
var de *diag.DiagError
if errors.As(err, &de) {
*dst = append(*dst, de.D)
}
}

Expand Down
69 changes: 69 additions & 0 deletions cmd/fas/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"

"github.com/srnnkls/fas/internal/diag"
)

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -3088,6 +3091,9 @@ func TestRun_FasLog_WritesLogFile(t *testing.T) {
if _, ok := entry["raw_input"]; !ok {
t.Error("log entry missing raw_input")
}
if _, ok := entry["input"]; !ok {
t.Error("log entry missing input (preprocessed envelope)")
}
if _, ok := entry["output"]; !ok {
t.Error("log entry missing output")
}
Expand Down Expand Up @@ -3157,3 +3163,66 @@ func TestRun_FasLog_NonFatalOnBadDir(t *testing.T) {
t.Errorf("expected FAS_LOG warning on stderr, got %q", res.stderr)
}
}

func TestCollectDiagErrors_JoinNoDuplicate(t *testing.T) {
g := &diag.DiagError{D: diag.Diagnostic{Code: "E001"}}
p := &diag.DiagError{D: diag.Diagnostic{Code: "E002"}}

var diags []diag.Diagnostic
collectDiagErrors(errors.Join(g, p), &diags)

if len(diags) != 2 {
t.Fatalf("got %d diagnostics, want 2 (first must not be double-counted)", len(diags))
}
if diags[0].Code != "E001" || diags[1].Code != "E002" {
t.Errorf("codes=%q,%q want E001,E002", diags[0].Code, diags[1].Code)
}
}

func TestRun_Vet_PositionalArg_ExitTwo(t *testing.T) {
projectDir := emptyRulesDir(t)
globalDir := emptyRulesDir(t)

var stdout, stderr bytes.Buffer
exit := run(nil, &stdout, &stderr,
[]string{"vet", "./rules",
"--config", projectDir,
"--global-config", globalDir,
})

if exit != 2 {
t.Fatalf("exit=%d want 2 (stray positional arg must be rejected); stderr=%s", exit, stderr.String())
}
if !strings.Contains(stderr.String(), "positional") {
t.Errorf("expected positional-argument error; stderr=%q", stderr.String())
}
}

func TestRun_Vet_FormatSARIF_EmitsSARIF(t *testing.T) {
projectDir := writeRuleFiles(t, map[string]string{
"system.cue": denySystemTargetRule,
})
globalDir := emptyRulesDir(t)

var stdout, stderr bytes.Buffer
exit := run(nil, &stdout, &stderr,
[]string{"vet",
"--format", "sarif",
"--config", projectDir,
"--global-config", globalDir,
})

if exit != 0 {
t.Fatalf("exit=%d want 0; stderr=%s", exit, stderr.String())
}
var sarif struct {
Schema string `json:"$schema"`
Runs []any `json:"runs"`
}
if err := json.Unmarshal(stdout.Bytes(), &sarif); err != nil {
t.Fatalf("vet --format sarif must emit SARIF JSON, not text; got %q (err: %v)", stdout.String(), err)
}
if len(sarif.Runs) == 0 {
t.Errorf("expected a SARIF run; stdout=%q", stdout.String())
}
}
Loading
Loading