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
6 changes: 3 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ analysis:
scorer: "cosine"
threshold: 0.75 # Minimum score; defaults to vector_store.similarity_threshold
top_k: 3 # Maximum ADRs kept; defaults to max_relevant_adrs (3 when unset)
on_error: "skip" # "skip" (default) skips the file when the stage fails; "fail" fails the check
accepted_statuses: ["Accepted", "Active"] # Use ["*"] to include all statuses
exclude_patterns:
- "**/*_test.go"
Expand Down Expand Up @@ -159,6 +160,7 @@ For each changed file, ArchGuard picks which of the ADRs whose `scope` matches i
- `scorer`: how candidates are scored. `cosine` (embedding similarity) is the only scorer today, and it is the default when omitted.
- `threshold`: the minimum score, from 0 to 1. Candidates scoring below it are dropped.
- `top_k`: the maximum number of candidates kept, a positive integer.
- `on_error`: what happens when the stage fails, `skip` or `fail` (see [When a stage fails](#when-a-stage-fails)). Unset behaves as `skip`.

| | `rank` | `rerank` |
|---|---|---|
Expand All @@ -182,7 +184,27 @@ A `rerank` configured without a `rank` runs after the default `rank`. An ADR's o

Every cosine stage re-scores the same candidates, so a cosine `rerank` after a cosine `rank` can only tighten `threshold` or `top_k` and costs one extra embedding call per file. It becomes useful once other scorers can fill a stage.

An unknown scorer, an unrecognized stage or stage key, a non-numeric or out-of-range `threshold`, or a non-positive `top_k` stops `archguard` at startup with exit code 3 and a message naming the stage and key.
An unknown scorer, an unrecognized stage or stage key, a non-numeric or out-of-range `threshold`, a non-positive `top_k`, or an `on_error` other than `skip` or `fail` stops `archguard` at startup with exit code 3 and a message naming the stage and key.

#### When a stage fails

By default, if a stage fails for a file (for example the embedding call errors), ArchGuard skips that file, prints the error, counts it in the skipped-files summary, and the run still exits `0`. Set `on_error: fail` on a stage to fail the check instead:

```yaml
analysis:
pipeline:
rank:
on_error: fail
```

Under `fail`, a failed stage stops that file's remaining stages, other files still run, and every failure is printed with its stage, file and error. The run then exits non-zero:

| Failure kind | Meaning | Exit code |
|---|---|---|
| `unavailable` | A dependency did not respond, such as the embedding provider | `6` |
| `precondition_not_met` | The stage could not run at all, such as no embedding provider being configured | `7` |

A run with both kinds exits `7`. `on_error: skip` behaves exactly like leaving it unset. With `--update-baseline`, a `fail` failure exits `6` or `7` without writing the baseline. Under `--format json` the failures are listed in a `failures` array (see [Machine-Readable Output](#machine-readable-output)).

### ADR Format

Expand Down Expand Up @@ -277,6 +299,10 @@ This will automatically create the `archguard_adrs` table and safely scope all A
- **3**: Config error (failed to load or validate `archguard.yaml`).
- **4**: Architectural drift detected.
- **5**: Index error (failed to build, load, or fetch ADRs for the vector store).
- **6**: A ranking stage with `on_error: fail` could not reach a dependency it needs, such as the embedding provider (see [Ranking Stages](#ranking-stages)).
- **7**: A ranking stage with `on_error: fail` could not run because a precondition was not met, such as no embedding provider being configured. If a run has both kinds of failure, it exits `7`.

Codes `6` and `7` take precedence over `4`: a run that also found drift still exits `6` or `7`, because the check was incomplete. They are only returned when a stage sets `on_error: fail`; by default a failed ranking stage skips its file and the run exits `0` (see [Ranking Stages](#ranking-stages)).

### Machine-Readable Output

Expand All @@ -299,6 +325,8 @@ This will automatically create the `archguard_adrs` table and safely scope all A
}
```

When a stage with `on_error: fail` fails, the document also carries a `failures` array, each entry with the `stage`, the `file`, the `kind` (`unavailable` or `precondition_not_met`) and the underlying `error`; the array is omitted when nothing failed. The error text itself goes to stderr.

`count` matches the number of new (non-baselined) violations that drives the `4` (drift detected) exit code above. `suggestion` is present only when `--suggest-fixes` was passed; it's an LLM-generated pointer, not a verified or guaranteed fix, and it is omitted from the JSON entirely (not an empty string) when `--suggest-fixes` is off or the LLM produced nothing.

> **Note:** run `archguard index` before a `--format json` check. If the index needs an automatic rebuild during `check` (e.g. a stale/missing index, or an ADR provider warning), that rebuild's own progress text currently still prints to stdout ahead of the JSON document (tracked in [#163](https://github.com/Tgenz1213/ArchGuard/issues/163)). With an up-to-date index this doesn't happen.
Expand Down
1 change: 1 addition & 0 deletions docs/arch/0014-json-check-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ scope: "internal/**"

- stdout carries exactly one JSON document: `{"violations": [...], "count": N}`, where each violation has `file`, `adr_id`, `adr_title`, `line`, `reasoning`, `quoted_code`, and (per `docs/arch/0016-llm-suggested-remediation.md`) an optional `suggestion`, and `count` matches `DriftDetectedError.Count` (the same number that drives the `ExitDriftDetected` exit code).
- Everything else that would normally print to stdout (the startup banner, `--debug` logging, per-file progress, index-rebuild notices, the final "No new architectural violations found." summary) is redirected to stderr instead of being suppressed outright, so `--format json --debug` still gives visibility into what happened without breaking a pipe consuming stdout.
- When a stage with `on_error: fail` fails (see `docs/arch/0022-candidate-scoring-pipeline.md`), the document also carries a `failures` array of `{stage, file, kind, error}`; it is omitted when empty.
- Exit codes are unaffected: `--format` only changes what's printed, never what's returned.
- `--update-baseline` ignores `--format` entirely (a printed note explains this) -- its output is a maintenance summary about the baseline file, not the violation report `--format json` targets, and baselining suppresses the very violations this flag would otherwise report.

Expand Down
7 changes: 4 additions & 3 deletions docs/arch/0022-candidate-scoring-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ Candidates flow through an ordered list of `stage.Stage`s (`internal/analysis/st
- `stage.Scorer` receives a `File`, a `Debug`, and all of a file's `Candidate`s in one call, and returns one score per candidate, in order, on the scale its stage's `Threshold` uses (0-1 for screening models; raw cosine similarity, -1 to 1, for `CosineRanker`). It only scores; it never drops or reorders.
- `stage.Stage` wraps a `Scorer` with a `Threshold` (the minimum score, optionally per ADR) and `MaxKeep`. The stage drops candidates below the threshold, orders survivors by descending score, and cuts to `MaxKeep`. The next stage receives only the survivors.
- `stage.File` exposes the path and the text to embed (`QueryText`, built lazily, diff-preferred and capped). `stage.Debug` is a real writer under `--debug` and a no-op otherwise, so scorers never nil-check.
- `stage.Error` carries the action that failed (`generating embedding`, `scoring candidates`); the engine reports `Error <action> for <file>` without knowing which scorer ran.
- `stage.Error` carries the action that failed (`generating embedding`, `scoring candidates`) and a `Kind`: `KindUnavailable` (a dependency did not respond, the zero value) or `KindPreconditionNotMet` (the stage cannot run at all, such as cosine with no embedding provider). The engine reports `Error <action> for <file>` without knowing which scorer ran.
- `Engine.Stages` holds the pipeline. When unset it is one `stage.NewCosineStage`: `CosineRanker` scoring, an `ADRThreshold` (an ADR's own `similarity_threshold` over `vector_store.similarity_threshold`), and `analysis.max_relevant_adrs` as `MaxKeep`.
- `analysis.pipeline` configures the stages. `config.Pipeline` holds optional `rank` and `rerank` `StageConfig`s (`scorer`, `threshold`, `top_k`), validated when the config loads. `analysis.BuildStages` turns it into `Engine.Stages`, and returns nil when there is no block so the default above stays in force. An absent `rank` is the default cosine stage; an unset `rank` `threshold` or `top_k` falls back to `vector_store.similarity_threshold` and `analysis.max_relevant_adrs`; `rerank` uses 0 and 3 with a printed warning. A stage `threshold` is the default for ADRs without their own `similarity_threshold`, which wins in every cosine stage.
- `analysis.pipeline` configures the stages. `config.Pipeline` holds optional `rank` and `rerank` `StageConfig`s (`scorer`, `threshold`, `top_k`, `on_error`), validated when the config loads. `analysis.BuildStages` turns it into `Engine.Stages`, and returns nil when there is no block so the default above stays in force. An absent `rank` is the default cosine stage; an unset `rank` `threshold` or `top_k` falls back to `vector_store.similarity_threshold` and `analysis.max_relevant_adrs`; `rerank` uses 0 and 3 with a printed warning. A stage `threshold` is the default for ADRs without their own `similarity_threshold`, which wins in every cosine stage.
- The LLM is the only source of reported violations. Stages choose which ADRs are judged, never whether a file violates one.

The pipeline's input is built in a fixed order by `candidateSource`:
Expand All @@ -35,7 +35,8 @@ Embedding is done by `CosineRanker` alone, through `llm.Embedder`, so a pipeline
- Adding a scorer means implementing `stage.Scorer`; stage ordering, thresholds, caps, and reporting are unchanged.
- Scorer names are validated in `internal/config`, which cannot import `stage`, so a new scorer is also added to that list.
- Every cosine stage scores the same candidates, so a cosine `rerank` after a cosine `rank` only tightens `threshold` or `top_k` and embeds the file a second time.
- An embedding failure is reported as `Error generating embedding for <file>`; any other scorer failure as `Error scoring candidates for <file>`. Both count the file as skipped.
- An embedding failure is reported as `Error generating embedding for <file>`; any other scorer failure as `Error scoring candidates for <file>`. Both count the file as skipped, unless the stage sets `on_error: fail`.
- `on_error` is per stage, `skip` (the default) or `fail`. Under `fail` the engine records a `StageFailure` (stage name, file, kind, error) in `Engine.StageFailures` instead of counting a skipped file, stops that file's remaining stages, and lets other files run. `cli.runCheck` exits `6` when every failure is `KindUnavailable` and `7` when any is `KindPreconditionNotMet`; both take precedence over the drift exit code `4`, because an incomplete check is not a clean verdict, and `--update-baseline` exits with them without writing the baseline. `check --format json` lists the failures under `failures`, omitted when there are none, so a run without `fail` failures produces the same document as before.
- Every `VectorStore` implementation provides `ScopedADRs`, and it returns an error rather than an empty list when the backend fails; the engine reports `Error loading candidate ADRs for <file>` and counts the file as skipped, so an unreachable database can't make a check pass.
- Equal scores keep candidate order (the store's order), so a tie at the top-K boundary is resolved deterministically.
- `--debug` prints `Skipping ADR ... (Suppressed)` for every suppressed scope-matched ADR first, then the stage's `Below threshold` (capped at `MaxKeep`) and `Cut by top-K limit` lines, whichever scorer produced the scores.
40 changes: 38 additions & 2 deletions internal/analysis/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"os"
"regexp"
"sort"
"strings"
"sync"
"unicode/utf8"
Expand Down Expand Up @@ -39,6 +40,7 @@ type Engine struct {
JSONOutput bool
Writer io.Writer
CollectedViolations []Violation
StageFailures []StageFailure
// Off by default: adds one LLM call per reported violation.
SuggestFixes bool
Stages []stage.Stage
Expand All @@ -54,6 +56,13 @@ type Violation struct {
Suggestion string `json:"suggestion,omitempty"`
}

type StageFailure struct {
Stage string `json:"stage"`
File string `json:"file"`
Kind stage.Kind `json:"kind"`
Error string `json:"error"`
}

var ErrDriftDetected = errors.New("architectural drift detected")

type DriftDetectedError struct {
Expand Down Expand Up @@ -119,6 +128,7 @@ func (e *Engine) Run(ctx context.Context) error {
skippedADRChecks int
collectedEntries []baseline.Entry
collectedViolations []Violation
stageFailures []StageFailure
mu sync.Mutex
)

Expand Down Expand Up @@ -198,10 +208,16 @@ func (e *Engine) Run(ctx context.Context) error {
for _, st := range stages {
hits, err = st.Apply(ctx, query, debug, hits)
if err != nil {
sb.WriteString(scoringErrorMessage(file, err))
mu.Lock()
if st.FailOnError {
failure := newStageFailure(st.Name, file, err)
sb.WriteString(failureMessage(failure))
stageFailures = append(stageFailures, failure)
} else {
sb.WriteString(scoringErrorMessage(file, err))
skippedFiles++
}
_, _ = fmt.Fprint(e.writer(), sb.String())
skippedFiles++
mu.Unlock()
return nil
}
Expand Down Expand Up @@ -391,6 +407,13 @@ func (e *Engine) Run(ctx context.Context) error {

e.SkippedFiles = skippedFiles
e.SkippedADRChecks = skippedADRChecks
sort.Slice(stageFailures, func(i, j int) bool {
if stageFailures[i].File != stageFailures[j].File {
return stageFailures[i].File < stageFailures[j].File
}
return stageFailures[i].Stage < stageFailures[j].Stage
})
e.StageFailures = stageFailures
if e.JSONOutput {
if collectedViolations == nil {
collectedViolations = []Violation{}
Expand Down Expand Up @@ -612,6 +635,19 @@ func writeViolationOutput(sb *strings.Builder, v violationOutput, verified bool)
}
}

func newStageFailure(name, file string, err error) StageFailure {
failure := StageFailure{Stage: name, File: file, Error: err.Error()}
var stageErr *stage.Error
if errors.As(err, &stageErr) {
failure.Kind = stageErr.Kind
}
return failure
}

func failureMessage(f StageFailure) string {
return fmt.Sprintf("Error: stage %s failed for %s (%s): %s\n", f.Stage, f.File, f.Kind, f.Error)
}

func scoringErrorMessage(file string, err error) string {
var stageErr *stage.Error
if errors.As(err, &stageErr) {
Expand Down
Loading
Loading