-
-
Notifications
You must be signed in to change notification settings - Fork 321
Expand file tree
/
Copy pathconfig.go
More file actions
586 lines (535 loc) · 27.5 KB
/
Copy pathconfig.go
File metadata and controls
586 lines (535 loc) · 27.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
// SPDX-License-Identifier: MIT
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/boyter/gocodewalker"
"github.com/boyter/scc/v3/processor"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// isCompletionInvocation reports whether args is a cobra completion request:
// the hidden __complete / __completeNoDesc commands the shell calls on TAB, or
// the user-facing `completion` command. These must reach cobra with the genuine
// argv, so config discovery is skipped for them.
func isCompletionInvocation(args []string) bool {
if len(args) < 2 {
return false
}
switch args[1] {
case cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd, "completion":
return true
default:
return false
}
}
// SccConfigEnv is the environment variable naming the global config source.
const SccConfigEnv = "SCC_CONFIG_PATH"
// Default slice values are kept as Go constants rather than as cobra flag
// defaults.
var (
defaultExcludeDirs = []string{".git", ".hg", ".svn"}
defaultExcludeFiles = []string{"package-lock.json", "Cargo.lock", "yarn.lock", "pubspec.lock", "Podfile.lock", "pnpm-lock.yaml"}
defaultGeneratedMarkers = []string{"do not edit", "<auto-generated />"}
)
// configSource records a config file that was loaded during discovery and the
// tokens it contributed.
type configSource struct {
label string
tokens []string
}
var configTrace []string
var configSources []configSource
// parseConfigArgs tokenizes the contents of a config-ish source (a config file
// or an @file) into a flat slice of CLI tokens. Format rules:
//
// - One or more whitespace-separated tokens per line; blank lines ignored.
// - '#' begins a comment; whole-line and inline trailing comments are stripped.
// - Tokenization is quote-aware: single or double quotes group a run literally
// up to the matching close quote. To include a literal quote, switch styles.
// - Backslash is an ordinary literal outside quotes (NOT an escape), so a
// Windows path like C:\build\out survives verbatim.
//
// When allowPositional is false (config sources) a line whose first token does
// not start with '-' is skipped with a warning to ungated stderr, preventing a
// config file from injecting count targets. When true (@file) positional lines
// are kept, preserving the existing @file behaviour.
func parseConfigArgs(content string, allowPositional bool) []string {
var args []string
for _, rawLine := range strings.Split(content, "\n") {
line := strings.TrimSpace(strings.TrimSuffix(rawLine, "\r"))
if line == "" {
continue
}
tokens := tokenizeConfigLine(line)
if len(tokens) == 0 {
continue
}
if !allowPositional {
if !strings.HasPrefix(tokens[0], "-") {
_, _ = fmt.Fprintf(os.Stderr, "warning: ignoring config line (not a flag): %s\n", line)
continue
}
// '--' is pflag's end-of-flags marker. Config tokens are prepended
// ahead of the genuine CLI, so a '--' from a config file would turn
// the user's real flags into positional args - disabling them and
// undermining the "config cannot inject count targets" guarantee.
// Config has no legitimate positionals, so strip the marker.
kept := tokens[:0]
for _, t := range tokens {
if t == "--" {
_, _ = fmt.Fprintf(os.Stderr, "warning: ignoring end-of-flags marker '--' in config: %s\n", line)
continue
}
kept = append(kept, t)
}
tokens = kept
if len(tokens) == 0 {
continue
}
}
args = append(args, tokens...)
}
return args
}
// tokenizeConfigLine splits a single (already trimmed, non-empty) line into
// quote-aware tokens, stripping an unquoted trailing '#' comment. Backslash is
// a literal outside quotes.
func tokenizeConfigLine(line string) []string {
var tokens []string
var sb strings.Builder
var quote rune // 0 when not inside quotes, otherwise the active quote char
inToken := false
flush := func() {
if inToken {
tokens = append(tokens, sb.String())
sb.Reset()
inToken = false
}
}
for _, r := range line {
switch {
case quote != 0:
if r == quote {
quote = 0
} else {
sb.WriteRune(r)
}
case r == '\'' || r == '"':
// Quotes are the sole grouping mechanism and always begin a token,
// so an empty quoted value (e.g. "") still produces a token.
quote = r
inToken = true
case r == '#':
// Unquoted '#' starts a comment for the rest of the line.
flush()
return tokens
case r == ' ' || r == '\t':
flush()
default:
sb.WriteRune(r)
inToken = true
}
}
flush()
return tokens
}
// preScanConfig scans os.Args for the three config-control flags before
// cobra runs. Reading os.Args alone is what makes --config/--no-config inside a
// config file inert.
func preScanConfig(args []string) (noConfig bool, findRoot bool, explicitPath string) {
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--no-config":
noConfig = true
case a == "--find-root-config":
findRoot = true
case a == "--config":
// Bounds-check: guard against --config being the final token (a
// user typo) before consuming args[i+1].
if i+1 < len(args) {
explicitPath = args[i+1]
}
case strings.HasPrefix(a, "--config="):
explicitPath = strings.TrimPrefix(a, "--config=")
}
}
return noConfig, findRoot, explicitPath
}
// discoverConfigArgs resolves the global and project config sources
func discoverConfigArgs(noConfig, findRoot bool, explicitPath string) (globalTokens, projectTokens []string, err error) {
// Global source: --config wins, else SCC_CONFIG_PATH (when non-empty and not
// disabled), else nothing. No default location, no home-dir stat.
globalPath := ""
switch {
case explicitPath != "":
globalPath = explicitPath
case !noConfig:
if env := os.Getenv(SccConfigEnv); env != "" {
globalPath = env
}
}
if globalPath != "" {
tokens, readErr := readConfigFile(globalPath, "global config "+globalPath)
if readErr != nil {
// Explicit source the user asked for: surface and exit non-zero.
return nil, nil, fmt.Errorf("could not read config file %q: %w", globalPath, readErr)
}
globalTokens = tokens
}
// Project source: ./.sccconfig by default unless --find-root-config set
if !noConfig {
projectPath := "./.sccconfig"
if findRoot {
projectPath = filepath.Join(gocodewalker.FindRepositoryRoot("."), ".sccconfig")
}
if _, statErr := os.Stat(projectPath); statErr == nil {
tokens, readErr := readConfigFile(projectPath, "project "+projectPath)
if readErr != nil {
_, _ = fmt.Fprintf(os.Stderr, "warning: could not read config file %q: %s\n", projectPath, readErr)
} else {
projectTokens = tokens
}
}
}
return globalTokens, projectTokens, nil
}
// readConfigFile reads and tokenizes a config source, recording it for trace
// output and unknown-flag attribution.
func readConfigFile(path, label string) (tokens []string, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic while reading config: %v", r)
}
}()
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
tokens = parseConfigArgs(string(b), false)
configTrace = append(configTrace, fmt.Sprintf("loaded %s (%d tokens)", label, len(tokens)))
configSources = append(configSources, configSource{label: label, tokens: tokens})
return tokens, nil
}
// flushConfigTrace emits the buffered discovery messages once Trace/Debug have been set.
func flushConfigTrace() {
for _, msg := range configTrace {
processor.PrintTrace(msg)
processor.PrintDebug(msg)
}
}
// attributeConfigFlag returns the label of the config source that contributed
// the named flag, or "" if it was not seen in any config source (came from the
// genuine CLI, or is ambiguous).
func attributeConfigFlag(name string) string {
want := "--" + name
for _, src := range configSources {
for _, tok := range src.tokens {
if tok == want || strings.HasPrefix(tok, want+"=") {
return src.label
}
}
}
return ""
}
// flagBindings abstracts the three write-flag sinks plus a mode switch so
// registerFlags can build either a real-binding flag set or an inert one.
type flagBindings struct {
output *string
report *string
formatMulti *string
inert bool
}
// registerFlags registers the full scc flag set on fs, binding write flags via
// b and every other flag either to the real processor.* vars (b.inert == false)
// or to throwaway sinks (b.inert == true). It does NOT register the three
// config-control flags (--config/--no-config/--find-root-config); those are
// handled by registerConfigControlFlags so they can be added to both the
// rootCmd and the CLI-only flag set.
func registerFlags(flags *pflag.FlagSet, b *flagBindings) {
// Binding-target selectors: return the real pointer in normal mode and a
// fresh throwaway in inert mode.
boolVar := func(real *bool) *bool {
if b.inert {
return new(bool)
}
return real
}
strVar := func(real *string) *string {
if b.inert {
return new(string)
}
return real
}
sliceVar := func(real *[]string) *[]string {
if b.inert {
return new([]string)
}
return real
}
intVar := func(real *int) *int {
if b.inert {
return new(int)
}
return real
}
int64Var := func(real *int64) *int64 {
if b.inert {
return new(int64)
}
return real
}
floatVar := func(real *float64) *float64 {
if b.inert {
return new(float64)
}
return real
}
boolFunc := func(real func(string) error) func(string) error {
if b.inert {
return func(string) error { return nil }
}
return real
}
flags.BoolVarP(boolVar(&processor.MaxMean), "character", "m", false, "calculate max and mean characters per line")
flags.BoolVarP(boolVar(&processor.Percent), "percent", "p", false, "include percentage values in output")
flags.BoolVarP(boolVar(&processor.UlocMode), "uloc", "u", false, "calculate the number of unique lines of code (ULOC) for the project")
flags.BoolVarP(boolVar(&processor.Dryness), "dryness", "a", false, "calculate the DRYness of the project (implies --uloc)")
flags.BoolVar(boolVar(&processor.Cognitive), "cognitive", false, "calculate cognitive (nesting-weighted) complexity")
flags.BoolVar(boolVar(&processor.DisableCheckBinary), "binary", false, "disable binary file detection")
flags.BoolVar(boolVar(&processor.Files), "by-file", false, "display output for every file")
flags.BoolVar(boolVar(&processor.Ci), "ci", false, "enable CI output settings where stdout is ASCII")
flags.BoolVar(boolVar(&processor.Ignore), "no-ignore", false, "disables .ignore file logic")
flags.BoolVar(boolVar(&processor.SccIgnore), "no-scc-ignore", false, "disables .sccignore file logic")
flags.BoolVar(boolVar(&processor.GitIgnore), "no-gitignore", false, "disables .gitignore file logic")
flags.BoolVar(boolVar(&processor.GitModuleIgnore), "no-gitmodule", false, "disables .gitmodules file logic")
flags.BoolVar(boolVar(&processor.CountIgnore), "count-ignore", false, "set to allow .gitignore and .ignore files to be counted")
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")
flags.BoolVar(boolVar(&processor.Debug), "debug", false, "enable debug output")
// Registered with an empty default; the real default is merged back post-parse.
flags.StringSliceVar(sliceVar(&processor.PathDenyList), "exclude-dir", []string{}, "directories to exclude")
flags.IntVar(intVar(&processor.GcFileCount), "file-gc-count", 10000, "number of files to parse before turning the GC on")
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")
flags.IntVar(intVar(&processor.FileProcessJobWorkers), "file-process-job-workers", runtime.NumCPU(), "number of goroutine workers that process files collecting stats")
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")
flags.IntVar(intVar(&processor.DirectoryWalkerJobWorkers), "directory-walker-job-workers", 8, "controls the maximum number of workers which will walk the directory tree")
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]")
// Write flag: bound via b so config can never reach the real var.
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")
// NoOptDefVal makes a bare `--report` work (no `=value`). runReport compares
// ReportOut to processor.DefaultReportName to tell "bare flag" apart from an
// explicit path, so the two must stay in sync.
flags.Lookup("report").NoOptDefVal = processor.DefaultReportName
flags.StringVar(strVar(&processor.ReportSkip), "report-skip", "", "comma-separated sections to omit (cocomo,locomo,hotspots,authors,timeline,files,uloc,linelength,card)")
flags.StringVar(strVar(&processor.ReportTitle), "report-title", "", "override the repo name shown in the report banner")
flags.StringSliceVarP(sliceVar(&processor.AllowListExtensions), "include-ext", "i", []string{}, "limit to file extensions [comma separated list: e.g. go,java,js]")
flags.StringSliceVarP(sliceVar(&processor.ExcludeListExtensions), "exclude-ext", "x", []string{}, "ignore file extensions (overrides include-ext) [comma separated list: e.g. go,java,js]")
// Registered with an empty default; the real default is merged back post-parse.
flags.StringSliceVarP(sliceVar(&processor.ExcludeFilename), "exclude-file", "n", []string{}, "ignore files with matching names")
flags.BoolVarP(boolVar(&processor.Languages), "languages", "l", false, "print supported languages and extensions")
flags.Int64Var(int64Var(&processor.AverageWage), "avg-wage", 56286, "average wage value used for basic COCOMO calculation")
flags.Float64Var(floatVar(&processor.Overhead), "overhead", 2.4, "set the overhead multiplier for corporate overhead (facilities, equipment, accounting, etc.)")
flags.Float64Var(floatVar(&processor.EAF), "eaf", 1.0, "the effort adjustment factor derived from the cost drivers (1.0 if rated nominal)")
flags.BoolVar(boolVar(&processor.SLOCCountFormat), "sloccount-format", false, "print a more SLOCCount like COCOMO calculation")
flags.BoolVar(boolVar(&processor.Cocomo), "no-cocomo", false, "remove COCOMO calculation output")
flags.StringVar(strVar(&processor.CocomoProjectType), "cocomo-project-type", "organic", "change COCOMO model type [organic, semi-detached, embedded, \"custom,1,1,1,1\"]")
flags.BoolVar(boolVar(&processor.Size), "no-size", false, "remove size calculation output")
flags.BoolVar(boolVar(&processor.HBorder), "no-hborder", false, "remove horizontal borders between sections")
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]")
flags.BoolVarP(boolVar(&processor.Complexity), "no-complexity", "c", false, "skip calculation of code complexity")
flags.BoolVarP(boolVar(&processor.Duplicates), "no-duplicates", "d", false, "remove duplicate files from stats and output")
flags.BoolFuncP("min-gen", "z", "identify minified or generated files", boolFunc(func(s string) error { // using func so that last flag wins
v, _ := strconv.ParseBool(s)
processor.MinifiedGenerated = v
if v {
processor.IgnoreMinifiedGenerate = false
}
return nil
}))
flags.BoolFunc("min", "identify minified files", boolFunc(func(s string) error { // using func so that last flag wins
v, _ := strconv.ParseBool(s)
processor.Minified = v
if v {
processor.IgnoreMinified = false
}
return nil
}))
flags.BoolFunc("gen", "identify generated files", boolFunc(func(s string) error { // using func so that last flag wins
v, _ := strconv.ParseBool(s)
processor.Generated = v
if v {
processor.IgnoreGenerated = false
}
return nil
}))
// Registered with an empty default; the real default is merged back post-parse.
flags.StringSliceVar(sliceVar(&processor.GeneratedMarkers), "generated-markers", []string{}, "string markers in head of generated files")
flags.BoolFunc("no-min-gen", "ignore minified or generated files in output (implies --min-gen)", boolFunc(func(s string) error {
v, _ := strconv.ParseBool(s)
processor.IgnoreMinifiedGenerate = v
return nil
}))
flags.BoolFunc("no-min", "ignore minified files in output (implies --min)", boolFunc(func(s string) error {
v, _ := strconv.ParseBool(s)
processor.IgnoreMinified = v
return nil
}))
flags.BoolFunc("no-gen", "ignore generated files in output (implies --gen)", boolFunc(func(s string) error {
v, _ := strconv.ParseBool(s)
processor.IgnoreGenerated = v
return nil
}))
flags.IntVar(intVar(&processor.MinifiedGeneratedLineByteLength), "min-gen-line-length", 255, "number of bytes per average line for file to be considered minified or generated")
flags.StringArrayVarP(sliceVar(&processor.Exclude), "not-match", "M", []string{}, "ignore files and directories matching regular expression")
// Write flag: bound via b so config can never reach the real var.
flags.StringVarP(b.output, "output", "o", "", "output filename (default stdout)")
flags.StringVarP(strVar(&processor.SortBy), "sort", "s", "files", "column to sort by [files, name, lines, blanks, code, comments, complexity]")
flags.BoolVarP(boolVar(&processor.Trace), "trace", "t", false, "enable trace output (not recommended when processing multiple files)")
flags.BoolVarP(boolVar(&processor.Verbose), "verbose", "v", false, "verbose output")
flags.BoolVarP(boolVar(&processor.More), "wide", "w", false, "wider output with additional statistics (implies --complexity)")
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")
flags.BoolVar(boolVar(&processor.IncludeSymLinks), "include-symlinks", false, "if set will count symlink files")
flags.Int64Var(int64Var(&processor.LargeLineCount), "large-line-count", 40000, "number of lines a file can contain before being removed from output")
flags.Int64Var(int64Var(&processor.LargeByteCount), "large-byte-count", 1000000, "number of bytes a file can contain before being removed from output")
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]")
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 "+
"[repeatable; pattern is glob by default, prefix with re: for regex; "+
"e.g. *_spec.rb:\"Ruby Spec\":Ruby or re:\\.test\\.js$:\"JavaScript Tests\":JavaScript]")
// Write flag: bound via b so config can never reach the real var.
flags.StringVar(b.formatMulti, "format-multi", "", "have multiple format output overriding --format [e.g. tabular:stdout,csv:file.csv,json:file.json]")
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")
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\"]")
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\"]")
flags.StringVar(strVar(&processor.CurrencySymbol), "currency-symbol", "$", "set currency symbol")
flags.BoolVar(boolVar(&processor.Locomo), "locomo", false, "enable LOCOMO (LLM Output COst MOdel) cost estimation")
flags.BoolVar(boolVar(&processor.CostComparison), "cost-comparison", false, "show both COCOMO and LOCOMO estimates side by side")
flags.StringVar(strVar(&processor.LocomoPresetName), "locomo-preset", "medium", "LOCOMO model preset [large, medium, small, local]")
flags.Float64Var(floatVar(&processor.LocomoReviewMinutesPerLine), "locomo-review", 0.01, "human review minutes per line of code for LOCOMO estimate")
flags.StringVar(strVar(&processor.LocomoConfig), "locomo-config", "", "LOCOMO power-user config \"tokensPerLine,inputPerLine,complexityWeight,iterations,iterationWeight\"")
flags.Float64Var(floatVar(&processor.LocomoInputPrice), "locomo-input-price", 0, "LOCOMO cost per 1M input tokens in dollars (overrides preset)")
flags.Float64Var(floatVar(&processor.LocomoOutputPrice), "locomo-output-price", 0, "LOCOMO cost per 1M output tokens in dollars (overrides preset)")
flags.Float64Var(floatVar(&processor.LocomoTPS), "locomo-tps", 0, "LOCOMO output tokens per second (overrides preset)")
flags.Float64Var(floatVar(&processor.LocomoCyclesOverride), "locomo-cycles", 0, "override estimated LLM iteration cycles (default: calculated from complexity)")
flags.BoolVar(boolVar(&processor.Hotspots), "hotspots", false, "render the hotspots report (files ranked by complexity × change frequency over recent git history)")
flags.BoolVar(boolVar(&processor.ByAuthor), "by-author", false, "render the author rollup report (bus factor and last-toucher attribution over recent git history)")
flags.IntVar(intVar(&processor.HistoryDepth), "depth", 1000, "commit window size for git history reports; 0 means entire history (large repos may be slow)")
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")
flags.IntVar(intVar(&processor.HistoryBuckets), "buckets", 60, "time-bucket resolution for the git timeline reports (default 60)")
// --no-fold-authors is read back via cmd.PersistentFlags().GetBool in Run, so
// its bound var is irrelevant; a throwaway sink is enough in both modes.
flags.BoolVar(new(bool), "no-fold-authors", false, "disable the name+email-domain identity folding fallback for git author reports (mailmap still applied)")
// --mcp is intercepted before cobra runs, but we register it here so it
// appears in --help and the CLI-only parse accepts it dummy-bound.
flags.BoolVar(new(bool), "mcp", false, "start as an MCP (Model Context Protocol) server over stdio")
// Restore the displayed (default ...) text for the three slice flags whose
// runtime default was emptied above; DefValue is presentation only, the bound
// slice stays empty so the replace-on-first-Set footgun is still defused.
flags.Lookup("exclude-dir").DefValue = "[" + strings.Join(defaultExcludeDirs, ",") + "]"
flags.Lookup("exclude-file").DefValue = "[" + strings.Join(defaultExcludeFiles, ",") + "]"
flags.Lookup("generated-markers").DefValue = "[" + strings.Join(defaultGeneratedMarkers, ",") + "]"
}
// registerConfigControlFlags registers --config, --no-config and
// --find-root-config as dummy-bound flags. They are pre-scanned from os.Args, so
// the bound values are never read; registering them keeps --help complete and
// stops cobra (and the CLI-only parse) treating them as unknown flags. Note: no
// -r shorthand - that short flag is reserved for a future find-root that
// relocates the scan directory, matching cs.
func registerConfigControlFlags(flags *pflag.FlagSet) {
flags.String("config", "", "load this file as the global config source; overrides SCC_CONFIG_PATH, honored even with --no-config")
flags.Bool("no-config", false, "disable auto-discovery of the SCC_CONFIG_PATH global and the project ./.sccconfig config")
flags.Bool("find-root-config", false, "discover the project .sccconfig by walking up to the repository root instead of using ./.sccconfig")
}
// mergeSliceDefault implements the slice-default preservation. If set is
// empty (nobody supplied the flag) it returns the built-in defaults; otherwise
// it returns defaults ∪ set, de-duplicated and order-stable (defaults first,
// then the supplied values), so a config/CLI value is additive to the built-in
// safety net rather than replacing it.
func mergeSliceDefault(set, defaults []string) []string {
if len(set) == 0 {
return append([]string{}, defaults...)
}
var result []string
seen := map[string]struct{}{}
for _, v := range defaults {
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
result = append(result, v)
}
}
for _, v := range set {
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
result = append(result, v)
}
}
return result
}
// applySliceDefaults merges the built-in defaults back into the three slice
// flags after parsing. Called from rootCmd.Run on every path because the
// flags are always registered with an empty runtime default.
func applySliceDefaults() {
processor.PathDenyList = mergeSliceDefault(processor.PathDenyList, defaultExcludeDirs)
processor.ExcludeFilename = mergeSliceDefault(processor.ExcludeFilename, defaultExcludeFiles)
processor.GeneratedMarkers = mergeSliceDefault(processor.GeneratedMarkers, defaultGeneratedMarkers)
}
// cliWriteFlags records which write flags the genuine CLI explicitly set, taken
// from the CLI-only parse's Changed() bits. Keying off this rather than the
// resolved value lets warnIfConfigWrote tell "CLI set it" from "CLI left it
// empty" — an explicit --output= / --report= would look unset by value alone.
type cliWriteFlags struct {
output bool
report bool
formatMulti bool
}
// warnIfConfigWrote emits an ungated-stderr notice when a write flag was set in
// the merged parse (i.e. by config, since its discard binding was hit) but the
// genuine CLI did not set it — telling the user their config line was ignored.
// mergedFlags is the rootCmd flag set whose write flags were bound to discards;
// cliSet is whether each write flag was set on the genuine command line.
func warnIfConfigWrote(mergedFlags *pflag.FlagSet, cliSet cliWriteFlags) {
check := func(name string, setOnCLI bool) {
if mergedFlags.Changed(name) && !setOnCLI {
_, _ = fmt.Fprintf(os.Stderr, "warning: ignoring --%s from config; file output can only be set on the command line\n", name)
}
}
check("output", cliSet.output)
check("report", cliSet.report)
check("format-multi", cliSet.formatMulti)
}
// resolveWriteFlags is the sole writer of the real processor.FileOutput,
// processor.ReportOut and processor.FormatMulti vars when config was discovered.
// It parses the genuine-CLI slice (post-@file, pre-config-prepend) through a
// write-only flag set: the three write flags bind to the real vars, everything
// else is inert. This makes file output a CLI-only capability - config tokens
// are structurally incapable of reaching these vars.
func resolveWriteFlags(genuineCLI []string) (set cliWriteFlags) {
defer func() { _ = recover() }()
fs := pflag.NewFlagSet("scc-cli-only", pflag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.ParseErrorsAllowlist.UnknownFlags = true
registerFlags(fs, &flagBindings{
output: &processor.FileOutput,
report: &processor.ReportOut,
formatMulti: &processor.FormatMulti,
inert: true,
})
// The config-control flags are registered in main.go, not by registerFlags,
// so a genuine CLI like `scc --config team.scc -o out.json` would otherwise
// hit an unknown flag here and -o would silently stop writing.
registerConfigControlFlags(fs)
_ = fs.Parse(genuineCLI)
// Report which write flags the CLI actually set (named return stays zero —
// nothing set — if Parse panics and the deferred recover fires).
set = cliWriteFlags{
output: fs.Changed("output"),
report: fs.Changed("report"),
formatMulti: fs.Changed("format-multi"),
}
return set
}