codesnake 1.2.1__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codesnake
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: Semantic code checker for Python 3
5
5
  Author: CodeSnake Contributors
6
6
  License-Expression: MIT
@@ -30,6 +30,11 @@ Provides-Extra: dev
30
30
  Requires-Dist: codesnake[tools]; extra == "dev"
31
31
  Dynamic: license-file
32
32
 
33
+ <p align="center">
34
+ <img src="https://raw.githubusercontent.com/bitWarrior/codesnake/main/images/codesnake-logo.jpg"
35
+ alt="CodeSnake — semantic analysis for Python" width="640">
36
+ </p>
37
+
33
38
  # CodeSnake
34
39
 
35
40
  [![CI](https://github.com/bitWarrior/codesnake/actions/workflows/ci.yml/badge.svg)](https://github.com/bitWarrior/codesnake/actions/workflows/ci.yml)
@@ -97,10 +102,13 @@ adoption path is in [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md#adopting-codesna
97
102
  ## Usage
98
103
 
99
104
  ```bash
100
- # Files or directories (walks *.py, skips venvs, caches, and .gitignore)
105
+ # Files or directories (walks *.py; skips venvs, caches, and untracked .gitignore matches)
101
106
  codesnake check src/codesnake/checker.py test/example_bad_code.py
102
107
  codesnake check src/
103
108
 
109
+ # Also analyze untracked files that .gitignore hides, e.g. generated output
110
+ codesnake check --no-ignore src/
111
+
104
112
  # Same thing without the subcommand
105
113
  codesnake src/
106
114
 
@@ -137,11 +145,12 @@ codesnake config -o .codesnake.json
137
145
  | `--no-color` | Disable ANSI color (`NO_COLOR` also works) |
138
146
  | `--bandit` | Merge Bandit results when the `bandit` executable is installed |
139
147
  | `--staged` | Check `git diff --cached` Python files only |
148
+ | `--no-ignore` | When walking directories, ignore `.gitignore` entirely, including for untracked files (venvs, caches, and `.git` are still skipped). Tracked files are analyzed either way. |
140
149
  | `--baseline FILE` | Hide issues whose fingerprint is already in the baseline |
141
150
  | `--update-baseline FILE` | Write the current finding set as a baseline |
142
151
  | `-j`, `--jobs N` | Worker processes (default: auto — one per CPU once 8+ files are checked; `1` disables) |
143
152
 
144
- `--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
153
+ `--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. Directory walks skip `.gitignore` matches only for files git does not track, mirroring git's own behavior — so a file committed with `git add -f` is still analyzed, and no flag is needed to catch it. `--no-ignore` additionally covers *untracked* ignored files, such as generated output. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
145
154
 
146
155
  ### Output formats
147
156
 
@@ -1,3 +1,8 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/bitWarrior/codesnake/main/images/codesnake-logo.jpg"
3
+ alt="CodeSnake — semantic analysis for Python" width="640">
4
+ </p>
5
+
1
6
  # CodeSnake
2
7
 
3
8
  [![CI](https://github.com/bitWarrior/codesnake/actions/workflows/ci.yml/badge.svg)](https://github.com/bitWarrior/codesnake/actions/workflows/ci.yml)
@@ -65,10 +70,13 @@ adoption path is in [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md#adopting-codesna
65
70
  ## Usage
66
71
 
67
72
  ```bash
68
- # Files or directories (walks *.py, skips venvs, caches, and .gitignore)
73
+ # Files or directories (walks *.py; skips venvs, caches, and untracked .gitignore matches)
69
74
  codesnake check src/codesnake/checker.py test/example_bad_code.py
70
75
  codesnake check src/
71
76
 
77
+ # Also analyze untracked files that .gitignore hides, e.g. generated output
78
+ codesnake check --no-ignore src/
79
+
72
80
  # Same thing without the subcommand
73
81
  codesnake src/
74
82
 
@@ -105,11 +113,12 @@ codesnake config -o .codesnake.json
105
113
  | `--no-color` | Disable ANSI color (`NO_COLOR` also works) |
106
114
  | `--bandit` | Merge Bandit results when the `bandit` executable is installed |
107
115
  | `--staged` | Check `git diff --cached` Python files only |
116
+ | `--no-ignore` | When walking directories, ignore `.gitignore` entirely, including for untracked files (venvs, caches, and `.git` are still skipped). Tracked files are analyzed either way. |
108
117
  | `--baseline FILE` | Hide issues whose fingerprint is already in the baseline |
109
118
  | `--update-baseline FILE` | Write the current finding set as a baseline |
110
119
  | `-j`, `--jobs N` | Worker processes (default: auto — one per CPU once 8+ files are checked; `1` disables) |
111
120
 
112
- `--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
121
+ `--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. Directory walks skip `.gitignore` matches only for files git does not track, mirroring git's own behavior — so a file committed with `git add -f` is still analyzed, and no flag is needed to catch it. `--no-ignore` additionally covers *untracked* ignored files, such as generated output. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
113
122
 
114
123
  ### Output formats
115
124
 
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version (read by pyproject.toml)."""
2
2
 
3
- __version__ = "1.2.1"
3
+ __version__ = "1.3.0"
@@ -360,6 +360,34 @@ def find_repo_root(start: Path) -> Optional[Path]:
360
360
  return None
361
361
 
362
362
 
363
+ def git_tracked_files(repo_root: Path) -> Set[Path]:
364
+ """Resolved paths of every file git tracks in ``repo_root``, or an empty set.
365
+
366
+ git's own rule is that ``.gitignore`` governs *untracked* files only -- a
367
+ file that is committed is not affected by an ignore rule matching it. A
368
+ scanner that filters tracked files by ``.gitignore`` therefore diverges
369
+ from git, and can be made to skip a committed file with ``git add -f``.
370
+ Failure here (no git, not a repository, a timeout) yields an empty set,
371
+ which restores the previous behavior rather than hiding files.
372
+ """
373
+ try:
374
+ completed = _subprocess.run(
375
+ ['git', 'ls-files', '-z', '--full-name'],
376
+ capture_output=True,
377
+ timeout=30,
378
+ cwd=str(repo_root),
379
+ )
380
+ except (OSError, _subprocess.TimeoutExpired):
381
+ return set()
382
+ if completed.returncode != 0:
383
+ return set()
384
+ tracked: Set[Path] = set()
385
+ for name in completed.stdout.decode('utf-8', errors='replace').split('\0'):
386
+ if name:
387
+ tracked.add(repo_root / name)
388
+ return tracked
389
+
390
+
363
391
  def detect_source_encoding(data: bytes) -> str:
364
392
  """PEP 263 encoding cookie, else UTF-8 (with BOM)."""
365
393
  if data.startswith(b'\xef\xbb\xbf'):
@@ -404,12 +432,15 @@ def issue_ignored_by_pragma(code: str, line: int, source_lines: Sequence[str]) -
404
432
  return code.upper() in codes
405
433
 
406
434
 
407
- def iter_python_files(root: Path) -> Iterable[Path]:
408
- """Yield .py files under root, skipping venvs, caches, and .gitignore matches."""
409
- try:
410
- root_resolved = root.resolve()
411
- except OSError:
412
- root_resolved = root
435
+ def _load_ignore_state(
436
+ root_resolved: Path,
437
+ ) -> Tuple['_IgnoreStack', Set[Path], Set[Path]]:
438
+ """Ignore rules for a walk, plus the tracked paths exempt from them.
439
+
440
+ Returns the loaded ``.gitignore`` stack, the set of files git tracks, and
441
+ the directories those files live in. Tracked entries are exempt because
442
+ git applies ignore rules to untracked files only.
443
+ """
413
444
  ignore = _IgnoreStack()
414
445
  # .gitignore files between the repository root and the target apply to
415
446
  # everything below them, so load them first (top-most first).
@@ -425,23 +456,68 @@ def iter_python_files(root: Path) -> Iterable[Path]:
425
456
  if gi_path.is_file():
426
457
  ignore.add_gitignore(gi_path)
427
458
 
459
+ if repo_root is None:
460
+ return ignore, set(), set()
461
+ try:
462
+ tracked = git_tracked_files(repo_root.resolve())
463
+ except OSError:
464
+ return ignore, set(), set()
465
+ # An ignored *directory* holding a committed file must still be walked, or
466
+ # the file-level exemption is never reached.
467
+ tracked_dirs: Set[Path] = set()
468
+ for path in tracked:
469
+ tracked_dirs.update(path.parents)
470
+ return ignore, tracked, tracked_dirs
471
+
472
+
473
+ def iter_python_files(root: Path, *, respect_gitignore: bool = True) -> Iterable[Path]:
474
+ """Yield .py files under root, skipping venvs, caches, and (by default) .gitignore matches.
475
+
476
+ ``respect_gitignore=False`` still skips ``SKIP_DIR_NAMES`` (venvs, caches,
477
+ ``.git``, ...) so a CI gate does not have to crawl ``site-packages``. It
478
+ does not skip a committed file that ``.gitignore`` would hide — pass
479
+ ``--no-ignore`` for that, or name the file explicitly.
480
+ """
481
+ try:
482
+ root_resolved = root.resolve()
483
+ except OSError:
484
+ root_resolved = root
485
+ ignore, tracked, tracked_dirs = (
486
+ _load_ignore_state(root_resolved) if respect_gitignore
487
+ else (_IgnoreStack(), set(), set())
488
+ )
489
+
428
490
  for dirpath, dirnames, filenames in os.walk(root_resolved):
429
491
  current = Path(dirpath)
430
- nested_gi = current / '.gitignore'
431
- if nested_gi.is_file():
432
- ignore.add_gitignore(nested_gi)
433
- views = ignore.view(current)
492
+ views: _DirectoryView = []
493
+ if respect_gitignore:
494
+ nested_gi = current / '.gitignore'
495
+ if nested_gi.is_file():
496
+ ignore.add_gitignore(nested_gi)
497
+ views = ignore.view(current)
434
498
 
435
499
  dirnames[:] = [
436
500
  name for name in dirnames
437
501
  if name not in SKIP_DIR_NAMES
438
502
  and not name.endswith('.egg-info')
439
- and not _IgnoreStack.ignored(views, name, is_dir=True)
503
+ and not (
504
+ respect_gitignore
505
+ and (current / name) not in tracked_dirs
506
+ and _IgnoreStack.ignored(views, name, is_dir=True)
507
+ )
440
508
  ]
441
509
 
442
510
  for name in filenames:
443
- if name.endswith('.py') and not _IgnoreStack.ignored(views, name, is_dir=False):
444
- yield current / name
511
+ if not name.endswith('.py'):
512
+ continue
513
+ candidate = current / name
514
+ if (
515
+ respect_gitignore
516
+ and candidate not in tracked
517
+ and _IgnoreStack.ignored(views, name, is_dir=False)
518
+ ):
519
+ continue
520
+ yield candidate
445
521
 
446
522
 
447
523
  @dataclass
@@ -2139,7 +2215,11 @@ def git_staged_python_files() -> Tuple[List[str], Optional[str]]:
2139
2215
  return files, None
2140
2216
 
2141
2217
 
2142
- def expand_python_targets(paths: Sequence[str]) -> Tuple[List[str], List[Issue]]:
2218
+ def expand_python_targets(
2219
+ paths: Sequence[str],
2220
+ *,
2221
+ respect_gitignore: bool = True,
2222
+ ) -> Tuple[List[str], List[Issue]]:
2143
2223
  """Expand directories to .py files. Explicit files are kept as given."""
2144
2224
  targets: List[str] = []
2145
2225
  extras: List[Issue] = []
@@ -2160,7 +2240,12 @@ def expand_python_targets(paths: Sequence[str]) -> Tuple[List[str], List[Issue]]
2160
2240
  for raw in paths:
2161
2241
  path = Path(raw)
2162
2242
  if path.is_dir():
2163
- found = [str(candidate) for candidate in iter_python_files(path)]
2243
+ found = [
2244
+ str(candidate)
2245
+ for candidate in iter_python_files(
2246
+ path, respect_gitignore=respect_gitignore,
2247
+ )
2248
+ ]
2164
2249
  if not found:
2165
2250
  extras.append(_io_issue(raw, f"No Python files found in '{raw}'"))
2166
2251
  continue
@@ -2609,10 +2694,14 @@ def run_check(
2609
2694
  baseline_path: Optional[str] = None,
2610
2695
  update_baseline: Optional[str] = None,
2611
2696
  jobs: Optional[int] = None,
2697
+ no_ignore: bool = False,
2612
2698
  ) -> int:
2613
2699
  """Analyze one or more files. Returns 1 if any error-severity issue exists.
2614
2700
 
2615
2701
  ``jobs``: worker processes (``None``/``0`` = auto, ``1`` = sequential).
2702
+ ``no_ignore``: walk directories without applying ``.gitignore`` (venvs,
2703
+ caches, and ``.git`` are still skipped). Explicit file arguments are
2704
+ never gitignore-filtered.
2616
2705
  """
2617
2706
  out = stream if stream is not None else sys.stdout
2618
2707
 
@@ -2643,7 +2732,9 @@ def run_check(
2643
2732
  if show_banner and output_format == 'text':
2644
2733
  print_snake_banner(use_color=use_color)
2645
2734
 
2646
- targets, extra_issues = expand_python_targets(file_list)
2735
+ targets, extra_issues = expand_python_targets(
2736
+ file_list, respect_gitignore=not no_ignore,
2737
+ )
2647
2738
 
2648
2739
  file_reports: List[Tuple[str, List[Issue], Optional[str]]] = []
2649
2740
  for extra in extra_issues:
@@ -54,6 +54,12 @@ def add_check_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentPar
54
54
  action='store_true',
55
55
  help='Check only Python files staged in git',
56
56
  )
57
+ parser.add_argument(
58
+ '--no-ignore',
59
+ action='store_true',
60
+ help='Do not skip .gitignore matches when walking directories '
61
+ '(venvs, caches, and .git are still skipped)',
62
+ )
57
63
  parser.add_argument(
58
64
  '--baseline',
59
65
  metavar='FILE',
@@ -141,6 +147,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
141
147
  baseline_path=args.baseline,
142
148
  update_baseline=args.update_baseline,
143
149
  jobs=args.jobs,
150
+ no_ignore=args.no_ignore,
144
151
  )
145
152
 
146
153
  if args.command == 'config':
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codesnake
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: Semantic code checker for Python 3
5
5
  Author: CodeSnake Contributors
6
6
  License-Expression: MIT
@@ -30,6 +30,11 @@ Provides-Extra: dev
30
30
  Requires-Dist: codesnake[tools]; extra == "dev"
31
31
  Dynamic: license-file
32
32
 
33
+ <p align="center">
34
+ <img src="https://raw.githubusercontent.com/bitWarrior/codesnake/main/images/codesnake-logo.jpg"
35
+ alt="CodeSnake — semantic analysis for Python" width="640">
36
+ </p>
37
+
33
38
  # CodeSnake
34
39
 
35
40
  [![CI](https://github.com/bitWarrior/codesnake/actions/workflows/ci.yml/badge.svg)](https://github.com/bitWarrior/codesnake/actions/workflows/ci.yml)
@@ -97,10 +102,13 @@ adoption path is in [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md#adopting-codesna
97
102
  ## Usage
98
103
 
99
104
  ```bash
100
- # Files or directories (walks *.py, skips venvs, caches, and .gitignore)
105
+ # Files or directories (walks *.py; skips venvs, caches, and untracked .gitignore matches)
101
106
  codesnake check src/codesnake/checker.py test/example_bad_code.py
102
107
  codesnake check src/
103
108
 
109
+ # Also analyze untracked files that .gitignore hides, e.g. generated output
110
+ codesnake check --no-ignore src/
111
+
104
112
  # Same thing without the subcommand
105
113
  codesnake src/
106
114
 
@@ -137,11 +145,12 @@ codesnake config -o .codesnake.json
137
145
  | `--no-color` | Disable ANSI color (`NO_COLOR` also works) |
138
146
  | `--bandit` | Merge Bandit results when the `bandit` executable is installed |
139
147
  | `--staged` | Check `git diff --cached` Python files only |
148
+ | `--no-ignore` | When walking directories, ignore `.gitignore` entirely, including for untracked files (venvs, caches, and `.git` are still skipped). Tracked files are analyzed either way. |
140
149
  | `--baseline FILE` | Hide issues whose fingerprint is already in the baseline |
141
150
  | `--update-baseline FILE` | Write the current finding set as a baseline |
142
151
  | `-j`, `--jobs N` | Worker processes (default: auto — one per CPU once 8+ files are checked; `1` disables) |
143
152
 
144
- `--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
153
+ `--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. Directory walks skip `.gitignore` matches only for files git does not track, mirroring git's own behavior — so a file committed with `git add -f` is still analyzed, and no flag is needed to catch it. `--no-ignore` additionally covers *untracked* ignored files, such as generated output. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
145
154
 
146
155
  ### Output formats
147
156
 
@@ -864,6 +864,138 @@ class TestDirectoryWalkAndEncoding(unittest.TestCase):
864
864
  names = {Path(path).name for path in targets}
865
865
  self.assertEqual(names, {'kept.py'})
866
866
 
867
+ def _git_repo(self, tmp):
868
+ """A real git repo; skip if git is unavailable."""
869
+ import subprocess
870
+ root = Path(tmp).resolve()
871
+ try:
872
+ for cmd in (['git', 'init', '-q', '.'],
873
+ ['git', 'config', 'user.email', 't@e.st'],
874
+ ['git', 'config', 'user.name', 'test']):
875
+ subprocess.run(cmd, cwd=root, check=True, capture_output=True, timeout=30)
876
+ except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
877
+ self.skipTest('git not available')
878
+ return root
879
+
880
+ def _git(self, root, *args):
881
+ import subprocess
882
+ subprocess.run(['git', *args], cwd=root, check=True, capture_output=True, timeout=30)
883
+
884
+ def test_committed_file_is_not_hidden_by_gitignore(self):
885
+ """git ignores .gitignore for tracked files; so must the walk.
886
+
887
+ Otherwise a PR can add a file, gitignore it, `git add -f` it, and the
888
+ default directory walk never sees it.
889
+ """
890
+ with tempfile.TemporaryDirectory() as tmp:
891
+ root = self._git_repo(tmp)
892
+ (root / 'src').mkdir()
893
+ (root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8')
894
+ (root / 'src' / 'evil.py').write_text('eval(input())\n', encoding='utf-8')
895
+ (root / '.gitignore').write_text('src/evil.py\n', encoding='utf-8')
896
+ self._git(root, 'add', '.gitignore', 'src/good.py')
897
+ self._git(root, 'add', '-f', 'src/evil.py')
898
+ self._git(root, 'commit', '-q', '-m', 'x')
899
+
900
+ targets, _ = expand_python_targets([str(root / 'src')])
901
+ self.assertEqual({Path(t).name for t in targets}, {'good.py', 'evil.py'})
902
+
903
+ def test_committed_file_inside_ignored_directory_is_walked(self):
904
+ """An ignored directory holding a tracked file must not be pruned."""
905
+ with tempfile.TemporaryDirectory() as tmp:
906
+ root = self._git_repo(tmp)
907
+ (root / 'src' / 'hidden').mkdir(parents=True)
908
+ (root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8')
909
+ (root / 'src' / 'hidden' / 'evil.py').write_text('eval(input())\n', encoding='utf-8')
910
+ (root / '.gitignore').write_text('src/hidden/\n', encoding='utf-8')
911
+ self._git(root, 'add', '.gitignore', 'src/good.py')
912
+ self._git(root, 'add', '-f', 'src/hidden/evil.py')
913
+ self._git(root, 'commit', '-q', '-m', 'x')
914
+
915
+ targets, _ = expand_python_targets([str(root / 'src')])
916
+ self.assertEqual({Path(t).name for t in targets}, {'good.py', 'evil.py'})
917
+
918
+ def test_untracked_gitignored_files_are_still_skipped(self):
919
+ """The exemption is for tracked files only — build output stays ignored."""
920
+ with tempfile.TemporaryDirectory() as tmp:
921
+ root = self._git_repo(tmp)
922
+ (root / 'src' / 'build').mkdir(parents=True)
923
+ (root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8')
924
+ (root / 'src' / 'generated.py').write_text('eval(input())\n', encoding='utf-8')
925
+ (root / 'src' / 'build' / 'gen.py').write_text('eval(input())\n', encoding='utf-8')
926
+ (root / '.gitignore').write_text('src/generated.py\nsrc/build/\n', encoding='utf-8')
927
+ self._git(root, 'add', '.gitignore', 'src/good.py')
928
+ self._git(root, 'commit', '-q', '-m', 'x')
929
+
930
+ targets, _ = expand_python_targets([str(root / 'src')])
931
+ self.assertEqual({Path(t).name for t in targets}, {'good.py'})
932
+
933
+ def test_no_ignore_includes_gitignored_files(self):
934
+ """A committed-but-ignored file must be visible to a CI directory walk."""
935
+ with tempfile.TemporaryDirectory() as tmp:
936
+ root = Path(tmp)
937
+ (root / '.gitignore').write_text('ignored.py\nsecret/\n', encoding='utf-8')
938
+ (root / 'kept.py').write_text('x = 1\n', encoding='utf-8')
939
+ (root / 'ignored.py').write_text('eval(1)\n', encoding='utf-8')
940
+ (root / 'secret').mkdir()
941
+ (root / 'secret' / 'bad.py').write_text('eval(1)\n', encoding='utf-8')
942
+ targets, extras = expand_python_targets(
943
+ [str(root)], respect_gitignore=False,
944
+ )
945
+ self.assertEqual(extras, [])
946
+ names = {Path(path).name for path in targets}
947
+ self.assertEqual(names, {'kept.py', 'ignored.py', 'bad.py'})
948
+
949
+ def test_no_ignore_still_skips_venvs_and_caches(self):
950
+ with tempfile.TemporaryDirectory() as tmp:
951
+ root = Path(tmp)
952
+ (root / 'kept.py').write_text('x = 1\n', encoding='utf-8')
953
+ (root / '__pycache__').mkdir()
954
+ (root / '__pycache__' / 'skip.py').write_text('eval(1)\n', encoding='utf-8')
955
+ (root / '.venv').mkdir()
956
+ (root / '.venv' / 'lib.py').write_text('eval(1)\n', encoding='utf-8')
957
+ targets, extras = expand_python_targets(
958
+ [str(root)], respect_gitignore=False,
959
+ )
960
+ self.assertEqual(extras, [])
961
+ self.assertEqual([Path(path).name for path in targets], ['kept.py'])
962
+
963
+ def test_run_check_no_ignore_reports_gitignored_error(self):
964
+ with tempfile.TemporaryDirectory() as tmp:
965
+ root = Path(tmp)
966
+ (root / '.gitignore').write_text('evil.py\n', encoding='utf-8')
967
+ (root / 'ok.py').write_text('x = 1\n', encoding='utf-8')
968
+ (root / 'evil.py').write_text('eval(input())\n', encoding='utf-8')
969
+ skipped = StringIO()
970
+ self.assertEqual(
971
+ run_check(
972
+ [str(root)],
973
+ config=CheckerConfig(),
974
+ output_format='json',
975
+ show_banner=False,
976
+ color=False,
977
+ stream=skipped,
978
+ ),
979
+ 0,
980
+ )
981
+ found = StringIO()
982
+ rc = run_check(
983
+ [str(root)],
984
+ config=CheckerConfig(),
985
+ output_format='json',
986
+ show_banner=False,
987
+ color=False,
988
+ stream=found,
989
+ no_ignore=True,
990
+ )
991
+ self.assertEqual(rc, 1)
992
+ codes = [
993
+ issue['code']
994
+ for file_report in json.loads(found.getvalue())['files']
995
+ for issue in file_report['issues']
996
+ ]
997
+ self.assertIn('SEC001', codes)
998
+
867
999
  def test_run_check_on_directory(self):
868
1000
  with tempfile.TemporaryDirectory() as tmp:
869
1001
  root = Path(tmp)
@@ -2116,6 +2248,27 @@ class TestPackageAndCli(unittest.TestCase):
2116
2248
  self.assertEqual(main(['check']), 2)
2117
2249
  self.assertIn('provide files', err.getvalue())
2118
2250
 
2251
+ def test_cli_no_ignore_checks_gitignored_file(self):
2252
+ from unittest.mock import patch
2253
+ from codesnake.cli import main
2254
+ with tempfile.TemporaryDirectory() as tmp:
2255
+ root = Path(tmp)
2256
+ (root / '.gitignore').write_text('evil.py\n', encoding='utf-8')
2257
+ (root / 'ok.py').write_text('x = 1\n', encoding='utf-8')
2258
+ (root / 'evil.py').write_text('eval(input())\n', encoding='utf-8')
2259
+ with patch('sys.stdout', StringIO()):
2260
+ self.assertEqual(
2261
+ main(['check', str(root), '--format', 'json', '--no-color']),
2262
+ 0,
2263
+ )
2264
+ with patch('sys.stdout', StringIO()) as out:
2265
+ rc = main([
2266
+ 'check', str(root), '--format', 'json', '--no-color',
2267
+ '--no-ignore',
2268
+ ])
2269
+ self.assertEqual(rc, 1)
2270
+ self.assertIn('SEC001', out.getvalue())
2271
+
2119
2272
  def test_deeply_nested_file_is_contained_not_fatal(self):
2120
2273
  """One unanalyzable file must not sink the whole run."""
2121
2274
  from codesnake import check_file, run_check
@@ -2350,6 +2503,24 @@ class TestGitignoreFromRepoRoot(unittest.TestCase):
2350
2503
  targets, _ = expand_python_targets([str(outer / 'proj')])
2351
2504
  self.assertEqual({Path(t).name for t in targets}, {'a.py'})
2352
2505
 
2506
+ def test_no_ignore_includes_root_gitignored_files(self):
2507
+ with tempfile.TemporaryDirectory() as tmp:
2508
+ root = Path(tmp).resolve()
2509
+ (root / '.git').mkdir()
2510
+ (root / '.gitignore').write_text('generated/\n*_pb2.py\n', encoding='utf-8')
2511
+ src = root / 'src'
2512
+ (src / 'generated').mkdir(parents=True)
2513
+ (src / 'a.py').write_text('x = 1\n', encoding='utf-8')
2514
+ (src / 'proto_pb2.py').write_text('x = 1\n', encoding='utf-8')
2515
+ (src / 'generated' / 'g.py').write_text('x = 1\n', encoding='utf-8')
2516
+ targets, _ = expand_python_targets(
2517
+ [str(src)], respect_gitignore=False,
2518
+ )
2519
+ self.assertEqual(
2520
+ {Path(t).name for t in targets},
2521
+ {'a.py', 'proto_pb2.py', 'g.py'},
2522
+ )
2523
+
2353
2524
 
2354
2525
  class TestBaselineFingerprints(unittest.TestCase):
2355
2526
 
File without changes
File without changes
File without changes