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
- func Admits(mode DeletionMode, status byte) bool
- func AttributeFiles(cites []Citation, files []File)
- func Env(tree, base, head string) []string
- func FilesPart(p Payload) string
- func GuardFingerprint(files, subjectFP string) string
- func Plain(cites []Citation) []transcript.Citation
- func ResolveCitations(commits []Commit, resolve Resolver) ([]Citation, []Unresolved)
- func RuleHash(dir string) (string, error)
- func RuleHashAt(repo, dir string, plugin bool) (string, error)
- func RuleHashesAt(repo string, dirs []string, plugins []bool) ([]string, error)
- func Selects(sel func(Scope) (bool, error), s Scope) (bool, error)
- func TrailerFor(pools []transcript.SourceType) string
- func TrailerScope(commits []Commit) map[string][]string
- type Changeset
- type Citation
- type CiteLine
- type Commit
- type DeletionMode
- type Event
- type File
- type Marker
- type Options
- type Other
- type Payload
- type Resolver
- type Role
- type Scanner
- type Scope
- type SubRange
- type Subject
- type Unresolved
Constants ¶
const ( TrailerCitesUser = "Sloprail-Cites-User" TrailerCitesTool = "Sloprail-Cites-Tool" )
Trailer keys the engine reads.
const DefaultSubjectID = "changeset"
DefaultSubjectID is the id of the one subject a rule without `subjects:` has: the whole changeset.
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 ¶
AttributeFiles fills each citation's Files: the selected files changed by the commits that carry it.
func Env ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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
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
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 ¶
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 ¶
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.
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 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 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.
type Resolver ¶
type Resolver func(transcript.CitationRequest) (transcript.Citation, error)
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 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 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 ¶
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
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