diff --git a/README.md b/README.md index 8c27c82a3..5ed04bd92 100644 --- a/README.md +++ b/README.md @@ -633,6 +633,7 @@ graphify-out/ /graphify ./raw # run on a specific folder /graphify ./raw --mode deep # more aggressive relationship extraction graphify extract ./raw --code-only # index code only — local AST, no API key (skips docs/PDFs/images); an `extract` flag, not a skill flag +graphify extract ./raw --html-as-code # treat .html as code (embedded \n" + ) + return repo + + +def _run(repo: Path, *extra: str): + env = {k: v for k, v in os.environ.items() if k not in _KEY_VARS} + env["GRAPHIFY_OUT"] = str(repo / "graphify-out") + return subprocess.run( + [PYTHON, "-m", "graphify", "extract", ".", "--code-only", "--no-cluster", *extra], + cwd=repo, capture_output=True, text=True, env=env, + ) + + +def _labels(repo: Path) -> list[str]: + graph = json.loads((repo / "graphify-out" / "graph.json").read_text()) + return [n.get("label") for n in graph["nodes"]] + + +def test_html_as_code_off_by_default(tmp_path): + """--code-only alone: .html is a document, skipped entirely (like any other + doc under --code-only) — no embedded JS symbol appears in the graph.""" + repo = _html_repo(tmp_path) + r = _run(repo) + assert r.returncode == 0, r.stderr + labels = _labels(repo) + assert any(str(l).startswith("hello") for l in labels), "python code was indexed" + assert not any("greet" in str(l) for l in labels), ( + "embedded JS must not appear without --html-as-code (opt-in contract, #1230)" + ) + + +def test_html_as_code_flag_extracts_embedded_js(tmp_path): + repo = _html_repo(tmp_path) + r = _run(repo, "--html-as-code") + assert r.returncode == 0, r.stderr + labels = _labels(repo) + assert any("greet" in str(l) for l in labels), "embedded JS was not extracted" + + +def test_extract_usage_advertises_html_as_code(tmp_path): + r = subprocess.run( + [PYTHON, "-m", "graphify", "extract"], + cwd=tmp_path, capture_output=True, text=True, + ) + assert r.returncode != 0 + assert "--html-as-code" in r.stdout + r.stderr, ( + "extract usage must advertise --html-as-code so the option is discoverable" + ) + + +def test_html_as_code_setting_persists_across_flagless_extract(tmp_path): + """Mirrors the --no-gitignore persistence contract (#1971): once + --html-as-code is set, a later flag-less `graphify extract` (the shape + `graphify update`/`watch`/hooks reuse) must not silently drop .html back + to the document path.""" + repo = _html_repo(tmp_path) + + r1 = _run(repo, "--html-as-code") + assert r1.returncode == 0, r1.stderr + assert any("greet" in str(l) for l in _labels(repo)) + + r2 = _run(repo, "--force") # flag-less re-extract, full rescan + assert r2.returncode == 0, r2.stderr + assert any("greet" in str(l) for l in _labels(repo)), ( + "flag-less re-extract clobbered the persisted --html-as-code setting" + ) + + +def test_html_as_code_persistence_survives_update_rebuild(tmp_path): + """The real-world path #1971-style persistence exists for: `graphify update` + (AST-only incremental rebuild) must also honor the persisted flag, not just + a second `graphify extract` invocation.""" + repo = _html_repo(tmp_path) + r1 = _run(repo, "--html-as-code") + assert r1.returncode == 0, r1.stderr + + env = {k: v for k, v in os.environ.items() if k not in _KEY_VARS} + env["GRAPHIFY_OUT"] = str(repo / "graphify-out") + r2 = subprocess.run( + [PYTHON, "-m", "graphify", "update"], + cwd=repo, capture_output=True, text=True, env=env, + ) + assert r2.returncode == 0, r2.stderr + assert any("greet" in str(l) for l in _labels(repo)), ( + "graphify update dropped the persisted --html-as-code setting (#1230)" + ) diff --git a/tests/test_html_extraction.py b/tests/test_html_extraction.py new file mode 100644 index 000000000..fd41c545c --- /dev/null +++ b/tests/test_html_extraction.py @@ -0,0 +1,166 @@ +"""Tests for `.html` extraction (#1230 — opt-in via `--html-as-code`). + +`.html` is DOCUMENT by default: `graphify.detect.classify_file` only routes it +through the local AST path when `html_as_code=True`. `extract_html` mirrors +`extract_vue`/`extract_svelte`/`extract_astro`: mask everything outside inline, +JS-typed ` + + +""", + ) + result = extract_html(page) + labels = _labels(result, callable_only=True) + assert "setMode()" in labels + assert "draw()" in labels + + # Line numbers refer to the HOST .html file, not a stripped-down script-only + # buffer — masking preserves newlines precisely so this holds. + set_mode = next(n for n in result["nodes"] if n["label"] == "setMode()") + assert set_mode["source_location"] == "L5" + + calls = _calls(result) + draw_id = next(n["id"] for n in result["nodes"] if n["label"] == "draw()") + set_mode_id = set_mode["id"] + assert (draw_id, set_mode_id) in calls + + +def test_extract_html_covers_multiple_script_blocks(tmp_path): + page = _write( + tmp_path / "multi.html", + """ + +

markup between the two blocks

+ + +""", + ) + result = extract_html(page) + labels = _labels(result, callable_only=True) + assert "first()" in labels + assert "second()" in labels + + +def test_extract_html_no_script_block_yields_only_file_node(tmp_path): + """Edge case explicitly called out in #1230: a plain .html with no \n', + ) + result = extract_html(page) + assert len(result["nodes"]) == 1 + assert result["edges"] == [] + + +def test_extract_html_skips_non_js_script_type(tmp_path): + """A + + +""", + ) + result = extract_html(page) + labels = _labels(result, callable_only=True) + assert labels == {"real()"} + + +def test_extract_html_mixed_inline_and_external_scripts(tmp_path): + page = _write( + tmp_path / "mixed.html", + """ + + + +""", + ) + result = extract_html(page) + labels = _labels(result, callable_only=True) + assert labels == {"local()"}