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 project disown`. Generate skips these paths while the file exists; deleting the file re-adopts it. Committed.
.forge/hashes/ — the scoped fallback for the few Tier-1 output formats that cannot carry comments (JSON): one certificate file per output, holding the body hash of its last render (hashdir.go). One file per output, never one shared manifest, so only that output's changes touch its certificate and parallel branches do not conflict in it. Committed.
.forge/scaffolded/ — the scaffold-once BIRTH LEDGER, one entry file per path (scaffoldledger.go): every path forge has ever scaffolded here. It is what lets a DELETED scaffold stay deleted (see scaffoldledger.go). Committed.
Scaffold ("yours") files carry NO marker and are user-owned from birth: forge writes each one exactly once, keyed on the BIRTH LEDGER (WriteScaffoldIfMissing / ScaffoldOnceDecision). Once forge has scaffolded a path it NEVER writes it again — no flag, no exception — whether the user then edits the file or deletes it. Deleting is an act of ownership, not a request for a fresh copy. To re-scaffold on purpose: `forge project rescaffold <path>`, which drops the path's entry and writes it again.
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:
- 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.
- 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/) — 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 project 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/ certificate 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 project 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/ / scaffolded/ 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 — every file the run writes, by any route it declares:
- forge's own writers journal each file they touch 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.
- A step that runs an EXTERNAL tool (`buf generate` into gen/, `go mod tidy` on go.mod/go.sum, protoc-gen-connect-openapi) declares the paths that tool may write, and the pipeline captures them with CaptureExternalWrites immediately before the tool runs. These used to be left out on the theory that tool output is "deterministic from the inputs". It is not: a failed control-plane run left 32 buf outputs modified while reporting the tree "back to its pre-run state" — some carried a different protoc-gen-go version header (the local plugin is not the one that produced the committed stubs), others were regenerated from another agent's uncommitted proto. A revert that skips them does not restore the pre-run tree.
- It does NOT snapshot the whole working tree. Restoring paths a run never declared would also revert edits another process made to them DURING the run — another agent in a shared checkout, an editor. What is outside the declared set is verified rather than restored: the generate pipeline compares the tree to a pre-run fingerprint and names anything else that changed instead of claiming a restore it did not perform.
- 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 project upgrade, project creation) never call Begin, so journaling stays OFF and their writes are recorded nowhere — they have their own recovery stories.
The scaffold-once BIRTH LEDGER — the record that makes "written once" mean once, including across a deletion.
FRICTION: a generated CRUD lifecycle test (handlers_crud_test.go) whose own banner reads "yours: scaffolded once, never touched again — forge will not overwrite this file" came BACK three times in one session. The author had hardened their migrations with CHECK constraints, the scaffolded lifecycle test did not satisfy them, and they deleted it. Every subsequent `forge generate` wrote it again (15:44, 15:48, 15:49).
The cause was the shape of the guard, not the guard's placement. Every scaffold-once writer in forge asked ONE question:
if _, err := os.Stat(path); err == nil { return } // exists → skip
which implements "restore if absent", not "write once". Existence is a two-state answer to a THREE-state question:
never scaffolded → write it (birth) scaffolded, present → leave it (the user's bytes) scaffolded, DELETED → leave it DELETED (also the user's decision)
The third state is invisible to os.Stat: a deleted file and a file that was never born look identical on disk. Deleting is a legitimate act of ownership — an author who does not want forge's lifecycle test should be able to remove it and have it stay removed — so the missing state is the one that carries the user's intent, and forge was silently overriding it.
This ledger supplies the third state: the set of project-relative paths forge has EVER scaffolded. Present in the ledger + absent on disk = deliberately deleted, and forge does not write it again.
── Why a new state file, given the package doc says scaffold files need no record ───────────────────────────────────────────────────────────
That claim (see the package comment in checksums.go, and migrate.go's DroppedTier2) is exactly the assumption this defect disproves. The existing ledgers cannot answer the question:
- .forge/disowned.json records a DIFFERENT, opposite decision ("forge owns this path but I am taking it") and is keyed to Tier-1 paths.
- .forge/hashes/ holds render hashes for comment-incapable Tier-1 outputs only; scaffold-once files carry no marker and no hash by design, so there is nothing to look them up by.
- the forge:hash marker scan reads certification out of the FILE, and a deleted file has no bytes to read.
- the rollback journal is per-run and dropped on commit.
Every one of those is keyed on something the deleted file would have to still exist to provide. So the record has to be external, and it has to be committed — a teammate cloning the repo must inherit "we removed the lifecycle test", not re-acquire it on their first generate.
It is deliberately PATHS ONLY — no hashes, no content, no history. The manifest-era failure this package spent a release escaping was a global hash ledger that churned on every run and made 22 of 69 commits pure bookkeeping. This file changes only when a scaffold is BORN or a scaffolded path is deliberately re-adopted, which is a handful of lines at project birth and silence forever after.
── Re-scaffolding on purpose ─────────────────────────────────────────
Deleting the file is no longer the reset (that is the whole point), so the reset is dropping the ENTRY and writing the file again: `forge project rescaffold <path>` (internal/cli/rescaffold.go). The file is human-readable, sorted, and committed, so the resulting diff — one ledger line out and back, one file re-created — is an ordinary reviewable change.
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:
- 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.
- 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.
- 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 .gitattributes .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/ 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:
- 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);
- for .md: after the closing --- of a leading YAML frontmatter block (Claude Code requires frontmatter at byte 0);
- after a #! shebang line;
- for a Dockerfile: after its leading parser directives (# syntax=…);
- at the very top.
Index ¶
- Constants
- Variables
- func AbsentScaffolds(root string) []string
- func AbsentScaffoldsIfChanged(root string) []string
- func AddSideRenderOnly(relPath string)
- func BeginRollbackJournal(root string)
- func BodyHash(content []byte) string
- func CanonicalBodyHash(root, relPath string, content []byte) string
- func CanonicalGoSource(localPrefix, filename string, src []byte) ([]byte, error)
- func CaptureExternalWrites(root string, relPaths ...string)
- func CarryDisownAcrossRename(root string, cs *FileChecksums, oldRel, newRel string) bool
- func ChangedJournalPaths(root string) []string
- func CleanSideRenders(root, relPath string) error
- func CommitRollback()
- func CommittedStateFiles() []string
- func CommittedStatePaths(root string) []string
- func DeclineScaffold(root, relPath, reason string)
- func DeclinedScaffolds(root string) map[string]string
- func DeferScaffoldLedger(root string) (flush func())
- func ExtractGoExports(content []byte) (exports []string, pkgName string)
- func ExtractMarker(content []byte) (string, bool)
- func ExtractMarkerFor(relPath string, content []byte) (string, bool)
- func FindGolangciConfig(dir string) string
- func FlushHealNotices(root string)
- func ForgetScaffold(root, relPath string)
- func GoImportsLocalPrefix(root string) string
- func Hash(content []byte) string
- func HashCertPath(relPath string) string
- func HashCertTarget(certRel string) (string, bool)
- func IsCommittedState(relPath string) bool
- func IsGoPath(relPath string) bool
- func IsHashCert(relPath string) bool
- func IsLocalOnlyScaffold(relPath string) bool
- func IsTier1Target(relPath string) bool
- func JournaledPaths() []string
- func MarkTier1Target(relPath string)
- func MarkWrittenThisRun(relPath string)
- func MergeStateFile(relPath string, base, ours, theirs []byte) (merged []byte, ok bool)
- func NoteLeftAlone(relPath, why string)
- func NoteLeftAloneDetail(relPath, why string, detail []string)
- func ParseHashCert(data []byte) (string, bool)
- func PersistLedgerMigrations(root string)
- func PreservedOwnershipTransfer(root, legacy, originalBody string) bool
- func RecordPreWrite(root, relPath string)
- func RecordPreWriteAbs(path string)
- func RecordPreservedOwnershipTransfer(root, legacy, destination string, preserved []byte)
- func RecordScaffold(root, relPath string)
- func RecordScaffoldWithHash(root, relPath string, content []byte)
- func RemoveJournaled(path string) error
- func RescueFreshRender(root, relPath string, cs *FileChecksums) bool
- func RescueUnverified(root, relPath string) bool
- func ResetPerRunState()
- func ResetScaffoldLedgerCache()
- func ResetSkipWrite()
- func ResetWriteBlocks()
- func RestampWritten(root string, cs *FileChecksums)
- func RestoreRollback(root string) []string
- func RestoreRollbackReport(root string) (restored, failed []string)
- func RetireObsoleteDisowns(cs *FileChecksums, targetable func(relPath string) bool) []string
- func RollbackEnabled() bool
- func Save(root string, cs *FileChecksums) error
- func ScaffoldBirthsThisRun() []string
- func ScaffoldDeclined(root, relPath string) (reason string, ok bool)
- func ScaffoldEntryPath(relPath string) string
- func ScaffoldEntryTarget(entryRel string) (string, bool)
- func ScaffoldIsPristine(root, relPath string) bool
- func ScaffoldLedgerPaths(root string) []string
- func ScaffoldMayBeBorn(root, relPath string) bool
- func ScaffoldOnceDecision(root, relPath string) (write bool)
- func ScaffoldRecorded(root, relPath string) bool
- func ScaffoldsCreatedThisRun() []string
- func ScanMarkers(root string) map[string]MarkerInfo
- func ScanProjectGoExports(projectRoot string) map[string][]SymbolLocation
- func SectionLine(relPath, name string) (string, bool)
- func SetForceScope(relPaths []string)
- func SetTier1TargetSet(m map[string]bool)
- func SetWrittenThisRun(m map[string]bool)
- func SideRenderRelPath(relPath string) string
- func SnapshotJournalTargets(root, preserveDir string) []string
- func SplitScaffoldPath(path string) (root, relPath string, ok bool)
- func Stamp(relPath string, content []byte) ([]byte, bool)
- func StampUnverified(root, relPath string) bool
- func StampWithValue(relPath string, content []byte, value string) ([]byte, bool)
- func Stampable(relPath string) bool
- func StripMarker(content []byte) []byte
- func Tier1Targets() map[string]bool
- func Uncertified(relPath string) bool
- func WasWrittenThisRun(relPath string) bool
- func WriteBlocked(relPath string) bool
- func WriteGeneratedFile(root, relPath string, content []byte, cs *FileChecksums, force bool) (bool, error)
- func WriteGeneratedFileTier1(root, relPath string, content []byte, cs *FileChecksums, force bool) (bool, error)
- func WriteScaffoldIfMissing(root, relPath string, content []byte) (bool, error)
- func WriteSideRenderNoBase(root, relPath string, content []byte) error
- func WrittenPaths() map[string]bool
- type DisownedEntry
- type FileChecksums
- type GoFormatStandard
- type HealTrigger
- type Inspector
- func (i *Inspector) DeclaredTypesIn(relPath string) map[string]bool
- func (i *Inspector) DisownedGoFilesByDir() map[string][]string
- func (i *Inspector) GoSiblingsIn(relDir string) ([]string, error)
- func (i *Inspector) IsDisowned(relPath string) bool
- func (i *Inspector) IsGo(relPath string) bool
- func (i *Inspector) IsTier1(relPath string) bool
- func (i *Inspector) IsTracked(relPath string) bool
- func (i *Inspector) Tier1GoFiles() []string
- type LeftAlone
- type LegacyEntry
- type LegacyManifest
- type MarkerInfo
- type MigrationOutcome
- type ScaffoldEntry
- type SymbolLocation
- type Tier1DriftEntry
- type VerifyStatus
- type WriteOutcome
- type WriteSummary
Constants ¶
const ( // DisownedFile records one-way ownership transfers (`forge project disown`). DisownedFile = ".forge/disowned.json" // HashesDir holds one certificate file per comment-incapable Tier-1 // output (see hashdir.go). HashesDir = ".forge/hashes" // LegacyHashesFile is the one-manifest predecessor of HashesDir. Load // still reads it and the next Save deletes it (hashdir.go). LegacyHashesFile = ".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.
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.
const LegacyScaffoldedFile = ".forge/scaffolded.json"
LegacyScaffoldedFile is the one-file ledger ScaffoldedDir replaces. The load still reads it, and the next save writes its entries out and deletes it, so one `forge generate` migrates a project.
const ScaffoldedDir = ".forge/scaffolded"
ScaffoldedDir holds the birth ledger, ONE FILE PER PATH: the record of internal/app/compose.go is .forge/scaffolded/internal/app/compose.go.json (ScaffoldEntryPath). Committed (see the .gitignore negation beside disowned.json / hashes/): the decision to delete a scaffold belongs to the repository, not to one developer's working copy.
One file per path, not one shared file: every branch that scaffolds anything records births, and with one sorted file two branches' births were neighbouring insertions in it, which git (and GitHub's server-side merge, which runs no merge driver) reported as a conflict. A path's entry now changes only when that path is born, declined or re-adopted, so two branches conflict here only over the same path.
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 project disown` (keep their bytes).
Variables ¶
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 project upgrade` does not consult this var — it has its own diff-driven writer with its own --force.
var DisownCarryConflictFn = func(oldRel, newRel string) { fmt.Fprintf(os.Stderr, "⚠️ disowned %s could not be carried to its new name %s: that path already exists and is not forge's to replace.\n"+ " Both files may declare the same symbols. Resolve by keeping ONE:\n"+ " - keep yours: mv %s %s && forge project disown %s\n"+ " - drop yours: rm %s (forge then regenerates %s)\n", oldRel, newRel, oldRel, newRel, newRel, oldRel, newRel) }
DisownCarryConflictFn is invoked when a disowned old path cannot be carried because the new path is already occupied by a file forge may not remove. This is the one case forge cannot resolve alone, so it is reported as a runbook rather than resolved silently.
var DisownCarryNoticeFn = func(oldRel, newRel string) { fmt.Fprintf(os.Stderr, "📦 disowned file renamed: %s → %s — forge renamed this generated output; your disowned copy moved with it and is still yours. Nothing was overwritten.\n", oldRel, newRel) }
DisownCarryNoticeFn is invoked once per disown record carried across an emitter rename. Package var so the CLI can capture the report; the default prints to stderr. Never nil it out — assign a no-op in tests.
var GolangciConfigNames = []string{".golangci.yml", ".golangci.yaml", ".golangci.toml", ".golangci.json"}
GolangciConfigNames are the files golangci-lint reads its config from, in the order it tries them within one directory.
var HealNoticeFn = func(relPath string, trigger HealTrigger) { fmt.Fprint(os.Stderr, healNoticeText(relPath, trigger)) }
HealNoticeFn is invoked once per file per run when a write has replaced on-disk content that was a PRISTINE OLDER forge render with the current output. Healing must never be silent: if the old vintage was actually a deliberate edit that happened to hash-equal a prior render, this notice is the only trace that forge threw it away. The trigger records what allowed the overwrite so the message never claims a flag opt-in the user didn't make.
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.
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 project 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.
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 project 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.
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.
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 AbsentScaffolds ¶ added in v0.1.45
AbsentScaffolds returns the recorded paths that are NOT on disk, sorted.
That set is the ledger's third state made VISIBLE. The ledger exists so a deleted scaffold stays deleted, and the suppression it implements is correct — but it is also completely silent, and silence is how the deletion turns into a defect. A measured run deleted a scaffolded CRUD lifecycle test expecting `forge generate` to re-derive it against the migrations it had just corrected; generate reported success, said nothing, and the file was simply gone. The author spent an hour trying to reproduce the birth condition (copying the project aside, stripping RPCs from the proto, deleting db/migrations) because nothing in forge's output named the file or the one edit that brings it back.
The remedy is not to weaken the guarantee — forge must still never resurrect a file the user removed — but to say what it is doing. This is the query the reporting layer needs, and it is deliberately GENERAL: it answers for every scaffold-once path the ledger holds, so a writer added later is covered without being taught about here.
Unreadable (as opposed to absent) paths are not reported: a permission error is not evidence of a deletion, and a report that cannot tell the two apart is a report that cries wolf.
func AbsentScaffoldsIfChanged ¶ added in v0.1.45
AbsentScaffoldsIfChanged returns the absent set only when it DIFFERS from the set the last run reported, and records the new set as reported. Returns nil (and writes nothing) when the set is unchanged.
AbsentScaffolds answers "which scaffolds are deleted?", which is a standing fact and therefore a standing banner: control-plane printed the same 38-path list, plus five lines of explanation, on EVERY run. The notice exists for the moment a scaffold GOES missing — the run that deleted one and got a silent success. That is an event, and an event reported unconditionally is indistinguishable from wallpaper by the third run.
Both directions count as a change. A path appearing is the original case. A path DISAPPEARING from the set (the user rescaffolded it, or restored it from git) is also worth one line, because the previous notice is now stale, and because silently continuing to suppress would mean the next deletion of that same path never reports.
The standing fact remains available on demand — `forge project scaffolded` lists it whenever the user asks, which is the right shape for a fact that does not change.
func AddSideRenderOnly ¶
func AddSideRenderOnly(relPath string)
AddSideRenderOnly marks relPath as side-render-only for the current run. Idempotent.
func BeginRollbackJournal ¶
func BeginRollbackJournal(root string)
BeginRollbackJournal turns journaling ON and clears any prior capture. Called once at the head of a `forge generate` run, before any writer fires, with the project root the run operates on. After this, every forge write captures its target's pre-run state (once per path) so RestoreRollback can undo the whole run.
func BodyHash ¶
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 CanonicalBodyHash ¶ added in v0.1.45
CanonicalBodyHash is canonicalGoBody in total form: the body hash of content after canonical Go formatting, falling back to the exact-byte BodyHash for non-Go paths and for content that does not parse. It gives callers one rule for "are these two files the same render?" regardless of format — the .go tolerance applies where it is meaningful and is a no-op everywhere else.
func CanonicalGoSource ¶
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 CaptureExternalWrites ¶ added in v0.1.45
CaptureExternalWrites journals the paths an external tool is about to write, so a failed run restores them like any forge write. Call it immediately before the tool runs, with project-relative paths: a file, or a directory the tool writes into (captured recursively; files created under it during the run are deleted on restore). A path that does not exist yet is both — whatever the tool creates there is removed.
First-capture-wins, exactly as recordPreWrite: a path forge already wrote earlier this run keeps its true pre-run bytes. Paths outside the root are ignored. No-op when journaling is OFF.
func CarryDisownAcrossRename ¶ added in v0.1.45
func CarryDisownAcrossRename(root string, cs *FileChecksums, oldRel, newRel string) bool
CarryDisownAcrossRename moves a disown record — and the user's file with it — from oldRel to newRel when forge has renamed an emitter's output.
It is called by the rename-retirement helpers BEFORE the emitter writes newRel, which is what makes a collision impossible: by the time the write happens, newRel is a disowned path and the writer skips it.
Returns true when a carry-over happened (the caller must then NOT treat oldRel as a reclaimable forge file).
The safety rules, in order:
- No disown on oldRel → nothing to carry; the normal retirement path (reclaim forge's own pristine copy) applies.
- oldRel is already gone → the record alone is re-keyed. The user deleted their copy, which is the documented re-adoption gesture; the stale record must not keep pointing at a path that no longer exists.
- newRel already disowned → the user has already taken ownership under the new name too. Drop the old record and leave both files alone.
- newRel occupied by forge's own PRISTINE render → that copy is forge's to remove (same test the retirement path uses), so remove it and move the user's file in.
- newRel occupied by anything else → REFUSE, loudly, via DisownCarryConflictFn. Forge will not overwrite a file it did not write, and the old record is KEPT so the user's copy stays protected.
func ChangedJournalPaths ¶ added in v0.1.45
ChangedJournalPaths returns every path this run actually changed, of the ones a restore owns: each journaled path whose bytes or existence differ from what was captured before the run, and each file an external tool created under a captured directory. A journaled path rewritten with its pre-run bytes is not a change — every write goes through the journal, and most rewrite a file identically. Slash form, sorted. Read it BEFORE RestoreRollbackReport, which clears the journal.
func CleanSideRenders ¶
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 CommittedStateFiles ¶ added in v0.1.45
func CommittedStateFiles() []string
CommittedStateFiles are the single files under .forge/ that a project commits: the ownership state. The certificate files under HashesDir and the scaffold ledger's entry files under ScaffoldedDir are committed too (IsCommittedState, CommittedStatePaths). Everything else there (the lock, logs, side renders, a failed run's output) is per-clone scratch. A caller that sets .forge/ aside as scratch keeps these.
func CommittedStatePaths ¶ added in v1.0.10
CommittedStatePaths lists the committed state files present under root, project-relative and sorted: what a caller that sets .forge/ aside as scratch must keep.
func DeclineScaffold ¶ added in v1.0.0
func DeclineScaffold(root, relPath, reason string)
DeclineScaffold records that the project declines relPath: forge must never scaffold it here. reason is the design feedback the decline carries, and is required (an empty reason is ignored by the caller's validation, never stored as a decline that reads as a deletion).
An existing birth record is kept and marked, so a scaffold the user had deleted becomes a declared decline without losing its provenance.
func DeclinedScaffolds ¶ added in v1.0.0
DeclinedScaffolds returns every declined path with its reason.
func DeferScaffoldLedger ¶ added in v1.0.0
func DeferScaffoldLedger(root string) (flush func())
DeferScaffoldLedger holds back root's ledger writes until the returned function runs, then writes the ledger once if anything changed.
Every birth record rewrites the whole file (marshal, temp file, fsync, rename), so a scaffold that records N files writes it N times — quadratic in the project's size, and for a frontend's few hundred files most of the time `forge project new` spends. The in-memory ledger is authoritative the whole time; only the file waits. A process that dies inside the batch loses the records of the scaffold it was writing, which is the state a half-written scaffold is already in.
Batches nest. Safe to call from any goroutine.
func ExtractGoExports ¶
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 ¶
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.
Prefer ExtractMarkerFor where the path is known: without it this cannot tell a real stamp from a foreign-syntax example of one.
func ExtractMarkerFor ¶ added in v0.1.45
ExtractMarkerFor is ExtractMarker with the file's path in hand, which lets it reject a marker written in a comment syntax relPath's format cannot produce. Stamp only ever emits styleFor(relPath), so a marker in any OTHER syntax is prose ABOUT the marker — the canonical case being documentation that shows a Go banner inside a ```go fence in a .md file (docs/concepts.md did exactly this, and the plausible-looking example hash hard-blocked `forge generate` on a file forge does not emit). Falls back to the path-less scan for unstampable formats.
func FindGolangciConfig ¶ added in v1.0.0
FindGolangciConfig returns the config file golangci-lint loads for a run started in dir: the first of GolangciConfigNames in dir, else in the nearest ancestor that has one. "" when there is none.
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 ForgetScaffold ¶ added in v0.1.45
func ForgetScaffold(root, relPath string)
ForgetScaffold drops relPath's birth record, returning the path to the "never scaffolded" state so the next run scaffolds it fresh — the deliberate reset `forge project rescaffold` takes.
A failed `forge generate` rarely needs it: the rollback journal restores the ledger file itself to its pre-run bytes (see RestoreRollbackReport), and reconcileScaffoldBirths calls it only when that restore failed.
func GoImportsLocalPrefix ¶
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 HashCertPath ¶ added in v1.0.10
HashCertPath is the project-relative certificate file of the comment-incapable output relPath.
func HashCertTarget ¶ added in v1.0.10
HashCertTarget is the inverse of HashCertPath: the output a certificate file at certRel certifies, and whether certRel is one.
func IsCommittedState ¶ added in v1.0.10
IsCommittedState reports whether relPath is one of the files under .forge/ a project commits: a CommittedStateFiles entry, a certificate file or a scaffold ledger entry.
func IsGoPath ¶
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 IsHashCert ¶ added in v1.0.10
IsHashCert reports whether relPath is a certificate file.
func IsLocalOnlyScaffold ¶ added in v0.1.46
IsLocalOnlyScaffold reports whether relPath is a scaffold-once file that is gitignored by construction — a developer's secret store, `secrets/<env>.yaml` (the scaffolded .gitignore ignores `secrets/`; projects that narrow it use `secrets/*.yaml`).
The ledger is committed; such a file is not. Recording it would make the committed ledger a function of one checkout's untracked state: a clean clone and a developer's tree would write different bytes from the same commit. And the ledger's "recorded but absent means deliberately deleted" rule is meaningless for a file that never reaches a clone — it would leave a fresh checkout without the store it needs. So these paths are never recorded, never reported absent, and their birth is decided by presence alone.
func IsTier1Target ¶ added in v0.1.45
IsTier1Target reports whether relPath was recorded as a Tier-1 emit target this run.
func JournaledPaths ¶ added in v0.1.45
func JournaledPaths() []string
JournaledPaths returns every path the journal holds (slash form, sorted) — the set a restore puts back. Callers verifying a restore read it BEFORE RestoreRollback, which clears the journal.
func MarkTier1Target ¶ added in v0.1.45
func MarkTier1Target(relPath string)
MarkTier1Target records relPath as a Tier-1 emit target. For an emitter that regenerates a path every run without going through a Tier-1 writer (the hooks barrel), and for tests that bypass the writer chokepoint.
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 MergeStateFile ¶ added in v0.1.45
MergeStateFile three-way merges one of the committed state files under .forge/ (disowned.json, the legacy scaffolded.json, a certificate under hashes/, a ledger entry under scaffolded/) key by key, and renders the result exactly as forge's own writers do.
WHY. Every branch that adds a service records its births in the scaffold ledger and the hashes of its JSON outputs, so two such branches always change the same small files. git merges them as lines, and adjacent insertions — two services sorting between the same two existing entries, or two outputs' hashes that sort side by side — conflict on every merge and every rebase. The P3b pilot, 33 parallel lanes each adopting one service, resolved them by hand in each lane (F16). The files are maps: merged as maps, the only collision left is one key whose value both sides changed, and for these files that has a forge answer:
- a ledger entry under scaffolded/ (and the legacy scaffolded.json, key by key): a birth recorded on either side is recorded; one side's deletion (rescaffold) stands when the other left the entry alone. Both sides recording the same path keep this side's entry — the timestamp is provenance nobody branches on.
- a certificate under .forge/hashes/ (and the legacy hashes.json, key by key): both sides re-rendered the same comment-incapable output. The merge driver keeps THIS side's bytes for that output (see the cli merge driver), so this side's hash is the one that certifies them, and the next `forge generate` renders the merged inputs over a file it can still verify. One side's change alone (or deletion) stands.
- disowned.json: both sides changed one disown's reason; keep this side's.
ok is false when relPath is none of these files, or a side does not parse as one — the caller then merges it as text, the historical behaviour, so nothing is lost to a format this function does not know.
func NoteLeftAlone ¶ added in v1.0.0
func NoteLeftAlone(relPath, why string)
NoteLeftAlone records that forge did not write relPath this run, and why. The first reason recorded for a path wins.
func NoteLeftAloneDetail ¶ added in v1.0.0
NoteLeftAloneDetail is NoteLeftAlone with the exact edits the user must make, printed under the reason.
func ParseHashCert ¶ added in v1.0.10
ParseHashCert reads a certificate file's hash.
func PersistLedgerMigrations ¶ added in v1.0.13
func PersistLedgerMigrations(root string)
PersistLedgerMigrations rewrites root's ledger when the copy on disk is in a shape this forge no longer writes: the legacy one-file ledger (LegacyScaffoldedFile), or entries IsLocalOnlyScaffold forbids. Every save converges both, but a save happens only when a run records a birth, so a project whose upgrade run scaffolded nothing kept its legacy ledger, and the first pull request to scaffold anything afterwards carried the whole migration. `forge generate` calls this on every successful run, so the upgrade run itself migrates. A ledger already in the current shape is not touched.
func PreservedOwnershipTransfer ¶ added in v1.0.0
PreservedOwnershipTransfer proves that the exact quarantined edit survived an explicit transfer this run, independently of obsolete-disown retirement.
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 RecordPreWriteAbs ¶ added in v0.1.45
func RecordPreWriteAbs(path string)
RecordPreWriteAbs is RecordPreWrite for call sites that only hold the joined destination path (the scaffold-once raw writers in internal/codegen address files as filepath.Join(projectDir, rel) and never thread the pair through). The path is resolved against the journal's armed root; a path outside that root is not captured — the journal restores relative to the root, so an outside path is not ours to rewind.
This closes the defect where a failed generate run reverted the Tier-1 files it wrote but left the scaffold-once files written THE SAME RUN (e.g. handlers_crud.go surviving while its handlers_crud_ops_gen.go dependency was rolled back), stranding the tree in a state that compiles against neither the pre-run nor the post-run world.
func RecordPreservedOwnershipTransfer ¶ added in v1.0.0
RecordPreservedOwnershipTransfer records a completed emitter-owned migration. Adjudication later verifies the captured bytes still exist at the destination or the retained legacy path; a disown flag alone is never sufficient proof.
func RecordScaffold ¶ added in v0.1.45
func RecordScaffold(root, relPath string)
RecordScaffold marks relPath as scaffolded and persists the ledger immediately.
Immediate persistence (rather than a save hook at the end of the run) is deliberate: scaffold-once writes happen from FOUR entry points — the generate pipeline, `forge new`, `forge project upgrade`, and the `forge scaffold ...` commands — and only the first has a save hook. A ledger that recorded a birth but persisted it only on the pipeline path would leave every other path with the old restore-if-absent behavior, which is the defect this file exists to close.
Idempotent: re-recording a known path neither changes the entry nor rewrites the file, so a steady-state `forge generate` produces no ledger diff.
func RecordScaffoldWithHash ¶ added in v0.1.45
RecordScaffoldWithHash records a birth AND the content hash forge wrote, for the scaffolds that keep refreshing until the user touches them.
Unlike RecordScaffold it overwrites an existing entry's hash: each refresh is a new birth for this purpose, and the recorded hash has to track the bytes currently on disk or the very next run would read the file as edited.
func RemoveJournaled ¶ added in v0.1.45
RemoveJournaled deletes a forge-owned path after capturing its pre-run bytes, so a `forge generate` that aborts later restores it. It is the DELETE-side twin of the WriteGeneratedFile chokepoint, and every forge deletion of a tracked file should go through it.
WHY A DELETE NEEDS JOURNALING AT ALL. The journal is populated lazily, at the first write that targets a path. A raw os.Remove is invisible to it, and the consequence is worse than "not restored" — it INVERTS the rewind:
- a step deletes internal/<pkg>/middleware_gen.go (unjournaled)
- a later step rewrites it, and recordPreWrite — running for the first time, seeing no file — records existed=false, "absent before this run"
- the run fails, and RestoreRollback honours existed=false by DELETING
So the rewind whose entire purpose is to hand the tree back clean is what removes the file. Observed in a shared checkout: a proto validation error left internal/coupon with no middleware_gen.go, and `go build ./...` failed for every agent with `undefined: couponsvc.NewServiceWithForgeMiddleware` — an error naming a package that had nothing to do with the failure.
Capture-then-delete fixes both halves at once, because recordPreWrite is first-write-wins: the capture here holds the TRUE pre-run bytes, and a later rewrite's capture is a no-op. A missing file is not an error, and journaling never itself fails a delete.
func RescueFreshRender ¶ added in v0.1.45
func RescueFreshRender(root, relPath string, cs *FileChecksums) bool
RescueFreshRender settles a Tier-1 drift report against what this run would write. The stomp guard reports a file as hand-edited when its bytes disagree with forge's record of them — the embedded forge:hash marker, or for a comment-incapable format the .forge/hashes.json entry. That record can be wrong while the bytes are exactly forge's own:
- a `forge generate` killed after rewriting outputs but before saving .forge/hashes.json (it saves once, at the end of the run);
- a merge that took one side's generated file and the other side's ledger, or git's three-way merge of a stamped file's body under one side's marker.
None of those is a hand-edit, and refusing them made the next run demand --force for files no person had touched. So the guard parks a fresh render of each drifted path (the side-render redirect) instead of failing, and this decides it: when the on-disk bytes are the fresh render — modulo the marker line, and for Go modulo canonical formatting — the record is repaired to match them. A stamped file is restamped (journaled, so a later failure in the run rewinds it); a comment-incapable one gets its hashes.json entry, persisted with the rest of the run's state.
Returns false — leaving the drift standing — when no fresh render was parked for relPath (its emitter did not run, or forge no longer emits the path), when the bytes differ, or when the file cannot be read or written. The parked render is removed once consulted.
func RescueUnverified ¶
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 ResetScaffoldLedgerCache ¶ added in v0.1.45
func ResetScaffoldLedgerCache()
ResetScaffoldLedgerCache drops the memoized ledgers so the next query re-reads from disk. Called at the head of a pipeline run (a long-lived process must not answer from a previous invocation's cache) and by tests that build several projects in one binary.
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 ResetWriteBlocks ¶ added in v0.1.45
func ResetWriteBlocks()
ResetWriteBlocks clears the per-run block set. Called at the start of a generate run (and by tests) so a refusal never leaks between runs.
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 ¶
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 RestoreRollbackReport ¶ added in v0.1.45
RestoreRollbackReport is RestoreRollback that also returns the paths it could NOT restore, so a caller can refuse to claim a restore that did not happen.
Paths captured ahead of an external tool (CaptureExternalWrites) are rewritten and listed only when the tool actually changed them, and every file created under a captured directory during the run is deleted.
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 state DELETES its files — 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 ScaffoldBirthsThisRun ¶ added in v1.0.0
func ScaffoldBirthsThisRun() []string
ScaffoldBirthsThisRun returns the paths whose birth this generate run recorded, sorted. Read it BEFORE RestoreRollbackReport, which clears it.
func ScaffoldDeclined ¶ added in v1.0.0
ScaffoldDeclined reports whether the project declined relPath, and why.
func ScaffoldEntryPath ¶ added in v1.0.11
ScaffoldEntryPath is the project-relative entry file recording relPath.
func ScaffoldEntryTarget ¶ added in v1.0.11
ScaffoldEntryTarget is the inverse of ScaffoldEntryPath: the path an entry file at entryRel records, and whether entryRel is one.
func ScaffoldIsPristine ¶ added in v0.1.45
ScaffoldIsPristine reports whether relPath is still byte-for-byte what forge last wrote there.
False when the file is absent, unreadable, was never recorded with a hash, or differs by a single byte — every one of which means "do not touch it". A missing hash reads as edited on purpose: a scaffold born before this bookkeeping existed is one forge has no evidence about, and silently rewriting a file the user may have spent an afternoon on is the failure worth avoiding.
func ScaffoldLedgerPaths ¶ added in v0.1.45
ScaffoldLedgerPaths returns root's recorded paths, sorted. Exposed for tests and for `forge project audit`-style reporting; callers must not mutate the ledger through it.
func ScaffoldMayBeBorn ¶ added in v1.0.0
ScaffoldMayBeBorn is the one answer to "may forge create this scaffold-once file, which is not on disk?", shared by every writer that can birth one: `forge generate`'s emitters (through ScaffoldOnceDecision), `forge project upgrade`'s templates phase, and the scaffold commands.
It is false when the ledger records the path (the user deleted it, or declined it with `forge project disown --absent`) and when the path was disowned, because either way the project has said the file is not forge's to write. Only `forge project rescaffold`, which drops the record first, or naming the path under `forge project upgrade --force`, brings it back.
The caller has already established the file is absent; this does not stat.
func ScaffoldOnceDecision ¶ added in v0.1.45
ScaffoldOnceDecision is the single answer every scaffold-once writer in forge asks for, so that the three-state rule is stated ONCE instead of being re-derived (and mis-derived as a bare os.Stat) at each site.
Returns write=true only for a genuine birth. When it returns false the caller must not write; when it returns true the caller writes and then calls RecordScaffold.
The `present` arm ADOPTS: a file that exists but has no birth record is a project scaffolded by an older forge, so the record is backfilled here. Without that backfill, every project created before this ledger existed would keep the old behavior forever — the user's first deletion would look like "never scaffolded" and be undone exactly once more.
func ScaffoldRecorded ¶ added in v0.1.45
ScaffoldRecorded reports whether forge has EVER scaffolded relPath in this project. Combined with the file's presence on disk it resolves the three-state question in the file doc:
!recorded → never scaffolded: write it
recorded && present → the user's bytes: leave them
recorded && absent → the user deleted it, or declined it
(ScaffoldEntry.Declined): leave it absent
func ScaffoldsCreatedThisRun ¶ added in v1.0.0
func ScaffoldsCreatedThisRun() []string
ScaffoldsCreatedThisRun returns the scaffold-once files this run brought into existence: a birth recorded this run for a file that was absent before it. Sorted; read it BEFORE RestoreRollbackReport, which clears both the journal and the birth set.
A birth recorded for a file that was already there (an older project's scaffold, adopted by ScaffoldOnceDecision) is not one: a rollback leaves that file, restores the ledger without the adoption, and the next run adopts it again.
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 SectionLine ¶ added in v1.0.10
SectionLine is the section header an emitter writes where the part named name begins in relPath, in relPath's comment syntax and terminated with "\n". Every section of a file needs a distinct, stable name (the table, the service, the interface). ok=false for a format that cannot carry comments.
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 SetTier1TargetSet ¶ added in v0.1.45
SetTier1TargetSet replaces the Tier-1 target set wholesale. For tests that need to stage a specific set.
func SetWrittenThisRun ¶ added in v0.1.45
SetWrittenThisRun replaces the written-this-run set wholesale. For tests that need to stage a specific set.
func SideRenderRelPath ¶
SideRenderRelPath returns the project-relative location of the latest side render for relPath (`.forge/render/<relpath>`).
func SnapshotJournalTargets ¶ added in v0.1.45
SnapshotJournalTargets copies the CURRENT on-disk content of every journaled path — i.e. the failed run's output — into preserveDir, mirroring each file's project-relative path. Called BEFORE RestoreRollback on the failure path so the revert (which is the right default: the user's tree must come back clean) does not also destroy the only evidence of WHY the run failed: a `go build` validate error cites file:line coordinates in generated sources, and pre-fix the revert deleted those sources, leaving the user to debug a compiler error against code they could not read.
The preserve directory is recreated fresh on every call so successive failures never interleave. Journaled paths whose current bytes equal their captured pre-run bytes are skipped (nothing new to inspect), as are paths that no longer exist. Best-effort per file — a copy failure drops that file from the returned list, never aborts. Returns the sorted relative paths preserved; nil when journaling is OFF, the journal is empty, or nothing qualified.
func SplitScaffoldPath ¶ added in v0.1.45
SplitScaffoldPath resolves an absolute destination path into the (projectRoot, relPath) pair the ledger is keyed on, by walking up to the nearest directory holding a forge.yaml.
It exists because several scaffold-once writers — the raw ones in internal/codegen — were built to take a single joined path and never thread the pair through (the same shape that forced RecordPreWriteAbs to exist for the rollback journal). Rather than re-plumb every call site, the root is recovered here.
When journaling is armed its root is authoritative: the pipeline knows which project it is generating, and a nested module inside the project (a frontend workspace, an examples/ tree) must not be mistaken for a separate project with its own ledger.
Returns ok=false when no project root can be established — a bare temp directory in a unit test, for instance. Callers then fall back to the historical presence check rather than refusing to write.
func Stamp ¶
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 ¶
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 ¶
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 ¶
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 ¶
StripMarker removes every marker line from content (preserving the original line endings everywhere else). Used by `forge project disown` (a user-owned file must not advertise forge certification) and by the Tier-2 reclassification path.
func Tier1Targets ¶ added in v0.1.45
Tier1Targets returns a snapshot copy of the Tier-1 target set, safe to range over while other goroutines are still emitting.
func Uncertified ¶ added in v1.0.9
Uncertified reports whether relPath is a Tier-1 output that NOTHING certifies: a comment-incapable forge aggregate in forge's wholly-owned gen/ tree (uncertifiedOutputs). .forge/hashes/ never records one (Load drops a leftover entry), so it is regenerated whole on every run and is never a stomp-guard or cleanup candidate. gen/forge_descriptor.json has never had a certificate either; gen/forge_service_catalog.json is the Tier-1 one.
WHY. gen/'s JSON files are aggregates of the whole project, and a certificate of an aggregate is a fingerprint of every input at once: it changes on every branch that changes any of them. Any two such branches then conflict on the certificate even when their changes to the file are disjoint lines that merge cleanly, and git's server-side merge (a merge queue, GitHub's "update branch") runs no merge driver to settle it. reliant's catalog line in hashes.json conflicted between every pair of proto-changing pull requests; after a local merge the merged catalog matched neither side's recorded hash, so the stomp guard refused it as a hand edit. What the certificate bought (refusing to overwrite a hand edit of a generated file nobody edits) is not worth that: `forge ci verify-generated` already fails a tree whose copy is not forge's render, and `forge project disown` still takes the path over (it admits Uncertified paths, and a disowned path is never written).
func WasWrittenThisRun ¶ added in v0.1.45
WasWrittenThisRun reports whether relPath was written this run.
func WriteBlocked ¶ added in v0.1.45
WriteBlocked reports whether a refused carry-over has blocked relPath. Consulted by the Tier-1 write chokepoint.
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/ certificate);
- 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 ¶
WriteScaffoldIfMissing writes a scaffold ("yours") file exactly ONCE, at birth. Scaffold content carries NO marker and is user-owned from birth: forge writes it once, then NEVER touches it again — no flag, no exception.
"Once" is decided by the BIRTH LEDGER (scaffoldledger.go), not by the file's presence on disk. The two differ in the case that matters: a user who DELETES a scaffold has exercised ownership just as much as one who edits it, and a presence check reads that deletion as "absent, so write it" and silently undoes the decision. The ledger separates "never scaffolded" (write) from "scaffolded and then deleted" (leave deleted).
To re-scaffold on purpose, `forge project rescaffold <path>` drops the path's entry (ForgetScaffold) and writes it again — deleting the file is not the reset.
Returns true when the file was written (a genuine birth). An existing file — pristine, hand-edited, or fully rewritten — and a deliberately deleted one are both left exactly as they are, returning (false, nil). Parent directories are created as needed.
func WriteSideRenderNoBase ¶
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).
func WrittenPaths ¶ added in v0.1.45
WrittenPaths returns a snapshot copy of the written-this-run set, safe to range over while other goroutines are still writing.
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; `forge project audit --json` surfaces it on each disowned_files row.
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 one certificate file per path under
// .forge/hashes/ (hashdir.go). 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 + the certificate files under .forge/hashes/, and a legacy .forge/hashes.json whose entries a certificate file does not already hold). 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 project disown`-ed.
type GoFormatStandard ¶ added in v1.0.0
type GoFormatStandard struct {
// GoImports applies goimports' import grouping (with LocalPrefix); false
// applies gofmt alone.
GoImports bool
// LocalPrefix is the comma-separated local-prefixes the gate declares for
// goimports ("" for none).
LocalPrefix string
// GroupingOwned is true when gci is enabled: gci owns import grouping and
// golangci-lint --fix applies it, so forge leaves grouping alone.
GroupingOwned bool
// Declared is true when the project has a golangci-lint config that parsed,
// i.e. the standard above is the project's word and not a default.
Declared bool
}
GoFormatStandard is the formatting applied to a project's Go.
func ProjectGoFormat ¶ added in v1.0.0
func ProjectGoFormat(root string) GoFormatStandard
ProjectGoFormat reads the standard from the golangci-lint config a run started in root loads. No config, or one that does not parse, states no standard: the zero value (gofmt alone) is returned, because rewriting the author's files must not regroup what no gate checks. `forge lint --fix` formats to this.
func RewriteGoFormat ¶ added in v1.0.0
func RewriteGoFormat(root string) GoFormatStandard
RewriteGoFormat is the standard for a file forge has just REWRITTEN (a codemod, `forge fix narrow-deps`): bytes the project's linters accept, so the formatting findings forge produced itself never reach a person.
It is ProjectGoFormat wherever the project has a golangci-lint config: its own local-prefixes (or none), gci's hand-off, or gofmt alone. Only a project with no config at all gets forge's own convention, the one the scaffolded .golangci.yml declares and the generate pipeline writes: goimports with the module path as the local prefix.
type HealTrigger ¶ added in v0.1.45
type HealTrigger int
HealTrigger names WHAT allowed replacing a pristine-but-older forge render, so the heal notice can tell the truth about it. Pre-trigger, the notice unconditionally claimed "(you opted in via --heal/--force)" — but the most common path into a heal is neither flag: the generate pipeline's own writers pass force=true for the Tier-1 paths they own, and on a run with no --force the scope is never installed, so that blanket force applies (forceApplies' unscoped-legacy branch). A plain `forge generate` after a proto edit or template bump then printed "you opted in via --heal/--force" for every regenerated file — a claim of user intent the user never expressed.
const ( // HealTriggerImplicit means no user flag was involved — the write went // through because the pipeline (or another forge-internal caller) // holds unscoped force over its own Tier-1 paths. This is the normal // "regenerate after inputs/templates changed" path. HealTriggerImplicit HealTrigger = iota // HealTriggerHealFlag means the user passed --heal (AutoHeal). HealTriggerHealFlag // HealTriggerForceFlag means the user passed --force and this file is in // the per-run force scope the Tier-1 drift guard installed. HealTriggerForceFlag )
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 ¶
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 ¶
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 ¶
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. The paths are slash-separated (see slashKey), so they compare equal to DisownedGoFilesByDir's values on every OS.
func (*Inspector) IsDisowned ¶
IsDisowned reports whether relPath was `forge project disown`-ed: a one-way transfer to user ownership recorded in .forge/disowned.json.
func (*Inspector) IsGo ¶
IsGo is the small filename-suffix probe shared by every Go-aware ownership query.
func (*Inspector) IsTier1 ¶
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 ¶
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 ¶
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 LeftAlone ¶ added in v1.0.0
type LeftAlone struct {
// Path is project-relative and slash-separated.
Path string
// Why says what forge found and what to do about it, in one sentence.
Why string
// Detail is the exact edits, one printed line each, when one sentence
// cannot carry them — the rows of a starter test a hook-kind flip left
// stale, with the body that replaces each.
Detail []string
}
LeftAlone is one path forge declined to write this run, and why.
func LeftAloneThisRun ¶ added in v1.0.0
func LeftAloneThisRun() []LeftAlone
LeftAloneThisRun returns every path recorded by NoteLeftAlone, sorted.
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 project upgrade` stamps them immediately.
func (*MigrationOutcome) Total ¶
func (o *MigrationOutcome) Total() int
Total returns the number of legacy entries processed.
type ScaffoldEntry ¶ added in v0.1.45
type ScaffoldEntry struct {
// BirthHash is the content hash forge wrote, recorded only for the few
// scaffolds that keep refreshing while untouched (see
// RecordScaffoldWithHash / ScaffoldIsPristine). Empty for the ordinary
// write-once scaffolds, which never need to know whether the file still
// matches what forge produced.
BirthHash string `json:"birth_hash,omitempty"`
// Declined, when set, is the project's reason for declaring that forge
// must never scaffold this path (`forge project disown <path> --absent`).
//
// The ledger already treats "recorded + absent" as "leave it absent",
// but that state was reachable only by a BIRTH that survived its run: a
// path whose birth broke the generate it was born in (reliant's serve.go,
// which switched composition on mid-migration) was rolled back with its
// record every time, so there was never a record to delete it against,
// and the only escape was hand-writing JSON. A declined entry is that
// record written on purpose, by a command outside generate — so a failed
// run's rollback, which restores this file to its pre-run bytes, keeps it.
//
// It is a declaration, not a deletion: AbsentScaffolds does not report
// it (nothing went missing), and `forge project rescaffold <path>`
// re-adopts the path by dropping the entry, exactly as for a deletion.
Declined string `json:"declined,omitempty"`
// AbsentReported marks a path that was absent at the last run that
// reported the absent set (AbsentScaffoldsIfChanged): what turns the
// absent-scaffolds notice from a standing banner into an EVENT. It lives
// on the path's own entry so that two branches deleting different
// scaffolds change different entry files.
AbsentReported bool `json:"absent_reported,omitempty"`
}
ScaffoldEntry is one path's birth record. A plain birth records nothing but its existence: its entry file holds `{}`. It used to carry the birth's timestamp, which nothing branched on and which made the record a function of the clock: two branches that each scaffolded the same new path wrote two different entries for it, and conflicted.
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 ClassifyPath ¶ added in v0.1.45
func ClassifyPath(root, relPath string, content []byte) VerifyStatus
ClassifyPath is the path-aware form of Verify: the exact-byte classification widened by the .go formatter tolerance, so import regrouping or a gofmt realignment reads as Pristine rather than as a hand-edit. Verify itself stays exact-byte by design (it knows nothing about paths); every gate that has a path in hand should classify through here so the drift scan, the writer, and the artifact- retirement sweep all answer the same question the same way. Marker detection is path-aware too: a marker in a comment syntax relPath's own format cannot produce is documentation about the marker, not a stamp, so such a file classifies NoMarker (forge claims no ownership) rather than Modified. See ExtractMarkerFor.
func Verify ¶
func Verify(content []byte) VerifyStatus
Verify classifies content against its embedded marker. Purely local: no filesystem, no manifest.
type WriteOutcome ¶ added in v0.1.46
type WriteOutcome struct {
// Wrote is true when the render landed on disk: the path is current
// and forge-owned this run. A re-run that renders the same bytes still
// writes them, so Wrote alone says nothing about change.
Wrote bool
// Changed is true when Wrote and the file's bytes now differ from what
// was on disk before the write (including a file that did not exist).
// It is the per-file answer the run's closing WriteSummary gives for
// the whole tree, so a progress line built on it agrees with the
// summary.
Changed bool
}
WriteOutcome is what one Tier-1 write did.
func WriteDerivedFileOutcome ¶ added in v1.0.0
func WriteDerivedFileOutcome(root, relPath string, content []byte, cs *FileChecksums, force bool) (WriteOutcome, error)
WriteDerivedFileOutcome is WriteGeneratedFileOutcome for a file that is a pure function of a hand-written source sitting beside it (a contract package's mock_gen.go and decorator, rendered from contract.go).
The non-destructive heal default exists because a deliberate edit can be byte-identical to an older render of a TEMPLATE, and forge cannot tell the two apart. For a source-derived file that ambiguity does not exist in the way the default assumes: after the author edits the source, the previous render is stale by construction, and "an older render, left untouched" is exactly the state that keeps a stale mock — which no longer compiles against the edited contract — from being regenerated, with validation then blaming the generated file. So a pristine older render is refreshed here without --heal or a --force scope, and without the heal notice (the source changed; nothing was reverted).
A file whose bytes were EDITED after forge stamped it is still a hand-edit and is skipped unless force applies, exactly as for any Tier-1 file.
func WriteGeneratedFileOutcome ¶ added in v0.1.46
func WriteGeneratedFileOutcome(root, relPath string, content []byte, cs *FileChecksums, force bool) (WriteOutcome, error)
WriteGeneratedFileOutcome is WriteGeneratedFile, reporting whether the write changed the file's bytes as well as whether it landed — what a progress line needs to say "updated" only when something was.
type WriteSummary ¶ added in v0.1.45
type WriteSummary struct {
// Touched is every path a forge writer targeted this run, whether or
// not the bytes changed.
Touched int
// Created are paths that did not exist before the run.
Created []string
// Updated are paths that existed and whose bytes differ now.
Updated []string
// Deleted are paths that existed and are gone now — a retired
// generated file (RemoveJournaled) is a change like any write.
Deleted []string
}
WriteSummary is the run's write ledger: what forge's writers actually did to the tree, derived from the same journal the rollback uses.
The three fields answer three DIFFERENT questions, and conflating them is what made `✅ Code generation complete!` unfalsifiable:
Touched == 0 no emitter fired at all. The pipeline ran and
produced nothing. This is the silent-no-op
signature (`--steps mocks` omitting its own
emitter printed success from exactly here).
Touched > 0, changed 0 every emitter fired and the bytes already
matched — a healthy idempotent re-run.
changed > 0 real work landed; the names are the proof.
func SummarizeWrites ¶ added in v0.1.45
func SummarizeWrites(root string) WriteSummary
SummarizeWrites compares every journaled path's CURRENT bytes against the pre-run bytes the journal captured, and reports what changed. It mutates nothing — call it any time before CommitRollback (which drops the journal) to learn what the run actually did.
Returns a zero WriteSummary when journaling is OFF, so callers that run outside the pipeline degrade to "no claim" rather than to a false one.
func (WriteSummary) Changed ¶ added in v0.1.45
func (s WriteSummary) Changed() int
Changed returns the number of paths whose on-disk bytes differ from their pre-run state: created, updated or deleted.