diff --git a/research-radar/README.md b/research-radar/README.md index 8ba30a9..ddf93ea 100644 --- a/research-radar/README.md +++ b/research-radar/README.md @@ -49,6 +49,43 @@ python3 research-radar/bin/run_daily.py --write python3 research-radar/bin/validate_reports.py ``` +## Shared Intake Shadow + +`research-radar/bin/run_daily.py` is still the scheduled daily collector. The +shared-intake path is a manual shadow path for checking whether this project can +consume the shared collector/governance repo without changing daily report +output yet. + +The shared-intake consumer contract is repo-owned here: + +- `research-radar/shared-intake.lock.json` pins the exact + `heurema/shared-intake-governance` commit this project accepts. +- `research-radar/shared-intake/profile.json` defines this project's intake + profile. +- `research-radar/shared-intake/sources/*.json` defines the source configs this + project asks shared-intake to validate or run. + +Check the pinned dependency and local configs: + +```bash +python3 research-radar/bin/check_shared_intake_dependency.py \ + --shared-repo-root ../shared-intake-governance +``` + +Run a shadow pass with shared-intake runtime artifacts outside git: + +```bash +python3 research-radar/bin/run_shared_shadow.py \ + --shared-repo-root ../shared-intake-governance \ + --runtime-root /tmp/code-intel-shared-intake-shadow +``` + +To use a new shared-intake version, update the shared checkout, run +`python3 scripts/check_repo.py` in `shared-intake-governance`, replace +`upstream.pinned_commit` in `research-radar/shared-intake.lock.json`, then run +the dependency check and shadow command above. A moving upstream `main` does not +silently change this project while the lock is enforced. + ## Codex App Automation The bounded weekday automation is configured in Codex App, not as a repository workflow. Details are documented in `research-radar/automation.md`. diff --git a/research-radar/automation.md b/research-radar/automation.md index f02d828..b3e10eb 100644 --- a/research-radar/automation.md +++ b/research-radar/automation.md @@ -21,6 +21,40 @@ python3 research-radar/bin/run_daily.py --write --date YYYY-MM-DD python3 research-radar/bin/validate_reports.py ``` +## Shared Intake Shadow + +The scheduled automation still uses `research-radar/bin/run_daily.py`. The +shared-intake integration is manual shadow/preflight only until a separate +cutover changes the scheduled command. + +Before using a shared-intake checkout for this project, run: + +```bash +python3 research-radar/bin/check_shared_intake_dependency.py \ + --shared-repo-root ../shared-intake-governance +``` + +That check verifies three things: + +- the shared checkout is exactly the commit pinned in + `research-radar/shared-intake.lock.json`; +- this project's shared-intake profile validates with the pinned shared CLI; +- this project's shared-intake source configs validate with the pinned shared CLI. + +To adopt a newer shared-intake version: + +1. Update the `shared-intake-governance` checkout. +2. Run `python3 scripts/check_repo.py` inside `shared-intake-governance`. +3. Replace `upstream.pinned_commit` in + `research-radar/shared-intake.lock.json`. +4. Run `python3 research-radar/bin/check_shared_intake_dependency.py`. +5. Run `python3 research-radar/bin/run_shared_shadow.py` with a temporary + runtime root and inspect the JSON summary. +6. Commit the consumer lock/config/docs change in this repository. + +Do not point automation at a new shared-intake commit until that bump is +reviewed in this repository. + ## Sources Automation reads: diff --git a/research-radar/bin/check_shared_intake_dependency.py b/research-radar/bin/check_shared_intake_dependency.py new file mode 100644 index 0000000..5e79f27 --- /dev/null +++ b/research-radar/bin/check_shared_intake_dependency.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Validate the Research Radar shared-intake dependency lock.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any + + +LOCK_SCHEMA_VERSION = "shared-intake-consumer.v1" +SHARED_CLI_MARKER = Path("src/shared_intake_governance/cli/__main__.py") + + +class DependencyError(RuntimeError): + """Raised when the shared-intake dependency cannot be trusted.""" + + +def repo_root_from_script() -> Path: + return Path(__file__).resolve().parents[2] + + +def default_lock_path(consumer_repo_root: Path) -> Path: + return consumer_repo_root / "research-radar" / "shared-intake.lock.json" + + +def load_lock(lock_path: Path) -> dict[str, Any]: + try: + payload = json.loads(lock_path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise DependencyError(f"lock file not found: {lock_path}") from exc + except json.JSONDecodeError as exc: + raise DependencyError(f"invalid lock JSON: {lock_path}: {exc}") from exc + + if not isinstance(payload, dict): + raise DependencyError("lock file must contain a JSON object") + if payload.get("schema_version") != LOCK_SCHEMA_VERSION: + raise DependencyError( + f"unsupported lock schema_version: {payload.get('schema_version')!r}" + ) + + upstream = _required_object(payload, "upstream") + _required_string(upstream, "repository") + _required_string(upstream, "pinned_commit") + _required_string(upstream, "default_relative_path") + + paths = _required_object(payload, "paths") + _required_string(paths, "profile") + _required_string(paths, "sources_glob") + return payload + + +def _required_object(payload: dict[str, Any], key: str) -> dict[str, Any]: + value = payload.get(key) + if not isinstance(value, dict): + raise DependencyError(f"lock field {key!r} must be an object") + return value + + +def _required_string(payload: dict[str, Any], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value.strip(): + raise DependencyError(f"lock field {key!r} must be a non-empty string") + return value + + +def resolve_shared_repo_root( + lock: dict[str, Any], + *, + consumer_repo_root: Path, + explicit: Path | None = None, + env: dict[str, str] | None = None, +) -> Path: + current_env = os.environ if env is None else env + candidates: list[Path] = [] + if explicit is not None: + candidates.append(explicit) + for env_name in ("SIG_SHARED_REPO_ROOT", "SHARED_INTAKE_ROOT"): + env_root = current_env.get(env_name) + if env_root: + candidates.append(Path(env_root).expanduser()) + candidates.append( + consumer_repo_root / lock["upstream"]["default_relative_path"] + ) + + for candidate in candidates: + resolved = candidate.expanduser().resolve() + if (resolved / SHARED_CLI_MARKER).exists(): + return resolved + + candidate_text = ", ".join(str(candidate) for candidate in candidates) + raise DependencyError( + "shared-intake-governance repo not found; pass --shared-repo-root " + f"or set SIG_SHARED_REPO_ROOT. Checked: {candidate_text}" + ) + + +def actual_git_commit(shared_repo_root: Path) -> str: + completed = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=shared_repo_root, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + raise DependencyError( + "could not read shared-intake git commit: " + + completed.stderr.strip() + ) + return completed.stdout.strip() + + +def verify_pinned_commit(*, expected_commit: str, actual_commit: str) -> None: + if expected_commit != actual_commit: + raise DependencyError( + "shared-intake pinned commit mismatch: " + f"expected {expected_commit}, got {actual_commit}" + ) + + +def cli_env(shared_repo_root: Path) -> dict[str, str]: + env = dict(os.environ) + source_path = str(shared_repo_root / "src") + existing = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + source_path if not existing else source_path + os.pathsep + existing + ) + return env + + +def run_shared_cli(shared_repo_root: Path, argv: list[str]) -> dict[str, Any]: + completed = subprocess.run( + [sys.executable, "-m", "shared_intake_governance.cli", *argv], + cwd=shared_repo_root, + capture_output=True, + text=True, + check=False, + env=cli_env(shared_repo_root), + ) + if completed.returncode != 0: + raise DependencyError( + "shared-intake CLI failed for " + + " ".join(argv) + + f"\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise DependencyError( + "shared-intake CLI did not return JSON for " + " ".join(argv) + ) from exc + if not isinstance(payload, dict): + raise DependencyError("shared-intake CLI returned non-object JSON") + return payload + + +def consumer_preflight_commands( + lock: dict[str, Any], consumer_repo_root: Path +) -> list[list[str]]: + profile_path = consumer_repo_root / lock["paths"]["profile"] + source_paths = sorted(consumer_repo_root.glob(lock["paths"]["sources_glob"])) + if not profile_path.exists(): + raise DependencyError(f"profile path not found: {profile_path}") + if not source_paths: + raise DependencyError( + f"no source configs matched: {lock['paths']['sources_glob']}" + ) + + commands = [["inspect-profile", "--profile", str(profile_path)]] + for source_path in source_paths: + commands.append( + ["inspect-source-config", "--source-config", str(source_path)] + ) + return commands + + +def check_dependency( + *, + consumer_repo_root: Path, + lock_path: Path | None = None, + explicit_shared_root: Path | None = None, +) -> dict[str, Any]: + resolved_consumer_root = consumer_repo_root.resolve() + resolved_lock_path = (lock_path or default_lock_path(resolved_consumer_root)).resolve() + lock = load_lock(resolved_lock_path) + shared_repo_root = resolve_shared_repo_root( + lock, + consumer_repo_root=resolved_consumer_root, + explicit=explicit_shared_root, + ) + expected_commit = lock["upstream"]["pinned_commit"] + actual_commit = actual_git_commit(shared_repo_root) + verify_pinned_commit( + expected_commit=expected_commit, + actual_commit=actual_commit, + ) + + commands = consumer_preflight_commands(lock, resolved_consumer_root) + checked_commands: list[list[str]] = [] + for command in commands: + run_shared_cli(shared_repo_root, command) + checked_commands.append(command) + + return { + "status": "ok", + "lock_path": str(resolved_lock_path), + "shared_repo_root": str(shared_repo_root), + "pinned_commit": expected_commit, + "actual_commit": actual_commit, + "profile_path": str(resolved_consumer_root / lock["paths"]["profile"]), + "source_config_count": len(commands) - 1, + "checked_commands": checked_commands, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate Research Radar's pinned shared-intake dependency." + ) + parser.add_argument( + "--repo-root", + default=str(repo_root_from_script()), + help="Consumer repository root. Defaults to this checkout.", + ) + parser.add_argument("--lock", help="Path to shared-intake.lock.json.") + parser.add_argument( + "--shared-repo-root", + help="Path to the shared-intake-governance checkout.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + summary = check_dependency( + consumer_repo_root=Path(args.repo_root), + lock_path=Path(args.lock) if args.lock else None, + explicit_shared_root=Path(args.shared_repo_root) + if args.shared_repo_root + else None, + ) + except DependencyError as exc: + json.dump({"status": "error", "error": str(exc)}, sys.stderr) + sys.stderr.write("\n") + return 1 + + json.dump(summary, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/research-radar/bin/run_shared_shadow.py b/research-radar/bin/run_shared_shadow.py new file mode 100644 index 0000000..bca00f4 --- /dev/null +++ b/research-radar/bin/run_shared_shadow.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Run code-intel-kernel Research Radar in shared-intake shadow mode.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +from pathlib import Path +import sys +import tempfile +from typing import Any + +from check_shared_intake_dependency import check_dependency, load_lock, run_shared_cli + + +KNOWN_GAPS = [ + "Shadow mode writes shared runtime artifacts only; it does not replace research-radar/reports output yet.", + "The existing run_daily.py collector remains the scheduled Research Radar path until a separate cutover.", + "The shared github_repo collector captures repository metadata only, not release or commit enrichment parity with run_daily.py.", +] + + +def main() -> int: + args = parse_args() + repo_root = ( + Path(args.repo_root).expanduser().resolve() + if args.repo_root + else Path(__file__).resolve().parents[2] + ) + lock_path = ( + Path(args.lock).expanduser().resolve() + if args.lock + else repo_root / "research-radar" / "shared-intake.lock.json" + ) + lock = load_lock(lock_path) + dependency = check_dependency( + consumer_repo_root=repo_root, + lock_path=lock_path, + explicit_shared_root=Path(args.shared_repo_root).expanduser() + if args.shared_repo_root + else None, + ) + profile_path = ( + Path(args.profile).expanduser().resolve() + if args.profile + else repo_root / lock["paths"]["profile"] + ) + sources_dir = ( + Path(args.sources_dir).expanduser().resolve() + if args.sources_dir + else (repo_root / lock["paths"]["sources_glob"]).parent + ) + runtime_root = ( + Path(args.runtime_root).expanduser().resolve() + if args.runtime_root + else Path.home() / ".local" / "share" / "shared-intake-governance" + ) + run_date = parse_date(args.date) + + summary = run_shadow( + shared_repo_root=Path(dependency["shared_repo_root"]), + dependency=dependency, + runtime_root=runtime_root, + profile_path=profile_path, + sources_dir=sources_dir, + run_date=run_date, + source_ids=args.source_ids or [], + output_id=args.output_id or f"{run_date.isoformat()}-shadow", + update_seen_state=args.update_seen_state, + ) + json.dump(summary, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run Research Radar through shared-intake shadow mode." + ) + parser.add_argument("--date", help="Run date in YYYY-MM-DD format.") + parser.add_argument("--repo-root", help="Consumer repository root.") + parser.add_argument("--runtime-root", help="Shared runtime root outside git.") + parser.add_argument("--lock", help="Path to shared-intake.lock.json.") + parser.add_argument( + "--shared-repo-root", + help="Path to the shared-intake-governance repository.", + ) + parser.add_argument("--profile", help="Consumer-owned profile path.") + parser.add_argument("--sources-dir", help="Directory with source-config templates.") + parser.add_argument( + "--source-id", + dest="source_ids", + action="append", + help="Limit the run to one or more source_id values.", + ) + parser.add_argument("--output-id", help="Projection output id.") + parser.add_argument( + "--update-seen-state", + action="store_true", + help="Merge projected record ids into the profile-local seen state.", + ) + return parser.parse_args() + + +def parse_date(value: str | None) -> dt.date: + if value: + return dt.date.fromisoformat(value) + return dt.datetime.now(dt.timezone.utc).date() + + +def build_placeholders(run_date: dt.date) -> dict[str, str]: + return { + "${TODAY}": run_date.isoformat(), + "${TODAY_MINUS_2D}": (run_date - dt.timedelta(days=2)).isoformat(), + "${TODAY_MINUS_7D}": (run_date - dt.timedelta(days=7)).isoformat(), + "${TODAY_MINUS_30D}": (run_date - dt.timedelta(days=30)).isoformat(), + } + + +def replace_placeholders(value: Any, placeholders: dict[str, str]) -> Any: + if isinstance(value, str): + output = value + for placeholder, replacement in placeholders.items(): + output = output.replace(placeholder, replacement) + return output + if isinstance(value, list): + return [replace_placeholders(item, placeholders) for item in value] + if isinstance(value, dict): + return { + key: replace_placeholders(item, placeholders) + for key, item in value.items() + } + return value + + +def discover_source_templates( + sources_dir: Path, source_ids: list[str] +) -> list[tuple[str, Path]]: + templates: list[tuple[str, Path]] = [] + for path in sorted(sources_dir.glob("*.json")): + config = json.loads(path.read_text(encoding="utf-8")) + source_id = str(config["source_id"]) + if source_ids and source_id not in source_ids: + continue + templates.append((source_id, path)) + + if not templates: + raise ValueError("no source configs matched the requested source ids") + return templates + + +def materialize_source_config( + template_path: Path, + *, + placeholders: dict[str, str], + destination_dir: Path, +) -> Path: + config = json.loads(template_path.read_text(encoding="utf-8")) + materialized = replace_placeholders(config, placeholders) + destination = destination_dir / template_path.name + destination.write_text( + json.dumps(materialized, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return destination + + +def source_run_id(run_date: dt.date, source_id: str) -> str: + return f"{run_date.strftime('%Y%m%d')}-shadow-{source_id}" + + +def run_shadow( + *, + shared_repo_root: Path, + dependency: dict[str, Any], + runtime_root: Path, + profile_path: Path, + sources_dir: Path, + run_date: dt.date, + source_ids: list[str], + output_id: str, + update_seen_state: bool, +) -> dict[str, Any]: + placeholders = build_placeholders(run_date) + source_templates = discover_source_templates(sources_dir, source_ids) + runtime_root.mkdir(parents=True, exist_ok=True) + + source_runs: list[dict[str, Any]] = [] + with tempfile.TemporaryDirectory(prefix="shared-shadow-configs-") as tmp_dir: + materialized_dir = Path(tmp_dir) + for source_id, template_path in source_templates: + materialized_path = materialize_source_config( + template_path, + placeholders=placeholders, + destination_dir=materialized_dir, + ) + run_id = source_run_id(run_date, source_id) + result = run_shared_cli( + shared_repo_root, + [ + "run-source-config", + "--runtime-root", + str(runtime_root), + "--profile", + str(profile_path), + "--source-config", + str(materialized_path), + "--run-id", + run_id, + "--output-id", + run_id, + ], + ) + source_runs.append( + { + "source_id": source_id, + "template_path": str(template_path), + "run_summary": result, + } + ) + + project_argv = [ + "project-profiles", + "--runtime-root", + str(runtime_root), + "--profile", + str(profile_path), + "--output-id", + output_id, + ] + if update_seen_state: + project_argv.append("--update-seen-state") + projection = run_shared_cli(shared_repo_root, project_argv) + + return { + "run_date": run_date.isoformat(), + "shared_dependency": dependency, + "runtime_root": str(runtime_root), + "profile_path": str(profile_path), + "sources_dir": str(sources_dir), + "source_count": len(source_runs), + "source_runs": source_runs, + "projection": projection, + "known_gaps": KNOWN_GAPS, + } + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/research-radar/shared-intake.lock.json b/research-radar/shared-intake.lock.json new file mode 100644 index 0000000..e6dd05b --- /dev/null +++ b/research-radar/shared-intake.lock.json @@ -0,0 +1,21 @@ +{ + "schema_version": "shared-intake-consumer.v1", + "consumer": "code-intel-kernel/research-radar", + "upstream": { + "repository": "https://github.com/heurema/shared-intake-governance", + "default_relative_path": "../shared-intake-governance", + "pinned_commit": "c9a28c55c95267d5e6ff09435cf6d2ff39a2bef9" + }, + "paths": { + "profile": "research-radar/shared-intake/profile.json", + "sources_glob": "research-radar/shared-intake/sources/*.json", + "runtime_root_hint": "$HOME/.local/share/shared-intake-governance" + }, + "update_policy": [ + "Pull or checkout the desired shared-intake-governance revision.", + "Run python3 scripts/check_repo.py in shared-intake-governance.", + "Update upstream.pinned_commit in this lock file.", + "Run python3 research-radar/bin/check_shared_intake_dependency.py.", + "Run python3 research-radar/bin/run_shared_shadow.py with a temporary runtime root before using the revision in automation." + ] +} diff --git a/research-radar/shared-intake/profile.json b/research-radar/shared-intake/profile.json new file mode 100644 index 0000000..e5bbb10 --- /dev/null +++ b/research-radar/shared-intake/profile.json @@ -0,0 +1,29 @@ +{ + "profile_id": "code-intel-kernel", + "description": "Code intelligence research intake focused on repository evidence, parser infrastructure, benchmarks, and adjacent coding-agent mechanics.", + "accepted_sources": [ + "github_repo", + "github_search", + "arxiv_query" + ], + "keywords": [ + "code intelligence", + "coding agent", + "repository-level", + "repository graph", + "tree-sitter", + "program repair", + "benchmark", + "repository context" + ], + "required_risk_flags_absent": [ + "instruction_like_content", + "tool_escalation_language" + ], + "output_mode": "research_digest", + "provider_preferences": [ + "claude", + "agy", + "gemini" + ] +} diff --git a/research-radar/shared-intake/sources/arxiv-code-agents.json b/research-radar/shared-intake/sources/arxiv-code-agents.json new file mode 100644 index 0000000..a091eb0 --- /dev/null +++ b/research-radar/shared-intake/sources/arxiv-code-agents.json @@ -0,0 +1,7 @@ +{ + "schema_version": "source-config.v1", + "source_type": "arxiv_query", + "source_id": "arxiv-code-agents", + "query": "all:\"coding agent\" OR all:\"code agent\" OR all:\"program repair\" OR all:\"repository-level\"", + "max_results": 5 +} diff --git a/research-radar/shared-intake/sources/arxiv-code-graphs.json b/research-radar/shared-intake/sources/arxiv-code-graphs.json new file mode 100644 index 0000000..85b4646 --- /dev/null +++ b/research-radar/shared-intake/sources/arxiv-code-graphs.json @@ -0,0 +1,7 @@ +{ + "schema_version": "source-config.v1", + "source_type": "arxiv_query", + "source_id": "arxiv-code-graphs", + "query": "all:\"code graph\" OR all:\"repository graph\" OR all:\"Tree-sitter\" OR all:\"code intelligence\"", + "max_results": 5 +} diff --git a/research-radar/shared-intake/sources/codebase-memory.json b/research-radar/shared-intake/sources/codebase-memory.json new file mode 100644 index 0000000..e193ffe --- /dev/null +++ b/research-radar/shared-intake/sources/codebase-memory.json @@ -0,0 +1,7 @@ +{ + "schema_version": "source-config.v1", + "source_type": "github_repo", + "source_id": "codebase-memory", + "owner": "DeusData", + "repo": "codebase-memory-mcp" +} diff --git a/research-radar/shared-intake/sources/github-research-repos.json b/research-radar/shared-intake/sources/github-research-repos.json new file mode 100644 index 0000000..3665b2c --- /dev/null +++ b/research-radar/shared-intake/sources/github-research-repos.json @@ -0,0 +1,7 @@ +{ + "schema_version": "source-config.v1", + "source_type": "github_search", + "source_id": "github-research-repos", + "query": "(\"code intelligence\" OR \"coding agent\" OR \"repository graph\" OR \"Tree-sitter\") pushed:>=${TODAY_MINUS_7D}", + "max_results": 5 +} diff --git a/research-radar/shared-intake/sources/rust-analyzer.json b/research-radar/shared-intake/sources/rust-analyzer.json new file mode 100644 index 0000000..f889a46 --- /dev/null +++ b/research-radar/shared-intake/sources/rust-analyzer.json @@ -0,0 +1,7 @@ +{ + "schema_version": "source-config.v1", + "source_type": "github_repo", + "source_id": "rust-analyzer", + "owner": "rust-lang", + "repo": "rust-analyzer" +} diff --git a/research-radar/shared-intake/sources/tree-sitter.json b/research-radar/shared-intake/sources/tree-sitter.json new file mode 100644 index 0000000..57f2a66 --- /dev/null +++ b/research-radar/shared-intake/sources/tree-sitter.json @@ -0,0 +1,7 @@ +{ + "schema_version": "source-config.v1", + "source_type": "github_repo", + "source_id": "tree-sitter", + "owner": "tree-sitter", + "repo": "tree-sitter" +} diff --git a/scripts/run-deterministic-tests.sh b/scripts/run-deterministic-tests.sh index 97bd210..061c209 100755 --- a/scripts/run-deterministic-tests.sh +++ b/scripts/run-deterministic-tests.sh @@ -6,4 +6,5 @@ cargo test cargo clippy -- -D warnings cargo run --quiet -- eval-fixtures --json python3 research-radar/bin/validate_reports.py +python3 -m unittest tests.test_research_radar_shared_intake_dependency git diff --check diff --git a/tests/test_research_radar_shared_intake_dependency.py b/tests/test_research_radar_shared_intake_dependency.py new file mode 100644 index 0000000..322b91b --- /dev/null +++ b/tests/test_research_radar_shared_intake_dependency.py @@ -0,0 +1,131 @@ +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "research-radar" / "bin" / "check_shared_intake_dependency.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location( + "check_shared_intake_dependency", + SCRIPT, + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def write_lock(path: Path, **overrides): + payload = { + "schema_version": "shared-intake-consumer.v1", + "consumer": "code-intel-kernel/research-radar", + "upstream": { + "repository": "https://github.com/heurema/shared-intake-governance", + "pinned_commit": "abc123", + "default_relative_path": "../shared-intake-governance", + }, + "paths": { + "profile": "research-radar/shared-intake/profile.json", + "sources_glob": "research-radar/shared-intake/sources/*.json", + }, + } + for key, value in overrides.items(): + payload[key] = value + path.write_text(json.dumps(payload), encoding="utf-8") + + +class SharedIntakeDependencyTests(unittest.TestCase): + def test_load_lock_requires_consumer_schema(self): + module = load_module() + with tempfile.TemporaryDirectory() as tmp_dir: + lock_path = Path(tmp_dir) / "shared-intake.lock.json" + write_lock(lock_path) + + lock = module.load_lock(lock_path) + + self.assertEqual(lock["upstream"]["pinned_commit"], "abc123") + + def test_load_lock_rejects_unknown_schema(self): + module = load_module() + with tempfile.TemporaryDirectory() as tmp_dir: + lock_path = Path(tmp_dir) / "shared-intake.lock.json" + write_lock(lock_path, schema_version="other.v1") + + with self.assertRaisesRegex(module.DependencyError, "schema_version"): + module.load_lock(lock_path) + + def test_resolve_shared_repo_root_prefers_explicit_root(self): + module = load_module() + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + explicit = root / "shared-intake-governance" + marker = ( + explicit + / "src" + / "shared_intake_governance" + / "cli" + / "__main__.py" + ) + marker.parent.mkdir(parents=True) + marker.write_text("", encoding="utf-8") + + resolved = module.resolve_shared_repo_root( + { + "upstream": { + "default_relative_path": "../missing-shared-intake" + } + }, + consumer_repo_root=root / "consumer", + explicit=explicit, + env={}, + ) + + self.assertEqual(resolved, explicit.resolve()) + + def test_verify_pinned_commit_reports_mismatch(self): + module = load_module() + + with self.assertRaisesRegex( + module.DependencyError, + "shared-intake pinned commit mismatch", + ): + module.verify_pinned_commit( + expected_commit="abc123", + actual_commit="def456", + ) + + def test_consumer_preflight_commands_validate_profile_then_sorted_sources(self): + module = load_module() + with tempfile.TemporaryDirectory() as tmp_dir: + consumer_root = Path(tmp_dir) + profile = consumer_root / "research-radar" / "shared-intake" / "profile.json" + source_dir = profile.parent / "sources" + source_dir.mkdir(parents=True) + profile.write_text("{}", encoding="utf-8") + (source_dir / "z-source.json").write_text("{}", encoding="utf-8") + (source_dir / "a-source.json").write_text("{}", encoding="utf-8") + + commands = module.consumer_preflight_commands( + { + "paths": { + "profile": "research-radar/shared-intake/profile.json", + "sources_glob": "research-radar/shared-intake/sources/*.json", + } + }, + consumer_root, + ) + + self.assertEqual(commands[0][:2], ["inspect-profile", "--profile"]) + self.assertEqual( + [Path(command[-1]).name for command in commands[1:]], + ["a-source.json", "z-source.json"], + ) + + +if __name__ == "__main__": + unittest.main()