changeset

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 8, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package changeset is what a file-guard judges: the net change between two commits, as one value.

A file-guard used to read the working tree one file at a time, so a half-finished edit could be judged and there was never "a concrete slice of the change to review". It now reads commits. This package turns a range of history into the payload a check receives on stdin and a judge template renders, resolves the grounding written into the range's commit messages, and says what makes two such inputs the same one.

It owns no git and no database: git comes from gitrepo, verdicts live in sessionstate, and quotes resolve through transcript. What is here is the shape and the rules that shape follows.

Index

Constants

View Source
const (
	TrailerCitesUser = "Sloprail-Cites-User"
	TrailerCitesTool = "Sloprail-Cites-Tool"
)

Trailer keys the engine reads.

View Source
const DefaultSubjectID = "changeset"

DefaultSubjectID is the id of the one subject a rule without `subjects:` has: the whole changeset.

View Source
const Kind = "Changeset"

Kind is the event kind a file-guard's check is handed.

Variables

This section is empty.

Functions

func Admits

func Admits(mode DeletionMode, status byte) bool

Admits says whether a status may enter `files` at all under a deletions mode. A rename is not a deletion. sr:invariant fileguard/deletions-filter

func AttributeFiles

func AttributeFiles(cites []Citation, files []File)

AttributeFiles fills each citation's Files: the selected files changed by the commits that carry it.

func Env

func Env(tree, base, head string) []string

Env is what a check's process is told about the commits it judges, beside its stdin: where the read-only snapshot of head is, and the range's two ends.

A check reads SR_TREE, never the working tree, which may hold a half-finished edit. SR_BASE and SR_HEAD are SHAs for a check that wants to ask git something itself (`git -C "$SR_TREE" diff "$SR_BASE" "$SR_HEAD"`). They are not part of the fingerprint, so a check must not let a verdict depend on the SHAs themselves — only on what they select.

func FilesPart

func FilesPart(p Payload) string

