diff --git a/CHANGELOG.md b/CHANGELOG.md index 1699fd4..75fcc17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ All notable changes to CodeSnake are documented here. The format follows ## [Unreleased] +### Fixed + +- **`.gitignore` no longer hides committed files from a directory walk.** git's own + rule is that an ignore pattern has no effect on a tracked file; CodeSnake applied + it regardless, so a pull request could add a file, ignore it, `git add -f` it, and + `codesnake check src/` would never see it — exiting 0 with the file in the tree. + Tracked files are now exempt from ignore filtering, and an ignored directory + holding a tracked file is walked rather than pruned. Untracked ignored files, such + as generated output, are still skipped. No flag is needed, and CI gates written + before this release are covered on upgrade. + +### Added + +- **`--no-ignore`** disables `.gitignore` handling entirely, covering *untracked* + ignored files as well — useful for linting generated code or scanning a tree that + is not a git repository. Venvs, caches, and `.git` stay skipped. Explicit file + arguments were never gitignore-filtered. + ## [1.2.1] - 2026-09-01 First release published to PyPI. The package itself is byte-identical to 1.2.0 — diff --git a/README.md b/README.md index 5281a9c..6f708be 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,13 @@ adoption path is in [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md#adopting-codesna ## Usage ```bash -# Files or directories (walks *.py, skips venvs, caches, and .gitignore) +# Files or directories (walks *.py; skips venvs, caches, and untracked .gitignore matches) codesnake check src/codesnake/checker.py test/example_bad_code.py codesnake check src/ +# Also analyze untracked files that .gitignore hides, e.g. generated output +codesnake check --no-ignore src/ + # Same thing without the subcommand codesnake src/ @@ -110,11 +113,12 @@ codesnake config -o .codesnake.json | `--no-color` | Disable ANSI color (`NO_COLOR` also works) | | `--bandit` | Merge Bandit results when the `bandit` executable is installed | | `--staged` | Check `git diff --cached` Python files only | +| `--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. | | `--baseline FILE` | Hide issues whose fingerprint is already in the baseline | | `--update-baseline FILE` | Write the current finding set as a baseline | | `-j`, `--jobs N` | Worker processes (default: auto — one per CPU once 8+ files are checked; `1` disables) | -`--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). +`--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). ### Output formats diff --git a/SECURITY.md b/SECURITY.md index d13b6cc..b1e8b0b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,6 +32,8 @@ Two places reach outside the process, both only when you ask: - `--bandit` runs the separate `bandit` executable, if it is installed and on `PATH`, over the files you named. That is a different project with its own dependencies and its own threat model. - `--staged` runs `git rev-parse` and `git diff --cached` to list staged files. +Directory walks skip `.gitignore` matches, but **only for files git does not track** — matching git's own rule that an ignore pattern has no effect on a committed file. A file added with `git add -f` is therefore still analyzed, and so is a tracked file inside an ignored directory. `--no-ignore` disables ignore handling entirely, for scanning a working tree that is not a git repository or whose ignored output you want covered. Venvs, caches, and `.git` are skipped in every mode. + ### In scope - Anything that makes CodeSnake execute code from a file it is analyzing diff --git a/codesnake-launcher.sh b/codesnake-launcher.sh index 6f4070a..221c8f4 100755 --- a/codesnake-launcher.sh +++ b/codesnake-launcher.sh @@ -46,8 +46,8 @@ Options: --test Run test suite --banner Show CodeSnake banner -Any other argument (files, directories, --bandit, --staged, --baseline FILE, ---no-color, ...) is passed straight through to CodeSnake. +Any other argument (files, directories, --bandit, --staged, --no-ignore, +--baseline FILE, --no-color, ...) is passed straight through to CodeSnake. Examples: $0 mycode.py # Check a file diff --git a/docs/BASH_SCRIPTS_GUIDE.md b/docs/BASH_SCRIPTS_GUIDE.md index 99e34b6..f6b3dc7 100644 --- a/docs/BASH_SCRIPTS_GUIDE.md +++ b/docs/BASH_SCRIPTS_GUIDE.md @@ -58,7 +58,7 @@ Activates `codesnake-venv/` (creating and populating it on first run), then `exe | `--banner` | Print the banner | | `-e`, `--enhanced` | Deprecated no-op (the "enhanced" checker was merged into the main CLI) | -Anything else — files, directories, `--bandit`, `--staged`, `--baseline FILE`, `--update-baseline FILE`, `--jobs N`, `--no-color` — is passed straight through to `codesnake check`. Arguments are forwarded as an array, so paths with spaces or shell metacharacters are safe. An option that needs a value but has none exits with status 2 and a message. +Anything else — files, directories, `--bandit`, `--staged`, `--no-ignore`, `--baseline FILE`, `--update-baseline FILE`, `--jobs N`, `--no-color` — is passed straight through to `codesnake check`. Arguments are forwarded as an array, so paths with spaces or shell metacharacters are safe. An option that needs a value but has none exits with status 2 and a message. ```bash ./codesnake-launcher.sh mycode.py diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index d740692..6410cbe 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -46,6 +46,20 @@ To have pre-commit manage the install instead, use `language: python` with ## GitHub Actions +`.gitignore` is applied only to files git does not track, matching git's own rule that an ignore pattern has no effect on a committed file. A file added with `git add -f`, or a tracked file inside an ignored directory, is analyzed by a plain `codesnake check src/` — no flag required. This is what stops a pull request from hiding code from the gate by ignoring it and force-adding it. + +Two variations are still useful: + +```bash +# Also cover UNTRACKED ignored files, e.g. generated code you want linted anyway +codesnake check --no-ignore --severity error --no-color src/ + +# Exactly what git tracks, and nothing else +codesnake check --no-color --severity error $(git ls-files '*.py') +``` + +Run the `git ls-files` form from the repository root. If it expands to nothing, CodeSnake exits 2 (`no files to check`); the directory form does not have that empty-tree edge. + ### Inline annotations `--format github` prints workflow commands; GitHub turns them into annotations on the PR diff. This repository's own `.github/workflows/ci.yml` does exactly this. @@ -80,7 +94,7 @@ Upload SARIF so findings appear under **Security → Code scanning**. The `|| tr - uses: github/codeql-action/upload-sarif@v4 with: sarif_file: codesnake.sarif - - run: codesnake check --severity error --no-color src/ + - run: codesnake check --severity error --no-color --no-ignore src/ ``` ### Only fail on new findings @@ -95,7 +109,7 @@ git add .codesnake-baseline.json Baselines store paths relative to the working directory, so write and read them from the **same** directory — the repository root, to match CI. A baseline recorded at the root suppresses nothing when the check is later run from inside `src/`. (This is why `--baseline` does not belong in the `--staged` hook above without pinning the directory first.) ```yaml - - run: codesnake check --baseline .codesnake-baseline.json --format github --no-color src/ + - run: codesnake check --baseline .codesnake-baseline.json --format github --no-color --no-ignore src/ ``` A fingerprint is the file path, the rule code, the message with digits normalized away, and an occurrence index. Line numbers and numeric counts therefore do not matter — edits above a finding, or a complexity score drifting from 15 to 16, will not re-fail CI. Anything else does: moving code to another file, or a rename that changes an identifier quoted in the message (`Unused import 'os'`), mints a new fingerprint even though the finding is unchanged. A genuinely *new* violation (including a second identical one in the same file) fails too. Refresh the baseline with `--update-baseline` as you pay down the backlog. @@ -159,7 +173,7 @@ lint: ## everything, human-readable codesnake check src/ test/ lint-errors: ## only what would fail CI - codesnake check --severity error src/ + codesnake check --severity error --no-ignore src/ lint-baseline: ## only findings not in the committed baseline codesnake check --baseline .codesnake-baseline.json src/ @@ -194,7 +208,8 @@ CodeSnake's differentiators are the taint tracking behind SEC001/SEC003 severiti | Symptom | Fix | |---|---| | Too many findings | Baseline first, then tune thresholds and `check_*` flags; `# noqa: CODE` for the rest | -| Slow on a large tree | `--jobs N` (auto-parallel from 8 files); point at specific directories; generated code under `.gitignore`d directories is already skipped | +| Slow on a large tree | `--jobs N` (auto-parallel from 8 files); point at specific directories; untracked generated code under `.gitignore`d directories is skipped unless you pass `--no-ignore` | +| CI missed a committed file | Tracked files are never hidden by `.gitignore`. If one was missed it is untracked — commit it, or pass `--no-ignore` | | `No staged Python files.` | Nothing staged, or the files are not `*.py`; a real git failure prints an `Error:` instead | | Escape codes in CI logs | `--no-color` or `NO_COLOR=1` | | Warnings don't fail the build | By design; see the exit-code note at the top | diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index f81e8ff..dfefb8d 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -37,7 +37,7 @@ codesnake/ | Module | Contents | |---|---| -| `checker.py` | `SemanticChecker` (the AST visitor and every rule), `CheckerConfig` / `load_config`, file discovery (`iter_python_files`, `.gitignore` handling, `expand_python_targets`), `check_file`, bandit merge, baselines, `--staged` support, the four report formatters, and `run_check` (the orchestration entry point used by the CLI and by library callers) | +| `checker.py` | `SemanticChecker` (the AST visitor and every rule), `CheckerConfig` / `load_config`, file discovery (`iter_python_files`, `.gitignore` handling, `--no-ignore` / `respect_gitignore`, `expand_python_targets`), `check_file`, bandit merge, baselines, `--staged` support, the four report formatters, and `run_check` (the orchestration entry point used by the CLI and by library callers) | | `cli.py` | `add_check_arguments()` (the single definition of the `check` flags), `build_parser()`, `normalize_argv()` (so `codesnake FILES` means `codesnake check FILES`), and `main()` | | `banner.py` | `print_snake_banner()`, `print_version()`; `VERSION` is imported from `_version` | | `_version.py` | `__version__` — read statically by `pyproject.toml` (`dynamic = ["version"]`) | diff --git a/src/codesnake/checker.py b/src/codesnake/checker.py index 09477e8..0515f8e 100644 --- a/src/codesnake/checker.py +++ b/src/codesnake/checker.py @@ -360,6 +360,34 @@ def find_repo_root(start: Path) -> Optional[Path]: return None +def git_tracked_files(repo_root: Path) -> Set[Path]: + """Resolved paths of every file git tracks in ``repo_root``, or an empty set. + + git's own rule is that ``.gitignore`` governs *untracked* files only -- a + file that is committed is not affected by an ignore rule matching it. A + scanner that filters tracked files by ``.gitignore`` therefore diverges + from git, and can be made to skip a committed file with ``git add -f``. + Failure here (no git, not a repository, a timeout) yields an empty set, + which restores the previous behavior rather than hiding files. + """ + try: + completed = _subprocess.run( + ['git', 'ls-files', '-z', '--full-name'], + capture_output=True, + timeout=30, + cwd=str(repo_root), + ) + except (OSError, _subprocess.TimeoutExpired): + return set() + if completed.returncode != 0: + return set() + tracked: Set[Path] = set() + for name in completed.stdout.decode('utf-8', errors='replace').split('\0'): + if name: + tracked.add(repo_root / name) + return tracked + + def detect_source_encoding(data: bytes) -> str: """PEP 263 encoding cookie, else UTF-8 (with BOM).""" if data.startswith(b'\xef\xbb\xbf'): @@ -404,12 +432,15 @@ def issue_ignored_by_pragma(code: str, line: int, source_lines: Sequence[str]) - return code.upper() in codes -def iter_python_files(root: Path) -> Iterable[Path]: - """Yield .py files under root, skipping venvs, caches, and .gitignore matches.""" - try: - root_resolved = root.resolve() - except OSError: - root_resolved = root +def _load_ignore_state( + root_resolved: Path, +) -> Tuple['_IgnoreStack', Set[Path], Set[Path]]: + """Ignore rules for a walk, plus the tracked paths exempt from them. + + Returns the loaded ``.gitignore`` stack, the set of files git tracks, and + the directories those files live in. Tracked entries are exempt because + git applies ignore rules to untracked files only. + """ ignore = _IgnoreStack() # .gitignore files between the repository root and the target apply to # everything below them, so load them first (top-most first). @@ -425,23 +456,68 @@ def iter_python_files(root: Path) -> Iterable[Path]: if gi_path.is_file(): ignore.add_gitignore(gi_path) + if repo_root is None: + return ignore, set(), set() + try: + tracked = git_tracked_files(repo_root.resolve()) + except OSError: + return ignore, set(), set() + # An ignored *directory* holding a committed file must still be walked, or + # the file-level exemption is never reached. + tracked_dirs: Set[Path] = set() + for path in tracked: + tracked_dirs.update(path.parents) + return ignore, tracked, tracked_dirs + + +def iter_python_files(root: Path, *, respect_gitignore: bool = True) -> Iterable[Path]: + """Yield .py files under root, skipping venvs, caches, and (by default) .gitignore matches. + + ``respect_gitignore=False`` still skips ``SKIP_DIR_NAMES`` (venvs, caches, + ``.git``, ...) so a CI gate does not have to crawl ``site-packages``. It + does not skip a committed file that ``.gitignore`` would hide — pass + ``--no-ignore`` for that, or name the file explicitly. + """ + try: + root_resolved = root.resolve() + except OSError: + root_resolved = root + ignore, tracked, tracked_dirs = ( + _load_ignore_state(root_resolved) if respect_gitignore + else (_IgnoreStack(), set(), set()) + ) + for dirpath, dirnames, filenames in os.walk(root_resolved): current = Path(dirpath) - nested_gi = current / '.gitignore' - if nested_gi.is_file(): - ignore.add_gitignore(nested_gi) - views = ignore.view(current) + views: _DirectoryView = [] + if respect_gitignore: + nested_gi = current / '.gitignore' + if nested_gi.is_file(): + ignore.add_gitignore(nested_gi) + views = ignore.view(current) dirnames[:] = [ name for name in dirnames if name not in SKIP_DIR_NAMES and not name.endswith('.egg-info') - and not _IgnoreStack.ignored(views, name, is_dir=True) + and not ( + respect_gitignore + and (current / name) not in tracked_dirs + and _IgnoreStack.ignored(views, name, is_dir=True) + ) ] for name in filenames: - if name.endswith('.py') and not _IgnoreStack.ignored(views, name, is_dir=False): - yield current / name + if not name.endswith('.py'): + continue + candidate = current / name + if ( + respect_gitignore + and candidate not in tracked + and _IgnoreStack.ignored(views, name, is_dir=False) + ): + continue + yield candidate @dataclass @@ -2139,7 +2215,11 @@ def git_staged_python_files() -> Tuple[List[str], Optional[str]]: return files, None -def expand_python_targets(paths: Sequence[str]) -> Tuple[List[str], List[Issue]]: +def expand_python_targets( + paths: Sequence[str], + *, + respect_gitignore: bool = True, +) -> Tuple[List[str], List[Issue]]: """Expand directories to .py files. Explicit files are kept as given.""" targets: List[str] = [] extras: List[Issue] = [] @@ -2160,7 +2240,12 @@ def _add(item: str) -> None: for raw in paths: path = Path(raw) if path.is_dir(): - found = [str(candidate) for candidate in iter_python_files(path)] + found = [ + str(candidate) + for candidate in iter_python_files( + path, respect_gitignore=respect_gitignore, + ) + ] if not found: extras.append(_io_issue(raw, f"No Python files found in '{raw}'")) continue @@ -2609,10 +2694,14 @@ def run_check( baseline_path: Optional[str] = None, update_baseline: Optional[str] = None, jobs: Optional[int] = None, + no_ignore: bool = False, ) -> int: """Analyze one or more files. Returns 1 if any error-severity issue exists. ``jobs``: worker processes (``None``/``0`` = auto, ``1`` = sequential). + ``no_ignore``: walk directories without applying ``.gitignore`` (venvs, + caches, and ``.git`` are still skipped). Explicit file arguments are + never gitignore-filtered. """ out = stream if stream is not None else sys.stdout @@ -2643,7 +2732,9 @@ def run_check( if show_banner and output_format == 'text': print_snake_banner(use_color=use_color) - targets, extra_issues = expand_python_targets(file_list) + targets, extra_issues = expand_python_targets( + file_list, respect_gitignore=not no_ignore, + ) file_reports: List[Tuple[str, List[Issue], Optional[str]]] = [] for extra in extra_issues: diff --git a/src/codesnake/cli.py b/src/codesnake/cli.py index 18715f9..b2082f7 100644 --- a/src/codesnake/cli.py +++ b/src/codesnake/cli.py @@ -54,6 +54,12 @@ def add_check_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentPar action='store_true', help='Check only Python files staged in git', ) + parser.add_argument( + '--no-ignore', + action='store_true', + help='Do not skip .gitignore matches when walking directories ' + '(venvs, caches, and .git are still skipped)', + ) parser.add_argument( '--baseline', metavar='FILE', @@ -141,6 +147,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: baseline_path=args.baseline, update_baseline=args.update_baseline, jobs=args.jobs, + no_ignore=args.no_ignore, ) if args.command == 'config': diff --git a/test/test_codesnake.py b/test/test_codesnake.py index d858be1..58c4f21 100644 --- a/test/test_codesnake.py +++ b/test/test_codesnake.py @@ -864,6 +864,138 @@ def test_gitignore_skips_files(self): names = {Path(path).name for path in targets} self.assertEqual(names, {'kept.py'}) + def _git_repo(self, tmp): + """A real git repo; skip if git is unavailable.""" + import subprocess + root = Path(tmp).resolve() + try: + for cmd in (['git', 'init', '-q', '.'], + ['git', 'config', 'user.email', 't@e.st'], + ['git', 'config', 'user.name', 'test']): + subprocess.run(cmd, cwd=root, check=True, capture_output=True, timeout=30) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + self.skipTest('git not available') + return root + + def _git(self, root, *args): + import subprocess + subprocess.run(['git', *args], cwd=root, check=True, capture_output=True, timeout=30) + + def test_committed_file_is_not_hidden_by_gitignore(self): + """git ignores .gitignore for tracked files; so must the walk. + + Otherwise a PR can add a file, gitignore it, `git add -f` it, and the + default directory walk never sees it. + """ + with tempfile.TemporaryDirectory() as tmp: + root = self._git_repo(tmp) + (root / 'src').mkdir() + (root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8') + (root / 'src' / 'evil.py').write_text('eval(input())\n', encoding='utf-8') + (root / '.gitignore').write_text('src/evil.py\n', encoding='utf-8') + self._git(root, 'add', '.gitignore', 'src/good.py') + self._git(root, 'add', '-f', 'src/evil.py') + self._git(root, 'commit', '-q', '-m', 'x') + + targets, _ = expand_python_targets([str(root / 'src')]) + self.assertEqual({Path(t).name for t in targets}, {'good.py', 'evil.py'}) + + def test_committed_file_inside_ignored_directory_is_walked(self): + """An ignored directory holding a tracked file must not be pruned.""" + with tempfile.TemporaryDirectory() as tmp: + root = self._git_repo(tmp) + (root / 'src' / 'hidden').mkdir(parents=True) + (root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8') + (root / 'src' / 'hidden' / 'evil.py').write_text('eval(input())\n', encoding='utf-8') + (root / '.gitignore').write_text('src/hidden/\n', encoding='utf-8') + self._git(root, 'add', '.gitignore', 'src/good.py') + self._git(root, 'add', '-f', 'src/hidden/evil.py') + self._git(root, 'commit', '-q', '-m', 'x') + + targets, _ = expand_python_targets([str(root / 'src')]) + self.assertEqual({Path(t).name for t in targets}, {'good.py', 'evil.py'}) + + def test_untracked_gitignored_files_are_still_skipped(self): + """The exemption is for tracked files only — build output stays ignored.""" + with tempfile.TemporaryDirectory() as tmp: + root = self._git_repo(tmp) + (root / 'src' / 'build').mkdir(parents=True) + (root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8') + (root / 'src' / 'generated.py').write_text('eval(input())\n', encoding='utf-8') + (root / 'src' / 'build' / 'gen.py').write_text('eval(input())\n', encoding='utf-8') + (root / '.gitignore').write_text('src/generated.py\nsrc/build/\n', encoding='utf-8') + self._git(root, 'add', '.gitignore', 'src/good.py') + self._git(root, 'commit', '-q', '-m', 'x') + + targets, _ = expand_python_targets([str(root / 'src')]) + self.assertEqual({Path(t).name for t in targets}, {'good.py'}) + + def test_no_ignore_includes_gitignored_files(self): + """A committed-but-ignored file must be visible to a CI directory walk.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / '.gitignore').write_text('ignored.py\nsecret/\n', encoding='utf-8') + (root / 'kept.py').write_text('x = 1\n', encoding='utf-8') + (root / 'ignored.py').write_text('eval(1)\n', encoding='utf-8') + (root / 'secret').mkdir() + (root / 'secret' / 'bad.py').write_text('eval(1)\n', encoding='utf-8') + targets, extras = expand_python_targets( + [str(root)], respect_gitignore=False, + ) + self.assertEqual(extras, []) + names = {Path(path).name for path in targets} + self.assertEqual(names, {'kept.py', 'ignored.py', 'bad.py'}) + + def test_no_ignore_still_skips_venvs_and_caches(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / 'kept.py').write_text('x = 1\n', encoding='utf-8') + (root / '__pycache__').mkdir() + (root / '__pycache__' / 'skip.py').write_text('eval(1)\n', encoding='utf-8') + (root / '.venv').mkdir() + (root / '.venv' / 'lib.py').write_text('eval(1)\n', encoding='utf-8') + targets, extras = expand_python_targets( + [str(root)], respect_gitignore=False, + ) + self.assertEqual(extras, []) + self.assertEqual([Path(path).name for path in targets], ['kept.py']) + + def test_run_check_no_ignore_reports_gitignored_error(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / '.gitignore').write_text('evil.py\n', encoding='utf-8') + (root / 'ok.py').write_text('x = 1\n', encoding='utf-8') + (root / 'evil.py').write_text('eval(input())\n', encoding='utf-8') + skipped = StringIO() + self.assertEqual( + run_check( + [str(root)], + config=CheckerConfig(), + output_format='json', + show_banner=False, + color=False, + stream=skipped, + ), + 0, + ) + found = StringIO() + rc = run_check( + [str(root)], + config=CheckerConfig(), + output_format='json', + show_banner=False, + color=False, + stream=found, + no_ignore=True, + ) + self.assertEqual(rc, 1) + codes = [ + issue['code'] + for file_report in json.loads(found.getvalue())['files'] + for issue in file_report['issues'] + ] + self.assertIn('SEC001', codes) + def test_run_check_on_directory(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -2116,6 +2248,27 @@ def test_cli_check_without_files_is_usage_error(self): self.assertEqual(main(['check']), 2) self.assertIn('provide files', err.getvalue()) + def test_cli_no_ignore_checks_gitignored_file(self): + from unittest.mock import patch + from codesnake.cli import main + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / '.gitignore').write_text('evil.py\n', encoding='utf-8') + (root / 'ok.py').write_text('x = 1\n', encoding='utf-8') + (root / 'evil.py').write_text('eval(input())\n', encoding='utf-8') + with patch('sys.stdout', StringIO()): + self.assertEqual( + main(['check', str(root), '--format', 'json', '--no-color']), + 0, + ) + with patch('sys.stdout', StringIO()) as out: + rc = main([ + 'check', str(root), '--format', 'json', '--no-color', + '--no-ignore', + ]) + self.assertEqual(rc, 1) + self.assertIn('SEC001', out.getvalue()) + def test_deeply_nested_file_is_contained_not_fatal(self): """One unanalyzable file must not sink the whole run.""" from codesnake import check_file, run_check @@ -2350,6 +2503,24 @@ def test_without_repo_only_target_gitignore_applies(self): targets, _ = expand_python_targets([str(outer / 'proj')]) self.assertEqual({Path(t).name for t in targets}, {'a.py'}) + def test_no_ignore_includes_root_gitignored_files(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + (root / '.git').mkdir() + (root / '.gitignore').write_text('generated/\n*_pb2.py\n', encoding='utf-8') + src = root / 'src' + (src / 'generated').mkdir(parents=True) + (src / 'a.py').write_text('x = 1\n', encoding='utf-8') + (src / 'proto_pb2.py').write_text('x = 1\n', encoding='utf-8') + (src / 'generated' / 'g.py').write_text('x = 1\n', encoding='utf-8') + targets, _ = expand_python_targets( + [str(src)], respect_gitignore=False, + ) + self.assertEqual( + {Path(t).name for t in targets}, + {'a.py', 'proto_pb2.py', 'g.py'}, + ) + class TestBaselineFingerprints(unittest.TestCase):