config.go GO 594 lines View on github.com → Search inside
1// SPDX-License-Identifier: MIT23package main45import (6	"fmt"7	"io"8	"os"9	"path/filepath"10	"runtime"11	"strconv"12	"strings"1314	"github.com/boyter/gocodewalker"15	"github.com/boyter/scc/v4/processor"16	"github.com/spf13/cobra"17	"github.com/spf13/pflag"18)1920// isCompletionInvocation reports whether args is a cobra completion request:21// the hidden __complete / __completeNoDesc commands the shell calls on TAB, or22// the user-facing `completion` command. These must reach cobra with the genuine23// argv, so config discovery is skipped for them.24func isCompletionInvocation(args []string) bool {25	if len(args) < 2 {26		return false27	}28	switch args[1] {29	case cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd, "completion":30		return true31	default:32		return false33	}34}3536// SccConfigEnv is the environment variable naming the global config source.37const SccConfigEnv = "SCC_CONFIG_PATH"3839// Default slice values are kept as Go constants rather than as cobra flag40// defaults.41var (42	defaultExcludeDirs      = []string{".git", ".hg", ".svn"}43	defaultExcludeFiles     = []string{"package-lock.json", "Cargo.lock", "yarn.lock", "pubspec.lock", "Podfile.lock", "pnpm-lock.yaml"}44	defaultGeneratedMarkers = []string{"do not edit", "<auto-generated />"}45)4647// configSource records a config file that was loaded during discovery and the48// tokens it contributed.49type configSource struct {50	label  string51	tokens []string52}5354var configTrace []string55var configSources []configSource5657// parseConfigArgs tokenizes the contents of a config-ish source (a config file58// or an @file) into a flat slice of CLI tokens. Format rules:59//60//   - One or more whitespace-separated tokens per line; blank lines ignored.61//   - '#' begins a comment; whole-line and inline trailing comments are stripped.62//   - Tokenization is quote-aware: single or double quotes group a run literally63//     up to the matching close quote. To include a literal quote, switch styles.64//   - Backslash is an ordinary literal outside quotes (NOT an escape), so a65//     Windows path like C:\build\out survives verbatim.66//67// When allowPositional is false (config sources) a line whose first token does68// not start with '-' is skipped with a warning to ungated stderr, preventing a69// config file from injecting count targets. When true (@file) positional lines70// are kept, preserving the existing @file behaviour.71func parseConfigArgs(content string, allowPositional bool) []string {72	var args []string7374	for _, rawLine := range strings.Split(content, "\n") {75		line := strings.TrimSpace(strings.TrimSuffix(rawLine, "\r"))76		if line == "" {77			continue78		}7980		tokens := tokenizeConfigLine(line)81		if len(tokens) == 0 {82			continue83		}8485		if !allowPositional {86			if !strings.HasPrefix(tokens[0], "-") {87				_, _ = fmt.Fprintf(os.Stderr, "warning: ignoring config line (not a flag): %s\n", line)88				continue89			}90			// '--' is pflag's end-of-flags marker. Config tokens are prepended91			// ahead of the genuine CLI, so a '--' from a config file would turn92			// the user's real flags into positional args - disabling them and93			// undermining the "config cannot inject count targets" guarantee.94			// Config has no legitimate positionals, so strip the marker.95			kept := tokens[:0]96			for _, t := range tokens {97				if t == "--" {98					_, _ = fmt.Fprintf(os.Stderr, "warning: ignoring end-of-flags marker '--' in config: %s\n", line)99					continue100				}101				kept = append(kept, t)102			}103			tokens = kept104			if len(tokens) == 0 {105				continue106			}107		}108109		args = append(args, tokens...)110	}111112	return args113}114115// tokenizeConfigLine splits a single (already trimmed, non-empty) line into116// quote-aware tokens, stripping an unquoted trailing '#' comment. Backslash is117// a literal outside quotes.118func tokenizeConfigLine(line string) []string {119	var tokens []string120	var sb strings.Builder121	var quote rune // 0 when not inside quotes, otherwise the active quote char122	inToken := false123124	flush := func() {125		if inToken {126			tokens = append(tokens, sb.String())127			sb.Reset()128			inToken = false129		}130	}131132	for _, r := range line {133		switch {134		case quote != 0:135			if r == quote {136				quote = 0137			} else {138				sb.WriteRune(r)139			}140		case r == '\'' || r == '"':141			// Quotes are the sole grouping mechanism and always begin a token,142			// so an empty quoted value (e.g. "") still produces a token.143			quote = r144			inToken = true145		case r == '#':146			// Unquoted '#' starts a comment for the rest of the line.147			flush()148			return tokens149		case r == ' ' || r == '\t':150			flush()151		default:152			sb.WriteRune(r)153			inToken = true154		}155	}156	flush()157158	return tokens159}160161// preScanConfig scans os.Args for the three config-control flags before162// cobra runs. Reading os.Args alone is what makes --config/--no-config inside a163// config file inert.164func preScanConfig(args []string) (noConfig bool, findRoot bool, explicitPath string) {165	for i := 0; i < len(args); i++ {166		a := args[i]167		switch {168		case a == "--no-config":169			noConfig = true170		case a == "--find-root-config":171			findRoot = true172		case a == "--config":173			// Bounds-check: guard against --config being the final token (a174			// user typo) before consuming args[i+1].175			if i+1 < len(args) {176				explicitPath = args[i+1]177			}178		case strings.HasPrefix(a, "--config="):179			explicitPath = strings.TrimPrefix(a, "--config=")180		}181	}182183	return noConfig, findRoot, explicitPath184}185186// discoverConfigArgs resolves the global and project config sources187func discoverConfigArgs(noConfig, findRoot bool, explicitPath string) (globalTokens, projectTokens []string, err error) {188	// Global source: --config wins, else SCC_CONFIG_PATH (when non-empty and not189	// disabled), else nothing. No default location, no home-dir stat.190	globalPath := ""191	switch {192	case explicitPath != "":193		globalPath = explicitPath194	case !noConfig:195		if env := os.Getenv(SccConfigEnv); env != "" {196			globalPath = env197		}198	}199200	if globalPath != "" {201		tokens, readErr := readConfigFile(globalPath, "global config "+globalPath)202		if readErr != nil {203			// Explicit source the user asked for: surface and exit non-zero.204			return nil, nil, fmt.Errorf("could not read config file %q: %w", globalPath, readErr)205		}206		globalTokens = tokens207	}208209	// Project source: ./.sccconfig by default unless --find-root-config set210	if !noConfig {211		projectPath := "./.sccconfig"212		if findRoot {213			projectPath = filepath.Join(gocodewalker.FindRepositoryRoot("."), ".sccconfig")214		}215216		if _, statErr := os.Stat(projectPath); statErr == nil {217			tokens, readErr := readConfigFile(projectPath, "project "+projectPath)218			if readErr != nil {219				_, _ = fmt.Fprintf(os.Stderr, "warning: could not read config file %q: %s\n", projectPath, readErr)220			} else {221				projectTokens = tokens222			}223		}224	}225226	return globalTokens, projectTokens, nil227}228229// readConfigFile reads and tokenizes a config source, recording it for trace230// output and unknown-flag attribution.231func readConfigFile(path, label string) (tokens []string, err error) {232	defer func() {233		if r := recover(); r != nil {234			err = fmt.Errorf("panic while reading config: %v", r)235		}236	}()237238	b, err := os.ReadFile(path)239	if err != nil {240		return nil, err241	}242243	tokens = parseConfigArgs(string(b), false)244	configTrace = append(configTrace, fmt.Sprintf("loaded %s (%d tokens)", label, len(tokens)))245	configSources = append(configSources, configSource{label: label, tokens: tokens})246247	return tokens, nil248}249250// flushConfigTrace emits the buffered discovery messages once Trace/Debug have been set.251func flushConfigTrace() {252	for _, msg := range configTrace {253		processor.PrintTrace(msg)254		processor.PrintDebug(msg)255	}256}257258// attributeConfigFlag returns the label of the config source that contributed259// the named flag, or "" if it was not seen in any config source (came from the260// genuine CLI, or is ambiguous).261func attributeConfigFlag(name string) string {262	want := "--" + name263	for _, src := range configSources {264		for _, tok := range src.tokens {265			if tok == want || strings.HasPrefix(tok, want+"=") {266				return src.label267			}268		}269	}270	return ""271}272273// flagBindings abstracts the three write-flag sinks plus a mode switch so274// registerFlags can build either a real-binding flag set or an inert one.275type flagBindings struct {276	output      *string277	report      *string278	formatMulti *string279	inert       bool280}281282// registerFlags registers the full scc flag set on fs, binding write flags via283// b and every other flag either to the real processor.* vars (b.inert == false)284// or to throwaway sinks (b.inert == true). It does NOT register the three285// config-control flags (--config/--no-config/--find-root-config); those are286// handled by registerConfigControlFlags so they can be added to both the287// rootCmd and the CLI-only flag set.288func registerFlags(flags *pflag.FlagSet, b *flagBindings) {289	// Binding-target selectors: return the real pointer in normal mode and a290	// fresh throwaway in inert mode.291	boolVar := func(real *bool) *bool {292		if b.inert {293			return new(bool)294		}295		return real296	}297	strVar := func(real *string) *string {298		if b.inert {299			return new(string)300		}301		return real302	}303	sliceVar := func(real *[]string) *[]string {304		if b.inert {305			return new([]string)306		}307		return real308	}309	intVar := func(real *int) *int {310		if b.inert {311			return new(int)312		}313		return real314	}315	int64Var := func(real *int64) *int64 {316		if b.inert {317			return new(int64)318		}319		return real320	}321	floatVar := func(real *float64) *float64 {322		if b.inert {323			return new(float64)324		}325		return real326	}327	boolFunc := func(real func(string) error) func(string) error {328		if b.inert {329			return func(string) error { return nil }330		}331		return real332	}333334	flags.BoolVarP(boolVar(&processor.MaxMean), "character", "m", false, "calculate max and mean characters per line")335	flags.BoolVarP(boolVar(&processor.Percent), "percent", "p", false, "include percentage values in output")336	flags.BoolVarP(boolVar(&processor.UlocMode), "uloc", "u", false, "calculate the number of unique lines of code (ULOC) for the project")337	flags.BoolVarP(boolVar(&processor.Dryness), "dryness", "a", false, "calculate the DRYness of the project (implies --uloc)")338	flags.BoolVar(boolVar(&processor.Cognitive), "cognitive", false, "calculate cognitive (nesting-weighted) complexity")339	flags.BoolVar(boolVar(&processor.DisableCheckBinary), "binary", false, "disable binary file detection")340	flags.BoolVar(boolVar(&processor.Files), "by-file", false, "display output for every file")341	flags.BoolVar(boolVar(&processor.Ci), "ci", false, "enable CI output settings where stdout is ASCII")342	flags.BoolVar(boolVar(&processor.Ignore), "no-ignore", false, "disables .ignore file logic")343	flags.BoolVar(boolVar(&processor.SccIgnore), "no-scc-ignore", false, "disables .sccignore file logic")344	flags.BoolVar(boolVar(&processor.GitIgnore), "no-gitignore", false, "disables .gitignore file logic")345	flags.BoolVar(boolVar(&processor.GitModuleIgnore), "no-gitmodule", false, "disables .gitmodules file logic")346	flags.BoolVar(boolVar(&processor.CountIgnore), "count-ignore", false, "set to allow .gitignore and .ignore files to be counted")347	flags.BoolVar(boolVar(&processor.CountUnsupported), "count-unsupported", false, "count files with an unrecognised language under an \"Unknown\" category as plain text")348	flags.StringArrayVar(sliceVar(&processor.IgnoreFiles), "ignore-file", nil, "path to an additional gitignore-format ignore file, applied from the scan root; repeat to add more, later files and any in-tree ignore files take precedence")349	flags.BoolVar(boolVar(&processor.Debug), "debug", false, "enable debug output")350	// Registered with an empty default; the real default is merged back post-parse.351	flags.StringSliceVar(sliceVar(&processor.PathDenyList), "exclude-dir", []string{}, "directories to exclude")352	flags.IntVar(intVar(&processor.GcFileCount), "file-gc-count", 10000, "number of files to parse before turning the GC on")353	flags.IntVar(intVar(&processor.FileListQueueSize), "file-list-queue-size", runtime.NumCPU(), "the size of the queue of files found and ready to be read into memory")354	flags.IntVar(intVar(&processor.FileProcessJobWorkers), "file-process-job-workers", runtime.NumCPU(), "number of goroutine workers that process files collecting stats")355	flags.BoolVar(boolVar(&processor.SpecialisedCounters), "exp-per-language-counters", false, "experimental, may change or be removed: count with the scanner written for the file's language where there is one, see --list-counters")356	flags.BoolVar(boolVar(&processor.ListCounters), "list-counters", false, "print the languages that have a scanner of their own and exit")357	flags.IntVar(intVar(&processor.FileListJobWorkers), "file-list-job-workers", runtime.NumCPU(), "number of goroutine workers that turn a walked path into a job to be counted")358	flags.IntVar(intVar(&processor.FileSummaryJobQueueSize), "file-summary-job-queue-size", runtime.NumCPU(), "the size of the queue used to hold processed file statistics before formatting")359	flags.IntVar(intVar(&processor.DirectoryWalkerJobWorkers), "directory-walker-job-workers", 8, "controls the maximum number of workers which will walk the directory tree")360	flags.StringVarP(strVar(&processor.Format), "format", "f", "tabular", "set output format [tabular, wide, json, json2, csv, csv-stream, cloc-yaml, html, html-table, sql, sql-insert, openmetrics]")361362	// Write flag: bound via b so config can never reach the real var.363	flags.StringVar(b.report, "report", "", "write a self-contained HTML report; bare flag writes scc-report.html and prompts before overwriting, --report=path/out.html overwrites silently")364365	// NoOptDefVal makes a bare `--report` work (no `=value`). runReport compares366	// ReportOut to processor.DefaultReportName to tell "bare flag" apart from an367	// explicit path, so the two must stay in sync.368	flags.Lookup("report").NoOptDefVal = processor.DefaultReportName369	flags.StringVar(strVar(&processor.ReportSkip), "report-skip", "", "comma-separated sections to omit (cocomo,locomo,hotspots,coupling,authors,timeline,files,uloc,linelength,card)")370	flags.StringVar(strVar(&processor.ReportTitle), "report-title", "", "override the repo name shown in the report banner")371	flags.StringSliceVarP(sliceVar(&processor.AllowListExtensions), "include-ext", "i", []string{}, "limit to file extensions [comma separated list: e.g. go,java,js]")372	flags.StringSliceVarP(sliceVar(&processor.ExcludeListExtensions), "exclude-ext", "x", []string{}, "ignore file extensions (overrides include-ext) [comma separated list: e.g. go,java,js]")373374	// Registered with an empty default; the real default is merged back post-parse.375	flags.StringSliceVarP(sliceVar(&processor.ExcludeFilename), "exclude-file", "n", []string{}, "ignore files with matching names")376	flags.BoolVarP(boolVar(&processor.Languages), "languages", "l", false, "print supported languages and extensions")377	flags.Int64Var(int64Var(&processor.AverageWage), "avg-wage", 56286, "average wage value used for basic COCOMO calculation")378	flags.Float64Var(floatVar(&processor.Overhead), "overhead", 2.4, "set the overhead multiplier for corporate overhead (facilities, equipment, accounting, etc.)")379	flags.Float64Var(floatVar(&processor.EAF), "eaf", 1.0, "the effort adjustment factor derived from the cost drivers (1.0 if rated nominal)")380	flags.BoolVar(boolVar(&processor.SLOCCountFormat), "sloccount-format", false, "print a more SLOCCount like COCOMO calculation")381	flags.BoolVar(boolVar(&processor.Cocomo), "no-cocomo", false, "remove COCOMO calculation output")382	flags.StringVar(strVar(&processor.CocomoProjectType), "cocomo-project-type", "organic", "change COCOMO model type [organic, semi-detached, embedded, \"custom,1,1,1,1\"]")383	flags.BoolVar(boolVar(&processor.Size), "no-size", false, "remove size calculation output")384	flags.BoolVar(boolVar(&processor.HBorder), "no-hborder", false, "remove horizontal borders between sections")385	flags.StringVar(strVar(&processor.SizeUnit), "size-unit", "si", "set size unit [si, binary, mixed, xkcd-kb, xkcd-kelly, xkcd-imaginary, xkcd-intel, xkcd-drive, xkcd-bakers]")386	flags.BoolVarP(boolVar(&processor.Complexity), "no-complexity", "c", false, "skip calculation of code complexity")387	flags.BoolVarP(boolVar(&processor.Duplicates), "no-duplicates", "d", false, "remove duplicate files from stats and output")388	flags.BoolFuncP("min-gen", "z", "identify minified or generated files", boolFunc(func(s string) error { // using func so that last flag wins389		v, _ := strconv.ParseBool(s)390		processor.MinifiedGenerated = v391		if v {392			processor.IgnoreMinifiedGenerate = false393		}394		return nil395	}))396	flags.BoolFunc("min", "identify minified files", boolFunc(func(s string) error { // using func so that last flag wins397		v, _ := strconv.ParseBool(s)398		processor.Minified = v399		if v {400			processor.IgnoreMinified = false401		}402		return nil403	}))404	flags.BoolFunc("gen", "identify generated files", boolFunc(func(s string) error { // using func so that last flag wins405		v, _ := strconv.ParseBool(s)406		processor.Generated = v407		if v {408			processor.IgnoreGenerated = false409		}410		return nil411	}))412	// Registered with an empty default; the real default is merged back post-parse.413	flags.StringSliceVar(sliceVar(&processor.GeneratedMarkers), "generated-markers", []string{}, "string markers in head of generated files")414	flags.BoolFunc("no-min-gen", "ignore minified or generated files in output (implies --min-gen)", boolFunc(func(s string) error {415		v, _ := strconv.ParseBool(s)416		processor.IgnoreMinifiedGenerate = v417		return nil418	}))419	flags.BoolFunc("no-min", "ignore minified files in output (implies --min)", boolFunc(func(s string) error {420		v, _ := strconv.ParseBool(s)421		processor.IgnoreMinified = v422		return nil423	}))424	flags.BoolFunc("no-gen", "ignore generated files in output (implies --gen)", boolFunc(func(s string) error {425		v, _ := strconv.ParseBool(s)426		processor.IgnoreGenerated = v427		return nil428	}))429	flags.IntVar(intVar(&processor.MinifiedGeneratedLineByteLength), "min-gen-line-length", 255, "number of bytes per average line for file to be considered minified or generated")430	flags.StringArrayVarP(sliceVar(&processor.Exclude), "not-match", "M", []string{}, "ignore files and directories matching regular expression")431	// Write flag: bound via b so config can never reach the real var.432	flags.StringVarP(b.output, "output", "o", "", "output filename (default stdout)")433	flags.StringVarP(strVar(&processor.SortBy), "sort", "s", "files", "column to sort by [files, name, lines, blanks, code, comments, complexity]")434	flags.BoolVarP(boolVar(&processor.Trace), "trace", "t", false, "enable trace output (not recommended when processing multiple files)")435	flags.BoolVarP(boolVar(&processor.Verbose), "verbose", "v", false, "verbose output")436	flags.BoolVarP(boolVar(&processor.More), "wide", "w", false, "wider output with additional statistics (implies --complexity)")437	flags.BoolVar(boolVar(&processor.NoLarge), "no-large", false, "ignore files over certain byte and line size set by large-line-count and large-byte-count")438	flags.BoolVar(boolVar(&processor.IncludeSymLinks), "include-symlinks", false, "if set will count symlink files")439	flags.Int64Var(int64Var(&processor.LargeLineCount), "large-line-count", 40000, "number of lines a file can contain before being removed from output")440	flags.Int64Var(int64Var(&processor.LargeByteCount), "large-byte-count", 1000000, "number of bytes a file can contain before being removed from output")441	flags.StringVar(strVar(&processor.CountAs), "count-as", "", "count extension as language [e.g. jsp:htm,chead:\"C Header\" maps extension jsp to html and chead to C Header]")442	flags.StringArrayVar(sliceVar(&processor.CountAsPattern), "count-as-pattern", nil, "count files matching a path pattern as a new named category backed by a base language "+443		"[repeatable; pattern is glob by default, prefix with re: for regex; "+444		"e.g. *_spec.rb:\"Ruby Spec\":Ruby or re:\\.test\\.js$:\"JavaScript Tests\":JavaScript]")445	// Write flag: bound via b so config can never reach the real var.446	flags.StringVar(b.formatMulti, "format-multi", "", "have multiple format output overriding --format [e.g. tabular:stdout,csv:file.csv,json:file.json]")447	flags.StringVar(strVar(&processor.SQLProject), "sql-project", "", "use supplied name as the project identifier for the current run. Only valid with the --format sql or sql-insert option")448	flags.StringVar(strVar(&processor.RemapUnknown), "remap-unknown", "", "inspect files of unknown type and remap by checking for a string and remapping the language [e.g. \"-*- C++ -*-\":\"C Header\"]")449	flags.StringVar(strVar(&processor.RemapAll), "remap-all", "", "inspect every file and remap by checking for a string and remapping the language [e.g. \"-*- C++ -*-\":\"C Header\"]")450	flags.StringVar(strVar(&processor.CurrencySymbol), "currency-symbol", "$", "set currency symbol")451	flags.BoolVar(boolVar(&processor.Locomo), "locomo", false, "enable LOCOMO (LLM Output COst MOdel) cost estimation")452	flags.BoolVar(boolVar(&processor.CostComparison), "cost-comparison", false, "show both COCOMO and LOCOMO estimates side by side")453	flags.StringVar(strVar(&processor.LocomoPresetName), "locomo-preset", "medium", "LOCOMO model preset [large, medium, small, local]")454	flags.Float64Var(floatVar(&processor.LocomoReviewMinutesPerLine), "locomo-review", 0.01, "human review minutes per line of code for LOCOMO estimate")455	flags.StringVar(strVar(&processor.LocomoConfig), "locomo-config", "", "LOCOMO power-user config \"tokensPerLine,inputPerLine,complexityWeight,iterations,iterationWeight\"")456	flags.Float64Var(floatVar(&processor.LocomoInputPrice), "locomo-input-price", 0, "LOCOMO cost per 1M input tokens in dollars (overrides preset)")457	flags.Float64Var(floatVar(&processor.LocomoOutputPrice), "locomo-output-price", 0, "LOCOMO cost per 1M output tokens in dollars (overrides preset)")458	flags.Float64Var(floatVar(&processor.LocomoTPS), "locomo-tps", 0, "LOCOMO output tokens per second (overrides preset)")459	flags.Float64Var(floatVar(&processor.LocomoCyclesOverride), "locomo-cycles", 0, "override estimated LLM iteration cycles (default: calculated from complexity)")460	flags.BoolVar(boolVar(&processor.Hotspots), "hotspots", false, "render the hotspots report (files ranked by complexity × change frequency over recent git history)")461	flags.BoolVar(boolVar(&processor.Coupling), "coupling", false, "render the change-coupling report (file pairs that change together over recent git history)")462	flags.StringVar(strVar(&processor.CouplingFor), "coupling-for", "", "blast-radius view: given a file path, show what tends to change with it over recent git history")463	flags.BoolVar(boolVar(&processor.CouplingWeighted), "coupling-weighted", false, "weight coupling by file complexity so pairs of complex files rank above generated/data-file churn (implies --coupling)")464	flags.BoolVar(boolVar(&processor.ByAuthor), "by-author", false, "render the author rollup report (bus factor and last-toucher attribution over recent git history)")465	flags.IntVar(intVar(&processor.HistoryDepth), "depth", 1000, "commit window size for git history reports; 0 means entire history (large repos may be slow)")466	flags.BoolVar(boolVar(&processor.Timeline), "timeline", false, "render an over-time view of recent git history; with --by-author runs the author timeline, alone runs the languages timeline")467	flags.IntVar(intVar(&processor.HistoryBuckets), "buckets", 60, "time-bucket resolution for the git timeline reports")468	// --no-fold-authors is read back via cmd.PersistentFlags().GetBool in Run, so469	// its bound var is irrelevant; a throwaway sink is enough in both modes.470	flags.BoolVar(new(bool), "no-fold-authors", false, "disable the name+email-domain identity folding fallback for git author reports (mailmap still applied)")471	// --mcp is intercepted before cobra runs, but we register it here so it472	// appears in --help and the CLI-only parse accepts it dummy-bound.473	flags.BoolVar(new(bool), "mcp", false, "start as an MCP (Model Context Protocol) server over stdio")474475	// Restore the displayed (default ...) text for the three slice flags whose476	// runtime default was emptied above; DefValue is presentation only, the bound477	// slice stays empty so the replace-on-first-Set footgun is still defused.478	flags.Lookup("exclude-dir").DefValue = "[" + strings.Join(defaultExcludeDirs, ",") + "]"479	flags.Lookup("exclude-file").DefValue = "[" + strings.Join(defaultExcludeFiles, ",") + "]"480	flags.Lookup("generated-markers").DefValue = "[" + strings.Join(defaultGeneratedMarkers, ",") + "]"481}482483// registerConfigControlFlags registers --config, --no-config and484// --find-root-config as dummy-bound flags. They are pre-scanned from os.Args, so485// the bound values are never read; registering them keeps --help complete and486// stops cobra (and the CLI-only parse) treating them as unknown flags. Note: no487// -r shorthand - that short flag is reserved for a future find-root that488// relocates the scan directory, matching cs.489func registerConfigControlFlags(flags *pflag.FlagSet) {490	flags.String("config", "", "load this file as the global config source; overrides SCC_CONFIG_PATH, honored even with --no-config")491	flags.Bool("no-config", false, "disable auto-discovery of the SCC_CONFIG_PATH global and the project ./.sccconfig config")492	flags.Bool("find-root-config", false, "discover the project .sccconfig by walking up to the repository root instead of using ./.sccconfig")493}494495// mergeSliceDefault implements the slice-default preservation. If set is496// empty (nobody supplied the flag) it returns the built-in defaults; otherwise497// it returns defaults ∪ set, de-duplicated and order-stable (defaults first,498// then the supplied values), so a config/CLI value is additive to the built-in499// safety net rather than replacing it.500func mergeSliceDefault(set, defaults []string) []string {501	if len(set) == 0 {502		return append([]string{}, defaults...)503	}504505	var result []string506	seen := map[string]struct{}{}507508	for _, v := range defaults {509		if _, ok := seen[v]; !ok {510			seen[v] = struct{}{}511			result = append(result, v)512		}513	}514	for _, v := range set {515		if _, ok := seen[v]; !ok {516			seen[v] = struct{}{}517			result = append(result, v)518		}519	}520	return result521}522523// applySliceDefaults merges the built-in defaults back into the three slice524// flags after parsing. Called from rootCmd.Run on every path because the525// flags are always registered with an empty runtime default.526func applySliceDefaults() {527	processor.PathDenyList = mergeSliceDefault(processor.PathDenyList, defaultExcludeDirs)528	processor.ExcludeFilename = mergeSliceDefault(processor.ExcludeFilename, defaultExcludeFiles)529	processor.GeneratedMarkers = mergeSliceDefault(processor.GeneratedMarkers, defaultGeneratedMarkers)530}531532// cliWriteFlags records which write flags the genuine CLI explicitly set, taken533// from the CLI-only parse's Changed() bits. Keying off this rather than the534// resolved value lets warnIfConfigWrote tell "CLI set it" from "CLI left it535// empty" — an explicit --output= / --report= would look unset by value alone.536type cliWriteFlags struct {537	output      bool538	report      bool539	formatMulti bool540}541542// warnIfConfigWrote emits an ungated-stderr notice when a write flag was set in543// the merged parse (i.e. by config, since its discard binding was hit) but the544// genuine CLI did not set it — telling the user their config line was ignored.545// mergedFlags is the rootCmd flag set whose write flags were bound to discards;546// cliSet is whether each write flag was set on the genuine command line.547func warnIfConfigWrote(mergedFlags *pflag.FlagSet, cliSet cliWriteFlags) {548	check := func(name string, setOnCLI bool) {549		if mergedFlags.Changed(name) && !setOnCLI {550			_, _ = fmt.Fprintf(os.Stderr, "warning: ignoring --%s from config; file output can only be set on the command line\n", name)551		}552	}553	check("output", cliSet.output)554	check("report", cliSet.report)555	check("format-multi", cliSet.formatMulti)556}557558// resolveWriteFlags is the sole writer of the real processor.FileOutput,559// processor.ReportOut and processor.FormatMulti vars when config was discovered.560// It parses the genuine-CLI slice (post-@file, pre-config-prepend) through a561// write-only flag set: the three write flags bind to the real vars, everything562// else is inert. This makes file output a CLI-only capability - config tokens563// are structurally incapable of reaching these vars.564func resolveWriteFlags(genuineCLI []string) (set cliWriteFlags) {565	defer func() { _ = recover() }()566567	fs := pflag.NewFlagSet("scc-cli-only", pflag.ContinueOnError)568	fs.SetOutput(io.Discard)569	fs.ParseErrorsAllowlist.UnknownFlags = true570571	registerFlags(fs, &flagBindings{572		output:      &processor.FileOutput,573		report:      &processor.ReportOut,574		formatMulti: &processor.FormatMulti,575		inert:       true,576	})577578	// The config-control flags are registered in main.go, not by registerFlags,579	// so a genuine CLI like `scc --config team.scc -o out.json` would otherwise580	// hit an unknown flag here and -o would silently stop writing.581	registerConfigControlFlags(fs)582583	_ = fs.Parse(genuineCLI)584585	// Report which write flags the CLI actually set (named return stays zero —586	// nothing set — if Parse panics and the deferred recover fires).587	set = cliWriteFlags{588		output:      fs.Changed("output"),589		report:      fs.Changed("report"),590		formatMulti: fs.Changed("format-multi"),591	}592	return set593}

Code quality findings 1

May hide panics instead of handling errors properly; use only with specific panic recovery logic
warning correctness recover-without-defer
defer func() { _ = recover() }()

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.

Generated by searchcode.com · powered by code_get_file + code_get_findings