claude-dev-env 2.9.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -2
- package/_shared/advisor/CLAUDE.md +3 -2
- package/_shared/advisor/advisor-protocol.md +74 -108
- package/_shared/advisor/reference/advisor-block.md +37 -0
- package/_shared/advisor/reference/cli-chain.md +45 -0
- package/_shared/advisor/reference/consult-format.md +41 -0
- package/_shared/advisor/reference/lifecycle.md +21 -0
- package/_shared/advisor/reference/sol-rung.md +31 -0
- package/_shared/advisor/reference/spawn-walk-log.md +31 -0
- package/_shared/advisor/reference/third-party-bind.md +30 -0
- package/_shared/advisor/reference/warm-up.md +33 -0
- package/_shared/advisor/scripts/codex_sol_advisor.py +449 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +21 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +19 -17
- package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +28 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +32 -9
- package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +474 -0
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +79 -0
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +39 -17
- package/_shared/advisor/scripts/tier_model_ids.py +24 -0
- package/_shared/pr-loop/CLAUDE.md +1 -1
- package/_shared/pr-loop/audit-contract.md +17 -6
- package/_shared/pr-loop/audit-reply-template.md +4 -4
- package/_shared/pr-loop/code-rules-gate.md +3 -5
- package/_shared/pr-loop/fix-protocol.md +2 -3
- package/_shared/pr-loop/gh-payloads.md +1 -1
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/README.md +1 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
- package/_shared/pr-loop/scripts/preflight.py +9 -4
- package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
- package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
- package/_shared/pr-loop/state-schema.md +5 -14
- package/agents/CLAUDE.md +2 -2
- package/agents/clean-coder.md +58 -548
- package/agents/code-quality-agent.md +10 -2
- package/agents/code-verifier.md +1 -1
- package/agents/test_agent_frontmatter.py +32 -40
- package/audit-rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/audit-categories.json +704 -0
- package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
- package/bin/CLAUDE.md +16 -5
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/install-plan.mjs +402 -0
- package/bin/install-transaction.mjs +455 -0
- package/bin/install.mjs +593 -147
- package/bin/install.plan.test.mjs +194 -0
- package/bin/install.profile-root.test.mjs +154 -0
- package/bin/install.profiles.test.mjs +253 -0
- package/bin/install.settings-defaults.test.mjs +200 -0
- package/bin/install.transaction.test.mjs +400 -0
- package/bin/install.uninstall-transaction.test.mjs +418 -0
- package/bin/merge_managed_permissions.mjs +130 -0
- package/bin/resolve-install-root.mjs +181 -0
- package/bin/select-install-targets.mjs +401 -0
- package/commands/CLAUDE.md +0 -2
- package/docs/references/CLAUDE.md +3 -1
- package/docs/references/advisor-tool.md +25 -7
- package/docs/references/prose-style-enforcement.md +25 -0
- package/docs/references/team-advisor-skill.md +3 -3
- package/docs/references/weak-executor-advisor.md +91 -0
- package/hooks/blocking/CLAUDE.md +6 -6
- package/hooks/blocking/_path_setup.py +9 -5
- package/hooks/blocking/code_rules_docstrings.py +124 -30
- package/hooks/blocking/code_rules_enforcer.py +161 -16
- package/hooks/blocking/code_rules_shared.py +40 -23
- package/hooks/blocking/config/CLAUDE.md +3 -5
- package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
- package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
- package/hooks/blocking/eli11_reply_enforcer.py +70 -113
- package/hooks/blocking/hedging_language_blocker.py +103 -20
- package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
- package/hooks/blocking/intent_only_ending_blocker.py +6 -0
- package/hooks/blocking/plain_language_blocker.py +139 -20
- package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
- package/hooks/blocking/state_description_blocker.py +7 -1
- package/hooks/blocking/tdd_enforcer.py +8 -0
- package/hooks/blocking/test__path_setup.py +28 -0
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
- package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
- package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
- package/hooks/blocking/test_fable_spawn_gate.py +18 -11
- package/hooks/blocking/test_hedging_language_blocker.py +120 -1
- package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
- package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
- package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
- package/hooks/blocking/test_plain_language_blocker.py +129 -19
- package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
- package/hooks/blocking/test_state_description_blocker.py +45 -2
- package/hooks/blocking/test_stop_dispatcher.py +11 -7
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
- package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/CLAUDE.md +14 -3
- package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
- package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
- package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
- package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
- package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/project_paths_reader.py +31 -4
- package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
- package/hooks/hooks_constants/pytest_invocation.py +354 -0
- package/hooks/hooks_constants/session_start_injector.py +163 -0
- package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
- package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
- package/hooks/hooks_constants/shell_command_segments.py +5 -0
- package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
- package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
- package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
- package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
- package/hooks/hooks_constants/test_session_start_injector.py +168 -0
- package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
- package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/prose_matcher_advisory.py +237 -0
- package/hooks/observability/test_prose_matcher_advisory.py +143 -0
- package/hooks/session/CLAUDE.md +9 -1
- package/hooks/session/_path_setup.py +13 -0
- package/hooks/session/issue_tracker_session_starter.py +135 -0
- package/hooks/session/orchestrator_auto_starter.py +100 -0
- package/hooks/session/test__path_setup.py +28 -0
- package/hooks/session/test_issue_tracker_session_starter.py +104 -0
- package/hooks/session/test_orchestrator_auto_starter.py +99 -0
- package/hooks/session/test_working_style_prompt.py +47 -0
- package/hooks/session/untracked_repo_detector.py +1 -24
- package/hooks/session/working_style_prompt.py +36 -0
- package/hooks/validators/_path_setup.py +19 -0
- package/hooks/validators/run_all_validators.py +8 -13
- package/installable-surfaces.manifest.json +21 -0
- package/output-styles/CLAUDE.md +1 -3
- package/package.json +4 -2
- package/rules/CLAUDE.md +1 -0
- package/rules/durable-post-artifacts.md +2 -2
- package/rules/eli11-replies.md +6 -1
- package/rules/hedging-claims.md +4 -2
- package/rules/long-horizon-autonomy.md +3 -1
- package/rules/opus5-communication-contract.md +45 -0
- package/rules/plain-language.md +2 -2
- package/rules/research-mode.md +1 -1
- package/scripts/CLAUDE.md +11 -0
- package/scripts/Sync-RepoMain.ps1 +215 -0
- package/scripts/active_capability_references.py +218 -0
- package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
- package/scripts/claude_chain_runner.py +394 -6
- package/scripts/claude_chain_usage.py +1 -1
- package/scripts/codex_compat_materializer.py +105 -85
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
- package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
- package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
- package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
- package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
- package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
- package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
- package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
- package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
- package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
- package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
- package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
- package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
- package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
- package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
- package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
- package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
- package/scripts/spawn_grok_batch.py +3 -0
- package/scripts/test_active_capability_references.py +108 -0
- package/scripts/test_claude_chain_runner.py +414 -82
- package/scripts/test_claude_chain_usage.py +12 -12
- package/scripts/test_resolve_worker_spawn.py +2 -2
- package/scripts/test_verify_installable_package.py +208 -0
- package/scripts/tests/test_codex_compat_materializer.py +33 -0
- package/scripts/verify_installable_package.py +612 -0
- package/settings.json +10 -0
- package/skills/CLAUDE.md +2 -0
- package/skills/_shared/advisor/CLAUDE.md +1 -1
- package/skills/_shared/advisor/scripts/README.md +2 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
- package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/stop-conditions.md +5 -3
- package/skills/beat-sheet/SKILL.md +54 -0
- package/skills/beat-sheet/reference/visual-beats.md +29 -0
- package/skills/bugteam/CONSTRAINTS.md +4 -4
- package/skills/bugteam/EXAMPLES.md +1 -1
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/e-code-review/SKILL.md +26 -5
- package/skills/e-code-review/reference/effort-evaluation.md +35 -0
- package/skills/e-code-review/reference/medium.md +15 -4
- package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
- package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
- package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
- package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
- package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
- package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
- package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
- package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
- package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
- package/skills/e-code-review/scripts/grok_code_review.py +16 -7
- package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
- package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
- package/skills/e-code-review/scripts/test_grok_code_review.py +77 -0
- package/skills/grokify/SKILL.md +1 -1
- package/skills/grokify/templates/handoff-template.md +2 -2
- package/skills/orchestrator/SKILL.md +5 -4
- package/skills/plan-to-pr/scripts/create_packet.py +4 -4
- package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
- package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
- package/skills/plan-to-pr/scripts/validate_run.py +4 -1
- package/skills/pr-converge/scripts/check_convergence.py +21 -19
- package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
- package/skills/pr-converge/scripts/conftest.py +35 -0
- package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/rebase/SKILL.md +15 -3
- package/skills/reviewer-gates/SKILL.md +2 -2
- package/skills/show/SKILL.md +51 -0
- package/skills/show/references/accessibility.md +7 -0
- package/skills/show/references/art.md +3 -0
- package/skills/show/references/charts.md +3 -0
- package/skills/show/references/core-design.md +14 -0
- package/skills/show/references/erds.md +3 -0
- package/skills/show/references/flowcharts.md +3 -0
- package/skills/show/references/host-and-html.md +3 -0
- package/skills/show/references/illustrative-diagrams.md +10 -0
- package/skills/show/references/interaction.md +3 -0
- package/skills/show/references/mockups.md +3 -0
- package/skills/show/references/quality-gates.md +7 -0
- package/skills/show/references/structural-diagrams.md +3 -0
- package/skills/show/references/subject-inventory.md +21 -0
- package/skills/show/references/svg-contract.md +22 -0
- package/skills/show/routing.yaml +30 -0
- package/skills/show/samples/pr1262-v2.svg +222 -0
- package/skills/show/scripts/README.md +6 -0
- package/skills/show/scripts/validate-artifact.py +91 -0
- package/skills/show/scripts/validate-package.py +18 -0
- package/skills/show/templates/html-widget.html +4 -0
- package/skills/show/templates/svg-base.svg +19 -0
- package/skills/show/tests/fixtures/css-var.svg +6 -0
- package/skills/show/tests/fixtures/dead-ref.svg +7 -0
- package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
- package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
- package/skills/show/tests/fixtures/invalid.svg +1 -0
- package/skills/show/tests/fixtures/large-canvas.svg +21 -0
- package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
- package/skills/show/tests/fixtures/valid.html +1 -0
- package/skills/show/tests/test_validate-artifact.py +74 -0
- package/skills/show/tests/test_validators.py +59 -0
- package/skills/show/workflows/create-visual.md +13 -0
- package/skills/show/workflows/review-visual.md +20 -0
- package/skills/split-pr/SKILL.md +85 -0
- package/skills/split-pr/reference/path-layers.md +16 -0
- package/skills/split-pr/reference/proposal-format.md +15 -0
- package/skills/split-pr/reference/split-further-loop.md +10 -0
- package/skills/split-pr/reference/splitting-principles.md +26 -0
- package/skills/split-pr/scripts/analyze_pr.py +279 -0
- package/skills/split-pr/scripts/categorize_files.py +106 -0
- package/skills/split-pr/scripts/config/__init__.py +1 -0
- package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
- package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
- package/skills/split-pr/scripts/config/packing_constants.py +61 -0
- package/skills/split-pr/scripts/config/plan_constants.py +49 -0
- package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
- package/skills/split-pr/scripts/execute_split_slices.py +82 -0
- package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
- package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
- package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
- package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
- package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
- package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
- package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
- package/skills/split-pr/scripts/split_pr_title.py +41 -0
- package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
- package/skills/split-pr/scripts/test_categorize_files.py +55 -0
- package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
- package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
- package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
- package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
- package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
- package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
- package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
- package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
- package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
- package/skills/split-pr/scripts/test_verify_plan.py +56 -0
- package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
- package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
- package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
- package/skills/split-pr/scripts/verify_plan.py +139 -0
- package/skills/team-advisor/SKILL.md +7 -4
- package/skills/team-advisor/reference/advisor-docs-review.md +207 -0
- package/system-prompts/software-engineer.xml +11 -2
- package/commands/initialize.md +0 -90
- package/commands/stubcheck.md +0 -88
- package/output-styles/caveman-agent.md +0 -37
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Boundary tests for the 199/200/599/600 hand-written line gates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
13
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
15
|
+
|
|
16
|
+
from analyze_pr import (
|
|
17
|
+
_fetch_pr_files,
|
|
18
|
+
_file_records_from_gh,
|
|
19
|
+
_flatten_paginated_file_pages,
|
|
20
|
+
build_analysis_from_files,
|
|
21
|
+
)
|
|
22
|
+
from config.split_pr_constants import (
|
|
23
|
+
DEFAULT_SPLIT_HAND_WRITTEN_LINE_THRESHOLD,
|
|
24
|
+
EXIT_CODE_SUCCESS,
|
|
25
|
+
FILE_KEY_ADDITIONS,
|
|
26
|
+
FILE_KEY_DELETIONS,
|
|
27
|
+
FILE_KEY_PATH,
|
|
28
|
+
GH_API_SUBCOMMAND,
|
|
29
|
+
GH_PAGINATE_FLAG,
|
|
30
|
+
GH_SLURP_FLAG,
|
|
31
|
+
PAYLOAD_KEY_ATOMIC_EXCEPTION,
|
|
32
|
+
PAYLOAD_KEY_DEFAULT_SPLIT,
|
|
33
|
+
PAYLOAD_KEY_EXCLUDED_CHURN_LINES,
|
|
34
|
+
PAYLOAD_KEY_FILE_COUNT,
|
|
35
|
+
PAYLOAD_KEY_HAND_WRITTEN_LINES,
|
|
36
|
+
PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS,
|
|
37
|
+
SPLIT_ANALYSIS_HAND_WRITTEN_LINE_THRESHOLD,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _records_for_hand_written_lines(line_count: int) -> list[dict[str, object]]:
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
FILE_KEY_PATH: "src/module.py",
|
|
45
|
+
FILE_KEY_ADDITIONS: line_count,
|
|
46
|
+
FILE_KEY_DELETIONS: 0,
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_hand_written_199_does_not_require_split_analysis() -> None:
|
|
52
|
+
analysis = build_analysis_from_files(
|
|
53
|
+
_records_for_hand_written_lines(
|
|
54
|
+
SPLIT_ANALYSIS_HAND_WRITTEN_LINE_THRESHOLD - 1
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 199
|
|
58
|
+
assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is False
|
|
59
|
+
assert analysis[PAYLOAD_KEY_DEFAULT_SPLIT] is False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_hand_written_200_requires_recorded_split_analysis() -> None:
|
|
63
|
+
analysis = build_analysis_from_files(
|
|
64
|
+
_records_for_hand_written_lines(SPLIT_ANALYSIS_HAND_WRITTEN_LINE_THRESHOLD)
|
|
65
|
+
)
|
|
66
|
+
assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 200
|
|
67
|
+
assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is True
|
|
68
|
+
assert analysis[PAYLOAD_KEY_DEFAULT_SPLIT] is False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_hand_written_599_requires_analysis_but_not_default_split() -> None:
|
|
72
|
+
analysis = build_analysis_from_files(
|
|
73
|
+
_records_for_hand_written_lines(
|
|
74
|
+
DEFAULT_SPLIT_HAND_WRITTEN_LINE_THRESHOLD - 1
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 599
|
|
78
|
+
assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is True
|
|
79
|
+
assert analysis[PAYLOAD_KEY_DEFAULT_SPLIT] is False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_hand_written_600_defaults_to_multiple_slices() -> None:
|
|
83
|
+
analysis = build_analysis_from_files(
|
|
84
|
+
_records_for_hand_written_lines(DEFAULT_SPLIT_HAND_WRITTEN_LINE_THRESHOLD)
|
|
85
|
+
)
|
|
86
|
+
assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 600
|
|
87
|
+
assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is True
|
|
88
|
+
assert analysis[PAYLOAD_KEY_DEFAULT_SPLIT] is True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_atomic_exception_records_reason_and_fable_verdict() -> None:
|
|
92
|
+
analysis = build_analysis_from_files(
|
|
93
|
+
_records_for_hand_written_lines(DEFAULT_SPLIT_HAND_WRITTEN_LINE_THRESHOLD),
|
|
94
|
+
atomic_exception_reason="single schema migration cannot be sliced",
|
|
95
|
+
fable_verdict="ENDORSE",
|
|
96
|
+
)
|
|
97
|
+
assert analysis[PAYLOAD_KEY_DEFAULT_SPLIT] is False
|
|
98
|
+
atomic = analysis[PAYLOAD_KEY_ATOMIC_EXCEPTION]
|
|
99
|
+
assert isinstance(atomic, dict)
|
|
100
|
+
assert atomic["reason"] == "single schema migration cannot be sliced"
|
|
101
|
+
assert atomic["fable_verdict"] == "ENDORSE"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_atomic_exception_without_fable_verdict_is_rejected() -> None:
|
|
105
|
+
with pytest.raises(ValueError, match="Fable verdict"):
|
|
106
|
+
build_analysis_from_files(
|
|
107
|
+
_records_for_hand_written_lines(
|
|
108
|
+
DEFAULT_SPLIT_HAND_WRITTEN_LINE_THRESHOLD
|
|
109
|
+
),
|
|
110
|
+
atomic_exception_reason="cannot split",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_excluded_churn_does_not_inflate_hand_written_threshold() -> None:
|
|
115
|
+
analysis = build_analysis_from_files(
|
|
116
|
+
[
|
|
117
|
+
{
|
|
118
|
+
FILE_KEY_PATH: "src/a.py",
|
|
119
|
+
FILE_KEY_ADDITIONS: 50,
|
|
120
|
+
FILE_KEY_DELETIONS: 0,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
FILE_KEY_PATH: "package-lock.json",
|
|
124
|
+
FILE_KEY_ADDITIONS: 5000,
|
|
125
|
+
FILE_KEY_DELETIONS: 0,
|
|
126
|
+
},
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 50
|
|
130
|
+
assert analysis[PAYLOAD_KEY_EXCLUDED_CHURN_LINES] == 5000
|
|
131
|
+
assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is False
|
|
132
|
+
assert analysis[PAYLOAD_KEY_FILE_COUNT] == 2
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_file_count_is_reported_but_not_a_hard_gate() -> None:
|
|
136
|
+
analysis = build_analysis_from_files(
|
|
137
|
+
[
|
|
138
|
+
{
|
|
139
|
+
FILE_KEY_PATH: f"src/file_{each_index}.py",
|
|
140
|
+
FILE_KEY_ADDITIONS: 1,
|
|
141
|
+
FILE_KEY_DELETIONS: 0,
|
|
142
|
+
}
|
|
143
|
+
for each_index in range(20)
|
|
144
|
+
]
|
|
145
|
+
)
|
|
146
|
+
assert analysis[PAYLOAD_KEY_FILE_COUNT] == 20
|
|
147
|
+
assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 20
|
|
148
|
+
assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_fetch_pr_files_uses_paginated_pulls_files_api(
|
|
152
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
153
|
+
) -> None:
|
|
154
|
+
all_captured_commands: list[list[str]] = []
|
|
155
|
+
|
|
156
|
+
def _fake_run(all_command: list[str], **_kwargs: object) -> object:
|
|
157
|
+
all_captured_commands.append(list(all_command))
|
|
158
|
+
|
|
159
|
+
class _Completed:
|
|
160
|
+
returncode = EXIT_CODE_SUCCESS
|
|
161
|
+
stdout = json.dumps(
|
|
162
|
+
[
|
|
163
|
+
[
|
|
164
|
+
{
|
|
165
|
+
"filename": "src/a.py",
|
|
166
|
+
"additions": 3,
|
|
167
|
+
"deletions": 1,
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
]
|
|
171
|
+
)
|
|
172
|
+
stderr = ""
|
|
173
|
+
|
|
174
|
+
return _Completed()
|
|
175
|
+
|
|
176
|
+
monkeypatch.setattr("analyze_pr.subprocess.run", _fake_run)
|
|
177
|
+
all_records = _fetch_pr_files(895, "jl-cmd/claude-dev-env")
|
|
178
|
+
assert len(all_captured_commands) == 1
|
|
179
|
+
assert all_captured_commands[0][0:2] == ["gh", GH_API_SUBCOMMAND]
|
|
180
|
+
assert "repos/jl-cmd/claude-dev-env/pulls/895/files" in all_captured_commands[0]
|
|
181
|
+
assert GH_PAGINATE_FLAG in all_captured_commands[0]
|
|
182
|
+
assert GH_SLURP_FLAG in all_captured_commands[0]
|
|
183
|
+
assert all_records[0][FILE_KEY_PATH] == "src/a.py"
|
|
184
|
+
assert all_records[0][FILE_KEY_ADDITIONS] == 3
|
|
185
|
+
assert all_records[0][FILE_KEY_DELETIONS] == 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_flatten_paginated_file_pages_merges_pages() -> None:
|
|
189
|
+
all_files = _flatten_paginated_file_pages(
|
|
190
|
+
[
|
|
191
|
+
[{"filename": "a.py", "additions": 1, "deletions": 0}],
|
|
192
|
+
[{"filename": "b.py", "additions": 2, "deletions": 0}],
|
|
193
|
+
]
|
|
194
|
+
)
|
|
195
|
+
assert len(all_files) == 2
|
|
196
|
+
all_records = _file_records_from_gh(all_files)
|
|
197
|
+
assert {each[FILE_KEY_PATH] for each in all_records} == {"a.py", "b.py"}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_analyze_pr_cli_files_json(tmp_path: Path) -> None:
|
|
201
|
+
files_path = tmp_path / "files.json"
|
|
202
|
+
files_path.write_text(
|
|
203
|
+
json.dumps(
|
|
204
|
+
[
|
|
205
|
+
{"path": "src/a.py", "additions": 200, "deletions": 0},
|
|
206
|
+
{"path": "yarn.lock", "additions": 900, "deletions": 0},
|
|
207
|
+
]
|
|
208
|
+
),
|
|
209
|
+
encoding="utf-8",
|
|
210
|
+
)
|
|
211
|
+
completed = subprocess.run(
|
|
212
|
+
[
|
|
213
|
+
sys.executable,
|
|
214
|
+
str(_SCRIPTS_DIR / "analyze_pr.py"),
|
|
215
|
+
"--files-json",
|
|
216
|
+
str(files_path),
|
|
217
|
+
"--pretty",
|
|
218
|
+
],
|
|
219
|
+
cwd=str(_SCRIPTS_DIR),
|
|
220
|
+
capture_output=True,
|
|
221
|
+
text=True,
|
|
222
|
+
check=False,
|
|
223
|
+
)
|
|
224
|
+
assert completed.returncode == EXIT_CODE_SUCCESS, completed.stderr
|
|
225
|
+
analysis = json.loads(completed.stdout)
|
|
226
|
+
assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 200
|
|
227
|
+
assert analysis[PAYLOAD_KEY_EXCLUDED_CHURN_LINES] == 900
|
|
228
|
+
assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is True
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Classification tests for hand-written vs excluded churn paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
9
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
11
|
+
|
|
12
|
+
from categorize_files import annotate_files, classify_path_churn, sum_churn_by_class
|
|
13
|
+
from config.split_pr_constants import (
|
|
14
|
+
CHURN_CLASS_GENERATED,
|
|
15
|
+
CHURN_CLASS_HAND_WRITTEN,
|
|
16
|
+
CHURN_CLASS_LOCKFILE,
|
|
17
|
+
CHURN_CLASS_MINIFIED,
|
|
18
|
+
CHURN_CLASS_VENDOR,
|
|
19
|
+
FILE_KEY_ADDITIONS,
|
|
20
|
+
FILE_KEY_DELETIONS,
|
|
21
|
+
FILE_KEY_PATH,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_classify_hand_written_source_paths() -> None:
|
|
26
|
+
assert classify_path_churn("packages/app/main.py") == CHURN_CLASS_HAND_WRITTEN
|
|
27
|
+
assert classify_path_churn("src/components/Button.tsx") == CHURN_CLASS_HAND_WRITTEN
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_classify_excluded_churn_classes() -> None:
|
|
31
|
+
assert classify_path_churn("package-lock.json") == CHURN_CLASS_LOCKFILE
|
|
32
|
+
assert classify_path_churn("vendor/lib/util.js") == CHURN_CLASS_VENDOR
|
|
33
|
+
assert classify_path_churn("assets/app.min.js") == CHURN_CLASS_MINIFIED
|
|
34
|
+
assert classify_path_churn("src/generated/types.ts") == CHURN_CLASS_GENERATED
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_annotate_and_sum_separates_hand_written_from_excluded() -> None:
|
|
38
|
+
all_annotated = annotate_files(
|
|
39
|
+
[
|
|
40
|
+
{
|
|
41
|
+
FILE_KEY_PATH: "src/a.py",
|
|
42
|
+
FILE_KEY_ADDITIONS: 10,
|
|
43
|
+
FILE_KEY_DELETIONS: 5,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
FILE_KEY_PATH: "package-lock.json",
|
|
47
|
+
FILE_KEY_ADDITIONS: 1000,
|
|
48
|
+
FILE_KEY_DELETIONS: 0,
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
assert sum_churn_by_class(
|
|
53
|
+
all_annotated, churn_class=CHURN_CLASS_HAND_WRITTEN
|
|
54
|
+
) == 15
|
|
55
|
+
assert sum_churn_by_class(all_annotated, churn_class=CHURN_CLASS_LOCKFILE) == 1000
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Packing stays under the 200 hand-written review budget per slice."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
11
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
13
|
+
|
|
14
|
+
from config.packing_constants import REVIEW_BUDGET_HAND_WRITTEN_LINES
|
|
15
|
+
from config.split_pr_constants import (
|
|
16
|
+
FILE_KEY_ADDITIONS,
|
|
17
|
+
FILE_KEY_DELETIONS,
|
|
18
|
+
FILE_KEY_PATH,
|
|
19
|
+
)
|
|
20
|
+
from pack_files_into_slices import infer_path_layer, pack_files_into_slices
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_infer_path_layer_maps_tests_and_docs() -> None:
|
|
24
|
+
assert infer_path_layer("packages/x/tests/test_a.py") == "tests"
|
|
25
|
+
assert infer_path_layer("README.md") == "docs"
|
|
26
|
+
assert infer_path_layer("src/app.py") == "backend"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_pack_splits_when_budget_exceeded() -> None:
|
|
30
|
+
all_files = [
|
|
31
|
+
{FILE_KEY_PATH: "a.py", FILE_KEY_ADDITIONS: 120, FILE_KEY_DELETIONS: 0},
|
|
32
|
+
{FILE_KEY_PATH: "b.py", FILE_KEY_ADDITIONS: 120, FILE_KEY_DELETIONS: 0},
|
|
33
|
+
]
|
|
34
|
+
plan = pack_files_into_slices("deadbeef", all_files)
|
|
35
|
+
assert len(plan["all_slices"]) == 2
|
|
36
|
+
for each_slice in plan["all_slices"]:
|
|
37
|
+
assert len(each_slice["all_paths"]) == 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_pack_keeps_same_layer_under_budget() -> None:
|
|
41
|
+
all_files = [
|
|
42
|
+
{FILE_KEY_PATH: "a.py", FILE_KEY_ADDITIONS: 40, FILE_KEY_DELETIONS: 0},
|
|
43
|
+
{FILE_KEY_PATH: "b.py", FILE_KEY_ADDITIONS: 40, FILE_KEY_DELETIONS: 0},
|
|
44
|
+
]
|
|
45
|
+
plan = pack_files_into_slices("deadbeef", all_files)
|
|
46
|
+
assert len(plan["all_slices"]) == 1
|
|
47
|
+
assert set(plan["all_slices"][0]["all_paths"]) == {"a.py", "b.py"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_pack_rejects_single_file_above_hard_cap() -> None:
|
|
51
|
+
all_files = [
|
|
52
|
+
{
|
|
53
|
+
FILE_KEY_PATH: "huge.py",
|
|
54
|
+
FILE_KEY_ADDITIONS: REVIEW_BUDGET_HAND_WRITTEN_LINES * 4,
|
|
55
|
+
FILE_KEY_DELETIONS: 0,
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
with pytest.raises(ValueError, match="hard cap"):
|
|
59
|
+
pack_files_into_slices("deadbeef", all_files)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Local slice materialization walks dependency order with restore on error."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
11
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
13
|
+
|
|
14
|
+
from execute_split_slices import materialize_plan_locally
|
|
15
|
+
from split_pr_process_runner import CapturedProcessOutcome
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _SequenceRunner:
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self.head = "sha0"
|
|
21
|
+
self.commit_count = 0
|
|
22
|
+
self.fail_on_commit_index: int | None = None
|
|
23
|
+
|
|
24
|
+
def __call__(
|
|
25
|
+
self, all_command: list[str], working_directory: str
|
|
26
|
+
) -> CapturedProcessOutcome:
|
|
27
|
+
all_git_args = all_command[1:]
|
|
28
|
+
if all_git_args[:2] == ["status", "--porcelain"]:
|
|
29
|
+
return CapturedProcessOutcome(0, "", "")
|
|
30
|
+
if all_git_args[:2] == ["rev-parse", "HEAD"]:
|
|
31
|
+
return CapturedProcessOutcome(0, self.head + "\n", "")
|
|
32
|
+
if all_git_args[:1] == ["add"]:
|
|
33
|
+
return CapturedProcessOutcome(0, "", "")
|
|
34
|
+
if all_git_args[:1] == ["commit"]:
|
|
35
|
+
self.commit_count += 1
|
|
36
|
+
if self.fail_on_commit_index == self.commit_count:
|
|
37
|
+
return CapturedProcessOutcome(1, "", "boom")
|
|
38
|
+
self.head = f"sha{self.commit_count}"
|
|
39
|
+
return CapturedProcessOutcome(0, "", "")
|
|
40
|
+
if all_git_args[:1] == ["checkout"]:
|
|
41
|
+
self.head = all_git_args[-1]
|
|
42
|
+
return CapturedProcessOutcome(0, "", "")
|
|
43
|
+
if all_git_args[:1] in (["restore"], ["clean"]):
|
|
44
|
+
return CapturedProcessOutcome(0, "", "")
|
|
45
|
+
return CapturedProcessOutcome(1, "", f"unexpected {all_command}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_materialize_plan_locally_commits_in_layer_order(tmp_path: Path) -> None:
|
|
49
|
+
plan = {
|
|
50
|
+
"schema_version": 1,
|
|
51
|
+
"source_commit": "sha0",
|
|
52
|
+
"all_changed_paths": ["a.py", "b.md"],
|
|
53
|
+
"all_slices": [
|
|
54
|
+
{
|
|
55
|
+
"id": "02-docs",
|
|
56
|
+
"title": "docs: b",
|
|
57
|
+
"layer": "docs",
|
|
58
|
+
"all_paths": ["b.md"],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "01-backend",
|
|
62
|
+
"title": "feat: a",
|
|
63
|
+
"layer": "backend",
|
|
64
|
+
"all_paths": ["a.py"],
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
}
|
|
68
|
+
runner = _SequenceRunner()
|
|
69
|
+
all_commit_records = materialize_plan_locally(tmp_path, plan, run=runner)
|
|
70
|
+
assert [each["commit_sha"] for each in all_commit_records] == ["sha1", "sha2"]
|
|
71
|
+
assert all_commit_records[0]["all_paths"] == ["a.py"]
|
|
72
|
+
assert all_commit_records[1]["all_paths"] == ["b.md"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_materialize_plan_locally_restores_source_on_failure(tmp_path: Path) -> None:
|
|
76
|
+
plan = {
|
|
77
|
+
"schema_version": 1,
|
|
78
|
+
"source_commit": "sha0",
|
|
79
|
+
"all_changed_paths": ["a.py", "b.py"],
|
|
80
|
+
"all_slices": [
|
|
81
|
+
{
|
|
82
|
+
"id": "01",
|
|
83
|
+
"title": "feat: a",
|
|
84
|
+
"layer": "backend",
|
|
85
|
+
"all_paths": ["a.py"],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"id": "02",
|
|
89
|
+
"title": "feat: b",
|
|
90
|
+
"layer": "backend",
|
|
91
|
+
"all_paths": ["b.py"],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
runner = _SequenceRunner()
|
|
96
|
+
runner.fail_on_commit_index = 2
|
|
97
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
98
|
+
materialize_plan_locally(tmp_path, plan, run=runner)
|
|
99
|
+
assert runner.head == "sha0"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Dependency graph is deterministic and layer-ordered."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
11
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
13
|
+
|
|
14
|
+
from split_pr_dependency_graph import build_dependency_graph
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_graph_orders_config_before_docs() -> None:
|
|
18
|
+
all_slices = [
|
|
19
|
+
{"id": "02-docs", "layer": "docs"},
|
|
20
|
+
{"id": "01-config", "layer": "config"},
|
|
21
|
+
]
|
|
22
|
+
graph = build_dependency_graph(all_slices)
|
|
23
|
+
assert graph["topological_order"] == ["01-config", "02-docs"]
|
|
24
|
+
assert {"from": "01-config", "to": "02-docs"} in graph["edges"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_duplicate_id_fails() -> None:
|
|
28
|
+
with pytest.raises(ValueError, match="duplicate"):
|
|
29
|
+
build_dependency_graph(
|
|
30
|
+
[{"id": "x", "layer": "backend"}, {"id": "x", "layer": "tests"}]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_unknown_layer_fails() -> None:
|
|
35
|
+
with pytest.raises(ValueError, match="unknown layer"):
|
|
36
|
+
build_dependency_graph([{"id": "x", "layer": "mystery"}])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_same_layer_no_edge() -> None:
|
|
40
|
+
graph = build_dependency_graph(
|
|
41
|
+
[
|
|
42
|
+
{"id": "a", "layer": "backend"},
|
|
43
|
+
{"id": "b", "layer": "backend"},
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
assert graph["edges"] == []
|
|
47
|
+
assert graph["topological_order"] == ["a", "b"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Git materialization stages only slice paths and restores on failure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
11
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
13
|
+
|
|
14
|
+
from split_pr_git_operations import (
|
|
15
|
+
assert_clean_worktree,
|
|
16
|
+
materialize_slice_commit,
|
|
17
|
+
read_head_sha,
|
|
18
|
+
restore_repository_state,
|
|
19
|
+
)
|
|
20
|
+
from split_pr_process_runner import CapturedProcessOutcome
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _FakeRunner:
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.all_calls: list[list[str]] = []
|
|
26
|
+
self.head_sha = "base111"
|
|
27
|
+
self.fail_commit = False
|
|
28
|
+
self.is_dirty = False
|
|
29
|
+
|
|
30
|
+
def __call__(
|
|
31
|
+
self, all_command: list[str], working_directory: str
|
|
32
|
+
) -> CapturedProcessOutcome:
|
|
33
|
+
self.all_calls.append(list(all_command))
|
|
34
|
+
all_git_args = all_command[1:]
|
|
35
|
+
if all_git_args[:2] == ["status", "--porcelain"]:
|
|
36
|
+
text = " M dirty.py\n" if self.is_dirty else ""
|
|
37
|
+
return CapturedProcessOutcome(0, text, "")
|
|
38
|
+
if all_git_args[:2] == ["rev-parse", "HEAD"]:
|
|
39
|
+
return CapturedProcessOutcome(0, self.head_sha + "\n", "")
|
|
40
|
+
if all_git_args[:1] == ["add"]:
|
|
41
|
+
return CapturedProcessOutcome(0, "", "")
|
|
42
|
+
if all_git_args[:1] == ["commit"]:
|
|
43
|
+
if self.fail_commit:
|
|
44
|
+
return CapturedProcessOutcome(1, "", "commit failed")
|
|
45
|
+
self.head_sha = "commit222"
|
|
46
|
+
return CapturedProcessOutcome(0, "", "")
|
|
47
|
+
if all_git_args[:1] == ["checkout"]:
|
|
48
|
+
self.head_sha = all_git_args[-1]
|
|
49
|
+
return CapturedProcessOutcome(0, "", "")
|
|
50
|
+
if all_git_args[:1] == ["restore"]:
|
|
51
|
+
return CapturedProcessOutcome(0, "", "")
|
|
52
|
+
if all_git_args[:1] == ["clean"]:
|
|
53
|
+
return CapturedProcessOutcome(0, "", "")
|
|
54
|
+
return CapturedProcessOutcome(1, "", f"unexpected {all_command}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_read_head_sha_returns_current_head(tmp_path: Path) -> None:
|
|
58
|
+
runner = _FakeRunner()
|
|
59
|
+
assert read_head_sha(tmp_path, run=runner) == "base111"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_assert_clean_worktree_passes_on_empty_status(tmp_path: Path) -> None:
|
|
63
|
+
runner = _FakeRunner()
|
|
64
|
+
assert_clean_worktree(tmp_path, run=runner)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_restore_repository_state_checks_out_target(tmp_path: Path) -> None:
|
|
68
|
+
runner = _FakeRunner()
|
|
69
|
+
restore_repository_state(tmp_path, "restore999", run=runner)
|
|
70
|
+
assert runner.head_sha == "restore999"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_materialize_slice_commit_stages_only_slice_paths(tmp_path: Path) -> None:
|
|
74
|
+
runner = _FakeRunner()
|
|
75
|
+
commit_record = materialize_slice_commit(
|
|
76
|
+
repository_path=tmp_path,
|
|
77
|
+
all_slice_paths=["a.py", "b.py"],
|
|
78
|
+
commit_message="chore: slice",
|
|
79
|
+
expected_base_sha="base111",
|
|
80
|
+
run=runner,
|
|
81
|
+
)
|
|
82
|
+
assert commit_record["commit_sha"] == "commit222"
|
|
83
|
+
assert commit_record["all_paths"] == ["a.py", "b.py"]
|
|
84
|
+
add_call = next(each for each in runner.all_calls if each[1] == "add")
|
|
85
|
+
assert add_call[3:] == ["a.py", "b.py"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_materialize_slice_commit_refuses_dirty_tree(tmp_path: Path) -> None:
|
|
89
|
+
runner = _FakeRunner()
|
|
90
|
+
runner.is_dirty = True
|
|
91
|
+
with pytest.raises(RuntimeError, match="dirty"):
|
|
92
|
+
materialize_slice_commit(
|
|
93
|
+
repository_path=tmp_path,
|
|
94
|
+
all_slice_paths=["a.py"],
|
|
95
|
+
commit_message="chore: x",
|
|
96
|
+
expected_base_sha="base111",
|
|
97
|
+
run=runner,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_materialize_slice_commit_restores_on_commit_failure(tmp_path: Path) -> None:
|
|
102
|
+
runner = _FakeRunner()
|
|
103
|
+
runner.fail_commit = True
|
|
104
|
+
with pytest.raises(RuntimeError, match="commit failed"):
|
|
105
|
+
materialize_slice_commit(
|
|
106
|
+
repository_path=tmp_path,
|
|
107
|
+
all_slice_paths=["a.py"],
|
|
108
|
+
commit_message="chore: x",
|
|
109
|
+
expected_base_sha="base111",
|
|
110
|
+
run=runner,
|
|
111
|
+
)
|
|
112
|
+
assert any(each[1] == "checkout" for each in runner.all_calls)
|
|
113
|
+
assert runner.head_sha == "base111"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_materialize_slice_commit_rejects_base_mismatch(tmp_path: Path) -> None:
|
|
117
|
+
runner = _FakeRunner()
|
|
118
|
+
with pytest.raises(RuntimeError, match="does not match"):
|
|
119
|
+
materialize_slice_commit(
|
|
120
|
+
repository_path=tmp_path,
|
|
121
|
+
all_slice_paths=["a.py"],
|
|
122
|
+
commit_message="chore: x",
|
|
123
|
+
expected_base_sha="other999",
|
|
124
|
+
run=runner,
|
|
125
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Layer order is deterministic and config-first."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
9
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
11
|
+
|
|
12
|
+
from config.plan_constants import SLICE_KEY_ID, SLICE_KEY_LAYER
|
|
13
|
+
from split_pr_layer_order import layer_rank, sort_slices_by_layer_order
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_layer_rank_orders_config_before_docs() -> None:
|
|
17
|
+
assert layer_rank("config") < layer_rank("docs")
|
|
18
|
+
assert layer_rank("mystery") > layer_rank("other")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_sort_places_config_before_docs() -> None:
|
|
22
|
+
all_slices = [
|
|
23
|
+
{SLICE_KEY_ID: "docs", SLICE_KEY_LAYER: "docs"},
|
|
24
|
+
{SLICE_KEY_ID: "config", SLICE_KEY_LAYER: "config"},
|
|
25
|
+
]
|
|
26
|
+
all_ordered = sort_slices_by_layer_order(all_slices)
|
|
27
|
+
assert [each[SLICE_KEY_ID] for each in all_ordered] == ["config", "docs"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_unknown_layer_sorts_after_known() -> None:
|
|
31
|
+
all_slices = [
|
|
32
|
+
{SLICE_KEY_ID: "z", SLICE_KEY_LAYER: "mystery"},
|
|
33
|
+
{SLICE_KEY_ID: "a", SLICE_KEY_LAYER: "backend"},
|
|
34
|
+
]
|
|
35
|
+
all_ordered = sort_slices_by_layer_order(all_slices)
|
|
36
|
+
assert [each[SLICE_KEY_ID] for each in all_ordered] == ["a", "z"]
|