checksums

package
v0.0.3 Latest Latest
Warning

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

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package checksums owns forge's file-ownership machinery: which bytes does forge certify as its own render, which files has the user taken over, and what may a generate run overwrite.

── The mechanism (2026-06 redesign) ──────────────────────────────────

Tier-1 (regenerated-every-run) files are SELF-CERTIFYING: each one embeds its own content hash in the "Code generated by forge. DO NOT EDIT." header (`forge:hash=<sha256>` — see stamp.go for the hash spec and comment-syntax matrix). The pristine check is purely local: recompute vs embedded. There is no global manifest for Tier-1 — the old .forge/checksums.json was shared mutable state OUTSIDE the files it described, and it produced the fr-9a54388f0b failure class: a manifest committed from a WIP lane made a clean clone of green HEAD unable to regenerate itself, and every regen rewrote the manifest (pure-bookkeeping commits, constant conflicts between parallel agents).

Verification outcomes at a Tier-1 write site:

  • no file on disk → new file: write it.
  • marker matches body → pristine render of SOME vintage. If it differs from the current render, it is byte-indistinguishable from a deliberate edit (FRICTION fr-2c1c2328c7), so by DEFAULT the write SKIPS it loudly (once-per-file notice). Overwriting requires explicit opt-in: `--heal` (whole tree) or `--force` (this file).
  • marker mismatches body → hand-edited: the stomp guard refuses, naming the file (per-run-scoped `--force` overwrites, `forge disown` transfers ownership).
  • no marker, file exists → forge never certified these bytes at a path it owns: write (matches the legacy untracked-path behavior).

── What little persistent state remains under .forge/ ───────────────

  • .forge/disowned.json — the one-way ownership door. Path + reason
  • timestamp per `forge disown`. Generate skips these paths while the file exists; deleting the file re-adopts it. Committed.
  • .forge/hashes.json — the scoped fallback for the few Tier-1 output formats that cannot carry comments (JSON). path → body hash of the last render. Deliberately minimal: this exception must not regrow the global manifest. Committed.

Scaffold ("yours") files carry NO marker and are user-owned from birth: forge writes each one exactly once, keyed on the file's own absence (WriteScaffoldIfMissing). Once the file exists forge NEVER overwrites it — no flag, no exception. To refresh one, delete it and regenerate. This is the single non-Tier-1 tier: there is no separate "scaffold reset" machinery (deleting the file IS the reset).

Public-identifier extraction for generated Go files.

The rename-detection pass (see RenameWarnings in this package) needs to know which exported names a Tier-1 Go file declared in its most recent render, so it can diff against the current render and flag dropped names whose callers may not have been updated. This file owns the AST parse that produces that list.

Non-Go files (.tsx, .yaml, .k, …) return an empty exports list — rename detection is currently scoped to Go callers because Go is the language where forge's codegen emits public package-level symbols that other (hand-written) packages depend on by name. Extending to TypeScript or KCL is straightforward but not needed by the current FRICTION class.

Canonical Go formatting — the formatter half of the self-certification contract for .go output.

── Why the writer formats before it stamps ───────────────────────────

