diff --git a/README.md b/README.md index 10d0111..c400f21 100644 --- a/README.md +++ b/README.md @@ -183,9 +183,9 @@ Response without New Zealand reference. **sctx hook** - Reads agent hook input from stdin, returns matching context entries. This is the main integration point. Decisions are excluded from hook output. -**sctx context \** - Query context entries for a file or directory. Supports `--on `, `--when `, and `--json`. +**sctx context \** - Query context entries for a file or directory. Supports `--on `, `--when `, `--json`, and `--all` to dump every entry from every AGENTS.yaml. -**sctx decisions \** - Query decisions for a file or directory. Supports `--json`. +**sctx decisions \** - Query decisions for a file or directory. Supports `--json` and `--all`. **sctx validate [\]** - Checks all context files in a directory tree for schema errors and invalid globs. diff --git a/cmd/sctx/main.go b/cmd/sctx/main.go index 6f3f036..c4d0069 100644 --- a/cmd/sctx/main.go +++ b/cmd/sctx/main.go @@ -19,8 +19,10 @@ const usage = `sctx — Structured Context CLI Usage: sctx hook Read agent hook input from stdin, return matching context sctx context [--on ] [--when ] [--json] + sctx context --all [--on ] [--when ] [--json] Query context entries for a file or directory - sctx decisions [--json] Query decisions for a file or directory + sctx decisions [--json] + sctx decisions --all [--json] Query decisions for a file or directory sctx validate [] Validate all context files in a directory tree sctx init Create a starter AGENTS.yaml in the current directory sctx claude enable Enable sctx hooks in Claude Code @@ -37,6 +39,7 @@ var ( version = "dev" errMissingPath = errors.New("missing required argument") + errAllAndPath = errors.New("--all and are mutually exclusive") errOnNeedsValue = errors.New("--on requires a value") errWhenNeedsValue = errors.New("--when requires a value") errInvalidAction = errors.New("invalid --on value") @@ -101,17 +104,16 @@ func cmdHook(in io.Reader, out, errOut io.Writer) error { } func cmdContext(args []string, out, errOut io.Writer) error { - if len(args) < 1 { - return errMissingPath - } - - filePath := args[0] action := core.ActionAll timing := core.TimingAll jsonOutput := false + allFlag := false + var filePath string - for i := 1; i < len(args); i++ { + for i := 0; i < len(args); i++ { switch args[i] { + case "--all": + allFlag = true case "--on": if i+1 >= len(args) { return errOnNeedsValue @@ -140,9 +142,23 @@ func cmdContext(args []string, out, errOut io.Writer) error { timing = core.Timing(v) case "--json": jsonOutput = true + default: + filePath = args[i] } } + if allFlag && filePath != "" { + return errAllAndPath + } + + if allFlag { + return cmdContextAll(action, timing, jsonOutput, out, errOut) + } + + if filePath == "" { + return errMissingPath + } + absPath, err := filepath.Abs(filePath) if err != nil { return fmt.Errorf("resolving path: %w", err) @@ -187,20 +203,67 @@ func cmdContext(args []string, out, errOut io.Writer) error { return nil } -func cmdDecisions(args []string, out, errOut io.Writer) error { - if len(args) < 1 { - return errMissingPath +func cmdContextAll(action core.Action, timing core.Timing, jsonOutput bool, out, errOut io.Writer) error { + result, warnings, err := core.ResolveAll(core.ResolveAllRequest{ + Action: action, + Timing: timing, + }) + if err != nil { + return err + } + + for _, w := range warnings { + _, _ = fmt.Fprintln(errOut, w) } - filePath := args[0] + if jsonOutput { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(result.ContextEntries) + } + + if len(result.ContextEntries) == 0 { + _, _ = fmt.Fprintln(out, "No matching context found.") + return nil + } + + for _, entry := range result.ContextEntries { + _, _ = fmt.Fprintf(out, " - %s\n", entry.Content) + _, _ = fmt.Fprintf(out, " Match: %s\n", strings.Join(entry.Match, ", ")) + _, _ = fmt.Fprintf(out, " (from %s)\n", entry.SourceFile) + } + + return nil +} + +func cmdDecisions(args []string, out, errOut io.Writer) error { jsonOutput := false + allFlag := false + var filePath string - for i := 1; i < len(args); i++ { - if args[i] == "--json" { + for i := range args { + switch args[i] { + case "--all": + allFlag = true + case "--json": jsonOutput = true + default: + filePath = args[i] } } + if allFlag && filePath != "" { + return errAllAndPath + } + + if allFlag { + return cmdDecisionsAll(jsonOutput, out, errOut) + } + + if filePath == "" { + return errMissingPath + } + absPath, err := filepath.Abs(filePath) if err != nil { return fmt.Errorf("resolving path: %w", err) @@ -253,6 +316,46 @@ func cmdDecisions(args []string, out, errOut io.Writer) error { return nil } +func cmdDecisionsAll(jsonOutput bool, out, errOut io.Writer) error { + result, warnings, err := core.ResolveAll(core.ResolveAllRequest{}) + if err != nil { + return err + } + + for _, w := range warnings { + _, _ = fmt.Fprintln(errOut, w) + } + + if jsonOutput { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(result.DecisionEntries) + } + + if len(result.DecisionEntries) == 0 { + _, _ = fmt.Fprintln(out, "No matching decisions found.") + return nil + } + + for _, entry := range result.DecisionEntries { + _, _ = fmt.Fprintf(out, " - %s\n", entry.Decision) + _, _ = fmt.Fprintf(out, " Rationale: %s\n", entry.Rationale) + + for _, alt := range entry.Alternatives { + _, _ = fmt.Fprintf(out, " Considered %s, rejected: %s\n", alt.Option, alt.ReasonRejected) + } + + if entry.RevisitWhen != "" { + _, _ = fmt.Fprintf(out, " Revisit when: %s\n", entry.RevisitWhen) + } + + _, _ = fmt.Fprintf(out, " Match: %s\n", strings.Join(entry.Match, ", ")) + _, _ = fmt.Fprintf(out, " (from %s)\n", entry.SourceFile) + } + + return nil +} + func cmdValidate(args []string, out io.Writer) error { dir := "." if len(args) > 0 { diff --git a/cmd/sctx/main_test.go b/cmd/sctx/main_test.go index dfc8e3e..a0b5ed5 100644 --- a/cmd/sctx/main_test.go +++ b/cmd/sctx/main_test.go @@ -259,6 +259,154 @@ func TestCmdDecisions(t *testing.T) { } } +func setupAllFixture(t *testing.T) { + t.Helper() + tmp := t.TempDir() + t.Chdir(tmp) + + writeTestFile(t, filepath.Join(tmp, "AGENTS.yaml"), `context: + - content: "root guidance" + match: ["**"] + on: read + when: before + +decisions: + - decision: "use Go" + rationale: "fast" + match: ["**"] +`) + writeTestFile(t, filepath.Join(tmp, "sub", "AGENTS.yaml"), `context: + - content: "sub guidance" + match: ["*.go"] + on: edit + when: after + +decisions: + - decision: "use Postgres" + rationale: "reliable" + match: ["*.sql"] +`) +} + +func TestCmdContext_All(t *testing.T) { + setupAllFixture(t) + + var out, errOut bytes.Buffer + if err := cmdContext([]string{"--all"}, &out, &errOut); err != nil { + t.Fatal(err) + } + + got := out.String() + for _, want := range []string{"root guidance", "sub guidance", "Match:", "(from AGENTS.yaml)", "(from sub/AGENTS.yaml)"} { + if !strings.Contains(got, want) { + t.Errorf("output missing %q, got %q", want, got) + } + } +} + +func TestCmdContext_AllJson(t *testing.T) { + setupAllFixture(t) + + var out, errOut bytes.Buffer + if err := cmdContext([]string{"--all", "--json"}, &out, &errOut); err != nil { + t.Fatal(err) + } + + var entries []map[string]any + if err := json.Unmarshal(out.Bytes(), &entries); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + for _, e := range entries { + if _, ok := e["Content"]; !ok { + t.Error("missing Content field") + } + if _, ok := e["Match"]; !ok { + t.Error("missing Match field") + } + if _, ok := e["SourceFile"]; !ok { + t.Error("missing SourceFile field") + } + } +} + +func TestCmdContext_AllWithPath(t *testing.T) { + err := cmdContext([]string{"--all", "some/path"}, nil, nil) + if !errors.Is(err, errAllAndPath) { + t.Errorf("got error %v, want %v", err, errAllAndPath) + } +} + +func TestCmdContext_AllWithFilters(t *testing.T) { + setupAllFixture(t) + + var out, errOut bytes.Buffer + if err := cmdContext([]string{"--all", "--on", "read", "--when", "before"}, &out, &errOut); err != nil { + t.Fatal(err) + } + + got := out.String() + if !strings.Contains(got, "root guidance") { + t.Errorf("expected 'root guidance', got %q", got) + } + if strings.Contains(got, "sub guidance") { + t.Errorf("should not contain 'sub guidance' (on:edit), got %q", got) + } +} + +func TestCmdDecisions_All(t *testing.T) { + setupAllFixture(t) + + var out, errOut bytes.Buffer + if err := cmdDecisions([]string{"--all"}, &out, &errOut); err != nil { + t.Fatal(err) + } + + got := out.String() + for _, want := range []string{"use Go", "use Postgres", "Match:", "(from AGENTS.yaml)", "(from sub/AGENTS.yaml)"} { + if !strings.Contains(got, want) { + t.Errorf("output missing %q, got %q", want, got) + } + } +} + +func TestCmdDecisions_AllJson(t *testing.T) { + setupAllFixture(t) + + var out, errOut bytes.Buffer + if err := cmdDecisions([]string{"--all", "--json"}, &out, &errOut); err != nil { + t.Fatal(err) + } + + var entries []map[string]any + if err := json.Unmarshal(out.Bytes(), &entries); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + for i, e := range entries { + for _, field := range []string{"Decision", "Match", "SourceFile"} { + if _, ok := e[field]; !ok { + t.Errorf("entry %d: missing %s field", i, field) + } + } + } +} + +func TestCmdDecisions_AllWithPath(t *testing.T) { + err := cmdDecisions([]string{"--all", "some/path"}, nil, nil) + if !errors.Is(err, errAllAndPath) { + t.Errorf("got error %v, want %v", err, errAllAndPath) + } +} + func TestCmdDecisions_NoMatch(t *testing.T) { tmp := t.TempDir() writeTestFile(t, filepath.Join(tmp, "AGENTS.yaml"), `context: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 069c016..dcff1d8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -32,12 +32,17 @@ sctx context src/api/handler.py sctx context src/api/handler.py --on edit --when before sctx context src/api/ # directory query sctx context src/api/handler.py --json +sctx context --all # every entry from every AGENTS.yaml +sctx context --all --on edit --json ``` +Use `--all` to dump every context entry from every `AGENTS.yaml` file in the tree, skipping glob matching entirely. Output includes the source file path and match patterns for each entry so you can see where each entry is defined and what it targets. `--all` and `` are mutually exclusive. `--on` and `--when` filters still apply with `--all`. + ### Flags | Flag | Default | Description | |---|---|---| +| `--all` | off | Return all entries from all AGENTS.yaml files, skip glob matching | | `--on ` | `all` | Filter by action: `read`, `edit`, `create`, `all` | | `--when ` | `all` | Filter by timing: `before`, `after`, `all` | | `--json` | off | Output as JSON instead of human-readable text | @@ -50,12 +55,17 @@ Query decisions for a file or directory. Shows architectural decisions that appl sctx decisions src/api/handler.py sctx decisions src/api/ # directory query sctx decisions src/api/handler.py --json +sctx decisions --all # every decision from every AGENTS.yaml +sctx decisions --all --json ``` +Use `--all` to dump every decision entry from every `AGENTS.yaml` file in the tree. Like `sctx context --all`, output includes source file paths and match patterns. `--all` and `` are mutually exclusive. + ### Flags | Flag | Default | Description | |---|---|---| +| `--all` | off | Return all entries from all AGENTS.yaml files, skip glob matching | | `--json` | off | Output as JSON instead of human-readable text | ## sctx validate [\] diff --git a/internal/core/engine.go b/internal/core/engine.go index 8fe92b3..ef86e78 100644 --- a/internal/core/engine.go +++ b/internal/core/engine.go @@ -94,6 +94,111 @@ func resolveDir(req ResolveRequest, root string) (*ResolveResult, []string, erro return result, warnings, nil } +// ResolveAll walks the entire directory tree from Root, discovers all +// AGENTS.yaml files, and returns every context and decision entry without +// glob filtering. Action and Timing filters are still applied to context entries. +func ResolveAll(req ResolveAllRequest) (*ResolveAllResult, []string, error) { + root := req.Root + var err error + if root == "" { + root, err = os.Getwd() + if err != nil { + return nil, nil, fmt.Errorf("getting working directory: %w", err) + } + } + + root, err = filepath.Abs(root) + if err != nil { + return nil, nil, fmt.Errorf("resolving absolute path: %w", err) + } + + result := &ResolveAllResult{} + var warnings []string + + walkErr := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + + w := collectDir(path, root, req, result) + warnings = append(warnings, w...) + return nil + }) + if walkErr != nil { + return nil, nil, fmt.Errorf("walking directory tree: %w", walkErr) + } + + if len(result.ContextEntries) == 0 && len(result.DecisionEntries) == 0 { + warnings = append(warnings, "warning: no AGENTS.yaml files found") + } + + return result, warnings, nil +} + +// collectDir parses AGENTS.yaml files in dir and appends entries to result. +func collectDir(dir, root string, req ResolveAllRequest, result *ResolveAllResult) []string { + var warnings []string + + for _, name := range AgentsFileNames { + fp := filepath.Join(dir, name) + data, readErr := os.ReadFile(fp) //nolint:gosec // paths come from directory walk + if readErr != nil { + continue + } + + var cf ContextFile + if parseErr := yaml.Unmarshal(data, &cf); parseErr != nil { + warnings = append(warnings, fmt.Sprintf("warning: failed to parse %s: %v", fp, parseErr)) + continue + } + + cf.sourceDir = dir + applyDefaults(&cf) + + relFile, _ := filepath.Rel(root, fp) + relFile = filepath.ToSlash(relFile) + + collectContextEntries(cf, relFile, req, result) + collectDecisionEntries(cf, relFile, result) + } + + return warnings +} + +func collectContextEntries(cf ContextFile, relFile string, req ResolveAllRequest, result *ResolveAllResult) { + for _, entry := range cf.Context { + if !matchesAction(entry.On, req.Action) { + continue + } + if req.Timing != "" && req.Timing != TimingAll && + Timing(entry.When) != TimingAll && Timing(entry.When) != req.Timing { + continue + } + result.ContextEntries = append(result.ContextEntries, AllContextEntry{ + Content: entry.Content, + Match: entry.Match, + SourceFile: relFile, + }) + } +} + +func collectDecisionEntries(cf ContextFile, relFile string, result *ResolveAllResult) { + for _, entry := range cf.Decisions { + result.DecisionEntries = append(result.DecisionEntries, AllDecisionEntry{ + Decision: entry.Decision, + Rationale: entry.Rationale, + Alternatives: entry.Alternatives, + RevisitWhen: entry.RevisitWhen, + Date: entry.Date, + Match: entry.Match, + SourceFile: relFile, + }) + } +} + // discoverAndParse walks from startDir up to root, collecting and parsing all // context files. Files from parent directories come first (lower specificity). func discoverAndParse(startDir, root string) (files []ContextFile, warnings []string) { diff --git a/internal/core/engine_test.go b/internal/core/engine_test.go index fa4cef1..fbf8947 100644 --- a/internal/core/engine_test.go +++ b/internal/core/engine_test.go @@ -2136,3 +2136,249 @@ func assertContextContents(t *testing.T, got []MatchedContext, want []string) { } } } + +func TestResolveAll_CollectsAllEntries(t *testing.T) { + root := t.TempDir() + + // Root AGENTS.yaml: one context, one decision + writeRawAgentsYAML(t, root, ` +context: + - content: "Root context" + match: ["*.go"] +decisions: + - decision: "Use Go" + rationale: "Performance" +`) + + // Subdirectory AGENTS.yaml: one context, one decision with different globs + sub := filepath.Join(root, "sub") + mkdirAll(t, sub) + writeRawAgentsYAML(t, sub, ` +context: + - content: "Sub context" + match: ["*.py"] +decisions: + - decision: "Use Python here" + rationale: "Data science" +`) + + result, warnings, err := ResolveAll(ResolveAllRequest{Root: root}) + if err != nil { + t.Fatalf("ResolveAll() error: %v", err) + } + + // Should not warn about missing files + for _, w := range warnings { + if strings.Contains(w, "no AGENTS.yaml") { + t.Errorf("unexpected warning: %s", w) + } + } + + if len(result.ContextEntries) != 2 { + t.Fatalf("want 2 context entries, got %d", len(result.ContextEntries)) + } + if len(result.DecisionEntries) != 2 { + t.Fatalf("want 2 decision entries, got %d", len(result.DecisionEntries)) + } + + // Verify all entries present regardless of glob + contents := map[string]bool{} + for _, e := range result.ContextEntries { + contents[e.Content] = true + } + for _, want := range []string{"Root context", "Sub context"} { + if !contents[want] { + t.Errorf("missing context entry %q", want) + } + } + + decisions := map[string]bool{} + for _, e := range result.DecisionEntries { + decisions[e.Decision] = true + } + for _, want := range []string{"Use Go", "Use Python here"} { + if !decisions[want] { + t.Errorf("missing decision entry %q", want) + } + } +} + +func TestResolveAll_FiltersActionAndTiming(t *testing.T) { + root := t.TempDir() + + writeRawAgentsYAML(t, root, ` +context: + - content: "Edit only" + on: edit + when: before + - content: "Read only" + on: read + when: after + - content: "All actions" + on: all + when: before +`) + + tests := []struct { + name string + action Action + timing Timing + want []string + }{ + {"edit+before", ActionEdit, TimingBefore, []string{"Edit only", "All actions"}}, + {"read+after", ActionRead, TimingAfter, []string{"Read only"}}, + {"all+all", ActionAll, TimingAll, []string{"Edit only", "Read only", "All actions"}}, + {"edit+after", ActionEdit, TimingAfter, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, _, err := ResolveAll(ResolveAllRequest{ + Root: root, + Action: tt.action, + Timing: tt.timing, + }) + if err != nil { + t.Fatalf("ResolveAll() error: %v", err) + } + if len(result.ContextEntries) != len(tt.want) { + got := make([]string, len(result.ContextEntries)) + for i, e := range result.ContextEntries { + got[i] = e.Content + } + t.Fatalf("want %d entries %v, got %d %v", len(tt.want), tt.want, len(result.ContextEntries), got) + } + gotSet := map[string]bool{} + for _, e := range result.ContextEntries { + gotSet[e.Content] = true + } + for _, w := range tt.want { + if !gotSet[w] { + t.Errorf("missing %q", w) + } + } + }) + } +} + +func TestResolveAll_PopulatesSourceFileAndMatch(t *testing.T) { + root := t.TempDir() + + writeRawAgentsYAML(t, root, ` +context: + - content: "Root entry" + match: ["**/*.go"] +decisions: + - decision: "Root decision" + match: ["src/**"] +`) + + sub := filepath.Join(root, "src", "pkg") + mkdirAll(t, sub) + writeRawAgentsYAML(t, sub, ` +context: + - content: "Nested entry" +decisions: + - decision: "Nested decision" + match: ["*.rs"] +`) + + result, _, err := ResolveAll(ResolveAllRequest{Root: root}) + if err != nil { + t.Fatalf("ResolveAll() error: %v", err) + } + + // Build lookup maps for verification + ctxByContent := map[string]AllContextEntry{} + for _, e := range result.ContextEntries { + ctxByContent[e.Content] = e + } + decByDecision := map[string]AllDecisionEntry{} + for _, e := range result.DecisionEntries { + decByDecision[e.Decision] = e + } + + t.Run("context", func(t *testing.T) { + assertSourceAndMatch(t, ctxByContent["Root entry"].SourceFile, "AGENTS.yaml", + ctxByContent["Root entry"].Match, []string{"**/*.go"}) + assertSourceAndMatch(t, ctxByContent["Nested entry"].SourceFile, "src/pkg/AGENTS.yaml", + ctxByContent["Nested entry"].Match, []string{"**"}) + }) + + t.Run("decisions", func(t *testing.T) { + assertSourceAndMatch(t, decByDecision["Root decision"].SourceFile, "AGENTS.yaml", + decByDecision["Root decision"].Match, []string{"src/**"}) + assertSourceAndMatch(t, decByDecision["Nested decision"].SourceFile, "src/pkg/AGENTS.yaml", + decByDecision["Nested decision"].Match, []string{"*.rs"}) + }) +} + +func TestResolveAll_WarnsOnParseFailure(t *testing.T) { + root := t.TempDir() + + // Valid file + writeRawAgentsYAML(t, root, ` +decisions: + - decision: "Good entry" + rationale: "Works" +`) + + // Malformed file in subdirectory + bad := filepath.Join(root, "bad") + mkdirAll(t, bad) + writeRawAgentsYAML(t, bad, `{{{not valid yaml`) + + result, warnings, err := ResolveAll(ResolveAllRequest{Root: root}) + if err != nil { + t.Fatalf("ResolveAll() error: %v", err) + } + + // Should still get the good entry + if len(result.DecisionEntries) != 1 { + t.Fatalf("want 1 decision entry, got %d", len(result.DecisionEntries)) + } + if result.DecisionEntries[0].Decision != "Good entry" { + t.Errorf("got decision %q, want %q", result.DecisionEntries[0].Decision, "Good entry") + } + + // Should have a warning about the bad file + found := false + for _, w := range warnings { + if strings.Contains(w, "failed to parse") && strings.Contains(w, "bad") { + found = true + } + } + if !found { + t.Errorf("expected parse warning, got %v", warnings) + } +} + +func assertSourceAndMatch(t *testing.T, gotSource, wantSource string, gotMatch, wantMatch []string) { + t.Helper() + if gotSource != wantSource { + t.Errorf("SourceFile: got %q, want %q", gotSource, wantSource) + } + if len(gotMatch) != len(wantMatch) { + t.Errorf("Match: got %v, want %v", gotMatch, wantMatch) + return + } + for i := range wantMatch { + if gotMatch[i] != wantMatch[i] { + t.Errorf("Match[%d]: got %q, want %q", i, gotMatch[i], wantMatch[i]) + } + } +} + +func writeRawAgentsYAML(t *testing.T, dir, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "AGENTS.yaml"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func mkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o750); err != nil { + t.Fatal(err) + } +} diff --git a/internal/core/schema.go b/internal/core/schema.go index 06d7bb8..b998626 100644 --- a/internal/core/schema.go +++ b/internal/core/schema.go @@ -115,3 +115,35 @@ type MatchedContext struct { Content string SourceDir string } + +// ResolveAllRequest contains the inputs for an unscoped resolution that +// collects every entry from every discovered AGENTS.yaml file. +type ResolveAllRequest struct { + Root string + Action Action + Timing Timing +} + +// ResolveAllResult contains every context and decision entry found in the tree. +type ResolveAllResult struct { + ContextEntries []AllContextEntry + DecisionEntries []AllDecisionEntry +} + +// AllContextEntry is a context entry augmented with source metadata. +type AllContextEntry struct { + Content string `json:"Content"` + Match []string `json:"Match"` + SourceFile string `json:"SourceFile"` +} + +// AllDecisionEntry is a decision entry augmented with source metadata. +type AllDecisionEntry struct { + Decision string `json:"Decision"` + Rationale string `json:"Rationale"` + Alternatives []Alternative `json:"Alternatives,omitempty"` + RevisitWhen string `json:"RevisitWhen,omitempty"` + Date string `json:"Date,omitempty"` + Match []string `json:"Match"` + SourceFile string `json:"SourceFile"` +}