config.go GO 590 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/v3/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.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")348	flags.BoolVar(boolVar(&processor.Debug), "debug", false, "enable debug output")349	// Registered with an empty default; the real default is merged back post-parse.350	flags.StringSliceVar(sliceVar(&processor.PathDenyList), "exclude-dir", []string{}, "directories to exclude")351	flags.IntVar(intVar(&processor.GcFileCount), "file-gc-count", 10000, "number of files to parse before turning the GC on")352	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")353	flags.IntVar(intVar(&processor.FileProcessJobWorkers), "file-process-job-workers", runtime.NumCPU(), "number of goroutine workers that process files collecting stats")354	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")355	flags.IntVar(intVar(&processor.DirectoryWalkerJobWorkers), "directory-walker-job-workers", 8, "controls the maximum number of workers which will walk the directory tree")356	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]")357358	// Write flag: bound via b so config can never reach the real var.359	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")360361	// NoOptDefVal makes a bare `--report` work (no `=value`). runReport compares362	// ReportOut to processor.DefaultReportName to tell "bare flag" apart from an363	// explicit path, so the two must stay in sync.364	flags.Lookup("report").NoOptDefVal = processor.DefaultReportName365	flags.StringVar(strVar(&processor.ReportSkip), "report-skip", "", "comma-separated sections to omit (cocomo,locomo,hotspots,coupling,authors,timeline,files,uloc,linelength,card)")366	flags.StringVar(strVar(&processor.ReportTitle), "report-title", "", "override the repo name shown in the report banner")367	flags.StringSliceVarP(sliceVar(&processor.AllowListExtensions), "include-ext", "i", []string{}, "limit to file extensions [comma separated list: e.g. go,java,js]")368	flags.StringSliceVarP(sliceVar(&processor.ExcludeListExtensions), "exclude-ext", "x", []string{}, "ignore file extensions (overrides include-ext) [comma separated list: e.g. go,java,js]")369370	// Registered with an empty default; the real default is merged back post-parse.371	flags.StringSliceVarP(sliceVar(&processor.ExcludeFilename), "exclude-file", "n", []string{}, "ignore files with matching names")372	flags.BoolVarP(boolVar(&processor.Languages), "languages", "l", false, "print supported languages and extensions")373	flags.Int64Var(int64Var(&processor.AverageWage), "avg-wage", 56286, "average wage value used for basic COCOMO calculation")374	flags.Float64Var(floatVar(&processor.Overhead), "overhead", 2.4, "set the overhead multiplier for corporate overhead (facilities, equipment, accounting, etc.)")375	flags.Float64Var(floatVar(&processor.EAF), "eaf", 1.0, "the effort adjustment factor derived from the cost drivers (1.0 if rated nominal)")376	flags.BoolVar(boolVar(&processor.SLOCCountFormat), "sloccount-format", false, "print a more SLOCCount like COCOMO calculation")377	flags.BoolVar(boolVar(&processor.Cocomo), "no-cocomo", false, "remove COCOMO calculation output")378	flags.StringVar(strVar(&processor.CocomoProjectType), "cocomo-project-type", "organic", "change COCOMO model type [organic, semi-detached, embedded, \"custom,1,1,1,1\"]")379	flags.BoolVar(boolVar(&processor.Size), "no-size", false, "remove size calculation output")380	flags.BoolVar(boolVar(&processor.HBorder), "no-hborder", false, "remove horizontal borders between sections")381	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]")382	flags.BoolVarP(boolVar(&processor.Complexity), "no-complexity", "c", false, "skip calculation of code complexity")383	flags.BoolVarP(boolVar(&processor.Duplicates), "no-duplicates", "d", false, "remove duplicate files from stats and output")384	flags.BoolFuncP("min-gen", "z", "identify minified or generated files", boolFunc(func(s string) error { // using func so that last flag wins385		v, _ := strconv.ParseBool(s)386		processor.MinifiedGenerated = v387		if v {388			processor.IgnoreMinifiedGenerate = false389		}390		return nil391	}))392	flags.BoolFunc("min", "identify minified files", boolFunc(func(s string) error { // using func so that last flag wins393		v, _ := strconv.ParseBool(s)394		processor.Minified = v395		if v {396			processor.IgnoreMinified = false397		}398		return nil399	}))400	flags.BoolFunc("gen", "identify generated files", boolFunc(func(s string) error { // using func so that last flag wins401		v, _ := strconv.ParseBool(s)402		processor.Generated = v403		if v {404			processor.IgnoreGenerated = false405		}406		return nil407	}))408	// Registered with an empty default; the real default is merged back post-parse.409	flags.StringSliceVar(sliceVar(&processor.GeneratedMarkers), "generated-markers", []string{}, "string markers in head of generated files")410	flags.BoolFunc("no-min-gen", "ignore minified or generated files in output (implies --min-gen)", boolFunc(func(s string) error {411		v, _ := strconv.ParseBool(s)412		processor.IgnoreMinifiedGenerate = v413		return nil414	}))415	flags.BoolFunc("no-min", "ignore minified files in output (implies --min)", boolFunc(func(s string) error {416		v, _ := strconv.ParseBool(s)417		processor.IgnoreMinified = v418		return nil419	}))420	flags.BoolFunc("no-gen", "ignore generated files in output (implies --gen)", boolFunc(func(s string) error {421		v, _ := strconv.ParseBool(s)422		processor.IgnoreGenerated = v423		return nil424	}))425	flags.IntVar(intVar(&processor.MinifiedGeneratedLineByteLength), "min-gen-line-length", 255, "number of bytes per average line for file to be considered minified or generated")426	flags.StringArrayVarP(sliceVar(&processor.Exclude), "not-match", "M", []string{}, "ignore files and directories matching regular expression")427	// Write flag: bound via b so config can never reach the real var.428	flags.StringVarP(b.output, "output", "o", "", "output filename (default stdout)")429	flags.StringVarP(strVar(&processor.SortBy), "sort", "s", "files", "column to sort by [files, name, lines, blanks, code, comments, complexity]")430	flags.BoolVarP(boolVar(&processor.Trace), "trace", "t", false, "enable trace output (not recommended when processing multiple files)")431	flags.BoolVarP(boolVar(&processor.Verbose), "verbose", "v", false, "verbose output")432	flags.BoolVarP(boolVar(&processor.More), "wide", "w", false, "wider output with additional statistics (implies --complexity)")433	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")434	flags.BoolVar(boolVar(&processor.IncludeSymLinks), "include-symlinks", false, "if set will count symlink files")435	flags.Int64Var(int64Var(&processor.LargeLineCount), "large-line-count", 40000, "number of lines a file can contain before being removed from output")436	flags.Int64Var(int64Var(&processor.LargeByteCount), "large-byte-count", 1000000, "number of bytes a file can contain before being removed from output")437	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]")438	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 "+439		"[repeatable; pattern is glob by default, prefix with re: for regex; "+440		"e.g. *_spec.rb:\"Ruby Spec\":Ruby or re:\\.test\\.js$:\"JavaScript Tests\":JavaScript]")441	// Write flag: bound via b so config can never reach the real var.442	flags.StringVar(b.formatMulti, "format-multi", "", "have multiple format output overriding --format [e.g. tabular:stdout,csv:file.csv,json:file.json]")443	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")444	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\"]")445	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\"]")446	flags.StringVar(strVar(&processor.CurrencySymbol), "currency-symbol", "$", "set currency symbol")447	flags.BoolVar(boolVar(&processor.Locomo), "locomo", false, "enable LOCOMO (LLM Output COst MOdel) cost estimation")448	flags.BoolVar(boolVar(&processor.CostComparison), "cost-comparison", false, "show both COCOMO and LOCOMO estimates side by side")449	flags.StringVar(strVar(&processor.LocomoPresetName), "locomo-preset", "medium", "LOCOMO model preset [large, medium, small, local]")450	flags.Float64Var(floatVar(&processor.LocomoReviewMinutesPerLine), "locomo-review", 0.01, "human review minutes per line of code for LOCOMO estimate")451	flags.StringVar(strVar(&processor.LocomoConfig), "locomo-config", "", "LOCOMO power-user config \"tokensPerLine,inputPerLine,complexityWeight,iterations,iterationWeight\"")452	flags.Float64Var(floatVar(&processor.LocomoInputPrice), "locomo-input-price", 0, "LOCOMO cost per 1M input tokens in dollars (overrides preset)")453	flags.Float64Var(floatVar(&processor.LocomoOutputPrice), "locomo-output-price", 0, "LOCOMO cost per 1M output tokens in dollars (overrides preset)")454	flags.Float64Var(floatVar(&processor.LocomoTPS), "locomo-tps", 0, "LOCOMO output tokens per second (overrides preset)")455	flags.Float64Var(floatVar(&processor.LocomoCyclesOverride), "locomo-cycles", 0, "override estimated LLM iteration cycles (default: calculated from complexity)")456	flags.BoolVar(boolVar(&processor.Hotspots), "hotspots", false, "render the hotspots report (files ranked by complexity × change frequency over recent git history)")457	flags.BoolVar(boolVar(&processor.Coupling), "coupling", false, "render the change-coupling report (file pairs that change together over recent git history)")458	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")459	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)")460	flags.BoolVar(boolVar(&processor.ByAuthor), "by-author", false, "render the author rollup report (bus factor and last-toucher attribution over recent git history)")461	flags.IntVar(intVar(&processor.HistoryDepth), "depth", 1000, "commit window size for git history reports; 0 means entire history (large repos may be slow)")462	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")463	flags.IntVar(intVar(&processor.HistoryBuckets), "buckets", 60, "time-bucket resolution for the git timeline reports (default 60)")464	// --no-fold-authors is read back via cmd.PersistentFlags().GetBool in Run, so465	// its bound var is irrelevant; a throwaway sink is enough in both modes.466	flags.BoolVar(new(bool), "no-fold-authors", false, "disable the name+email-domain identity folding fallback for git author reports (mailmap still applied)")467	// --mcp is intercepted before cobra runs, but we register it here so it468	// appears in --help and the CLI-only parse accepts it dummy-bound.469	flags.BoolVar(new(bool), "mcp", false, "start as an MCP (Model Context Protocol) server over stdio")470471	// Restore the displayed (default ...) text for the three slice flags whose472	// runtime default was emptied above; DefValue is presentation only, the bound473	// slice stays empty so the replace-on-first-Set footgun is still defused.474	flags.Lookup("exclude-dir").DefValue = "[" + strings.Join(defaultExcludeDirs, ",") + "]"475	flags.Lookup("exclude-file").DefValue = "[" + strings.Join(defaultExcludeFiles, ",") + "]"476	flags.Lookup("generated-markers").DefValue = "[" + strings.Join(defaultGeneratedMarkers, ",") + "]"477}478479// registerConfigControlFlags registers --config, --no-config and480// --find-root-config as dummy-bound flags. They are pre-scanned from os.Args, so481// the bound values are never read; registering them keeps --help complete and482// stops cobra (and the CLI-only parse) treating them as unknown flags. Note: no483// -r shorthand - that short flag is reserved for a future find-root that484// relocates the scan directory, matching cs.485func registerConfigControlFlags(flags *pflag.FlagSet) {486	flags.String("config", "", "load this file as the global config source; overrides SCC_CONFIG_PATH, honored even with --no-config")487	flags.Bool("no-config", false, "disable auto-discovery of the SCC_CONFIG_PATH global and the project ./.sccconfig config")488	flags.Bool("find-root-config", false, "discover the project .sccconfig by walking up to the repository root instead of using ./.sccconfig")489}490491// mergeSliceDefault implements the slice-default preservation. If set is492// empty (nobody supplied the flag) it returns the built-in defaults; otherwise493// it returns defaults ∪ set, de-duplicated and order-stable (defaults first,494// then the supplied values), so a config/CLI value is additive to the built-in495// safety net rather than replacing it.496func mergeSliceDefault(set, defaults []string) []string {497	if len(set) == 0 {498		return append([]string{}, defaults...)499	}500501	var result []string502	seen := map[string]struct{}{}503504	for _, v := range defaults {505		if _, ok := seen[v]; !ok {506			seen[v] = struct{}{}507			result = append(result, v)508		}509	}510	for _, v := range set {511		if _, ok := seen[v]; !ok {512			seen[v] = struct{}{}513			result = append(result, v)514		}515	}516	return result517}518519// applySliceDefaults merges the built-in defaults back into the three slice520// flags after parsing. Called from rootCmd.Run on every path because the521// flags are always registered with an empty runtime default.522func applySliceDefaults() {523	processor.PathDenyList = mergeSliceDefault(processor.PathDenyList, defaultExcludeDirs)524	processor.ExcludeFilename = mergeSliceDefault(processor.ExcludeFilename, defaultExcludeFiles)525	processor.GeneratedMarkers = mergeSliceDefault(processor.GeneratedMarkers, defaultGeneratedMarkers)526}527528// cliWriteFlags records which write flags the genuine CLI explicitly set, taken529// from the CLI-only parse's Changed() bits. Keying off this rather than the530// resolved value lets warnIfConfigWrote tell "CLI set it" from "CLI left it531// empty" — an explicit --output= / --report= would look unset by value alone.532type cliWriteFlags struct {533	output      bool534	report      bool535	formatMulti bool536}537538// warnIfConfigWrote emits an ungated-stderr notice when a write flag was set in539// the merged parse (i.e. by config, since its discard binding was hit) but the540// genuine CLI did not set it — telling the user their config line was ignored.541// mergedFlags is the rootCmd flag set whose write flags were bound to discards;542// cliSet is whether each write flag was set on the genuine command line.543func warnIfConfigWrote(mergedFlags *pflag.FlagSet, cliSet cliWriteFlags) {544	check := func(name string, setOnCLI bool) {545		if mergedFlags.Changed(name) && !setOnCLI {546			_, _ = fmt.Fprintf(os.Stderr, "warning: ignoring --%s from config; file output can only be set on the command line\n", name)547		}548	}549	check("output", cliSet.output)550	check("report", cliSet.report)551	check("format-multi", cliSet.formatMulti)552}553554// resolveWriteFlags is the sole writer of the real processor.FileOutput,555// processor.ReportOut and processor.FormatMulti vars when config was discovered.556// It parses the genuine-CLI slice (post-@file, pre-config-prepend) through a557// write-only flag set: the three write flags bind to the real vars, everything558// else is inert. This makes file output a CLI-only capability - config tokens559// are structurally incapable of reaching these vars.560func resolveWriteFlags(genuineCLI []string) (set cliWriteFlags) {561	defer func() { _ = recover() }()562563	fs := pflag.NewFlagSet("scc-cli-only", pflag.ContinueOnError)564	fs.SetOutput(io.Discard)565	fs.ParseErrorsAllowlist.UnknownFlags = true566567	registerFlags(fs, &flagBindings{568		output:      &processor.FileOutput,569		report:      &processor.ReportOut,570		formatMulti: &processor.FormatMulti,571		inert:       true,572	})573574	// The config-control flags are registered in main.go, not by registerFlags,575	// so a genuine CLI like `scc --config team.scc -o out.json` would otherwise576	// hit an unknown flag here and -o would silently stop writing.577	registerConfigControlFlags(fs)578579	_ = fs.Parse(genuineCLI)580581	// Report which write flags the CLI actually set (named return stays zero —582	// nothing set — if Parse panics and the deferred recover fires).583	set = cliWriteFlags{584		output:      fs.Changed("output"),585		report:      fs.Changed("report"),586		formatMulti: fs.Changed("format-multi"),587	}588	return set589}

Code quality findings 15

Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(os.Stderr, "warning: ignoring config line (not a flag): %s\n", line)
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(os.Stderr, "warning: ignoring end-of-flags marker '--' in config: %s\n", line)
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(os.Stderr, "warning: could not read config file %q: %s\n", projectPath, readErr)
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(os.Stderr, "warning: ignoring --%s from config; file output can only be set on the command line\n", name)
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
defer func() { _ = recover() }()
May hide panics instead of handling errors properly; use only with specific panic recovery logic
warning correctness recover-without-defer
defer func() { _ = recover() }()
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = fs.Parse(genuineCLI)
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
kept = append(kept, t)
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
tokens = append(tokens, sb.String())
Can cause issues on Windows consider filepath.Join instead
info correctness path-join-windows
projectPath = filepath.Join(gocodewalker.FindRepositoryRoot("."), ".sccconfig")
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
configTrace = append(configTrace, fmt.Sprintf("loaded %s (%d tokens)", label, len(tokens)))
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
configSources = append(configSources, configSource{label: label, tokens: tokens})
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
return append([]string{}, defaults...)
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
result = append(result, v)
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
result = append(result, v)

Get this view in your editor

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