Architecture¶
How the code is organized and how a command moves data through it. Read this before changing anything under pkg/ or internal/.
sarifix is one Go binary built from small packages. Each package does one step of the pipeline, and the parts that touch the outside world (scanners today; the agent, the forge and the state store later) sit behind interfaces, so tests can replace them with fakes.
Packages¶
flowchart LR
cli["internal/cli"] --> config["pkg/config"]
cli --> scanner["pkg/scanner"]
scanner --> sarif["pkg/sarif"]
cli --> sarif
sarif --> finding["pkg/finding"]
finding --> baseline["pkg/baseline"]
finding --> policy["pkg/policy"]
policy --> group["pkg/group"]
group --> plan["pkg/plan"]
plan --> publish["pkg/publish"]
config -.-> placeholder["pkg/placeholder"]
config -.-> glob["pkg/glob"]
policy -.-> glob
scanner -.-> placeholder
Solid arrows follow the data; dotted ones are helper libraries.
| Package | Role | Main entry points |
|---|---|---|
cmd/sarifix |
main: runs the CLI and maps errors to exit codes |
main |
internal/cli |
Cobra commands, flags, viper settings, human and JSON output. One command per file. | Execute, NewRoot |
internal/ui |
Every terminal style (lipgloss), the fang help theme, the slog handler, and Sanitize for untrusted text |
NewStyles, Sanitize, NewLogger |
internal/version |
Build information injected with -ldflags |
Get |
internal/github |
A minimal GitHub REST client for issues mode: list, create, edit, comment, label. Implements publish.Tracker |
FromEnv |
internal/gitrepo |
The few git commands check needs: repo root, git directory, resolving a revision, temporary worktrees |
Root, Resolve, AddWorktree |
pkg/config |
sarifix.yaml: schema, defaults, --org-config layering, validation with file:line errors |
LoadLayered, (*Loaded).Validate |
pkg/scanner |
Runs a configured scanner as a subprocess (no shell, own process group, timeout) and routes SARIF runs by tool name | NewExec(...).Scan, (*Registry).Route |
pkg/sarif |
Loads SARIF 2.1.0, validates each run against the official schema, normalizes URIs to repo paths, writes runs back from their original JSON plus patches | LoadGlobs, NewNormalizer, (*Log).Write |
pkg/finding |
Turns results into Findings: rule keys, levels, anchors, fingerprints |
NewExtractor, (*Extractor).Extract, Annotate |
pkg/baseline |
Compares two finding sets as a multiset of fingerprints | Compare |
pkg/policy |
Judges each finding against the rules and gives skipped ones a reason | New(...).Apply |
pkg/group |
Batches fixable findings per rule or campaign, splits by limits, orders and caps; names branches | Batches, Slug |
pkg/plan |
Runs policy and grouping and records one status per finding | Build, (*Plan).Summarize, (*Plan).Annotate |
pkg/publish |
Mode handlers. Today: report mode (enriched SARIF and a JSON summary) and issues mode (one issue per batch, through a Tracker) |
Report, Issues, IssueBody |
pkg/placeholder |
Expands {out}, {outdir}, {dir}, {paths} and {repo} in argv templates |
ExpandArgs, Unknown |
pkg/glob |
Path globs (**, segment-bound *) and rule-key globs (* crosses /) |
MatchPath, MatchKey |
pkg/ packages don't import internal/, so the pipeline can be used as a library. internal/ holds what only the CLI needs.
What a command does¶
sarifix scan¶
internal/cliloads and validates the config (config.LoadLayered,Validate).- For each scanner,
scanner.Exec.Scanexpands the command's placeholders, runs it in a fresh temporary directory, and loads the SARIF it wrote. A non-zero exit counts as success when SARIF was written, since most scanners exit non-zero when they find something. finding.Extractorcomputes each finding's anchor and fingerprint, andfinding.Annotaterecords it aspartialFingerprints["sarifix/v1"].(*sarif.Log).WriteFilewritessarif/<scanner>.sarif: the tool's original JSON for each run, with the fingerprints patched in.
sarifix plan and sarifix fix --mode report¶
- Load config, then
sarif.LoadGlobsreads the SARIF. A run that fails schema validation is skipped with a warning; the others load. finding.Extractor.Extractproduces findings. Paths are normalized bysarif.Normalizer, which resolvesuriBaseIdchains, strips the repo root or a CI workspace root, and rejects anything outside the repo.plan.Buildrunspolicy.Apply, thengroup.Batches, and records a status for every finding:batched,deferredorskippedwith a reason.planprints the result.fix --mode reportcallspublish.Report, which patchesproperties.sarifixinto every finding's SARIF result and writessummary.json.fix --mode issuespasses the batches topublish.Issueswith aninternal/githubclient.
sarifix check¶
- Find the git root and load the config from it.
- Resolve
--base(defaultHEAD). Look for its scan in.git/sarifix/scans/<commit>-<config hash>/; if it isn't there, check the commit out into a temporary worktree (internal/gitrepo), scan it, and cache the result. - Scan the working tree into a temporary directory.
baseline.Comparethe two, leaving out any scanner that failed on either side. Thenpolicy.ApplywithIgnoreAllow, sorules.allowdoesn't filter feedback.- Print the new findings, or with
--hook, answer Claude Code in JSON:additionalContextfor Claude,systemMessagefor the user. See ADR 6.
sarifix diff¶
Loads both sides, extracts findings (base lines are read under --base-root), and calls baseline.Compare. Line numbers never take part. See Fingerprints.
Design rules¶
These are the decisions that shape the code. The decision records give the reasoning.
- SARIF is written from its original JSON. go-sarif's structs serialize zero values that change a log's meaning, such as a rule's
"enabled": false. EachSourcedRunkeeps its raw JSON, and changes are recorded as patches (AddPartialFingerprint,SetResultProperty) and applied when writing. (ADR 2) - Identity never uses line numbers. Precedence: the tool's
primaryLocationLineHash, its other partial fingerprints, sarifix's recordedsarifix/v1, then a hash of the finding's anchor. (ADR 3) - Scanner output is untrusted. Messages and paths are passed through
ui.Sanitizebefore reaching a terminal, and are never logged at info level. URIs that escape the repo produce findings with no location, which are never fixable. - Every skip has a reason. Nothing disappears silently between scan and plan. (ADR 5)
- One command per file, registered in
init().NewRootbuilds a fresh command tree on every call, so tests never share flag state. (ADR 1) - Exit codes are part of the CLI contract: 0 success, 1 runtime error, 2 config error (any
*config.Errors), 3 failed batches.
Where the next phases go¶
The spec adds these packages, each behind an interface with a fake:
| Package | Phase | Interface |
|---|---|---|
pkg/agent |
2 | Agent.Fix(ctx, FixTask) (FixResult, error), with a Claude Code headless implementation |
pkg/sandbox |
2 | Per-batch git worktree with no network |
pkg/verify |
2 | Diff guard, rescan, target fingerprints gone, no new findings, tests pass |
pkg/forge |
3 | Forge: open PRs, find open PRs by branch, post reviews (GitHub first) |
pkg/state |
3 | Store: record and query attempts (JSONL, then SQLite) |
pkg/metrics |
5 | Aggregation for sarifix stats |
pkg/publish gains the local, pr and review handlers, and fix stops rejecting those modes.
Tests¶
- Unit tests live next to the code. Shared fixtures from real scanner runs are in
testdata/, and thetestdata/README.mdexplains each one and how to regenerate it. internal/clitests run commands throughExecuteand compare against golden files intestdata/plan/.test/integrationruns real scanners, behind theintegrationbuild tag.- Critical paths have benchmarks and fuzz targets, listed in Contributing.