The embedded forge:hash marker certifies exact bytes (modulo the line-ending/trailing-newline normalization documented in stamp.go). Go files, however, live under tooling that routinely rewrites bytes without changing meaning: gofmt realigns whitespace, goimports regroups import blocks (stdlib / third-party / local). If forge stamps a render that is NOT already in that canonical form, the first formatter pass over the tree turns every pristine render into byte drift, and the Tier-1 stomp guard reads it as a hand-edit (fr: control-plane 2026-07-08 — four internal/*/mock_gen.go files hard-blocked `forge generate` solely because a goimports pass had regrouped and aliased their imports).

The fix has two halves:

  1. Format-before-stamp: WriteGeneratedFile passes every .go render through CanonicalGoSource BEFORE computing the stamp, so the certified bytes are a fixed point of the canonical formatter and a user's formatter pass is a no-op.
  2. Normalize-on-compare: where a marker mismatches (the guard, the writer's Modified branch), the on-disk bytes are re-hashed AFTER the same canonical formatting; a match means formatter-only drift — still forge's render, not a hand-edit. Non-Go formats keep exact-byte semantics.

── The canonical formatter ───────────────────────────────────────────

CanonicalGoSource is goimports' formatting engine (golang.org/x/tools/imports.Process) in FormatOnly mode: gofmt plus import-block merging, sorting, and group splitting — but never the filesystem-scanning insertion/removal of imports, which would make output environment-dependent. The local-import prefix is the project's MODULE PATH, matching the two places forge already commits to that convention for user projects:

  • the generate pipeline's post-write pass: `goimports -local <module> -w …` (runGoimportsOnGenerated), and
  • the scaffolded .golangci.yml: `formatters.settings.goimports.local-prefixes: [<module>]`.

A pass of `goimports -local <module>` (or a golangci-lint fmt run with the scaffolded config) over canonical output is therefore a byte-level no-op. The one remaining semantic pass — the pipeline's real goimports fixing an import list — is already covered by RestampWritten.

Ownership inspector — single source of truth for "what does forge own at relPath, and how should we treat it?".

Background (2026-06-04 collapse pass): four parallel call sites in the generate pipeline each re-derived their own view of disowned / Tier-1 / Tier-2 ownership. The Inspector collapses those walks behind a small interface. In the self-certifying era ownership is read from the files themselves (the forge:hash marker scan) plus the two small state files (.forge/disowned.json, .forge/hashes.json) — there is no manifest to walk.

Scope: the Inspector answers questions about a *single project's* on-disk state. Call NewInspector once per pipeline run with the loaded *FileChecksums and project root; per-directory and per-file results are memoized so repeated queries inside one run don't re-read the disk.

One-time migration off the legacy global manifest (.forge/checksums.json) onto self-certifying files.

Runs automatically (and loudly) on the first `forge generate` / `forge upgrade` in a project that still carries the legacy manifest. Per legacy Tier-1 entry, in order:

  • on-disk bytes match the entry's recorded hash OR any hash in its render history → the file is a pristine forge render of some vintage: stamp the forge:hash marker into it (comment-incapable formats get a scoped .forge/hashes.json entry instead);
  • bytes match NOTHING the manifest recorded → provenance unknown. This is kalshi's exact mess (fr-9a54388f0b): a manifest committed from a different work lane than the committed bytes. The entry is returned as Unverified; the generate pipeline gives each such path one rescue attempt (side-render the fresh template output and compare bodies — a match proves pristineness and stamps the file), and everything unrescued is stamped with the UnverifiedMarkerValue sentinel so the stomp guard names it on every run until the user resolves it (--force to regenerate, `forge disown` to keep).

Disowned (and legacy forked) entries convert to .forge/disowned.json. Plain Tier-2 entries are dropped — scaffold-once files are user-owned from birth and need no record. Legacy Tier-1 entries whose path the CURRENT forge no longer emits are dropped too (the file stays, now plainly user-owned — stamping it would feed the stale-artifact sweep a file no emitter will ever refresh).

The legacy manifest is DELETED at the end. The migration is metadata-only and durable: it lands once, even if the same run later aborts at the stomp guard.

Side-render bookkeeping.

Two consumers remain now that the fork lifecycle is gone:

  • `forge generate --explain-drift` parks transient fresh renders under `.forge/render/<relpath>` so the post-pipeline diff can show "on-disk vs what regeneration would produce" without touching the user's drifted file (WriteSideRenderNoBase).
  • `forge unfork --merge` — the one-release migration aid for LEGACY forks — three-way-merges ours/base/theirs from `.forge/render/<relpath>` and `.forge/render-base/<relpath>`, renders parked by pre-disown forge versions. Current forge never writes render-base; the constants survive only so the migration tool can find what older versions left behind.

Both directories sit under `.forge/`, which the scaffolded project .gitignore already excludes (only disowned.json / hashes.json / friction.jsonl are negated back in) — side renders are per-developer state, never committed.

Stage-then-validate rollback journal for `forge generate`.

FRICTION cp-forge fr-40f7ec9bd9: `forge generate --force` on a clean clone rewrote Tier-1 files across the whole tree (CI workflows, skills, mocks, ORM, KCL), ran `go mod tidy`, and only THEN failed the final "go build (validate generated code)" step — exiting non-zero with the tree left mid-regen and recovery left to the user's `git checkout`. A generate run that fails its own validation must not leave the tree in a state the user has to hand-repair.

The fix is a write journal recorded at the SINGLE chokepoint every forge write flows through (WriteGeneratedFile / WriteScaffoldIfMissing / writeUnstampable, plus the in-place restamp and disown marker-strip). Before any of those mutate a path on disk, the journal captures the path's EXACT pre-run bytes (or records that it did not exist). On a post-write failure — most importantly the final `go build` validate — the pipeline calls RestoreRollback, which rewrites every journaled path back to its captured pre-run state (re-creating, overwriting, or deleting as needed). On success the pipeline calls CommitRollback, which simply drops the journal.

Scope — deliberately bounded to forge-WRITTEN files:

  • The journal restores exactly the files forge's writers touched this run (Tier-1 codegen, scaffold-once "yours" files, comment-incapable outputs, restamps, disown marker strips). That is the "mid-regen broken tree" the friction names.
  • It does NOT snapshot the whole working tree. External-tool churn (`buf generate` into gen/, `go mod tidy` rewriting go.mod/go.sum, `sqlc`, KCL render) is deterministic from the proto/config inputs and is NOT what leaves a half-regenerated Tier-1 tree; snapshotting it would manufacture spurious rollback diffs (e.g. a legitimately re-tidied go.sum) on every failed run.
  • goimports/restamp rewrite files that are THEMSELVES already in the journal (forge wrote them earlier this run), so restoring the journal also undoes those in-place rewrites.

The journal is process-global (like the rest of this package's per-run state) and reset by BeginRollbackJournal at the head of each pipeline run. Non-pipeline callers (forge upgrade, project creation) never call Begin, so journaling stays OFF and their writes are recorded nowhere — they have their own recovery stories.

Self-certifying generated files: the embedded forge:hash marker.

Every Tier-1 (regenerated-every-run) file forge emits carries its own content hash inside the existing "Code generated by forge. DO NOT EDIT." header block:

// forge:hash=<sha256-hex>

The pristine check is purely local — recompute the hash over the file's bytes and compare with the embedded value. No global manifest is consulted, so the file's provenance travels WITH the file through git clones, parallel work lanes, cherry-picks, and partial commits (the failure class that killed .forge/checksums.json for Tier-1: kalshi fr-9a54388f0b — a committed manifest recorded from a WIP lane made a green tree unable to reproduce itself).

── Hash spec (deliberate, documented) ────────────────────────────────

BodyHash(content) = sha256 over content transformed as follows:

  1. CRLF → LF. A git autocrlf checkout (or an editor that re-encodes line endings) must not turn a pristine render into "hand-edited". Forge itself always emits LF.
  2. Every line containing "forge:hash=" is removed in its entirety, including its line terminator (the hash cannot cover itself). Forge emits exactly one marker line; removal is defensive against duplicates.
  3. Trailing newlines are normalized to exactly one (a non-empty body always ends "\n"; an empty body stays empty). Editors and formatters disagree about final newlines; the count carries no content.

Anything else — including trailing whitespace inside lines — is hashed verbatim: it IS content.

Go files get one additional accommodation, OUTSIDE the hash spec: the Tier-1 writer canonical-formats .go renders (gofmt + goimports import grouping, module path as the local prefix) BEFORE stamping, and the verification sites re-hash mismatching .go bytes after the same canonical formatting before concluding "hand-edited" — formatter-only byte drift is not semantic drift. See goformat.go. Verify itself stays exact-byte; the tolerance lives at the path-aware call sites (ScanMarkers, WriteGeneratedFile).

── Marker line syntax per format ─────────────────────────────────────

//  forge:hash=…   .go .ts .tsx .js .jsx .mjs .cjs .proto .alloy .kt .rs
#   forge:hash=…   .yaml .yml .k .kcl .sh .bash .toml .tf .py .env
                   .example .gitignore .dockerignore Dockerfile
                   Makefile CODEOWNERS
--  forge:hash=…   .sql
<!-- forge:hash=… -->   .md .markdown .html .htm
/*  forge:hash=… */     .css

Comment-incapable formats (JSON et al.) are NOT stampable: callers route those few files through the scoped .forge/hashes.json fallback (see FileChecksums.Unstampable) — deliberately minimal so the exception cannot resurrect the global manifest.

── Marker placement ──────────────────────────────────────────────────

Stamp inserts the marker line, in order of preference:

  1. immediately after the first line containing "Code generated by forge" within the first stampBannerScanLines lines (the standard banner — keeps prologue-sensitive files like "use client" TSX and frontmatter-led SKILL.md correct, since the banner already sits after those prologues);
  2. for .md: after the closing --- of a leading YAML frontmatter block (Claude Code requires frontmatter at byte 0);
  3. after a #! shebang line;
  4. at the very top.

Index

Constants

View Source
const (
	// DisownedFile records one-way ownership transfers (`forge disown`).
	DisownedFile = ".forge/disowned.json"
	// HashesFile is the scoped fallback manifest for comment-incapable
	// Tier-1 outputs only (see package doc).
	HashesFile = ".forge/hashes.json"
	// LegacyChecksumFile is the dead global manifest. Only the one-time
	// migration (migrate.go) reads it; nothing writes it.
	LegacyChecksumFile = ".forge/checksums.json"
)

On-disk state files, project-relative.

View Source
const (
	// RenderDir holds transient fresh renders: written by
	// `--explain-drift` for diffing, and (legacy) the per-run "theirs"
	// renders older forge versions parked for forked paths.
	RenderDir = ".forge/render"
	// RenderBaseDir holds the merge base older forge versions captured
	// when a path was forked. Read-only in current forge — consumed by
	// `forge unfork --merge` (legacy-fork migration aid) only.
	RenderBaseDir = ".forge/render-base"
)

Side-render directory roots, project-relative. Exposed as constants so the cli layer can print them in messages without re-deriving.

View Source
const UnverifiedMarkerValue = "unverified-legacy"

UnverifiedMarkerValue is the sentinel hash value the legacy-manifest migration stamps into files whose provenance could not be established (the on-disk bytes matched neither the fresh render nor any hash the legacy manifest recorded). It never equals a real sha256, so Verify always answers Modified — the stomp guard keeps naming the file until the user resolves it with --force (regenerate) or `forge disown` (keep their bytes).

Variables

View Source
var AutoHeal bool

AutoHeal opts IN to overwriting on-disk content whose marker VERIFIES but whose body differs from the current render (a pristine render of an older vintage). It is OFF by default, and that default is the correctness fix for FRICTION cp-forge fr-2c1c2328c7.

The hazard: a deliberate user revert (or hand-edit) to content forge once rendered is BYTE-INDISTINGUISHABLE from stale codegen — both produce a file whose embedded marker self-verifies (Pristine) but whose body is an older vintage. The old default healed (overwrote) these silently-then-loudly, which SILENTLY DESTROYED a real hand-edit in pkg/app/bootstrap.go: the edit happened to hash-equal a prior render, so generate treated the user's drift as stale codegen and reverted it. The notice fired AFTER the bytes were already gone.

forge generate must never be silently destructive, so the default is now the NON-DESTRUCTIVE outcome: a pristine-but-stale file is treated as a possible hand-edit — the write SKIPS it and NoHealSkipFn fires once per file per run, naming the file and the remedies. Overwriting requires explicit intent: `forge generate --heal` (sets AutoHeal) to advance the whole tree to the current templates, or `forge generate --force` to discard a specific drifted file. Either way the user, not forge, decides to throw the bytes away.

`forge upgrade` does not consult this var — it has its own diff-driven writer with its own --force.

View Source
var HealNoticeFn = func(relPath string) {
	fmt.Fprintf(os.Stderr,
		"♻️  healed stale codegen: %s — on-disk content was a pristine prior forge render (not the latest); overwrote it with the current template (you opted in via --heal/--force). If that content was a deliberate edit, restore it, then move the edit to an extension point or `forge disown` the file.\n",
		relPath)
}

HealNoticeFn is invoked once per file per run when an EXPLICITLY requested heal (--heal / AutoHeal, or a scoped --force) has replaced on-disk content that was a PRISTINE OLDER forge render with the current template's output. Healing must never be silent: even when the user opted in, if the old vintage was actually a deliberate edit, this notice is the only trace that forge threw it away.

Package var so the CLI can redirect the report; the default prints to stderr. Never nil it out — assign a no-op func in tests instead.

View Source
var NoHealSkipFn = func(relPath string) {
	fmt.Fprintf(os.Stderr,
		"⏭️  %s matches a PRIOR forge render but not the current template — left untouched, because that is byte-indistinguishable from a deliberate edit and forge will not silently revert your work. To regenerate it from the current templates: `forge generate --heal` (advances every such file) or `forge generate --force` (this file only). To keep your version permanently: `forge disown %s`.\n",
		relPath, relPath)
}

NoHealSkipFn is invoked once per file per run when the default non-destructive behavior caused a write to SKIP a pristine-but-stale file. This is the DEFAULT outcome (AutoHeal off): the file's body is an older forge render than the current template, which is byte-indistinguishable from a deliberate user revert/edit, so forge refuses to silently overwrite it. Default prints to stderr.

View Source
var RetireNoticeFn = func(relPath string) {
	fmt.Fprintf(os.Stderr,
		"♻️  retired obsolete disown: %s — forge no longer owns this as generated code (it's now scaffold-once/user-owned); the file stays yours, the disown was dropped.\n",
		relPath)
}

RetireNoticeFn is invoked once per auto-retired obsolete disown. A disown is OBSOLETE when forge no longer Tier-1-owns its path: the path became a scaffold-once "yours" file (write-if-absent, never overwritten), or forge stopped emitting it entirely. Such a disown is dead weight — it protects against an overwrite that can no longer happen — and misleads users into thinking `forge disown` is routine.

Package var so the CLI can redirect/capture the report; the default prints to stderr. Never nil it out — assign a no-op in tests instead.

View Source
var Tier1TargetSet = map[string]bool{}

Tier1TargetSet is the per-pipeline-run set of relative paths that the current run TARGETED as Tier-1 (regenerated-every-run) output — every path that flowed through a Tier-1 writer (WriteGeneratedFile / WriteGeneratedFileTier1 / writeUnstampable), recorded BEFORE the disown-skip early-return.

Deliberately distinct from WrittenThisRun: a DISOWNED Tier-1 path is skipped (never written), so it is absent from WrittenThisRun — yet forge WOULD have regenerated it but for the disown, so it IS in Tier1TargetSet. That distinction is exactly what obsolete-disown retirement needs: a disown is still VALID iff its path is a current Tier-1 target (in this set); it is OBSOLETE (the path became a scaffold-once "yours" file, or forge no longer emits it at all) iff it is NOT in this set.

Scaffold-once ("yours") writes do NOT record here — those paths are user-owned write-if-absent, never Tier-1 targets.

View Source
var WrittenThisRun = map[string]bool{}

WrittenThisRun is a per-pipeline-run set of relative paths that the current `forge generate` invocation has successfully written via the `WriteGeneratedFile*` family. The marker-driven stale-artifact sweep consults this set: a marker-bearing file NOT in it is a candidate for removal (forge certified the path but didn't re-emit it this run, e.g. because the service was renamed or removed).

Functions

func AddSideRenderOnly

func AddSideRenderOnly(relPath string)

AddSideRenderOnly marks relPath as side-render-only for the current run. Idempotent.

func BeginRollbackJournal

func BeginRollbackJournal()

BeginRollbackJournal turns journaling ON and clears any prior capture. Called once at the head of a `forge generate` run, before any writer fires. After this, every forge write captures its target's pre-run state (once per path) so RestoreRollback can undo the whole run.

func BodyHash

func BodyHash(content []byte) string

BodyHash computes the self-certification digest of content per the hash spec at the top of this file: LF-normalized, marker lines excluded, trailing newlines normalized to exactly one.

func CanonicalGoSource

func CanonicalGoSource(localPrefix, filename string, src []byte) ([]byte, error)

CanonicalGoSource formats Go source exactly the way the pipeline's `goimports -local <localPrefix>` pass formats it, minus the import insertion/removal (FormatOnly): gofmt plus import-block merge/sort/group-split with localPrefix sorted into its own trailing group. Deterministic and hermetic — no filesystem or module-cache scanning. filename is advisory (error messages); src is never read from disk.

The result is a fixed point: CanonicalGoSource(CanonicalGoSource(x)) == CanonicalGoSource(x).

func CleanSideRenders

func CleanSideRenders(root, relPath string) error

CleanSideRenders removes both side-render files for relPath. Called when a path is disowned, re-adopted, or migrated off the legacy fork state — parked renders are stale once ownership is settled. Missing files are fine; any other removal error is returned.

func CommitRollback

func CommitRollback()

CommitRollback drops the journal without restoring anything — the run's writes stand. Called on the success path; also turns journaling back OFF so a subsequent non-pipeline write in the same process isn't silently recorded.

func ExtractGoExports

func ExtractGoExports(content []byte) (exports []string, pkgName string)

ExtractGoExports returns the sorted list of public top-level identifier names declared in the Go source `content`. Public is determined by the Go convention (first rune is uppercase). Functions, types, vars, and consts are all included; receiver methods are NOT (a method rename rarely orphans an external caller because it goes through an interface or value receiver).

Returns (nil, "") for non-Go content (parse error) — callers should treat that as "no exports recorded" rather than as an error.

The returned package name is the `package <name>` clause from the file, used by RenameWarnings to construct `pkg.Name` search patterns when grepping for stale callers.

func ExtractMarker

func ExtractMarker(content []byte) (string, bool)

ExtractMarker returns the embedded hash value from the first marker line in content, and whether a marker line was found. The value is the token following "forge:hash=" up to whitespace or a comment closer.

func FlushHealNotices

func FlushHealNotices(root string)

FlushHealNotices emits the deferred heal notices for every pending path whose final on-disk body hash actually changed relative to the pristine content the run replaced. Clears the pending set.

func GoImportsLocalPrefix

func GoImportsLocalPrefix(root string) string

GoImportsLocalPrefix returns the canonical goimports local-import prefix for the project at root: the module path declared in root/go.mod. Empty when go.mod is absent or carries no module directive — canonical formatting then simply has no local group, matching what a bare `goimports` pass would do.

func Hash

func Hash(content []byte) string

Hash returns the sha256 hex digest of content.

func IsGoPath

func IsGoPath(relPath string) bool

IsGoPath reports whether relPath is a Go source file that the exports extractor should attempt to parse. Used so the rename-detection wiring can short-circuit on non-Go Tier-1 files.

func MarkWrittenThisRun

func MarkWrittenThisRun(relPath string)

MarkWrittenThisRun records that relPath was written during the current run. Exposed publicly so tests that bypass the WriteGeneratedFile* chokepoint can still simulate the post-emit set.

func RecordPreWrite

func RecordPreWrite(root, relPath string)

RecordPreWrite is the exported shim for pipeline steps that mutate a forge-owned path DIRECTLY (a raw os.Remove / os.WriteFile) instead of through the WriteGeneratedFile* chokepoint. Call it immediately before the mutation so the rollback journal can restore the path on a failed run. No-op when journaling is OFF or the path was already captured.

func RescueUnverified

func RescueUnverified(root, relPath string) bool

RescueUnverified is the migration's provenance rescue: if a fresh side render was parked for relPath this run and its BODY matches the on-disk bytes, the file is provably a pristine render of the current templates — stamp it for real. Returns true when rescued. The parked render is cleaned up either way once consulted.

func ResetPerRunState

func ResetPerRunState()

ResetPerRunState clears the per-pipeline-run tracking sets (the side-render redirects, the heal-notice machinery, the --heal opt-in (AutoHeal), and the per-run --force scope). Called at the start of each pipeline run so a long-lived process doesn't leak state across invocations.

func ResetSkipWrite

func ResetSkipWrite()

ResetSkipWrite clears the written-this-run set. Called at the start of each pipeline run to avoid leaking state across forge invocations in tests or long-lived processes.

func RestampWritten

func RestampWritten(root string, cs *FileChecksums)

RestampWritten re-certifies every file written this run: post-write formatters (goimports) may have rewritten the bytes after the writer stamped them, which would otherwise leave the embedded hash stale and flag every formatted file as "hand-edited" on the next run. Also refreshes scoped-fallback entries for unstampable paths.

func RestoreRollback

func RestoreRollback(root string) []string

RestoreRollback rewinds every journaled path to its captured pre-run state and returns the sorted list of paths it restored. A path that existed before the run is rewritten with its original bytes + mode; a path that did NOT exist is removed (deleting forge's freshly-written output and pruning any now-empty parent directories forge created). Best-effort per path: an individual restore error does not abort the rest (a partially-restored tree still beats a fully mid-regen one), but the path is omitted from the returned list so the caller can report exactly what was recovered. Clears the journal and turns journaling OFF — a restored run is over.

func RetireObsoleteDisowns

func RetireObsoleteDisowns(cs *FileChecksums, targetable func(relPath string) bool) []string

RetireObsoleteDisowns drops disowns whose path is NO LONGER a current Tier-1 emit target, firing RetireNoticeFn for each. It must run AFTER every Tier-1 emitter (so Tier1TargetSet is fully populated) and BEFORE the state is saved.

Conservatism — the retirement is one-directional and only ever drops a disown when forge is CERTAIN it no longer Tier-1-owns the path:

  • a path IN Tier1TargetSet is one forge WOULD regenerate but for the disown → the disown is doing its job → KEPT.
  • a path NOT in Tier1TargetSet became scaffold-once ("yours") or is no longer emitted → the disown can never prevent an overwrite → RETIRED.

The guard against false retirement: callers must only invoke this when the Tier-1 target set is TRUSTWORTHY for the disowned path's owning emitter — i.e. that emitter's pipeline step actually ran this run. A disowned path whose emitter was gated OFF this run (e.g. a frontend-only file under features.frontend=false) would be absent from the target set for a reason unrelated to tiering, and must NOT be retired. RetireObsoleteDisowns takes a `targetable` predicate: it only considers a disown for retirement when targetable(path) is true, meaning "an emitter that COULD own this path as Tier-1 ran this run". A nil predicate means "trust the target set for every path" (used by unit tests that drive the set directly).

Returns the sorted list of retired paths.

func RollbackEnabled

func RollbackEnabled() bool

RollbackEnabled reports whether journaling is currently ON. Exposed so tests can assert the pipeline armed/disarmed it correctly.

func Save

func Save(root string, cs *FileChecksums) error

Save persists the ownership state. Empty maps DELETE their state file — the steady state for most projects is no .forge state files at all, so regeneration produces zero bookkeeping diff (the manifest-era "22/69 commits were pure bookkeeping" failure mode).

func ScanMarkers

func ScanMarkers(root string) map[string]MarkerInfo

ScanMarkers walks root and returns every file carrying a forge:hash marker, keyed by slash-separated project-relative path. Only stampable formats are probed; probe reads are head-bounded so large generated trees (gen/, proto stubs) cost almost nothing.

func ScanProjectGoExports

func ScanProjectGoExports(projectRoot string) map[string][]SymbolLocation

ScanProjectGoExports walks projectRoot and returns a map of public symbol name → every location declaring it. The same symbol declared in multiple packages produces a slice with multiple entries (the caller surfaces this as a collision warning).

Skipped directories mirror the rename-detection scanner's skip list — generated code, vendored modules, etc., have their own stale-ref handling and shouldn't influence the "which packages declare this symbol now" answer.

Returns an empty (non-nil) map on filepath.Walk error so callers can always range over the result without nil-checks.

func SetForceScope

func SetForceScope(relPaths []string)

SetForceScope installs the per-run --force scope: only the given relative paths may be force-overwritten. Passing an empty (or nil) slice installs an EMPTY scope — force becomes inert — which is distinct from never calling SetForceScope (unscoped legacy force). Cleared by ResetPerRunState.

func SideRenderRelPath

func SideRenderRelPath(relPath string) string

SideRenderRelPath returns the project-relative location of the latest side render for relPath (`.forge/render/<relpath>`).

func Stamp

func Stamp(relPath string, content []byte) ([]byte, bool)

Stamp embeds the self-certification marker into content: existing marker lines are removed, the body hash is computed, and the marker line is inserted per the placement rules. Stamping is idempotent — Stamp(Stamp(x)) == Stamp(x) — because the hash excludes the marker line. Returns ok=false for unstampable formats.

func StampUnverified

func StampUnverified(root, relPath string) bool

StampUnverified embeds the UnverifiedMarkerValue sentinel into relPath so the stomp guard keeps naming the file until its provenance is resolved. No-op (false) for unstampable formats and unreadable files.

func StampWithValue

func StampWithValue(relPath string, content []byte, value string) ([]byte, bool)

StampWithValue inserts a marker line carrying an explicit value (used by the migration's UnverifiedMarkerValue sentinel). Any existing marker lines are removed first. Returns ok=false for unstampable formats.

func Stampable

func Stampable(relPath string) bool

Stampable reports whether relPath's format can carry an embedded forge:hash marker. Unstampable Tier-1 outputs go through the scoped .forge/hashes.json fallback instead.

func StripMarker

func StripMarker(content []byte) []byte

StripMarker removes every marker line from content (preserving the original line endings everywhere else). Used by `forge disown` (a user-owned file must not advertise forge certification) and by the Tier-2 reclassification path.

func WriteGeneratedFile

func WriteGeneratedFile(root, relPath string, content []byte, cs *FileChecksums, force bool) (bool, error)

WriteGeneratedFile writes a Tier-1 (forge-owned, regenerated every run) file through the certification chokepoint:

  • .go renders are CANONICAL-FORMATTED first (goformat.go: gofmt + goimports grouping, module path as local prefix) so the stamped bytes are a fixed point of the formatter a user's tooling runs;
  • the rendered content is STAMPED with its embedded forge:hash marker (comment-incapable formats fall back to the scoped .forge/hashes.json entry);
  • existing on-disk content is classified by Verify: pristine content regenerates (healing older vintages loudly), hand-edited content is skipped unless force applies, unmarked content is overwritten (forge owns the path; it never certified those bytes);
  • disowned paths are never touched while the file exists.

Returns true if the file was written. A nil cs is tolerated — the file is still written and stamped, but disowned/unstampable state can't be consulted or recorded.

Historical note: this used to be the tier-agnostic legacy writer with WriteGeneratedFileTier1 layering a manifest tag on top. Every remaining caller emits regenerated-every-run output, so both names now share Tier-1 semantics.

func WriteGeneratedFileTier1

func WriteGeneratedFileTier1(root, relPath string, content []byte, cs *FileChecksums, force bool) (bool, error)

WriteGeneratedFileTier1 writes a Tier-1 (regenerated-every-run) file. Alias of WriteGeneratedFile — kept so call sites stay explicit about the tier they're emitting.

func WriteScaffoldIfMissing

func WriteScaffoldIfMissing(root, relPath string, content []byte) (bool, error)

WriteScaffoldIfMissing writes a scaffold ("yours") file only when the destination does not already exist. Scaffold content carries NO marker and is user-owned from birth: forge writes it once, then NEVER touches it again — no flag, no exception. To refresh, delete the file and regenerate (the write-if-absent gate re-emits the pristine scaffold).

Returns true when the file was written (it was absent). A file already on disk — whether pristine, hand-edited, or fully rewritten — is left exactly as-is and returns (false, nil). Parent directories are created as needed.

func WriteSideRenderNoBase

func WriteSideRenderNoBase(root, relPath string, content []byte) error

WriteSideRenderNoBase writes `.forge/render/<relpath>` — the transient render parked by the `--explain-drift` redirect so the post-pipeline diff has a "fresh render" side without touching the user's drifted file. No merge-base is ever seeded (that was fork-era machinery).

Types

type DisownedEntry

type DisownedEntry struct {
	Reason     string `json:"reason,omitempty"`
	DisownedAt string `json:"disowned_at,omitempty"`
}

DisownedEntry is the per-path record in .forge/disowned.json: the WHY of the one-way ownership transfer plus when it happened. The reason is design feedback — it's also recorded in .forge/friction.jsonl by the CLI layer.

type FileChecksums

type FileChecksums struct {
	// ForgeVersion is the version of the binary performing the current
	// run. Carried for save-time stamping of the state files.
	ForgeVersion string `json:"forge_version,omitempty"`

	// Disowned maps project-relative paths to their one-way ownership
	// transfer records. Loaded from / saved to .forge/disowned.json.
	Disowned map[string]DisownedEntry `json:"-"`

	// Unstampable maps project-relative paths of comment-incapable
	// Tier-1 outputs (JSON, …) to the BodyHash of forge's last render.
	// Loaded from / saved to .forge/hashes.json. The ONLY paths allowed
	// here are ones Stampable() rejects — the writer enforces it.
	Unstampable map[string]string `json:"-"`
}

FileChecksums is the project's persistent ownership state. The name survives from the manifest era (every emitter signature threads a *FileChecksums); the global per-file hash map does NOT — Tier-1 pristineness lives inside the files themselves (stamp.go).

func Load

func Load(root string) (*FileChecksums, error)

Load reads the project ownership state (.forge/disowned.json + .forge/hashes.json). Missing files yield empty maps — a project with no disowns and no comment-incapable outputs has NO forge state files at all, by design.

func (*FileChecksums) DisownPaths

func (cs *FileChecksums) DisownPaths(root string, relPaths []string, reason string) error

DisownPaths performs the one-way ownership transfer for each path: the embedded forge:hash marker is STRIPPED from the file (a user-owned file must not advertise forge certification), any scoped fallback entry is dropped, and the path is recorded in .forge/disowned.json with the user's reason. After this, no `WriteGeneratedFile*` call ever touches the path again (while the file exists). Re-adoption is by deletion: remove the file and run `forge generate`.

func (*FileChecksums) IsDisowned

func (cs *FileChecksums) IsDisowned(relPath string) bool

IsDisowned reports whether relPath has been `forge disown`-ed.

type Inspector

type Inspector struct {
	// contains filtered or unexported fields
}

Inspector is the read-only ownership query API.

func NewInspector

func NewInspector(root string, cs *FileChecksums) *Inspector

NewInspector constructs an Inspector. A nil cs is tolerated (every query returns a safe zero value).

func (*Inspector) DeclaredTypesIn

func (i *Inspector) DeclaredTypesIn(relPath string) map[string]bool

DeclaredTypesIn returns the set of top-level type identifiers declared in projectRoot/relPath. Returns an empty (non-nil) set on parse error or a missing file. Memoized per path.

func (*Inspector) DisownedGoFilesByDir

func (i *Inspector) DisownedGoFilesByDir() map[string][]string

DisownedGoFilesByDir groups every disowned Go file by its parent directory. The returned map's values are project-relative paths, sorted for deterministic iteration. Empty when no Go file is disowned. Memoized.

func (*Inspector) GoSiblingsIn

func (i *Inspector) GoSiblingsIn(relDir string) ([]string, error)

GoSiblingsIn returns the project-relative paths of every *.go file (excluding *_test.go) physically present under projectRoot/relDir. Reads the directory once and memoizes the result.

func (*Inspector) IsDisowned

func (i *Inspector) IsDisowned(relPath string) bool

IsDisowned reports whether relPath was `forge disown`-ed: a one-way transfer to user ownership recorded in .forge/disowned.json.

func (*Inspector) IsGo

func (i *Inspector) IsGo(relPath string) bool

IsGo is the small filename-suffix probe shared by every Go-aware ownership query.

func (*Inspector) IsTier1

func (i *Inspector) IsTier1(relPath string) bool

IsTier1 reports whether relPath is a Tier-1 (regenerated-every-run) file: it carries forge's certification marker (or scoped fallback entry) and has not been disowned.

func (*Inspector) IsTracked

func (i *Inspector) IsTracked(relPath string) bool

IsTracked reports whether forge claims any ownership record for relPath: an embedded marker on disk, a scoped-fallback hash entry, or a disown record. Untracked paths are user-owned by convention.

func (*Inspector) Tier1GoFiles

func (i *Inspector) Tier1GoFiles() []string

Tier1GoFiles returns the sorted list of project-relative paths for every Tier-1 Go file — files on disk carrying a forge:hash marker (any verification status) that have not been disowned. Used by the pre-codegen exports snapshot: rename detection only watches files forge still owns.

type LegacyEntry

type LegacyEntry struct {
	Hash       string   `json:"hash"`
	History    []string `json:"history,omitempty"`
	Tier       int      `json:"tier,omitempty"`
	Disowned   bool     `json:"disowned,omitempty"`
	DisownedAt string   `json:"disowned_at,omitempty"`
	Forked     bool     `json:"forked,omitempty"`
	ForkedAt   string   `json:"forked_at,omitempty"`
}

LegacyEntry mirrors the manifest-era per-file record (structured shape). Only the fields the migration consults are decoded.

type LegacyManifest

type LegacyManifest struct {
	ForgeVersion string                 `json:"forge_version"`
	Files        map[string]LegacyEntry `json:"files"`
}

LegacyManifest is the decoded .forge/checksums.json.

func LoadLegacyManifest

func LoadLegacyManifest(root string) (*LegacyManifest, error)

LoadLegacyManifest reads .forge/checksums.json if present. Returns (nil, nil) when the file doesn't exist. Both historical wire shapes are accepted: the structured entry form and the original flat path→hex-string form.

type MarkerInfo

type MarkerInfo struct {
	Status   VerifyStatus
	Embedded string
	Body     string
}

MarkerInfo is one ScanMarkers result: the verification status plus the embedded and recomputed hashes.

type MigrationOutcome

type MigrationOutcome struct {
	Stamped           []string // pristine → forge:hash marker embedded
	Fallback          []string // comment-incapable → .forge/hashes.json
	DisownedConverted []string // → .forge/disowned.json
	DroppedTier2      []string // scaffold-once: user-owned, no record kept
	DroppedUnknown    []string // path current forge doesn't emit: user-owned now
	MissingOnDisk     []string // tracked but gone: nothing to certify
	Unverified        []string // matched nothing recorded: provenance unknown
}

MigrationOutcome reports what the legacy migration did, for the loud one-time announcement.

func MigrateLegacyManifest

func MigrateLegacyManifest(root string, cs *FileChecksums, currentTier1 func(string) bool) (*MigrationOutcome, error)

MigrateLegacyManifest performs the one-time conversion described in the package comment. currentTier1 reports whether the CURRENT forge still emits relPath as a Tier-1 output (paths it doesn't recognize are dropped rather than stamped). The legacy manifest file is deleted on success. Returns (nil, nil) when there is no legacy manifest.

The caller decides what to do with Unverified paths — the pipeline runs the side-render rescue then stamps survivors with StampUnverified; `forge upgrade` stamps them immediately.

func (*MigrationOutcome) Total

func (o *MigrationOutcome) Total() int

Total returns the number of legacy entries processed.

type SymbolLocation

type SymbolLocation struct {
	Pkg     string // declared package name (e.g. "forgedb")
	RelPath string // project-relative path (e.g. "pkg/embed/embed.go")
}

SymbolLocation pairs a public symbol with the package and relative file path that currently declares it. Used by ScanProjectGoExports when rename detection needs to follow a symbol that moved to a new package — `MigrationsFS` migrating from `db/embed.go` to `pkg/embed/embed.go` between forge versions, for example.

The Pkg field is the declared `package <name>` clause, not the import path; rename detection's stale-ref grep is shape-matched against `pkgName.Name`, not the fully qualified import.

type Tier1DriftEntry

type Tier1DriftEntry struct {
	Path         string
	RecordedHash string // the embedded (claimed) hash
	OnDiskHash   string // the recomputed body hash
	// Unverified marks the legacy-migration sentinel: the file's
	// provenance could not be established when the project migrated off
	// .forge/checksums.json. The remedies are the same (--force /
	// disown / restore), but the report wording differs.
	Unverified bool
}

Tier1DriftEntry reports a single Tier-1 file whose on-disk content fails its own certification (embedded hash ≠ recomputed body hash) — positive evidence of a hand-edit. The slice returned by ScanTier1Drift is sorted for stable error messages.

func ScanTier1Drift

func ScanTier1Drift(root string, cs *FileChecksums) []Tier1DriftEntry

ScanTier1Drift walks the project for self-certifying files and returns every one whose verification fails (hand-edited), plus every scoped-fallback (comment-incapable) entry whose on-disk body hash mismatches the recorded render. Disowned paths are skipped — they're user-owned by recorded intent. Used as the pre-pipeline stomp guard.

type VerifyStatus

type VerifyStatus int

VerifyStatus classifies content against its embedded marker.

const (
	// NoMarker — no forge:hash line present. Either a brand-new path
	// forge has never written, or a user-owned (Tier-2 / disowned)
	// file: forge claims no ownership over the bytes.
	NoMarker VerifyStatus = iota
	// Pristine — embedded hash equals the recomputed body hash: an
	// unedited forge render of SOME vintage (not necessarily the
	// current template's output).
	Pristine
	// Modified — marker present but stale: the file was hand-edited
	// after forge stamped it. The Tier-1 stomp guard refuses to
	// overwrite these.
	Modified
)

func Verify

func Verify(content []byte) VerifyStatus

Verify classifies content against its embedded marker. Purely local: no filesystem, no manifest.

Jump to

Keyboard shortcuts

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