Blank identifier discarding results; verify intentional ignoring of return values
_, _ = fmt.Fprintf(os.Stderr, "warning: ignoring config line (not a flag): %s\n", line)
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}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.