Skip to content

staticcheck flags a possible typed-nil interface guard in writ — locally, but not in CI #669

Closed

Description

@David-Noble-at-work

./build/star lint go ./... reports four staticcheck SA4023 findings that CI does not:

cmd/writ/writ/deploy/deploy.go:389:31    SA4023: this comparison is always true
cmd/writ/writ/readback/readback.go:295:12 SA4023(related): the lhs is the 1st return value of this call
cmd/writ/writ/readback/readback.go:296:5  SA4023: this comparison is never true

Both sites guard the same call:

origin := graph.Origin()
if origin == nil || origin.Tool() != "writ" {
	return
}
if origin := graph.Origin(); origin != nil && origin.Scope() != "" {

Why this may be real

op.Origin is an interface (pkg/op/origin.go:16) and Graph.Origin() returns the interface field directly (pkg/op/graph.go:699). SA4023 fires on an impossible interface-vs-nil comparison — the classic typed-nil trap. If a nil *OriginBase is ever boxed into that field, origin != nil evaluates true and the next line calls origin.Tool() on a nil pointer.

The three assignment sites are graph.go:153, :208, and :478. Whether any can box a nil concrete pointer is the thing to check.

If it can, both guards are ineffective and the panic is one nil origin away. If it cannot, the comparisons are dead code that should say so.

Why it matters beyond the two sites

CI does not report these. A run on #668 reported exactly four issues, all of them mine in mem, and none of these. So the local gate and the CI gate disagree about what staticcheck finds.

That divergence is worth understanding on its own: either CI's lint is weaker than a developer's — in which case a class of defect reaches develop unflagged — or the local toolchain (Go 1.27) infers something CI's older toolchain does not, in which case these may be false positives that will start failing CI on the next toolchain bump.

Either way, "green in CI" and "green locally" currently mean different things, and neither is a superset of the other.

Found how

While fixing dead code that CI's lint caught on #668 and my local make check could not — make check's golangci-lint stage fails on Go 1.27's own standard library, so I had been treating lint as locally unavailable. ./build/star lint go ./... is the gate CI actually runs and it works fine locally. Discovering that is what surfaced this.

Activity

  1. David-Noble-at-work commented on Aug 25, 2026

    @David-Noble-at-work
    ContributorAuthor

    Correction: not a nil-panic risk, and not safe to simply delete

    I filed this as a possible typed-nil interface trap. That was wrong, and I reasoned from a pointer receiver I had not checked.

    OriginBase.Tool has a value receiver:

    func (o OriginBase) Tool() string { return o.tool }

    An OriginBase value boxed into the Origin interface is never nil, and its methods work fine on a zero value. So there is no panic risk at either guard — staticcheck is right that the comparisons are impossible, but they are dead guards, not a hazard.

    But deleting them is not obviously safe either

    The three sites that populate Graph.origin are not uniform:

    Site Declared as
    pkg/op/graph.go:145 var graphOrigin OriginBase — a value
    pkg/op/graph.go:808 Origin OriginBase — a value
    pkg/op/graph.go:1083 Origin Origin — an interface

    The third can hold a nil interface. If any path assigns it nil — a decoded document with no origin, say — then origin == nil is meaningful and the guard is load-bearing, and staticcheck's "impossible" is drawn from the other two.

    So the fix is not "remove the dead check." It is to determine whether that third path can produce a nil interface, and then either normalize it to a zero OriginBase (making the guards genuinely dead and removable) or keep the guards and silence the check at the site with a reason.

    Why this is not being fixed opportunistically

    make lint now runs the same tool CI runs (#670), so this finding is red locally. Deleting a nil-guard to turn a gate green — without establishing that nil cannot arrive — is exactly the failure mode that made the gate worth unifying. The red stays until the third path is understood.

    Note the two guards are also asymmetric in consequence: readback.go:295 calls origin.Tool() after the check, while deploy.go:389 calls origin.Scope(). Both are value-receiver methods today, so both are safe today — that safety is a property of OriginBase's receiver choice, not of the guards.

  2. David-Noble-at-work commented on Aug 26, 2026

    @David-Noble-at-work
    ContributorAuthor

    Fixed. make lint reports 0 issues; test counts unchanged at 34 / 69 / 201.

    The open question is answered: the panic could not happen

    This issue asked whether a nil concrete pointer can be boxed into the Origin interface — "if it can, both
    guards are ineffective and the panic is one nil origin away."

    It cannot. graphMetadata.origin is typed OriginBase (pkg/op/graph.go:1207) — the concrete value
    type, not the Origin interface and not a pointer. All three assignment sites store a value:

    • graph.go:145 declares var graphOrigin OriginBase and type-asserts into it, so a nil or non-OriginBase
      spec value yields the zero origin
    • graph.go:208 passes metadata.origin, already OriginBase
    • graph.go:478, the deserialization path, passes p.Origin into that same OriginBase field

    No *OriginBase is boxed anywhere in pkg/ or cmd/, and every Origin method has a value receiver, so
    NewOriginBase returns a value in the first place. Graph.Origin() therefore never returns nil.

    staticcheck was right on all four findings, and they were not false positives. The guards were dead code.

    Fixed

    File Change
    cmd/internal/cli/store.go if origin := graph.Origin(); origin != nil { … } → unconditional; the block always ran
    cmd/writ/writ/deploy/deploy.go dropped origin != nil &&; origin.Scope() != "" was always the real test
    cmd/writ/writ/readback/readback.go dropped `origin == nil
    pkg/op/graph.go documented the invariant on Origin() — never nil, absent origin reports the zero value, callers test Tool/Scope rather than the interface

    The doc comment is what makes it stick: without it the next reader sees an interface return and re-adds a guard.

    The divergence half moves to #670

    This issue also asked why CI did not report these. Measured 2026-08-25 — it is version skew, not tool choice:

    Local CI
    Go 1.27.0 1.26 (ci.yaml:32)
    golangci-lint v2.13.1 v2.12.2, pinned (ci.yaml:73)

    CI already runs both tools — ./build/star lint go ./... (ci.yaml:89) and make lint-all
    (ci.yaml:95). Local make check runs only lint, i.e. golangci-lint run.

    So the gaps run in both directions:

    1. CI is weaker on toolchain — v2.12.2 on Go 1.26 misses what v2.13.1 on Go 1.27 finds. Since these four
      were real defects, CI missed genuine dead code and will begin reporting it on the next toolchain bump.
    2. make check is weaker on GOOS — it runs lint (host only); CI runs lint-all across linux, darwin,
      and windows.

    That is #670's thesis exactly. Closing here; the divergence is tracked there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Epic:QualityCoverageCode quality, coverage, and the lint gatesbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions