Repository: VKCOM/noverify
Pull request #524 (2020-06-23 17:18:17)

cmd: replace the global flag vars with an explicit context
It's a small step, but it's better to do it now than never.

The first benefit that we get is that now it's obvious that
every InitEmbeddedRules call from the linttest package also
modified cmd package global state. It's not an error, but
it's not a pleasant side effect.

The next step would be to turn all linter.* vars from the
config.go file into a struct that would them and pass it
to the root walkers.

Updates #523

Signed-off-by: Iskander Sharipov <quasilyte@gmail.com>

quasilyte: Renamed the type and its usages.

Patch:
diff --git a/src/cmd/flags.go b/src/cmd/flags.go
index 03161cf..f75b349 100644
--- a/src/cmd/flags.go
+++ b/src/cmd/flags.go
@@ -3,10 +3,8 @@ package cmd
 import (
 	"flag"
 	"fmt"
-	"io"
 	"os"
 	"path/filepath"
-	"regexp"
 	"runtime"
 	"strings"
 
@@ -15,58 +13,51 @@ import (
 
 const allNonMaybe = "<all-non-maybe>"
 
-var (
-	outputFp io.Writer = os.Stderr
-
-	disableCache bool
-
-	gitRepo string
+type cmdlineArguments struct {
+	version bool
 
 	pprofHost string
 
-	gitCommitFrom              string
-	gitCommitTo                string
-	gitRef                     string
-	gitPushArg                 string
-	gitAuthorsWhitelist        string
-	gitWorkTree                string
-	gitSkipFetch               bool
-	gitDisableCompensateMaster bool
-	gitFullDiff                bool
-	gitIncludeUntracked        bool
+	cpuProfile string
+	memProfile string
 
-	phpExtensionsArg string
+	fullAnalysisFiles string
+	indexOnlyFiles    string
 
-	reportsExclude          string
-	reportsExcludeChecks    string
-	reportsCritical         string
-	reportsExcludeChecksSet = map[string]bool{}
-	reportsIncludeChecksSet = map[string]bool{}
-	reportsCriticalSet      = map[string]bool{}
+	rulesList string
 
-	allowChecks       string
-	allowDisable      string
-	allowDisableRegex *regexp.Regexp
+	output     string
+	outputJSON bool
 
 	misspellList string
 
 	unusedVarPattern string
 
-	fullAnalysisFiles string
-	indexOnlyFiles    string
+	allowChecks  string
+	allowDisable string
 
-	rulesList string
+	reportsExclude       string
+	reportsExcludeChecks string
+	reportsCritical      string
 
-	output     string
-	outputJSON bool
+	phpExtensionsArg string
 
-	version bool
+	gitPushArg                 string
+	gitAuthorsWhitelist        string
+	gitWorkTree                string
+	gitSkipFetch               bool
+	gitDisableCompensateMaster bool
+	gitFullDiff                bool
+	gitIncludeUntracked        bool
+	gitRepo                    string
+	gitRef                     string
+	gitCommitFrom              string
+	gitCommitTo                string
 
-	cpuProfile string
-	memProfile string
-)
+	disableCache bool
+}
 
-func bindFlags() {
+func bindFlags(args *cmdlineArguments) {
 	var enabledByDefault []string
 	declaredChecks := linter.GetDeclaredChecks()
 	for _, info := range declaredChecks {
@@ -101,41 +92,41 @@ func bindFlags() {
 		}
 	}
 
-	flag.StringVar(&pprofHost, "pprof", "", "HTTP pprof endpoint (e.g. localhost:8080)")
+	flag.StringVar(&args.pprofHost, "pprof", "", "HTTP pprof endpoint (e.g. localhost:8080)")
 
-	flag.StringVar(&reportsCritical, "critical", allNonMaybe,
+	flag.StringVar(&args.reportsCritical, "critical", allNonMaybe,
 		"Comma-separated list of check names that are considered critical (all non-maybe checks by default)")
 
-	flag.StringVar(&rulesList, "rules", "",
+	flag.StringVar(&args.rulesList, "rules", "",
 		"Comma-separated list of rules files")
 
-	flag.StringVar(&gitRepo, "git", "", "Path to git repository to analyze")
-	flag.StringVar(&gitCommitFrom, "git-commit-from", "", "Analyze changes between commits <git-commit-from> and <git-commit-to>")
-	flag.StringVar(&gitCommitTo, "git-commit-to", "", "")
-	flag.StringVar(&gitRef, "git-ref", "", "Ref (e.g. branch) that is being pushed")
-	flag.StringVar(&gitPushArg, "git-push-arg", "", "In {pre,post}-receive hooks a whole line from stdin can be passed")
-	flag.StringVar(&gitAuthorsWhitelist, "git-author-whitelist", "", "Whitelist (comma-separated) for commit authors, if needed")
-	flag.StringVar(&gitWorkTree, "git-work-tree", "", "Work tree. If specified, local changes will also be examined.")
-	flag.BoolVar(&gitSkipFetch, "git-skip-fetch", false, "Do not fetch ORIGIN_MASTER (use this option if you already fetch to ORIGIN_MASTER before that)")
-	flag.BoolVar(&gitDisableCompensateMaster, "git-disable-compensate-master", false, "Do not try to compensate for changes in ORIGIN_MASTER after branch point")
-	flag.BoolVar(&gitFullDiff, "git-full-diff", false, "Compute full diff: analyze all files, not just changed ones")
-	flag.BoolVar(&gitIncludeUntracked, "git-include-untracked", true, "Include untracked (new, uncommitted files) into analysis")
-
-	flag.StringVar(&reportsExclude, "exclude", "", "Exclude regexp for filenames in reports list")
-	flag.StringVar(&reportsExcludeChecks, "exclude-checks", "", "Comma-separated list of check names to be excluded")
-	flag.StringVar(&allowDisable, "allow-disable", "", "Regexp for filenames where '@linter disable' is allowed")
-	flag.StringVar(&allowChecks, "allow-checks", strings.Join(enabledByDefault, ","),
+	flag.StringVar(&args.gitRepo, "git", "", "Path to git repository to analyze")
+	flag.StringVar(&args.gitCommitFrom, "git-commit-from", "", "Analyze changes between commits <git-commit-from> and <git-commit-to>")
+	flag.StringVar(&args.gitCommitTo, "git-commit-to", "", "")
+	flag.StringVar(&args.gitRef, "git-ref", "", "Ref (e.g. branch) that is being pushed")
+	flag.StringVar(&args.gitPushArg, "git-push-arg", "", "In {pre,post}-receive hooks a whole line from stdin can be passed")
+	flag.StringVar(&args.gitAuthorsWhitelist, "git-author-whitelist", "", "Whitelist (comma-separated) for commit authors, if needed")
+	flag.StringVar(&args.gitWorkTree, "git-work-tree", "", "Work tree. If specified, local changes will also be examined.")
+	flag.BoolVar(&args.gitSkipFetch, "git-skip-fetch", false, "Do not fetch ORIGIN_MASTER (use this option if you already fetch to ORIGIN_MASTER before that)")
+	flag.BoolVar(&args.gitDisableCompensateMaster, "git-disable-compensate-master", false, "Do not try to compensate for changes in ORIGIN_MASTER after branch point")
+	flag.BoolVar(&args.gitFullDiff, "git-full-diff", false, "Compute full diff: analyze all files, not just changed ones")
+	flag.BoolVar(&args.gitIncludeUntracked, "git-include-untracked", true, "Include untracked (new, uncommitted files) into analysis")
+
+	flag.StringVar(&args.reportsExclude, "exclude", "", "Exclude regexp for filenames in reports list")
+	flag.StringVar(&args.reportsExcludeChecks, "exclude-checks", "", "Comma-separated list of check names to be excluded")
+	flag.StringVar(&args.allowDisable, "allow-disable", "", "Regexp for filenames where '@linter disable' is allowed")
+	flag.StringVar(&args.allowChecks, "allow-checks", strings.Join(enabledByDefault, ","),
 		"Comma-separated list of check names to be enabled")
-	flag.StringVar(&misspellList, "misspell-list", "Eng",
+	flag.StringVar(&args.misspellList, "misspell-list", "Eng",
 		"Comma-separated list of misspelling dicts; predefined sets are Eng, Eng/US and Eng/UK")
 
-	flag.StringVar(&phpExtensionsArg, "php-extensions", "php,inc,php5,phtml,inc", "List of PHP extensions to be recognized")
+	flag.StringVar(&args.phpExtensionsArg, "php-extensions", "php,inc,php5,phtml,inc", "List of PHP extensions to be recognized")
 
-	flag.StringVar(&fullAnalysisFiles, "full-analysis-files", "", "Comma-separated list of files to do full analysis")
-	flag.StringVar(&indexOnlyFiles, "index-only-files", "", "Comma-separated list of files to do indexing")
+	flag.StringVar(&args.fullAnalysisFiles, "full-analysis-files", "", "Comma-separated list of files to do full analysis")
+	flag.StringVar(&args.indexOnlyFiles, "index-only-files", "", "Comma-separated list of files to do indexing")
 
-	flag.StringVar(&output, "output", "", "Output reports to a specified file instead of stderr")
-	flag.BoolVar(&outputJSON, "output-json", false, "Format output as JSON")
+	flag.StringVar(&args.output, "output", "", "Output reports to a specified file instead of stderr")
+	flag.BoolVar(&args.outputJSON, "output-json", false, "Format output as JSON")
 
 	flag.BoolVar(&linter.CheckAutoGenerated, `check-auto-generated`, false, "whether to lint auto-generated PHP file")
 	flag.BoolVar(&linter.Debug, "debug", false, "Enable debug output")
@@ -146,13 +137,13 @@ func bindFlags() {
 	flag.StringVar(&linter.DefaultEncoding, "encoding", "UTF-8", "Default encoding. Only UTF-8 and windows-1251 are supported")
 	flag.StringVar(&linter.StubsDir, "stubs-dir", "", "phpstorm-stubs directory")
 	flag.StringVar(&linter.CacheDir, "cache-dir", defaultCacheDir, "Directory for linter cache (greatly improves indexing speed)")
-	flag.BoolVar(&disableCache, "disable-cache", false, "If set, cache is not used and cache-dir is ignored")
+	flag.BoolVar(&args.disableCache, "disable-cache", false, "If set, cache is not used and cache-dir is ignored")
 
-	flag.StringVar(&unusedVarPattern, "unused-var-regex", `^_$`,
+	flag.StringVar(&args.unusedVarPattern, "unused-var-regex", `^_$`,
 		"Variables that match such regexp are marked as discarded; not reported as unused, but should not be used as values")
 
-	flag.BoolVar(&version, "version", false, "Show version info and exit")
+	flag.BoolVar(&args.version, "version", false, "Show version info and exit")
 
-	flag.StringVar(&cpuProfile, "cpuprofile", "", "write cpu profile to `file`")
-	flag.StringVar(&memProfile, "memprofile", "", "write memory profile to `file`")
+	flag.StringVar(&args.cpuProfile, "cpuprofile", "", "write cpu profile to `file`")
+	flag.StringVar(&args.memProfile, "memprofile", "", "write memory profile to `file`")
 }
diff --git a/src/cmd/git_main.go b/src/cmd/git_main.go
index a5824a5..b0bb20d 100644
--- a/src/cmd/git_main.go
+++ b/src/cmd/git_main.go
@@ -11,12 +11,12 @@ import (
 	"github.com/VKCOM/noverify/src/meta"
 )
 
-func gitParseUntracked() []*linter.Report {
-	if !gitIncludeUntracked {
+func gitParseUntracked(l *linterRunner) []*linter.Report {
+	if !l.args.gitIncludeUntracked {
 		return nil
 	}
 
-	filenames, err := git.UntrackedFiles(gitRepo)
+	filenames, err := git.UntrackedFiles(l.args.gitRepo)
 	if err != nil {
 		log.Fatalf("get untracked files: %v", err)
 	}
@@ -24,49 +24,49 @@ func gitParseUntracked() []*linter.Report {
 	return linter.ParseFilenames(linter.ReadFilenames(filenames, nil))
 }
 
-func parseIndexOnlyFiles() {
-	if indexOnlyFiles == "" {
+func parseIndexOnlyFiles(l *linterRunner) {
+	if l.args.indexOnlyFiles == "" {
 		return
 	}
-	filenames := strings.Split(indexOnlyFiles, ",")
+	filenames := strings.Split(l.args.indexOnlyFiles, ",")
 	linter.ParseFilenames(linter.ReadFilenames(filenames, nil))
 }
 
 // Not the best name, and not the best function signature.
 // Refactor this function whenever you get the idea how to separate logic better.
-func gitRepoComputeReportsFromCommits(logArgs, diffArgs []string) (oldReports, reports []*linter.Report, changes []git.Change, changeLog []git.Commit, ok bool) {
+func gitRepoComputeReportsFromCommits(l *linterRunner, logArgs, diffArgs []string) (oldReports, reports []*linter.Report, changes []git.Change, changeLog []git.Commit, ok bool) {
 	// TODO(quasilyte): hard to replace fatalf with error return here. Use panicf for now.
 
 	start := time.Now()
-	changeLog, err := git.Log(gitRepo, logArgs)
+	changeLog, err := git.Log(l.args.gitRepo, logArgs)
 	if err != nil {
 		log.Panicf("Could not get commits in range %+v: %s", logArgs, err.Error())
 	}
 
-	if shouldRun := analyzeGitAuthorsWhiteList(changeLog); !shouldRun {
+	if shouldRun := analyzeGitAuthorsWhiteList(l, changeLog); !shouldRun {
 		return nil, nil, nil, nil, false
 	}
 
-	changes, err = git.Diff(gitRepo, "", diffArgs)
+	changes, err = git.Diff(l.args.gitRepo, "", diffArgs)
 	if err != nil {
 		log.Panicf("Could not compute git diff: %s", err.Error())
 	}
 
-	if gitFullDiff {
+	if l.args.gitFullDiff {
 		meta.ResetInfo()
 		if err := loadEmbeddedStubs(); err != nil {
 			log.Panicf("Load embedded stubs: %v", err)
 		}
 
 		start = time.Now()
-		linter.ParseFilenames(linter.ReadFilesFromGit(gitRepo, gitCommitFrom, nil))
-		parseIndexOnlyFiles()
+		linter.ParseFilenames(linter.ReadFilesFromGit(l.args.gitRepo, l.args.gitCommitFrom, nil))
+		parseIndexOnlyFiles(l)
 		log.Printf("Indexed old commit in %s", time.Since(start))
 
 		meta.SetIndexingComplete(true)
 
 		start = time.Now()
-		oldReports = linter.ParseFilenames(linter.ReadFilesFromGit(gitRepo, gitCommitFrom, linter.ExcludeRegex))
+		oldReports = linter.ParseFilenames(linter.ReadFilesFromGit(l.args.gitRepo, l.args.gitCommitFrom, linter.ExcludeRegex))
 		log.Printf("Parsed old commit for %s (%d reports)", time.Since(start), len(oldReports))
 
 		meta.ResetInfo()
@@ -75,49 +75,49 @@ func gitRepoComputeReportsFromCommits(logArgs, diffArgs []string) (oldReports, r
 		}
 
 		start = time.Now()
-		linter.ParseFilenames(linter.ReadFilesFromGit(gitRepo, gitCommitTo, nil))
+		linter.ParseFilenames(linter.ReadFilesFromGit(l.args.gitRepo, l.args.gitCommitTo, nil))
 		log.Printf("Indexed new commit in %s", time.Since(start))
 
 		meta.SetIndexingComplete(true)
 
 		start = time.Now()
-		reports = linter.ParseFilenames(linter.ReadFilesFromGit(gitRepo, gitCommitTo, linter.ExcludeRegex))
+		reports = linter.ParseFilenames(linter.ReadFilesFromGit(l.args.gitRepo, l.args.gitCommitTo, linter.ExcludeRegex))
 		log.Printf("Parsed new commit in %s (%d reports)", time.Since(start), len(reports))
 	} else {
 		start = time.Now()
-		linter.ParseFilenames(linter.ReadFilesFromGit(gitRepo, gitCommitTo, nil))
-		parseIndexOnlyFiles()
+		linter.ParseFilenames(linter.ReadFilesFromGit(l.args.gitRepo, l.args.gitCommitTo, nil))
+		parseIndexOnlyFiles(l)
 		log.Printf("Indexing complete in %s", time.Since(start))
 
 		meta.SetIndexingComplete(true)
 
 		start = time.Now()
-		oldReports = linter.ParseFilenames(linter.ReadOldFilesFromGit(gitRepo, gitCommitFrom, changes))
+		oldReports = linter.ParseFilenames(linter.ReadOldFilesFromGit(l.args.gitRepo, l.args.gitCommitFrom, changes))
 		log.Printf("Parsed old files versions for %s", time.Since(start))
 
 		start = time.Now()
 		meta.SetIndexingComplete(false)
-		linter.ParseFilenames(linter.ReadFilesFromGitWithChanges(gitRepo, gitCommitTo, changes))
+		linter.ParseFilenames(linter.ReadFilesFromGitWithChanges(l.args.gitRepo, l.args.gitCommitTo, changes))
 		meta.SetIndexingComplete(true)
 		log.Printf("Indexed files versions for %s", time.Since(start))
 
 		start = time.Now()
-		reports = linter.ParseFilenames(linter.ReadFilesFromGitWithChanges(gitRepo, gitCommitTo, changes))
+		reports = linter.ParseFilenames(linter.ReadFilesFromGitWithChanges(l.args.gitRepo, l.args.gitCommitTo, changes))
 		log.Printf("Parsed new file versions in %s", time.Since(start))
 	}
 
 	return oldReports, reports, changes, changeLog, true
 }
 
-func gitRepoComputeReportsFromLocalChanges() (oldReports, reports []*linter.Report, changes []git.Change, ok bool) {
+func gitRepoComputeReportsFromLocalChanges(l *linterRunner) (oldReports, reports []*linter.Report, changes []git.Change, ok bool) {
 	// TODO(quasilyte): hard to replace fatalf with error return here. Use panicf for now.
 
-	if gitWorkTree == "" {
+	if l.args.gitWorkTree == "" {
 		return nil, nil, nil, false
 	}
 
 	// compute changes for working copy (staged + unstaged changes combined starting with the commit being pushed)
-	changes, err := git.Diff(gitRepo, gitWorkTree, []string{gitCommitFrom})
+	changes, err := git.Diff(l.args.gitRepo, l.args.gitWorkTree, []string{l.args.gitCommitFrom})
 	if err != nil {
 		log.Panicf("Could not compute git diff: %s", err.Error())
 	}
@@ -126,35 +126,35 @@ func gitRepoComputeReportsFromLocalChanges() (oldReports, reports []*linter.Repo
 		return nil, nil, nil, false
 	}
 
-	log.Printf("You have changes in your work tree, showing diff between %s and work tree", gitCommitFrom)
+	log.Printf("You have changes in your work tree, showing diff between %s and work tree", l.args.gitCommitFrom)
 
 	start := time.Now()
-	linter.ParseFilenames(linter.ReadFilesFromGit(gitRepo, gitCommitFrom, nil))
-	parseIndexOnlyFiles()
+	linter.ParseFilenames(linter.ReadFilesFromGit(l.args.gitRepo, l.args.gitCommitFrom, nil))
+	parseIndexOnlyFiles(l)
 	log.Printf("Indexing complete in %s", time.Since(start))
 
 	meta.SetIndexingComplete(true)
 
 	start = time.Now()
-	oldReports = linter.ParseFilenames(linter.ReadOldFilesFromGit(gitRepo, gitCommitFrom, changes))
+	oldReports = linter.ParseFilenames(linter.ReadOldFilesFromGit(l.args.gitRepo, l.args.gitCommitFrom, changes))
 	log.Printf("Parsed old files versions for %s", time.Since(start))
 
 	start = time.Now()
 	meta.SetIndexingComplete(false)
-	linter.ParseFilenames(linter.ReadChangesFromWorkTree(gitWorkTree, changes))
-	gitParseUntracked()
+	linter.ParseFilenames(linter.ReadChangesFromWorkTree(l.args.gitWorkTree, changes))
+	gitParseUntracked(l)
 	meta.SetIndexingComplete(true)
 	log.Printf("Indexed new files versions for %s", time.Since(start))
 
 	start = time.Now()
-	reports = linter.ParseFilenames(linter.ReadChangesFromWorkTree(gitWorkTree, changes))
-	reports = append(reports, gitParseUntracked()...)
+	reports = linter.ParseFilenames(linter.ReadChangesFromWorkTree(l.args.gitWorkTree, changes))
+	reports = append(reports, gitParseUntracked(l)...)
 	log.Printf("Parsed new file versions in %s", time.Since(start))
 
 	return oldReports, reports, changes, true
 }
 
-func gitMain(cfg *MainConfig) (int, error) {
+func gitMain(l *linterRunner, cfg *MainConfig) (int, error) {
 	var (
 		oldReports, reports []*linter.Report
 		diffArgs            []string
@@ -164,27 +164,27 @@ func gitMain(cfg *MainConfig) (int, error) {
 	)
 
 	// prepareGitArgs also populates global variables like fromCommit
-	logArgs, diffArgs, err := prepareGitArgs()
+	logArgs, diffArgs, err := prepareGitArgs(l)
 	if err != nil {
 		return 0, err
 	}
 
-	oldReports, reports, changes, ok = gitRepoComputeReportsFromLocalChanges()
+	oldReports, reports, changes, ok = gitRepoComputeReportsFromLocalChanges(l)
 	if !ok {
-		oldReports, reports, changes, changeLog, ok = gitRepoComputeReportsFromCommits(logArgs, diffArgs)
+		oldReports, reports, changes, changeLog, ok = gitRepoComputeReportsFromCommits(l, logArgs, diffArgs)
 		if !ok {
 			return 0, nil
 		}
 	}
 
 	start := time.Now()
-	diff, err := linter.DiffReports(gitRepo, diffArgs, changes, changeLog, oldReports, reports, 8)
+	diff, err := linter.DiffReports(l.args.gitRepo, diffArgs, changes, changeLog, oldReports, reports, 8)
 	if err != nil {
 		return 0, fmt.Errorf("Could not compute reports diff: %v", err)
 	}
 	log.Printf("Computed reports diff for %s", time.Since(start))
 
-	criticalReports := analyzeReports(cfg, diff)
+	criticalReports := analyzeReports(l, cfg, diff)
 
 	if criticalReports > 0 {
 		log.Printf("Found %d critical issues, please fix them.", criticalReports)
@@ -194,10 +194,10 @@ func gitMain(cfg *MainConfig) (int, error) {
 	return 0, nil
 }
 
-func analyzeGitAuthorsWhiteList(changeLog []git.Commit) (shouldRun bool) {
-	if gitAuthorsWhitelist != "" {
+func analyzeGitAuthorsWhiteList(l *linterRunner, changeLog []git.Commit) (shouldRun bool) {
+	if l.args.gitAuthorsWhitelist != "" {
 		whiteList := make(map[string]bool)
-		for _, name := range strings.Split(gitAuthorsWhitelist, ",") {
+		for _, name := range strings.Split(l.args.gitAuthorsWhitelist, ",") {
 			whiteList[name] = true
 		}
 
@@ -212,48 +212,50 @@ func analyzeGitAuthorsWhiteList(changeLog []git.Commit) (shouldRun bool) {
 	return true
 }
 
-func prepareGitArgs() (logArgs, diffArgs []string, err error) {
-	if gitPushArg != "" {
-		args := strings.Fields(gitPushArg)
+func prepareGitArgs(l *linterRunner) (logArgs, diffArgs []string, err error) {
+	l.gitRef = l.args.gitRef
+	l.gitCommitFrom = l.args.gitCommitFrom
+	l.gitCommitTo = l.args.gitCommitTo
+	if l.args.gitPushArg != "" {
+		args := strings.Fields(l.args.gitPushArg)
 		if len(args) != 3 {
-			return nil, nil, fmt.Errorf("Unexpected format of push arguments, expected only 3 columns: %s", gitPushArg)
+			return nil, nil, fmt.Errorf("Unexpected format of push arguments, expected only 3 columns: %s", l.args.gitPushArg)
 		}
-		gitCommitFrom, gitCommitTo, gitRef = args[0], args[1], args[2]
+		l.gitCommitFrom, l.gitCommitTo, l.gitRef = args[0], args[1], args[2]
 	}
 
-	if gitCommitFrom == git.Zero {
-		gitCommitFrom = "master"
+	if l.args.gitCommitFrom == git.Zero {
+		l.args.gitCommitFrom = "master"
 	}
 
-	if !gitSkipFetch {
+	if !l.args.gitSkipFetch {
 		start := time.Now()
 		log.Printf("Fetching origin master to ORIGIN_MASTER")
-		if err := git.Fetch(gitRepo, "master", "ORIGIN_MASTER"); err != nil {
+		if err := git.Fetch(l.args.gitRepo, "master", "ORIGIN_MASTER"); err != nil {
 			return nil, nil, fmt.Errorf("Could not fetch ORIGIN_MASTER: %v", err.Error())
 		}
 		log.Printf("Fetched for %s", time.Since(start))
 	}
 
-	if !gitDisableCompensateMaster {
-
-		fromAndMaster, err := git.MergeBase(gitRepo, "ORIGIN_MASTER", gitCommitFrom)
+	if !l.args.gitDisableCompensateMaster {
+		fromAndMaster, err := git.MergeBase(l.args.gitRepo, "ORIGIN_MASTER", l.args.gitCommitFrom)
 		if err != nil {
-			return nil, nil, fmt.Errorf("Could not compute merge base between ORIGIN_MASTER and %s", gitCommitFrom)
+			return nil, nil, fmt.Errorf("Could not compute merge base between ORIGIN_MASTER and %s", l.args.gitCommitFrom)
 		}
 
-		toAndMaster, err := git.MergeBase(gitRepo, "ORIGIN_MASTER", gitCommitTo)
+		toAndMaster, err := git.MergeBase(l.args.gitRepo, "ORIGIN_MASTER", l.args.gitCommitTo)
 		if err != nil {
-			return nil, nil, fmt.Errorf("Could not compute merge base between ORIGIN_MASTER and %s", gitCommitTo)
+			return nil, nil, fmt.Errorf("Could not compute merge base between ORIGIN_MASTER and %s", l.args.gitCommitTo)
 		}
 
 		// check if master was merged in between the commits
 		if fromAndMaster != toAndMaster {
-			gitCommitFrom = toAndMaster
+			l.gitCommitFrom = toAndMaster
 		}
 	}
 
-	logArgs = []string{gitCommitFrom + ".." + gitCommitTo}
-	diffArgs = []string{gitCommitFrom + ".." + gitCommitTo}
+	logArgs = []string{l.gitCommitFrom + ".." + l.gitCommitTo}
+	diffArgs = []string{l.gitCommitFrom + ".." + l.gitCommitTo}
 
 	return logArgs, diffArgs, nil
 }
diff --git a/src/cmd/linter_runner.go b/src/cmd/linter_runner.go
new file mode 100644
index 0000000..8046c50
--- /dev/null
+++ b/src/cmd/linter_runner.go
@@ -0,0 +1,191 @@
+package cmd
+
+import (
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"regexp"
+	"strings"
+
+	"github.com/VKCOM/noverify/src/linter"
+	"github.com/VKCOM/noverify/src/rules"
+	"github.com/client9/misspell"
+)
+
+type linterRunner struct {
+	args *cmdlineArguments
+
+	outputFp io.Writer
+
+	reportsExcludeChecksSet map[string]bool
+	reportsIncludeChecksSet map[string]bool
+	reportsCriticalSet      map[string]bool
+
+	gitRef        string
+	gitCommitFrom string
+	gitCommitTo   string
+
+	allowDisableRegex *regexp.Regexp
+}
+
+func (l *linterRunner) IsEnabledByFlags(checkName string) bool {
+	if !l.reportsIncludeChecksSet[checkName] {
+		return false // Not enabled by -allow-checks
+	}
+
+	if l.reportsExcludeChecksSet[checkName] {
+		return false // Disabled by -exclude-checks
+	}
+
+	return true
+}
+
+func (l *linterRunner) Init(args *cmdlineArguments) error {
+	l.args = args
+
+	l.outputFp = os.Stderr
+	if args.output != "" {
+		outputFp, err := os.OpenFile(args.output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
+		if err != nil {
+			return fmt.Errorf("-output=%s: %v", args.output, err)
+		}
+		l.outputFp = outputFp
+	}
+
+	if err := l.compileRegexes(); err != nil {
+		return err
+	}
+
+	if args.misspellList != "" {
+		err := l.loadMisspellDicts(strings.Split(args.misspellList, ","))
+		if err != nil {
+			return err
+		}
+	}
+
+	if err := l.initRules(); err != nil {
+		return fmt.Errorf("init rules: %v", err)
+	}
+
+	l.initCheckMappings()
+
+	return nil
+}
+
+func (l *linterRunner) loadMisspellDicts(dicts []string) error {
+	linter.TypoFixer = &misspell.Replacer{}
+
+	for _, d := range dicts {
+		d = strings.TrimSpace(d)
+		switch {
+		case d == "Eng":
+			linter.TypoFixer.AddRuleList(misspell.DictMain)
+		case d == "Eng/US":
+			linter.TypoFixer.AddRuleList(misspell.DictAmerican)
+		case d == "Eng/UK" || d == "Eng/GB":
+			linter.TypoFixer.AddRuleList(misspell.DictBritish)
+		default:
+			return fmt.Errorf("unsupported %s misspell-list entry", d)
+		}
+	}
+
+	linter.TypoFixer.Compile()
+	return nil
+}
+
+func (l *linterRunner) compileRegexes() error {
+	if l.args.reportsExclude != "" {
+		var err error
+		linter.ExcludeRegex, err = regexp.Compile(l.args.reportsExclude)
+		if err != nil {
+			return fmt.Errorf("incorrect exclude regex: %v", err)
+		}
+	}
+
+	if l.args.allowDisable != "" {
+		allowDisableRegex, err := regexp.Compile(l.args.allowDisable)
+		if err != nil {
+			return fmt.Errorf("incorrect 'allow disable' regex: %v", err)
+		}
+		l.allowDisableRegex = allowDisableRegex
+	}
+
+	switch l.args.unusedVarPattern {
+	case "^_$":
+		// Default pattern, only $_ is allowed.
+		// Don't change anything.
+	case "^_.*$":
+		// Leading underscore plus anything after it.
+		// Recognize as quite common pattern.
+		linter.IsDiscardVar = func(s string) bool {
+			return strings.HasPrefix(s, "_")
+		}
+	default:
+		re, err := regexp.Compile(l.args.unusedVarPattern)
+		if err != nil {
+			return fmt.Errorf("incorrect unused-var-regex regex: %v", err)
+		}
+		linter.IsDiscardVar = re.MatchString
+	}
+
+	return nil
+}
+
+func (l *linterRunner) initCheckMappings() {
+	stringToSet := func(s string) map[string]bool {
+		set := make(map[string]bool)
+		for _, name := range strings.Split(s, ",") {
+			set[strings.TrimSpace(name)] = true
+		}
+		return set
+	}
+
+	l.reportsExcludeChecksSet = stringToSet(l.args.reportsExcludeChecks)
+	l.reportsIncludeChecksSet = stringToSet(l.args.allowChecks)
+	if l.args.reportsCritical != allNonMaybe {
+		l.reportsCriticalSet = stringToSet(l.args.reportsCritical)
+	}
+}
+
+func (l *linterRunner) initRules() error {
+	ruleFilter := func(r rules.Rule) bool {
+		return l.IsEnabledByFlags(r.Name)
+	}
+
+	linter.Rules = rules.NewSet()
+	p := rules.NewParser()
+
+	ruleSets, err := InitEmbeddedRules(p, ruleFilter)
+	if err != nil {
+		return err
+	}
+	for _, rset := range ruleSets {
+		l.updateCheckSets(rset)
+	}
+
+	if l.args.rulesList != "" {
+		for _, filename := range strings.Split(l.args.rulesList, ",") {
+			data, err := ioutil.ReadFile(filename)
+			if err != nil {
+				return err
+			}
+			rset, err := loadRulesFile(p, ruleFilter, filename, data)
+			if err != nil {
+				return err
+			}
+			l.updateCheckSets(rset)
+		}
+	}
+
+	return nil
+}
+
+func (l *linterRunner) updateCheckSets(rset *rules.Set) {
+	for _, name := range rset.AlwaysAllowed {
+		l.reportsIncludeChecksSet[name] = true
+	}
+	for _, name := range rset.AlwaysCritical {
+		l.reportsCriticalSet[name] = true
+	}
+}
diff --git a/src/cmd/main.go b/src/cmd/main.go
index aab1947..5eb9124 100644
--- a/src/cmd/main.go
+++ b/src/cmd/main.go
@@ -5,12 +5,10 @@ import (
 	"encoding/json"
 	"flag"
 	"fmt"
-	"io/ioutil"
 	"log"
 	"net/http"
 	_ "net/http/pprof" // it is ok for actually main package
 	"os"
-	"regexp"
 	"runtime"
 	"runtime/pprof"
 	"strings"
@@ -23,7 +21,6 @@ import (
 	"github.com/VKCOM/noverify/src/linter"
 	"github.com/VKCOM/noverify/src/meta"
 	"github.com/VKCOM/noverify/src/rules"
-	"github.com/client9/misspell"
 )
 
 // Line below implies that we have `https://github.com/VKCOM/phpstorm-stubs.git` cloned
@@ -33,27 +30,15 @@ import (
 
 //go:generate go-bindata -pkg embeddedrules -nometadata -o ./embeddedrules/rules.go ./embeddedrules/rules.php
 
-func isCritical(r *linter.Report) bool {
-	if len(reportsCriticalSet) != 0 {
-		return reportsCriticalSet[r.CheckName()]
+func isCritical(l *linterRunner, r *linter.Report) bool {
+	if len(l.reportsCriticalSet) != 0 {
+		return l.reportsCriticalSet[r.CheckName()]
 	}
 	return r.IsCritical()
 }
 
-func isEnabledByFlags(checkName string) bool {
-	if !reportsIncludeChecksSet[checkName] {
-		return false // Not enabled by -allow-checks
-	}
-
-	if reportsExcludeChecksSet[checkName] {
-		return false // Disabled by -exclude-checks
-	}
-
-	return true
-}
-
-func isEnabled(r *linter.Report) bool {
-	if !isEnabledByFlags(r.CheckName()) {
+func isEnabled(l *linterRunner, r *linter.Report) bool {
+	if !l.IsEnabledByFlags(r.CheckName()) {
 		return false
 	}
 
@@ -66,12 +51,12 @@ func isEnabled(r *linter.Report) bool {
 }
 
 // canBeDisabled returns whether or not '@linter disable' can be used for the specified file
-func canBeDisabled(filename string) bool {
-	if allowDisableRegex == nil {
+func canBeDisabled(l *linterRunner, filename string) bool {
+	if l.allowDisableRegex == nil {
 		return false
 	}
 
-	return allowDisableRegex.MatchString(filename)
+	return l.allowDisableRegex.MatchString(filename)
 }
 
 // Run executes linter main function.
@@ -88,16 +73,17 @@ func Run(cfg *MainConfig) (int, error) {
 		cfg = &MainConfig{}
 	}
 
-	bindFlags()
+	var args cmdlineArguments
+	bindFlags(&args)
 	flag.Parse()
-	if disableCache {
+	if args.disableCache {
 		linter.CacheDir = ""
 	}
 	if cfg.AfterFlagParse != nil {
 		cfg.AfterFlagParse()
 	}
 
-	return mainNoExit(cfg)
+	return mainNoExit(&args, cfg)
 }
 
 // Main is like Run(), but it calls os.Exit() and does not return.
@@ -109,46 +95,25 @@ func Main(cfg *MainConfig) {
 	os.Exit(status)
 }
 
-func loadMisspellDicts(dicts []string) error {
-	linter.TypoFixer = &misspell.Replacer{}
-
-	for _, d := range dicts {
-		d = strings.TrimSpace(d)
-		switch {
-		case d == "Eng":
-			linter.TypoFixer.AddRuleList(misspell.DictMain)
-		case d == "Eng/US":
-			linter.TypoFixer.AddRuleList(misspell.DictAmerican)
-		case d == "Eng/UK" || d == "Eng/GB":
-			linter.TypoFixer.AddRuleList(misspell.DictBritish)
-		default:
-			return fmt.Errorf("unsupported %s misspell-list entry", d)
-		}
-	}
-
-	linter.TypoFixer.Compile()
-	return nil
-}
-
 // mainNoExit implements main, but instead of doing log.Fatal or os.Exit it
 // returns error or non-zero integer status code to be passed to os.Exit by the caller.
 // Note that if error is not nil, integer code will be discarded, so it can be 0.
 //
 // We don't want os.Exit to be inserted randomly to avoid defer cancellation.
-func mainNoExit(cfg *MainConfig) (int, error) {
-	if version {
+func mainNoExit(args *cmdlineArguments, cfg *MainConfig) (int, error) {
+	if args.version {
 		// Version is already printed. Can exit here.
 		return 0, nil
 	}
 
-	if pprofHost != "" {
-		go http.ListenAndServe(pprofHost, nil)
+	if args.pprofHost != "" {
+		go http.ListenAndServe(args.pprofHost, nil)
 	}
 
 	// Since this function is expected to be exit-free, it's OK
 	// to defer calls here to make required flushes/cleanup.
-	if cpuProfile != "" {
-		f, err := os.Create(cpuProfile)
+	if args.cpuProfile != "" {
+		f, err := os.Create(args.cpuProfile)
 		if err != nil {
 			return 0, fmt.Errorf("Could not create CPU profile: %v", err)
 		}
@@ -158,9 +123,9 @@ func mainNoExit(cfg *MainConfig) (int, error) {
 		}
 		defer pprof.StopCPUProfile()
 	}
-	if memProfile != "" {
+	if args.memProfile != "" {
 		defer func() {
-			f, err := os.Create(memProfile)
+			f, err := os.Create(args.memProfile)
 			if err != nil {
 				log.Printf("could not create memory profile: %v", err)
 				return
@@ -173,24 +138,13 @@ func mainNoExit(cfg *MainConfig) (int, error) {
 		}()
 	}
 
-	if err := setDiscardVarPredicate(); err != nil {
-		return 0, fmt.Errorf("compile unused-var-regex: %v", err)
-	}
+	linter.PHPExtensions = strings.Split(args.phpExtensionsArg, ",")
 
-	linter.PHPExtensions = strings.Split(phpExtensionsArg, ",")
-	if err := compileRegexes(); err != nil {
-		return 0, err
+	var l linterRunner
+	if err := l.Init(args); err != nil {
+		return 1, fmt.Errorf("init: %v", err)
 	}
 
-	if misspellList != "" {
-		err := loadMisspellDicts(strings.Split(misspellList, ","))
-		if err != nil {
-			return 0, err
-		}
-	}
-
-	buildCheckMappings()
-
 	lintdebug.Register(func(msg string) { linter.DebugMessage("%s", msg) })
 	go linter.MemoryLimiterThread()
 
@@ -200,26 +154,14 @@ func mainNoExit(cfg *MainConfig) (int, error) {
 		return 0, nil
 	}
 
-	if output != "" {
-		var err error
-		outputFp, err = os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
-		if err != nil {
-			return 0, fmt.Errorf("Could not open output file: %v", err)
-		}
-	}
-
 	log.Printf("Started")
 
 	if err := initStubs(); err != nil {
 		return 0, fmt.Errorf("Init stubs: %v", err)
 	}
 
-	if err := initRules(); err != nil {
-		return 0, fmt.Errorf("Init rules: %v", err)
-	}
-
-	if gitRepo != "" {
-		return gitMain(cfg)
+	if args.gitRepo != "" {
+		return gitMain(&l, cfg)
 	}
 
 	linter.AnalysisFiles = flag.Args()
@@ -230,12 +172,12 @@ func mainNoExit(cfg *MainConfig) (int, error) {
 	log.Printf("Linting")
 
 	filenames := flag.Args()
-	if fullAnalysisFiles != "" {
-		filenames = strings.Split(fullAnalysisFiles, ",")
+	if args.fullAnalysisFiles != "" {
+		filenames = strings.Split(args.fullAnalysisFiles, ",")
 	}
 
 	reports := linter.ParseFilenames(linter.ReadFilenames(filenames, linter.ExcludeRegex))
-	criticalReports := analyzeReports(cfg, reports)
+	criticalReports := analyzeReports(&l, cfg, reports)
 
 	if criticalReports > 0 {
 		log.Printf("Found %d critical reports", criticalReports)
@@ -244,56 +186,20 @@ func mainNoExit(cfg *MainConfig) (int, error) {
 	return 0, nil
 }
 
-func compileRegexes() error {
-	var err error
-
-	if reportsExclude != "" {
-		linter.ExcludeRegex, err = regexp.Compile(reportsExclude)
-		if err != nil {
-			return fmt.Errorf("Incorrect exclude regex: %v", err)
-		}
-	}
-
-	if allowDisable != "" {
-		allowDisableRegex, err = regexp.Compile(allowDisable)
-		if err != nil {
-			return fmt.Errorf("Incorrect 'allow disable' regex: %v", err)
-		}
-	}
-
-	return nil
-}
-
-func buildCheckMappings() {
-	stringToSet := func(s string) map[string]bool {
-		set := make(map[string]bool)
-		for _, name := range strings.Split(s, ",") {
-			set[strings.TrimSpace(name)] = true
-		}
-		return set
-	}
-
-	reportsExcludeChecksSet = stringToSet(reportsExcludeChecks)
-	reportsIncludeChecksSet = stringToSet(allowChecks)
-	if reportsCritical != allNonMaybe {
-		reportsCriticalSet = stringToSet(reportsCritical)
-	}
-}
-
-func analyzeReports(cfg *MainConfig, diff []*linter.Report) (criticalReports int) {
+func analyzeReports(l *linterRunner, cfg *MainConfig, diff []*linter.Report) (criticalReports int) {
 	filtered := make([]*linter.Report, 0, len(diff))
 	var linterErrors []string
 	for _, r := range diff {
 		if cfg.BeforeReport != nil && !cfg.BeforeReport(r) {
 			continue
 		}
-		if !isEnabled(r) {
+		if !isEnabled(l, r) {
 			continue
 		}
 
 		if r.IsDisabledByUser() {
 			filename := r.GetFilename()
-			if !canBeDisabled(filename) {
+			if !canBeDisabled(l, filename) {
 				linterErrors = append(linterErrors, fmt.Sprintf("You are not allowed to disable linter for file '%s'", filename))
 			} else {
 				continue
@@ -302,12 +208,12 @@ func analyzeReports(cfg *MainConfig, diff []*linter.Report) (criticalReports int
 
 		filtered = append(filtered, r)
 
-		if isCritical(r) {
+		if isCritical(l, r) {
 			criticalReports++
 		}
 	}
 
-	if outputJSON {
+	if l.args.outputJSON {
 		type reportList struct {
 			Reports []*linter.Report
 			Errors  []string
@@ -316,20 +222,20 @@ func analyzeReports(cfg *MainConfig, diff []*linter.Report) (criticalReports int
 			Reports: filtered,
 			Errors:  linterErrors,
 		}
-		d := json.NewEncoder(outputFp)
+		d := json.NewEncoder(l.outputFp)
 		if err := d.Encode(list); err != nil {
 			// Should never fail to marshal our own reports.
 			panic(fmt.Sprintf("report list marshaling failed: %v", err))
 		}
 	} else {
 		for _, err := range linterErrors {
-			fmt.Fprintf(outputFp, "%s\n", err)
+			fmt.Fprintf(l.outputFp, "%s\n", err)
 		}
 		for _, r := range filtered {
-			if isCritical(r) {
-				fmt.Fprintf(outputFp, "<critical> %s\n", r.String())
+			if isCritical(l, r) {
+				fmt.Fprintf(l.outputFp, "<critical> %s\n", r.String())
 			} else {
-				fmt.Fprintf(outputFp, "%s\n", r.String())
+				fmt.Fprintf(l.outputFp, "%s\n", r.String())
 			}
 		}
 	}
@@ -337,29 +243,7 @@ func analyzeReports(cfg *MainConfig, diff []*linter.Report) (criticalReports int
 	return criticalReports
 }
 
-func setDiscardVarPredicate() error {
-	switch unusedVarPattern {
-	case "^_$":
-		// Default pattern, only $_ is allowed.
-		// Don't change anything.
-	case "^_.*$":
-		// Leading underscore plus anything after it.
-		// Recognize as quite common pattern.
-		linter.IsDiscardVar = func(s string) bool {
-			return strings.HasPrefix(s, "_")
-		}
-	default:
-		re, err := regexp.Compile(unusedVarPattern)
-		if err != nil {
-			return err
-		}
-		linter.IsDiscardVar = re.MatchString
-	}
-
-	return nil
-}
-
-func loadRulesFile(p *rules.Parser, filter func(r rules.Rule) bool, filename string, data []byte) error {
+func loadRulesFile(p *rules.Parser, filter func(r rules.Rule) bool, filename string, data []byte) (*rules.Set, error) {
 	appendRules := func(dst, src *rules.ScopedSet) {
 		for i, list := range src.RulesByKind {
 			for _, r := range list {
@@ -373,59 +257,30 @@ func loadRulesFile(p *rules.Parser, filter func(r rules.Rule) bool, filename str
 
 	rset, err := p.Parse(filename, bytes.NewReader(data))
 	if err != nil {
-		return err
-	}
-
-	for _, name := range rset.AlwaysAllowed {
-		reportsIncludeChecksSet[name] = true
-	}
-	for _, name := range rset.AlwaysCritical {
-		reportsCriticalSet[name] = true
+		return nil, err
 	}
 
 	appendRules(linter.Rules.Any, rset.Any)
 	appendRules(linter.Rules.Root, rset.Root)
 	appendRules(linter.Rules.Local, rset.Local)
 
-	return nil
+	return rset, nil
 }
 
-func InitEmbeddedRules(p *rules.Parser, filter func(r rules.Rule) bool) error {
+func InitEmbeddedRules(p *rules.Parser, filter func(r rules.Rule) bool) ([]*rules.Set, error) {
+	var ruleSets []*rules.Set
 	for _, filename := range embeddedrules.AssetNames() {
 		data, err := embeddedrules.Asset(filename)
 		if err != nil {
-			return err
-		}
-		if err := loadRulesFile(p, filter, filename, data); err != nil {
-			return err
+			return nil, err
 		}
-	}
-	return nil
-}
-
-func initRules() error {
-	ruleFilter := func(r rules.Rule) bool {
-		return isEnabledByFlags(r.Name)
-	}
-
-	linter.Rules = rules.NewSet()
-	p := rules.NewParser()
-
-	if err := InitEmbeddedRules(p, ruleFilter); err != nil {
-		return err
-	}
-
-	if rulesList != "" {
-		for _, filename := range strings.Split(rulesList, ",") {
-			data, err := ioutil.ReadFile(filename)
-			if err != nil {
-				return err
-			}
-			loadRulesFile(p, ruleFilter, filename, data)
+		rset, err := loadRulesFile(p, filter, filename, data)
+		if err != nil {
+			return nil, err
 		}
+		ruleSets = append(ruleSets, rset)
 	}
-
-	return nil
+	return ruleSets, nil
 }
 
 func initStubs() error {


Test patch:
diff --git a/src/linttest/golden_test.go b/src/linttest/golden_test.go
index 70d5900..f9a8f0e 100644
--- a/src/linttest/golden_test.go
+++ b/src/linttest/golden_test.go
@@ -24,7 +24,7 @@ func TestGolden(t *testing.T) {
 	enableAllRules := func(_ rules.Rule) bool { return true }
 	p := rules.NewParser()
 	linter.Rules = rules.NewSet()
-	if err := cmd.InitEmbeddedRules(p, enableAllRules); err != nil {
+	if _, err := cmd.InitEmbeddedRules(p, enableAllRules); err != nil {
 		t.Fatalf("init embedded rules: %v", err)
 	}