FilesPart is the path and content of the subject's matched files, in the subject's order: the bytes the verdict is about, and where they are (the same bytes at a new path have never been judged there: a rule's prompt and its match are about the path too), keyed whether or not the template renders them. A deleted file is marked as such; no SHA, no base.

func GuardFingerprint

func GuardFingerprint(files, subjectFP string) string

GuardFingerprint says what a guard's verdict over one subject is about, as one short string: the cache key's last part (the rest, the rule's name, the guard's fixed step id and the subject id, is the checkcache key's own).

It is the sha256 of two parts, neither of which depends on the session or the history:

  • files: FilesPart, the path and content of the subject's files, ALWAYS, whether or not any check reads them: the verdict is about those bytes.
  • subjectFP: the "fingerprint" the rule's `subjects:` script gave this subject, for whatever the verdict depends on beyond the files (a file a check opens with its own tools). It must be session-independent. Empty without `subjects:`.

Citations are NOT part of it: a stored pass says the content was fine, and a citation is only a gate, which the engine checks afresh on every run (#291, #298).

No commit SHA, run id, timestamp, session id or path of a snapshot is part of it. Parts are length-prefixed, so two parts cannot be re-cut into another pair. sr:invariant cache/verdict-identity

func Plain

func Plain(cites []Citation) []transcript.Citation

Plain is the citations as an event carries them, without where they came from.

func ResolveCitations

func ResolveCitations(commits []Commit, resolve Resolver) ([]Citation, []Unresolved)

ResolveCitations turns the range's `Sloprail-Cites-User:` and `Sloprail-Cites-Tool:` trailers into citations, exactly as `sr-file --cite` would: the quote must match exactly one real entry of its pool, model text is never citable, and a quote that resolves nowhere is not a citation.

The unresolved are returned rather than dropped, because "the commit says it was asked, and the words are not in the record" is what a refusal should be able to say. A resolution that fails for a reason other than the quote not resolving (an unreadable record) is in the unresolved too, and is never promoted to a citation.

Citations accumulate across the range's commits, in commit order, and one quote cited twice is one citation carried by both commits: a rule's base does not move until it passes, so the range grows and its grounding grows with it. Which files a citation grounds is AttributeFiles's to say, once the files are known. sr:invariant citations/pool-is-not-borrowed sr:invariant citations/unresolved-trailers-are-reported-not-dropped

func RuleHash

func RuleHash(dir string) (string, error)

RuleHash hashes every file under a rule's folder dir: the rule's own yaml, scripts and templates. Editing any of them changes the hash, and a verdict keyed on the old one no longer applies. a10n's key left the rubric out, and kept serving passes reached under a rubric that no longer existed.

RuleHash hashes everything on disk under dir (RuleHashAt narrows that to what the engine executes for a guard in a repository). Each file contributes its path, whether it is executable (a script losing its bit changes what runs), and its bytes; a symlink contributes its target rather than what it points at, so a link swung elsewhere is a change even when both ends read the same. Anything that cannot be read is an error: a hash over a folder that was only partly read would be a hash of a different rule.

.DS_Store is the one thing left out — a file the operating system writes into folders a person merely looked at, which would otherwise reset a rule's verdicts for no change of the rule.

func RuleHashAt

func RuleHashAt(repo, dir string, plugin bool) (string, error)

RuleHashAt is the hash of the bytes the engine EXECUTES for the guard whose folder is dir: every file of the guard's `.sloprail` root (its own folder, the other rules, the shared `_lib` scripts it sources, config) as it is on disk, restricted to

  • the files git TRACKS in the repository at repo, so an uncommitted edit of a tracked file changes the hash and its verdict never matches the committed rule's, while an untracked or ignored file a check writes (a ledger, a cache) does not; and
  • for a guard with nothing tracked in its own folder yet (new, uncommitted), also every file of that folder git does not ignore.

A plugin's rule (plugin true) lives outside the repository and has no git: all of the on-disk files of its plugin's `.sloprail` root are hashed (an installed plugin's folder is never written to). `run` and `verify` call this with the same arguments, so one rule has one hash in both.

It fails closed: a git error, or a dir outside the repository for a rule that is not a plugin's, is an error, never a fallback to another hash. Both paths are symlink-resolved before they are compared (macOS /tmp is /private/tmp).

func RuleHashesAt

func RuleHashesAt(repo string, dirs []string, plugins []bool) ([]string, error)

RuleHashesAt is RuleHashAt for several rules of one repository, asking git which files it tracks once per folder instead of once per rule. The hashes come back in the order of dirs.

func Selects

func Selects(sel func(Scope) (bool, error), s Scope) (bool, error)

Selects asks a rule's `match` about one file.

A rename is selected if `match` holds on its new path OR on the path it came from — with the markers it carried there. Otherwise moving a file out of a guarded path (`git mv memories/x.md archive/x.md`) would be the one way to change a guarded file that no rule is asked about. The second question is the old path, the old markers as `markers`, and the same status: the file the rule guards, as it was. sr:invariant fileguard/rename-selected-by-either-path

func TrailerFor

func TrailerFor(pools []transcript.SourceType) string

TrailerFor is the trailer a citation in these pools is written as: the tool trailer when tool output is the only source accepted, the user's otherwise.

func TrailerScope

func TrailerScope(commits []Commit) map[string][]string

TrailerScope is the `trailers` a rule's match sees: each trailer key mapped to its values across every commit of the range, in commit order.

Types

type Changeset

type Changeset struct {
	Base string `json:"base"`
	Head string `json:"head"`
	// Commits are every commit in Base..Head, oldest first.
	Commits []Commit `json:"commits"`
	// Files are what the rule's `match` selected, in full.
	Files []File `json:"files"`
	// Others are the rest of the range as names only, so a judge knows what else
	// moved without paying for it.
	Others []Other `json:"others"`
	// Citations are the quotes the range's commits ground themselves in, resolved
	// against the transcripts, in the shape an event's citations have, each with
	// the commits that carried it and the selected files those commits changed.
	// They accumulate over the whole range, for a judge; `require: citation`
	// reads them per file (see ForFile).
	Citations []Citation `json:"citations"`
}

Changeset is the net change between Base and Head.

func Build

func Build(dir string, r gitrepo.Range, o Options) (Changeset, error)

Build reads the range and returns the changeset a rule is judged on.

Every git failure returns an error and no changeset: a range that could not be read must never resemble one that had nothing selected. The latter is a Changeset with no Files, which the caller passes; the former is an error, which the caller refuses.

Contents and diffs are read from the two commits and never from the working tree, so an uncommitted edit is invisible here, however close to done.

Citations are not part of what Build knows; see ResolveCitations. sr:invariant fileguard/net-diff-of-commits sr:invariant fileguard/unreadable-range-refuses

func (Changeset) Change

func (cs Changeset) Change() string

Change is the combined diff of the changeset's selected files: what a judge's `{{ change }}` renders. Each file's own part of the squashed diff, in order.

func (Changeset) EvidenceForFile added in v0.5.0

func (cs Changeset) EvidenceForFile(f File) []Citation

EvidenceForFile is every citation carried by a commit of the range that changed f, in range order: what was cited along the way to the file as it stands. Unlike ForFile (which commit's citation GROUNDS the file), nothing is dropped for a later uncited change — a reviewer is handed the proof of each step.

func (Changeset) EvidenceForSubject added in v0.5.0

func (cs Changeset) EvidenceForSubject(s Subject) []Citation

EvidenceForSubject is the citations of every commit of the range that changed one of the subject's files (see EvidenceForFile), without repeats, in range order: what a subject's checks are handed. A commit touching none of its files is no input of it (#291).

func (Changeset) ForFile

func (cs Changeset) ForFile(f File) []Citation

ForFile is the citations that ground a file: those quoted by the last commit of the range that changed its content by more than whitespace. A citation grounds the change it rode on, so an uncited real change on top of a cited one leaves the file uncited, while a cited real change on top of an uncited one grounds the file as it now stands. A whitespace-only commit (or an empty trailer-only one) grounds nothing and is skipped: it can neither lend a citation to an earlier uncited change nor take one away. A file whose every commit is whitespace-only is judged by the commit that last changed it. A file no commit is known to have changed has no citations.

Grounding is by content, not position: when the last real change is uncited but left the file byte-identical to a state an earlier commit of the range made, and that commit is cited, the file is grounded by that citation (a revert of an uncited tweak, a restore of cited work). An uncited edit whose content no cited commit made stays uncited. sr:invariant citations/trailer-grounds-only-the-last-changing-commit

func (Changeset) ForSubject

func (cs Changeset) ForSubject(s Subject) []Citation

ForSubject is the citations that ground a subject: those that ground each of its files (see ForFile), without repeats. A subject naming no known file has none.

type Citation

type Citation struct {
	transcript.Citation
	// Commits are the SHAs of the range's commits whose trailers quote it.
	Commits []string `json:"commits"`
	// Files are the selected files those commits changed.
	Files []string `json:"files"`
}

Citation is one resolved quote of the range, and where in the range it came from. The quote's own fields are an event citation's, flattened.

type CiteLine

type CiteLine struct{ Key, Quote string }

CiteLine is one Sloprail-Cites-* line of a message: the canonical trailer key and its quote.

func CiteLines

func CiteLines(msg string) []CiteLine

CiteLines is every citation line of a commit message not yet made, in order, read as addBodyCitations reads a made one.

type Commit

type Commit struct {
	SHA     string `json:"sha"`
	Subject string `json:"subject"`
	Body    string `json:"body"`
	// Trailers maps each trailer key, in canonical case (Sloprail-Cites-User), to
	// its values in order.
	Trailers map[string][]string `json:"trailers"`
}

Commit is one commit of the range.

type DeletionMode

type DeletionMode string

DeletionMode is a file-guard's `deletions:`, read as a filter on a file's STATUS in the range. The zero value is Skip, the default.

const (
	// SkipDeletions leaves deleted files out of `files` and lists them in `others`.
	SkipDeletions DeletionMode = "skip"
	// IncludeDeletions lets deleted files into `files` beside the rest.
	IncludeDeletions DeletionMode = "include"
	// OnlyDeletions keeps only deleted files in `files`.
	OnlyDeletions DeletionMode = "only"
)

type Event

type Event struct {
	Kind string `json:"kind"`
}

Event is the fixed event a changeset evaluation presents.

type File

type File struct {
	Path string `json:"path"`
	// Status is A, M, D or R.
	Status string `json:"status"`
	// OldPath is where a renamed file came from; empty otherwise.
	OldPath string `json:"oldPath"`
	// OldBlob and NewBlob are git's object ids of the two sides: what a verdict key is over.
	OldBlob    string   `json:"oldBlob,omitempty"`
	NewBlob    string   `json:"newBlob,omitempty"`
	OldContent string   `json:"oldContent"`
	NewContent string   `json:"newContent"`
	OldMarkers []Marker `json:"oldMarkers"`
	NewMarkers []Marker `json:"newMarkers"`
	// Diff is this file's part of the squashed diff.
	Diff string `json:"diff"`
	// Commits are the SHAs of the range's commits that changed this file, oldest
	// first, a rename followed back to the name the file had before it.
	Commits []string `json:"commits"`
	// Substantive is the subset of Commits whose change to this file is more than
	// whitespace. Not part of the wire form: it decides which commits must carry a
	// citation (ForFile).
	Substantive []string `json:"-"`
	// SameContent is the subset of Substantive that left the file byte-identical to what it is at
	// head. Not part of the wire form: a later commit that restored such a state is grounded by
	// the citation of the commit that made it (ForFile).
	SameContent []string `json:"-"`
}

File is one selected file of the changeset.

type Marker

type Marker struct {
	Kind string `json:"kind"`
	FQN  string `json:"fqn"`
	Line int    `json:"line"`
}

Marker is one `sr:<kind> <fqn>` annotation, in the wire form an event carries.

type Options

type Options struct {
	Deletions DeletionMode
	// Scan finds markers in a file's text. Required.
	Scan Scanner
	// Select is the rule's `match`. Required. An error is the caller's to fail
	// closed on; nothing here treats it as "not selected".
	Select func(Scope) (bool, error)
	// Lean builds only what a verdict KEY is over, which is the files' git blob ids and paths:
	// no file is read, no patch made, no marker scanned (a `match` that reads markers is not
	// lean). It is what `verify` uses: it executes nothing, so nothing needs the bytes.
	Lean bool
	// NoPatch leaves each file's patch (Diff) out: not part of any key, and a verify never shows it.
	NoPatch bool
	// RawBlobs reads file text by blob id, in one long-lived git process, without attribute
	// filters: for a caller that only scans it (markers), never hands it to a check.
	RawBlobs bool
	// SkipHistory leaves out which commits changed each file; only a citation requirement
	// reads it.
	SkipHistory bool
}

Options say how to build a changeset.

type Other

type Other struct {
	Path   string `json:"path"`
	Status string `json:"status"`
}

Other is a file of the range the rule did not select.

type Payload

type Payload struct {
	Event     Event     `json:"event"`
	Changeset Changeset `json:"changeset"`
	// Subject is what is being judged as one unit. Without `subjects:` it is the
	// whole changeset; with it, one entry the rule's script returned. Carried
	// from the start so `subjects:` adds a producer, not a field.
	Subject        Subject `json:"subject"`
	TranscriptPath string  `json:"transcriptPath"`
}

Payload is what a check receives on stdin and a template renders.

func NewPayload

func NewPayload(cs Changeset, subject Subject, transcriptPath string) Payload

NewPayload assembles what a check receives for one subject of a changeset.

type Resolver

Resolver grounds one quote in the transcripts on disk. Bound to a session and a project by the caller — see transcript.ResolveCitationAcrossSessions.

type Role

type Role int

Role is what a subject is for: a rule's requirements and its checks do not default to the same unit.

const (
	// Requirement: the unit a `require` entry (and its `when`) is evaluated for.
	// Without `subjects:` it is ONE SELECTED FILE, so a requirement applies to the
	// files whose `when` applies and is named for each it fails on.
	Requirement Role = iota
	// Check: the unit a script or judge is handed. Without `subjects:` it is the
	// whole changeset, judged once.
	Check
)

type Scanner

type Scanner func(text string) []Marker

Scanner reads the markers out of a file's text.

type Scope

type Scope struct {
	Path string
	// OldPath is where a renamed file came from; empty otherwise. Not part of
	// what `match` reads: it is what Selects falls back on for a rename.
	OldPath    string
	Status     string
	Markers    []Marker
	OldMarkers []Marker
	Trailers   map[string][]string
}

Scope is what a rule's `match` is asked about one file: its path and status, the markers it carries and carried, and the trailers of the whole range. `context` is the caller's to add; it is not a fact about the range.

type SubRange

type SubRange struct {
	Base string `json:"base"`
	Head string `json:"head"`
}

SubRange is a sub-range of the changeset, as commit names.

type Subject

type Subject struct {
	ID    string   `json:"id"`
	Files []string `json:"files"`
	// Range, when set, narrows the subject to a sub-range of the changeset (a
	// single commit's, for example). Not produced yet.
	Range   *SubRange      `json:"range,omitempty"`
	Context map[string]any `json:"context"`
	// Fingerprint is what the `subjects:` script says the subject's verdict depends on
	// besides its files' content (a file the checks open with their own tools): added to the
	// verdict's cache key. Session-independent; empty for the default subject.
	Fingerprint string `json:"fingerprint,omitempty"`
}

Subject is one unit of evaluation and caching.

Subjects (by Role) produces them: one file per requirement, the whole changeset per check. A rule's `subjects:` script (ParseSubjects) returns several instead, each fingerprinted and judged on its own; the verdict key already carries a subject id.

func ParseSubjects

func ParseSubjects(stdout []byte, cs Changeset) ([]Subject, error)

ParseSubjects reads what a rule's `subjects:` script printed: a JSON array of {"id": "...", "files": ["..."], "fingerprint": "..."} (fingerprint optional). Each id must be unique and non-empty. A subject is a FILE subject when it names files (every one of them a file the changeset selected: the verdict is keyed by their content, plus the fingerprint when there is one) or any other subject (an FQN, say), which names no file and must then carry a fingerprint: it is the whole of what its verdict is about, and a subject with neither would be keyed by nothing, so it is an error (fail closed), never an empty key. An empty list is an error: a rule that selected files has something to judge, and a script that names nothing must not read as a pass. sr:invariant fileguard/subject-contract

func Subjects

func Subjects(cs Changeset, role Role) []Subject

Subjects is the one place a rule's subjects come from. Today it is the default for the role; `subjects:` will replace the body (a rule's script returning the list) and nothing that calls it changes: every caller already takes a slice of Subject and keys its results by Subject.ID.

func Whole

func Whole(cs Changeset) Subject

Whole is the one subject a rule without `subjects:` has: every selected file.

type Unresolved

type Unresolved struct {
	// Commit is the SHA of the commit that carried it.
	Commit  string
	Trailer string
	Quote   string
	Err     error
}

Unresolved is a citation trailer that did not become a citation.

func (Unresolved) String

func (u Unresolved) String() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL