Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
for sub, file := range layout {
1// SPDX-License-Identifier: MIT23package main45import (6 "bytes"7 "context"8 "os"9 "os/exec"10 "path/filepath"11 "strings"12 "testing"13 "time"1415 "github.com/spf13/pflag"16)1718// The tests in this file guard against regressions to pre-existing behaviour19// introduced by the config-dotfile work (spec/01-config-dotfile). They focus on20// functionality that existed before the feature and could have been silently21// changed by the registerFlags refactor, the shared @file tokenizer and the22// config discovery/merge pipeline - cases the feature's own tests do not cover.23//24// They run with noGlobalConfig (defined in config_test.go) so a SCC_CONFIG_PATH25// in the developer's environment cannot pollute the results.2627// TestRegressionExcludeDirPreservesDefaults exercises the phase-02 slice-default28// change on the ordinary no-config CLI path (the ~99% case). pflag replaces a29// slice's default on the first Set, so before this work `--exclude-dir vendor`30// dropped the built-in .git/.hg/.svn and scc descended into them. The post-parse31// union must keep the defaults as a non-removable safety net. .svn is the canary:32// nothing else skips it, so if preservation breaks it reappears in the output.33func TestRegressionExcludeDirPreservesDefaults(t *testing.T) {34 dir := t.TempDir()35 layout := map[string]string{".svn": "x.go", "vendor": "y.go", "keep": "z.go"}36 for sub, file := range layout {37 if err := os.Mkdir(filepath.Join(dir, sub), 0755); err != nil {38 t.Fatal(err)39 }40 if err := os.WriteFile(filepath.Join(dir, sub, file), []byte("package x\n"), 0644); err != nil {41 t.Fatal(err)42 }43 }4445 out, err := runSCCDir(t, dir, noGlobalConfig, "-f", "csv", "--by-file", "--exclude-dir", "vendor")46 if err != nil {47 t.Fatal(err)48 }49 if !strings.Contains(out, "z.go") {50 t.Errorf("keep/z.go should be counted, output:\n%s", out)51 }52 if strings.Contains(out, "y.go") {53 t.Errorf("vendor/y.go should be excluded by --exclude-dir, output:\n%s", out)54 }55 if strings.Contains(out, "x.go") {56 t.Errorf(".svn/x.go reappeared: a CLI --exclude-dir must not replace the built-in defaults (union, not replace), output:\n%s", out)57 }58}5960// TestRegressionExcludeFilePreservesDefaults is the --exclude-file counterpart of61// the above: adding one ignored filename on the CLI must not drop the built-in62// lockfile defaults. package-lock.json is the canary - before the fix pflag's63// replace-on-first-Set would have let it through.64func TestRegressionExcludeFilePreservesDefaults(t *testing.T) {65 dir := t.TempDir()66 files := map[string]string{67 "main.go": "package main\n",68 "package-lock.json": "{\"a\":1}\n",69 "myignore.txt": "hello\n",70 }71 for name, body := range files {72 if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0644); err != nil {73 t.Fatal(err)74 }75 }7677 out, err := runSCCDir(t, dir, noGlobalConfig, "-f", "csv", "--by-file", "--exclude-file", "myignore.txt")78 if err != nil {79 t.Fatal(err)80 }81 if !strings.Contains(out, "main.go") {82 t.Errorf("main.go should be counted, output:\n%s", out)83 }84 if strings.Contains(out, "myignore.txt") {85 t.Errorf("myignore.txt should be excluded by --exclude-file, output:\n%s", out)86 }87 if strings.Contains(out, "package-lock.json") {88 t.Errorf("package-lock.json reappeared: a CLI --exclude-file must not replace the built-in lockfile defaults, output:\n%s", out)89 }90}9192// TestRegressionGeneratedMarkersPreservesDefaults is the --generated-markers93// counterpart of the exclude-dir/exclude-file default-preservation tests above -94// the third (and easiest-to-miss) StringSlice flag with a non-empty built-in95// default. Supplying a custom marker on the CLI must not replace the built-in96// "do not edit" / "<auto-generated />" markers (pflag's replace-on-first-Set),97// so a file carrying only a default marker must still be flagged generated.98// examples/generated/test.h ("DO NOT EDIT") and test.cs ("<auto-generated />")99// are the canaries: both should remain "(gen)" despite the custom marker.100func TestRegressionGeneratedMarkersPreservesDefaults(t *testing.T) {101 t.Parallel()102 out, err := runSCC("-z", "--generated-markers", "zzz-not-a-real-marker", "--no-scc-ignore", "examples/generated/")103 if err != nil {104 t.Fatal(err)105 }106 if strings.Count(out, "(gen)") < 2 {107 t.Errorf("a custom --generated-markers must not drop the built-in default markers; both example files should still be flagged (gen), output:\n%s", out)108 }109}110111// TestRegressionConfigWithPositionalDir guards the merged argument ordering: a112// positional path must still be scanned when a project .sccconfig is present. Config113// tokens are prepended and the genuine CLI (including the path) comes last, so114// cobra must still receive the path as a positional. The .sccconfig sets --by-file, so115// the per-file row for the scanned path proves both that config applied and that116// the positional argument survived the prepend.117func TestRegressionConfigWithPositionalDir(t *testing.T) {118 dir := t.TempDir()119 if err := os.Mkdir(filepath.Join(dir, "code"), 0755); err != nil {120 t.Fatal(err)121 }122 if err := os.WriteFile(filepath.Join(dir, "code", "a.go"), []byte("package a\n"), 0644); err != nil {123 t.Fatal(err)124 }125 if err := os.WriteFile(filepath.Join(dir, ".sccconfig"), []byte("--by-file\n"), 0644); err != nil {126 t.Fatal(err)127 }128129 out, err := runSCCDir(t, dir, noGlobalConfig, "-f", "csv", "code")130 if err != nil {131 t.Fatal(err)132 }133 // a.go only appears in --by-file output, so its presence proves both the134 // config (--by-file) applied and the positional 'code' dir was scanned.135 if !strings.Contains(out, "a.go") {136 t.Errorf("positional 'code' dir should be scanned with a project .sccconfig present, output:\n%s", out)137 }138}139140// TestRegressionAtFileCommentsAndMultiToken confirms the shared tokenizer's new141// capabilities reach the existing @file syntax: multiple tokens per line, plus142// whole-line and inline '#' comments. Under the old whole-line splitter "-f csv"143// was a single unknown token and "main.go # x" an unreadable path, so this both144// proves the improvement and pins the new @file contract.145func TestRegressionAtFileCommentsAndMultiToken(t *testing.T) {146 dir := t.TempDir()147 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {148 t.Fatal(err)149 }150 atFile := filepath.Join(dir, "flags.txt")151 content := "# count the project\n\n-f csv\n--by-file\nmain.go # the entrypoint\n"152 if err := os.WriteFile(atFile, []byte(content), 0644); err != nil {153 t.Fatal(err)154 }155156 // @file must be the sole argument; the sole-argument trigger is preserved.157 out, err := runSCCDir(t, dir, noGlobalConfig, "@"+atFile)158 if err != nil {159 t.Fatalf("@file errored: %v\n%s", err, out)160 }161 if strings.Contains(out, "could not be read") {162 t.Errorf("inline '#' comment was not stripped from the path line, output:\n%s", out)163 }164 if !strings.Contains(out, "main.go") {165 t.Errorf("@file multi-token (-f csv --by-file) + main.go path not honoured, output:\n%s", out)166 }167}168169// TestRegressionHelpShowsSliceDefaults guards the user-visible default display.170// The three slice flags are registered with an empty runtime default (to defuse171// pflag's replace-on-first-Set); their "(default ...)" text is restored via172// DefValue. Skipping that restoration would silently drop the signal of what scc173// excludes out of the box, so assert --help still advertises the defaults.174func TestRegressionHelpShowsSliceDefaults(t *testing.T) {175 out, err := runSCC("--help")176 if err != nil {177 t.Fatalf("--help should exit 0: %v\n%s", err, out)178 }179 wants := []string{180 "[.git,.hg,.svn]",181 "[package-lock.json,Cargo.lock,yarn.lock,pubspec.lock,Podfile.lock,pnpm-lock.yaml]",182 "[do not edit,<auto-generated />]",183 }184 for _, w := range wants {185 if !strings.Contains(out, w) {186 t.Errorf("--help should display slice default %q, output:\n%s", w, out)187 }188 }189}190191// TestRegressionCommentOnlyConfigAllowsWrite documents an edge of the security192// model: a .sccconfig that contributes zero tokens (only comments/blanks) must not193// engage the write-blocking config path. With nothing injected there is nothing194// to defend against, so the no-config fast path runs and a CLI -o still writes.195func TestRegressionCommentOnlyConfigAllowsWrite(t *testing.T) {196 dir := t.TempDir()197 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {198 t.Fatal(err)199 }200 if err := os.WriteFile(filepath.Join(dir, ".sccconfig"), []byte("# just a comment\n\n"), 0644); err != nil {201 t.Fatal(err)202 }203204 target := filepath.Join(dir, "out.csv")205 if _, err := runSCCDir(t, dir, noGlobalConfig, "-f", "csv", "-o", target); err != nil {206 t.Fatal(err)207 }208 if info, statErr := os.Stat(target); statErr != nil || info.Size() == 0 {209 t.Errorf("a comment-only .sccconfig should not block a genuine CLI -o write")210 }211}212213// TestRegressionShorthandsPreserved guards the phase-02 registerFlags refactor.214// ~60 flag registrations were lifted out of main() into registerFlags; the215// existing exhaustive test only checks long names, so a shorthand silently216// dropped or rebound to the wrong flag during the move would slip through except217// for the handful of shorthands the integration tests happen to exercise. This218// pins the complete shorthand -> long-name map. The config-control flags219// (registered separately by registerConfigControlFlags) carry no shorthand: -r220// is deliberately unbound, reserved for a future find-root that relocates the221// scan directory like cs.222func TestRegressionShorthandsPreserved(t *testing.T) {223 t.Parallel()224 fs := pflag.NewFlagSet("test", pflag.ContinueOnError)225 var out, report, multi string226 registerFlags(fs, &flagBindings{output: &out, report: &report, formatMulti: &multi, inert: true})227 registerConfigControlFlags(fs)228229 want := map[string]string{230 "m": "character",231 "p": "percent",232 "u": "uloc",233 "a": "dryness",234 "f": "format",235 "i": "include-ext",236 "x": "exclude-ext",237 "n": "exclude-file",238 "l": "languages",239 "c": "no-complexity",240 "d": "no-duplicates",241 "z": "min-gen",242 "M": "not-match",243 "o": "output",244 "s": "sort",245 "t": "trace",246 "v": "verbose",247 "w": "wide",248 }249 for short, longName := range want {250 f := fs.ShorthandLookup(short)251 if f == nil {252 t.Errorf("shorthand -%s is not registered (expected --%s)", short, longName)253 continue254 }255 if f.Name != longName {256 t.Errorf("shorthand -%s bound to --%s, want --%s", short, f.Name, longName)257 }258 }259260 // Inverse guard: no shorthand exists that the map above does not account for,261 // so a stray/renamed shorthand introduced by the refactor is also caught.262 fs.VisitAll(func(f *pflag.Flag) {263 if f.Shorthand == "" {264 return265 }266 if _, ok := want[f.Shorthand]; !ok {267 t.Errorf("unexpected shorthand -%s on --%s (not in the locked map)", f.Shorthand, f.Name)268 }269 })270}271272// TestRegressionScalarDefaultsPreserved locks a representative sample of scalar273// flag defaults through the registerFlags refactor (the slice defaults are274// covered by TestRegisterFlagsSliceDefValue). It also pins the three write275// flags' empty defaults and, crucially, the --report NoOptDefVal: runReport276// compares ReportOut to that sentinel to tell a bare --report from --report=path,277// so a refactor that dropped it would silently break the bare-flag form.278func TestRegressionScalarDefaultsPreserved(t *testing.T) {279 t.Parallel()280 fs := pflag.NewFlagSet("test", pflag.ContinueOnError)281 var out, report, multi string282 registerFlags(fs, &flagBindings{output: &out, report: &report, formatMulti: &multi, inert: true})283284 wantDef := map[string]string{285 "format": "tabular",286 "sort": "files",287 "size-unit": "si",288 "currency-symbol": "$",289 "cocomo-project-type": "organic",290 "avg-wage": "56286",291 "large-line-count": "40000",292 "large-byte-count": "1000000",293 "output": "",294 "report": "",295 "format-multi": "",296 }297 for name, def := range wantDef {298 f := fs.Lookup(name)299 if f == nil {300 t.Errorf("--%s missing after refactor", name)301 continue302 }303 if f.DefValue != def {304 t.Errorf("--%s DefValue = %q, want %q", name, f.DefValue, def)305 }306 }307308 if f := fs.Lookup("report"); f == nil || f.NoOptDefVal != processorDefaultReportName() {309 got := "<nil flag>"310 if f != nil {311 got = f.NoOptDefVal312 }313 t.Errorf("--report NoOptDefVal = %q, want %q (bare --report relies on it)", got, processorDefaultReportName())314 }315}316317// processorDefaultReportName mirrors the sentinel used to wire --report's318// NoOptDefVal, kept as a tiny indirection so the test reads clearly.319func processorDefaultReportName() string {320 fs := pflag.NewFlagSet("probe", pflag.ContinueOnError)321 var out, report, multi string322 registerFlags(fs, &flagBindings{output: &out, report: &report, formatMulti: &multi, inert: true})323 return fs.Lookup("report").NoOptDefVal324}325326// TestRegressionGenuineReportWritesWithConfig fills the spec-04 checklist gap:327// "Genuine CLI ... --report ... -> do write" is only exercised for -o and328// --format-multi. With a project .sccconfig present the two-mode write split engages329// (write flags bound to discards in the merged parse, resolved from the genuine330// CLI alone), so this proves the --report arm of resolveWriteFlags still sources331// the real ReportOut from the command line. --report=path overwrites silently,332// avoiding the bare-flag interactive prompt.333func TestRegressionGenuineReportWritesWithConfig(t *testing.T) {334 dir := writeSccConfig(t, "--no-cocomo\n")335 target := filepath.Join(dir, "report.html")336 out, err := runSCCDir(t, dir, noGlobalConfig, "--report="+target)337 if err != nil {338 t.Fatalf("scc errored: %v\n%s", err, out)339 }340 if info, statErr := os.Stat(target); statErr != nil || info.Size() == 0 {341 t.Errorf("genuine CLI --report=path should write the report even with config present, output:\n%s", out)342 }343 // The genuine CLI set --report, so the "ignoring --report from config" notice344 // must NOT fire.345 if strings.Contains(out, "ignoring --report from config") {346 t.Errorf("genuine CLI --report was wrongly treated as config-sourced, output:\n%s", out)347 }348}349350// TestRegressionAtFileWritesWithProjectConfigPresent locks the spec-§6 @file ⨉351// config interaction. Running `scc @file` rewrites os.Args before discovery, so352// a ./.sccconfig in the directory is still discovered and layered beneath the @file353// tokens - and because the genuine-CLI slice is captured AFTER @file expansion,354// the @file is allowed to write even though config is present (config is not).355// Here the project .sccconfig supplies --format csv (proving config layered into the356// merged parse) while the @file supplies -o target (proving @file kept its write357// capability): a single CSV-content assertion on the written file proves both.358func TestRegressionAtFileWritesWithProjectConfigPresent(t *testing.T) {359 dir := writeSccConfig(t, "--format csv\n")360 target := filepath.Join(dir, "out.txt")361 atFile := filepath.Join(dir, "flags.txt")362 if err := os.WriteFile(atFile, []byte("-o "+target+"\n"), 0644); err != nil {363 t.Fatal(err)364 }365366 out, err := runSCCDir(t, dir, noGlobalConfig, "@"+atFile)367 if err != nil {368 t.Fatalf("scc errored: %v\n%s", err, out)369 }370 info, statErr := os.Stat(target)371 if statErr != nil || info.Size() == 0 {372 t.Fatalf("@file -o should write even with a project .sccconfig present, stdout:\n%s", out)373 }374 body, err := os.ReadFile(target)375 if err != nil {376 t.Fatal(err)377 }378 if !strings.Contains(string(body), "Language,Lines,Code") {379 t.Errorf("project .sccconfig --format csv should have layered into the @file run, file contents:\n%s", body)380 }381}382383// TestRegressionMcpSkipsConfigDiscovery guards the run-order guarantee that the384// --mcp short-circuit fires before any config discovery (spec §3.3 step 0 / the385// "--mcp does not load config" checklist item). If discovery ran first, an386// unreadable explicit SCC_CONFIG_PATH global would exit non-zero with a "could387// not read config" error before the server ever started. With the correct388// ordering --mcp wins, the server starts, reads the empty stdin, hits EOF and389// exits - never touching config. Empty stdin guarantees a prompt EOF so the390// process cannot hang; a context deadline is a belt-and-suspenders backstop.391func TestRegressionMcpSkipsConfigDiscovery(t *testing.T) {392 bin, err := filepath.Abs(sccBinPath)393 if err != nil {394 t.Fatal(err)395 }396 missing := filepath.Join(t.TempDir(), "does-not-exist.sccconfig")397398 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)399 defer cancel()400401 cmd := exec.CommandContext(ctx, bin, sccTestFlag, "--mcp")402 cmd.Env = append(os.Environ(), SccConfigEnv+"="+missing)403 cmd.Stdin = bytes.NewReader(nil) // immediate EOF -> server exits cleanly404 out, _ := cmd.CombinedOutput()405406 if ctx.Err() == context.DeadlineExceeded {407 t.Fatalf("scc --mcp did not exit on EOF, output:\n%s", out)408 }409 if strings.Contains(string(out), "could not read config") {410 t.Errorf("--mcp must short-circuit before config discovery; config was read, output:\n%s", out)411 }412}413414// TestRegressionVersionFlagWithConfig guards cobra's built-in meta flags through415// the new arg pipeline. main() no longer hands the genuine argv to cobra; it416// builds a merged list and passes it via rootCmd.SetArgs(merged[1:]). A bug in417// that wiring (e.g. an off-by-one slice, or config tokens shifting the version418// flag) could break --version even though it is not a real scc flag. The output419// must be byte-identical with and without a project .sccconfig present, and a .sccconfig420// must never leak a config error onto the version path.421func TestRegressionVersionFlagWithConfig(t *testing.T) {422 bare := t.TempDir()423 noCfg, err := runSCCDir(t, bare, noGlobalConfig, "--version")424 if err != nil {425 t.Fatalf("scc --version should exit 0 with no config: %v\n%s", err, noCfg)426 }427 if !strings.Contains(noCfg, "scc version") {428 t.Fatalf("--version output missing version banner, output:\n%s", noCfg)429 }430431 dir := writeSccConfig(t, "--format csv\n--no-cocomo\n")432 withCfg, err := runSCCDir(t, dir, noGlobalConfig, "--version")433 if err != nil {434 t.Fatalf("scc --version should exit 0 with a project .sccconfig present: %v\n%s", err, withCfg)435 }436 if withCfg != noCfg {437 t.Errorf("--version output changed when a project .sccconfig was present\nno config:\n%s\nwith config:\n%s", noCfg, withCfg)438 }439}440441// TestRegressionSccDirectoryDoesNotBreakRun guards the no-panics / robustness442// policy against a real-world surprise the feature introduces: a path named443// ".sccconfig" that is a *directory* (another tool's data dir, an accidental mkdir).444// Project discovery does os.Stat("./.sccconfig"), which succeeds for a directory, then445// os.ReadFile fails with "is a directory". That is the non-explicit project arm,446// so it must degrade to a stderr warning and let the run finish (exit 0 with447// real output) - never abort, panic, or swallow the scan.448func TestRegressionSccDirectoryDoesNotBreakRun(t *testing.T) {449 dir := t.TempDir()450 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {451 t.Fatal(err)452 }453 if err := os.Mkdir(filepath.Join(dir, ".sccconfig"), 0755); err != nil {454 t.Fatal(err)455 }456457 out, err := runSCCDir(t, dir, noGlobalConfig, "-f", "csv", "--by-file")458 if err != nil {459 t.Fatalf("a .sccconfig *directory* must not make scc exit non-zero: %v\n%s", err, out)460 }461 if !strings.Contains(out, "main.go") {462 t.Errorf("scc should still scan and emit output when ./.sccconfig is a directory, output:\n%s", out)463 }464}465466// TestRegressionConfigGlobalProjectPrecedence locks the middle rung of the467// precedence ladder (global < project) for a scalar flag. The existing suite468// proves CLI beats config and that each source loads, but not that the project469// .sccconfig overrides the SCC_CONFIG_PATH global when the two disagree - the ordering470// that falls out of prepending global tokens ahead of project tokens. Asserted471// in both directions so a swapped prepend order cannot pass.472func TestRegressionConfigGlobalProjectPrecedence(t *testing.T) {473 check := func(globalContent, projectContent string, wantCSV bool) {474 t.Helper()475 dir := t.TempDir()476 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {477 t.Fatal(err)478 }479 global := filepath.Join(dir, "global.sccconfig")480 if err := os.WriteFile(global, []byte(globalContent), 0644); err != nil {481 t.Fatal(err)482 }483 if err := os.WriteFile(filepath.Join(dir, ".sccconfig"), []byte(projectContent), 0644); err != nil {484 t.Fatal(err)485 }486 out, err := runSCCDir(t, dir, []string{SccConfigEnv + "=" + global})487 if err != nil {488 t.Fatal(err)489 }490 isCSV := strings.Contains(out, "Language,Lines,Code")491 if isCSV != wantCSV {492 t.Errorf("global=%q project=%q: wantCSV=%v gotCSV=%v, output:\n%s", globalContent, projectContent, wantCSV, isCSV, out)493 }494 }495 // project (json) overrides global (csv) -> not CSV496 check("--format csv\n", "--format json\n", false)497 // project (csv) overrides global (json) -> CSV498 check("--format json\n", "--format csv\n", true)499}500501// TestRegressionConfigGlobalProjectSliceUnion extends the §7 union semantics to502// the global+project pair. TestConfigSliceUnion covers project ∪ CLI ∪ defaults;503// this proves a slice flag set in the SCC_CONFIG_PATH global unions with the same504// flag set in the project .sccconfig (rather than one source replacing the other),505// which is the natural pflag append behaviour that the empty-default mechanism506// must preserve across both config sources.507func TestRegressionConfigGlobalProjectSliceUnion(t *testing.T) {508 dir := t.TempDir()509 for _, d := range []string{"ga", "pb", "keep"} {510 if err := os.Mkdir(filepath.Join(dir, d), 0755); err != nil {511 t.Fatal(err)512 }513 if err := os.WriteFile(filepath.Join(dir, d, "f.go"), []byte("package x\n"), 0644); err != nil {514 t.Fatal(err)515 }516 }517 global := filepath.Join(dir, "global.sccconfig")518 if err := os.WriteFile(global, []byte("--exclude-dir ga\n"), 0644); err != nil {519 t.Fatal(err)520 }521 if err := os.WriteFile(filepath.Join(dir, ".sccconfig"), []byte("--exclude-dir pb\n"), 0644); err != nil {522 t.Fatal(err)523 }524525 out, err := runSCCDir(t, dir, []string{SccConfigEnv + "=" + global}, "-f", "csv", "--by-file")526 if err != nil {527 t.Fatal(err)528 }529 if !strings.Contains(out, "keep/f.go") {530 t.Errorf("keep/f.go should be counted, output:\n%s", out)531 }532 // Both the global-excluded (ga) and project-excluded (pb) dirs must be gone:533 // a union, not one source clobbering the other. Key on the path column so the534 // shared basename (f.go) doesn't mask a leak.535 if strings.Contains(out, "ga/f.go") || strings.Contains(out, "pb/f.go") {536 t.Errorf("global+project --exclude-dir should union (ga and pb both excluded), output:\n%s", out)537 }538}539540// TestRegressionConfigFormatMultiIgnoredForStdout hardens the §5.3 guarantee541// that --format-multi is ignored *entirely* when it comes from config, not just542// blocked from writing files. TestConfigNeverWritesFile only proves no file543// appears; this proves config cannot even change the on-screen format via a544// stdout-only --format-multi. The merged parse binds --format-multi to a discard,545// so processor.FormatMulti stays empty and output keeps the default tabular form.546func TestRegressionConfigFormatMultiIgnoredForStdout(t *testing.T) {547 dir := writeSccConfig(t, "--format-multi json:stdout\n")548 out, err := runSCCDir(t, dir, noGlobalConfig)549 if err != nil {550 t.Fatalf("scc errored: %v\n%s", err, out)551 }552 // JSON would start with '[{'; tabular has the box-drawing header rule.553 if strings.Contains(out, "[{\"Name\"") {554 t.Errorf("config --format-multi must be ignored; output switched to JSON, output:\n%s", out)555 }556 if !strings.Contains(out, "Language") {557 t.Errorf("expected the default tabular output, got:\n%s", out)558 }559}560561// TestRegressionConfigMinFlagClassifies fills a behavioural gap the existing562// suite leaves open. TestConfigCoupledMinFlags only asserts the run does not563// error; nothing proves a coupled min/gen flag (-z/--min/--gen, registered as564// BoolFuncs whose closures mutate processor state during parse) actually reaches565// Process when it comes from config. These are the flags spec-04 repeatedly566// flags as fragile under the two-mode write split. With a project .sccconfig present567// the split engages: the merged parse fires the -z closure (real binding), then568// resolveWriteFlags re-parses the genuine CLI with the closures made inert so it569// cannot undo that. A long single line trips the minified heuristic, so the file570// must surface as "(min)" - proving config's coupled flag survived end to end.571func TestRegressionConfigMinFlagClassifies(t *testing.T) {572 dir := t.TempDir()573 // One ~415-byte line: avg bytes/line well over the 255 default, so -z flags574 // it minified.575 long := "var x = '" + strings.Repeat("a", 400) + "';\n"576 if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte(long), 0644); err != nil {577 t.Fatal(err)578 }579 // Everything needed is in the config; the genuine CLI is empty, so the580 // effect is purely config-sourced. -f csv keeps the "(min)" suffix off the581 // truncating tabular language column.582 if err := os.WriteFile(filepath.Join(dir, ".sccconfig"), []byte("-z\n-i js\n--no-scc-ignore\n-f csv\n"), 0644); err != nil {583 t.Fatal(err)584 }585586 out, err := runSCCDir(t, dir, noGlobalConfig)587 if err != nil {588 t.Fatalf("scc errored: %v\n%s", err, out)589 }590 if !strings.Contains(out, "(min)") {591 t.Errorf("config-supplied -z should flag the minified file as (min) through the two-mode pipeline, output:\n%s", out)592 }593}594595// TestRegressionConfigCannotStartMcpServer guards a security invariant that596// parallels the "config can never write a file" tests: a --mcp inside a project597// .sccconfig must NOT hijack stdio and start an MCP server. The interception reads598// os.Args ONLY (before config is discovered), so config's --mcp lands in the599// merged parse as the inert dummy flag registerFlags registers and the normal600// scan runs instead. This pins that ordering so a future refactor that moves the601// --mcp detection after the config merge - letting a checked-out .sccconfig silently602// turn a scan into a server - is caught. Empty stdin means even the bad case603// EOFs rather than hanging; the context deadline is a backstop.604func TestRegressionConfigCannotStartMcpServer(t *testing.T) {605 dir := t.TempDir()606 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {607 t.Fatal(err)608 }609 if err := os.WriteFile(filepath.Join(dir, ".sccconfig"), []byte("--mcp\n"), 0644); err != nil {610 t.Fatal(err)611 }612613 bin, err := filepath.Abs(sccBinPath)614 if err != nil {615 t.Fatal(err)616 }617 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)618 defer cancel()619620 cmd := exec.CommandContext(ctx, bin, sccTestFlag, "-f", "csv", "--by-file")621 cmd.Dir = dir622 cmd.Env = append(os.Environ(), noGlobalConfig...)623 cmd.Stdin = bytes.NewReader(nil) // a started server would EOF, not hang624 out, _ := cmd.CombinedOutput()625626 if ctx.Err() == context.DeadlineExceeded {627 t.Fatalf("config --mcp appears to have started a server (timed out), output:\n%s", out)628 }629 // The by-file scan ran: the source file appears in the CSV. An MCP server630 // would speak JSON-RPC and never emit this.631 if !strings.Contains(string(out), "main.go") {632 t.Errorf("config --mcp must be an inert dummy flag and let the scan run, output:\n%s", string(out))633 }634}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.