forgeconv

package
v1.0.13 Latest Latest
Warning

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

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

Documentation

Overview

File: internal/linter/forgeconv/allocate_port_spacing.go

The forgeconv-allocate-port-spacing analyzer catches a defect class in the parallel-dev-stack port allocator that is invisible until a project's stack ceiling grows: two DIFFERENT `allocate_port(base, key)` base literals that are congruent modulo BlockSize collide at whichever block their separation, in blocks, equals. `allocate_port` computes `base + block(key)*BlockSize` (see internal/kclplugin/resolver.go and internal/config.DevStackConfig); if base1 and base2 share a remainder mod BlockSize, then at block N = (base2-base1)/BlockSize the FIRST base's port equals the SECOND base's literal value — two listeners bound to the same port number, in the same dev stack.

The defect, concretely

control-plane's deploy/kcl/dev/main.k declares:

_gateway_controller_port = fp.allocate_port(28090, _key)  // line 164
_gateway_grpc_port       = fp.allocate_port(29190, _key)  // line 155

Both are ≡ 90 (mod 100 — the default BlockSize). They are 1100 apart, i.e. 11 blocks. At block 11, 28090 + 11*100 = 29190: the controller port for the 11th worktree equals the DEFAULT stack's gateway grpc port. Two unrelated listeners cross.

This has shipped invisibly because config.DefaultMaxStacks is 8 — the reachable block range is 0..7, well short of 11. The moment MaxStacks is raised past 11 (the whole point of making it configurable — see config.DevStackConfig's doc comment), this collision goes live. This rule is the prerequisite that makes raising the ceiling safe: it is meant to be run, and to newly fire, as part of evaluating any max_stacks increase.

Why the finding depends on max_stacks — load-bearing, not a bug

Condition (b) below — is the colliding block actually reachable at the project's configured max_stacks — is what keeps this rule actionable rather than noise. Every base pair that merely shares a remainder mod BlockSize is a LATENT instance of the same defect class (see the 8090/28090 pair below, 200 blocks apart), but flagging all of them unconditionally would make the rule fire on every dev/main.k that ever existed, most of which will never actually run 200 parallel stacks. A reader who raises max_stacks and reruns `forge lint` and sees a NEW finding that did not exist yesterday is seeing the rule do exactly what it is for.

What is scanned

The analyzer walks deploy/kcl/**/*.k for `allocate_port(<int>, ...)` call sites, matching on the function name only — it does not assume the `fp` alias real projects use for `import kcl_plugin.forge as fp`. KCL's `#` line comments are stripped (quote-aware, so a `#` inside a string literal is not mistaken for one) before matching, so a comment that merely MENTIONS `allocate_port(28090, ...)` for exposition (this package's own doc comment above does exactly that) is not double counted as a real call site.

Pairs are compared WITHIN one file only. Two different deploy environments (dev/main.k vs prod/main.k) do not share a running process group or a k3d port-mapping table, so a base collision across them is not a reachable defect — scoping per file is what keeps `3000` appearing once per environment from being flagged against itself.

Severity: error, not warning

Unlike forgeconv's heuristic analyzers (handler-file-size, no-handler-error-mapping), there is no false-positive risk here: the arithmetic is exact, not a pattern guess, and a fired finding describes a real port collision that WILL happen the moment a worktree's block reaches the stated number. That is the same bar forgeconv-unwrapped-domain-error and forgeconv-one-service-per-file hold their callers to — a structural certainty, not a style nit — so this rule gates the build like they do.

Package forgeconv implements lint rules that enforce forge codegen conventions on proto files. The analyzers here catch shapes that would otherwise fail silently or blow up at generate time, surfacing them as actionable lint findings with explicit remediation before `forge generate` runs.

The full list of rules:

forgeconv-one-service-per-file      one service per .proto, full stop
forgeconv-service-dir-consistency   proto service name must match its
                                    proto/services/<dir>/ directory
forgeconv-list-filter-optional      a List request's scalar/enum filter
                                    fields must be `optional`
                                    (see list_filter_optional.go)
forgeconv-activity-declared         every mutating rpc declares its
                                    activity event, or `none`
                                    (see activity_declared.go)
forgeconv-allocate-port-spacing     two allocate_port() base literals
                                    congruent mod BlockSize collide at
                                    a block reachable within MaxStacks
                                    (see allocate_port_spacing.go)
forgeconv-config-service-name       a config field must not bind
                                    OTEL_SERVICE_NAME, a per-workload
                                    fact forge.render derives
                                    (see config_service_name.go)
