claude-dev-env 2.9.0 → 2.10.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 +1 -1
- 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 +1 -0
- package/docs/references/advisor-tool.md +1 -1
- package/docs/references/prose-style-enforcement.md +25 -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_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/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/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/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,46 @@
|
|
|
1
|
+
"""Constants for the shared SessionStart context injector.
|
|
2
|
+
|
|
3
|
+
Claude Code SessionStart payloads carry ``source`` as one of::
|
|
4
|
+
|
|
5
|
+
startup | resume | clear | compact
|
|
6
|
+
|
|
7
|
+
Unknown sources map to a safe default status without blocking the session.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
SESSION_START_SOURCE_PAYLOAD_KEY: str = "source"
|
|
13
|
+
|
|
14
|
+
SESSION_START_SOURCE_STARTUP: str = "startup"
|
|
15
|
+
SESSION_START_SOURCE_RESUME: str = "resume"
|
|
16
|
+
SESSION_START_SOURCE_CLEAR: str = "clear"
|
|
17
|
+
SESSION_START_SOURCE_COMPACT: str = "compact"
|
|
18
|
+
|
|
19
|
+
ALL_KNOWN_SESSION_START_SOURCES: frozenset[str] = frozenset(
|
|
20
|
+
{
|
|
21
|
+
SESSION_START_SOURCE_STARTUP,
|
|
22
|
+
SESSION_START_SOURCE_RESUME,
|
|
23
|
+
SESSION_START_SOURCE_CLEAR,
|
|
24
|
+
SESSION_START_SOURCE_COMPACT,
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
SESSION_START_SOURCE_UNKNOWN: str = "unknown"
|
|
29
|
+
|
|
30
|
+
INJECTION_STATUS_OK: str = "ok"
|
|
31
|
+
INJECTION_STATUS_DISABLED: str = "disabled"
|
|
32
|
+
INJECTION_STATUS_TIMEOUT: str = "timeout"
|
|
33
|
+
INJECTION_STATUS_UNKNOWN_SOURCE: str = "unknown_source"
|
|
34
|
+
|
|
35
|
+
SESSION_START_INJECTOR_ENABLED_ENV_VAR: str = "CLAUDE_SESSION_START_INJECTOR_ENABLED"
|
|
36
|
+
ALL_INJECTOR_ENABLED_ENV_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})
|
|
37
|
+
DEFAULT_INJECTOR_TIMEOUT_MILLISECONDS: int = 50
|
|
38
|
+
MILLISECONDS_PER_SECOND: float = 1000.0
|
|
39
|
+
|
|
40
|
+
ALL_DEFAULT_CONTEXT_BY_SOURCE: dict[str, str] = {
|
|
41
|
+
SESSION_START_SOURCE_STARTUP: "",
|
|
42
|
+
SESSION_START_SOURCE_RESUME: "",
|
|
43
|
+
SESSION_START_SOURCE_CLEAR: "",
|
|
44
|
+
SESSION_START_SOURCE_COMPACT: "",
|
|
45
|
+
}
|
|
46
|
+
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Quote-aware shell command pipeline parsing.
|
|
2
|
+
|
|
3
|
+
Turns one Bash command string into operator-paired simple-command segments after
|
|
4
|
+
line-continuation join, comment strip, heredoc body drop, and parenthesis-group
|
|
5
|
+
join::
|
|
6
|
+
|
|
7
|
+
scannable_command_lines("pytest tests | tee out.log")
|
|
8
|
+
-> ["pytest tests | tee out.log"]
|
|
9
|
+
segments_with_following_operator(["pytest", "tests", "|", "tee", "out.log"])
|
|
10
|
+
-> [(["pytest", "tests"], "|"), (["tee", "out.log"], "")]
|
|
11
|
+
|
|
12
|
+
No pytest classification and no deny decision live here. Callers that need a
|
|
13
|
+
program verdict compose on top of these segments.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import shlex
|
|
19
|
+
from typing import NamedTuple
|
|
20
|
+
|
|
21
|
+
from hooks_constants.piped_pytest_blocker_constants import (
|
|
22
|
+
ALL_OPERATOR_TOKENS_LONGEST_FIRST,
|
|
23
|
+
ALL_PIPE_OPERATOR_TOKENS,
|
|
24
|
+
ALL_REDIRECTION_SUFFIX_CHARACTERS,
|
|
25
|
+
ALL_SEGMENT_RESET_OPERATOR_TOKENS,
|
|
26
|
+
CLOSED_GROUP_DEPTH,
|
|
27
|
+
COMMAND_LINE_SPLIT_PATTERN,
|
|
28
|
+
COMMENT_START_CHARACTER,
|
|
29
|
+
COMMENT_START_GROUP,
|
|
30
|
+
COMMENT_START_SCAN_PATTERN,
|
|
31
|
+
DISABLED_LEXER_COMMENTERS,
|
|
32
|
+
GROUP_CLOSE_CHARACTER,
|
|
33
|
+
GROUP_OPEN_CHARACTER,
|
|
34
|
+
HEREDOC_OPENER_OPERATOR,
|
|
35
|
+
HEREDOC_OPENER_PATTERN,
|
|
36
|
+
HEREDOC_STRIPPED_INDENT_CHARACTERS,
|
|
37
|
+
HEREDOC_TAB_STRIP_GROUP,
|
|
38
|
+
HEREDOC_TAB_STRIP_MARKER,
|
|
39
|
+
HEREDOC_TERMINATOR_GROUP,
|
|
40
|
+
LINE_CONTINUATION_JOIN,
|
|
41
|
+
LINE_CONTINUATION_PATTERN,
|
|
42
|
+
NO_FOLLOWING_OPERATOR,
|
|
43
|
+
PAREN_GROUP_LINE_JOIN,
|
|
44
|
+
PUNCTUATION_ONLY_TOKEN_PATTERN,
|
|
45
|
+
QUOTED_REGION_PATTERN,
|
|
46
|
+
QUOTED_REGION_REPLACEMENT,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"PendingHeredoc",
|
|
51
|
+
"all_operator_aware_tokenizations",
|
|
52
|
+
"all_operator_split_tokens",
|
|
53
|
+
"all_punctuation_token_parts",
|
|
54
|
+
"closes_the_heredoc",
|
|
55
|
+
"command_line_without_comment",
|
|
56
|
+
"join_line_continuations",
|
|
57
|
+
"live_command_lines",
|
|
58
|
+
"paren_group_joined_lines",
|
|
59
|
+
"pending_heredoc_opened_by",
|
|
60
|
+
"pipeline_segments_for_command",
|
|
61
|
+
"scannable_command_lines",
|
|
62
|
+
"segments_with_following_operator",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PendingHeredoc(NamedTuple):
|
|
67
|
+
"""The word an open heredoc closes on, and how its opener lets that word be indented."""
|
|
68
|
+
|
|
69
|
+
terminator: str
|
|
70
|
+
allows_leading_tabs: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def join_line_continuations(command: str) -> str:
|
|
74
|
+
"""Return the command with backslash line continuations joined.
|
|
75
|
+
|
|
76
|
+
::
|
|
77
|
+
|
|
78
|
+
pytest tests \\\\
|
|
79
|
+
| tee out.log -> "pytest tests | tee out.log"
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
command: One Bash command that may carry trailing-backslash continuations.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The same command with each continuation removed so the next line sits
|
|
86
|
+
on the same physical line.
|
|
87
|
+
"""
|
|
88
|
+
return LINE_CONTINUATION_PATTERN.sub(LINE_CONTINUATION_JOIN, command)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def all_operator_aware_tokenizations(command: str) -> list[list[str]]:
|
|
92
|
+
"""Return quote-aware tokenizations that carry operators as their own tokens.
|
|
93
|
+
|
|
94
|
+
POSIX mode resolves quoting the way Git Bash does; raw mode keeps a Windows
|
|
95
|
+
interpreter path such as ``C:\\Python313\\python.exe`` whole, since POSIX
|
|
96
|
+
mode reads its backslashes as escapes. Both are returned so a caller can
|
|
97
|
+
grade either spelling.
|
|
98
|
+
|
|
99
|
+
The lexer's own commenters are cleared, because it cuts at a ``#`` anywhere
|
|
100
|
+
in a word while a shell starts a comment only at a word's start.
|
|
101
|
+
``command_line_without_comment`` owns comment removal.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
command: One logical Bash command line (continuations already joined).
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Zero, one, or two non-empty token lists (POSIX and/or raw).
|
|
108
|
+
"""
|
|
109
|
+
all_tokenizations: list[list[str]] = []
|
|
110
|
+
for each_posix_mode in (True, False):
|
|
111
|
+
lexer = shlex.shlex(command, posix=each_posix_mode, punctuation_chars=True)
|
|
112
|
+
lexer.whitespace_split = True
|
|
113
|
+
lexer.commenters = DISABLED_LEXER_COMMENTERS
|
|
114
|
+
try:
|
|
115
|
+
all_tokens = list(lexer)
|
|
116
|
+
except ValueError:
|
|
117
|
+
continue
|
|
118
|
+
if all_tokens:
|
|
119
|
+
all_tokenizations.append(all_tokens)
|
|
120
|
+
return all_tokenizations
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _trailing_operator_token(token: str) -> str | None:
|
|
124
|
+
"""Return the longest control operator the token ends with, or None."""
|
|
125
|
+
for each_operator in ALL_OPERATOR_TOKENS_LONGEST_FIRST:
|
|
126
|
+
if token.endswith(each_operator):
|
|
127
|
+
return each_operator
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def all_punctuation_token_parts(token: str) -> list[str]:
|
|
132
|
+
"""Split a punctuation-only token into its leading text and trailing operators.
|
|
133
|
+
|
|
134
|
+
::
|
|
135
|
+
|
|
136
|
+
)| [')', '|']
|
|
137
|
+
)|| [')', '||']
|
|
138
|
+
tests ['tests']
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
token: One token from an operator-aware tokenization.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The token's parts in original order — a one-item list when no split is needed.
|
|
145
|
+
"""
|
|
146
|
+
if PUNCTUATION_ONLY_TOKEN_PATTERN.fullmatch(token) is None:
|
|
147
|
+
return [token]
|
|
148
|
+
all_peeled_operators: list[str] = []
|
|
149
|
+
remaining_text = token
|
|
150
|
+
while True:
|
|
151
|
+
trailing_operator = _trailing_operator_token(remaining_text)
|
|
152
|
+
if trailing_operator is None or trailing_operator == remaining_text:
|
|
153
|
+
break
|
|
154
|
+
leading_text = remaining_text[: -len(trailing_operator)]
|
|
155
|
+
if leading_text.endswith(ALL_REDIRECTION_SUFFIX_CHARACTERS):
|
|
156
|
+
break
|
|
157
|
+
all_peeled_operators.append(trailing_operator)
|
|
158
|
+
remaining_text = leading_text
|
|
159
|
+
return [remaining_text, *reversed(all_peeled_operators)]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def all_operator_split_tokens(all_command_tokens: list[str]) -> list[str]:
|
|
163
|
+
"""Return the tokens with every glued punctuation-and-operator token split apart.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
all_command_tokens: Tokens from an operator-aware tokenization.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Tokens with glued punctuation operators peeled into their own items.
|
|
170
|
+
"""
|
|
171
|
+
all_split_tokens: list[str] = []
|
|
172
|
+
for each_token in all_command_tokens:
|
|
173
|
+
all_split_tokens.extend(all_punctuation_token_parts(each_token))
|
|
174
|
+
return all_split_tokens
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def segments_with_following_operator(
|
|
178
|
+
all_command_tokens: list[str],
|
|
179
|
+
) -> list[tuple[list[str], str]]:
|
|
180
|
+
"""Pair each simple-command segment with the control operator that ends it.
|
|
181
|
+
|
|
182
|
+
A redirection token stays inside its segment. A close paren stays inside too,
|
|
183
|
+
so a subshell's program survives to the pipe that follows it.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
all_command_tokens: Tokens from an operator-aware tokenization.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
``(segment_tokens, following_operator)`` pairs; the last pair carries
|
|
190
|
+
an empty following operator when the command ends without one.
|
|
191
|
+
"""
|
|
192
|
+
all_segments: list[tuple[list[str], str]] = []
|
|
193
|
+
current_segment: list[str] = []
|
|
194
|
+
for each_token in all_operator_split_tokens(all_command_tokens):
|
|
195
|
+
if (
|
|
196
|
+
each_token in ALL_PIPE_OPERATOR_TOKENS
|
|
197
|
+
or each_token in ALL_SEGMENT_RESET_OPERATOR_TOKENS
|
|
198
|
+
):
|
|
199
|
+
all_segments.append((current_segment, each_token))
|
|
200
|
+
current_segment = []
|
|
201
|
+
continue
|
|
202
|
+
current_segment.append(each_token)
|
|
203
|
+
all_segments.append((current_segment, NO_FOLLOWING_OPERATOR))
|
|
204
|
+
return all_segments
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def pending_heredoc_opened_by(command_line: str) -> PendingHeredoc | None:
|
|
208
|
+
"""Return the heredoc the line opens, or None when it opens none.
|
|
209
|
+
|
|
210
|
+
::
|
|
211
|
+
|
|
212
|
+
cat > run.sh <<EOF PendingHeredoc('EOF', allows_leading_tabs=False)
|
|
213
|
+
cat > run.sh <<-EOF PendingHeredoc('EOF', allows_leading_tabs=True)
|
|
214
|
+
cat file <<<word None
|
|
215
|
+
echo hi None
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
command_line: One physical command line.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The pending heredoc, or None when the line opens none.
|
|
222
|
+
"""
|
|
223
|
+
heredoc_opener = HEREDOC_OPENER_PATTERN.search(command_line)
|
|
224
|
+
if heredoc_opener is None:
|
|
225
|
+
return None
|
|
226
|
+
return PendingHeredoc(
|
|
227
|
+
terminator=heredoc_opener.group(HEREDOC_TERMINATOR_GROUP),
|
|
228
|
+
allows_leading_tabs=(
|
|
229
|
+
heredoc_opener.group(HEREDOC_TAB_STRIP_GROUP) == HEREDOC_TAB_STRIP_MARKER
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def closes_the_heredoc(command_line: str, pending_heredoc: PendingHeredoc) -> bool:
|
|
235
|
+
"""Return True when the line is the terminator its opener spelled.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
command_line: One physical command line inside an open heredoc.
|
|
239
|
+
pending_heredoc: The opener that is still waiting to close.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
True when this line ends the heredoc body.
|
|
243
|
+
"""
|
|
244
|
+
if pending_heredoc.allows_leading_tabs:
|
|
245
|
+
return (
|
|
246
|
+
command_line.lstrip(HEREDOC_STRIPPED_INDENT_CHARACTERS)
|
|
247
|
+
== pending_heredoc.terminator
|
|
248
|
+
)
|
|
249
|
+
return command_line == pending_heredoc.terminator
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def live_command_lines(all_command_lines: list[str]) -> list[str]:
|
|
253
|
+
"""Return the lines the call runs, dropping every heredoc body and terminator.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
all_command_lines: The physical lines of one Bash command, in order.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
The lines outside every heredoc body, in their original order.
|
|
260
|
+
"""
|
|
261
|
+
all_live_lines: list[str] = []
|
|
262
|
+
pending_heredoc: PendingHeredoc | None = None
|
|
263
|
+
for each_line in all_command_lines:
|
|
264
|
+
if pending_heredoc is not None:
|
|
265
|
+
if closes_the_heredoc(each_line, pending_heredoc):
|
|
266
|
+
pending_heredoc = None
|
|
267
|
+
continue
|
|
268
|
+
all_live_lines.append(each_line)
|
|
269
|
+
pending_heredoc = pending_heredoc_opened_by(each_line)
|
|
270
|
+
return all_live_lines
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def command_line_without_comment(command_line: str) -> str:
|
|
274
|
+
"""Return the line with a shell comment and everything after it removed.
|
|
275
|
+
|
|
276
|
+
::
|
|
277
|
+
|
|
278
|
+
python -m pytest tests # fast -> python -m pytest tests
|
|
279
|
+
pytest -k "a#b" -> unchanged (hash inside quotes)
|
|
280
|
+
tee run#1.log -> unchanged (hash inside a word)
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
command_line: One physical command line.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
The line up to its comment, or the whole line when it carries none.
|
|
287
|
+
"""
|
|
288
|
+
for each_match in COMMENT_START_SCAN_PATTERN.finditer(command_line):
|
|
289
|
+
if each_match.group(COMMENT_START_GROUP) is not None:
|
|
290
|
+
return command_line[: each_match.start(COMMENT_START_GROUP)]
|
|
291
|
+
return command_line
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _comment_free_lines(all_command_lines: list[str]) -> list[str]:
|
|
295
|
+
"""Return every line with its shell comment removed.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
all_command_lines: The physical lines of one Bash command, in order.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
The comment-free lines, in their original order and count.
|
|
302
|
+
"""
|
|
303
|
+
return [command_line_without_comment(each_line) for each_line in all_command_lines]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _paren_depth_change(command_line: str) -> int:
|
|
307
|
+
"""Return how many parenthesis groups the line opens, minus the ones it closes."""
|
|
308
|
+
unquoted_line = QUOTED_REGION_PATTERN.sub(QUOTED_REGION_REPLACEMENT, command_line)
|
|
309
|
+
return unquoted_line.count(GROUP_OPEN_CHARACTER) - unquoted_line.count(
|
|
310
|
+
GROUP_CLOSE_CHARACTER
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def paren_group_joined_lines(all_command_lines: list[str]) -> list[str]:
|
|
315
|
+
"""Return the lines with each open parenthesis group joined into one logical line.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
all_command_lines: Live command lines, comments removed and heredoc bodies dropped.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
One line per closed parenthesis group, and the unchanged line for every other.
|
|
322
|
+
"""
|
|
323
|
+
all_joined_lines: list[str] = []
|
|
324
|
+
all_pending_lines: list[str] = []
|
|
325
|
+
open_group_depth = CLOSED_GROUP_DEPTH
|
|
326
|
+
for each_line in all_command_lines:
|
|
327
|
+
all_pending_lines.append(each_line)
|
|
328
|
+
open_group_depth = max(
|
|
329
|
+
open_group_depth + _paren_depth_change(each_line), CLOSED_GROUP_DEPTH
|
|
330
|
+
)
|
|
331
|
+
if open_group_depth > CLOSED_GROUP_DEPTH:
|
|
332
|
+
continue
|
|
333
|
+
all_joined_lines.append(PAREN_GROUP_LINE_JOIN.join(all_pending_lines))
|
|
334
|
+
all_pending_lines = []
|
|
335
|
+
if all_pending_lines:
|
|
336
|
+
all_joined_lines.append(PAREN_GROUP_LINE_JOIN.join(all_pending_lines))
|
|
337
|
+
return all_joined_lines
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def scannable_command_lines(joined_command: str) -> list[str]:
|
|
341
|
+
"""Return the lines to tokenize, running only the passes the command's text calls for.
|
|
342
|
+
|
|
343
|
+
::
|
|
344
|
+
|
|
345
|
+
pytest tests | tee x split only
|
|
346
|
+
pytest tests # | tee x comment pass runs
|
|
347
|
+
(pytest tests) | tee x parenthesis-group join runs
|
|
348
|
+
cat <<EOF ... EOF heredoc pass runs
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
joined_command: One Bash command, its line continuations already joined.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
The command lines ready for tokenization.
|
|
355
|
+
"""
|
|
356
|
+
all_command_lines = COMMAND_LINE_SPLIT_PATTERN.split(joined_command)
|
|
357
|
+
if COMMENT_START_CHARACTER in joined_command:
|
|
358
|
+
all_command_lines = _comment_free_lines(all_command_lines)
|
|
359
|
+
if HEREDOC_OPENER_OPERATOR in joined_command:
|
|
360
|
+
all_command_lines = live_command_lines(all_command_lines)
|
|
361
|
+
if GROUP_OPEN_CHARACTER not in joined_command:
|
|
362
|
+
return all_command_lines
|
|
363
|
+
return paren_group_joined_lines(all_command_lines)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def pipeline_segments_for_command(
|
|
367
|
+
command: str,
|
|
368
|
+
) -> list[tuple[list[str], str]]:
|
|
369
|
+
"""Return every operator-paired segment across every quote-aware tokenization.
|
|
370
|
+
|
|
371
|
+
Joins line continuations, builds scannable lines, tokenizes each, and pairs
|
|
372
|
+
segments with the control operator that follows them. Duplicate segment
|
|
373
|
+
pairs from the two tokenizations are kept only once, in first-seen order.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
command: One Bash command as the tool would receive it.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
``(segment_tokens, following_operator)`` pairs ready for a caller to
|
|
380
|
+
grade, with no program classification applied.
|
|
381
|
+
"""
|
|
382
|
+
joined_command = join_line_continuations(command)
|
|
383
|
+
all_pairs: list[tuple[list[str], str]] = []
|
|
384
|
+
seen_pair_keys: set[tuple[tuple[str, ...], str]] = set()
|
|
385
|
+
for each_line in scannable_command_lines(joined_command):
|
|
386
|
+
if not each_line.strip():
|
|
387
|
+
continue
|
|
388
|
+
for each_tokenization in all_operator_aware_tokenizations(each_line):
|
|
389
|
+
for each_segment, each_operator in segments_with_following_operator(
|
|
390
|
+
each_tokenization
|
|
391
|
+
):
|
|
392
|
+
pair_key = (tuple(each_segment), each_operator)
|
|
393
|
+
if pair_key in seen_pair_keys:
|
|
394
|
+
continue
|
|
395
|
+
seen_pair_keys.add(pair_key)
|
|
396
|
+
all_pairs.append((each_segment, each_operator))
|
|
397
|
+
return all_pairs
|
|
@@ -4,6 +4,11 @@ Tokenizes a command into simple-command segments on control operators and finds
|
|
|
4
4
|
each segment's effective leading program after env assignments and launcher
|
|
5
5
|
wrappers. NAS ssh enforcement and unscoped-search blocking both need this
|
|
6
6
|
shape, so one module owns it.
|
|
7
|
+
|
|
8
|
+
``split_into_segments`` is not quote-aware: a pipe inside ``-k "a|b"`` still
|
|
9
|
+
splits. Quote-aware pipeline parsing — operators as their own tokens, heredoc
|
|
10
|
+
bodies dropped, parenthesis groups joined — lives in
|
|
11
|
+
``shell_command_pipeline``.
|
|
7
12
|
"""
|
|
8
13
|
|
|
9
14
|
from __future__ import annotations
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Payload-shape fixtures for the pure AskUserQuestion analyzer.
|
|
2
|
+
|
|
3
|
+
Grades lean-block shape without hook I/O. Each case names one marker or cap
|
|
4
|
+
the PR #720 contract preserves.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pathlib
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
13
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
15
|
+
|
|
16
|
+
from hooks_constants.ask_user_question_shape import ( # noqa: E402
|
|
17
|
+
analyze_ask_user_question_shape,
|
|
18
|
+
count_prose_words,
|
|
19
|
+
find_question_block_violations,
|
|
20
|
+
mask_inline_code,
|
|
21
|
+
normalize_line_endings,
|
|
22
|
+
)
|
|
23
|
+
from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
|
|
24
|
+
MAXIMUM_OPTION_DESCRIPTION_WORD_COUNT,
|
|
25
|
+
MAXIMUM_QUESTION_SENTENCE_COUNT,
|
|
26
|
+
MAXIMUM_QUESTION_WORD_COUNT,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
LEAN_QUESTION = "Which gate should run first?"
|
|
30
|
+
LEAN_DESCRIPTION = "Runs on every write."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ask_payload(
|
|
34
|
+
question_text: str,
|
|
35
|
+
all_descriptions: list[str],
|
|
36
|
+
) -> dict[str, object]:
|
|
37
|
+
return {
|
|
38
|
+
"questions": [
|
|
39
|
+
{
|
|
40
|
+
"question": question_text,
|
|
41
|
+
"options": [
|
|
42
|
+
{"label": f"Option {each_index}", "description": each_description}
|
|
43
|
+
for each_index, each_description in enumerate(all_descriptions, start=1)
|
|
44
|
+
],
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_lean_payload_is_clean() -> None:
|
|
51
|
+
result = analyze_ask_user_question_shape(
|
|
52
|
+
_ask_payload(LEAN_QUESTION, [LEAN_DESCRIPTION])
|
|
53
|
+
)
|
|
54
|
+
assert result.is_lean
|
|
55
|
+
assert result.all_violations == ()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_list_marker_under_question_is_flagged() -> None:
|
|
59
|
+
plan_question = f"{LEAN_QUESTION}\n- Split the file\n- Wire the gate"
|
|
60
|
+
result = analyze_ask_user_question_shape(
|
|
61
|
+
_ask_payload(plan_question, [LEAN_DESCRIPTION])
|
|
62
|
+
)
|
|
63
|
+
assert not result.is_lean
|
|
64
|
+
assert any("list marker" in each for each in result.all_violations)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_fenced_block_is_flagged() -> None:
|
|
68
|
+
fenced_question = f"{LEAN_QUESTION}\n```\nrun_gate()\n```"
|
|
69
|
+
result = analyze_ask_user_question_shape(
|
|
70
|
+
_ask_payload(fenced_question, [LEAN_DESCRIPTION])
|
|
71
|
+
)
|
|
72
|
+
assert any("fenced code block" in each for each in result.all_violations)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_second_paragraph_is_flagged() -> None:
|
|
76
|
+
two_paragraph_question = f"{LEAN_QUESTION}\n\nThe write gate reads every file."
|
|
77
|
+
result = analyze_ask_user_question_shape(
|
|
78
|
+
_ask_payload(two_paragraph_question, [LEAN_DESCRIPTION])
|
|
79
|
+
)
|
|
80
|
+
assert any("more than one paragraph" in each for each in result.all_violations)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_carriage_return_paragraph_break_is_flagged() -> None:
|
|
84
|
+
crlf_question = f"{LEAN_QUESTION}\r\n\r\nThe write gate reads every file."
|
|
85
|
+
result = analyze_ask_user_question_shape(
|
|
86
|
+
_ask_payload(crlf_question, [LEAN_DESCRIPTION])
|
|
87
|
+
)
|
|
88
|
+
assert any("more than one paragraph" in each for each in result.all_violations)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_heading_is_flagged() -> None:
|
|
92
|
+
headed_question = f"{LEAN_QUESTION}\n## The write gate\nIt reads every file."
|
|
93
|
+
result = analyze_ask_user_question_shape(
|
|
94
|
+
_ask_payload(headed_question, [LEAN_DESCRIPTION])
|
|
95
|
+
)
|
|
96
|
+
assert any("heading" in each for each in result.all_violations)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_table_row_in_option_is_flagged() -> None:
|
|
100
|
+
result = analyze_ask_user_question_shape(
|
|
101
|
+
_ask_payload(LEAN_QUESTION, ["| gate | 12 ms |"])
|
|
102
|
+
)
|
|
103
|
+
assert any("table row" in each for each in result.all_violations)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_question_over_word_cap_is_flagged() -> None:
|
|
107
|
+
over_cap = " ".join(["word"] * (MAXIMUM_QUESTION_WORD_COUNT + 1))
|
|
108
|
+
result = analyze_ask_user_question_shape(_ask_payload(over_cap, [LEAN_DESCRIPTION]))
|
|
109
|
+
assert any("word" in each and "cap" in each for each in result.all_violations)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_question_over_sentence_cap_is_flagged() -> None:
|
|
113
|
+
three_sentences = "One. Two. Three."
|
|
114
|
+
assert MAXIMUM_QUESTION_SENTENCE_COUNT < 3
|
|
115
|
+
result = analyze_ask_user_question_shape(
|
|
116
|
+
_ask_payload(three_sentences, [LEAN_DESCRIPTION])
|
|
117
|
+
)
|
|
118
|
+
assert any("sentence" in each and "cap" in each for each in result.all_violations)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_option_description_over_word_cap_is_flagged() -> None:
|
|
122
|
+
over_cap = " ".join(["word"] * (MAXIMUM_OPTION_DESCRIPTION_WORD_COUNT + 1))
|
|
123
|
+
result = analyze_ask_user_question_shape(_ask_payload(LEAN_QUESTION, [over_cap]))
|
|
124
|
+
assert any(
|
|
125
|
+
"option description" in each and "word" in each for each in result.all_violations
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_option_description_over_sentence_cap_is_flagged() -> None:
|
|
130
|
+
result = analyze_ask_user_question_shape(
|
|
131
|
+
_ask_payload(LEAN_QUESTION, ["Runs on write. Reads every file."])
|
|
132
|
+
)
|
|
133
|
+
assert any(
|
|
134
|
+
"option description" in each and "sentence" in each
|
|
135
|
+
for each in result.all_violations
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_inline_code_counts_as_one_word() -> None:
|
|
140
|
+
masked = mask_inline_code("Does `git diff --cached --stat` list it?")
|
|
141
|
+
assert count_prose_words(masked) == count_prose_words("Does code list it?")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_normalize_line_endings_folds_crlf() -> None:
|
|
145
|
+
assert normalize_line_endings("a\r\nb\rc") == "a\nb\nc"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_duplicate_faults_report_once() -> None:
|
|
149
|
+
tool_input: dict[str, object] = {
|
|
150
|
+
"questions": [
|
|
151
|
+
{
|
|
152
|
+
"question": LEAN_QUESTION,
|
|
153
|
+
"options": [
|
|
154
|
+
{"label": "A", "description": "| a | b |"},
|
|
155
|
+
{"label": "B", "description": "| c | d |"},
|
|
156
|
+
],
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
all_violations = find_question_block_violations(tool_input)
|
|
161
|
+
table_faults = [each for each in all_violations if "table row" in each]
|
|
162
|
+
assert len(table_faults) == 1
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_non_dict_questions_list_is_clean() -> None:
|
|
166
|
+
result = analyze_ask_user_question_shape({"questions": "not-a-list"})
|
|
167
|
+
assert result.is_lean
|
|
@@ -4,6 +4,7 @@ import inspect
|
|
|
4
4
|
import json
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
7
8
|
|
|
8
9
|
import pytest
|
|
9
10
|
|
|
@@ -13,6 +14,7 @@ if str(_HOOKS_ROOT) not in sys.path:
|
|
|
13
14
|
|
|
14
15
|
from hooks_constants import project_paths_reader
|
|
15
16
|
from hooks_constants.project_paths_reader import (
|
|
17
|
+
find_git_root,
|
|
16
18
|
load_registry,
|
|
17
19
|
registry_contains_path,
|
|
18
20
|
registry_file_path,
|
|
@@ -147,3 +149,30 @@ def test_load_registry_uses_shared_utf8_encoding_constant() -> None:
|
|
|
147
149
|
assert 'encoding="utf-8"' not in source, (
|
|
148
150
|
'load_registry must use UTF8_ENCODING constant, not the bare literal "utf-8"'
|
|
149
151
|
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_find_git_root_returns_root_for_nested_path(tmp_path: Path) -> None:
|
|
155
|
+
repo_root = tmp_path / "repo"
|
|
156
|
+
nested = repo_root / "pkg"
|
|
157
|
+
nested.mkdir(parents=True)
|
|
158
|
+
(repo_root / ".git").mkdir()
|
|
159
|
+
found = find_git_root(str(nested))
|
|
160
|
+
assert found is not None
|
|
161
|
+
assert Path(found).resolve() == repo_root.resolve()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_find_git_root_returns_none_without_git(tmp_path: Path) -> None:
|
|
165
|
+
bare = tmp_path / "bare"
|
|
166
|
+
bare.mkdir()
|
|
167
|
+
original_exists = Path.exists
|
|
168
|
+
|
|
169
|
+
def exists_without_ambient_git(self_path: Path) -> bool:
|
|
170
|
+
if self_path.name == ".git":
|
|
171
|
+
resolved_parent = self_path.resolve().parent
|
|
172
|
+
if resolved_parent == bare.resolve() or bare.resolve() in resolved_parent.parents:
|
|
173
|
+
return original_exists(self_path)
|
|
174
|
+
return False
|
|
175
|
+
return original_exists(self_path)
|
|
176
|
+
|
|
177
|
+
with patch.object(Path, "exists", exists_without_ambient_git):
|
|
178
|
+
assert find_git_root(str(bare)) is None
|
|
@@ -35,6 +35,9 @@ from hooks_constants.eli11_reply_enforcer_constants import (
|
|
|
35
35
|
from hooks_constants.eli11_reply_enforcer_constants import (
|
|
36
36
|
COUNTABLE_WORD_PATTERN as ELI11_COUNTABLE_WORD_PATTERN,
|
|
37
37
|
)
|
|
38
|
+
from hooks_constants.ask_user_question_shape import (
|
|
39
|
+
COUNTABLE_WORD_PATTERN as SHAPE_COUNTABLE_WORD_PATTERN,
|
|
40
|
+
)
|
|
38
41
|
from hooks_constants.plain_language_blocker_constants import ALL_CHAT_DETAIL_MARKERS
|
|
39
42
|
from hooks_constants.plain_language_blocker_constants import (
|
|
40
43
|
COUNTABLE_WORD_PATTERN as PLAIN_LANGUAGE_COUNTABLE_WORD_PATTERN,
|
|
@@ -102,6 +105,11 @@ def test_countable_word_pattern_copies_stay_byte_equal() -> None:
|
|
|
102
105
|
)
|
|
103
106
|
|
|
104
107
|
|
|
108
|
+
def test_shape_analyzer_binds_the_plain_language_countable_word_pattern() -> None:
|
|
109
|
+
"""The pure shape analyzer reuses the shared pattern object, not a private copy."""
|
|
110
|
+
assert SHAPE_COUNTABLE_WORD_PATTERN is PLAIN_LANGUAGE_COUNTABLE_WORD_PATTERN
|
|
111
|
+
|
|
112
|
+
|
|
105
113
|
def test_table_row_pattern_copies_stay_byte_equal() -> None:
|
|
106
114
|
plain_language_table_row = _chat_detail_marker_named(_TABLE_ROW_MARKER_LABEL)
|
|
107
115
|
assert plain_language_table_row.pattern == TABLE_ROW_PATTERN.pattern
|