diff --git a/src/quant_advisor_research/advisory_report.py b/src/quant_advisor_research/advisory_report.py index a8910ac..09c405b 100644 --- a/src/quant_advisor_research/advisory_report.py +++ b/src/quant_advisor_research/advisory_report.py @@ -2,8 +2,11 @@ import argparse import datetime as dt +import hashlib import json import os +import re +import subprocess from collections import defaultdict from collections.abc import Mapping from dataclasses import dataclass @@ -96,6 +99,13 @@ AI_POLICY_ALLOWED_KEYS = frozenset({"execution_allowed", "portfolio_allocation_allowed", "downstream_use"}) AI_POLICY_FORBIDDEN_TERMS = frozenset({"live", "allocation", "broker", "execution", "order", "position", "account"}) AI_POLICY_BLOCKING_TERMS = frozenset({"blocked", "not allowed", "do not", "never", "no "}) +AI_SIGNAL_MANIFEST_NAME = "latest_signal.manifest.json" +AI_SIGNAL_MANIFEST_PATH = "data/output/latest_signal.manifest.json" +AI_SIGNAL_PATH = "data/output/latest_signal.json" +AI_SIGNAL_PRODUCER_REPOSITORY = "QuantStrategyLab/ResearchSignalContextPipelines" +AI_SIGNAL_PROVENANCE_WARNING = "ai_signal_provenance_untrusted" +SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") +GIT_SHA_PATTERN = re.compile(r"[0-9a-f]{40}") _UNAVAILABLE_INPUT = object() HORIZON_WINDOWS = { @@ -501,6 +511,114 @@ def load_ai_signal(path: str | Path | None, *, source_bytes: bytes | None = None return payload +def _ai_provenance_untrusted() -> None: + raise AISignalValidationError(AI_SIGNAL_PROVENANCE_WARNING) + + +def _git_output(repo: Path, *args: str) -> bytes: + result = subprocess.run( + ["git", "-C", str(repo), *args], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + if result.returncode != 0: + _ai_provenance_untrusted() + return result.stdout + + +def _repository_from_remote(remote: str) -> str: + value = remote.strip().removesuffix("/").removesuffix(".git") + for prefix in ("https://github.com/", "git@github.com:", "ssh://git@github.com/"): + if value.startswith(prefix): + return value.removeprefix(prefix) + return "" + + +def load_trusted_ai_signal( + path: str | Path | None, + *, + source_bytes: bytes | None = None, +) -> dict[str, Any] | None: + if path is None: + return None + signal_path = Path(path).resolve() + signal_bytes = source_bytes + if signal_bytes is None: + try: + signal_bytes = signal_path.read_bytes() + except OSError: + raise AISignalValidationError("ai_signal_unavailable") from None + payload = load_ai_signal(signal_path, source_bytes=signal_bytes) + if payload is None: + return None + manifest_path = signal_path.with_name(AI_SIGNAL_MANIFEST_NAME) + try: + manifest_bytes = manifest_path.read_bytes() + manifest = json.loads(manifest_bytes.decode("utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + _ai_provenance_untrusted() + if not isinstance(manifest, Mapping): + _ai_provenance_untrusted() + required_keys = { + "manifest_type", + "schema_version", + "artifact", + "as_of", + "generated_at", + "expires_at", + "mode", + "producer", + "input_digest", + "policy", + } + artifact = manifest.get("artifact") + producer = manifest.get("producer") + policy = manifest.get("policy") + if ( + set(manifest) != required_keys + or manifest.get("manifest_type") != "research_signal_context" + or manifest.get("schema_version") != 2 + or not isinstance(artifact, Mapping) + or set(artifact) != {"path", "sha256"} + or artifact.get("path") != AI_SIGNAL_PATH + or not isinstance(artifact.get("sha256"), str) + or SHA256_PATTERN.fullmatch(artifact["sha256"]) is None + or artifact["sha256"] != hashlib.sha256(signal_bytes).hexdigest() + or not isinstance(producer, Mapping) + or set(producer) != {"repository", "commit_sha"} + or producer.get("repository") != AI_SIGNAL_PRODUCER_REPOSITORY + or not isinstance(producer.get("commit_sha"), str) + or GIT_SHA_PATTERN.fullmatch(producer["commit_sha"]) is None + or not isinstance(manifest.get("input_digest"), str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", manifest["input_digest"]) is None + or not isinstance(policy, Mapping) + or set(policy) != {"execution_allowed"} + or policy.get("execution_allowed") is not False + or any(manifest.get(key) != payload.get(key) for key in ("as_of", "generated_at", "expires_at", "mode")) + ): + _ai_provenance_untrusted() + + try: + repo = Path(_git_output(signal_path.parent, "rev-parse", "--show-toplevel").decode("utf-8").strip()).resolve() + signal_relative = signal_path.relative_to(repo).as_posix() + manifest_relative = manifest_path.resolve().relative_to(repo).as_posix() + head = _git_output(repo, "rev-parse", "HEAD").decode("ascii").strip() + remote = _git_output(repo, "remote", "get-url", "origin").decode("utf-8").strip() + except (UnicodeError, ValueError): + _ai_provenance_untrusted() + if ( + signal_relative != AI_SIGNAL_PATH + or manifest_relative != AI_SIGNAL_MANIFEST_PATH + or GIT_SHA_PATTERN.fullmatch(head) is None + or _repository_from_remote(remote) != AI_SIGNAL_PRODUCER_REPOSITORY + or _git_output(repo, "show", f"{head}:{signal_relative}") != signal_bytes + or _git_output(repo, "show", f"{head}:{manifest_relative}") != manifest_bytes + ): + _ai_provenance_untrusted() + return payload + + def load_theme_momentum(path: str | Path | None, *, source_bytes: bytes | None = None) -> dict[str, Any] | None: if path is None: @@ -1721,7 +1839,7 @@ def build_advisory_report( ai_quality_warnings.append("ai_signal_unavailable") else: try: - candidate_ai_signal = load_ai_signal( + candidate_ai_signal = load_trusted_ai_signal( ai_signal_path, source_bytes=ai_bytes if isinstance(ai_bytes, bytes) else None, ) diff --git a/src/quant_advisor_research/build_pipeline.py b/src/quant_advisor_research/build_pipeline.py index 39a9d7d..65b0e05 100644 --- a/src/quant_advisor_research/build_pipeline.py +++ b/src/quant_advisor_research/build_pipeline.py @@ -11,9 +11,17 @@ from urllib.parse import quote from urllib.request import urlopen -from .advisory_report import build_advisory_report, render_markdown, write_json, write_text +from .advisory_report import ( + AISignalValidationError, + build_advisory_report, + load_trusted_ai_signal, + render_markdown, + write_json, + write_text, +) from .artifacts import write_report_manifest from .market_confirmation import ( + EXCLUDED_SYMBOLS, build_market_confirmation_rows, collect_symbols, load_proxy_urls, @@ -119,12 +127,29 @@ def build_market_confirmation_artifact( cache_max_age_days: int, ) -> Path: theme_momentum = load_theme_momentum(theme_momentum_path) if theme_momentum_path else None - symbols = collect_symbols( - political_watchlist_path=political_watchlist_path, - ai_signal_path=ai_signal_path, - theme_momentum=theme_momentum, - max_symbols=max_symbols, + ai_signal = None + if ai_signal_path: + try: + ai_signal = load_trusted_ai_signal(ai_signal_path) + except AISignalValidationError: + pass + symbols = set( + collect_symbols( + political_watchlist_path=political_watchlist_path, + ai_signal_path=None, + theme_momentum=theme_momentum, + max_symbols=2**31 - 1, + ) ) + if ai_signal: + for symbol in ai_signal.get("universe", []): + if isinstance(symbol, str) and symbol.strip(): + symbols.add(symbol.upper()) + for key in ("candidate_bias", "research_bias", "symbol_bias", "symbol_theme_exposure"): + value = ai_signal.get(key) + if isinstance(value, dict): + symbols.update(str(symbol).upper() for symbol in value if str(symbol).strip()) + symbols = sorted(symbols - EXCLUDED_SYMBOLS)[:max_symbols] proxy_urls = load_proxy_urls( proxy_list_path=proxy_list, proxy_urls_text=proxy_urls_text, diff --git a/tests/test_advisory_report.py b/tests/test_advisory_report.py index 7ca7ab8..4eb91de 100644 --- a/tests/test_advisory_report.py +++ b/tests/test_advisory_report.py @@ -1,7 +1,9 @@ from __future__ import annotations import datetime as dt +import hashlib import json +import subprocess from pathlib import Path import pytest @@ -25,6 +27,55 @@ ROOT = Path(__file__).resolve().parents[1] +def write_trusted_example_ai_signal(tmp_path: Path) -> Path: + repo = tmp_path / "ResearchSignalContextPipelines" + signal = repo / "data/output/latest_signal.json" + signal.parent.mkdir(parents=True) + signal.write_bytes((ROOT / "examples/research_signal_context.example.json").read_bytes()) + payload = json.loads(signal.read_text(encoding="utf-8")) + manifest = { + "manifest_type": "research_signal_context", + "schema_version": 2, + "artifact": { + "path": "data/output/latest_signal.json", + "sha256": hashlib.sha256(signal.read_bytes()).hexdigest(), + }, + "as_of": payload["as_of"], + "generated_at": payload["generated_at"], + "expires_at": payload["expires_at"], + "mode": payload["mode"], + "producer": { + "repository": "QuantStrategyLab/ResearchSignalContextPipelines", + "commit_sha": "a" * 40, + }, + "input_digest": f"sha256:{'b' * 64}", + "policy": {"execution_allowed": False}, + } + signal.with_name("latest_signal.manifest.json").write_text(json.dumps(manifest) + "\n", encoding="utf-8") + subprocess.run(["git", "init", "-q", repo], check=True) + subprocess.run( + ["git", "-C", repo, "remote", "add", "origin", "https://github.com/QuantStrategyLab/ResearchSignalContextPipelines.git"], + check=True, + ) + subprocess.run(["git", "-C", repo, "add", "data/output"], check=True) + subprocess.run( + [ + "git", + "-C", + repo, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-qm", + "trusted fixture", + ], + check=True, + ) + return signal + + def test_build_advisory_report_blocks_execution_and_allocation() -> None: report = build_advisory_report( as_of="2026-05-30", @@ -61,13 +112,13 @@ def test_low_confidence_events_remain_verify_source_until_verified() -> None: assert by_symbol["EVT1"]["evidence_score"] > by_symbol["EVT4"]["evidence_score"] -def test_ai_avoid_bias_defers_research_item() -> None: +def test_ai_avoid_bias_defers_research_item(tmp_path: Path) -> None: report = build_advisory_report( as_of="2026-05-30", cadence="monthly", political_events_path=ROOT / "examples/political_events.example.csv", political_watchlist_path=ROOT / "examples/political_watchlist.example.csv", - ai_signal_path=ROOT / "examples/research_signal_context.example.json", + ai_signal_path=write_trusted_example_ai_signal(tmp_path), ) by_symbol = {item["symbol"]: item for item in report["recommendations"]} @@ -138,13 +189,13 @@ def test_mixed_confidence_recommendation_is_not_tier_one(tmp_path: Path) -> None assert rec["recommendation_tier"] == "tier_2" -def test_long_horizon_window_is_measured_in_years() -> None: +def test_long_horizon_window_is_measured_in_years(tmp_path: Path) -> None: report = build_advisory_report( as_of="2026-05-30", cadence="weekly", political_events_path=ROOT / "examples/political_events.example.csv", political_watchlist_path=ROOT / "examples/political_watchlist.example.csv", - ai_signal_path=ROOT / "examples/research_signal_context.example.json", + ai_signal_path=write_trusted_example_ai_signal(tmp_path), ) by_symbol = {item["symbol"]: item for item in report["recommendations"]} @@ -280,7 +331,7 @@ def test_report_manifest_records_contract_version_and_hashes(tmp_path: Path) -> assert manifest["artifacts"]["markdown"]["sha256"] -def test_theme_bias_can_lift_static_watchlist_item_without_direct_symbol_bias(tmp_path: Path) -> None: +def test_legacy_v1_theme_bias_is_untrusted_no_op(tmp_path: Path) -> None: events_path = tmp_path / "events.csv" events_path.write_text( "event_id,event_date,symbol,event_type,direction,confidence,source_url,notes\n", @@ -339,11 +390,12 @@ def test_theme_bias_can_lift_static_watchlist_item_without_direct_symbol_bias(tm rec = report["recommendations"][0] assert rec["symbol"] == "MU" - assert rec["rating"] == "watch" - assert rec["evidence_score"] > 4 - assert any("主题=hbm_memory" in reason for reason in rec["reasons"]) + assert rec["rating"] == "monitor" + assert rec["evidence_score"] == 4 + assert rec["ai_context"]["source"] == "" + assert report["summary"]["data_quality_warnings"] == ["ai_signal_provenance_untrusted"] assert report["summary"]["long_context_available"] is False - assert report["summary"]["long_context_missing_reason"] == "current_candidates_do_not_meet_long_context_gate" + assert report["summary"]["long_context_missing_reason"] == "ai_signal_not_available" assert "MU" not in report["summary"]["long_context_symbols"] assert report["final_decisions"]["horizon_action_buckets"]["long"]["watch"] == [] diff --git a/tests/test_ai_ingestion_v6.py b/tests/test_ai_ingestion_v6.py index ef637c8..6ca48e6 100644 --- a/tests/test_ai_ingestion_v6.py +++ b/tests/test_ai_ingestion_v6.py @@ -3,13 +3,16 @@ import datetime as dt import hashlib import json +import subprocess from copy import deepcopy from pathlib import Path import pytest from quant_advisor_research import advisory_report as advisory_report_module +from quant_advisor_research import build_pipeline as build_pipeline_module from quant_advisor_research.advisory_report import build_advisory_report +from quant_advisor_research.build_pipeline import build_advisory_artifacts from quant_advisor_research.contracts import validate_advisory_report @@ -78,6 +81,63 @@ def write_signal(path: Path, payload: dict[str, object]) -> Path: return path +def write_trusted_signal( + tmp_path: Path, + payload: dict[str, object] | None = None, + *, + manifest_updates: dict[str, object] | None = None, +) -> Path: + repo = tmp_path / "research-signal-context" + signal = repo / "data/output/latest_signal.json" + signal.parent.mkdir(parents=True) + signal.write_text(json.dumps(payload or valid_v2_signal()) + "\n", encoding="utf-8") + signal_payload = json.loads(signal.read_text(encoding="utf-8")) + manifest: dict[str, object] = { + "manifest_type": "research_signal_context", + "schema_version": 2, + "artifact": { + "path": "data/output/latest_signal.json", + "sha256": hashlib.sha256(signal.read_bytes()).hexdigest(), + }, + "as_of": signal_payload["as_of"], + "generated_at": signal_payload["generated_at"], + "expires_at": signal_payload["expires_at"], + "mode": signal_payload["mode"], + "producer": { + "repository": "QuantStrategyLab/ResearchSignalContextPipelines", + "commit_sha": "a" * 40, + }, + "input_digest": f"sha256:{'b' * 64}", + "policy": {"execution_allowed": False}, + } + if manifest_updates: + manifest.update(manifest_updates) + manifest_path = signal.with_name("latest_signal.manifest.json") + manifest_path.write_text(json.dumps(manifest) + "\n", encoding="utf-8") + subprocess.run(["git", "init", "-q", repo], check=True) + subprocess.run( + ["git", "-C", repo, "remote", "add", "origin", "https://github.com/QuantStrategyLab/ResearchSignalContextPipelines.git"], + check=True, + ) + subprocess.run(["git", "-C", repo, "add", "data/output/latest_signal.json", "data/output/latest_signal.manifest.json"], check=True) + subprocess.run( + [ + "git", + "-C", + repo, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-qm", + "test fixture", + ], + check=True, + ) + return signal + + def replace_nested(payload: dict[str, object], path: tuple[str, ...], value: object) -> None: target = payload for key in path[:-1]: @@ -186,6 +246,121 @@ def test_unavailable_ai_signal_is_sanitized_no_op(tmp_path: Path) -> None: assert missing_signal.name not in json.dumps(report) +def test_missing_ai_manifest_is_sanitized_no_op(tmp_path: Path) -> None: + signal = write_signal(tmp_path / "signal.json", valid_v2_signal()) + + report = build_report(tmp_path, signal) + + assert "AI1" not in {item["symbol"] for item in report["recommendations"]} + assert report["summary"]["data_quality_warnings"] == ["ai_signal_provenance_untrusted"] + assert signal.name not in json.dumps(report) + + +@pytest.mark.parametrize( + "manifest_updates", + [ + {"schema_version": 1}, + {"schema_version": 3}, + {"artifact": {"path": "data/output/latest_signal.json", "sha256": "0" * 64}}, + {"as_of": "2026-05-29"}, + {"generated_at": "2026-05-29T12:00:00Z"}, + {"expires_at": "2026-06-29"}, + {"mode": "unknown"}, + {"producer": {"repository": "untrusted/repository", "commit_sha": "a" * 40}}, + { + "producer": { + "repository": "QuantStrategyLab/ResearchSignalContextPipelines", + "commit_sha": "main", + } + }, + {"input_digest": "sha256:invalid"}, + {"policy": {"execution_allowed": True}}, + {"publisher_commit": "c" * 40}, + ], +) +def test_untrusted_ai_manifest_is_no_op(tmp_path: Path, manifest_updates: dict[str, object]) -> None: + signal = write_trusted_signal(tmp_path, manifest_updates=manifest_updates) + + report = build_report(tmp_path / "report", signal) + + assert "AI1" not in {item["symbol"] for item in report["recommendations"]} + assert report["summary"]["data_quality_warnings"] == ["ai_signal_provenance_untrusted"] + + +def test_ai_signal_and_manifest_must_match_checkout_head_blobs(tmp_path: Path) -> None: + signal = write_trusted_signal(tmp_path) + payload = valid_v2_signal(confidence=0.9) + signal.write_text(json.dumps(payload) + "\n", encoding="utf-8") + manifest_path = signal.with_name("latest_signal.manifest.json") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["artifact"]["sha256"] = hashlib.sha256(signal.read_bytes()).hexdigest() + manifest_path.write_text(json.dumps(manifest) + "\n", encoding="utf-8") + + report = build_report(tmp_path / "report", signal) + + assert "AI1" not in {item["symbol"] for item in report["recommendations"]} + assert report["summary"]["data_quality_warnings"] == ["ai_signal_provenance_untrusted"] + + +def test_ai_signal_checkout_remote_must_match_producer_repository(tmp_path: Path) -> None: + signal = write_trusted_signal(tmp_path) + repo = signal.parents[2] + subprocess.run( + ["git", "-C", repo, "remote", "set-url", "origin", "https://github.com/untrusted/repository.git"], + check=True, + ) + + report = build_report(tmp_path / "report", signal) + + assert "AI1" not in {item["symbol"] for item in report["recommendations"]} + assert report["summary"]["data_quality_warnings"] == ["ai_signal_provenance_untrusted"] + + +def test_valid_immutable_ai_provenance_can_score(tmp_path: Path) -> None: + signal = write_trusted_signal(tmp_path) + + report = build_report(tmp_path / "report", signal) + + recommendation = next(item for item in report["recommendations"] if item["symbol"] == "AI1") + assert recommendation["ai_context"]["bias"] == "positive" + assert report["summary"]["data_quality_warnings"] == [] + + +def test_untrusted_ai_signal_does_not_expand_market_universe(tmp_path: Path, monkeypatch) -> None: + signal = write_signal(tmp_path / "signal.json", valid_v2_signal()) + events, watchlist = write_base_inputs(tmp_path / "inputs") + captured_symbols: list[str] = [] + + def capture_rows(*, symbols, **_kwargs): + captured_symbols.extend(symbols) + return [] + + monkeypatch.setattr(build_pipeline_module, "build_market_confirmation_rows", capture_rows) + build_advisory_artifacts( + as_of=dt.date(2026, 5, 30), + cadence="weekly", + political_events_path=events, + political_watchlist_path=watchlist, + ai_signal_path=signal, + theme_momentum_path=None, + market_confirmation_path=None, + output_dir=tmp_path / "output", + max_candidates=12, + market_benchmark="SPY", + market_max_symbols=80, + market_request_pause_seconds=0, + market_proxy_list=None, + market_proxy_urls="", + market_proxy_pool_url="", + market_use_network=False, + market_cache_dir=None, + market_cache_max_age_days=14, + ) + + assert "BASE" in captured_symbols + assert "AI1" not in captured_symbols + + @pytest.mark.parametrize( ("updates", "reason"), [ @@ -209,7 +384,7 @@ def test_temporally_invalid_ai_signal_is_reported_and_does_not_score( ) -> None: payload = valid_v2_signal() payload.update(updates) - signal = write_signal(tmp_path / "signal.json", payload) + signal = write_trusted_signal(tmp_path, payload) report = build_report(tmp_path, signal) @@ -262,7 +437,7 @@ def test_new_builder_emits_v6_time_contract_and_content_bound_digest(tmp_path: P def test_input_digest_binds_the_bytes_consumed_before_source_replacement(tmp_path: Path, monkeypatch) -> None: events, watchlist = write_base_inputs(tmp_path) - signal = write_signal(tmp_path / "signal.json", valid_v2_signal()) + signal = write_trusted_signal(tmp_path / "signal-repo") original_signal_bytes = signal.read_bytes() original_loader = advisory_report_module.load_ai_signal @@ -297,10 +472,10 @@ def replacing_loader(path, *args, **kwargs): def test_positive_ai_confidence_is_display_only_for_recommendation_scoring(tmp_path: Path) -> None: - low_path = write_signal(tmp_path / "low.json", valid_v2_signal(confidence=0.0)) - high_path = write_signal(tmp_path / "high.json", valid_v2_signal(confidence=1.0)) - low = build_report(tmp_path / "low", low_path) - high = build_report(tmp_path / "high", high_path) + low_path = write_trusted_signal(tmp_path / "low", valid_v2_signal(confidence=0.0)) + high_path = write_trusted_signal(tmp_path / "high", valid_v2_signal(confidence=1.0)) + low = build_report(tmp_path / "low-report", low_path) + high = build_report(tmp_path / "high-report", high_path) low_rec = next(item for item in low["recommendations"] if item["symbol"] == "AI1") high_rec = next(item for item in high["recommendations"] if item["symbol"] == "AI1") diff --git a/tests/test_m0_research_hypothesis.py b/tests/test_m0_research_hypothesis.py index af0359c..ffb4a00 100644 --- a/tests/test_m0_research_hypothesis.py +++ b/tests/test_m0_research_hypothesis.py @@ -108,14 +108,7 @@ def test_adapter_supports_existing_v6_public_report_contract() -> None: "expires_at": (reference_time + dt.timedelta(days=7)).isoformat().replace("+00:00", "Z"), "input_digest": "a" * 64, "freshness": { - "ai_signal": { - "present": True, - "valid": True, - "reason": "fresh", - "as_of": "2026-05-30", - "generated_at": "2026-05-30T00:00:00Z", - "expires_at": "2026-06-30", - }, + "ai_signal": {"present": False, "valid": False, "reason": "not_provided"}, "theme_momentum": {"present": False, "valid": False, "reason": "not_provided"}, }, } diff --git a/tests/test_publisher.py b/tests/test_publisher.py index d529322..d23e4e1 100644 --- a/tests/test_publisher.py +++ b/tests/test_publisher.py @@ -183,7 +183,7 @@ def test_index_limits_recent_history_and_archive_keeps_all_reports() -> None: assert '"json": "advisory_report_2026-05-01.json"' in report_index -def test_render_report_html_does_not_show_fixture_warning_for_live_paths(tmp_path: Path) -> None: +def test_render_report_html_treats_manifestless_operator_ai_as_no_op(tmp_path: Path) -> None: live_dir = tmp_path / "live" live_dir.mkdir() political_events = live_dir / "political_events.csv" @@ -206,7 +206,8 @@ def test_render_report_html_does_not_show_fixture_warning_for_live_paths(tmp_pat html = render_report_html(report) assert report["summary"]["source_mode"] == "operator_supplied" - assert report["summary"]["data_quality_warnings"] == [] + assert report["summary"]["data_quality_warnings"] == ["ai_signal_provenance_untrusted"] + assert report["source_artifacts"]["ai_signal"] == "" assert "来源模式" not in html assert "Input artifacts include example fixture paths" not in html