forgeconv-bun-dollar-placeholder    `$N` in SQL bun formats (NewRaw,
                                    the builders), which binds only `?`
forgeconv-bun-literal-question-mark a `?` bun will bind by mistake: in
                                    a comment, a string literal, or a
                                    jsonb ?/?|/?& operator
                                    (see bun_placeholders.go)
forgeconv-tx-lock-under-serializable a SERIALIZABLE transaction whose
                                    body takes a lock (FOR UPDATE, an
                                    advisory lock, LOCK TABLE); warning,
                                    error under --strict
                                    (see tx_lock_under_serializable.go)
forgeconv-deps-surface              a Deps interface field declares
                                    methods the package never calls
forgeconv-no-container-deps         a Deps field holds a container
                                    (internal/app types, cmd.Deps, a
                                    locator, `any`)
forgeconv-deps-no-downcast          a type assertion from a Deps field
                                    to a concrete type
forgeconv-no-getenv                 a component package reads the
                                    process environment itself
forgeconv-deps-root-config          a Deps field holds the root config
                                    (--strict only, a warning)
                                    (see least_knowledge.go)

`auth_required` is lint-free here: it is enforced at runtime (projected into pkg/middleware/procedures_gen.go, read fail-closed by the auth interceptor), and forge reads no authorization annotations beyond it.

The package exposes a single LintProtoTree entry point that takes a project root (or any directory containing .proto files) and returns a Result. Findings are emitted in deterministic order (file, then byte position) so output is stable across runs.

Index

Constants

View Source
const (
	SeverityError   = finding.SeverityError
	SeverityWarning = finding.SeverityWarning
)

Severity enum values (aliases onto the canonical single-spelling set).

View Source
const (
	RuleDepsSurface     = "forgeconv-deps-surface"
	RuleNoContainerDeps = "forgeconv-no-container-deps"
	RuleDepsNoDowncast  = "forgeconv-deps-no-downcast"
	RuleNoGetenv        = "forgeconv-no-getenv"
	RuleDepsRootConfig  = "forgeconv-deps-root-config"
)

Rule ids.

View Source
const RuleFrontendProcessEnv = "forgeconv-frontend-process-env"

RuleFrontendProcessEnv is the rule ID, addressable in forge.yaml's `lint.rules` map and in per-line suppression directives.

Variables

This section is empty.

Functions

func HookKind added in v1.0.0

func HookKind(query bool) string

HookKind names a hook kind the way the findings and generate's report do.

func HookTestRowFix added in v1.0.0

func HookTestRowFix(hook string, query bool) string

HookTestRowFix is the body a stale row's renderHook and assertions are replaced with, for a hook of the given kind — the starter template's own lines, except that a mutation row waits for the rendered status inline, because a file scaffolded for a query-only service declares no waitForSettled helper to call.

func ParseHookKinds added in v1.0.0

func ParseHookKinds(src string) map[string]bool

ParseHookKinds maps each exported hook of a generated hooks module to its kind: true for a useQuery hook, false for a useMutation hook.

func StaleRowsOrphanSettleHelper added in v1.0.0

func StaleRowsOrphanSettleHelper(testSrc string, isQuery map[string]bool) bool

StaleRowsOrphanSettleHelper reports whether rewriting every stale row leaves the starter's waitForSettled helper with no caller. The helper exists only for mutation rows, so when the last one becomes a query row the declaration must go too — or no-unused-vars flags the file.

Types

type Finding

type Finding = finding.Finding

Finding is the shared linter finding shape, re-exported under the historical forgeconv spelling. forgeconv findings populate Rule/Severity/File/Line/Message/Remediation.

type HookTestRow added in v1.0.0

type HookTestRow struct {
	// Hook is the hook the row exercises ("useGetKey").
	Hook string
	// Line is the 1-based line of the row's `it(` call.
	Line int
	// Query is true for the query idiom (await isSuccess) and false for
	// the mutation idiom (fire mutateAsync).
	Query bool
}

HookTestRow is one row of a hook test written in one of the starter's two idioms.

func StaleHookTestRows added in v1.0.0

func StaleHookTestRows(testSrc string, isQuery map[string]bool) []HookTestRow

