| """Turn whatever the user gives us into a clean list of analysable files. |
| |
| Handles a ZIP upload, a single pasted/uploaded file, a folder path, or a |
| GitHub URL. Filters out noise (node_modules, .git, binaries, lockfiles), |
| enforces size caps, and — critically — runs a **secret scan** so we never |
| display or send API keys and passwords to the model. |
| """ |
| from __future__ import annotations |
|
|
| import os |
| import re |
| import tempfile |
| import zipfile |
| from dataclasses import dataclass, field |
|
|
| import config |
|
|
|
|
| @dataclass |
| class SourceFile: |
| path: str |
| abspath: str |
| text: str |
| lang: str |
|
|
|
|
| @dataclass |
| class Ingested: |
| name: str |
| files: list[SourceFile] = field(default_factory=list) |
| skipped: list[str] = field(default_factory=list) |
| secrets_found: list[str] = field(default_factory=list) |
| note: str = "" |
|
|
|
|
| |
| |
| |
| |
| _SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], None | str] = () |
| SECRET_RULES: list[tuple[str, re.Pattern[str]]] = [ |
| ("OpenAI / Anthropic-style key", re.compile(r"\b(sk-[A-Za-z0-9_-]{16,}|sk-ant-[A-Za-z0-9_-]{16,})")), |
| ("AWS access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), |
| ("Google API key", re.compile(r"\bAIza[0-9A-Za-z_-]{30,}\b")), |
| ("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")), |
| ("Slack token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")), |
| ("Private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), |
| |
| ("Hard-coded secret value", re.compile( |
| r"(?i)\b(password|passwd|secret|token|api[_-]?key|access[_-]?key)\b\s*[:=]\s*" |
| r"['\"][^'\"]{8,}['\"]")), |
| ] |
| _REDACTION = "«REDACTED-SECRET»" |
|
|
|
|
| def redact_secrets(text: str) -> tuple[str, list[str]]: |
| """Replace likely secrets with a placeholder. Returns (clean_text, hits).""" |
| hits: list[str] = [] |
| for label, pattern in SECRET_RULES: |
| if pattern.search(text): |
| hits.append(label) |
| text = pattern.sub(_REDACTION, text) |
| return text, hits |
|
|
|
|
| |
| def _lang_for(path: str) -> str | None: |
| _, ext = os.path.splitext(path.lower()) |
| if ext in config.BINARY_EXTS: |
| return None |
| return config.LANG_BY_EXT.get(ext) |
|
|
|
|
| def _should_skip_dir(name: str) -> bool: |
| return name in config.IGNORE_DIRS or name.startswith(".") or name == "__MACOSX" |
|
|
|
|
| def _iter_files(root: str): |
| """Yield (relpath, abspath) for candidate files under root, pruning noise.""" |
| for dirpath, dirnames, filenames in os.walk(root): |
| dirnames[:] = [d for d in dirnames if not _should_skip_dir(d)] |
| for fn in filenames: |
| if fn.lower() in config.IGNORE_FILES: |
| continue |
| abspath = os.path.join(dirpath, fn) |
| rel = os.path.relpath(abspath, root).replace(os.sep, "/") |
| yield rel, abspath |
|
|
|
|
| def _read(abspath: str) -> str | None: |
| try: |
| if os.path.getsize(abspath) > config.MAX_FILE_BYTES: |
| return None |
| with open(abspath, "r", encoding="utf-8", errors="ignore") as fh: |
| return fh.read() |
| except (OSError, ValueError): |
| return None |
|
|
|
|
| |
| def from_folder(root: str, name: str | None = None) -> Ingested: |
| """Analyse a directory already on disk.""" |
| out = Ingested(name=name or os.path.basename(os.path.normpath(root)) or "your project") |
| total = 0 |
| for rel, abspath in _iter_files(root): |
| lang = _lang_for(rel) |
| base = os.path.basename(rel).lower() |
| is_manifest = base in config.DEP_MANIFESTS |
| if lang is None and not is_manifest: |
| out.skipped.append(rel) |
| continue |
| text = _read(abspath) |
| if text is None: |
| out.skipped.append(rel) |
| continue |
| total += len(text.encode("utf-8", "ignore")) |
| if total > config.MAX_TOTAL_BYTES or len(out.files) >= config.MAX_FILES: |
| out.skipped.append(rel) |
| out.note = (f"Large project — analysed the first {len(out.files)} files; " |
| f"the rest are listed but not narrated.") |
| continue |
| clean, hits = redact_secrets(text) |
| for h in hits: |
| desc = f"{rel}: {h}" |
| if desc not in out.secrets_found: |
| out.secrets_found.append(desc) |
| out.files.append(SourceFile(path=rel, abspath=abspath, text=clean, |
| lang=lang or "other")) |
| out.files.sort(key=lambda f: (0 if f.lang in ("python", "javascript", "typescript") else 1, f.path)) |
| out.skipped.sort() |
| return out |
|
|
|
|
| def from_zip(zip_path: str, name: str | None = None) -> Ingested: |
| """Unzip to a temp dir and analyse it.""" |
| tmp = tempfile.mkdtemp(prefix="storycode_") |
| derived = name or os.path.splitext(os.path.basename(zip_path))[0] |
| with zipfile.ZipFile(zip_path) as zf: |
| for member in zf.namelist(): |
| |
| target = os.path.normpath(os.path.join(tmp, member)) |
| if not target.startswith(os.path.abspath(tmp) + os.sep) and target != os.path.abspath(tmp): |
| continue |
| if member.endswith("/"): |
| continue |
| os.makedirs(os.path.dirname(target), exist_ok=True) |
| try: |
| with zf.open(member) as src, open(target, "wb") as dst: |
| dst.write(src.read()) |
| except (OSError, zipfile.BadZipFile): |
| continue |
| |
| entries = [e for e in os.listdir(tmp) if not e.startswith("__MACOSX")] |
| root = tmp |
| if len(entries) == 1 and os.path.isdir(os.path.join(tmp, entries[0])): |
| root = os.path.join(tmp, entries[0]) |
| derived = name or entries[0] |
| return from_folder(root, name=derived) |
|
|
|
|
| def from_text(text: str, filename: str = "snippet.py") -> Ingested: |
| """Analyse a single pasted file.""" |
| lang = _lang_for(filename) or "other" |
| clean, hits = redact_secrets(text) |
| out = Ingested(name=filename) |
| out.files.append(SourceFile(path=filename, abspath="", text=clean, lang=lang)) |
| out.secrets_found = [f"{filename}: {h}" for h in hits] |
| return out |
|
|
|
|
| |
| _GH_URL_RE = re.compile( |
| r"https?://github\.com/(?P<owner>[^/\s]+)/(?P<repo>[^/\s]+?)(?:\.git)?" |
| r"(?:/(?:tree|blob)/(?P<branch>[^/\s]+))?" |
| r"(?:/(?P<path>[^\s]*))?" |
| r"\s*$" |
| ) |
|
|
|
|
| def parse_github_url(url: str) -> tuple[str, str, str] | None: |
| """Parse a GitHub URL into (owner, repo, branch). Returns None if invalid.""" |
| m = _GH_URL_RE.search(url.strip()) |
| if not m: |
| return None |
| owner = m.group("owner") |
| repo = m.group("repo") |
| branch = m.group("branch") or "HEAD" |
| return owner, repo, branch |
|
|
|
|
| def from_github(url: str, name: str | None = None) -> Ingested: |
| """Download a public GitHub repo and analyse it. |
| |
| Uses the GitHub archive endpoint (no git required). For large repos, |
| only the first MAX_FILES files are analysed; the rest are listed but |
| not narrated (handled by from_folder's size caps). |
| """ |
| import urllib.request |
|
|
| parsed = parse_github_url(url) |
| if not parsed: |
| return Ingested(name="invalid URL", note="Could not parse GitHub URL. Use: https://github.com/owner/repo") |
|
|
| owner, repo, branch = parsed |
| repo_name = name or f"{owner}/{repo}" |
|
|
| |
| archive_urls = [ |
| f"https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip", |
| f"https://github.com/{owner}/{repo}/archive/refs/heads/main.zip", |
| f"https://github.com/{owner}/{repo}/archive/refs/heads/master.zip", |
| ] |
|
|
| tmp = tempfile.mkdtemp(prefix="storycode_gh_") |
| zip_path = os.path.join(tmp, "repo.zip") |
|
|
| for archive_url in archive_urls: |
| try: |
| req = urllib.request.Request(archive_url, headers={"User-Agent": "StoryCode/1.0"}) |
| with urllib.request.urlopen(req, timeout=60) as resp: |
| data = resp.read() |
| with open(zip_path, "wb") as f: |
| f.write(data) |
| break |
| except (urllib.error.URLError, OSError): |
| continue |
| else: |
| return Ingested( |
| name=repo_name, |
| note="Could not download the repository. Make sure it's public and the URL is correct.", |
| ) |
|
|
| |
| with zipfile.ZipFile(zip_path) as zf: |
| for member in zf.namelist(): |
| target = os.path.normpath(os.path.join(tmp, member)) |
| if not target.startswith(os.path.abspath(tmp) + os.sep) and target != os.path.abspath(tmp): |
| continue |
| if member.endswith("/"): |
| continue |
| os.makedirs(os.path.dirname(target), exist_ok=True) |
| try: |
| with zf.open(member) as src, open(target, "wb") as dst: |
| dst.write(src.read()) |
| except (OSError, zipfile.BadZipFile): |
| continue |
|
|
| |
| entries = [e for e in os.listdir(tmp) if not e.startswith("__MACOSX") and e != "repo.zip"] |
| root = tmp |
| if len(entries) == 1 and os.path.isdir(os.path.join(tmp, entries[0])): |
| root = os.path.join(tmp, entries[0]) |
|
|
| return from_folder(root, name=repo_name) |
|
|