diff --git a/backend/services/analyzer.py b/backend/services/analyzer.py index ae17858..e1a946a 100644 --- a/backend/services/analyzer.py +++ b/backend/services/analyzer.py @@ -14,7 +14,12 @@ def analyze(root): paths={e["path"].lower() for e in entries}; names={Path(e["path"]).name.lower() for e in entries} signals={ "readme":any(n.startswith("readme") for n in names), - "tests":any("/test" in p or p.startswith("test") or "/tests/" in p or p.startswith("tests/") for p in paths), + "tests":any( + any(part in {"test", "tests", "__tests__"} for part in p.split("/")[:-1]) + or Path(p).name.startswith(("test_", "test.")) + or Path(p).name.endswith(("_test.py", "_test.go", ".test.js", ".spec.js", ".test.ts", ".spec.ts", ".test.tsx", ".spec.tsx")) + for p in paths + ), "gitignore":".gitignore" in names, "manifest":any(n in names for n in ["package.json","requirements.txt","pyproject.toml","pom.xml","cargo.toml","go.mod","composer.json","gemfile"]), "ci":any(p.startswith(".github/workflows/") for p in paths), @@ -70,10 +75,10 @@ def build_context(root,q,max_files=8,max_chars=18000): tokens={x for x in re.findall(r"[\w-]+", q.casefold()) if len(x)>2}; ranked=[] for p in iter_files(root): text=read_text(p); rel=str(p.relative_to(root)).replace("\\","/") - low=(rel+"\n"+text[:30000]).casefold() + low=(rel+"\n"+text[:4000]).casefold() matched=sum(t in low for t in tokens) bonus=sum(k in rel.casefold() for k in ["readme","app.","main.","index.","route","api"]) - if matched or bonus: ranked.append((matched,bonus,rel,text)) + if matched or (not q.strip() and bonus): ranked.append((matched,bonus,rel,text)) ranked.sort(key=lambda x: (-x[0], -x[1], x[2].casefold(), x[2])); out=""; used=0; selected=0 for _,_,rel,text in ranked: block=f"\n--- FILE: {rel} ---\n{text[:4000]}\n" diff --git a/backend/services/github.py b/backend/services/github.py index 3e28e49..420a511 100644 --- a/backend/services/github.py +++ b/backend/services/github.py @@ -427,7 +427,7 @@ def iter_files(root: Path): try: size = p.stat().st_size except OSError: continue if size > 300_000: continue - if p.suffix.lower() in TEXT_EXTENSIONS or p.name.lower() in {"dockerfile","makefile",".gitignore"}: + if p.suffix.lower() in TEXT_EXTENSIONS or p.name.lower().endswith(".env.example") or p.name.lower() in {"dockerfile","makefile",".gitignore"}: yield p def read_text(path: Path): return path.read_text(encoding="utf-8", errors="replace") diff --git a/backend/tests/test_analyzer_edge_cases.py b/backend/tests/test_analyzer_edge_cases.py index 569b845..9f2fcd0 100644 --- a/backend/tests/test_analyzer_edge_cases.py +++ b/backend/tests/test_analyzer_edge_cases.py @@ -103,5 +103,22 @@ def test_context_budget_counts_included_not_considered_files(self): self.assertNotIn("--- FILE: a_large.py", context) + def test_context_selected_file_exposes_the_query_match(self): + self.write("a_hidden.py", "x" * 5000 + "needle_for_context") + self.write("z_visible.py", "needle_for_context = 42") + context = build_context(self.root, "needle_for_context", max_files=1) + self.assertIn("z_visible.py", context) + self.assertIn("needle_for_context = 42", context) + self.assertNotIn("a_hidden.py", context) + + + def test_unmatched_question_does_not_return_unrelated_readme(self): + self.write("README.md", "setup notes without the requested identifier") + self.write("app.py", "def start(): pass") + context = build_context(self.root, "nonexistent_unique_identifier") + self.assertIn("No strongly matching files", context) + self.assertNotIn("setup notes", context) + + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_repository_safety.py b/backend/tests/test_repository_safety.py index 4e3b728..1579c76 100644 --- a/backend/tests/test_repository_safety.py +++ b/backend/tests/test_repository_safety.py @@ -192,6 +192,30 @@ def test_zip_slip_is_refused_without_writing_outside(self): github.extract_zip(z) self.assertFalse((self.root / "escape.py").exists()) + def test_environment_examples_are_indexed_without_loading_env_secrets(self): + imported = self.repos / ("a" * 12) + imported.mkdir() + (imported / ".env.example").write_text("SAMPLE_OPTION=placeholder") + (imported / "service.env.example").write_text("SERVICE_OPTION=example") + (imported / ".env").write_text("REAL_PRIVATE_SETTING=secret") + listed = {p.name for p in github.iter_files(imported)} + self.assertIn(".env.example", listed) + self.assertIn("service.env.example", listed) + self.assertNotIn(".env", listed) + self.assertIn("SAMPLE_OPTION", build_context(imported, "SAMPLE_OPTION")) + self.assertNotIn("REAL_PRIVATE_SETTING", str(analyze(imported))) + + + def test_test_signal_ignores_unrelated_source_names(self): + imported = self.repos / ("b" * 12) + imported.mkdir() + (imported / "src").mkdir() + (imported / "src" / "testimonials.py").write_text("def describe(): pass") + (imported / "src" / "contest.py").write_text("def compete(): pass") + self.assertFalse(analyze(imported)["signals"]["tests"]) + (imported / "src" / "feature.test.ts").write_text("it('works', () => {})") + self.assertTrue(analyze(imported)["signals"]["tests"]) + if __name__ == "__main__": unittest.main() \ No newline at end of file