Skip to content

Commit b0a55aa

Browse files
committed
refactor: integrate v2 linter engine
Aiming for a surgical change that runs the v1 and v2 linter engines in parallel during a transitional period where we gradually migrate all the v1 rules to the v2 APIs.
1 parent a768d65 commit b0a55aa

13 files changed

Lines changed: 639 additions & 212 deletions

File tree

cmd/api-linter/cli.go

Lines changed: 127 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,11 @@ import (
2020
"fmt"
2121
"os"
2222
"strings"
23-
"sync"
2423

2524
"github.com/aep-dev/api-linter/internal"
2625
"github.com/aep-dev/api-linter/lint"
27-
"github.com/jhump/protoreflect/desc"
28-
"github.com/jhump/protoreflect/desc/protoparse"
26+
lint_v2 "github.com/aep-dev/api-linter/lint/v2"
2927
"github.com/spf13/pflag"
30-
"google.golang.org/protobuf/proto"
31-
dpb "google.golang.org/protobuf/types/descriptorpb"
3228
"gopkg.in/yaml.v2"
3329
)
3430

@@ -106,7 +102,7 @@ func newCli(args []string) *cli {
106102
}
107103
}
108104

109-
func (c *cli) lint(rules lint.RuleRegistry, configs lint.Configs) error {
105+
func (c *cli) lint(rulesV1 lint.RuleRegistry, rulesV2 lint_v2.RuleRegistry, configs lint.Configs) error {
110106
// Print version and exit if asked.
111107
if c.VersionFlag {
112108
fmt.Printf("api-linter %s\n", internal.Version)
@@ -137,60 +133,30 @@ func (c *cli) lint(rules lint.RuleRegistry, configs lint.Configs) error {
137133
configs = append(configs, lint.Config{
138134
DisabledRules: c.DisabledRules,
139135
})
140-
// Prepare proto import lookup.
141-
fs, err := loadFileDescriptors(c.ProtoDescPath...)
136+
137+
// V1 Pipeline
138+
resultsV1, err := c.runV1(rulesV1, configs)
142139
if err != nil {
143140
return err
144141
}
145-
lookupImport := func(name string) (*desc.FileDescriptor, error) {
146-
if f, found := fs[name]; found {
147-
return f, nil
148-
}
149-
return nil, fmt.Errorf("%q is not found", name)
150-
}
151-
var errorsWithPos []protoparse.ErrorWithPos
152-
var lock sync.Mutex
153-
// Parse proto files into `protoreflect` file descriptors.
154-
p := protoparse.Parser{
155-
ImportPaths: c.ProtoImportPaths,
156-
IncludeSourceCodeInfo: true,
157-
LookupImport: lookupImport,
158-
ErrorReporter: func(errorWithPos protoparse.ErrorWithPos) error {
159-
// Protoparse isn't concurrent right now but just to be safe for the future.
160-
lock.Lock()
161-
errorsWithPos = append(errorsWithPos, errorWithPos)
162-
lock.Unlock()
163-
// Continue parsing. The error returned will be protoparse.ErrInvalidSource.
164-
return nil
165-
},
166-
}
167-
// Resolve file absolute paths to relative ones.
168-
protoFiles, err := protoparse.ResolveFilenames(c.ProtoImportPaths, c.ProtoFiles...)
169-
if err != nil {
170-
return err
142+
143+
// V2 Pipeline
144+
var configsV2 lint_v2.Configs
145+
for _, cfg := range configs {
146+
configsV2 = append(configsV2, lint_v2.Config{
147+
IncludedPaths: cfg.IncludedPaths,
148+
ExcludedPaths: cfg.ExcludedPaths,
149+
EnabledRules: cfg.EnabledRules,
150+
DisabledRules: cfg.DisabledRules,
151+
})
171152
}
172-
fd, err := p.ParseFiles(protoFiles...)
153+
resultsV2, err := c.runV2(rulesV2, configsV2)
173154
if err != nil {
174-
if err == protoparse.ErrInvalidSource {
175-
if len(errorsWithPos) == 0 {
176-
return errors.New("got protoparse.ErrInvalidSource but no ErrorWithPos errors")
177-
}
178-
// TODO: There's multiple ways to deal with this but this prints all the errors at least
179-
errStrings := make([]string, len(errorsWithPos))
180-
for i, errorWithPos := range errorsWithPos {
181-
errStrings[i] = errorWithPos.Error()
182-
}
183-
return errors.New(strings.Join(errStrings, "\n"))
184-
}
185-
return err
155+
return fmt.Errorf("V2 pipeline failed: %w", err)
186156
}
187157

188-
// Create a linter to lint the file descriptors.
189-
l := lint.New(rules, configs, lint.Debug(c.DebugFlag), lint.IgnoreCommentDisables(c.IgnoreCommentDisablesFlag))
190-
results, err := l.LintProtos(fd...)
191-
if err != nil {
192-
return err
193-
}
158+
// Combine results from both pipelines, preserving file order.
159+
results := combineResponses(resultsV1, resultsV2)
194160

195161
// Determine the output for writing the results.
196162
// Stdout is the default output.
@@ -226,37 +192,124 @@ func (c *cli) lint(rules lint.RuleRegistry, configs lint.Configs) error {
226192
return nil
227193
}
228194

229-
func anyProblems(results []lint.Response) bool {
230-
for i := range results {
231-
if len(results[i].Problems) > 0 {
195+
func anyProblems(results []combinedResponse) bool {
196+
for _, r := range results {
197+
if r.hasProblems() {
232198
return true
233199
}
234200
}
235201
return false
236202
}
237203

238-
func loadFileDescriptors(filePaths ...string) (map[string]*desc.FileDescriptor, error) {
239-
fds := []*dpb.FileDescriptorProto{}
240-
for _, filePath := range filePaths {
241-
fs, err := readFileDescriptorSet(filePath)
242-
if err != nil {
243-
return nil, err
204+
// combinedResponse holds lint results from both V1 and V2 pipelines for a
205+
// single file. It preserves the concrete problem types so that custom
206+
// MarshalJSON/MarshalYAML methods on each Problem type are invoked correctly.
207+
type combinedResponse struct {
208+
FilePath string
209+
ProblemsV1 []lint.Problem
210+
ProblemsV2 []lint_v2.Problem
211+
}
212+
213+
// MarshalJSON produces output compatible with the original lint.Response format.
214+
func (r combinedResponse) MarshalJSON() ([]byte, error) {
215+
problems := make([]interface{}, 0, len(r.ProblemsV1)+len(r.ProblemsV2))
216+
for _, p := range r.ProblemsV1 {
217+
problems = append(problems, p)
218+
}
219+
for _, p := range r.ProblemsV2 {
220+
problems = append(problems, p)
221+
}
222+
return json.Marshal(struct {
223+
FilePath string `json:"file_path"`
224+
Problems []interface{} `json:"problems"`
225+
}{r.FilePath, problems})
226+
}
227+
228+
// MarshalYAML produces output compatible with the original lint.Response format.
229+
func (r combinedResponse) MarshalYAML() (interface{}, error) {
230+
problems := make([]interface{}, 0, len(r.ProblemsV1)+len(r.ProblemsV2))
231+
for _, p := range r.ProblemsV1 {
232+
problems = append(problems, p)
233+
}
234+
for _, p := range r.ProblemsV2 {
235+
problems = append(problems, p)
236+
}
237+
return struct {
238+
FilePath string `yaml:"file_path"`
239+
Problems []interface{} `yaml:"problems"`
240+
}{r.FilePath, problems}, nil
241+
}
242+
243+
func (r combinedResponse) hasProblems() bool {
244+
return len(r.ProblemsV1) > 0 || len(r.ProblemsV2) > 0
245+
}
246+
247+
// problemInfo provides a unified view of a problem for format functions
248+
// (github, summary) that need to access problem fields directly.
249+
type problemInfo struct {
250+
RuleID string
251+
Message string
252+
Span []int32 // From Location.Span; nil if no location.
253+
RuleURI string
254+
}
255+
256+
// allProblems returns a unified list of problem info from both V1 and V2 problems.
257+
func (r combinedResponse) allProblems() []problemInfo {
258+
result := make([]problemInfo, 0, len(r.ProblemsV1)+len(r.ProblemsV2))
259+
for _, p := range r.ProblemsV1 {
260+
var span []int32
261+
if p.Location != nil {
262+
span = p.Location.Span
244263
}
245-
fds = append(fds, fs.GetFile()...)
264+
result = append(result, problemInfo{
265+
RuleID: string(p.RuleID),
266+
Message: p.Message,
267+
Span: span,
268+
RuleURI: p.GetRuleURI(),
269+
})
246270
}
247-
return desc.CreateFileDescriptors(fds)
271+
for _, p := range r.ProblemsV2 {
272+
var span []int32
273+
if p.Location != nil {
274+
span = p.Location.Span
275+
}
276+
result = append(result, problemInfo{
277+
RuleID: string(p.RuleID),
278+
Message: p.Message,
279+
Span: span,
280+
RuleURI: p.GetRuleURI(),
281+
})
282+
}
283+
return result
248284
}
249285

250-
func readFileDescriptorSet(filePath string) (*dpb.FileDescriptorSet, error) {
251-
in, err := os.ReadFile(filePath)
252-
if err != nil {
253-
return nil, err
286+
// combineResponses merges V1 and V2 responses by file path, preserving
287+
// the input file ordering (V1 order first, then any V2-only files).
288+
func combineResponses(v1 []lint.Response, v2 []lint_v2.Response) []combinedResponse {
289+
order := make([]string, 0)
290+
byFile := make(map[string]*combinedResponse)
291+
292+
for _, r := range v1 {
293+
if _, ok := byFile[r.FilePath]; !ok {
294+
order = append(order, r.FilePath)
295+
byFile[r.FilePath] = &combinedResponse{FilePath: r.FilePath}
296+
}
297+
byFile[r.FilePath].ProblemsV1 = append(byFile[r.FilePath].ProblemsV1, r.Problems...)
254298
}
255-
fs := &dpb.FileDescriptorSet{}
256-
if err := proto.Unmarshal(in, fs); err != nil {
257-
return nil, err
299+
300+
for _, r := range v2 {
301+
if _, ok := byFile[r.FilePath]; !ok {
302+
order = append(order, r.FilePath)
303+
byFile[r.FilePath] = &combinedResponse{FilePath: r.FilePath}
304+
}
305+
byFile[r.FilePath].ProblemsV2 = append(byFile[r.FilePath].ProblemsV2, r.Problems...)
306+
}
307+
308+
result := make([]combinedResponse, 0, len(order))
309+
for _, fp := range order {
310+
result = append(result, *byFile[fp])
258311
}
259-
return fs, nil
312+
return result
260313
}
261314

262315
var outputFormatFuncs = map[string]formatFunc{
@@ -265,15 +318,15 @@ var outputFormatFuncs = map[string]formatFunc{
265318
"json": json.Marshal,
266319
"github": func(i interface{}) ([]byte, error) {
267320
switch v := i.(type) {
268-
case []lint.Response:
321+
case []combinedResponse:
269322
return formatGitHubActionOutput(v), nil
270323
default:
271324
return json.Marshal(v)
272325
}
273326
},
274327
"summary": func(i interface{}) ([]byte, error) {
275328
switch v := i.(type) {
276-
case []lint.Response:
329+
case []combinedResponse:
277330
return printSummaryTable(v)
278331
case listedRules:
279332
return v.printSummaryTable()

cmd/api-linter/cli_v1.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright 2019 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"errors"
19+
"fmt"
20+
"os"
21+
"strings"
22+
"sync"
23+
24+
"github.com/aep-dev/api-linter/lint"
25+
"github.com/jhump/protoreflect/desc"
26+
"github.com/jhump/protoreflect/desc/protoparse"
27+
"google.golang.org/protobuf/proto"
28+
dpb "google.golang.org/protobuf/types/descriptorpb"
29+
)
30+
31+
func (c *cli) runV1(rulesV1 lint.RuleRegistry, configs lint.Configs) ([]lint.Response, error) {
32+
// Prepare proto import lookup.
33+
fs, err := loadFileDescriptors(c.ProtoDescPath...)
34+
if err != nil {
35+
return nil, err
36+
}
37+
lookupImport := func(name string) (*desc.FileDescriptor, error) {
38+
if f, found := fs[name]; found {
39+
return f, nil
40+
}
41+
return nil, fmt.Errorf("%q is not found", name)
42+
}
43+
var errorsWithPos []protoparse.ErrorWithPos
44+
var lock sync.Mutex
45+
// Parse proto files into `protoreflect` file descriptors.
46+
p := protoparse.Parser{
47+
ImportPaths: c.ProtoImportPaths,
48+
IncludeSourceCodeInfo: true,
49+
LookupImport: lookupImport,
50+
ErrorReporter: func(errorWithPos protoparse.ErrorWithPos) error {
51+
// Protoparse isn't concurrent right now but just to be safe for the future.
52+
lock.Lock()
53+
errorsWithPos = append(errorsWithPos, errorWithPos)
54+
lock.Unlock()
55+
// Continue parsing. The error returned will be protoparse.ErrInvalidSource.
56+
return nil
57+
},
58+
}
59+
// Resolve file absolute paths to relative ones.
60+
protoFiles, err := protoparse.ResolveFilenames(c.ProtoImportPaths, c.ProtoFiles...)
61+
if err != nil {
62+
return nil, err
63+
}
64+
fd, err := p.ParseFiles(protoFiles...)
65+
if err != nil {
66+
if err == protoparse.ErrInvalidSource {
67+
if len(errorsWithPos) == 0 {
68+
return nil, errors.New("got protoparse.ErrInvalidSource but no ErrorWithPos errors")
69+
}
70+
errStrings := make([]string, len(errorsWithPos))
71+
for i, errorWithPos := range errorsWithPos {
72+
errStrings[i] = errorWithPos.Error()
73+
}
74+
return nil, errors.New(strings.Join(errStrings, "\n"))
75+
}
76+
return nil, err
77+
}
78+
79+
// Create a linter to lint the file descriptors.
80+
l := lint.New(rulesV1, configs, lint.Debug(c.DebugFlag), lint.IgnoreCommentDisables(c.IgnoreCommentDisablesFlag))
81+
return l.LintProtos(fd...)
82+
}
83+
84+
func loadFileDescriptors(filePaths ...string) (map[string]*desc.FileDescriptor, error) {
85+
fds := []*dpb.FileDescriptorProto{}
86+
for _, filePath := range filePaths {
87+
fs, err := readFileDescriptorSet(filePath)
88+
if err != nil {
89+
return nil, err
90+
}
91+
fds = append(fds, fs.GetFile()...)
92+
}
93+
return desc.CreateFileDescriptors(fds)
94+
}
95+
96+
func readFileDescriptorSet(filePath string) (*dpb.FileDescriptorSet, error) {
97+
in, err := os.ReadFile(filePath)
98+
if err != nil {
99+
return nil, err
100+
}
101+
fs := &dpb.FileDescriptorSet{}
102+
if err := proto.Unmarshal(in, fs); err != nil {
103+
return nil, err
104+
}
105+
return fs, nil
106+
}

0 commit comments

Comments
 (0)