Skip to content

Commit b2a4b64

Browse files
[linter-miner] feat(linters): add timenowsub linter — flag time.Now().Sub(t) → time.Since(t) (#46633)
1 parent 35709a9 commit b2a4b64

9 files changed

Lines changed: 302 additions & 0 deletions

File tree

cmd/linters/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import (
6363
"github.com/github/gh-aw/pkg/linters/stringscountcontains"
6464
"github.com/github/gh-aw/pkg/linters/stringsindexcontains"
6565
"github.com/github/gh-aw/pkg/linters/timeafterleak"
66+
"github.com/github/gh-aw/pkg/linters/timenowsub"
6667
"github.com/github/gh-aw/pkg/linters/timesleepnocontext"
6768
"github.com/github/gh-aw/pkg/linters/tolowerequalfold"
6869
"github.com/github/gh-aw/pkg/linters/trimleftright"
@@ -121,6 +122,7 @@ func main() {
121122
lenstringsplit.Analyzer,
122123
timeafterleak.Analyzer,
123124
timesleepnocontext.Analyzer,
125+
timenowsub.Analyzer,
124126
tolowerequalfold.Analyzer,
125127
trimleftright.Analyzer,
126128
uncheckedtypeassertion.Analyzer,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# ADR-46633: Add timenowsub Custom Linter for time.Now().Sub(t) → time.Since(t)
2+
3+
**Date**: 2026-07-19
4+
**Status**: Draft
5+
**Deciders**: Unknown
6+
7+
---
8+
9+
### Context
10+
11+
The repository maintains a collection of custom Go static analysis linters (under `pkg/linters/`) enforced via `cmd/linters/main.go`. A static scan of `pkg/` and `cmd/` identified occurrences of the verbose `time.Now().Sub(t)` pattern, which has a direct idiomatic replacement: `time.Since(t)`. Go's standard library documents `time.Since(t)` as shorthand for `time.Now().Sub(t)` (see [Go issue #16351](https://github.com/golang/go/issues/16351)), and this simplification is consistently flagged in Go code reviews. The pattern produces zero false positives since every `time.Now().Sub(x)` call has an exact mechanical replacement.
12+
13+
### Decision
14+
15+
We will add a new custom `timenowsub` analyzer to `pkg/linters/timenowsub/` and register it in `cmd/linters/main.go`. The analyzer uses `golang.org/x/tools/go/analysis` to detect AST nodes matching `time.Now().Sub(<arg>)` and reports a diagnostic with a `SuggestedFix` rewriting them to `time.Since(<arg>)`. Generated files and files with `//nolint:timenowsub` directives are skipped.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Enable gosimple (S1012) from golangci-lint / staticcheck
20+
21+
`gosimple` check S1012 already covers this exact pattern. Enabling it from the broader `golangci-lint` / `staticcheck` toolchain would address the issue without writing custom code.
22+
23+
This was not chosen because: the repository's custom linter framework provides consistent enforcement, nolint-directive handling, generated-file skipping, and test infrastructure that the generic `gosimple` integration does not supply out of the box. Relying on `gosimple` also requires maintaining the `golangci-lint` configuration and ensuring S1012 is not accidentally disabled, whereas a custom linter is unconditionally active.
24+
25+
#### Alternative 2: Rely on code review without automated enforcement
26+
27+
Reviewers could flag `time.Now().Sub(t)` patterns manually during PR review without any tooling.
28+
29+
This was not chosen because: manual code review is inconsistent and does not scale — patterns are missed, especially in large diffs. The zero-false-positive nature of this check makes automated enforcement strictly better than human review for this specific pattern.
30+
31+
### Consequences
32+
33+
#### Positive
34+
- Zero false positives: every flagged `time.Now().Sub(x)` call has a safe mechanical replacement.
35+
- Automatic fix: the `SuggestedFix` in the diagnostic allows `gopls` and `go fix`-style tools to apply the rewrite with no manual intervention.
36+
- Unconditional enforcement: the linter is always active regardless of golangci-lint configuration changes.
37+
- Consistent with existing linter patterns: follows the same structure as other custom linters in `pkg/linters/`.
38+
39+
#### Negative
40+
- New package to maintain: adds `pkg/linters/timenowsub/` to the custom linter collection, which must be updated if internal shared utilities (e.g., `astutil`, `filecheck`, `nolint`) change their APIs.
41+
- Slightly increases binary size of the `cmd/linters` tool.
42+
43+
#### Neutral
44+
- The linter only fires on `time.Now().Sub(x)` where the receiver is verified via type-checker to be `time.Now` — other `.Sub()` calls (e.g., `a.Sub(b)`) are unaffected.
45+
- Test coverage is provided via `analysistest.RunWithSuggestedFixes` and a golden file, following the established test pattern for this linter suite.
46+
47+
---
48+
49+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package time
2+
3+
import stdtime "time"
4+
5+
type Time struct{}
6+
7+
func Now() Time {
8+
return Time{}
9+
}
10+
11+
func (Time) Sub(Time) stdtime.Duration {
12+
return 0
13+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package timenowsub
2+
3+
import clock "time"
4+
5+
func badAlias(t clock.Time) {
6+
_ = clock.Now().Sub(t) // want `clock\.Now\(\)\.Sub\(t\) can be simplified to clock\.Since\(t\)`
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package timenowsub
2+
3+
import clock "time"
4+
5+
func badAlias(t clock.Time) {
6+
_ = clock.Since(t) // want `clock\.Now\(\)\.Sub\(t\) can be simplified to clock\.Since\(t\)`
7+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package timenowsub
2+
3+
import (
4+
faketime "faketime/time"
5+
"time"
6+
)
7+
8+
func bad(t time.Time) {
9+
_ = time.Now().Sub(t) // want `time\.Now\(\)\.Sub\(t\) can be simplified to time\.Since\(t\)`
10+
}
11+
12+
func badAssign(start time.Time) time.Duration {
13+
return time.Now().Sub(start) // want `time\.Now\(\)\.Sub\(start\) can be simplified to time\.Since\(start\)`
14+
}
15+
16+
func good(t time.Time) {
17+
_ = time.Since(t)
18+
}
19+
20+
func goodOtherSub(a, b time.Time) {
21+
_ = a.Sub(b)
22+
}
23+
24+
func goodCallExprArg() {
25+
_ = time.Now().Sub(loadStart())
26+
}
27+
28+
func goodOtherTimePackage(t faketime.Time) {
29+
_ = faketime.Now().Sub(t)
30+
}
31+
32+
func loadStart() time.Time {
33+
return time.Now()
34+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package timenowsub
2+
3+
import (
4+
faketime "faketime/time"
5+
"time"
6+
)
7+
8+
func bad(t time.Time) {
9+
_ = time.Since(t) // want `time\.Now\(\)\.Sub\(t\) can be simplified to time\.Since\(t\)`
10+
}
11+
12+
func badAssign(start time.Time) time.Duration {
13+
return time.Since(start) // want `time\.Now\(\)\.Sub\(start\) can be simplified to time\.Since\(start\)`
14+
}
15+
16+
func good(t time.Time) {
17+
_ = time.Since(t)
18+
}
19+
20+
func goodOtherSub(a, b time.Time) {
21+
_ = a.Sub(b)
22+
}
23+
24+
func goodCallExprArg() {
25+
_ = time.Now().Sub(loadStart())
26+
}
27+
28+
func goodOtherTimePackage(t faketime.Time) {
29+
_ = faketime.Now().Sub(t)
30+
}
31+
32+
func loadStart() time.Time {
33+
return time.Now()
34+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Package timenowsub implements a Go analysis linter that flags
2+
// time.Now().Sub(t) calls that can be simplified to time.Since(t).
3+
package timenowsub
4+
5+
import (
6+
"fmt"
7+
"go/ast"
8+
"go/types"
9+
10+
"golang.org/x/tools/go/analysis"
11+
"golang.org/x/tools/go/analysis/passes/inspect"
12+
13+
"github.com/github/gh-aw/pkg/linters/internal/astutil"
14+
"github.com/github/gh-aw/pkg/linters/internal/filecheck"
15+
"github.com/github/gh-aw/pkg/linters/internal/nolint"
16+
)
17+
18+
// Analyzer is the time-now-sub analysis pass.
19+
var Analyzer = &analysis.Analyzer{
20+
Name: "timenowsub",
21+
Doc: "reports time.Now().Sub(t) calls that should be simplified to time.Since(t)",
22+
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/timenowsub",
23+
Requires: []*analysis.Analyzer{inspect.Analyzer, nolint.Analyzer, filecheck.Analyzer},
24+
Run: run,
25+
}
26+
27+
func run(pass *analysis.Pass) (any, error) {
28+
insp, err := astutil.Inspector(pass)
29+
if err != nil {
30+
return nil, err
31+
}
32+
noLintIndex, err := nolint.Index(pass)
33+
if err != nil {
34+
return nil, err
35+
}
36+
generatedFiles, err := filecheck.Index(pass)
37+
if err != nil {
38+
return nil, err
39+
}
40+
41+
nodeFilter := []ast.Node{
42+
(*ast.CallExpr)(nil),
43+
}
44+
45+
insp.Preorder(nodeFilter, func(n ast.Node) {
46+
outer, ok := n.(*ast.CallExpr)
47+
if !ok {
48+
return
49+
}
50+
51+
// Match <expr>.Sub(<arg>) where <expr> is time.Now().
52+
sel, ok := outer.Fun.(*ast.SelectorExpr)
53+
if !ok || sel.Sel.Name != "Sub" {
54+
return
55+
}
56+
if len(outer.Args) != 1 {
57+
return
58+
}
59+
60+
// Verify the receiver is a call to time.Now().
61+
nowCall, ok := sel.X.(*ast.CallExpr)
62+
if !ok {
63+
return
64+
}
65+
qualifier, ok := timeNowQualifier(pass, nowCall)
66+
if !ok {
67+
return
68+
}
69+
if !isSafeSinceArg(outer.Args[0]) {
70+
return
71+
}
72+
73+
pos := pass.Fset.PositionFor(outer.Pos(), false)
74+
if filecheck.ShouldSkipFilename(pos.Filename, generatedFiles) {
75+
return
76+
}
77+
if nolint.HasDirectiveForLinter(pos, noLintIndex, "timenowsub") {
78+
return
79+
}
80+
81+
argText := astutil.NodeText(pass.Fset, outer.Args[0])
82+
if argText == "" {
83+
return
84+
}
85+
sinceText := qualifier + ".Since(" + argText + ")"
86+
87+
pass.Report(analysis.Diagnostic{
88+
Pos: outer.Pos(),
89+
End: outer.End(),
90+
Message: fmt.Sprintf("%s.Now().Sub(%s) can be simplified to %s", qualifier, argText, sinceText),
91+
SuggestedFixes: []analysis.SuggestedFix{{
92+
Message: fmt.Sprintf("Replace %s.Now().Sub(%s) with %s", qualifier, argText, sinceText),
93+
TextEdits: []analysis.TextEdit{{
94+
Pos: outer.Pos(),
95+
End: outer.End(),
96+
NewText: []byte(sinceText),
97+
}},
98+
}},
99+
})
100+
})
101+
102+
return nil, nil
103+
}
104+
105+
// timeNowQualifier reports the imported identifier used for time.Now().
106+
func timeNowQualifier(pass *analysis.Pass, call *ast.CallExpr) (string, bool) {
107+
if len(call.Args) != 0 {
108+
return "", false
109+
}
110+
sel, ok := call.Fun.(*ast.SelectorExpr)
111+
if !ok || sel.Sel.Name != "Now" {
112+
return "", false
113+
}
114+
ident, ok := sel.X.(*ast.Ident)
115+
if !ok {
116+
return "", false
117+
}
118+
obj := pass.TypesInfo.ObjectOf(ident)
119+
if obj == nil {
120+
return "", false
121+
}
122+
pkgName, ok := obj.(*types.PkgName)
123+
if !ok {
124+
return "", false
125+
}
126+
return ident.Name, pkgName.Imported().Path() == "time"
127+
}
128+
129+
// isSafeSinceArg reports whether expr can be evaluated before time.Now()
130+
// without introducing calls or other potentially observable behavior changes.
131+
func isSafeSinceArg(expr ast.Expr) bool {
132+
switch e := expr.(type) {
133+
case *ast.Ident:
134+
return true
135+
case *ast.ParenExpr:
136+
return isSafeSinceArg(e.X)
137+
default:
138+
return false
139+
}
140+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//go:build !integration
2+
3+
package timenowsub_test
4+
5+
import (
6+
"testing"
7+
8+
"golang.org/x/tools/go/analysis/analysistest"
9+
10+
"github.com/github/gh-aw/pkg/linters/timenowsub"
11+
)
12+
13+
func TestAnalyzer(t *testing.T) {
14+
testdata := analysistest.TestData()
15+
analysistest.RunWithSuggestedFixes(t, testdata, timenowsub.Analyzer, "timenowsub")
16+
}

0 commit comments

Comments
 (0)