diff --git a/docs/research/pr0_upstream_governance_baseline_2026_08_14.md b/docs/research/pr0_upstream_governance_baseline_2026_08_14.md new file mode 100644 index 00000000..9443f972 --- /dev/null +++ b/docs/research/pr0_upstream_governance_baseline_2026_08_14.md @@ -0,0 +1,34 @@ +# PR-0 Upstream Governance Baseline — 2026-08-14 + +## Scope + +Read-only baseline before implementing PR-0 from `Jyotisha_Coding_Agent_Execution_and_Test_Plan_2026-08-14.md`. This PR is limited to upstream provenance, one-way selective-import policy, deterministic manifests, and regression guards. It does not modify consultation, rectification, report runtime, authentication, billing, database, deployment, production, or DNS behavior. + +## Repository baseline + +- Commercial repository: `root/Jyotisha` +- Branch: `codex/pr0-upstream-governance` +- Base and refreshed `origin/staging`: `0fd111d16b45796086a6c1d0945dbd3de6755d8a` +- Worktree at baseline: clean +- Baseline recorded at: `2026-08-14T02:43:14Z` + +## Research input identity + +- Repository: `https://github.com/732642856/yinduzhanxing` +- Commit: `5db72537741fcedaa7b5498502d4a31b0f9fc147` +- Git tree object: `16935cb68a6fa1ef72661cfca4650a42c60e9b2c` +- ZIP SHA-256: `07d6b71af311160c544433eb9d51beb5b0a5e4bd0afabd4a7d43d3edb573518d` +- Importer-compatible source tree SHA-256: `18e3122ef73c2776a950bfec128efeb09ac0524ec1d2179390260166b87ca814` +- Root research `SKILL.md` SHA-256: `ef453dd8dd4a9da72b56010ec33bb7dab57fd72333d8785cac0986d5456e17c5` +- ZIP versus Git commit comparison: 2,901 files; zero missing, extra, or byte differences + +## Baseline checks + +| Command | Result | Duration | Notes | +|---|---:|---:|---| +| `.venv/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` | pass | about 17s | Python 3.12.12; fragment scan, external adapters, remote visibility, and focused tests passed | +| `.venv/bin/python -m pytest -q tests/test_import_yinduzhanxing.py tests/test_report_orchestrator_reader_contract.py` | 15 passed | 1.68s | Existing importer and report provenance baseline | + +## Protected surfaces + +PR-0 must not modify the commercial root `SKILL.md`, the birth-time rectification Skill, frontend production code, authentication, identity, billing, payment, subscriptions, entitlements, database migrations/RLS/RPC, deployment workflows, production, or DNS. diff --git a/references/cross_project_contract/import_manifest.schema.json b/references/cross_project_contract/import_manifest.schema.json index eb6e0ca7..b2b9a0b4 100644 --- a/references/cross_project_contract/import_manifest.schema.json +++ b/references/cross_project_contract/import_manifest.schema.json @@ -13,9 +13,11 @@ "properties": { "schema_version": {"const": 1}, "source_repository": {"type": "string", "minLength": 1}, + "source_repository_url": {"type": "string", "pattern": "^https://github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"}, "source_commit": {"type": "string", "pattern": "^(unknown|[0-9a-f]{40})$"}, "source_tree_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "source_mode": {"enum": ["git", "snapshot"]}, + "source_skill_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "SHA-256 of the research source root SKILL.md."}, + "source_mode": {"enum": ["git", "archive", "snapshot"]}, "target_repository": {"type": "string", "minLength": 1}, "target_base_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, "policy_version": {"const": 2}, @@ -36,6 +38,15 @@ }, "operator_review_required": {"type": "boolean"} }, + "allOf": [ + { + "if": {"properties": {"source_mode": {"enum": ["git", "archive"]}}}, + "then": { + "required": ["source_repository_url", "source_skill_sha256"], + "properties": {"source_commit": {"pattern": "^[0-9a-f]{40}$"}} + } + } + ], "$defs": { "file": { "type": "object", diff --git a/references/cross_project_contract/imports/commit-5db72537741fcedaa7b5498502d4a31b0f9fc147.json b/references/cross_project_contract/imports/commit-5db72537741fcedaa7b5498502d4a31b0f9fc147.json new file mode 100644 index 00000000..8ad9640a --- /dev/null +++ b/references/cross_project_contract/imports/commit-5db72537741fcedaa7b5498502d4a31b0f9fc147.json @@ -0,0 +1,80 @@ +{ + "generated_at": "2026-08-14T01:13:57Z", + "mirror_files": [ + { + "license": "MIT", + "source": "SKILL.md", + "source_sha256": "ef453dd8dd4a9da72b56010ec33bb7dab57fd72333d8785cac0986d5456e17c5", + "status": "applied", + "target": "references/upstream/yinduzhanxing/SKILL.md", + "target_sha256_after": "ef453dd8dd4a9da72b56010ec33bb7dab57fd72333d8785cac0986d5456e17c5", + "target_sha256_before": "1be8beafbd5f4b6df7f95afec39e87033cd70ef6bf582fe7d9f1eb62fb5d00fb" + } + ], + "operator_review_required": true, + "policy_version": 2, + "privacy_scan": { + "rejections": [], + "scanned_files": 1, + "status": "pass" + }, + "protected_rejections": [], + "schema_version": 1, + "semantic_merge_files": [ + { + "diff_summary": "manual semantic merge required: +42/-31 lines", + "path": "SKILL.md", + "source_sha256": "ef453dd8dd4a9da72b56010ec33bb7dab57fd72333d8785cac0986d5456e17c5", + "status": "review_required", + "target_sha256": "b0377ac7952f8a8d9433522c50837967939e5f91fbed5042467278520379da49" + }, + { + "diff_summary": "manual semantic merge required: +65/-53 lines", + "path": "AGENTS.md", + "source_sha256": "a1ff71472c6312000b8aef3f099d5499c0b27d1fd4f61594ef06a56c48533dd2", + "status": "review_required", + "target_sha256": "d8cf104ff60e627ffaf75031237a37b022b54f7145cafdffb3eacdc634f998f9" + }, + { + "diff_summary": "manual semantic merge required: +13/-1 lines", + "path": "references/strict-workflow-router.md", + "source_sha256": "afb83e0b1b290166cf7ecdcabcae8221d94b14138abc223e9cf57d27596f82d4", + "status": "review_required", + "target_sha256": "2dbab0179c6001c3c8b51e425af323f214e5e912afe7840821f6c38c3546c905" + }, + { + "diff_summary": "manual semantic merge required: +207/-202 lines", + "path": "scripts/unified_consultation_orchestrator.py", + "source_sha256": "f48ad61455ee344ffc53cda225198511979eea05a4be2ac8ef16ea56f40a4d1a", + "status": "review_required", + "target_sha256": "da87697fb8d9bcafa98b2ba0cf091df38769f936a030b8095288f4c99a5dd928" + }, + { + "diff_summary": "manual semantic merge required: +2/-11 lines", + "path": "scripts/report_orchestrator.py", + "source_sha256": "cd67d23cf9df68d200167ac25af8473bfabb1a344a5f0a1e1254de14ba426daf", + "status": "review_required", + "target_sha256": "5fbc2c8df6772597295ae80a5e68709a996ddb9b35724f0820f17f7cb028a201" + }, + { + "diff_summary": "manual semantic merge required: +5082/-1638 lines", + "path": "scripts/jyotish_api_server.py", + "source_sha256": "d522d82a453645770df25b63f6ba77e75de4c694b9b6580a0d88b931a2988314", + "status": "review_required", + "target_sha256": "5c2f4de25547a98d16f6f8e84c129f525a1be6359c3a5f53fa6fd2a631411aee" + } + ], + "source_commit": "5db72537741fcedaa7b5498502d4a31b0f9fc147", + "source_mode": "archive", + "source_repository": "732642856/yinduzhanxing", + "source_repository_url": "https://github.com/732642856/yinduzhanxing", + "source_skill_sha256": "ef453dd8dd4a9da72b56010ec33bb7dab57fd72333d8785cac0986d5456e17c5", + "source_tree_hash": "18e3122ef73c2776a950bfec128efeb09ac0524ec1d2179390260166b87ca814", + "target_base_commit": "0fd111d16b45796086a6c1d0945dbd3de6755d8a", + "target_repository": "root/Jyotisha", + "tests_run": [ + "tests/test_import_yinduzhanxing.py", + "tests/test_report_orchestrator_reader_contract.py", + "tests/test_upstream_import_plan.py" + ] +} diff --git a/references/cross_project_contract/sync_policy.v2.json b/references/cross_project_contract/sync_policy.v2.json index 6a6805bc..90f3210f 100644 --- a/references/cross_project_contract/sync_policy.v2.json +++ b/references/cross_project_contract/sync_policy.v2.json @@ -2,6 +2,7 @@ "schema_version": 2, "direction": "research_to_commercial_only", "source_repository": "732642856/yinduzhanxing", + "source_repository_url": "https://github.com/732642856/yinduzhanxing", "target_repository": "root/Jyotisha", "reverse_sync": "forbidden", "modes": { @@ -21,6 +22,9 @@ "scripts/jyotish_api_server.py" ], "protected": [ + "skills/jyotish-birth-time-rectification/**", + "skills/jyotish-vedic-astrology/**", + "SKILL.md", "frontend/**", "deploy/**", ".gitea/**", @@ -47,6 +51,23 @@ "Only explicit mirror mappings may be copied byte-for-byte.", "Semantic-merge paths are review inputs and are never overwritten by the importer.", "Commercial product, identity, billing, database and deployment surfaces are protected.", - "There is no commercial-to-research mode or gate in schema v2." - ] + "There is no commercial-to-research mode or gate in schema v2.", + "The only byte-for-byte mirror is upstream SKILL.md into references/upstream/yinduzhanxing/SKILL.md.", + "Commercial root and birth-time-rectification Skills are protected targets and may only be reviewed through separate semantic work.", + "The importer enforces the built-in minimum protected pattern set even when an alternate policy file is supplied.", + "Unbound non-Git snapshots are legacy records; new archive imports fail closed without commit and expected tree SHA-256.", + "A Git source must be a clean repository-root checkout whose origin matches source_repository; Git checkouts cannot be relabeled as archive sources.", + "Manifest generation requires a clean target repository root and is deterministic for fixed source, target base commit, policy and CLI identity inputs.", + "Manifest output must be JSON, cannot overlap the source tree, and may enter the target only through references/cross_project_contract/imports." + ], + "source_modes": { + "git": "Use the exact clean repository-root HEAD commit. Explicit commit and expected tree pins, when used, must be supplied together and match.", + "archive": "Requires both an explicit 40-hex source commit and expected importer-compatible source-tree SHA-256; absence or mismatch is rejected.", + "snapshot": "Legacy manifest records only. The v2 importer does not emit new unbound snapshots." + }, + "determinism": { + "tree_hash": "SHA-256 over sorted relative path, NUL, file SHA-256 bytes, NUL; .git is excluded and source symlinks are rejected.", + "generated_at": "Git source commit time for git mode; target base commit time for archive mode. Wall-clock time is forbidden.", + "json": "UTF-8, sorted keys, two-space indentation, trailing newline." + } } diff --git a/references/upstream/yinduzhanxing/SKILL.md b/references/upstream/yinduzhanxing/SKILL.md index 822f06c7..d8e220e4 100644 --- a/references/upstream/yinduzhanxing/SKILL.md +++ b/references/upstream/yinduzhanxing/SKILL.md @@ -120,6 +120,7 @@ adapter available 解释为已完成 VedAstro、PyJHora/JHora 或 jyotishganit r 3. **阶段二**:意图识别 → 路由目标宫位(无明确意图→Level 2综合解盘) 4. **阶段二点五**:若 `full-reading` 或网页/API 返回 `ai_prompt_pack`,必须优先读取 `prompt_zh`、`evidence_snapshot`、`retrieval_plan` 作为 AI/RAG 主上下文;若没有该字段,再退回传统 JSON 摘要。 4.1 **VedAstro 官方优先级**:用户给出生信息后,网页、Skill、MCP 都必须默认走同一条数据优先级:`VedAstro official snapshot -> local supplemental modules -> local fallback only when official blocked`。用户不需要主动要求“调用 VedAstro”。若 `evidence_snapshot.vedastro_official_full_snapshot.status` 为 `ok/partial` 且官方 chart 可用,D1/分盘/官方返回的原始字段以 VedAstro 为主;本地引擎只做补充、交叉检查或官方 blocked 时 fallback。 +4.1.1 **Formal Varga 官方证据审计**:MCP `strict_workflow` 需要补充 VedAstro 官方形式分盘证据时,可显式传 `official_formal_varga_audit=True`;返回的 `vedastro_official_formal_varga_audit` 只能进入 Evidence Ledger / Technique Audit Table,作为 `evidence_only`,不得宣称本地 Varga 公式真值闭环、不得用官方响应静默覆盖本地算法、不得写入或展示任何 API key。 4. **阶段三**:静态分析10步(宫位→承诺→Yoga→Argala→逆行→NK→Shadbala→AV→Ketu→分盘) 5. **阶段四**:动态推运7步(Dasha→五系统Convergence→Transit→Double Transit→Jaimini→KP→Varshaphala) 6. **阶段五**:应期输出(五层验证→时间窗口→Actionable Output+案例检索) diff --git a/references/upstream/yinduzhanxing/import-plan.json b/references/upstream/yinduzhanxing/import-plan.json new file mode 100644 index 00000000..5c340358 --- /dev/null +++ b/references/upstream/yinduzhanxing/import-plan.json @@ -0,0 +1,233 @@ +{ + "schema_version": 1, + "source_repository": "732642856/yinduzhanxing", + "source_repository_url": "https://github.com/732642856/yinduzhanxing", + "source_commit": "5db72537741fcedaa7b5498502d4a31b0f9fc147", + "source_tree_sha256": "18e3122ef73c2776a950bfec128efeb09ac0524ec1d2179390260166b87ca814", + "boundary": "PR-0 records selective semantic decisions only. The importer mirrors only the pinned upstream root SKILL.md under references/upstream; none of these research scripts is copied into the commercial runtime by this PR.", + "entries": [ + { + "source": "scripts/evidence_maturity.py", + "category": "evidence_assertion_contracts", + "action": "adapt", + "reason": "Reuse the explicit calculated/profile_validated/external_parity/predictively_validated vocabulary and visible partial/blocked states without treating a research receipt as commercial execution proof.", + "targets": [ + "scripts/strict_evidence_service.py", + "scripts/high_rigor_closure_gate.py" + ], + "tests": [ + "tests/test_high_rigor_closure_gate.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "source": "scripts/assertion_policy.py", + "category": "evidence_assertion_contracts", + "action": "adapt", + "reason": "Adapt assertion-strength labels so blocked and parameter-sensitive claims remain explicit; server-owned evidence, state and scoring continue to decide what may be asserted.", + "targets": [ + "scripts/rectification_policy.py", + "scripts/report_orchestrator.py" + ], + "tests": [ + "tests/test_rectification_technique_contract.py", + "tests/test_report_orchestrator_reader_contract.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "source": "scripts/evidence_labeled_reporting.py", + "category": "evidence_assertion_contracts", + "action": "adapt", + "reason": "Adapt evidence labels and limitation rendering while preserving the commercial reader contract and preventing computed_pending_validation material from being presented as externally verified truth.", + "targets": [ + "scripts/report_builder.py", + "scripts/report_orchestrator.py" + ], + "tests": [ + "tests/test_report_orchestrator_reader_contract.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "source": "scripts/calculation_profile_contract.py", + "category": "calculation_contracts", + "action": "adapt", + "reason": "Adapt deterministic calculation-profile identity and privacy-safe hashing to the commercial producer instead of creating a second calculation chain or importing research dependency paths.", + "targets": [ + "scripts/domain_calculation_service.py", + "scripts/rectification_three_engine_packet.py" + ], + "tests": [ + "tests/test_commercial_domain_calculation_contract.py", + "tests/test_rectification_three_engine_packet.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "source": "scripts/report_pack_contract.py", + "category": "report_domain_profile_contracts", + "action": "adapt", + "reason": "Adapt typed report-pack sections, provenance and limitations into the existing commercial report contract without replacing its routes, authorization or reader-facing conclusions.", + "targets": [ + "scripts/personal_report_contract.py", + "scripts/report_orchestrator.py" + ], + "tests": [ + "tests/test_personal_report_contract.py", + "tests/test_report_orchestrator_reader_contract.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "source": "scripts/domain_profile_builder.py", + "category": "report_domain_profile_contracts", + "action": "adapt", + "reason": "Adapt domain-profile normalization behind the existing commercial domain service; migration, family and annual route selection remains owned by the commercial orchestrator.", + "targets": [ + "scripts/domain_calculation_service.py", + "scripts/unified_consultation_orchestrator.py" + ], + "tests": [ + "tests/test_commercial_domain_calculation_contract.py", + "tests/test_unified_consultation_orchestrator.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "source": "scripts/unified_calculation_archive.py", + "category": "research_oracle_assets", + "action": "reference_only", + "reason": "Retain the research archive shape and PL9 observations as pinned reference evidence only. It must not replace the commercial calculation archive, satisfy required rectification layers, or become runtime truth.", + "targets": [ + "scripts/high_rigor_closure_gate.py", + "scripts/unified_consultation_orchestrator.py" + ], + "tests": [ + "tests/test_high_rigor_closure_gate.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "source": "scripts/specialized_evidence_packets.py", + "category": "research_oracle_assets", + "action": "adapt", + "reason": "Adapt the raw Jaimini, KP and Tajika packet boundaries as evidence-only structures; each system keeps its own commercial validation gate and cannot vote itself into a deterministic outcome.", + "targets": [ + "scripts/high_rigor_closure_gate.py", + "scripts/rectification_three_engine_packet.py" + ], + "tests": [ + "tests/test_high_rigor_closure_gate.py", + "tests/test_rectification_three_engine_packet.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "source": "scripts/timing_evidence_packets.py", + "category": "research_oracle_assets", + "action": "adapt", + "reason": "Adapt raw Dasha/transit packet labeling while retaining complete commercial candidate sensitivity and stability diagnostics; computed timing windows do not establish an event outcome or exact birth minute.", + "targets": [ + "scripts/candidate_time_sensitivity_scan.py", + "scripts/high_rigor_closure_gate.py" + ], + "tests": [ + "tests/test_candidate_time_sensitivity_scan.py", + "tests/test_high_rigor_closure_gate.py", + "tests/test_upstream_import_plan.py" + ] + } + ], + "formal_varga_policy": { + "action": "reference_only", + "reason": "The upstream Formal Varga official-response audit may appear only as evidence_only in Evidence Ledger or Technique Audit output. It cannot claim local-formula parity, satisfy required layers, overwrite local calculations, or expose an API key.", + "targets": [ + "references/upstream/yinduzhanxing/SKILL.md", + "scripts/high_rigor_closure_gate.py", + "scripts/report_orchestrator.py" + ], + "tests": [ + "tests/test_high_rigor_closure_gate.py", + "tests/test_report_orchestrator_reader_contract.py", + "tests/test_upstream_import_plan.py" + ] + }, + "rejected_regressions": [ + { + "id": "fixed_event_domain_count_threshold", + "action": "reject", + "reason": "A fixed count of 3 events across 2 domains is not sufficient evidence for exact-minute confirmation and cannot bypass server-owned evidence completeness, state or scoring policy.", + "commercial_owner": "scripts/rectification_technique_contract.py", + "tests": [ + "tests/test_rectification_technique_contract.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "id": "sparse_start_mid_end_rectification_scan", + "action": "reject", + "reason": "Sampling only range start, midpoint and end is not a complete sensitivity or stability gate; the commercial candidate scan and required diagnostic layers remain authoritative.", + "commercial_owner": "scripts/candidate_time_sensitivity_scan.py", + "tests": [ + "tests/test_candidate_time_sensitivity_scan.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "id": "weaken_three_engine_gate", + "action": "reject", + "reason": "Research prose or a single official response cannot weaken the commercial three-engine evidence gate or convert blocked parity into executed truth.", + "commercial_owner": "scripts/rectification_three_engine_packet.py", + "tests": [ + "tests/test_rectification_three_engine_packet.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "id": "weaken_required_layers", + "action": "reject", + "reason": "Missing required divisional, timing, strength or functional-benefic layers remain visible and blocked; upstream packets cannot silently satisfy them.", + "commercial_owner": "scripts/high_rigor_closure_gate.py", + "tests": [ + "tests/test_high_rigor_closure_gate.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "id": "weaken_stability_gate", + "action": "reject", + "reason": "Candidate range or exact-minute claims must retain the commercial stability and sensitivity gates; narrative confidence is not stability evidence.", + "commercial_owner": "scripts/candidate_time_sensitivity_scan.py", + "tests": [ + "tests/test_candidate_time_sensitivity_scan.py", + "tests/test_upstream_import_plan.py" + ] + }, + { + "id": "remove_commercial_routes", + "action": "reject", + "reason": "Existing migration, family and annual Web routes, their fallbacks and user-visible recovery remain commercial-runtime-owned and cannot be deleted or replaced by upstream routing.", + "commercial_owner": "scripts/unified_consultation_orchestrator.py", + "preserved_routes": [ + "migration", + "family", + "annual" + ], + "tests": [ + "tests/test_unified_consultation_orchestrator.py", + "tests/test_upstream_import_plan.py" + ] + } + ], + "protected_surfaces": [ + "SKILL.md", + "skills/jyotish-vedic-astrology/**", + "skills/jyotish-birth-time-rectification/**", + "frontend/**", + "deploy/**", + "authentication", + "billing", + "database" + ] +} diff --git a/references/upstream/yinduzhanxing/source-manifest.json b/references/upstream/yinduzhanxing/source-manifest.json index 76c1b2a8..6fb1dfb2 100644 --- a/references/upstream/yinduzhanxing/source-manifest.json +++ b/references/upstream/yinduzhanxing/source-manifest.json @@ -1,11 +1,18 @@ { - "schema_version": 1, - "source_repository": "732642856/yinduzhanxing", - "source_commit": null, - "source_mode": "snapshot", - "source_tree_sha256": "9034e1967032d09c7fbae83fc2205f7e75e8ad482c5f9eba1bf309fe30aef5bb", - "skill_sha256": "1be8beafbd5f4b6df7f95afec39e87033cd70ef6bf582fe7d9f1eb62fb5d00fb", + "archive_file_count": 2901, + "archive_sha256": "07d6b71af311160c544433eb9d51beb5b0a5e4bd0afabd4a7d43d3edb573518d", + "boundary": "Archive content was verified byte-for-byte against all 2,901 non-.git files at the bound commit. Only root SKILL.md is mirrored; commercial and rectification Skills remain protected. skill_sha256 is retained as a legacy alias of source_skill_sha256.", + "import_policy_version": 2, + "imported_at": "2026-08-14T03:16:04Z", "license": "MIT", - "imported_at": "2026-08-06T00:00:00Z", - "boundary": "The provided source had no usable Git metadata. This manifest does not claim parity with a GitHub commit." + "schema_version": 1, + "skill_sha256": "ef453dd8dd4a9da72b56010ec33bb7dab57fd72333d8785cac0986d5456e17c5", + "source_commit": "5db72537741fcedaa7b5498502d4a31b0f9fc147", + "source_committed_at": "2026-08-13T18:03:53Z", + "source_git_tree": "16935cb68a6fa1ef72661cfca4650a42c60e9b2c", + "source_mode": "archive", + "source_repository": "732642856/yinduzhanxing", + "source_repository_url": "https://github.com/732642856/yinduzhanxing", + "source_skill_sha256": "ef453dd8dd4a9da72b56010ec33bb7dab57fd72333d8785cac0986d5456e17c5", + "source_tree_sha256": "18e3122ef73c2776a950bfec128efeb09ac0524ec1d2179390260166b87ca814" } diff --git a/scripts/import_yinduzhanxing.py b/scripts/import_yinduzhanxing.py index e19e2d6e..8a52f3a4 100644 --- a/scripts/import_yinduzhanxing.py +++ b/scripts/import_yinduzhanxing.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """Safely import an explicit research allowlist into the commercial repository. -The importer is deliberately one-way and offline. It never writes semantic-merge -or protected paths, and defaults to a dry run. A source without usable Git -metadata is recorded as an auditable snapshot with ``source_commit=unknown``. +The importer is deliberately one-way and offline. It never writes semantic-merge +or protected paths, and defaults to a dry run. Non-Git archives are accepted +only when an operator binds them to an explicit commit and expected source-tree +SHA-256; missing or mismatched provenance fails closed. """ from __future__ import annotations @@ -20,6 +21,7 @@ import tempfile from datetime import UTC, datetime from pathlib import Path, PurePosixPath from typing import Any +from urllib.parse import urlsplit ROOT = Path(__file__).resolve().parents[1] DEFAULT_POLICY = ROOT / "references/cross_project_contract/sync_policy.v2.json" @@ -27,6 +29,29 @@ EXIT_OK = 0 EXIT_SEMANTIC_REVIEW = 2 EXIT_POLICY_REJECTED = 3 EXIT_VALIDATION_FAILED = 4 +COMMIT_HEX_LENGTH = 40 +SHA256_HEX_LENGTH = 64 +IMPORT_RECORD_DIR = ROOT / "references/cross_project_contract/imports" +REQUIRED_PROTECTED_PATTERNS = frozenset({ + "skills/jyotish-birth-time-rectification/**", + "skills/jyotish-vedic-astrology/**", + "SKILL.md", + "frontend/**", + "deploy/**", + ".gitea/**", + ".github/**", + "frontend/db/**", + "frontend/supabase/**", + "references/oracle/commercial_skill_truth_overlay.v1.json", + "**/.env", + "**/.env.*", + "**/*payment*", + "**/*billing*", + "**/*subscription*", + "**/*entitlement*", + "**/*admin*", + "**/*service_role*", +}) class ImportRejected(ValueError): @@ -54,8 +79,14 @@ def _safe_relative(value: str) -> str: def _resolved_file(root: Path, relative: str, *, must_exist: bool) -> Path: - path = root / _safe_relative(relative) root_resolved = root.resolve() + safe_relative = _safe_relative(relative) + path = root_resolved / safe_relative + current = root_resolved + for part in PurePosixPath(safe_relative).parts: + current = current / part + if current.is_symlink(): + raise ImportRejected(f"symlink_component_rejected:{relative}") if must_exist and not path.exists(): return path resolved = path.resolve(strict=must_exist) @@ -81,12 +112,20 @@ def load_policy(path: Path) -> dict[str, Any]: raise ImportRejected("reverse_sync_must_be_forbidden") if "commercial_to_research" in json.dumps(policy, sort_keys=True): raise ImportRejected("reverse_direction_is_not_expressible") + expected_identity = _repository_identity(str(policy.get("source_repository_url", ""))) + if expected_identity is None: + raise ImportRejected("source_repository_url_invalid") + if expected_identity[1] != str(policy.get("source_repository", "")).strip("/").casefold(): + raise ImportRejected("source_repository_url_slug_mismatch") modes = policy.get("modes") if not isinstance(modes, dict) or set(modes) != {"mirror", "semantic_merge", "protected"}: raise ImportRejected("policy_modes_invalid") if not isinstance(modes["mirror"], list) or not isinstance(modes["semantic_merge"], list): raise ImportRejected("policy_allowlists_invalid") protected = [_safe_relative(value) if "*" not in value else value.replace("\\", "/") for value in modes["protected"]] + missing_protected = sorted(REQUIRED_PROTECTED_PATTERNS - set(protected)) + if missing_protected: + raise ImportRejected(f"required_protected_patterns_missing:{','.join(missing_protected)}") for mapping in modes["mirror"]: if not isinstance(mapping, dict) or not {"source", "target", "license"} <= set(mapping): raise ImportRejected("mirror_mapping_invalid") @@ -108,12 +147,71 @@ def _git_output(root: Path, *args: str) -> str | None: return None -def source_identity(source: Path) -> tuple[str, str]: +def _normalized_hex(value: str | None, *, length: int, label: str) -> str | None: + if value is None: + return None + normalized = value.strip().lower() + if len(normalized) != length or any(ch not in "0123456789abcdef" for ch in normalized): + raise ImportRejected(f"{label}_invalid") + return normalized + + +def source_identity( + source: Path, + *, + source_mode: str = "auto", + source_commit: str | None = None, + expected_tree_sha256: str | None = None, + actual_tree_sha256: str | None = None, +) -> tuple[str, str]: + if source_mode not in {"auto", "git", "archive"}: + raise ImportRejected("source_mode_must_be_auto_git_or_archive") + if source_mode != "archive" and (source_commit is None) != (expected_tree_sha256 is None): + raise ImportRejected("source_commit_and_expected_tree_sha256_must_be_provided_together") + + explicit_commit = _normalized_hex( + source_commit, length=COMMIT_HEX_LENGTH, label="source_commit" + ) + expected_tree = _normalized_hex( + expected_tree_sha256, length=SHA256_HEX_LENGTH, label="expected_tree_sha256" + ) + actual_tree = actual_tree_sha256 or source_tree_hash(source) + inside = _git_output(source, "rev-parse", "--show-toplevel") - commit = _git_output(source, "rev-parse", "HEAD") if inside and Path(inside).resolve() == source.resolve() else None - if commit and len(commit) == 40 and all(ch in "0123456789abcdef" for ch in commit.lower()): - return "git", commit.lower() - return "snapshot", "unknown" + detected_commit = ( + _git_output(source, "rev-parse", "HEAD") + if inside and Path(inside).resolve() == source.resolve() + else None + ) + detected_commit = _normalized_hex( + detected_commit, length=COMMIT_HEX_LENGTH, label="detected_source_commit" + ) + + if source_mode == "archive" and detected_commit: + raise ImportRejected("archive_source_must_not_be_git_checkout") + + if source_mode != "archive" and detected_commit: + status = _git_output(source, "status", "--porcelain=v1", "--untracked-files=all") + ignored = _git_output(source, "ls-files", "--others", "--ignored", "--exclude-standard") + if status is None or ignored is None: + raise ImportRejected("git_source_status_unavailable") + if status or ignored: + raise ImportRejected("git_source_must_be_clean") + if explicit_commit and explicit_commit != detected_commit: + raise ImportRejected("source_commit_mismatch") + if expected_tree and expected_tree != actual_tree: + raise ImportRejected("source_tree_sha256_mismatch") + return "git", detected_commit + + if source_mode == "git": + raise ImportRejected("git_source_metadata_unavailable") + if explicit_commit is None: + raise ImportRejected("archive_source_commit_required") + if expected_tree is None: + raise ImportRejected("archive_expected_tree_sha256_required") + if expected_tree != actual_tree: + raise ImportRejected("source_tree_sha256_mismatch") + return "archive", explicit_commit def _normalized_remote(root: Path) -> str | None: @@ -124,6 +222,54 @@ def _normalized_remote(root: Path) -> str | None: return value.rstrip("/") +def _repository_identity(value: str) -> tuple[str, str] | None: + value = value.strip().removesuffix(".git").rstrip("/") + if not value: + return None + if "://" in value: + parsed = urlsplit(value) + host = (parsed.hostname or "").casefold() + path = parsed.path + elif ":" in value and "@" in value.split(":", 1)[0]: + host = value.split("@", 1)[1].split(":", 1)[0].casefold() + path = value.split(":", 1)[1] + else: + return None + parts = [part for part in path.split("/") if part] + if not host or len(parts) < 2: + return None + return host, "/".join(parts).casefold() + + +def _remote_repository_identity(root: Path) -> tuple[str, str] | None: + remote = _git_output(root, "remote", "get-url", "origin") + return _repository_identity(remote or "") + + +def validate_git_source_repository(source: Path, expected_repository: str, expected_url: str) -> None: + expected = expected_repository.strip().removesuffix(".git").strip("/").casefold() + expected_identity = _repository_identity(expected_url) + actual = _remote_repository_identity(source) + if actual is None: + raise ImportRejected("git_source_origin_unavailable") + if expected_identity is None or expected_identity[1] != expected: + raise ImportRejected("source_repository_url_invalid") + if actual != expected_identity: + raise ImportRejected(f"git_source_repository_mismatch:{actual[0]}/{actual[1]}") + + +def validate_clean_target(target: Path) -> None: + top_level = _git_output(target, "rev-parse", "--show-toplevel") + if not top_level or Path(top_level).resolve() != target.resolve(): + raise ImportRejected("target_must_be_git_repository_root") + status = _git_output(target, "status", "--porcelain=v1", "--untracked-files=all") + ignored = _git_output(target, "ls-files", "--others", "--ignored", "--exclude-standard") + if status is None or ignored is None: + raise ImportRejected("target_status_unavailable") + if status or ignored: + raise ImportRejected("target_must_be_clean") + + def validate_roots(source: Path, target: Path) -> None: source = source.expanduser().resolve(strict=True) target = target.expanduser().resolve(strict=True) @@ -141,7 +287,11 @@ def source_tree_hash(source: Path) -> str: digest = hashlib.sha256() for path in sorted(source.rglob("*"), key=lambda item: item.relative_to(source).as_posix()): relative = path.relative_to(source).as_posix() - if ".git" in PurePosixPath(relative).parts or path.is_symlink() or not path.is_file(): + if ".git" in PurePosixPath(relative).parts: + continue + if path.is_symlink(): + raise ImportRejected(f"source_tree_symlink_rejected:{relative}") + if not path.is_file(): continue digest.update(relative.encode("utf-8")) digest.update(b"\0") @@ -150,6 +300,37 @@ def source_tree_hash(source: Path) -> str: return digest.hexdigest() +def _normalized_commit_timestamp(value: str | None) -> str | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return parsed.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def deterministic_generated_at( + source: Path, + *, + source_mode: str, + source_commit: str, + target: Path, + target_commit: str, +) -> str: + timestamp = None + if source_mode == "git": + timestamp = _git_output(source, "show", "-s", "--format=%cI", source_commit) + if timestamp is None: + timestamp = _git_output(target, "show", "-s", "--format=%cI", target_commit) + normalized = _normalized_commit_timestamp(timestamp) + if normalized is None: + raise ImportRejected("deterministic_generated_at_unavailable") + return normalized + + def _privacy_rejection(path: Path, relative: str, privacy: dict[str, Any]) -> str | None: lowered = relative.casefold() basenames = {str(value).casefold() for value in privacy.get("forbidden_basenames", [])} @@ -189,17 +370,50 @@ def build_manifest( policy_path: Path = DEFAULT_POLICY, target: Path = ROOT, apply: bool = False, + source_mode: str = "auto", + source_commit: str | None = None, + expected_tree_sha256: str | None = None, + tests_run: list[str] | None = None, ) -> dict[str, Any]: source = source.expanduser().resolve(strict=True) target = target.expanduser().resolve(strict=True) - validate_roots(source, target) policy = load_policy(policy_path) + validate_roots(source, target) + validate_clean_target(target) protected = list(policy["modes"]["protected"]) privacy = policy.get("privacy") if isinstance(policy.get("privacy"), dict) else {} - source_mode, source_commit = source_identity(source) - target_commit = _git_output(target, "rev-parse", "HEAD") - if not target_commit or len(target_commit) != 40: + actual_tree_sha256 = source_tree_hash(source) + resolved_source_mode, resolved_source_commit = source_identity( + source, + source_mode=source_mode, + source_commit=source_commit, + expected_tree_sha256=expected_tree_sha256, + actual_tree_sha256=actual_tree_sha256, + ) + if resolved_source_mode == "git": + validate_git_source_repository( + source, + str(policy["source_repository"]), + str(policy["source_repository_url"]), + ) + source_skill_file = _resolved_file(source, "SKILL.md", must_exist=True) + if not source_skill_file.exists(): + raise ImportRejected("root_skill_missing") + source_skill_sha256 = sha256_file(source_skill_file) + target_commit = _normalized_hex( + _git_output(target, "rev-parse", "HEAD"), + length=COMMIT_HEX_LENGTH, + label="target_base_commit", + ) + if target_commit is None: raise ImportRejected("target_base_commit_unavailable") + generated_at = deterministic_generated_at( + source, + source_mode=resolved_source_mode, + source_commit=resolved_source_commit, + target=target, + target_commit=target_commit, + ) mirror_files: list[dict[str, Any]] = [] privacy_rejections: list[str] = [] @@ -291,22 +505,41 @@ def build_manifest( return { "schema_version": 1, "source_repository": policy["source_repository"], - "source_commit": source_commit, - "source_tree_hash": source_tree_hash(source), - "source_mode": source_mode, + "source_repository_url": policy["source_repository_url"], + "source_commit": resolved_source_commit, + "source_tree_hash": actual_tree_sha256, + "source_skill_sha256": source_skill_sha256, + "source_mode": resolved_source_mode, "target_repository": policy["target_repository"], - "target_base_commit": target_commit.lower(), + "target_base_commit": target_commit, "policy_version": 2, - "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "generated_at": generated_at, "mirror_files": mirror_files, "semantic_merge_files": semantic_files, "protected_rejections": [], - "tests_run": [], + "tests_run": sorted(set(tests_run or [])), "privacy_scan": {"status": "pass", "scanned_files": len(mirror_files), "rejections": []}, "operator_review_required": any(row["status"] in {"review_required", "target_missing"} for row in semantic_files), } +def validate_output_path(path: Path, *, target: Path = ROOT, source: Path | None = None) -> Path: + """Allow manifests outside the target or inside the dedicated import-record directory only.""" + resolved = path.expanduser().resolve(strict=False) + target_resolved = target.expanduser().resolve(strict=True) + if source is not None: + source_resolved = source.expanduser().resolve(strict=True) + if resolved == source_resolved or source_resolved in resolved.parents: + raise ImportRejected("manifest_output_must_not_overlap_source") + if resolved == target_resolved or target_resolved in resolved.parents: + import_root = (target_resolved / IMPORT_RECORD_DIR.relative_to(ROOT)).resolve(strict=False) + if import_root not in resolved.parents: + raise ImportRejected("manifest_output_must_be_external_or_import_record_json") + if resolved.suffix.casefold() != ".json": + raise ImportRejected("manifest_output_must_be_json") + return resolved + + def write_json_atomic(path: Path, value: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) data = (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") @@ -326,14 +559,27 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source", type=Path, required=True) parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY) + parser.add_argument("--source-mode", choices=("auto", "git", "archive"), default="auto") + parser.add_argument("--source-commit") + parser.add_argument("--expected-tree-sha256") + parser.add_argument("--test-run", action="append", default=[], help="Test path recorded in the manifest; repeatable.") mode = parser.add_mutually_exclusive_group() mode.add_argument("--dry-run", action="store_true") mode.add_argument("--apply", action="store_true") parser.add_argument("--output", type=Path, required=True) args = parser.parse_args(argv) try: - manifest = build_manifest(args.source, policy_path=args.policy, apply=args.apply) - write_json_atomic(args.output, manifest) + output = validate_output_path(args.output, source=args.source) + manifest = build_manifest( + args.source, + policy_path=args.policy, + apply=args.apply, + source_mode=args.source_mode, + source_commit=args.source_commit, + expected_tree_sha256=args.expected_tree_sha256, + tests_run=args.test_run, + ) + write_json_atomic(output, manifest) except ImportRejected as error: print(json.dumps({"status": "rejected", "reason": str(error)}, ensure_ascii=False), file=sys.stderr) return EXIT_POLICY_REJECTED diff --git a/tests/test_import_yinduzhanxing.py b/tests/test_import_yinduzhanxing.py index b0dd0c3d..0ed604b6 100644 --- a/tests/test_import_yinduzhanxing.py +++ b/tests/test_import_yinduzhanxing.py @@ -16,6 +16,7 @@ if str(SCRIPTS) not in sys.path: import import_yinduzhanxing as importer # noqa: E402 POLICY = ROOT / "references/cross_project_contract/sync_policy.v2.json" +ARCHIVE_COMMIT = "5db72537741fcedaa7b5498502d4a31b0f9fc147" def _git(root: Path, *args: str) -> str: @@ -47,10 +48,31 @@ def _source(tmp_path: Path) -> Path: return source -def _policy(tmp_path: Path, *, mirror: list[dict[str, str]] | None = None) -> Path: +def _git_source(tmp_path: Path) -> Path: + source = _source(tmp_path) + subprocess.run(["git", "init", "-q", str(source)], check=True) + subprocess.run(["git", "-C", str(source), "config", "user.email", "tests@example.invalid"], check=True) + subprocess.run(["git", "-C", str(source), "config", "user.name", "Tests"], check=True) + subprocess.run( + ["git", "-C", str(source), "remote", "add", "origin", "https://github.com/732642856/yinduzhanxing.git"], + check=True, + ) + subprocess.run(["git", "-C", str(source), "add", "."], check=True) + subprocess.run(["git", "-C", str(source), "commit", "-qm", "research base"], check=True) + return source + + +def _policy( + tmp_path: Path, + *, + mirror: list[dict[str, str]] | None = None, + protected: list[str] | None = None, +) -> Path: value = json.loads(POLICY.read_text(encoding="utf-8")) if mirror is not None: value["modes"]["mirror"] = mirror + if protected is not None: + value["modes"]["protected"] = protected path = tmp_path / "policy.json" path.write_text(json.dumps(value), encoding="utf-8") return path @@ -60,37 +82,186 @@ def test_v2_policy_is_strictly_one_way_and_has_no_reverse_gate() -> None: policy = importer.load_policy(POLICY) assert policy["direction"] == "research_to_commercial_only" assert policy["reverse_sync"] == "forbidden" + assert policy["source_repository_url"] == "https://github.com/732642856/yinduzhanxing" assert "commercial_to_research" not in json.dumps(policy, sort_keys=True) assert policy["modes"]["protected"] -def test_dry_run_writes_no_target_file_and_snapshot_commit_is_unknown(tmp_path: Path) -> None: +def test_policy_cannot_remove_mandatory_commercial_protections(tmp_path: Path) -> None: + weakened = sorted(importer.REQUIRED_PROTECTED_PATTERNS - {"frontend/**"}) + policy = _policy(tmp_path, protected=weakened) + with pytest.raises(importer.ImportRejected, match="required_protected_patterns_missing:frontend/\\*\\*"): + importer.load_policy(policy) + + +def _archive_kwargs(source: Path) -> dict[str, str]: + return { + "source_mode": "archive", + "source_commit": ARCHIVE_COMMIT, + "expected_tree_sha256": importer.source_tree_hash(source), + } + + +def test_dry_run_writes_no_target_file_and_records_bound_archive_identity(tmp_path: Path) -> None: source, target = _source(tmp_path), _target(tmp_path) before = _git(target, "status", "--porcelain=v1") - manifest = importer.build_manifest(source, policy_path=POLICY, target=target) + manifest = importer.build_manifest(source, policy_path=POLICY, target=target, **_archive_kwargs(source)) after = _git(target, "status", "--porcelain=v1") assert before == after == "" - assert manifest["source_mode"] == "snapshot" - assert manifest["source_commit"] == "unknown" + assert manifest["source_mode"] == "archive" + assert manifest["source_commit"] == ARCHIVE_COMMIT + assert manifest["source_tree_hash"] == importer.source_tree_hash(source) assert manifest["mirror_files"][0]["status"] == "new" assert not (target / "references/upstream/yinduzhanxing/SKILL.md").exists() +@pytest.mark.parametrize( + ("source_commit", "expected_tree_sha256", "error"), + [ + (ARCHIVE_COMMIT, None, "archive_expected_tree_sha256_required"), + (None, "0" * 64, "archive_source_commit_required"), + (ARCHIVE_COMMIT, "0" * 64, "source_tree_sha256_mismatch"), + ], +) +def test_archive_identity_requires_commit_and_matching_tree_hash( + tmp_path: Path, source_commit: str | None, expected_tree_sha256: str | None, error: str +) -> None: + source, target = _source(tmp_path), _target(tmp_path) + with pytest.raises(importer.ImportRejected, match=error): + importer.build_manifest( + source, + policy_path=POLICY, + target=target, + source_mode="archive", + source_commit=source_commit, + expected_tree_sha256=expected_tree_sha256, + ) + + + + +@pytest.mark.parametrize( + ("source_commit", "expected_tree_sha256"), + [(ARCHIVE_COMMIT, None), (None, "0" * 64)], +) +def test_explicit_provenance_pins_must_be_provided_together_for_git_sources( + tmp_path: Path, source_commit: str | None, expected_tree_sha256: str | None +) -> None: + source, target = _git_source(tmp_path), _target(tmp_path) + with pytest.raises(importer.ImportRejected, match="source_commit_and_expected_tree_sha256_must_be_provided_together"): + importer.build_manifest( + source, + policy_path=POLICY, + target=target, + source_mode="git", + source_commit=source_commit, + expected_tree_sha256=expected_tree_sha256, + ) + + +def test_unbound_non_git_source_no_longer_emits_unknown_snapshot(tmp_path: Path) -> None: + source, target = _source(tmp_path), _target(tmp_path) + with pytest.raises(importer.ImportRejected, match="archive_source_commit_required"): + importer.build_manifest(source, policy_path=POLICY, target=target) + + +def test_git_source_must_be_clean_and_is_bound_to_head(tmp_path: Path) -> None: + source, target = _git_source(tmp_path), _target(tmp_path) + manifest = importer.build_manifest(source, policy_path=POLICY, target=target) + assert manifest["source_mode"] == "git" + assert manifest["source_commit"] == _git(source, "rev-parse", "HEAD") + + (source / "SKILL.md").write_text("# Dirty tracked research skill\n", encoding="utf-8") + with pytest.raises(importer.ImportRejected, match="git_source_must_be_clean"): + importer.build_manifest(source, policy_path=POLICY, target=target) + + subprocess.run(["git", "-C", str(source), "restore", "SKILL.md"], check=True) + (source / "untracked.txt").write_text("not committed\n", encoding="utf-8") + with pytest.raises(importer.ImportRejected, match="git_source_must_be_clean"): + importer.build_manifest(source, policy_path=POLICY, target=target) + + +def test_git_source_cannot_be_forced_through_archive_mode(tmp_path: Path) -> None: + source, target = _git_source(tmp_path), _target(tmp_path) + with pytest.raises(importer.ImportRejected, match="archive_source_must_not_be_git_checkout"): + importer.build_manifest(source, policy_path=POLICY, target=target, **_archive_kwargs(source)) + + +def test_git_source_origin_must_match_policy_repository(tmp_path: Path) -> None: + source, target = _git_source(tmp_path), _target(tmp_path) + subprocess.run( + ["git", "-C", str(source), "remote", "set-url", "origin", "https://github.com/example/not-the-upstream.git"], + check=True, + ) + with pytest.raises(importer.ImportRejected, match="git_source_repository_mismatch"): + importer.build_manifest(source, policy_path=POLICY, target=target) + + subprocess.run( + ["git", "-C", str(source), "remote", "set-url", "origin", "https://evil.example/732642856/yinduzhanxing.git"], + check=True, + ) + with pytest.raises(importer.ImportRejected, match="git_source_repository_mismatch"): + importer.build_manifest(source, policy_path=POLICY, target=target) + + subprocess.run( + ["git", "-C", str(source), "remote", "set-url", "origin", "https://github.com/attacker/732642856/yinduzhanxing.git"], + check=True, + ) + with pytest.raises(importer.ImportRejected, match="git_source_repository_mismatch"): + importer.build_manifest(source, policy_path=POLICY, target=target) + + +def test_manifest_output_cannot_overwrite_commercial_repository_files(tmp_path: Path) -> None: + protected = ROOT / "SKILL.md" + before = protected.read_bytes() + with pytest.raises(importer.ImportRejected, match="manifest_output_must_be_external_or_import_record_json"): + importer.validate_output_path(protected) + assert protected.read_bytes() == before + + external = tmp_path / "manifest.json" + assert importer.validate_output_path(external) == external.resolve() + internal = ROOT / "references/cross_project_contract/imports/test-output.json" + assert importer.validate_output_path(internal) == internal.resolve() + + source, target = _source(tmp_path), _target(tmp_path) + with pytest.raises(importer.ImportRejected, match="manifest_output_must_not_overlap_source"): + importer.validate_output_path(source / "manifest.json", target=target, source=source) + with pytest.raises(importer.ImportRejected, match="manifest_output_must_be_json"): + importer.validate_output_path(tmp_path / "manifest.txt", target=target, source=source) + + +def test_fixed_archive_inputs_build_byte_equivalent_manifests(tmp_path: Path) -> None: + source, target = _source(tmp_path), _target(tmp_path) + kwargs = _archive_kwargs(source) + + recorded_tests = ["tests/test_upstream_import_plan.py", "tests/test_import_yinduzhanxing.py", "tests/test_import_yinduzhanxing.py"] + first = importer.build_manifest(source, policy_path=POLICY, target=target, tests_run=recorded_tests, **kwargs) + second = importer.build_manifest(source, policy_path=POLICY, target=target, tests_run=list(reversed(recorded_tests)), **kwargs) + + assert first == second + assert json.dumps(first, ensure_ascii=False, sort_keys=True) == json.dumps(second, ensure_ascii=False, sort_keys=True) + assert first["source_skill_sha256"] == hashlib.sha256((source / "SKILL.md").read_bytes()).hexdigest() + assert first["tests_run"] == ["tests/test_import_yinduzhanxing.py", "tests/test_upstream_import_plan.py"] + + def test_apply_handles_new_update_and_unchanged_with_recomputable_hashes(tmp_path: Path) -> None: source, target = _source(tmp_path), _target(tmp_path) destination = target / "references/upstream/yinduzhanxing/SKILL.md" - first = importer.build_manifest(source, policy_path=POLICY, target=target, apply=True) + first = importer.build_manifest(source, policy_path=POLICY, target=target, apply=True, **_archive_kwargs(source)) assert first["mirror_files"][0]["status"] == "applied" assert destination.read_bytes() == (source / "SKILL.md").read_bytes() - unchanged = importer.build_manifest(source, policy_path=POLICY, target=target) + subprocess.run(["git", "-C", str(target), "add", "references/upstream/yinduzhanxing/SKILL.md"], check=True) + subprocess.run(["git", "-C", str(target), "commit", "-qm", "record mirrored skill"], check=True) + + unchanged = importer.build_manifest(source, policy_path=POLICY, target=target, **_archive_kwargs(source)) assert unchanged["mirror_files"][0]["status"] == "unchanged" assert unchanged["mirror_files"][0]["source_sha256"] == hashlib.sha256(destination.read_bytes()).hexdigest() (source / "SKILL.md").write_text("# Updated research skill\n", encoding="utf-8") - update = importer.build_manifest(source, policy_path=POLICY, target=target) + update = importer.build_manifest(source, policy_path=POLICY, target=target, **_archive_kwargs(source)) assert update["mirror_files"][0]["status"] == "update" assert update["mirror_files"][0]["target_sha256_before"] != update["mirror_files"][0]["target_sha256_after"] @@ -98,9 +269,11 @@ def test_apply_handles_new_update_and_unchanged_with_recomputable_hashes(tmp_pat def test_semantic_files_never_overwrite_target(tmp_path: Path) -> None: source, target = _source(tmp_path), _target(tmp_path) (target / "SKILL.md").write_text("commercial skill\n", encoding="utf-8") + subprocess.run(["git", "-C", str(target), "add", "SKILL.md"], check=True) + subprocess.run(["git", "-C", str(target), "commit", "-qm", "add commercial skill"], check=True) before = (target / "SKILL.md").read_bytes() - manifest = importer.build_manifest(source, policy_path=POLICY, target=target, apply=True) + manifest = importer.build_manifest(source, policy_path=POLICY, target=target, apply=True, **_archive_kwargs(source)) assert (target / "SKILL.md").read_bytes() == before row = next(item for item in manifest["semantic_merge_files"] if item["path"] == "SKILL.md") @@ -112,16 +285,31 @@ def test_protected_mapping_is_rejected_instead_of_silently_skipped(tmp_path: Pat source, target = _source(tmp_path), _target(tmp_path) policy = _policy(tmp_path, mirror=[{"source": "SKILL.md", "target": "frontend/src/stolen.ts", "license": "MIT"}]) with pytest.raises(importer.ImportRejected, match="protected_rejected"): - importer.build_manifest(source, policy_path=policy, target=target, apply=True) + importer.build_manifest(source, policy_path=policy, target=target, apply=True, **_archive_kwargs(source)) assert not (target / "frontend/src/stolen.ts").exists() +@pytest.mark.parametrize( + "target_path", + [ + "SKILL.md", + "skills/jyotish-vedic-astrology/SKILL.md", + "skills/jyotish-birth-time-rectification/SKILL.md", + "skills/jyotish-birth-time-rectification/references/private.md", + ], +) +def test_commercial_skill_surfaces_cannot_be_mirror_targets(tmp_path: Path, target_path: str) -> None: + policy = _policy(tmp_path, mirror=[{"source": "SKILL.md", "target": target_path, "license": "MIT"}]) + with pytest.raises(importer.ImportRejected, match="protected_rejected"): + importer.load_policy(policy) + + @pytest.mark.parametrize("path", ["../escape", "/absolute/path", "safe/../../escape"]) def test_path_traversal_and_absolute_paths_are_rejected(tmp_path: Path, path: str) -> None: source, target = _source(tmp_path), _target(tmp_path) policy = _policy(tmp_path, mirror=[{"source": "SKILL.md", "target": path, "license": "MIT"}]) with pytest.raises(importer.ImportRejected, match="unsafe_path"): - importer.build_manifest(source, policy_path=policy, target=target) + importer.build_manifest(source, policy_path=policy, target=target, **_archive_kwargs(source)) def test_symlink_escape_is_rejected(tmp_path: Path) -> None: @@ -130,8 +318,29 @@ def test_symlink_escape_is_rejected(tmp_path: Path) -> None: outside.write_text("outside\n", encoding="utf-8") (source / "linked.md").symlink_to(outside) policy = _policy(tmp_path, mirror=[{"source": "linked.md", "target": "references/upstream/linked.md", "license": "MIT"}]) - with pytest.raises(importer.ImportRejected, match="symlink_escape|non_regular_or_symlink"): - importer.build_manifest(source, policy_path=policy, target=target) + with pytest.raises(importer.ImportRejected, match="source_tree_symlink_rejected|symlink_escape|non_regular_or_symlink"): + importer.build_manifest(source, policy_path=policy, target=target, **_archive_kwargs(source)) + + +def test_target_parent_symlink_cannot_redirect_mirror_into_protected_surface(tmp_path: Path) -> None: + source, target = _source(tmp_path), _target(tmp_path) + (target / "frontend").mkdir() + (target / "frontend/.keep").write_text("protected\n", encoding="utf-8") + (target / "references/upstream").mkdir(parents=True) + (target / "references/upstream/yinduzhanxing").symlink_to("../../frontend") + subprocess.run(["git", "-C", str(target), "add", "frontend", "references/upstream/yinduzhanxing"], check=True) + subprocess.run(["git", "-C", str(target), "commit", "-qm", "add redirecting symlink"], check=True) + + with pytest.raises(importer.ImportRejected, match="symlink_component_rejected"): + importer.build_manifest(source, policy_path=POLICY, target=target, apply=True, **_archive_kwargs(source)) + assert not (target / "frontend/SKILL.md").exists() + + +def test_target_worktree_must_be_clean_for_deterministic_manifest(tmp_path: Path) -> None: + source, target = _source(tmp_path), _target(tmp_path) + (target / "untracked.txt").write_text("dirty target\n", encoding="utf-8") + with pytest.raises(importer.ImportRejected, match="target_must_be_clean"): + importer.build_manifest(source, policy_path=POLICY, target=target, **_archive_kwargs(source)) def test_sensitive_source_is_rejected(tmp_path: Path) -> None: @@ -139,7 +348,7 @@ def test_sensitive_source_is_rejected(tmp_path: Path) -> None: (source / ".env").write_text("OPENAI_API_KEY=not-a-real-key\n", encoding="utf-8") policy = _policy(tmp_path, mirror=[{"source": ".env", "target": "references/upstream/env.txt", "license": "MIT"}]) with pytest.raises(importer.ImportRejected, match="sensitive_filename|sensitive_content"): - importer.build_manifest(source, policy_path=policy, target=target) + importer.build_manifest(source, policy_path=policy, target=target, **_archive_kwargs(source)) def test_overlapping_source_and_target_are_rejected(tmp_path: Path) -> None: @@ -169,6 +378,6 @@ def test_apply_rolls_back_earlier_files_when_later_replace_fails(tmp_path: Path, monkeypatch.setattr(importer.os, "replace", fail_second) with pytest.raises(OSError, match="simulated"): - importer.build_manifest(source, policy_path=policy, target=target, apply=True) + importer.build_manifest(source, policy_path=policy, target=target, apply=True, **_archive_kwargs(source)) assert not (target / "references/upstream/one.md").exists() assert not (target / "references/upstream/two.md").exists() diff --git a/tests/test_report_orchestrator_reader_contract.py b/tests/test_report_orchestrator_reader_contract.py index 855ce41f..80863d1a 100644 --- a/tests/test_report_orchestrator_reader_contract.py +++ b/tests/test_report_orchestrator_reader_contract.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib import json +import re from pathlib import Path from scripts.report_orchestrator import render_reader_report @@ -42,9 +43,11 @@ def test_upstream_skill_snapshot_matches_manifest_and_commercial_root_remains_ro root_skill = (ROOT / "SKILL.md").read_text(encoding="utf-8") linked_skill = ROOT / "skills/jyotish-vedic-astrology/SKILL.md" - assert hashlib.sha256(snapshot.read_bytes()).hexdigest() == manifest["skill_sha256"] - assert manifest["source_mode"] == "snapshot" - assert manifest["source_commit"] is None + snapshot_hash = hashlib.sha256(snapshot.read_bytes()).hexdigest() + assert snapshot_hash == manifest.get("source_skill_sha256", manifest["skill_sha256"]) + assert manifest["source_mode"] == "archive" + assert re.fullmatch(r"[0-9a-f]{40}", manifest["source_commit"]) + assert re.fullmatch(r"[0-9a-f]{64}", manifest["source_tree_sha256"]) assert linked_skill.resolve() == (ROOT / "SKILL.md").resolve() assert "商业运行时路由(最高优先级)" in root_skill assert "references/upstream/yinduzhanxing/SKILL.md" in root_skill diff --git a/tests/test_upstream_import_plan.py b/tests/test_upstream_import_plan.py new file mode 100644 index 00000000..07e2af7f --- /dev/null +++ b/tests/test_upstream_import_plan.py @@ -0,0 +1,157 @@ +"""PR-0 selective upstream import-plan and provenance contracts.""" +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path + +from jsonschema import Draft202012Validator, FormatChecker + +ROOT = Path(__file__).resolve().parents[1] +UPSTREAM = ROOT / "references/upstream/yinduzhanxing" +PLAN = UPSTREAM / "import-plan.json" +SOURCE_MANIFEST = UPSTREAM / "source-manifest.json" +IMPORT_SCHEMA = ROOT / "references/cross_project_contract/import_manifest.schema.json" +LEGACY_SNAPSHOT = ROOT / "references/cross_project_contract/imports/snapshot-9034e1967032d09c.json" +PINNED_IMPORT_RECORD = ROOT / "references/cross_project_contract/imports/commit-5db72537741fcedaa7b5498502d4a31b0f9fc147.json" + +EXPECTED_SOURCES = { + "scripts/evidence_maturity.py", + "scripts/assertion_policy.py", + "scripts/evidence_labeled_reporting.py", + "scripts/calculation_profile_contract.py", + "scripts/report_pack_contract.py", + "scripts/domain_profile_builder.py", + "scripts/unified_calculation_archive.py", + "scripts/specialized_evidence_packets.py", + "scripts/timing_evidence_packets.py", +} +EXPECTED_CATEGORIES = { + "calculation_contracts", + "evidence_assertion_contracts", + "report_domain_profile_contracts", + "research_oracle_assets", +} +EXPECTED_REJECTIONS = { + "fixed_event_domain_count_threshold", + "sparse_start_mid_end_rectification_scan", + "weaken_three_engine_gate", + "weaken_required_layers", + "weaken_stability_gate", + "remove_commercial_routes", +} +EXPECTED_UPSTREAM_IDENTITY = { + "source_repository": "732642856/yinduzhanxing", + "source_repository_url": "https://github.com/732642856/yinduzhanxing", + "source_commit": "5db72537741fcedaa7b5498502d4a31b0f9fc147", + "source_tree_sha256": "18e3122ef73c2776a950bfec128efeb09ac0524ec1d2179390260166b87ca814", +} +EXPECTED_SOURCE_GIT_TREE = "16935cb68a6fa1ef72661cfca4650a42c60e9b2c" +EXPECTED_ARCHIVE_SHA256 = "07d6b71af311160c544433eb9d51beb5b0a5e4bd0afabd4a7d43d3edb573518d" +EXPECTED_ARCHIVE_FILE_COUNT = 2901 +EXPECTED_SOURCE_SKILL_SHA256 = "ef453dd8dd4a9da72b56010ec33bb7dab57fd72333d8785cac0986d5456e17c5" + + +def _load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def test_import_plan_records_all_nine_upstream_sources_and_semantic_destinations() -> None: + plan = _load(PLAN) + entries = plan["entries"] + assert len(entries) == 9 + assert {entry["source"] for entry in entries} == EXPECTED_SOURCES + assert {entry["category"] for entry in entries} == EXPECTED_CATEGORIES + assert [entry["action"] for entry in entries].count("adapt") == 8 + assert [entry["action"] for entry in entries].count("reference_only") == 1 + + for entry in entries: + assert set(("source", "category", "action", "reason", "targets", "tests")) <= set(entry) + assert entry["source"].startswith("scripts/") + assert entry["reason"] + assert entry["targets"] and all(target.startswith(("scripts/", "references/")) for target in entry["targets"]) + assert entry["tests"] and all(test.startswith("tests/") for test in entry["tests"]) + + archive = next(entry for entry in entries if entry["source"] == "scripts/unified_calculation_archive.py") + assert archive["action"] == "reference_only" + assert "runtime truth" in archive["reason"] + assert "none of these research scripts is copied" in plan["boundary"] + + +def test_rejected_regressions_protect_server_truth_gates_and_commercial_routes() -> None: + plan = _load(PLAN) + rejections = plan["rejected_regressions"] + assert {item["id"] for item in rejections} == EXPECTED_REJECTIONS + assert all(item["action"] == "reject" for item in rejections) + + by_id = {item["id"]: item for item in rejections} + fixed_count = by_id["fixed_event_domain_count_threshold"]["reason"].lower() + assert "3 events" in fixed_count and "2 domains" in fixed_count + assert "not sufficient" in fixed_count and "cannot bypass" in fixed_count + + sparse_scan = by_id["sparse_start_mid_end_rectification_scan"]["reason"].lower() + assert all(token in sparse_scan for token in ("start", "midpoint", "end")) + assert "not a complete" in sparse_scan + + for rejection_id in ( + "weaken_three_engine_gate", + "weaken_required_layers", + "weaken_stability_gate", + ): + assert by_id[rejection_id]["action"] == "reject" + assert by_id[rejection_id]["commercial_owner"].startswith("scripts/") + + routes = by_id["remove_commercial_routes"] + assert routes["preserved_routes"] == ["migration", "family", "annual"] + assert "commercial-runtime-owned" in routes["reason"] + + +def test_formal_varga_is_evidence_only_and_cannot_close_local_truth() -> None: + policy = _load(PLAN)["formal_varga_policy"] + assert policy["action"] == "reference_only" + reason = policy["reason"].lower() + for token in ("evidence_only", "cannot claim", "cannot", "api key"): + assert token in reason + assert "local-formula parity" in reason + assert "required layers" in reason + + +def test_source_manifest_pins_valid_archive_commit_tree_and_root_skill_hash() -> None: + manifest = _load(SOURCE_MANIFEST) + plan = _load(PLAN) + assert manifest["source_mode"] == "archive" + assert manifest["import_policy_version"] == 2 + assert manifest["imported_at"] != manifest["source_committed_at"] + assert {key: manifest[key] for key in EXPECTED_UPSTREAM_IDENTITY} == EXPECTED_UPSTREAM_IDENTITY + assert {key: plan[key] for key in EXPECTED_UPSTREAM_IDENTITY} == EXPECTED_UPSTREAM_IDENTITY + assert manifest["source_git_tree"] == EXPECTED_SOURCE_GIT_TREE + assert manifest["archive_sha256"] == EXPECTED_ARCHIVE_SHA256 + assert manifest["archive_file_count"] == EXPECTED_ARCHIVE_FILE_COUNT + assert manifest["source_committed_at"] == "2026-08-13T18:03:53Z" + assert re.fullmatch(r"[0-9a-f]{40}", manifest["source_commit"]) + assert re.fullmatch(r"[0-9a-f]{64}", manifest["source_tree_sha256"]) + source_skill_sha256 = manifest.get("source_skill_sha256", manifest["skill_sha256"]) + assert re.fullmatch(r"[0-9a-f]{64}", source_skill_sha256) + snapshot_hash = hashlib.sha256((UPSTREAM / "SKILL.md").read_bytes()).hexdigest() + assert source_skill_sha256 == snapshot_hash == EXPECTED_SOURCE_SKILL_SHA256 + assert manifest["skill_sha256"] == manifest["source_skill_sha256"] + + +def test_import_manifest_schema_accepts_archive_and_legacy_snapshot_records() -> None: + schema = _load(IMPORT_SCHEMA) + validator = Draft202012Validator(schema, format_checker=FormatChecker()) + legacy = _load(LEGACY_SNAPSHOT) + pinned = _load(PINNED_IMPORT_RECORD) + validator.validate(legacy) + validator.validate(pinned) + invalid_git = dict(pinned, source_mode="git", source_commit="unknown") + assert list(validator.iter_errors(invalid_git)) + assert "archive" in schema["properties"]["source_mode"]["enum"] + source_manifest = _load(SOURCE_MANIFEST) + plan = _load(PLAN) + assert pinned["source_repository"] == source_manifest["source_repository"] == plan["source_repository"] + assert pinned["source_repository_url"] == source_manifest["source_repository_url"] == plan["source_repository_url"] == EXPECTED_UPSTREAM_IDENTITY["source_repository_url"] + assert pinned["source_commit"] == source_manifest["source_commit"] == plan["source_commit"] + assert pinned["source_tree_hash"] == source_manifest["source_tree_sha256"] == plan["source_tree_sha256"] + assert pinned["source_skill_sha256"] == source_manifest["source_skill_sha256"] == EXPECTED_SOURCE_SKILL_SHA256