Help us shape the future of Go! Take the 2026 Go Developer Survey

generator

package
v1.0.14 Latest Latest
Warning

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

Go to latest
Published: Nov 2, 2026 License: Apache-2.0 Imports: 55 Imported by: 0

Documentation

Overview

Package generator: checksums shim.

The canonical ownership-state type (FileChecksums: disowned paths + the scoped unstampable-format hashes) and the self-certification write helpers live in internal/checksums so that internal/codegen (per-feature emitters) can also import them without an import cycle (generator already imports codegen). Existing call sites in the cli/ and generator/ packages continue to work via the re-exports below.

Package generator orchestrates project scaffolding: it lays down a new project, scaffolds individual services / workers / operators / webhooks / frontends, regenerates infra files on upgrade, and emits the plan-mode entity / proto / ORM / migration files.

The behavioural surface is split into two narrow Services:

  • Service — the file-emission orchestrator (every file the scaffolder writes into the user project).
  • ConfigService — read / write / mutate forge.yaml on disk.

Data carriers (FileChecksums, Harness, ProjectGenerator, E2EMethodInfo, UpgradeResult, *TemplateData structs) remain plain types — they have no behavioural seam to mock.

The project drift probe — the ONE answer to "which forge-owned files were hand-edited after forge wrote them?".

checksums.ScanTier1Drift is the raw signal: every self-certifying file whose embedded forge:hash marker fails to verify, plus every comment-incapable output whose recorded body hash mismatches. That raw set needs exactly one adjustment before any consumer may act on it: the Tier-2-managed starters (Dockerfile, Taskfile.yml, .golangci.yml, the one-shot .github scaffolds …) carry a marker so `forge project upgrade` can tell "still pristine" from "user customized it" — editing them is SANCTIONED, so their drift is not drift.

This file owns that adjustment, and it lives here (next to Tier2ManagedPaths, the registry that defines the exemption) rather than in any one command so that every gate reads the same set:

  • `forge generate`'s pre-pipeline stomp guard (internal/cli),
  • `forge lint`'s generated-file drift gate (internal/cli/lint),
  • `forge project audit`'s codegen category,
  • `forge ci verify-generated`.

A finding that gates one surface and not another is a bug, not a policy — hence one probe.

Package generator — @reliantlabs/forge-web-runtime dependency resolution for generated frontends.

Generated frontends import @reliantlabs/forge-web-runtime (the web twin of forge/pkg): connect.ts builds its interceptor stack from it, providers.tsx mounts RuntimeShell, the CRUD list pages render <Resource>. It is therefore a REAL dependency of shipped application code and belongs in "dependencies", never "devDependencies" — a production install (`npm ci --omit=dev`, the Next.js standalone Docker image) has to resolve it or `next build` fails.

Which specifier to write is the whole job here, and it is the npm mirror of what project_pkgdep.go decides for forge/pkg:

  • RELEASE flow: an ordinary semver range (webRuntimePublishedRange). A released forge scaffolds a project that resolves the package from the registry like any other dependency. A release build never emits a `file:` specifier — the path would be meaningless on the user's disk.

  • DEV flow: `file:<path-to-the-running-forge's-web-runtime>`. npm's file: protocol symlinks the directory into node_modules, so a dev forge's frontend resolves the package straight out of the checkout — edits to web-runtime/src land in the project with nothing published, nothing pushed and no reinstall. Because the dependency is DECLARED, `npm install` maintains the link instead of pruning it, which a bare symlink into node_modules could never survive.

The dev path is written RELATIVE ("../../../../forge/web-runtime") or home-anchored ("~/src/forge/web-runtime") — never absolute. An absolute path carries the maintainer's home directory and username into a committed file, and npm resolves both of the other forms natively. When neither form can be expressed without embedding one, forge writes no dev specifier at all and says why.

Package generator — the DEV bridge from a project's frontends to a local @reliantlabs/forge-web-runtime checkout.

This is the npm mirror of what forge already does for Go, and the shape is deliberately the same one:

Go: a gitignored `go.work` bridges the project to a sibling forge/pkg
    checkout. go.mod keeps its published pin. Nothing machine-specific is
    committed, and a clone that lacks the sibling still builds.

JS: a gitignored npm workspace ROOT (package.json + .forge-link/) bridges
    the project's frontends to a sibling forge/web-runtime checkout. Each
    frontend's package.json keeps its published range. Nothing
    machine-specific is committed, and a clone that lacks the sibling
    still installs.

This file describes the npm half. pnpm ignores package.json `workspaces`, so pnpm frontends — the scaffold default — get a gitignored root pnpm-workspace.yaml instead: frontend_webruntime_devlink_pnpm.go.

npm owns node_modules. A bare symlink dropped into node_modules/@reliantlabs/ has no corresponding edge in the dependency graph, so the next install that reshapes the tree replaces it with the registry copy and the developer silently goes back to testing published code. The link has to be something npm itself computes.

The previous design got that right and paid for it in the wrong currency: it rewrote the specifier to `file:<path>` inside frontends/<name>/package.json, a TRACKED file. That left every maintainer's `git status` permanently dirty, made the diff meaningless to review, and made it easy to commit a path that resolves only on a machine with a sibling forge checkout at exactly that depth — which breaks CI and every other developer.

The mechanism

A workspace root ABOVE the frontends, both parts of it gitignored:

<project>/package.json      { "workspaces": ["frontends/web", …, ".forge-link/*"] }
<project>/.forge-link/web-runtime -> ../../forge/web-runtime   (symlink)

npm reads the root, sees a workspace member whose package.json declares the name @reliantlabs/forge-web-runtime, and hoists it to <project>/node_modules as a link. The frontends' own `"^0.3.1"` requirement resolves against that member by NAME, so the tracked manifests never mention a path. Node's resolution walks up from frontends/<name>/, finds the hoisted link, and lands in the live checkout — verified by require.resolve pointing straight into forge/web-runtime/dist, and by an edit to that file being visible to the next import with no reinstall.

Three properties this buys that the alternatives do not:

  • The tracked package.json AND package-lock.json are both untouched. An in-frontend overlay (workspaces + a link dir inside the frontend) also keeps package.json clean, but npm records the member in the frontend's OWN package-lock.json — measured — so the churn simply moves to the other tracked file. Hoisting to a root the frontend does not own is what keeps both clean.
  • A stale registry copy already installed under frontends/<name>/node_modules does not shadow the link: the root install REMOVES it (measured: "added 2 packages, and removed 5 packages", after which require.resolve pointed at the checkout).
  • `npm install` run from INSIDE a frontend still honours the root workspace and leaves the nested lockfile byte-identical (measured).

The symlink points at the checkout with a RELATIVE path, and the root manifest names no path at all, so — as with `go.work` — even these ignored files carry no username and no home directory.

Everything here FOLLOWS the Go bridge rather than deciding on its own: the twin exists only while the project's go.work bridges a forge checkout (the opt-in record `forge project new --link-forge` or `go work use` writes), and it links that same checkout's web-runtime. It used to be laid down by every dev build of forge, for whatever checkout that build came from — so a project nobody had asked to bridge got .forge-link/ on its first generate. The workspace root is a maintainer's dev-loop artifact; scattering one into a project that did not opt in changes how its install resolves for no benefit.

The pnpm twin of the dev web-runtime bridge.

pnpm reads its workspace from pnpm-workspace.yaml and never from package.json `workspaces`, so the npm workspace root above does nothing for a pnpm frontend: under a dev forge it silently installed the REGISTRY web-runtime. The twin is a second gitignored root file:

<project>/pnpm-workspace.yaml
    packages:  [frontends/web, …]
    overrides: {"@reliantlabs/forge-web-runtime": "link:./.forge-link/web-runtime"}
<project>/.forge-link/web-runtime -> ../../forge/web-runtime   (the same symlink)

A root `pnpm install` makes frontends/<name>/node_modules/@reliantlabs/ forge-web-runtime a symlink into the checkout. Measured on pnpm 12:

  • the frontend's tracked pnpm-lock.yaml stays byte-identical (the root writes its own, gitignored, pnpm-lock.yaml);
  • the checkout's own node_modules is not touched — `link:` installs nothing into its target, where a workspace MEMBER would get its dependencies installed into the forge checkout;
  • a later `pnpm install --frozen-lockfile` inside the frontend keeps the link.

A link, not a membership, is also why no peer-dedup work is needed beyond what the npm bridge already relies on: the runtime is a symlink to a tree with its own node_modules in both, and next.config.ts / vite.config.ts / the tsconfig peer pins redirect its peers to the app's copy either way.

Each scaffolded pnpm frontend is its own workspace root (its overrides and allowBuilds live in its own pnpm-workspace.yaml), and pnpm installs at the NEAREST one, so a plain `pnpm install` in the frontend never sees this root. forge's installs run at the root instead (frontenddeps.PnpmInstallRoot); the root carries every member's overrides and allowBuilds, because a member's own settings are not read when the root installs it.

Only frontends that ARE their own pnpm root are members. A frontend of the frontend-workspaces layout has no pnpm-workspace.yaml of its own — the project's TRACKED root file is its workspace — and that root is the user's, so the bridge stays out of it.

Code generated by forge. DO NOT EDIT. forge:hash=27f671322a066af4ad209e0d8bddb09b186fede4c2e049dd70bf5f3afa25589a forge-owned: regenerated every run — do not edit (forge project disown to take ownership) Source: contract.go in this package.

To customize: edit contract.go (the interface IS the public surface) and re-run "forge generate". This file is regenerated unconditionally.

Package generator — forge dependency resolution for scaffolded go.mod files.