StaleHookTestRows returns the rows of testSrc whose idiom contradicts the kind of the hook they exercise. isQuery maps a hook name to true for a useQuery hook and false for a useMutation hook; a row about a hook absent from it (another module's, or one forge no longer generates) is skipped.

It is the one comparison both `forge lint` (reading the kinds off the generated hooks file) and `forge generate` (holding them in memory) make, so the two cannot disagree about which rows are stale.

func StarterHookTestRows added in v1.0.0

func StarterHookTestRows(src string) []HookTestRow

StarterHookTestRows returns every row of a hook test that uses one of the starter's two idioms, in file order. A row using neither — a hand-written test — is left out: its assertions are the user's business.

type LintOptions

type LintOptions struct {
	// Strict escalates advisory findings to errors: the least-knowledge
	// rules and forgeconv-tx-lock-under-serializable. No proto rule
	// escalates on it.
	Strict bool
}

LintOptions tunes the proto convention analyzers. The zero value is the default (advisory) posture; callers opt into stricter gating.

type Result

type Result struct {
	Findings []Finding `json:"findings"`
}

Result aggregates findings from a single lint run. It is a distinct type (not an alias) so forgeconv can hang its own FormatText rendering on it; the finding vocabulary inside is the shared one.

func LintAllocatePortSpacing added in v0.1.45

func LintAllocatePortSpacing(rootDir string, maxStacks, blockSize int) (Result, error)

LintAllocatePortSpacing walks rootDir/deploy/kcl for *.k files and flags pairs of allocate_port base literals, within the same file, that collide at a reachable block. maxStacks and blockSize should come from the project's config.DevStackConfig (EffectiveMaxStacks / EffectiveBlockSize) — a value <= 0 is treated as "unset" and falls back to the same config.Default* the allocator itself uses, so a caller that doesn't have a loaded project config yet still gets a meaningful scan.

A missing deploy/kcl directory is not an error — CLI/library projects and any project that hasn't scaffolded a deploy tree yet have nothing to scan.

func LintBunPlaceholders added in v0.1.46

func LintBunPlaceholders(rootDir string) (Result, error)

LintBunPlaceholders walks rootDir for Go files and reports SQL in bun-formatted calls that is written for the other placeholder dialect. Generated files (gen/, *_gen.go), vendor/, node_modules/ and testdata/ are skipped. Findings are in deterministic order.

func LintFrontendEnvFiles added in v0.1.45

func LintFrontendEnvFiles(root string, feDirs []string, sev finding.Severity) Result

LintFrontendEnvFiles scans each frontend source directory for committed dotenv files (.env / .env.local / .env.<env>) that hard-code a forge-owned frontend variable, and reports each as a finding at the given severity.

The severity is the caller's — this is the SINGLE analyzer behind the dev/build severity split: `forge lint` calls it at SeverityWarning (a nudge, never gates), while the build/deploy path calls it at SeverityError (a hard-coded forge-owned var — mock above all — must not reach a shipped build). Routing both through one analyzer keeps the two verdicts from ever diverging.

feDirs are frontend source dirs (absolute, or resolvable by the caller); root is used only to relativize the reported File path for stable output. A missing / unreadable frontend dir is silently skipped — this rule flags committed files, not absent ones.

func LintFrontendHookTestShape added in v0.1.45

func LintFrontendHookTestShape(rootDir string) (Result, error)

LintFrontendHookTestShape walks rootDir/frontends/*/src/hooks/ and rootDir/packages/hooks/src/generated/ for generated hooks files, pairs each with its scaffold-once sibling test, and reports every row whose test asserts the opposite kind. A missing frontends/ tree, or a hooks file with no sibling test, produces no finding — the latter belongs to forgeconv-frontend-hook-tests.

func LintFrontendHookTests

func LintFrontendHookTests(rootDir string) (Result, error)

LintFrontendHookTests walks rootDir/frontends/*/src/hooks/ for *-hooks.ts files and warns when no sibling test file or starter is present. Returns findings ordered by (file, line) to keep CI logs stable. A missing frontends/ tree is not an error — projects without any frontends produce an empty Result.

func LintFrontendProcessEnv added in v0.1.45

func LintFrontendProcessEnv(root string, feDirs []string, sev finding.Severity) Result

LintFrontendProcessEnv scans each frontend for source files that read process.env / import.meta.env directly instead of the generated typed config module, and reports each read at the given severity.

The severity is the caller's, matching LintFrontendEnvFiles next door: one analyzer behind every verdict, so an advisory `forge lint` run and a gating one can never disagree about what is a violation.

root relativizes reported paths; feDirs are the frontend roots. A frontend with no generated config module is skipped entirely — see the file header for why that precondition is load-bearing.

func LintFrontendStores

func LintFrontendStores(rootDir string) (Result, error)

LintFrontendStores scans every frontend's src/stores/ and src/store/ (frontends/<name>/ and web/) for files that both spin up a Zustand store AND pull in a generated Connect client. Warnings only. A project with no frontends at all gets an empty result.

Findings are sorted by (file, line, rule) for stable output. Line is the line where reZustandCreate hits (the user's eye goes to the store-factory call, not the import line).

func LintHandlerErrorMapping

func LintHandlerErrorMapping(rootDir string) (Result, error)

LintHandlerErrorMapping walks rootDir for Go files under handlers/ directories and runs the handler error rules over each:

  • forgeconv-no-handler-error-mapping — a hand-rolled service-error → connect.Error helper (this file). Warning.
  • forgeconv-unwrapped-domain-error — a domain error returned across the RPC boundary with nothing applied to it (unwrapped_domain_error.go). Error.
  • forgeconv-handler-connect-error — a hand-built connect.NewError, which publishes its error's text unredacted (handler_connect_error.go). Warning; a project flips it to error once migrated.

Returns findings in deterministic order.

The walk recognises any directory whose path component is exactly "handlers" as the canonical handler tree (matches the project-template `internal/handlers/<svc>/` shape). Test files (_test.go) are skipped — fixtures and table tests sometimes legitimately construct connect errors for assertions.

func LintHandlerFileSize

func LintHandlerFileSize(rootDir string, threshold int) (Result, error)

LintHandlerFileSize walks rootDir/handlers/ for *.go files and warns on any whose source-LOC count exceeds threshold. Test files (*_test.go) and generated files (*_gen.go) are excluded — they have their own size dynamics (table-driven tests legitimately go long; generated files aren't user-owned). A missing handlers/ directory is not an error (CLI / library projects).

Findings are emitted in deterministic order (file, then rule).

func LintHandlerTests

func LintHandlerTests(rootDir string) (Result, error)

LintHandlerTests walks rootDir/handlers/ for *_test.go files and warns when each looks like the hand-rolled `tests := []struct{name, call}` shape rather than `tdd.RunRPCCases`. Returns findings in deterministic order (file, then line). A missing internal/handlers/ directory is not an error — projects without an internal/handlers/ tree (CLI, library kinds) get an empty result.

func LintLeastKnowledge added in v1.0.0

func LintLeastKnowledge(root string, opts LintOptions) (Result, error)

LintLeastKnowledge runs the least-knowledge rules over the component packages under root/internal.

func LintOptionalDepMarkerPosition

func LintOptionalDepMarkerPosition(rootDir string) (Result, error)

LintOptionalDepMarkerPosition walks rootDir's internal/handlers/, internal/workers/, and internal/operators/ trees for files whose comments mention the `forge:optional-dep` marker, and emits a finding when the marker is not attached to a `Deps` struct field. Returns findings in deterministic order (file, then line). Missing component dirs are not an error — projects may ship none of them.

func LintProtoTree

func LintProtoTree(rootDir string) (Result, error)

LintProtoTree walks rootDir for .proto files and runs every analyzer in the default (advisory) posture. Thin wrapper over LintProtoTreeOpts kept for the existing call sites + tests.

func LintProtoTreeOpts

func LintProtoTreeOpts(rootDir string, opts LintOptions) (Result, error)

LintProtoTreeOpts walks rootDir for .proto files and runs every analyzer with the supplied options. Files under proto/forge/ (vendored forge annotation protos) are skipped — they're external definitions, not user code. Returns a deterministic Result ordered by (file, line, rule).

func LintTxLockUnderSerializable added in v1.0.0

func LintTxLockUnderSerializable(rootDir string, opts LintOptions) (Result, error)

LintTxLockUnderSerializable walks rootDir for Go files and reports SERIALIZABLE transactions that take a lock — through pkg/orm's openers and every one declared with //forge:tx-opener. Generated files (gen/, *_gen.go), vendor/, node_modules/, testdata/ and pkg/orm itself are skipped. Findings are in deterministic order.

func (Result) FormatText

func (r Result) FormatText() string

FormatText renders findings as a human-readable report. Empty result produces an empty string so callers can prefix their own success line.

func (Result) HasErrors

func (r Result) HasErrors() bool

HasErrors returns true if any finding has Severity == SeverityError. Used by `forge lint` to decide exit status.

type Severity

type Severity = finding.Severity

Severity is the shared finding severity vocabulary, re-exported under the historical forgeconv spelling.

Jump to

Keyboard shortcuts

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