Skip to content

Commit 91acb63

Browse files
committed
fix: OPE-181/184/185 -- data safety + false positive framework detection
OPE-181: never overwrite hand-crafted AGENTS.md - _write_with_markers() now detects files without SAAR markers - If existing file has 5+ lines and no markers: skip with clear message - Interactive mode: asks user before overwriting - Non-interactive/CI: always skips, never silently destroys content - --force flag explicitly bypasses protection - Tested: PostHog's 78-line AGENTS.md now safely skipped OPE-185: skip examples/ and compiled/ from all analysis - Added to SKIP_DIRS: examples, example, demo, demos, playground, playgrounds, starters, templates, fixtures, test-fixtures - Added: compiled, bundles, vendor, vendors (OPE-184) - Fixes shadcn/Zustand false positives on vercel/next.js - Fixes Express false positive on storybookjs/react-native OPE-178: fix Express false positive from CommonJS require() - Was: r'^const\s+\w+\s*=\s*require\(' -- matches ANY require() call - Now: r'require\([\'"']express[\'"]')' -- matches only express import - Every JS file using CommonJS style was scoring as 'Express backend' - storybookjs/react-native: Backend: Express -> no backend (correct) Also fixed: OPE-179 (markers splice leading newline stripped) - after.lstrip('\n') prevents double blank lines below AUTO-END - Empty after section no longer adds trailing newline Tests: 457 passing
1 parent c8b90a0 commit 91acb63

5 files changed

Lines changed: 69 additions & 14 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "saar"
7-
version = "0.5.0"
7+
version = "0.5.1"
88
description = "Extract the essence of your codebase. Auto-generate AGENTS.md, CLAUDE.md, .cursorrules and more."
99
readme = "README.md"
1010
license = "MIT"

saar/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Saar -- extract the essence of your codebase."""
22

3-
__version__ = "0.5.0"
3+
__version__ = "0.5.1"

saar/cli.py

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -620,28 +620,75 @@ def _line_count(text: str) -> int:
620620
end_idx = existing.find(_MARKER_END)
621621

622622
if start_idx == -1 or end_idx == -1:
623-
# No markers -- file exists but was written before markers were introduced
624-
# (or is purely hand-written). Treat it like first write: prepend auto block.
625-
target.write_text(wrapped + "\n" + existing, encoding="utf-8")
623+
# No saar markers -- file exists but has never been written by saar.
624+
# This means it's either hand-crafted or written by another tool.
625+
# We must NOT silently overwrite hand-crafted files. (OPE-181)
626+
existing_lines = _line_count(existing)
627+
628+
if existing_lines >= 5 and not force:
629+
# Substantial hand-crafted file -- protect it.
630+
# In interactive mode: ask. In non-interactive: skip with clear message.
631+
import sys
632+
import os
633+
is_tty = sys.stdin.isatty() and sys.stdout.isatty()
634+
ci = any(os.environ.get(v) for v in [
635+
"CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL",
636+
"CIRCLECI", "TRAVIS", "BUILDKITE",
637+
])
638+
interactive = is_tty and not ci
639+
640+
if interactive:
641+
try:
642+
import questionary
643+
answer = questionary.confirm(
644+
f"{_display_path(target)} exists ({existing_lines} lines, "
645+
"not generated by saar). Overwrite it?",
646+
default=False,
647+
).ask()
648+
if not answer:
649+
console.print(
650+
f" [yellow]skipped[/yellow] {_display_path(target)} "
651+
"[dim]Use --force to overwrite.[/dim]"
652+
)
653+
return
654+
except Exception:
655+
# questionary not available -- fall through to skip
656+
console.print(
657+
f" [yellow]skipped[/yellow] {_display_path(target)} "
658+
f"[dim]({existing_lines} lines, not generated by saar). "
659+
"Use --force to overwrite.[/dim]"
660+
)
661+
return
662+
else:
663+
# Non-interactive (CI / --no-interview): never overwrite silently.
664+
console.print(
665+
f" [yellow]skipped[/yellow] {_display_path(target)} "
666+
f"[dim]({existing_lines} lines, not generated by saar). "
667+
"Use --force to overwrite.[/dim]"
668+
)
669+
return
670+
671+
# Either force=True, or the file is essentially empty (<5 lines).
672+
# Safe to write.
673+
target.write_text(wrapped, encoding="utf-8")
626674
console.print(
627-
f" [green]updated[/green] {_display_path(target)}"
628-
f" [dim]({_line_count(wrapped + existing)} lines)[/dim]"
675+
f" [green]wrote[/green] {_display_path(target)}"
676+
f" [dim]({_line_count(wrapped)} lines)[/dim]"
629677
)
630678
return
631679

632680
# Splice: keep everything before the start marker and after the end marker.
633-
# Use find() for END to get the first legitimate closing marker (the one that
634-
# belongs to this auto-block). Then strip any orphaned SAAR markers from the
635-
# preserved manual content -- these can accumulate from old runs or copy-paste.
681+
# Splice: keep before START + new auto block + after END.
636682
# rfind() would be wrong here: it would swallow manual content sitting between
637683
# the legitimate END and an orphaned END. (OPE-169)
638684
before = existing[:start_idx]
639685
after = existing[end_idx + len(_MARKER_END):]
640686

641-
# Strip any orphaned SAAR markers from the preserved manual section.
687+
# Strip orphaned SAAR markers and leading blank lines. (OPE-179)
642688
after = after.replace(_MARKER_START, "").replace(_MARKER_END, "")
689+
after = after.lstrip("\n")
643690

644-
final = before + wrapped + after
691+
final = before + wrapped + ("\n" + after if after.strip() else "")
645692
target.write_text(final, encoding="utf-8")
646693
console.print(
647694
f" [green]updated[/green] {_display_path(target)}"

saar/extractor.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,20 @@ class DNAExtractor:
6060
"node_modules", ".next", ".nuxt", ".svelte-kit",
6161
# build outputs
6262
"dist", "build", "out", "target",
63+
# compiled/vendored assets -- never source code (OPE-184)
64+
"compiled", "bundles", "vendor", "vendors",
6365
# test/coverage artifacts
6466
"coverage", ".pytest_cache", "htmlcov", ".nyc_output",
6567
# cloned repo dirs (the specific OCI case)
6668
"repos",
6769
# ide
6870
".idea", ".vscode",
71+
# example/demo apps -- not the primary project (OPE-185)
72+
# These directories contain demo applications that use different stacks.
73+
# Scanning them produces false positives (Express in a React Native lib,
74+
# shadcn/ui in the Next.js framework repo itself, etc.)
75+
"examples", "example", "demo", "demos", "playground", "playgrounds",
76+
"starters", "templates", "fixtures", "test-fixtures",
6977
}
7078

7179
# file suffixes that are never source code -- skip regardless of directory
@@ -302,7 +310,7 @@ def _detect_framework(self, files: List[Path]) -> Optional[str]:
302310
"flask": [r"^from flask\b", r"^import flask\b"],
303311
}
304312
js_indicators = {
305-
"express": [r"^const\s+\w+\s*=\s*require\("],
313+
"express": [r"require\(['\"]express['\"]\)", r"from ['\"]express['\"]"],
306314
"nextjs": [r"^import\s+.*from\s+.next/"],
307315
"nestjs": [r"^import\s+.*from\s+.@nestjs/"],
308316
}

tests/test_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class TestCLI:
1313
def test_version(self):
1414
result = runner.invoke(app, ["--version"])
1515
assert result.exit_code == 0
16-
assert "0.5.0" in result.stdout
16+
assert "0.5.1" in result.stdout
1717

1818
def test_help(self):
1919
result = runner.invoke(app, ["--help"])

0 commit comments

Comments
 (0)