Generated projects import github.com/reliant-labs/forge/pkg/* (serverkit, appkit, orm, ...). Those are packages inside the SINGLE forge module — forge/pkg stopped being a companion submodule — so a scaffold pins ONE requirement, `require github.com/reliant-labs/forge vX.Y.Z`, in BOTH go.mod and gen/go.mod, with no replace and no vendoring; `go mod tidy` resolves it from the module proxy like any other dependency.

WHICH VERSION.

  • A binary a module proxy can serve (a release tag, or the clean pseudo-version `go install .../cmd/forge@<ref>` records) pins ITSELF: the version that generated the code and the one it compiles against are the same number.
  • A binary no proxy can serve (a local `go build`, any dirty tree) pins the newest RELEASE its source descends from — buildinfo.PublishedFloor — and the scaffold bridges it to this checkout with go.work (writeDevForgeGoWork in internal/cli/new.go), which decides what the build actually compiles.

THE REQUIRE MUST NEVER BE EMPTY. It used to be for the second case, on the theory that pinning nothing is safer than pinning a version the generated code might outrun. It is not: `go mod tidy` ignores go.work, so a module importing forge/pkg/* with no forge requirement makes tidy search the proxy for a module providing that import path, and the longest match is the RETIRED github.com/reliant-labs/forge/pkg module. tidy wrote `forge/pkg v0.1.15` into both go.mod files, the next `forge generate` refused the project, and `forge env up` — which runs `go mod tidy -diff` — reported "go module graph is stale" on every start, because gen/'s tidy was never clean.

The floor cannot reintroduce the old `undefined: testkit.StubNotConfigured` failure silently: with the bridge in place it compiles against source, and without one generate's pre-codegen check (internal/cli/generate_pkg_compat.go) refuses an unreleasable binary generating into a published pin before any file is touched.

The scaffold-once advisory lane of `forge project upgrade`.

Forge writes three kinds of file:

  • Tier-1 — re-rendered every `forge generate`, self-certifying, gated by the drift probe (drift.go).
  • Tier-2 managed — written once, checksum-protected, and kept current by `forge project upgrade` when the on-disk copy is still a pristine render (buf.yaml, Taskfile.yml, Dockerfile, .golangci.yml, …).
  • scaffold-once ("yours") — written once at birth with NO marker, and never touched again. The frontend mechanism modules (src/lib/query-client.ts, src/lib/events.ts, src/hooks/*) and everything under .github/ are this tier.

The third tier had no feedback path at all. When forge IMPROVED one of those templates, every existing project stayed on its birth copy and nothing anywhere said so. That is not hypothetical: a shipped query-client.ts kept a blanket `retry: 1` that retried 4xx long after the template replaced it with an `isRetryable` predicate, and the events.ts it depends on was missing the `emitToast` the new client imports. Both were found by a human reading templates, not by forge.

This file closes that loop WITHOUT breaking the ownership contract:

report, never apply.

An advisory row is rendered from the current template and compared with what is on disk. A difference is printed — never written — unless the user names that exact path after --force. Bare --force deliberately does NOT reach this tier: a flag that names nothing cannot express intent about a file forge handed over at birth, and the whole point of the tier is that adoption is the user's call.

Membership is derived, not hand-listed. The frontend rows are exactly the files that win composition from the SHARED template roots (templates.FrontendTemplateRoots) — the roots whose stated job is "generic mechanism modules … rendered into ALL frontend kinds". A new shared mechanism module is covered the day it lands, with no second registry to forget to update; forgetting to update a registry is the bug class this whole file exists to fix.

A row may only be added when the project has exactly ONE renderer for that path. The GitHub Actions workflows used to fail that rule: forge rendered .github/workflows/* from two config→data mappings (one per command) that disagreed on a project seconds old — a whole docker-build job in ci.yml, the env order in deploy.yml. They now share one, CIWorkflows (ci_workflows.go), pinned byte-identical by TestCIWorkflows_NewAndGenerateRenderIdentically, and are rows too (CIWorkflowAdvisoryFiles) — supplied by internal/cli, because their render needs a KCL render of every env.

They had to be. With no row, a defect fixed in a workflow template never reached a single existing project: ci.yml shipped `cancel-in-progress: true` for every ref, so on a busy runner pool each merge cancelled the main run before it, and control-plane's main went 2.5 hours without one completed run (control-plane#831). The fix to the template changed nothing for the projects that already had the file.

The non-workflow .github starters and pre-commit.yml have a single renderer each and are in AdvisoryFilesFor directly.

Per-kind frontend membership for the scaffold-once advisory lane.

The lane began by covering only the SHARED template roots, on the reasoning (still correct) that a shared root is forge's own declaration of "this file is generic mechanism I own the design of". That is a sound rule for deciding what forge should OWN. It is the wrong rule for deciding what forge should REPORT, and the difference is the bug this file fixes.

The symptom: frontends/<name>/eslint.config.mjs is written from internal/templates/frontend/<kind>/eslint.config.mjs and then never mentioned again by any forge command. Hand-edit it and `forge project upgrade --check` is completely silent — while .golangci.yml, the same kind of lint config on the backend side, prints a diff and both remedies because it happens to be a Tier-2 managed file. Same class of file, same user gesture, opposite outcome, decided by which registry it landed in.

The rule applied here is deliberately generic, and NOT about lint files:

if forge wrote it from a template, its drift is reportable.

Registering eslint.config.mjs by name would have fixed the reported symptom and left the bug — the next per-kind template forge adds would be invisible in exactly the same way. So membership is DERIVED from the template tree, and what this file states instead is the far smaller set of exclusions, each of which is a file another lane already renders.

── Reporting a file is not claiming it ───────────────────────────────

Nothing here changes ownership. These files stay scaffold-once: no forge:hash marker, no manifest entry, never written unless the user names the exact path after --force. The advisory lane reports and does not apply, which is what makes widening it safe — the cost of a new row is a diff the user can ignore, not a file forge starts overwriting.

── Why the exclusions are exclusions ─────────────────────────────────

The lane's standing admission rule is that a row may exist only when the project has exactly ONE renderer for that path. That rule is why the .github WORKFLOWS are absent (two config→data mappings disagree about them on a project seconds old, so reporting the difference would report forge's own inconsistency as the user's staleness). The same rule excludes exactly three groups here, and nothing else:

  • Tier-1 *_gen.* files (apiurl_gen.ts, basepath_gen.ts, otel_gen.ts). Rewritten every `forge generate` and guarded by the drift probe. A row would duplicate a check that already exists and is stricter.
  • Inventory-seeded files (nav.tsx, dashboard.tsx, page.tsx). The nav generator re-renders these from the DISCOVERED entity set (generate_frontend_nav.go). This lane has the frontend config but not the inventory, so its render would show empty routes against a real project's populated ones — a large, permanent, entirely bogus diff.
  • src/gen/ and the mock surface, written per-entity from a different registry for the same reason.
  • Files the MANAGED lane registers (upgrade_frontend_managed.go — today, eslint.config.mjs). Two lanes, one file: `--check` listed it twice, once as a managed Tier-2 entry and once as an advisory row. Cosmetic in the report, but the two lanes disagree about who owns the file, and they do not offer the same thing — managed refreshes a pristine copy and offers `disown`, advisory only ever reports. The managed lane wins because it deliberately claimed the file (see that file's header: symmetry with .golangci.yml, and it is the only lane through which a template improvement actually ARRIVES).

Everything else in the per-kind tree renders from the frontend's own config, which this lane has, so it is admitted.

Per-frontend Tier-2 managed files.

`forge project upgrade` has two lanes, and the difference between them is the whole point of this file:

the ADVISORY lane (upgrade_advisory.go) REPORTS. It says "the template
moved on, here is the diff" and never writes unless the exact path is
named after --force. It reports staleness in one direction only —
template content the file lacks — so a file the user merely ADDED to is
silent, and there is no `disown`, because a file forge handed over at
birth was never forge's to be disowned from.

the MANAGED lane (upgrade.go) TRACKS. A pristine render is refreshed
automatically on upgrade; an edited one is reported as `user-modified
(skipped)` with a diff and BOTH remedies — `--force <path>` to adopt,
`disown` to claim.

frontends/<name>/eslint.config.mjs was in NEITHER, which is the reported bug: hand-edit it and no forge command mentions it, ever. `.golangci.yml` — the same kind of file on the backend side — is a Tier-2 managed file and reports exactly as described above.

It is registered here in the MANAGED lane rather than the advisory one for two reasons, and the second is load-bearing:

  1. Symmetry with .golangci.yml is what was actually asked for: the same file class should behave the same way on both sides of the stack, including the two remedies. The advisory lane cannot offer `disown` and cannot report a pure addition.

  2. It is the only lane that makes template improvements ARRIVE. An advisory row tells you a rule exists; a managed file DELIVERS it to every project whose copy is still pristine. That distinction decided the design of the frontend process.env guardrail, which documented the constraint precisely: "eslint.config.mjs is SCAFFOLD-ONCE … not in generator.managedFiles(), so `forge project upgrade` never refreshes it — a rule added there would reach projects created after this change and NO existing project". Registering it here is what retires that objection.

── Tier 2, not Tier 1 ────────────────────────────────────────────────

A lint config is a file users legitimately edit — a project-specific rule, a disabled check, an extra ignore. Tier-1 would stomp that every generate. Tier-2 is the tier whose contract is exactly "keep it current while it is untouched, and never stomp an edit": reporting drift is not the same as forge owning the file.

── Why these entries carry a render closure ──────────────────────────

Every other managedFile names a template in the project/ category and writes to a fixed path. These are neither: the template is one per frontend KIND (internal/templates/frontend/<kind>/eslint.config.mjs) and the destination is one per declared FRONTEND. So the entries are generated per frontend with their render attached — see managedFile.render.

Index

Constants

View Source
const (
	// Tier1 files are always overwritten by forge generate and gitignored.
	// These are pure infrastructure, 100% derivable from forge.yaml.
	Tier1 = 1
	// Tier2 files are checksum-protected and committed to git.
	// Overwritten only if the user hasn't modified them.
	Tier2 = 2
)

File ownership tiers.

View Source
const DefaultServiceProtoPackagePattern = "services." + config.ProtoPackageServicePlaceholder + ".v1"

DefaultServiceProtoPackagePattern is the proto package a scaffolded service gets when the project has said nothing — no `api.proto_package` in forge.yaml and no majority convention among its existing service protos. {service} is the snake_case service package (naming.ServicePackage).

View Source
const FixtureMarkerDir = ".forge/fixtures"

FixtureMarkerDir holds, per frontend, the record of what the last `forge generate` IN THIS CHECKOUT wrote its mock fixtures from. It is under .forge/, which every forge project ignores, so it is never committed.

WHY NOT COMMITTED. The record used to be SEED_FINGERPRINT in the committed fixture-manifest_gen.ts: one digest over every migration. Every branch that added a migration rewrote that one line, so any two such branches conflicted there — control-plane's manifest conflicted in 45 merges in the week to 2026-10-30 — and a digest of a list cannot be split per input, because two new migrations always sort to the same place (the end). It is tooling-only state, needed by no compiler, so it follows the descriptor out of git (#1572): the committed manifest keeps only constants, and the fingerprint lives here.

The guard stays useful without it. The marker also records the bytes of the fixture files it describes, so the guard can tell its two cases apart:

  • the fixtures on disk are the ones this checkout's last generate wrote, and the seed inputs moved since → STALE, fail with the fix;
  • no marker (a fresh clone, CI), or fixtures that differ from what the marker recorded (pulled or checked out since) → cannot verify; skip, naming `forge generate`. Committed fixtures are checked against their inputs by `forge ci verify-generated`, which regenerates them.
View Source
const ForgeDeployActionPath = ".github/actions/forge-deploy/action.yml"

ForgeDeployActionPath is where the vendored composite action lands (hosted-deploy-primitives §3.6, owner ruling Q5: vendored per repo, not published, so it versions with the project's forge pin).

View Source
const WebRuntimePackage = "@reliantlabs/forge-web-runtime"

WebRuntimePackage is the npm package name of forge's frontend runtime library.

Variables

This section is empty.

Functions

func AdvisoryPaths added in v0.1.45

func AdvisoryPaths(files []AdvisoryFile) []string

AdvisoryPaths returns the project-relative paths of a row set, in order. `forge project upgrade --force <path>` validates its arguments against this plus the Tier-2 managed set.

func CIWorkflowAbsenceReason added in v0.1.45

func CIWorkflowAbsenceReason(relPath string) string

CIWorkflowAbsenceReason explains why a project has no file at a CI-mapper path, in the terms of the switch that would give it one.

func CIWorkflowDataFor added in v0.1.45

func CIWorkflowDataFor(root string, cfg *config.ProjectConfig, in CIInputs) templates.CIWorkflowData

CIWorkflowDataFor returns what the scaffolded ci.yml renders with: which lints, scanners and jobs this project has. `forge ci gate` plans its checks from the same struct, so a forge.yaml toggle means the same thing to the workflow and to the local gate.

func CIWorkflowFileFor added in v0.1.45

func CIWorkflowFileFor(projectDir string, cfg *config.ProjectConfig, in CIInputs, relPath string) (content []byte, ok bool, err error)

CIWorkflowFileFor renders one of the project's CI files through the single CI mapper, reporting ok=false when this project has no such file.

This is the authority rescaffold consults for .github/workflows/* and .github/dependabot.yml: CIWorkflows decides which workflows a project HAS (e2e.yml only with an e2e suite feature), and a staging render of `forge project new` must never smuggle in one it would not emit.

func CaseCollision added in v1.0.0

func CaseCollision(dir, fileName string) string

CaseCollision reports a file already in dir whose name equals fileName case-insensitively but not exactly — `Button.tsx` when forge is about to write `button.tsx`.

Such a pair is not two files on the filesystems that matter. macOS (APFS, HFS+) and Windows fold case, so the second write replaces the first, and the desktop app built on a Mac ships whichever landed last. On Linux the pair does coexist, and TypeScript refuses it instead: TS1261 "Already included file name differs from file name only in casing". reliant's web app has Button/Modal/Input/Card/Badge.tsx; forge's starter set installed button/modal/input/card/badge.tsx beside them, five TS1261s on the first generate. Every forge write of a component goes through this check.

Returns the colliding name, or "" when there is none (an unreadable dir has no entries to collide with).

func CaseCollisionError added in v1.0.0

func CaseCollisionError(dir, fileName, existing string) error

CaseCollisionError is the refusal for one colliding component write.

func EmitFixtureFreshnessSurface added in v0.1.45

func EmitFixtureFreshnessSurface(root, feRel string, fixtures int, files, cfg seedplan.Fingerprint, cs *FileChecksums) error

EmitFixtureFreshnessSurface writes the two files that make a stale fixture a LOUD failure instead of a silent one, into <feRel>/src/mocks/:

  • fixture-manifest_gen.ts — the provenance record: which schema the fixtures beside it were generated from.
  • fixture-freshness_gen.test.ts — a vitest that re-derives that fingerprint from db/migrations on disk and fails when the two disagree.

Why a fingerprint rather than fixtures computed at runtime

Computing the fixtures where they are USED would be strictly better, and it is not possible. Their values come from seedplan.Plan, which is built from []schemadef.Table, which exists only by applying db/migrations to a real postgres and introspecting it. The two consumers — a browser in mock mode and vitest under `npm test` — have no database and, in the browser's case, cannot have one. Emitting the fixtures as literals is therefore forced.

What is NOT forced is emitting them with no way to tell when they went out of date. The values need a database; the QUESTION they answer is a hash of two files, and anything that can read the project directory can recompute that. So the fixtures stay a cache, and this is the cache's validity tag: a schema edit without a regenerate now fails `npm test` (and `task test` in CI) with the fix command, instead of silently serving rows for a schema the project no longer has.

Why it is emitted only beside fixtures

fixtures is how many per-entity fixture modules EmitFrontendMockSurface just wrote for this frontend. With none, the pair is not emitted, and a copy left by an earlier run is retired through the ownership test (codegen. RetireGenerated): a certified copy goes, a disowned or hand-edited one stays.

The guard is a cache's validity tag, and a frontend with no fixtures has no cache. That is the common shape of an ADOPTED frontend: its services are custom rpcs with no CRUD entity, so its mock transport dispatches nothing from src/mocks/<entity>_gen.ts. Emitting the pair anyway did not check anything; it made every migration a change to a generated frontend file, and a branch that merged main's migrations without a regenerate turned the frontend's `npm test` red over a fingerprint that described nothing. With parallel lanes landing migrations, that was every few merges.

The file set therefore changes shape when the first entity fixture lands — the same generate that writes the fixtures writes their tag.

files is the digest of the seed INPUT FILES alone, and cfg the same digest with the seed config folded in (codegen.SeedFingerprints returns the pair). They are separate parameters, and separate constants in the emitted manifest, because only ONE of them is reproducible without a database:

  • files is what the TypeScript guard recomputes. It hashes migrations and vocab.yaml, which a vitest process can read.
  • cfg additionally covers the salt and row counts out of forge.yaml, which the guard has no way to reach.

Recording cfg where the guard expects files makes the comparison fail on every clean tree. That is not hypothetical: this shipped that way once, and `npm test` on a freshly generated project reported STALE MOCK FIXTURES against fixtures that had just been written.

func EmitFrontendMockSurface added in v0.1.45

func EmitFrontendMockSurface(root, feRel string, services []codegen.ServiceDef, entities []codegen.EntityDef, seed *codegen.SeedProjection, cs *FileChecksums) (int, error)

EmitFrontendMockSurface writes the mock/scenario surface for ONE browser frontend, rooted at root (the project directory) under the project-relative feRel (e.g. "frontends/dashboard"), and returns the number of per-entity fixture files written.

Every file it emits except the seeded `scenarios/default.ts` is Tier-1 — it carries the "Code generated by forge. DO NOT EDIT." banner and IS rewritten on every `forge generate` — so all of them go through the certification chokepoint (WriteGeneratedFileTier1) that stamps the embedded forge:hash marker. Until they did, the header on every one of them named a command that refused the file:

forge project disown frontends/web/src/mocks/products.ts
Error: 1 path(s) carry no forge certification …
Fix: … scaffold-once files are yours from birth

— an answer that was false about a file forge overwrites on every run, while the drift lint reported "no hand-edits to forge-generated files" for a tree in which every one of these had been hand-edited. A plain os.WriteFile emits the claim without the evidence for it.

The surface is:

  • src/lib/mock-transport.ts — the transport carrying scenario dispatch, hybrid passthrough, and the Unimplemented fallback.
  • src/mocks/<entity>_gen.ts — per-entity fixtures, carrying the values the project's own seed plan writes into the dev database for those rows. There is deliberately no barrel over them: mock-transport_gen.ts imports each module by name (see retireMocksIndex).
  • src/mocks/scenario-types.ts, src/mocks/scenario-rpcs_gen.ts, and src/mocks/scenarios/{default,index}.ts — the scenarios scaffolding. See docs/adr/0002-frontend-scenarios.md.

It has TWO callers, and the split is the point:

  • GenerateFrontendFilesWithOptions calls it with no services/entities as part of the scaffold. `connect.ts` references "@/lib/mock-transport" statically (Vite ESM) or via a literal `require()` (Next.js/webpack), so a scaffold that omits the module leaves a tree that does not typecheck (`tsc`: TS2307) and does not build (`next build`: "Module not found"). The scaffold emits what the scaffold imports.
  • The `forge generate` frontend-mocks step calls it with the project's parsed services + entities, re-rendering the same files with the real fixture dispatch table.

seed is the project's dev dataset (codegen.BuildSeedProjection), the source every per-entity fixture value is read from so the mocks and the seeded database hold the same rows. The scaffold-time caller has no entities and passes nil.

Scenario support is DECOUPLED from entity-CRUD: the transport's scenario-dispatch pipeline depends only on the scenarios scaffolding (always emitted), not on entity fixtures. The per-entity fixture switch is the ONLY part gated on entities — with none, the template omits that section, leaving a fully scenario-capable transport (never a do-nothing stub that would silently drop the scenario mechanism) that also carries no `@/gen/...` imports, so the scaffold-time render stands alone before the TypeScript stubs exist.

func EmitScenarioScaffolding added in v0.1.45

func EmitScenarioScaffolding(root, mocksRel string, services []codegen.ServiceDef, cs *FileChecksums) error

EmitScenarioScaffolding writes the scenarios files under the project-relative <mocksRel>/scenarios/ inside root:

  • scenario-types.ts (Tier-1 — the contract type)
  • scenario-rpcs_gen.ts (Tier-1 — typed per-RPC handler map derived from the proto descriptor; empty-but-valid when the project has no services yet)
  • default.ts (scaffold-once; yours from birth, safe to hand-edit)
  • index.ts (Tier-1 — regenerated by walking the directory for *.ts)

The tier split is what each file's own header already claims, and the writer is now the thing that makes the claim true: the three Tier-1 files go through WriteGeneratedFileTier1 (stamped, disownable, drift-covered) and default.ts through WriteScaffoldIfMissing (written once, never touched again).

scenario-types.ts and scenario-rpcs_gen.ts are emitted one level up at <mocksRel>/ so user scenarios can `import { defineScenario } from "../scenario-types"`. The regeneration is idempotent: running `forge generate` twice produces the same output.

User-authored scenarios (created with `forge scaffold scenario`) are NEVER touched here — the registry just re-exports them.

func EnsureBrowserTelemetryDependencies added in v0.1.45

func EnsureBrowserTelemetryDependencies(projectDir, feRelDir, feName string)

EnsureBrowserTelemetryDependencies declares the packages the generated src/lib/otel_gen.ts needs in a web frontend's package.json.

A project scaffolded before web-runtime 0.4.0 has none of them, and `forge generate` rewrites otel_gen.ts (Tier-1) to import the SDK, so without this the first build after upgrading fails to resolve "@hyperdx/browser".

Add-only: a version the project already declares is its own decision, and the peer is exact-pinned, so a different one is reported by npm, not rewritten here. The specifier is the runtime's own (webruntimepeers.PeerSpec).

func EnsureCoreComponents

func EnsureCoreComponents(frontendDir string) error

EnsureCoreComponents installs any missing core components into an existing frontend directory's src/components/ui/. See EnsureCoreComponentsIn.

func EnsureCoreComponentsIn added in v1.0.0

func EnsureCoreComponentsIn(frontendDir, componentsRel string) error

EnsureCoreComponentsIn installs any missing core components into componentsRel (relative to frontendDir; "" = src/components/ui). Safe to call repeatedly.

Each component is scaffold-once by the birth ledger, not by presence: one the user deleted stays deleted, the way every other "yours" file does. Outside a forge project there is no ledger, and presence decides. A component whose name collides case-insensitively with a file already in the directory is refused (CaseCollision) while the rest still install; the refusals come back together as one error.

func EnsureDevWebRuntimeLink(projectDir string)

EnsureDevWebRuntimeLink reconciles the gitignored dev bridge for projectDir.

No-op unless the project's Go build is bridged to a forge checkout and that checkout actually carries web-runtime. Every failure is a warning: a bridge is a convenience for forge maintainers, and no dev-loop nicety justifies failing somebody's generate.

func EnsureScaffoldNPMExemptions added in v0.1.46

func EnsureScaffoldNPMExemptions(projectDir, frontendType string, now time.Time)

EnsureScaffoldNPMExemptions makes forge.yaml accept the no-fix npm advisories a newly scaffolded frontend of frontendType carries, so the project's vuln-npm gate is green on the day the frontend is born.

It only ever ADDS, and only what is missing: an id the project already lists — accepted, disputed with a different reason, or re-dated — is the project's decision and is left exactly as it is.

forge.yaml is the user's file, so the edit is an append of a whole `ci:` section, made only when the file has no `ci:` section yet; that cannot reorder, reformat or lose anything already there. When a `ci:` section exists, merging into it would mean re-serializing the user's YAML, so forge prints the entry to paste instead and touches nothing. Every failure is a printed warning: no bookkeeping here justifies failing a scaffold.

func EnsureWebRuntimeDependency added in v0.1.45

func EnsureWebRuntimeDependency(projectDir, feRelDir, feName string) bool

EnsureWebRuntimeDependency reconciles the @reliantlabs/forge-web-runtime entry in frontends/<name>/package.json so that npm — not forge — owns the link under node_modules.

It runs at scaffold time (before the first `npm install`, so the very first install resolves the bridge) and again on every `forge generate`, which is what keeps it honest when a project is moved on disk or picked up by a differently-built forge. The edit is surgical: the entry's VALUE is replaced in place, so a hand-formatted, hand-extended package.json survives untouched, and a run that changes nothing writes nothing.

Every failure is a warning. A frontend whose package.json forge cannot parse is the user's file to fix, and no dependency bookkeeping justifies failing their build.

It reports whether it rewrote the manifest.

func EnsureWorkspaceHooksWebRuntimeDependency added in v0.1.45

func EnsureWorkspaceHooksWebRuntimeDependency(projectDir string) bool

EnsureWorkspaceHooksWebRuntimeDependency reconciles the same entry in packages/hooks/package.json — the workspace-mode home of the generated hooks. Those hooks type every error as ConnectClientError, imported from the runtime package, and a type-only import still has to RESOLVE: under pnpm's strict node_modules an undeclared package is simply not there. Declaring it is what makes the workspace layout typecheck.

No-op when the manifest does not exist, so a non-workspace project calls it harmlessly.

func FixtureMarkerPath added in v1.0.11

func FixtureMarkerPath(feRel string) string

FixtureMarkerPath is the project-relative marker of the frontend at feRel.

func FrontendConfigMessageName added in v0.1.45

func FrontendConfigMessageName(frontend string) string

FrontendConfigMessageName is the proto message declaring a frontend's config. It names the generated KCL schema and the TypeScript type, so it is derived once here rather than spelled independently at each site.

func FrontendConfigProtoRelPath added in v0.1.45

func FrontendConfigProtoRelPath(layout configproto.Layout, frontend string) string

FrontendConfigProtoRelPath is where a frontend's config message is declared, relative to the project root: <name>_config.proto beside the project's root config.proto, in layout.Dir().

ONE FILE PER CONSUMER, ONE SHARED PACKAGE. The file is per-frontend so that adding a frontend WRITES a new file rather than editing a file that already has content in it — which is what makes `forge scaffold frontend` safe on an existing project without parsing or rewriting anyone's proto.

The DIRECTORY is shared (layout.Dir(), proto/<project>/config/v1/), so every config message stays in the project's one config package and therefore in one generated Go package. That is not tidiness: buf's STANDARD lint enforces PACKAGE_DIRECTORY_MATCH, so a per-consumer directory forces a per-consumer proto package, which forces a per-consumer Go package — and pkg/config/config_gen.go imports exactly one generated config package and names every config message through it. A directory-per-consumer layout does not merely reorganize files; it stops the generated Go config shim from compiling.

Sharing the package also keeps shared DEFINITIONS free: a block declared in config.proto composes onto a message here by bare name, with no cross-package import, so one fact declared once projects into both the backend's config and the browser's.

The base name goes through naming.GoPackage, not naming.ToSnakeCase. ToSnakeCase splits camelCase/PascalCase boundaries but passes a HYPHEN straight through, and `forge scaffold frontend` accepts hyphenated names (ValidateFrontendName rejects only a leading "-"). A frontend named "internal-console" therefore produced "internal-console_config.proto", which buf's STANDARD lint rejects with `Filename ... should be lower_snake_case.proto` — a red `buf lint` in the generated CI workflow on the project's first push, fixable only by renaming the frontend or hand-editing a forge-owned path. GoPackage is the canonical CLI-name → snake_case identifier rule (hyphens fold, repeated separators collapse), already used for the Go package, the KCL binding and the handler dir, so routing through it keeps this filename agreeing with them by construction.

func FrontendDependencyNotices added in v1.0.0

func FrontendDependencyNotices(projectDir, feRelDir string) []string

FrontendDependencyNotices is the read-only counterpart of EnsureWebRuntimeDependency + EnsureBrowserTelemetryDependencies, for a frontend whose package.json the project DISOWNED (`forge project disown <frontend>/package.json`): it reports what those would change instead of changing it, one line each.

It used to be what every ADOPTED frontend got. That left an adopted app below the runtime floor after each forge bump that raised it — reliant's web app regenerated hooks calling runtime APIs its declared range did not have, and had to bump the range by hand every time. Adopted frontends are reconciled like scaffolded ones now; disowning the manifest is the opt-out. A dev forge build's `file:` bridge is never reported: it is machine-local by definition.

func FrontendTypeHasMockSurface added in v0.1.45

func FrontendTypeHasMockSurface(feType string) bool

FrontendTypeHasMockSurface is frontendKindHasMockSurface keyed by the forge.yaml `frontends[].type` (nextjs / vite-spa / react-native) rather than by the `--kind` flag the scaffold takes. Used by the `forge generate` frontend-mocks step, which reads the persisted config. An unrecognised type gets nothing — the surface is rendered from templates that only the two browser kinds import.

func GenerateBinaryFiles

func GenerateBinaryFiles(root, modulePath, binaryName string) error

GenerateBinaryFiles emits the files that make up a non-server long-running binary scaffold:

  • cmd/<package>/main.go thin standalone main (own cmd/<bin>/ tree)
  • internal/<package>/<package>.go main loop with Start/Stop/Run lifecycle
  • internal/<package>/contract.go Deps, Service interface, New(deps)
  • internal/<package>/<package>_test.go basic lifecycle test

The shape is deliberately opinionated: the scaffold solves the boilerplate (signal handling, graceful shutdown, config loading) so the user can hand-write only the binary's actual business logic.

Each binary gets its OWN cmd/<bin>/ tree (devspace idiom): the primary server binary lives under cmd/<server>/ with the full command tree, and each secondary binary added here gets cmd/<package>/main.go — a thin, self-contained main rather than a subcommand of the server. At deploy time KCL emits one Deployment per binary, each running its own image.

CLI/display name (which may contain hyphens) is translated to a Go- package-safe form for the directory and `package` declaration; the hyphenated form is preserved on the cobra `Use:` field.

func GenerateCRDFiles

func GenerateCRDFiles(in CRDGenInput) error

GenerateCRDFiles scaffolds a single CRD into an existing operator:

  • api/<version>/<lower-name>_types.go — CRD spec/status types
  • internal/operators/<operator>/<lower-name>_controller.go — thin shim
  • internal/operators/<operator>/<lower-name>_controller_test.go — fake-client unit test

The operator must already exist; generation is idempotent in the sense that re-running with the same inputs overwrites the three generated files (no merge), but it does NOT rebuild the operator's types.go (placeholder CRD or otherwise).

func GenerateE2ETests

func GenerateE2ETests(projectDir, serviceName, modulePath, projectName string, methods []E2EMethodInfo) error

GenerateE2ETests renders E2E test templates into e2e/<servicePackage>/ under projectDir. It does not overwrite existing files — only creates new ones.

func GenerateFrontendFiles

func GenerateFrontendFiles(root, modulePath, projectName, frontendName string, apiPort int, kind string) error

GenerateFrontendFiles generates the frontend directory and files. kind selects the template set: "" or "web" for Next.js, "mobile" for React Native. Both the "new" project flow and the "scaffold frontend" flow delegate here so the output is always identical.

This thin shim preserves the original signature for backward compatibility with the Service contract; new call sites should prefer GenerateFrontendFilesWithOptions when they have access to the project-level Frontend config.

func GenerateFrontendFilesWithOptions

func GenerateFrontendFilesWithOptions(root, modulePath, projectName, frontendName string, apiPort int, kind string, opts FrontendGenOptions) error

GenerateFrontendFilesWithOptions is GenerateFrontendFiles with an extra FrontendGenOptions param for project-level toggles (today only Workspaces). The default-zero opts struct produces output byte-identical to GenerateFrontendFiles so existing callers and snapshot tests are unaffected.

func GenerateOperatorBinaryOnly

func GenerateOperatorBinaryOnly(root, modulePath, name, group, version string) error

GenerateOperatorBinaryOnly scaffolds an operator package WITHOUT a placeholder CRD. CRDs are added via `forge scaffold crd <name>` instead, which produces per-CRD shims that delegate to forge/pkg/controller.

The generator emits two files:

  • internal/operators/<package>/doc.go — package documentation that explains the operator's role and points users at `forge scaffold crd`.

  • internal/operators/<package>/operator.go — the wiring symbols (Controller, Deps, New, AddToScheme) that the bootstrap template expects every operator package to export. The Controller type is a thin holder for the manager-level deps and is not itself a reconciler — per-CRD reconcilers live in <crd>_controller.go and are wired in cmd/<op>/main.go.

The operator's main.go (the cmd binary entrypoint) is generated by the standard project bootstrap step.

func GenerateOperatorFiles

func GenerateOperatorFiles(root, modulePath, name, group, version string) error

GenerateOperatorFiles generates all files for a single operator:

  • internal/operators/<package>/types.go (from operator/types.go.tmpl)
  • internal/operators/<package>/controller.go (from operator/controller.go.tmpl)
  • internal/operators/<package>/controller_test.go (from operator/controller_test.go.tmpl)

The CLI/display name (which may contain hyphens) is translated to a Go-package-safe form for the directory and `package` declaration.

Both the "new project" and "scaffold operator" flows delegate here so the generated output is always identical.

func GenerateOperatorFilesWithAPI

func GenerateOperatorFilesWithAPI(root, modulePath, name, group, version, apiPackage, crdType string) error

GenerateOperatorFilesWithAPI is GenerateOperatorFiles with explicit overrides for the CRD type's Go package name and type name. This is the path used when the operator name and CRD type diverge — e.g. a `workspace-controller` operator reconciling a `Workspace` CRD. When apiPackage and crdType are both empty this behaves identically to GenerateOperatorFiles.

apiPackage selects the Go-package directory + `package` declaration for the CRD types. When empty, the CRD types live in the operator's own package (the original shape). When set, types are written to api/<version>/<apiPackage>/types.go with `package <apiPackage>`, and the controller imports them from there. crdType selects the Go type name; when empty it defaults to PascalCase(apiPackage) when apiPackage is set, or PascalCase(name) otherwise (preserving original behaviour).

func GeneratePlanORM

func GeneratePlanORM(root string, orm config.ORMConfig, entities []config.PlanEntity, cs *FileChecksums) error

GeneratePlanORM writes internal/db/<snake_name>_orm.go for each entity defined in the plan. The generated files provide a Bun-tagged entity struct (a projection of the applied schema) and standalone CRUD functions built on Bun's query builder (reached via orm.Context.Bun()). CRUD is emitted as package-level functions that take an orm.Context, so the same functions run inside or outside a transaction.

cs is the project's checksum tracker: every output is recorded as a Tier-1 manifest entry so `forge project disown` can transfer ownership of an ORM file (kalshi fr-4dfef712e9: disown refused on internal/db/*_orm.go because nothing registered them) and `forge project audit` doesn't see the files as orphans. A nil cs is tolerated (files written, not tracked).

orm places the projection (database.orm.package, default internal/db): every file lands in that directory and declares its package.

func GeneratePlanProtoFile

func GeneratePlanProtoFile(root, modulePath, serviceName string, rpcs []config.PlanRPC, entities []config.PlanEntity) error

GeneratePlanProtoFile writes a populated proto file for a service based on plan RPC definitions and entity messages. It overwrites any existing proto stub at proto/services/<name>/v1/<name>.proto.

When entities are provided, their message definitions are emitted inline in the service proto (before the RPC request/response messages) so that CRUD RPCs can reference them by bare name.

When forge.yaml at `root` declares `api.rest: true`, CRUD-prefixed RPCs (Get/List/Create/Update/Delete<Entity>) are emitted with default `google.api.http` annotations so vanguard can transcode REST↔Connect at runtime. Non-CRUD RPCs are left unannotated and need the user to add annotations by hand. The `google/api/annotations.proto` import is added to the proto only when at least one CRUD RPC was annotated.

func GenerateServiceFiles

func GenerateServiceFiles(root, modulePath, serviceName, projectName string) error

GenerateServiceFiles generates all files for a single Go service:

  • handlers/<servicePackage>/service.go (from service/service.go.tmpl)
  • proto/services/<servicePackage>/v1/<servicePackage>.proto (inline stub, skipped if exists)

The display/CLI form of the service name (which may contain hyphens) is translated to a Go-package-safe form ("admin-server" -> "admin_server") for filesystem directories and Go/proto identifiers. Display strings keep the original spelling.

Both the "new project" and "scaffold service" flows delegate here so the generated output is always identical.

No handler file is emitted at scaffold time: with zero RPC methods there is nothing for one to contain. Once RPCs are added to the proto file, `forge generate` scaffolds each missing stub into its own user-owned rpc_<name>.go (codegen.GenerateMissingHandlerStubs).

There is no per-service port: every service mounts onto the one Connect mux the binary serves, and the listener reads the AppConfig `port` field (env PORT, default 8080) set per environment in deploy/kcl/<env>/config.k.

func GenerateServiceFilesWithMode

func GenerateServiceFilesWithMode(root, modulePath, serviceName, projectName string, mode ScaffoldMode, progress io.Writer) error

GenerateServiceFilesWithMode is the mode-aware form of GenerateServiceFiles. `mode` controls per-file overwrite/skip behavior so callers can support --resume (recover a partially-applied scaffold) and --force (re-stamp every output file). When non-nil, `progress` receives one human-readable line per scaffolding step so the CLI can render "skipped" / "overwriting" notices without re-implementing the existence check.

func GenerateWebhookFiles

func GenerateWebhookFiles(root, modulePath, serviceName, webhookName string) error

GenerateWebhookFiles generates all files for a webhook endpoint within a service:

  • internal/handlers/<service>/webhook_<name>.go (handler + signature verification + dedup)
  • internal/handlers/<service>/webhook_<name>_test.go (tests)

Every symbol a handler needs is either its own (uniquely named per webhook) or comes from forge/pkg/middleware, so each webhook is one self-contained pair of files and adding a second webhook to a service cannot collide with the first.

func GenerateWorkerFiles

func GenerateWorkerFiles(root, modulePath, workerName, kind, schedule string) error

GenerateWorkerFiles generates all files for a single worker:

  • internal/workers/<package>/worker.go (from worker/worker.go.tmpl or worker-cron/worker.go.tmpl)
  • internal/workers/<package>/worker_test.go (from worker/worker_test.go.tmpl or worker-cron/worker_test.go.tmpl)

The CLI/display name (which may contain hyphens) is translated to a Go-package-safe form for the directory and `package` declaration so hyphenated worker names like "email-sender" produce a buildable internal/workers/email_sender/ package.

When kind is "cron", the cron-specific templates are used and the schedule is embedded as a constant in the generated code.

Both the "new project" and "scaffold worker" flows delegate here so the generated output is always identical.

func HashContent

func HashContent(content []byte) string

HashContent returns the sha256 hex digest of content.

func InstalledDeclarationEntry added in v0.1.45

func InstalledDeclarationEntry(feDir, pkg string, hoisted bool) string

InstalledDeclarationEntry reads the declaration entry of pkg's TYPES package as installed under feDir's node_modules for the given layout, or "" when no manifest is there to read.

func IsCIMapperPath added in v0.1.45

func IsCIMapperPath(relPath string) bool

IsCIMapperPath reports whether relPath is a file the CI mapper owns — any GitHub Actions workflow template forge ships, plus dependabot.yml — whether or not THIS project has it.

func IsForgeGenerated added in v0.1.45

func IsForgeGenerated(content []byte) bool

IsForgeGenerated reports whether content carries forge's generated-file banner — a Tier-1 file `forge generate` re-renders, as opposed to a scaffold the user owns.

func IsForgeManagedGitHook added in v0.1.45

func IsForgeManagedGitHook(script []byte) bool

IsForgeManagedGitHook reports whether script is a git hook forge wrote (generateGitHooks) rather than one the project owns. It reads only the line after the shebang, where every forge hook carries forgeManagedHookHeader, so a project hook that merely mentions forge is still the project's.

Forge activates .githooks (core.hooksPath) only for its own hooks. An in-place adopted repository keeps the hooks it already had, and pointing git at them is that project's decision, made by its own setup step.

func IsFrontendManagedPath added in v0.1.45

func IsFrontendManagedPath(rel string) bool

IsFrontendManagedPath reports whether a project-relative path is a per-frontend managed file, by SHAPE rather than by membership.

The stale-artifact sweep needs this. That sweep deletes any file carrying forge's certification marker that the current `forge generate` run did not write: the pristine ones outright, the hand-edited ones under `--force-cleanup`. A Tier-2 managed file is stamped and is written by `forge project upgrade`, not by `forge generate`, so it lands in the sweep's crosshairs exactly as the command-tree files did — the data-loss incident UpgradeManagedPaths documents at length.

UpgradeManagedPaths answers that question from a static union over (kind, binary), computed with no project config. These paths depend on the frontends a project declares, so they cannot appear in that union. A shape test is the honest way to express "frontends/<any>/eslint.config.mjs belongs to the upgrade lane" without a config in hand.

func IsTier2Managed added in v0.1.45

func IsTier2Managed(relPath string) bool

IsTier2Managed reports whether relPath's canonical template tier is Tier-2 (user-owned after the first write). Hand-edits there are sanctioned and must never be reported as drift.

func ManagedPathsFor added in v0.1.45

func ManagedPathsFor(cfg *config.ProjectConfig) []string

ManagedPathsFor returns the project-relative paths `forge project upgrade` manages for THIS project, in manifest order.

UpgradeManagedPaths is the union over every (kind, binary) combination — the right question for the stale-artifact sweep, which must not flag a file just because detection guessed a different shape. This is the narrower question a user-facing path argument needs: "is this a file upgrade can write HERE", answered against the project's own kind, binary mode, and binary name.

func ReadProjectConfig

func ReadProjectConfig(path string) (*config.ProjectConfig, error)

ReadProjectConfig reads a forge.yaml from the given path with strict validation: unknown keys, missing required fields, and type mismatches are surfaced together via config.ValidationError rather than failing fast on the first issue. forge.yaml is project-global only: what the project CONTAINS is read from the code that declares it (codegen.DiscoverProjectComponents), and the project kind is derived from the tree beside forge.yaml (see config.LoadProject). See config.LoadStrict for the full validation semantics.

func ReconcileFrontendTsconfigPeers added in v0.1.45

func ReconcileFrontendTsconfigPeers(projectDir string)

ReconcileFrontendTsconfigPeers retargets every frontend's tsconfig peer pins to the node_modules layout the install forge just ran produced, and aims each at its package's installed declaration entry. It is the SCAFFOLD-time pass (`forge project new --frontend` and `forge scaffold frontend`, right after their npm install) and reads node_modules on purpose — see ObserveInstalledPinLayout. `forge generate` has its own pass that reads declarations only. Best-effort and non-fatal: a missing or unrecognised tsconfig is skipped rather than failed, a project whose layout cannot be identified is left untouched, and a file already correct is left byte-identical so a re-run reports nothing.

func ReconcileTsconfigPins added in v0.1.45

func ReconcileTsconfigPins(path string, decide PinDecision) bool

ReconcileTsconfigPins rewrites every present, forge-shaped peer pin in path to whatever decide returns for it, leaving every other byte of the file alone. Reports whether the file changed.

A pin forge does not recognise (another prefix, several candidates, a multi-line value) is left exactly as written: it is a shape a human chose.

func RecordFrozenChecksums

func RecordFrozenChecksums(projectDir, binary, kind string) error

RecordFrozenChecksums re-stamps the embedded forge:hash marker on every marker-bearing managed file at projectDir. Exposed publicly so callers outside the scaffold path (e.g. `forge project new` after `bootstrapGeneratedCode` runs goimports and reformats files) can re-certify the post-formatting bytes — otherwise the hashes stamped at scaffold time would not match the on-disk content, and the drift guard / `forge project upgrade --dry-run` would flag every formatted file as user-modified.

func RegenerateInfraFiles

func RegenerateInfraFiles(projectDir string, cfg *config.ProjectConfig) error

RegenerateInfraFiles regenerates all Tier 1 (always-overwrite) infrastructure files. Called by forge generate to keep infrastructure in sync with templates.

The cmd/<bin>/cmd command tree (root.go, serve.go, server.go, version.go, db.go) is part of this Tier-1 set, keyed off the project name so the per-binary destPaths resolve. cmd/<bin>/main.go is NOT — it is the composition root, owned by the generate pipeline (GenerateCmdGroups), which has the component inventory this path lacks.

func RegenerateInfraFilesTracked

func RegenerateInfraFilesTracked(projectDir string, cfg *config.ProjectConfig, cs *FileChecksums) error

RegenerateInfraFilesTracked is RegenerateInfraFiles routed through the checksums chokepoint. With a non-nil cs every Tier-1 infra write:

  • honors disowned entries (the user ran `forge project disown`: the write is skipped while the file exists — the raw os.WriteFile path this replaces violated the "forge never regenerates user-owned files" contract for cmd/*.go and friends);
  • records the render hash + WrittenThisRun so the stale sweep and the next run's drift guard see an accurate manifest;
  • tags the entry Tier-1 (these files ARE regenerated every run).

force=true preserves the historical always-overwrite semantics for forge-owned files: the Tier-1 stomp guard ran earlier in the pipeline, so any surviving drift was already adjudicated (--force / disown).

A nil cs falls back to untracked writes (legacy callers).

func RenderAdvisoryFile added in v0.1.45

func RenderAdvisoryFile(projectDir string, cfg *config.ProjectConfig, relPath string) (content []byte, ok bool, err error)

RenderAdvisoryFile renders a scaffold-once advisory row (a frontend's shared mechanism module, a .github starter) — the bytes `forge project upgrade --force <path>` adopts — reporting ok=false when relPath is not one.

func RenderUpgradeManagedFile added in v0.1.45

func RenderUpgradeManagedFile(projectDir string, cfg *config.ProjectConfig, relPath string) (content []byte, tier int, ok bool, err error)

RenderUpgradeManagedFile renders relPath the way `forge project upgrade` would write it into this project today, reporting ok=false when relPath is not an upgrade-managed file here. tier is the file's ownership tier: a Tier-1 file is regenerated by `forge generate`, not re-scaffolded.

func SaveChecksums

func SaveChecksums(root string, cs *FileChecksums) error

SaveChecksums persists the project ownership state. Empty state deletes the files — no bookkeeping churn in the steady state.

func ScaffoldIdentityKCL added in v0.1.46

func ScaffoldIdentityKCL(projectDir, projectName string) error

ScaffoldIdentityKCL writes deploy/kcl/identity.k, the one ManagedIdentity declaration every env imports. Write-if-absent: it is the project's from birth, and nothing regenerates it.

func ScaffoldProjectInto added in v0.1.45

func ScaffoldProjectInto(projectDir, dst string, cfg *config.ProjectConfig) error

ScaffoldProjectInto renders the `forge project new` scaffold of the project described by cfg (rooted at projectDir) into dst, which must be empty.

Nothing under projectDir is written. dst is a staging tree: the caller copies out the paths it wants and discards the rest.

The shape comes from forge.yaml and the tree, never from flags:

  • kind, binary mode, harness and features from cfg (the loader has already derived what forge.yaml leaves unset);
  • the first frontend from cfg.Frontends;
  • the first server component discovered in the tree, which is what the scaffold would have been given as --service. Its e2e harness is what makes e2e.yml part of the project (see CIWorkflows).

Render-time facts that depend on the TREE (the deploy envs the CI mapper reads, the go directive) come from projectDir via the projectDir-aware helpers, so a staged file matches this project rather than a fresh one.

func ScaffoldWorkloadsKCL added in v0.1.45

func ScaffoldWorkloadsKCL(projectDir, modulePath, projectName string, components codegen.Inventory, migrations bool) error

ScaffoldWorkloadsKCL writes deploy/kcl/workloads.k when the project does not already have one, seeded with the workloads discovered so far.

It is a NO-OP when the file exists. That is the whole contract: the file is user-owned, so the only safe automatic write is the FIRST one. Drift between the tree and this file afterwards is REPORTED by `forge lint` (which prints the exact stanza to paste) rather than silently repaired — forge cannot know whether a component missing from an env is an oversight or a deliberate choice.

Callers that run before the proto descriptor exists should skip an empty inventory rather than write an empty file, or that empty file becomes the one automatic write and the real components never land (see the call in generateKCLDeploy).

func ScaffoldedFrontendConfig added in v0.1.45

func ScaffoldedFrontendConfig(frontend string, apiPort int) codegen.FrontendConfig

ScaffoldedFrontendConfig is the codegen view of that same declaration: the FrontendConfig `forge generate` would derive from the descriptor once the proto above has been compiled.

It exists because the two are needed at the SAME moment and only one of them used to be produced. `forge scaffold frontend` renders templates that import the typed module (src/lib/auth/session-provider.ts does so unconditionally: `import ... from "@/lib/config_gen"`), and it gates that on ScaffoldedFrontendTypedConfig — but it never wrote the module those imports name, because writing it lived only in the generate pipeline's "frontend typed config" step. The add verb deliberately does NOT run generate (staging a frontend must not trigger project-wide codegen churn), so a freshly added frontend shipped a tree that could not typecheck: `Cannot find module '@/lib/config_gen'`, from the scaffold's own files, before the user had written a line. This is the same bug class as connect.ts referencing a mock-transport only generate emitted.

The field set is derived from frontendConfigEnvVars so it cannot drift from the proto beside it; the values here mirror the template's own declarations. `forge generate` later re-derives this from the compiled descriptor and rewrites the module — the point is only that the tree is coherent before it does.

func SetProjectConfigScalar added in v0.1.45

func SetProjectConfigScalar(path, key string, value any) error

SetProjectConfigScalar sets a single top-level scalar key in the forge.yaml at path, rewriting ONLY the bytes that hold that value and leaving the rest of the document untouched — comments, key order, blank lines, quoting style and any key the Go struct does not model all survive byte-for-byte.

This is the write path for "change one field on a file the user owns". It exists because forge.yaml is a hand-editable manifest, not a serialization of a Go value. See WriteProjectConfigFile for what the whole-struct alternative destroys.

The key is LOCATED through yaml.Node rather than by pattern-matching text, so a same-named key nested inside another block, or one mentioned in a comment, is not mistaken for the top-level one. Only the located value's own bytes are replaced; a trailing end-of-line comment on that line is preserved with its original spacing.

If the key is absent it is appended to the document. If the value is already correct the file is not written at all.

func SetProjectConfigScalarPath added in v0.1.45

func SetProjectConfigScalarPath(path string, keys []string, value any) error

SetProjectConfigScalarPath is SetProjectConfigScalar for a nested key: it sets the scalar at keys (e.g. {"ci","provider"}) in the forge.yaml at path, rewriting only that value's own bytes.

Blocks along the path that do not exist are created — appended at the end of their parent, so nothing already in the file is shifted or reindented. A path whose parent exists but is not a mapping is an error rather than a silent overwrite of whatever the user put there.

func SiteStarterKindNames added in v1.0.0

func SiteStarterKindNames() []string

SiteStarterKindNames lists the kinds SiteStarterFrontend accepts, sorted.

func StagedScaffoldBirthHash added in v0.1.45

func StagedScaffoldBirthHash(stagingRoot, relPath string) string

StagedScaffoldBirthHash returns the birth hash the staging render recorded for relPath, or "" when it recorded none. A few scaffolds (the frontend nav and dashboard) keep refreshing until the user edits them, and that rests on the ledger knowing the bytes forge wrote; a re-emitted copy must carry the same record or it would read as edited from the moment it lands.

func SyncFrontendLock added in v1.0.0

func SyncFrontendLock(projectDir, feRelDir string) error

SyncFrontendLock brings the lockfile that governs feRelDir's package.json back in step with the dependency ranges forge manages there (WebRuntimePackage, browserSDKPackage).

The Ensure* functions above rewrite those ranges in package.json and nothing else, so a project with a lockfile is left with one that records the OLD range: `npm ci` then fails ("package.json and package-lock.json are not in sync") until someone runs an install by hand. This runs the package manager's lockfile-only refresh (no node_modules, no lifecycle scripts) when, and only when, the lockfile disagrees about one of those packages. A project with no lockfile has nothing to refresh, and a lockfile that already agrees is never rewritten.

It returns an error when the refresh could not run (no package manager on PATH, registry unreachable), naming the frontend and the exact command, so the inconsistency is never silent. FORGE_SKIP_NPM_INSTALL opts out of running a package manager; the command is printed instead.

func Tier2ManagedPaths

func Tier2ManagedPaths() map[string]bool

Tier2ManagedPaths returns the set of project-relative paths whose canonical template tier is Tier-2 (scaffold-once, user-owned after the first write). It is the source of truth for `forge generate`'s tier-migration step (generate_tier_migrate.go in internal/cli): a `.forge/checksums.json` entry for one of these paths that still carries tier=1 (or the legacy unset tier=0) predates the template's reclassification and must be flipped to tier=2 so the file stops being drift-guarded and stops surfacing as a "fork".

Two sources:

  • The managed-file registry entries tagged Tier2. A destPath's tier is invariant across the (kind, binary) matrix — only the source template varies — so the union over combinations is safe (same posture as UpgradeManagedPaths).
  • The one-shot .github scaffolds written once at `forge project new` time (project_ci.go) and never re-emitted by `forge generate`. CODEOWNERS even carries the `yours: scaffolded once ... (starter)` banner; recording them as Tier-1 was a historical accident that made hand-editing your own CODEOWNERS trip the Tier-1 stomp guard. (FRICTION 2026-06-05, cp-forge: users worked around the misclassification by hand-flipping `forked: true`.)

.github/workflows/e2e.yml and .github/dependabot.yml belong to that second group. They were excluded from this set on the grounds that `forge generate`'s CI step re-renders them — which stopped being true when the CI workflows became write-once scaffolds (writeCIScaffold in internal/cli/generate_ci.go): every one of them is now written at most once and is the user's from birth, carrying no marker at all.

The exclusion outliving that change is what produced the worst shape a guard can take. A project scaffolded by an older forge still has the Tier-1 forge:hash marker its e2e.yml was born with; editing that file is now sanctioned, but the drift scan still read it as a hand-edited generated file and aborted `forge generate` WHOLESALE — including runs that touched nothing near CI. And because no emitter rewrites the file, the drift report had no extension point to name and said so ("NO EXTENSION POINT EXISTS"), which is forge telling the user their only remaining options are to discard their fix or disown the file forever. Both were wrong: the edit was legitimate and the tier was not.

func UpgradeManagedPaths

func UpgradeManagedPaths() map[string]bool

UpgradeManagedPaths returns the set of project-relative paths that `forge project upgrade` (not `forge generate`) is responsible for emitting. Used by `forge generate`'s stale-artifact sweep to exclude these paths from the "stale codegen" candidate list: they're tracked in `.forge/checksums.json` but only re-rendered by upgrade, so seeing them missing from this run's WrittenThisRun set is the expected state, not a stale signal.

The set is the union over every (kind, binary) combination. Forge only ships a small number of these combinations so the union is cheap; computing the union (rather than asking the caller for the project's specific kind/binary) keeps the helper signature simple and means a kind/binary mismatch in detection doesn't accidentally flag a managed file as stale.

FRICTION 2026-06-05 (cp-forge project audit-cleanup agent): `forge generate` warned 7 "stale" files — .github/CODEOWNERS, .golangci.yml, cmd/main.go, cmd/db.go, cmd/version.go, .github/workflows/e2e.yml, .github/pull_request_template.md — all of which are managed by `forge project upgrade`. The user worked around it by hand-flipping `forked: true` in checksums.json, which silenced the warnings but also disconnected the files from the upgrade pipeline. The right fix is for the stale-sweep to know about the upgrade-managed set.

Keys are slash-separated, the form of every ownership key the sweep compares them to (checksums.ScanMarkers). destPath is a native path — cmdTreePath joins with filepath — so on Windows the unnormalized set never matched and the sweep flagged these files as stale again. Every key goes through add, so the extra list below cannot skip the normalization: it once did, and on Windows the sweep marked the scaffold-once cmd/cmd/*.go files stale and deleted them.

func WriteFrontendConfigProto added in v0.1.45

func WriteFrontendConfigProto(root string, layout configproto.Layout, modulePath, frontend string, apiPort int) error

WriteFrontendConfigProto declares the named frontend's public runtime config, with the message-level (forge.v1.frontend_config) annotation that activates the three projections: the typed zod module the bundle imports, the KCL schema per-env values are authored against, and the config.js document the browser reads at boot.

Without this file the config system is complete and unreachable — every generator keys on the annotation, so a project that declares none correctly emits nothing and its frontend keeps reading build-time process.env.NEXT_PUBLIC_*.

APPEND-ONLY BY CONSTRUCTION. Adding a frontend to an existing project writes a NEW file, so no existing config content is read, rewritten or reordered — the failure mode the workloads.k append helper has to guard against does not arise here, because there is nothing to append INTO.

It refuses to overwrite an existing file. A project that already declares this frontend's config has values in it (an issuer, a client id) that a re-scaffold must not silently discard.

func WriteFrontendWorkspaceFiles

func WriteFrontendWorkspaceFiles(projectDir, projectName string, workspaces bool) error

WriteFrontendWorkspaceFiles emits the workspace-layout scaffolding:

  • pnpm-workspace.yaml at the project root
  • packages/api/package.json + tsconfig.json + .gitignore
  • packages/hooks/package.json + tsconfig.json + use-api-{query,mutation}.ts
  • packages/ui-web/package.json + tsconfig.json + src/components/*.tsx

Idempotent: writes files only if they do not already exist, so re- running `forge generate` after the user customizes (e.g.) the packages/api/package.json does not clobber their edits.

Returns nil immediately when workspaces is disabled — callers can invoke unconditionally and let this function decide whether work is needed.

func WriteGeneratedFile

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

WriteGeneratedFile writes a Tier-1 file through the certification chokepoint. See checksums.WriteGeneratedFile for full semantics.

func WriteGeneratedFileTier1

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

WriteGeneratedFileTier1 writes a Tier-1 (regenerated-every-run, self-certifying) file. See checksums.WriteGeneratedFileTier1.

func WriteRescaffoldedFile added in v0.1.45

func WriteRescaffoldedFile(root, relPath string, content []byte, mode os.FileMode, birthHash string) error

WriteRescaffoldedFile writes a re-emitted scaffold-once file and records its birth, preserving mode (scripts/bootstrap.sh is executable). birthHash is non-empty only for the scaffolds that refresh until touched (see StagedScaffoldBirthHash).

func WriteScaffoldIfMissing

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

WriteScaffoldIfMissing writes a scaffold ("yours", user-owned) file only when it does not already exist; once on disk forge never overwrites it. See checksums.WriteScaffoldIfMissing.

func WriteScaffoldedFrontendConfigTS added in v0.1.45

func WriteScaffoldedFrontendConfigTS(root, projectName, frontend, platform string, apiPort int) error

WriteScaffoldedFrontendConfigTS writes src/lib/config_gen.ts for a just-scaffolded frontend, so the templates that import "@/lib/config_gen" resolve in the tree the add verb leaves behind.

platform is the same label the generate pipeline passes (nextjs / vite-spa / react-native): it selects how the module reads its values server-side, so it must match the frontend's kind.

Written through the Tier-1 writer with a nil checksum tracker, exactly as the generate step does — the next `forge generate` re-derives the module from the compiled descriptor and overwrites this one, which is why nothing here needs to be preserved.

func WriteScenariosIndex added in v0.1.45

func WriteScenariosIndex(root, scenariosRel string, cs *FileChecksums) error

WriteScenariosIndex walks the project-relative <scenariosRel> inside root for *.ts files (excluding index.ts itself) and renders scenarios-index.ts.tmpl with one entry per file. Ordering is alphabetical for deterministic output — re-running `forge generate` on the same set of files produces the same bytes.

The barrel is Tier-1: it carries the generated banner and is rewritten whenever the directory's contents change, so it goes through the certification chokepoint like every other regenerated file.

func WriteSiteScaffold added in v1.0.0

func WriteSiteScaffold(dir, projectName string, harness Harness, fe SiteFrontend) error

WriteSiteScaffold writes a static site's forge files into dir: forge.yaml, deploy/kcl/kcl.mod and the dev / staging / prod envs, each declaring the root frontend (`path = "."`) and binding it — to its dev server in dev, to hosted static hosting (forge.OnHosted) in staging and prod.

That is the whole project. A site has no Go module, so there is no cmd/, go.mod, proto/, Dockerfile or server to scaffold; every forge command reads the absent go.mod (config.ProjectConfig.HasGoModule) and skips its Go steps. The frontend's own files are never touched: the app was there first and stays exactly as it was.

dir is normally a staging directory the caller merges into the project (the in-place rule that an existing file is kept unless --force).

func WriteSiteStarter added in v1.0.0

func WriteSiteStarter(dir, projectName string, fe SiteFrontend) error

WriteSiteStarter writes the starter app of a greenfield static site into dir (its root): the template tree internal/templates/frontend/site/<type> plus the files it reuses from the app frontend of that framework. fe is what SiteStarterFrontend returned; WriteSiteScaffold then writes the forge files declaring it.

func WriteUINativePackageFiles

func WriteUINativePackageFiles(projectDir string, layout FrontendWorkspaceLayout) error

WriteUINativePackageFiles emits the packages/ui-native/ scaffold:

  • packages/ui-native/package.json (workspace member)
  • packages/ui-native/tsconfig.json (no DOM lib, React JSX)
  • packages/ui-native/src/tokens.ts (design tokens)
  • packages/ui-native/src/index.ts (re-export barrel)
  • packages/ui-native/src/components/<primitive>.tsx (one per primitive)
  • packages/ui-native/README.md

Idempotent: write-if-missing throughout. Once a user edits a primitive (or rewrites the tokens), `forge generate` will not clobber the edit — the package is effectively scaffolded once and owned by the project after.

Guarded by the two preconditions documented at the call sites in generate_pipeline.go and add.go:

  1. `frontend.workspaces: true`
  2. At least one frontend with `type: react-native`

When either is false this function should not be called (callers gate); calling it directly still works but emits files that have no consumer.

func WriteUIWebPackageFiles

func WriteUIWebPackageFiles(projectDir, projectName string, workspaces bool) error

WriteUIWebPackageFiles emits packages/ui-web/ — the shared web-UI component library workspace. Idempotent (write-if-missing); safe to call from `forge generate` cycles after the user customizes the scaffolded files.

No-op when workspaces is false so existing single-frontend projects regenerate byte-identically.

Split from WriteFrontendWorkspaceFiles so callers that want to refresh just the ui-web scaffold (e.g. ensure-step in `forge generate`) don't have to touch the api/hooks packages.

Types

type AdvisoryFile added in v0.1.45

type AdvisoryFile struct {
	// Path is the project-relative destination (e.g.
	// "frontends/admin-web/src/lib/query-client.ts").
	Path string
	// Render produces the current template's bytes for this path.
	Render func() ([]byte, error)
}

AdvisoryFile is one scaffold-once row: a project-relative path plus the render of the template that path was born from.

Render is a closure rather than a (template name, data) pair because the rows span template registries with unrelated payload types — the composed frontend tree and the .github starters — and each row's payload is built where that payload's meaning already lives.

func AdvisoryFilesFor added in v0.1.45

func AdvisoryFilesFor(projectDir string, cfg *config.ProjectConfig) ([]AdvisoryFile, error)

AdvisoryFilesFor returns every scaffold-once row for a project: each frontend's SHARED-root mechanism files, plus the two .github starters that have a single renderer.

Per-kind frontend files (connect.ts.tmpl, next.config.ts.tmpl, the app shell) are deliberately absent. They are the platform wiring a project is expected to grow into, and a shared root is forge's own declaration of "this file is mechanism I own the design of" — which is exactly the set worth reporting on.

projectDir is the project root: the typed-config presence set a frontend's session-provider.ts / connect.ts render from is read from its proto/<project>/config/v1. It used to be read from the process working directory, so `forge project upgrade -C <dir>` — or any caller not sitting in the project — rendered the untyped variant of those files and reported (or adopted!) the wrong bytes.

func CIWorkflowAdvisoryFiles added in v0.1.45

func CIWorkflowAdvisoryFiles(projectDir string, cfg *config.ProjectConfig, in CIInputs) []AdvisoryFile

CIWorkflowAdvisoryFiles returns a scaffold-once row for every GitHub Actions file the CI mapper gives this project (CIWorkflowsFor) that is on disk, rendered exactly as `forge generate` would write it today.

It is separate from AdvisoryFilesFor because two of the mapper's inputs — which frontends exist and which envs are hosted — come from a KCL render of every env, which only internal/cli can perform. The caller supplies them, and must not call this when an env failed to render: the rows would compare against a workflow set forge only guessed at.

A workflow that is not on disk is not a row. One never written is `forge generate`'s to write (writeCIScaffold); one the user deleted stays deleted, and `forge project rescaffold <path>` is how to get it back. Reporting either as "absent" would be noise on every upgrade.

type AdvisoryResult added in v0.1.45

type AdvisoryResult struct {
	Path   string
	Status AdvisoryStatus
	Diff   string
	// Missing counts non-blank lines the current template has that the
	// on-disk file does not — "how far behind", in lines.
	Missing int
	// Local counts non-blank lines the on-disk file has that the current
	// template does not — the evidence of customization.
	Local int
	// Proven is true when forge can PROVE the bytes are an unedited forge
	// render (a verifying embedded forge:hash marker). Scaffold-once
	// files carry no marker by contract, so this is normally false; it
	// stays here so a path that acquired one along the way is read as the
	// certainty it is rather than as the Local-count heuristic.
	Proven bool
	// Selected is true when the user named this exact path after --force.
	Selected bool
	// Adopted is true when this run actually wrote the template over the
	// file (Selected, and not a dry run).
	Adopted bool
}

AdvisoryResult is the outcome for one advisory row.

func InspectAdvisories added in v0.1.45

func InspectAdvisories(projectDir string, cs *FileChecksums, files []AdvisoryFile, force ForceSelection, checkOnly bool) ([]AdvisoryResult, error)

InspectAdvisories compares every advisory row with the project on disk.

It is read-only EXCEPT for rows the caller's force selection names explicitly: `--force <path>` is the adoption gesture, and it is the only thing that writes here. checkOnly suppresses even that.

A disowned path is skipped outright. Disowning is a recorded, one-way statement that the file is the user's; continuing to offer it as adoptable would be forge asking for it back every run.

func (AdvisoryResult) Behind added in v0.1.45

func (r AdvisoryResult) Behind() bool

Behind reports whether the row has anything to say — i.e. anything but "current".

type AdvisoryStatus added in v0.1.45

type AdvisoryStatus string

AdvisoryStatus is the verdict for one scaffold-once file.

const (
	// AdvisoryCurrent — the template has nothing this file lacks: either
	// the bodies are equal, or the file has grown past the template and
	// still contains all of it. Silent, because there is no advice to
	// give.
	AdvisoryCurrent AdvisoryStatus = "current"
	// AdvisoryAbsent — the template ships a file this project does not
	// have. Almost always "scaffolded before the module existed";
	// adopting it is a pure add that destroys nothing.
	AdvisoryAbsent AdvisoryStatus = "absent"
	// AdvisoryBehind — the file differs from the template, and every
	// non-blank line it has, the template also has. The delta is one-way:
	// template content this copy lacks. Adoption is a refresh.
	AdvisoryBehind AdvisoryStatus = "behind"
	// AdvisoryDiverged — the file differs AND carries non-blank lines no
	// current template line matches. Someone put those there. Adoption
	// would discard them, so the honest framing is a merge the user
	// performs, not an overwrite forge performs.
	AdvisoryDiverged AdvisoryStatus = "diverged"
)

type BinaryBuild

type BinaryBuild struct {
	// Dir is the cmd/<Dir>/ leaf — the `go build ./cmd/<Dir>` package path
	// segment AND the output binary basename (/app/<Dir>).
	Dir string
	// Primary marks the server entrypoint (cmd/<projectName>/) that the
	// production image runs by default (ENTRYPOINT/CMD). Exactly one binary
	// is primary; the rest are run via a per-workload command override.
	Primary bool
}

BinaryBuild is one buildable entrypoint: the cmd/<Dir>/ leaf, which is also the output binary name (/app/<Dir>).

type CIInputs added in v0.1.45

type CIInputs struct {
	// Frontends are the repo-relative frontend directories CI drives a Node
	// toolchain for.
	Frontends []templates.FrontendCIConfig
	// HostedEnvs are the declared envs a control plane RUNS (forge.ControlPlane
	// plus a hosted workload or database). Non-empty ⇒ release.yml and the
	// vendored forge-deploy action are scaffolded, and deploy.yml stops
	// rebuilding per env for them (release.yml builds once and deploys that
	// one release through each of them).
	HostedEnvs []string
	// MixedEnvs is the subset of HostedEnvs that ALSO apply a part from CI —
	// a cluster or compose workload, a cluster database, infra. Their
	// release.yml stage always deploys client-side (a control plane never
	// converges a cluster half) and carries the kubeconfig step.
	MixedEnvs []string
}

CIInputs are the facts only the caller can answer, because they come from a KCL render of a live project (a freshly scaffolded one has no topology yet).

type CIWorkflowFile added in v0.1.45

type CIWorkflowFile struct {
	Template string
	Dest     string
	Data     any
}

CIWorkflowFile is one scaffolded GitHub Actions file: the template that renders it, where it lands, and the data it renders with.

func CIWorkflows added in v0.1.45

func CIWorkflows(root string, cfg *config.ProjectConfig, frontends []templates.FrontendCIConfig) []CIWorkflowFile

CIWorkflows is THE mapping from a project to its scaffolded GitHub Actions files. `forge project new` (generateCIFiles) and `forge generate` (internal/cli's generateCIWorkflows) both call it; neither builds template data of its own.

There used to be two mappers, one per command, and they disagreed on a project seconds old. houndersclub deleted ci.yml to pick up forge v0.1.18's template, re-ran `forge generate`, and lost the whole Docker Build job: the generate-side mapper never set HasDocker. It also dropped e2e.yml, the reconcile workflow, and — on a project without a service proto yet — every buf step, even though `forge generate` runs `buf generate` on that very project. TestCIWorkflows_NewAndGenerateRenderIdentically pins the two commands to identical bytes so a second mapper cannot grow back.

root is the project directory, read for the facts that live on disk: the declared deploy envs (deploy/kcl/<env>/main.k) and the e2e harness (e2e/). cfg is the project config with derived defaults applied — what the loader returns, or what `forge project new` is about to write. frontends are the repo-relative frontend directories CI drives a Node toolchain for; the caller supplies them because only it knows the authoritative source (the KCL topology on a live project, the requested frontend at scaffold time).

func CIWorkflowsFor added in v0.1.45

func CIWorkflowsFor(root string, cfg *config.ProjectConfig, in CIInputs) []CIWorkflowFile

CIWorkflowsFor is CIWorkflows with every caller-supplied input.

type CRDGenInput

type CRDGenInput struct {
	Root         string   // project root
	ModulePath   string   // go module path (e.g. "github.com/example/opt")
	OperatorName string   // target operator (display form, may have hyphens)
	TypeName     string   // CRD type name (PascalCase, e.g. "Workspace")
	Group        string   // API group (e.g. "reliant.dev")
	Version      string   // API version (e.g. "v1alpha1")
	Shape        CRDShape // reconciler scaffold style
}

CRDGenInput collects the parameters needed to scaffold a CRD into an existing operator.

type CRDShape

type CRDShape string

CRDShape is the reconciler scaffold style chosen at scaffold time.

state-machine — the CRD has a Spec.State field that drives the reconcile loop through observable phases (Pending → Running → Suspended → Failed). Generated controller carries reconcileRunning / reconcileSuspended / reconcileDeleting helpers and a switch over Spec.State.

config — declarative-only CRD with no observable state. The reconciler's job is to project Spec into a runtime store. Generated controller has a single ReconcileSpec.

composite — the CRD owns a set of sub-resources whose lifetime is coupled to the parent. Generated controller has a ReconcileSpec that's structured as a series of ensure* helpers.

const (
	CRDShapeStateMachine CRDShape = "state-machine"
	CRDShapeConfig       CRDShape = "config"
	CRDShapeComposite    CRDShape = "composite"
)

func (CRDShape) IsValid

func (s CRDShape) IsValid() bool

IsValid returns true if s is one of the recognised CRD shapes.

type ConfigService

type ConfigService interface {
	ReadProjectConfig(path string) (*config.ProjectConfig, error)
	SetProjectConfigScalar(path, key string, value any) error
	SetProjectConfigScalarPath(path string, keys []string, value any) error
}

ConfigService loads and mutates the on-disk forge.yaml.

Every mutation here is SURGICAL — it edits only the bytes it owns and leaves the rest of the document, comments included, untouched. There is deliberately no "write the whole config" method: forge.yaml is hand-authored, and a config.ProjectConfig cannot represent comments, key order, or the sections NormalizeForWrite drops, so marshalling one back silently deletes all three. See the WRITING forge.yaml note in project_config.go.

func NewConfigService

func NewConfigService(_ Deps) ConfigService

NewConfigService constructs the forge.yaml read/write surface.

type Deps

type Deps struct{}

Deps wires generator's cross-package collaborators. Empty today; the scaffolders use codegen + templates via package-level helpers. When internal/cli is ported, the Deps struct will gain Codegen / Templates fields wired through the New constructor.

type DisownedEntry

type DisownedEntry = checksums.DisownedEntry

DisownedEntry is re-exported as a type alias from internal/checksums.

type E2EMethodInfo

type E2EMethodInfo struct {
	Name       string
	InputType  string
	OutputType string
}

E2EMethodInfo holds method metadata for E2E test template rendering: the RPC and its messages as the proto spells them. GenerateE2ETests re-cases them to the Go identifiers the generators declare.

func MethodsFromProtoStub

func MethodsFromProtoStub(serviceName string) []E2EMethodInfo

MethodsFromProtoStub returns placeholder E2E method info when no proto methods are available yet (e.g., when creating a brand-new service).

type E2ETemplateData

type E2ETemplateData struct {
	Module         string
	ServiceName    string // display form, may contain hyphens
	ServicePackage string // Go/proto-package-safe form (snake_case)
	// ConnectServiceGoName is protoc-gen-connect-go's base for the service
	// (naming.ConnectServiceGoName): the client is <base>Client.
	ConnectServiceGoName string
	ProtoPackage         string
	ProjectName          string
	Port                 int
	Methods              []E2EMethodInfo
	FirstRequestType     string // Used to anchor the pb import in helpers
	// ServeArgs is the argv (after the binary) that runs this service. A
	// project composed by the graph (codegen.GraphComposition — every new
	// project) has no per-component subcommands: `server` runs the default
	// root, which serves every service. A legacy Components project gives
	// each service its own top-level subcommand, named by the same
	// derivation that generates the CLI tree (codegen.CmdServiceCommand), so
	// the harness and the CLI cannot disagree; a service whose name collides
	// with a built-in command gets none, and `server` runs it.
	ServeArgs []string
}

E2ETemplateData holds all data needed to render E2E test templates.

type FileChecksums

type FileChecksums = checksums.FileChecksums

FileChecksums is re-exported as a type alias from internal/checksums.

func LoadChecksums

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

LoadChecksums loads the project ownership state (.forge/disowned.json + .forge/hashes.json).

type ForceSelection added in v0.1.45

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

ForceSelection names which user-modified files an upgrade run is allowed to overwrite.

Force is per-path because adopting a template is per-path. A project accumulates edits at different times for different reasons: one file was customized on purpose, another drifted because its provenance predates the self-certifying marker. A single project-wide switch makes adopting the second impossible without stomping the first, so the only safe move is to run nothing — which is how a project stops upgrading at all.

The zero value forces nothing.

func ForceAll added in v0.1.45

func ForceAll() ForceSelection

ForceAll returns a selection that overwrites every user-modified managed file — the whole-project meaning of a bare --force.

func ForceNone added in v0.1.45

func ForceNone() ForceSelection

ForceNone returns a selection that overwrites nothing the user has touched.

func ForcePaths added in v0.1.45

func ForcePaths(paths ...string) ForceSelection

ForcePaths returns a selection that overwrites exactly the named project-relative paths and nothing else.

func (ForceSelection) Allows added in v0.1.45

func (f ForceSelection) Allows(relPath string) bool

Allows reports whether relPath may have the user's edits overwritten.

func (ForceSelection) Any added in v0.1.45

func (f ForceSelection) Any() bool

Any reports whether the selection can overwrite anything at all.

func (ForceSelection) Names added in v0.1.45

func (f ForceSelection) Names(relPath string) bool

Names reports whether relPath was named EXPLICITLY — the whole-project form does not satisfy it.

The scaffold-once advisory lane (upgrade_advisory.go) gates adoption on this rather than on Allows. Those files are the user's from birth, so "overwrite everything you have edited" cannot be a statement about them: a flag that names nothing carries no intent about a file forge already handed over. Naming the path is the intent.

type FrontendGenOptions

type FrontendGenOptions struct {
	// Workspaces opts the frontend into the pnpm-workspace layout —
	// its package.json declares "workspace:*" deps on @<scope>/api /
	// @<scope>/hooks, and templates render imports of those packages
	// instead of relative @/gen / @/hooks paths.
	Workspaces bool
	// Output selects the Next.js build/runtime shape rendered into
	// `next.config.ts` and the Dockerfile. Valid values: "static",
	// "standalone", "server". See config.FrontendConfig.Output for the
	// per-mode semantics. Empty string means the scaffold default,
	// config.FrontendOutputScaffoldDefault ("static") — this renders a NEW
	// frontend, so "the default" is the current one. (Reading an existing
	// forge.yaml entry is different: there an empty field is the legacy
	// standalone shape; see config.FrontendConfig.EffectiveOutput.)
	//
	// Ignored for kind=mobile (react-native) and kind=vite-spa; those
	// trees have their own production shapes.
	Output string
	// BasePath is the URL prefix the frontend is mounted under (e.g.
	// "/admin"), mirroring config.FrontendConfig.BasePath. Rendered
	// into `next.config.ts` (basePath + assetPrefix defaults) and
	// `src/lib/basepath_gen.ts` (BASE_PATH / joinBasePath fallback).
	// Empty = served from the host root. Like Output, only the nextjs
	// template tree reads it.
	BasePath string
	// TypedConfig describes the config message bound to THIS frontend by
	// (forge.v1.frontend_config), when the project declares one. Zero value
	// means the project has not opted in, and every template renders its
	// previous env-var form unchanged.
	TypedConfig FrontendTypedConfig
	// Public scaffolds a frontend with NO sign-in gate: no route guard
	// wrapping the app, and no /auth/sign-in route. It is what
	// `auth_mode: none` renders, and what `forge scaffold frontend` picks
	// by default for a project whose dev environment declares no identity
	// provider — there, a guarded frontend can only ever redirect every
	// page to a sign-in form nothing can satisfy.
	//
	// The auth modules themselves (src/lib/auth/*) are still emitted: they
	// compile, answer "signed out" without an issuer, and are what a later
	// switch to `auth_mode: native` re-attaches to.
	Public bool
}

FrontendGenOptions carries optional project-level settings forwarded to the per-frontend file emitter. Kept distinct from the existing positional GenerateFrontendFiles params so that adding new optional settings (here: Workspaces) doesn't churn every call site.

type FrontendTypedConfig added in v0.1.45

type FrontendTypedConfig struct {
	// Bound reports that a config message names this frontend.
	Bound bool

	HasMockAPI bool
}

FrontendTypedConfig tells the frontend templates which typed config fields exist for the frontend being rendered.

It carries per-FIELD presence rather than a bare "has config" flag because the generated TypeScript module's type has exactly the keys the proto declared. A template that reads cfg.MOCK_API when the proto never declared mock_api produces a frontend that does not type-check — so each read is gated on the field that backs it.

There are no OIDC gates here because a browser frontend carries no OIDC config at all: sign-in is NATIVE, so the issuer, client id, redirect URI, scopes and resource indicator are backend configuration and never reach the bundle. See frontendConfigEnvVars in frontend_config_proto.go.

func FrontendTypedConfigFrom added in v0.1.45

func FrontendTypedConfigFrom(envVars []string) FrontendTypedConfig

FrontendTypedConfigFrom builds the per-field presence set from the leaf fields of the frontend's config message, keyed on each field's env_var (the one spelling shared by the proto, the KCL projection, the runtime document and the generated module).

func ScaffoldedFrontendTypedConfig added in v0.1.45

func ScaffoldedFrontendTypedConfig() FrontendTypedConfig

ScaffoldedFrontendTypedConfig is the presence set a frontend gets from the config proto this package scaffolds for it.

type FrontendWorkspaceLayout

type FrontendWorkspaceLayout struct {
	// Scope is the npm scope without the leading `@`. Derived from
	// the project name; sanitized to a valid npm scope segment.
	Scope string
	// APIPackage is the fully-qualified npm package name for the API
	// workspace (e.g. "@myapp/api").
	APIPackage string
	// HooksPackage is the fully-qualified npm package name for the
	// hooks workspace (e.g. "@myapp/hooks").
	HooksPackage string
	// UIWebPackage is the fully-qualified npm package name for the
	// shared web-UI component workspace (e.g. "@myapp/ui-web"). It
	// holds the React/Tailwind component library that all browser-
	// targeted frontends import from instead of duplicating per-
	// frontend copies under src/components/.
	UIWebPackage string
	// UINativePackage is the fully-qualified npm package name for the
	// React Native primitives workspace (e.g. "@myapp/ui-native").
	// Only emitted when the project has at least one RN frontend AND
	// workspaces are enabled — empty otherwise.
	UINativePackage string
}

FrontendWorkspaceLayout describes the workspace package names + paths derived from a project. It is the single source of truth that hooks generation, buf TS generation, and frontend templates consult.

func NewFrontendWorkspaceLayout

func NewFrontendWorkspaceLayout(projectName string) FrontendWorkspaceLayout

NewFrontendWorkspaceLayout builds the canonical layout from a raw project name. Falls back to "@app/api" / "@app/hooks" / "@app/ui-web" when the project name doesn't produce a valid npm scope segment.

type Harness

type Harness string

Harness identifies which AI tool's conventions to scaffold for at project creation time: the file(s) that point that tool at the project guide. The guide itself is always AGENTS.md; a harness adds the thin files its tool needs to find it. The default is "reliant".

const (
	HarnessReliant Harness = "reliant"
	HarnessClaude  Harness = "claude"
	HarnessCursor  Harness = "cursor"
	HarnessCopilot Harness = "copilot"
	HarnessCodex   Harness = "codex"
)

The harnesses forge can scaffold for.

func DetectHarness added in v1.0.0

func DetectHarness(getenv func(string) string) (Harness, string)

DetectHarness reports which agent harness is running this process, from the markers above, and the variable that identified it. It returns ("", "") when none is set. getenv is os.Getenv in production.

func ParseHarness

func ParseHarness(s string) (Harness, error)

ParseHarness validates and normalises a user-supplied string.

func ResolveHarness added in v1.0.0

func ResolveHarness(flag string, getenv func(string) string) (h Harness, detected string, err error)

ResolveHarness applies the precedence for `forge project new`: an explicit --harness value wins, then a detected harness, then reliant. detected is the marker variable when detection decided it, "" otherwise.

func ValidHarnesses

func ValidHarnesses() []Harness

ValidHarnesses lists all accepted --harness values. Returned as a getter to satisfy the no-exported-vars rule; callers should treat the returned slice as read-only (it is rebuilt on each call).

func (Harness) MemoryFilePath

func (h Harness) MemoryFilePath() string

MemoryFilePath returns the project-root-relative path of the harness's own top-level instruction file. For claude, cursor and copilot it is a thin pointer to AGENTS.md (see agentFiles); codex reads AGENTS.md itself, and reliant renders the guide in memory.

func (Harness) Normalized added in v0.1.45

func (h Harness) Normalized() Harness

Normalized resolves the zero value to the default harness. A ProjectGenerator built by a caller that never set Harness carries "", which every switch below already treats as reliant; Normalized makes that resolution explicit for the callers that SERIALIZE the value, so what lands in forge.yaml is a real harness name rather than a blank.

type MockConfigService

type MockConfigService struct {
	contractkit.Recorder
	ReadProjectConfigFunc          func(string) (*config.ProjectConfig, error)
	SetProjectConfigScalarFunc     func(string, string, any) error
	SetProjectConfigScalarPathFunc func(string, []string, any) error
}

MockConfigService is a test mock for the ConfigService interface.

The embedded contractkit.Recorder records every call so tests can assert call counts and captured arguments. Set XxxFunc fields to override per-method behaviour; unset methods return the canonical "MockConfigService.<Method>Func not set" error.

func (*MockConfigService) ReadProjectConfig

func (m *MockConfigService) ReadProjectConfig(path string) (*config.ProjectConfig, error)

ReadProjectConfig records the call and delegates to ReadProjectConfigFunc when set.

func (*MockConfigService) SetProjectConfigScalar added in v0.1.45

func (m *MockConfigService) SetProjectConfigScalar(path string, key string, value any) error

SetProjectConfigScalar records the call and delegates to SetProjectConfigScalarFunc when set.

func (*MockConfigService) SetProjectConfigScalarPath added in v0.1.45

func (m *MockConfigService) SetProjectConfigScalarPath(path string, keys []string, value any) error

SetProjectConfigScalarPath records the call and delegates to SetProjectConfigScalarPathFunc when set.

type MockService

type MockService struct {
	contractkit.Recorder
	GeneratePlanORMFunc       func(string, config.ORMConfig, []config.PlanEntity, *FileChecksums) error
	GeneratePlanProtoFileFunc func(string, string, string, []config.PlanRPC, []config.PlanEntity) error
	GenerateServiceFilesFunc  func(string, string, string, string) error
	GenerateWorkerFilesFunc   func(string, string, string, string, string) error
	GenerateOperatorFilesFunc func(string, string, string, string, string) error
	GenerateWebhookFilesFunc  func(string, string, string, string) error
	GenerateFrontendFilesFunc func(string, string, string, string, int, string) error
	EnsureCoreComponentsFunc  func(string) error
	GenerateE2ETestsFunc      func(string, string, string, string, []E2EMethodInfo) error
	RegenerateInfraFilesFunc  func(string, *config.ProjectConfig) error
	UpgradeFunc               func(string, *config.ProjectConfig, bool, bool) ([]UpgradeResult, error)
	WriteGeneratedFileFunc    func(string, string, []byte, *FileChecksums, bool) (bool, error)
	HashContentFunc           func([]byte) string
	LoadChecksumsFunc         func(string) (*FileChecksums, error)
	SaveChecksumsFunc         func(string, *FileChecksums) error
}

MockService is a test mock for the Service interface.

The embedded contractkit.Recorder records every call so tests can assert call counts and captured arguments. Set XxxFunc fields to override per-method behaviour; unset methods return the canonical "MockService.<Method>Func not set" error.

func (*MockService) EnsureCoreComponents

func (m *MockService) EnsureCoreComponents(frontendDir string) error

EnsureCoreComponents records the call and delegates to EnsureCoreComponentsFunc when set.

func (*MockService) GenerateE2ETests

func (m *MockService) GenerateE2ETests(projectDir string, serviceName string, modulePath string, projectName string, methods []E2EMethodInfo) error

GenerateE2ETests records the call and delegates to GenerateE2ETestsFunc when set.

func (*MockService) GenerateFrontendFiles

func (m *MockService) GenerateFrontendFiles(root string, modulePath string, projectName string, frontendName string, apiPort int, kind string) error

GenerateFrontendFiles records the call and delegates to GenerateFrontendFilesFunc when set.

func (*MockService) GenerateOperatorFiles

func (m *MockService) GenerateOperatorFiles(root string, modulePath string, name string, group string, version string) error

GenerateOperatorFiles records the call and delegates to GenerateOperatorFilesFunc when set.

func (*MockService) GeneratePlanORM

func (m *MockService) GeneratePlanORM(root string, orm config.ORMConfig, entities []config.PlanEntity, cs *FileChecksums) error

GeneratePlanORM records the call and delegates to GeneratePlanORMFunc when set.

func (*MockService) GeneratePlanProtoFile

func (m *MockService) GeneratePlanProtoFile(root string, modulePath string, serviceName string, rpcs []config.PlanRPC, entities []config.PlanEntity) error

GeneratePlanProtoFile records the call and delegates to GeneratePlanProtoFileFunc when set.

func (*MockService) GenerateServiceFiles

func (m *MockService) GenerateServiceFiles(root string, modulePath string, serviceName string, projectName string) error

GenerateServiceFiles records the call and delegates to GenerateServiceFilesFunc when set.

func (*MockService) GenerateWebhookFiles

func (m *MockService) GenerateWebhookFiles(root string, modulePath string, serviceName string, webhookName string) error

GenerateWebhookFiles records the call and delegates to GenerateWebhookFilesFunc when set.

func (*MockService) GenerateWorkerFiles

func (m *MockService) GenerateWorkerFiles(root string, modulePath string, workerName string, kind string, schedule string) error

GenerateWorkerFiles records the call and delegates to GenerateWorkerFilesFunc when set.

func (*MockService) HashContent

func (m *MockService) HashContent(content []byte) string

HashContent records the call and delegates to HashContentFunc when set.

func (*MockService) LoadChecksums

func (m *MockService) LoadChecksums(root string) (*FileChecksums, error)

LoadChecksums records the call and delegates to LoadChecksumsFunc when set.

func (*MockService) RegenerateInfraFiles

func (m *MockService) RegenerateInfraFiles(projectDir string, cfg *config.ProjectConfig) error

RegenerateInfraFiles records the call and delegates to RegenerateInfraFilesFunc when set.

func (*MockService) SaveChecksums

func (m *MockService) SaveChecksums(root string, cs *FileChecksums) error

SaveChecksums records the call and delegates to SaveChecksumsFunc when set.

func (*MockService) Upgrade

func (m *MockService) Upgrade(projectDir string, cfg *config.ProjectConfig, force bool, checkOnly bool) ([]UpgradeResult, error)

Upgrade records the call and delegates to UpgradeFunc when set.

func (*MockService) WriteGeneratedFile

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

WriteGeneratedFile records the call and delegates to WriteGeneratedFileFunc when set.

type ORMCollisionError added in v0.1.46

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

ORMCollisionError is the refusal checkORMNameCollisions returns: the names the project already declares, and how to resolve each entity.

func (*ORMCollisionError) Entities added in v0.1.46

func (e *ORMCollisionError) Entities() []string

Entities returns the entities whose names collide, sorted.

func (*ORMCollisionError) Error added in v0.1.46

func (e *ORMCollisionError) Error() string

type PinDecision added in v0.1.45

type PinDecision func(pkg string, hoisted bool, entry string) (wantHoisted bool, wantEntry string)

PinDecision is what a reconcile wants one existing pin to become, given the layout and declaration entry the pin currently carries.

type PinLayout added in v0.1.45

type PinLayout struct {
	// Hoisted is meaningful only when Known; false otherwise.
	Hoisted bool
	// Known reports whether the evidence identified a layout at all.
	Known bool
}

PinLayout names WHICH node_modules a frontend's tsconfig peer pins resolve against.

A `paths` value for a non-wildcard key must hold EXACTLY ONE element — SWC asserts that rule and panics `next build` on anything longer (measured; see webruntimepeers). So forge cannot list both layouts and let the toolchain choose: it has to name the one this project actually has, which makes this a decision procedure rather than a lookup.

The two layouts, and what each pin does in each

Measured end-to-end against control-plane's internal-console, both `tsc --noEmit` and `next build`:

layout                     "./node_modules/…"   "../../node_modules/…"
standalone (npm ci here)   green                green (pin dangles, inert)
workspace (hoisted root)   TS2322               green

The asymmetry is the whole design. A pin that names a directory holding the package is right; a pin that names a directory that does not exist is not an error tsc reports — it silently no-ops and resolution falls back to the ordinary upward walk. In a standalone project that walk finds the one real copy and nothing is harmed. In a BRIDGED project it finds the linked runtime's own private copy first, and two physically distinct installs are two distinct types:

src/lib/mock-transport_gen.ts(158,5): error TS2322: Type
'…/forge/web-runtime/node_modules/@connectrpc/connect/…'.Transport is not
assignable to type '…/<project>/node_modules/@connectrpc/connect/…'.Transport

So "hoisted" is the answer that is merely redundant when wrong, and "local" is the answer that breaks a build when wrong.

Why this returns a KNOWN flag instead of just a bool

tsconfig.json is generated AND COMMITTED, and consumers re-run `forge generate` in CI and fail the build on any diff. A decision that reads untracked state therefore generates one file on a developer's machine and a different one on a runner, from the same commit.

That is not hypothetical, and forge shipped it: control-plane's root package.json IS forge's own dev web-runtime bridge (frontend_webruntime_devlink.go) and is GITIGNORED by construction, while node_modules is a build artifact absent from a fresh checkout. A maintainer's tree has both, so the probe answered "hoisted" and the committed pins say "../../node_modules/…". A CI checkout has NEITHER, so the same probe answered "local" and rewrote all 14 pins to "./node_modules/…" — turning Verify Generated Code red on a file nobody had touched, on a clean clone of main.

Reading only tracked files does not rescue it either, and this is the part worth stating plainly: from tracked files alone a bridged project is INDISTINGUISHABLE from a standalone one. control-plane's tracked evidence — a per-frontend package.json naming the registry range "^0.3.1", a per-frontend package-lock.json, no root manifest — is exactly what a plain standalone project looks like. The signal that says otherwise is ignored on purpose, so no amount of preferring tracked inputs can recover it.

The resolution is to stop forcing an answer. When the evidence identifies a layout, forge retargets and heals a stale pin. When it does not, forge leaves the committed value alone: that value is itself a tracked, reviewed declaration of the layout, and respecting it makes generate a fixed point in every environment. Silence is a third state, not a vote for the default.

func DetectFrontendPinLayout added in v0.1.45

func DetectFrontendPinLayout(projectDir, feDir string) PinLayout

DetectFrontendPinLayout reports which node_modules feDir's PEER dependencies resolve from, as far as the project's DECLARED files say, and whether they say anything at all.

It reads no node_modules. That is the whole contract, and it is what keeps `forge generate` from rewriting a committed tsconfig.json according to who last ran `npm install`. It used to probe for an installed copy — a nested frontends/<name>/node_modules/@connectrpc/connect first, then a hoisted one — and a nested install is exactly what `npm ci` inside a frontend creates. control-plane commits its pins at the project root (its layout is forge's dev bridge); a developer who had run `npm ci` in internal-console got all 14 pins rewritten to "./node_modules/…" by the next `forge generate`, and the next bridged generate flipped them back. A committed file must not follow an install.

The signals are declarations, strongest-first, and each is a POSITIVE identification. Running out of signals yields Known=false, which callers must treat as "leave the committed pins alone", never as a default.

What is installed is still the right evidence at ONE moment: the instant forge itself has just installed a frontend it is creating, before anything is committed. ObserveInstalledPinLayout serves that, and only that.

func ObserveInstalledPinLayout added in v0.1.45

func ObserveInstalledPinLayout(projectDir, feDir string) PinLayout

ObserveInstalledPinLayout is DetectFrontendPinLayout plus what an install actually produced. It is for the moment forge has just run `npm install` on a frontend it is CREATING (`forge scaffold frontend`): the tsconfig was written before the install decided the layout, nothing about it is committed yet, and the tree on disk is the best evidence there is.

It must not be used by `forge generate`, which re-runs on every machine and on committed files — see DetectFrontendPinLayout for what that caused.

type ProjectGenerator

type ProjectGenerator struct {
	Name               string
	Path               string
	ModulePath         string
	Kind               string   // project kind: "service" (default), "cli", "library"
	Binary             string   // binary mode: "per-service" (default), "shared" — emit one Go binary with cobra subcommand per service when shared
	ServiceName        string   // initial service name (empty if none specified)
	AdditionalServices []string // additional service names beyond ServiceName — only consumed by binary=shared scaffolds; per-service mode adds these post-scaffold
	ServicePort        int      // scaffold artifact port (devcontainer/vscode); the dev backend's real bind port is allocated at runtime — see resolveEphemeralHostPorts
	FrontendName       string   // optional initial Next.js frontend name
	FrontendOutput     string   // static (default), standalone, or server
	FrontendRoutes     []string // CRUD allowlist; none disables generated pages
	FrontendPort       int      // frontend port; 0 = ephemeral (forge run/up allocates a free port at launch and reports it)
	// FrontendWorkspaces opts the project into the pnpm-workspaces
	// layout: emit a root pnpm-workspace.yaml + packages/api +
	// packages/hooks, frontends consume @<scope>/api / @<scope>/hooks
	// via "workspace:*" deps. Off by default — single-frontend
	// projects keep the historic per-frontend layout unchanged.
	FrontendWorkspaces bool
	GoVersionOverride  string                // if set, use this Go version instead of detecting
	Features           config.FeaturesConfig // feature flags for generation
	Harness            Harness               // AI harness (default: reliant) — controls memory file path
	// BuildVersionVar mirrors forge.yaml build.version_var: an additional
	// `-ldflags -X` target the Dockerfile stamps with the build version.
	// Empty at scaffold time (a fresh forge.yaml has no build: block), so
	// the Dockerfile's `{{if .VersionVar}}` renders nothing — preserving
	// the historical main.version-only stamping. `forge generate` /
	// upgrade re-render with the live forge.yaml value.
	BuildVersionVar string

	// Claimable scaffolds the prod env for an ANONYMOUS deploy (`forge env
	// deploy prod --claimable`, `forge project new --claimable`): its
	// control plane declares `claimable = True`, and its hosted binder sizes
	// every service and job to the claimable plan (deploy.ClaimableLimitsFallback().Resources())
	// instead of the band-conformant entry shape, so the default full-stack
	// scaffold deploys with no account and no edits.
	Claimable bool

	// ForgePin is the forge version the project ALREADY requires, set when
	// `forge project new --in-place` adopts a module whose go.mod pins forge.
	// It is then the version forge.yaml's forge_version records and the
	// version a --force-replaced go.mod (and gen/go.mod) requires, so the
	// scaffold never contradicts the pin the user chose. Empty for a fresh
	// project: forge.yaml records this binary and go.mod requires
	// resolveForgeVersion().
	ForgePin string

	// GenInRootModule is set when `forge project new --in-place` adopts a
	// module that already owns gen/: its generated Go packages live in the
	// root module, with no gen/go.mod. The scaffold then keeps them there —
	// it writes no gen/go.mod, and go.work uses only the root. Splitting gen/
	// off would move every gen/ package out of the module that the project's
	// importers require (control-plane imports reliant's gen/reliant/v1
	// through reliant's root module), and the root's own `go mod tidy` could
	// no longer resolve them. The import paths are the same either way, so
	// nothing else in forge depends on which module holds gen/.
	GenInRootModule bool

	// Adopting is set when `forge project new --in-place` scaffolds into a
	// directory that already holds a project. That project's code reads the
	// environment directly today, so the typed-access guardrail scaffolds in
	// its advisory form (warn) instead of the greenfield gating form (error)
	// — see scaffoldTypedAccess.
	Adopting bool
	// contains filtered or unexported fields
}

ProjectGenerator generates new project structure

func NewProjectGenerator

func NewProjectGenerator(name, path, modulePath string) *ProjectGenerator

NewProjectGenerator creates a new project generator

func (*ProjectGenerator) ApplyKindFeatureDefaults

func (g *ProjectGenerator) ApplyKindFeatureDefaults(kind string)

ApplyKindFeatureDefaults is the public entry point invoked by `forge project new` after parsing the --kind flag. It delegates to the private applyKindFeatureDefaults helper so the scaffold-time matrix lives in one place. Exposed publicly so other callers (tests, sibling tools) can derive the same defaults from a kind string without duplicating the per-feature decisions.

func (*ProjectGenerator) Generate

func (g *ProjectGenerator) Generate() error

Generate creates the project structure.

type ProjectMemoryData added in v1.0.0

type ProjectMemoryData struct {
	// Name is the project's name (forge.yaml `name:`).
	Name string
	// CLI is how the user invokes forge: "forge", or "reliant forge" when
	// embedded.
	CLI string
	// Site reports a static site: a project with no Go module, made of the
	// app at its root and its deploy envs (`forge project new --kind site`).
	// Its memory is the frontend-only quick reference; every other project's
	// is the Go one. Derived from the tree (config.DiscoverRootFrontend),
	// like every other fact about a project's shape, so no flag can disagree
	// with what is on disk.
	Site bool
}

ProjectMemoryData is what the framework memory template (project/reliant.md.tmpl) renders from — on disk for a harness that reads a file, and live for reliant (cli.RenderProjectMemory).

type ProtoPackageResolution added in v0.1.45

type ProtoPackageResolution struct {
	// Package is the concrete proto package to declare, e.g.
	// "controlplane.v1" or "services.billing.v1".
	Package string
	// Source is the rule that chose Package.
	Source ProtoPackageSource
	// Votes and Total describe an inference: Votes of Total existing
	// service protos agree on the winning convention. Zero otherwise.
	Votes, Total int
	// Dissenters lists the existing service protos that declared something
	// else ("proto/services/project/v1/project.proto (reliant.v1)"), so an
	// outlier is visible rather than silently overruled.
	Dissenters []string
}

ProtoPackageResolution is the package chosen for one service, and why.

func ResolveServiceProtoPackage added in v0.1.45

func ResolveServiceProtoPackage(root, serviceName string) ProtoPackageResolution

ResolveServiceProtoPackage decides the proto package for a service being scaffolded into the project at root. serviceName is the CLI/display form; it is normalized with naming.ServicePackage.

Precedence:

  1. forge.yaml `api.proto_package` — the explicit declaration always wins. It is a fixed package ("platform.v1": every service shares it) or a pattern carrying {service} ("acme.{service}.v1").
  2. The project's existing service protos (proto/services/*/v1/*.proto). Each one votes for the CONVENTION it follows: a package that embeds its own service name ("acme.orders.v1" under services/orders/) votes for the pattern "acme.{service}.v1"; anything else ("controlplane.v1") votes for that literal shared package. A convention held by a strict majority wins.
  3. DefaultServiceProtoPackagePattern.

Majority, not unanimity, is deliberate. A real project keeps deliberate outliers — control-plane declares twenty services in `controlplane.v1` beside one `reliant.v1` stub that mirrors another product's wire API — and requiring every proto to agree would hand such a project the fallback forever. The outliers are reported (Dissenters), never hidden. A tie is not a convention, so it falls back.

The service being scaffolded does not vote: on `--force` its own stub is already on disk, and the package forge wrote last time must not decide the package forge writes now.

func (ProtoPackageResolution) Describe added in v0.1.45

func (r ProtoPackageResolution) Describe() string

Describe renders the resolution as one line for the scaffold output.

type ProtoPackageSource added in v0.1.45

type ProtoPackageSource string

ProtoPackageSource names which rule chose a scaffolded service's proto package, so the CLI can say why — and where to change it.

const (
	// ProtoPackageConfigured means forge.yaml `api.proto_package` declared it.
	ProtoPackageConfigured ProtoPackageSource = "forge.yaml api.proto_package"
	// ProtoPackageInferred means a strict majority of the project's existing
	// service protos agree on it.
	ProtoPackageInferred ProtoPackageSource = "inferred from existing service protos"
	// ProtoPackageDefault means neither of the above: forge's own default.
	ProtoPackageDefault ProtoPackageSource = "default"
)

type ScaffoldMode

type ScaffoldMode int

ScaffoldMode controls how scaffolding functions handle pre-existing output files. It exists so `forge scaffold service --resume / --force` can re-run a partial scaffold without forcing the user to rm -rf and start over.

const (
	// ScaffoldFail is the default: writes unconditionally. Callers are
	// expected to have done their own up-front "does this name conflict"
	// check (forge.yaml services: list). Equivalent to pre-resume behavior.
	ScaffoldFail ScaffoldMode = iota
	// ScaffoldResume skips any output file that already exists on disk.
	// Use this to recover from a partial scaffold where buf generate (or a
	// later pipeline step) failed mid-flight and left some files written.
	ScaffoldResume
	// ScaffoldForce overwrites every output file, even when present. The
	// proto file is included — the only file the default codepath
	// historically preserved — so users can fully re-stamp a scaffold.
	ScaffoldForce
)

type Service

type Service interface {
	// Plan-mode generators.
	GeneratePlanORM(root string, orm config.ORMConfig, entities []config.PlanEntity, cs *FileChecksums) error
	GeneratePlanProtoFile(root, modulePath, serviceName string, rpcs []config.PlanRPC, entities []config.PlanEntity) error

	// Component scaffolders.
	GenerateServiceFiles(root, modulePath, serviceName, projectName string) error
	GenerateWorkerFiles(root, modulePath, workerName, kind, schedule string) error
	GenerateOperatorFiles(root, modulePath, name, group, version string) error
	GenerateWebhookFiles(root, modulePath, serviceName, webhookName string) error
	GenerateFrontendFiles(root, modulePath, projectName, frontendName string, apiPort int, kind string) error
	EnsureCoreComponents(frontendDir string) error
	GenerateE2ETests(projectDir, serviceName, modulePath, projectName string, methods []E2EMethodInfo) error

	// Project-level upgrade / regeneration.
	RegenerateInfraFiles(projectDir string, cfg *config.ProjectConfig) error
	Upgrade(projectDir string, cfg *config.ProjectConfig, force bool, checkOnly bool) ([]UpgradeResult, error)

	// Checksum-aware file writes (used by the bootstrap orchestrator).
	WriteGeneratedFile(root, relPath string, content []byte, cs *FileChecksums, force bool) (bool, error)
	HashContent(content []byte) string
	LoadChecksums(root string) (*FileChecksums, error)
	SaveChecksums(root string, cs *FileChecksums) error
}

Service is the file-emission surface of the generator package. Methods write into the user-project working directory; callers wanting finer-grained mocking can stub one method at a time.

func New

func New(_ Deps) Service

New constructs the file-emission Service.

forge:no-observe Pure compute: empty Deps; renders templates and writes files during a CLI run. No request lifecycle, no remote dependency.

type SiteFrontend added in v1.0.0

type SiteFrontend struct {
	// Name is the forge.Frontend name every env declares it under.
	Name string
	// Type is the KCL forge.Frontend.type: "vite" or "nextjs".
	Type string
	// PublicDir is where its build lands, relative to the root ("dist" for
	// Vite, "out" for a Next.js static export).
	PublicDir string
}

SiteFrontend is the frontend a static site is made of: the app at the project root (`forge project new --in-place --kind site`).

func SiteStarterFrontend added in v1.0.0

func SiteStarterFrontend(kind, name string) (SiteFrontend, bool)

SiteStarterFrontend returns the SiteFrontend a greenfield site of the given kind ("nextjs" or "vite-spa") declares under name, or false for a kind there is no starter for.

type Tier1DriftEntry

type Tier1DriftEntry = checksums.Tier1DriftEntry

Tier1DriftEntry is re-exported as a type alias from internal/checksums.

func FilterTier2Managed added in v0.1.45

func FilterTier2Managed(drift []Tier1DriftEntry) []Tier1DriftEntry

FilterTier2Managed drops drift entries for Tier-2-managed paths.

func ScanProjectDrift added in v0.1.45

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

ScanProjectDrift returns every forge-owned file under root whose on-disk bytes are positive evidence of a hand-edit, minus the Tier-2-managed exemption.

What it CANNOT report, by construction — the false-positive classes every gate depends on being absent:

  • scaffold-once ("yours") files carry no marker at all, so they never enter the scan;
  • disowned paths are skipped (and disowning strips the marker anyway);
  • files forge never wrote carry no marker;
  • a pristine render of an OLDER vintage verifies against its own embedded hash, so "the user hasn't re-run generate yet" is Pristine, not drift;
  • formatter-only byte churn on .go files re-hashes to the certified render after canonical formatting and is likewise Pristine.

type UpgradeResult

type UpgradeResult struct {
	Path   string        // relative path in project (e.g. "cmd/server.go")
	Status UpgradeStatus // what happened
	Diff   string        // unified-style diff when file changed
	// Forced is true when this file was (or would be) written only
	// because the force selection covered it — i.e. the user's edits
	// were discarded. Refreshing a pristine render never sets it.
	Forced bool
	// Missing and Local size the delta the same way the advisory lane
	// does (lineDelta): template lines this copy lacks, and lines this
	// copy has that no template line accounts for.
	//
	// They exist so the report can state the SIZE of a difference
	// without printing the difference. A file two lines behind with
	// nothing of its own is a one-command adopt; one carrying forty
	// local lines is a merge done by hand. That gap is the report's
	// ranking, and it cannot be read off a status word.
	Missing int
	Local   int
	// Absent marks a managed file the template ships that this project
	// does not have on disk. Adoption is a pure add — it destroys
	// nothing — which is a different (and cheaper) act than refreshing
	// a file that exists, so the report ranks it separately.
	Absent bool
	// LeftDeleted marks a scaffold-once file that is absent because the
	// project deleted, declined or disowned it. Upgrade leaves it so.
	LeftDeleted bool
}

UpgradeResult holds the outcome for a single managed file.

func Upgrade

func Upgrade(projectDir string, cfg *config.ProjectConfig, force bool, checkOnly bool) ([]UpgradeResult, error)

Upgrade checks all managed (frozen) files against the current templates and optionally applies updates.

When checkOnly is true, no files are written — it only reports what would change. When force is true, EVERY user-modified file is overwritten. Callers that want to adopt specific files should use UpgradeSelection.

func UpgradeSelection added in v0.1.45

func UpgradeSelection(projectDir string, cfg *config.ProjectConfig, force ForceSelection, checkOnly bool) ([]UpgradeResult, error)

UpgradeSelection is Upgrade with a per-path force selection: only the files the selection covers have user edits overwritten, everything else follows the ordinary rules (pristine renders auto-update, user-modified files are reported and skipped).

A disowned file is never written regardless of the selection — ownership transfer outranks force, which is the whole reason `forge project disown` is the durable answer to "this file is mine now".

type UpgradeStatus

type UpgradeStatus string

UpgradeStatus describes the outcome for each managed file.

const (
	UpgradeUpToDate     UpgradeStatus = "up-to-date"
	UpgradeUpdated      UpgradeStatus = "updated"
	UpgradeUserModified UpgradeStatus = "user-modified"
	UpgradeSkipped      UpgradeStatus = "skipped"
)

The per-file outcomes an upgrade can report.

Directories

Path Synopsis
Package contract drives the four *_gen.go files (mock, middleware, tracing, metrics) emitted from a single hand-written contract.go.
Package contract drives the four *_gen.go files (mock, middleware, tracing, metrics) emitted from a single hand-written contract.go.

Jump to

Keyboard shortcuts

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