Option C Tools
Loading...
Searching...
No Matches
source_exporter.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/tools/source_exporter.py
4
5"""
6Purpose
7-------
8Provide a unified, Option C‑compliant source‑exporting tool that collects,
9combines, and consolidates all source‑code files in a project into structured
10inspection files. This enables fast review, debugging, auditing, and
11cross‑project comparison.
12
13Responsibilities
14----------------
15- Walk a project directory tree while respecting exclusion rules.
16- Collect all source files with supported extensions.
17- Generate per‑directory `_source.<path>.txt` files.
18- Generate a root‑level `_full_source.<root>.txt` file.
19- Support `--clean` to remove all `_source_code-*` directories.
20- Support `--verbose` to print per‑file statistics.
21- Integrate cleanly with the OCT CLI as `oct export-source`.
22- Handle I/O errors (including Windows MAX_PATH) gracefully: warn and continue.
23
24Diagnostics
25-----------
26Domain: OCT-EXPORTER
27Levels:
28 L2 — lifecycle
29 L3 — semantic details
30 L4 — deep tracing
31
32Contracts
33---------
34- Must not modify project files except inside `_source_code-*` directories.
35- Must accept any directory as a valid export root.
36- Must remain deterministic and safe for large projects.
37- Must not require external dependencies beyond the standard library.
38- Must never crash on a recoverable I/O error; warn and continue instead.
39"""
40
41import fnmatch
42import hashlib
43import json
44import os
45import re
46import sys
47import shutil
48from datetime import datetime
49from pathlib import Path
50from typing import Dict, List, Tuple
51
52
53# ============================================================
54# OI-416: content-level secret scanning
55# ============================================================
56
57# Compiled once; matches ``name = "value"`` / ``name: "value"`` style
58# assignments on a single line. Multi-line heredocs and concatenation are
59# intentionally out of scope — this is a "shallow guard" before upload.
60_SECRET_ASSIGN_RE = re.compile(
61 r'(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*'
62 r'(?:"(?P<dq>[^"\n]{4,})"|\'(?P<sq>[^\'\n]{4,})\')'
63)
64
65
66def _scan_content_for_secrets(content: str, rel_path: str) -> list[str]:
67 """Scan ``content`` for likely hardcoded secrets. Returns warning strings.
68
69 Uses the same name-heuristics as the linter (``_SECRET_NAME_PATTERNS``).
70 Empty list means no warnings. The ``rel_path`` is embedded in each
71 warning for user attribution.
72 """
73 try:
74 # OI-514: scanner moved to a standalone module; source_exporter no
75 # longer pulls in the whole linter just to reach the heuristics.
76 from oct.tools.secret_scanner import _name_matches_secret
77 except Exception:
78 return []
79
80 warnings: list[str] = []
81 for lineno, line in enumerate(content.splitlines(), 1):
82 for m in _SECRET_ASSIGN_RE.finditer(line):
83 name = m.group("name")
84 if _name_matches_secret(name):
85 warnings.append(
86 f"{rel_path}:{lineno}: potential secret '{name}'"
87 )
88 break # one warning per line is enough
89 return warnings
90
91
92# Cache of per-file secret scan results, keyed by absolute path. Used to
93# dedup warnings between the per-directory and full-project passes while
94# still allowing both passes to make block decisions.
95_secret_scan_cache: dict[str, list[str]] = {}
96# Paths whose warnings have already been emitted to stderr this run.
97_scanned_secret_paths: set[str] = set()
98
99
100def _get_cached_secret_warnings(abs_path: str, content: str, rel_path: str) -> list[str]:
101 """Return (cached or computed) secret warnings for ``abs_path``."""
102 if abs_path not in _secret_scan_cache:
103 _secret_scan_cache[abs_path] = _scan_content_for_secrets(content, rel_path)
104 return _secret_scan_cache[abs_path]
105
106
107# ============================================================
108# Configuration
109# ============================================================
110
111_EXTENSION_PROFILES = {
112 "python": [".py"],
113 "web": [".js", ".ts", ".tsx", ".css", ".html"],
114 "jvm": [".java", ".kt"],
115 "systems": [".rs", ".go", ".cpp", ".c", ".h", ".hpp"],
116 "config": [".json", ".yaml", ".yml", ".toml", ".ini", ".xml"],
117 "docs": [".md"],
118 "scripts": [".sh", ".bat", ".ps1", ".ahk"],
119 "misc": [".npmignore", ".gitignore", ".sql", ".rb", ".swift"],
120}
121
122INCLUDE_EXTENSIONS = [ext for exts in _EXTENSION_PROFILES.values() for ext in exts]
123
124from oct.core.exclusions import EXPORTER_EXCLUDE_DIRS as EXCLUDE_DIRS
125from oct.core.exclusions import WILDCARD_EXCLUDE_DIRS
126from oct.core.exclusions import NEVER_EXPORT_FILE_PATTERNS
127
128HEADER_TEXT = (
129 "This file contains all source-code files in the directory {directory} "
130 "(and its subdirectories) of this project.\n"
131 "Sections are separated as follows:\n"
132 "- '##### File name XX: <path>' marks individual files.\n"
133 "- '####### Subdirectory name XX: <dirname>' marks combined subdirectory sources.\n"
134 "- Root-level files use './<filename>' for clarity.\n\n"
135)
136
137SEPARATOR_FILE = "##### File name {num:02d}: {path}\n"
138SEPARATOR_SUBDIR = "####### Subdirectory name {num:02d}: {dirname}\n"
139ROOT_HEADER = "####### Root directory: .\n\n"
140
141# Windows MAX_PATH guard: keep well below the 260-character hard limit
142_MAX_SAFE_PATH_LEN = 240
143
144_CONFIG_FILENAME = "_oct_exporter_config.json"
145
146
147# ============================================================
148# Helpers
149# ============================================================
150
151def load_exporter_config(root: Path) -> dict:
152 """Load optional per-project config, merging with module-level defaults."""
153 cfg = {
154 "include_extensions": list(INCLUDE_EXTENSIONS),
155 "exclude_dirs": set(EXCLUDE_DIRS),
156 "wildcard_exclude_dirs": list(WILDCARD_EXCLUDE_DIRS),
157 "header_text": HEADER_TEXT,
158 "separator_file": SEPARATOR_FILE,
159 "separator_subdir": SEPARATOR_SUBDIR,
160 "root_header": ROOT_HEADER,
161 "max_safe_path_len": _MAX_SAFE_PATH_LEN,
162 }
163
164 config_path = root / _CONFIG_FILENAME
165 if not config_path.is_file():
166 # Fall back to oct's own bundled config as the default.
167 _oct_root = Path(__file__).resolve().parent.parent.parent
168 fallback_path = _oct_root / _CONFIG_FILENAME
169 if fallback_path.is_file() and fallback_path != config_path:
170 print(f" Note: No {_CONFIG_FILENAME} in {root}; "
171 f"using oct default from {fallback_path}.",
172 file=sys.stderr)
173 config_path = fallback_path
174 else:
175 print(f" Warning: No {_CONFIG_FILENAME} found; using built-in defaults.",
176 file=sys.stderr)
177 return cfg
178
179 try:
180 raw = json.loads(config_path.read_text(encoding="utf-8"))
181 except json.JSONDecodeError as e:
182 print(f" Warning: {_CONFIG_FILENAME} has invalid JSON ({e}); using defaults.",
183 file=sys.stderr)
184 return cfg
185
186 if not isinstance(raw, dict):
187 print(f" Warning: {_CONFIG_FILENAME} root must be a JSON object; using defaults.",
188 file=sys.stderr)
189 return cfg
190
191 print(f"Using {_CONFIG_FILENAME}")
192
193 def _merge_collection(key, default, collection_type):
194 """Apply replace / add / remove semantics for a collection-type key."""
195 add_key = f"{key}_add"
196 remove_key = f"{key}_remove"
197
198 if key in raw:
199 result = collection_type(raw[key])
200 print(f" {key}: replaced -> {sorted(result)}")
201 else:
202 result = collection_type(default)
203
204 if add_key in raw:
205 added = raw[add_key]
206 if collection_type is set:
207 result = result | set(added)
208 else:
209 result = result + [v for v in added if v not in result]
210 print(f" {key}: added {added}")
211
212 if remove_key in raw:
213 removed = raw[remove_key]
214 removals = set(removed)
215 if collection_type is set:
216 result = result - removals
217 else:
218 result = [v for v in result if v not in removals]
219 print(f" {key}: removed {removed}")
220
221 return result
222
223 cfg["include_extensions"] = _merge_collection(
224 "include_extensions", INCLUDE_EXTENSIONS, list
225 )
226 cfg["exclude_dirs"] = _merge_collection(
227 "exclude_dirs", EXCLUDE_DIRS, set
228 )
229 cfg["wildcard_exclude_dirs"] = _merge_collection(
230 "wildcard_exclude_dirs", WILDCARD_EXCLUDE_DIRS, list
231 )
232
233 for scalar_key in ("header_text", "separator_file", "separator_subdir",
234 "root_header", "max_safe_path_len"):
235 if scalar_key in raw:
236 cfg[scalar_key] = raw[scalar_key]
237 print(f" {scalar_key}: overridden -> {raw[scalar_key]!r}")
238
239 return cfg
240
241
242def should_skip_dirname(dirname: str, cfg: dict) -> bool:
243 name = dirname.lower()
244 if name in cfg["exclude_dirs"]:
245 return True
246 return any(pattern in name for pattern in cfg["wildcard_exclude_dirs"])
247
248
249def _is_never_export_file(filename: str) -> bool:
250 """Check if a file matches NEVER_EXPORT_FILE_PATTERNS (§6b Secret Hygiene)."""
251 name_lower = filename.lower()
252 for pattern in NEVER_EXPORT_FILE_PATTERNS:
253 if fnmatch.fnmatch(name_lower, pattern.lower()):
254 return True
255 return False
256
257
258def collect_recursive_files(folder: Path, cfg: dict) -> List[Path]:
259 collected = []
260 for root, dirs, files in os.walk(folder):
261 root_path = Path(root)
262 if should_skip_dirname(root_path.name, cfg):
263 dirs[:] = []
264 continue
265 dirs[:] = [d for d in dirs if not should_skip_dirname(d, cfg)]
266 for f in files:
268 continue
269 if any(f.endswith(ext) for ext in cfg["include_extensions"]):
270 collected.append(root_path / f)
271 return sorted(collected)
272
273
274def get_file_stats(path: Path) -> Tuple[int, int, int]:
275 try:
276 content = path.read_text(encoding="utf-8", errors="replace")
277 except (OSError, UnicodeDecodeError):
278 print(f"Warning: Could not read {path}: skipping", file=sys.stderr)
279 return 0, 0, 0
280 lines = content.count("\n") + 1 if content else 0
281 words = len(content.split()) if content else 0
282 try:
283 bytes_ = path.stat().st_size
284 except OSError:
285 bytes_ = len(content.encode("utf-8"))
286 return lines, words, bytes_
287
288
289def walk_included_dirs(start: Path, cfg: dict):
290 for root, dirs, _ in os.walk(start, topdown=True):
291 root_path = Path(root)
292 if should_skip_dirname(root_path.name, cfg):
293 dirs[:] = []
294 continue
295 dirs[:] = [d for d in dirs if not should_skip_dirname(d, cfg)]
296 yield root_path
297
298
299def clean_source_dirs(root: Path, dry_run: bool = False) -> None:
300 removed = 0
301 for dirpath, dirs, _ in os.walk(root, topdown=True):
302 for d in list(dirs):
303 if d.startswith("_source_code-"):
304 target = Path(dirpath) / d
305 if dry_run:
306 print(f"[DRY RUN] Would remove: {target}")
307 else:
308 shutil.rmtree(target, ignore_errors=True)
309 dirs.remove(d)
310 removed += 1
311 if dry_run:
312 print(f"[DRY RUN] Would remove {removed} _source_code-* directories under {root}")
313 else:
314 print(f"Removed {removed} _source_code-* directories under {root}")
315
316
317def clean_pycache_dirs(root: Path, dry_run: bool = False) -> None:
318 """Remove all __pycache__ directories under root."""
319 removed = 0
320 for dirpath, dirs, _ in os.walk(root, topdown=True):
321 for d in list(dirs):
322 if d == "__pycache__":
323 target = Path(dirpath) / d
324 if dry_run:
325 print(f"[DRY RUN] Would remove: {target}")
326 else:
327 shutil.rmtree(target, ignore_errors=True)
328 dirs.remove(d)
329 removed += 1
330 if dry_run:
331 print(f"[DRY RUN] Would remove {removed} __pycache__ directories under {root}")
332 else:
333 print(f"Removed {removed} __pycache__ directories under {root}")
334
335
336def _make_safe_output_path(out_dir: Path, dotpath: str, cfg: dict,
337 prefix: str = "_source") -> Path:
338 """
339 Build the output file path, truncating the dotpath if the full path would
340 exceed the Windows MAX_PATH limit (~260 chars). A short MD5 hash suffix is
341 appended when truncation is applied to ensure uniqueness.
342 """
343 max_len = cfg["max_safe_path_len"]
344 filename = f"{prefix}.{dotpath}.txt"
345 candidate = out_dir / filename
346 if len(str(candidate)) <= max_len:
347 return candidate
348 h = hashlib.md5(dotpath.encode()).hexdigest()[:8]
349 overhead = len(str(out_dir)) + len(f"{prefix}....{h}.txt") + 2 # 2 for sep + slack
350 max_dotpath_len = max_len - overhead
351 truncated = dotpath[:max(0, max_dotpath_len)].rstrip(".")
352 return out_dir / f"{prefix}.{truncated}...{h}.txt"
353
354
355# ============================================================
356# Core exporter logic
357# ============================================================
358
359def write_source_file_for_dir(folder: Path, output_path: Path, root: Path,
360 cfg: dict, warn_secrets: bool = False,
361 block_secrets: bool = False,
362 diff_filter: set[Path] | None = None):
363 files = collect_recursive_files(folder, cfg)
364 if diff_filter is not None:
365 files = [f for f in files if f.resolve() in diff_filter]
366 if not files:
367 return 0, 0, 0, 0, []
368
369 file_count = 0
370 total_lines = 0
371 total_words = 0
372 total_bytes = 0
373
374 try:
375 fh = output_path.open("w", encoding="utf-8")
376 except OSError as e:
377 print(f" Warning: cannot write '{output_path.name}': {e}")
378 return 0, 0, 0, 0, files
379
380 with fh as out:
381 out.write(cfg["header_text"].format(directory=folder))
382 out.write(cfg["root_header"])
383
384 for idx, file_path in enumerate(files, start=1):
385 rel_path = file_path.relative_to(folder)
386
387 try:
388 content = file_path.read_text(encoding="utf-8", errors="replace")
389 except (OSError, UnicodeDecodeError):
390 print(f"Warning: Could not read {file_path}: skipping", file=sys.stderr)
391 content = ""
392
393 # OI-416: content-level secret scanning
394 if warn_secrets and content:
395 abs_key = str(file_path.resolve())
396 rel_from_root = (
397 file_path.relative_to(root)
398 if file_path.is_relative_to(root) else rel_path
399 )
400 sec_warnings = _get_cached_secret_warnings(
401 abs_key, content, str(rel_from_root)
402 )
403 if abs_key not in _scanned_secret_paths:
404 _scanned_secret_paths.add(abs_key)
405 for w in sec_warnings:
406 print(f"Warning: {w}", file=sys.stderr)
407 if block_secrets and sec_warnings:
408 print(
409 f" Skipping {rel_from_root} due to potential secrets",
410 file=sys.stderr,
411 )
412 continue
413
414 out.write(cfg["separator_file"].format(num=idx, path=str(rel_path)))
415 out.write(content)
416 out.write("\n\n")
417
418 file_count += 1
419 total_bytes += file_path.stat().st_size
420 if content:
421 total_lines += content.count("\n") + 1
422 total_words += len(content.split())
423
424 return file_count, total_lines, total_words, total_bytes, files
425
426
427# ============================================================
428# Public entry point used by OCT CLI
429# ============================================================
430
431def run_exporter(root: Path, args: list[str]) -> None:
432 """
433 Execute the source exporter with the given root directory and arguments.
434 """
435 do_clean = "--clean" in args
436 verbose = "--verbose" in args
437 single_dir_mode = "--single-dir" in args
438 warn_secrets = "--warn-secrets" in args or "--block-secrets" in args
439 block_secrets = "--block-secrets" in args
440
441 # Phase 4E — G-E6: --diff REF support.
442 diff_ref: str | None = None
443 for arg in args:
444 if arg.startswith("--diff="):
445 diff_ref = arg.split("=", 1)[1]
446 break
447
448 # OI-416: reset scan caches at the start of each run so repeated
449 # invocations (e.g. from tests) emit warnings independently.
450 _scanned_secret_paths.clear()
451 _secret_scan_cache.clear()
452
453 if do_clean:
455 return
456
457 # Pre-compute diff filter set when --diff is active.
458 diff_filter: set[Path] | None = None
459 if diff_ref:
460 try:
461 from oct.core.git import git_diff_name_only
462 diff_paths = git_diff_name_only(root, diff_ref)
463 diff_filter = set(diff_paths)
464 if not diff_filter:
465 print(f"No files changed since {diff_ref}. Nothing to export.", file=sys.stderr)
466 return
467 print(f"Exporting {len(diff_filter)} file(s) changed since {diff_ref}.", file=sys.stderr)
468 except Exception as exc:
469 print(f"Warning: --diff failed ({exc}). Exporting all files.", file=sys.stderr)
470
471 cfg = load_exporter_config(root)
472
473 timestamp = datetime.now().strftime("%Y%m%d-%H%M")
474 root_name = root.name
475
476 summary: Dict[str, Tuple[int, int, int, int]] = {}
477 per_dir_files: Dict[str, List[Path]] = {}
478 skipped: List[str] = []
479
480 # In single-dir mode, all output goes under one root-level directory
481 single_out_root: Path | None = None
482 if single_dir_mode:
483 single_out_root = root / f"_source_code-{timestamp}"
484 try:
485 single_out_root.mkdir(parents=True, exist_ok=True)
486 except OSError as e:
487 print(f" Error: cannot create single output dir: {e}")
488 return
489
490 # Per-directory exports
491 for folder in walk_included_dirs(root, cfg):
492 if should_skip_dirname(folder.name, cfg):
493 continue
494
495 if single_dir_mode:
496 rel_to_root = folder.relative_to(root) if folder != root else Path(".")
497 out_dir = single_out_root / rel_to_root
498 else:
499 out_dir = folder / f"_source_code-{timestamp}"
500
501 try:
502 out_dir.mkdir(parents=True, exist_ok=True)
503 except OSError as e:
504 rel_err = "." if folder == root else str(folder.relative_to(root))
505 print(f" Warning: cannot create output dir for '{folder.name}': {e}")
506 skipped.append(rel_err)
507 continue
508
509 rel = "." if folder == root else str(folder.relative_to(root))
510 dotpath = root_name if folder == root else f"{root_name}.{rel.replace(os.sep, '.')}"
511 out_path = _make_safe_output_path(out_dir, dotpath, cfg)
512
513 try:
514 file_count, total_lines, total_words, total_bytes, files = write_source_file_for_dir(
515 folder, out_path, root, cfg,
516 warn_secrets=warn_secrets, block_secrets=block_secrets,
517 diff_filter=diff_filter,
518 )
519 except Exception as e:
520 print(f" Warning: export failed for '{rel}': {e}")
521 skipped.append(rel)
522 continue
523
524 summary[rel] = (file_count, total_lines, total_words, total_bytes)
525 per_dir_files[rel] = files
526
527 # Full project export
528 if single_dir_mode:
529 root_out_dir = single_out_root
530 else:
531 root_out_dir = root / f"_source_code-{timestamp}"
532 full_output_path = _make_safe_output_path(root_out_dir, root_name, cfg,
533 prefix="_full_source")
534
535 all_files = collect_recursive_files(root, cfg)
536 if diff_filter is not None:
537 all_files = [f for f in all_files if f.resolve() in diff_filter]
538 grouped: Dict[str, List[Path]] = {}
539 for p in all_files:
540 parent_rel = p.parent.relative_to(root)
541 key = "." if str(parent_rel) == "." else str(parent_rel)
542 grouped.setdefault(key, []).append(p)
543
544 try:
545 fh = full_output_path.open("w", encoding="utf-8")
546 except OSError as e:
547 print(f" Warning: cannot write full-source file: {e}")
548 fh = None
549
550 if fh is not None:
551 with fh as out:
552 out.write(cfg["header_text"].format(directory=root))
553 out.write(cfg["root_header"])
554
555 for idx_dir, dirname in enumerate(sorted(grouped.keys()), start=1):
556 out.write(cfg["separator_subdir"].format(num=idx_dir, dirname=dirname))
557 for idx_file, file_path in enumerate(grouped[dirname], start=1):
558 rel_path = file_path.relative_to(root)
559 try:
560 content = file_path.read_text(encoding="utf-8", errors="replace")
561 except (OSError, UnicodeDecodeError):
562 print(f"Warning: Could not read {file_path}: skipping", file=sys.stderr)
563 content = ""
564
565 # OI-416: content-level secret scanning (dedup with per-dir pass).
566 if warn_secrets and content:
567 abs_key = str(file_path.resolve())
568 sec_warnings = _get_cached_secret_warnings(
569 abs_key, content, str(rel_path)
570 )
571 if abs_key not in _scanned_secret_paths:
572 _scanned_secret_paths.add(abs_key)
573 for w in sec_warnings:
574 print(f"Warning: {w}", file=sys.stderr)
575 if block_secrets and sec_warnings:
576 print(
577 f" Skipping {rel_path} due to potential secrets",
578 file=sys.stderr,
579 )
580 continue
581
582 out.write(cfg["separator_file"].format(num=idx_file, path=str(rel_path)))
583 out.write(content)
584 out.write("\n\n")
585
586 # Summary
587 print("\n========== SUMMARY ==========")
588
589 seen_files = set()
590
591 for dirname in sorted(summary.keys()):
592 files, lines, words, bytes_ = summary[dirname]
593 kb = bytes_ / 1024.0
594 print(f"Directory '{dirname}': {files} files, {lines} lines, {words} words, {kb:.1f} KB")
595
596 if verbose:
597 for p in per_dir_files.get(dirname, []):
598 if p not in seen_files:
599 seen_files.add(p)
600 flines, fwords, fbytes = get_file_stats(p)
601 fkb = fbytes / 1024.0
602 rel_path = p.relative_to(root)
603 print(f" {rel_path} — {flines} lines, {fwords} words, {fkb:.1f} KB")
604
605 # Grand total: each per-directory entry counts files recursively, so summing
606 # them all would multiply each file by the number of ancestor directories it
607 # has. Use the root entry instead — it already covers the full project.
608 # Fall back to counting unique files directly if root was skipped due to error.
609 if "." in summary:
610 total_files, total_lines, total_words, total_bytes = summary["."]
611 else:
612 total_files = total_lines = total_words = total_bytes = 0
613 for p in all_files:
614 flines, fwords, fbytes = get_file_stats(p)
615 total_files += 1
616 total_lines += flines
617 total_words += fwords
618 total_bytes += fbytes
619
620 print(f"\nTotal directories processed: {len(summary)}")
621 print(f"Total source files processed: {total_files}")
622 print(f"Total lines: {total_lines}")
623 print(f"Total words: {total_words}")
624 print(f"Total size: {total_bytes / 1024.0:.1f} KB")
625 if single_dir_mode:
626 print(f"Output stored in single directory: {single_out_root}")
627 else:
628 print(f"Output stored under _source_code-{timestamp} directories")
629
630 if skipped:
631 n = len(skipped)
632 print(f"\nSkipped {n} director{'y' if n == 1 else 'ies'} due to errors:")
633 for s in skipped:
634 print(f" - {s}")
635
636 print("=============================\n")
bool should_skip_dirname(str dirname, dict cfg)
list[str] _scan_content_for_secrets(str content, str rel_path)
List[Path] collect_recursive_files(Path folder, dict cfg)
Tuple[int, int, int] get_file_stats(Path path)
bool _is_never_export_file(str filename)
dict load_exporter_config(Path root)
walk_included_dirs(Path start, dict cfg)
None clean_pycache_dirs(Path root, bool dry_run=False)
list[str] _get_cached_secret_warnings(str abs_path, str content, str rel_path)
None clean_source_dirs(Path root, bool dry_run=False)
None run_exporter(Path root, list[str] args)
Path _make_safe_output_path(Path out_dir, str dotpath, dict cfg, str prefix="_source")
write_source_file_for_dir(Path folder, Path output_path, Path root, dict cfg, bool warn_secrets=False, bool block_secrets=False, set[Path]|None diff_filter=None)