claude-dev-env 7.7.1 → 8.0.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.
Files changed (103) hide show
  1. package/.agents/agents/clean-coder.md +5 -6
  2. package/.agents/agents/issue-tracker.md +1 -1
  3. package/.agents/agents/pr-description-writer.md +1 -1
  4. package/.agents/agents/skill-writer-agent.md +1 -12
  5. package/.agents/agents/test_agent_frontmatter.py +2 -19
  6. package/.agents/skills/issue-tracker/SKILL.md +1 -1
  7. package/.agents/skills/orchestrator/SKILL.md +4 -5
  8. package/.agents/skills/pull-request/SKILL.md +8 -7
  9. package/.agents/skills/pull-request/reference/publication-tasks.md +1 -1
  10. package/.agents/skills/team-advisor/SKILL.md +2 -3
  11. package/_shared/advisor/advisor-protocol.md +0 -1
  12. package/_shared/advisor/reference/lifecycle.md +0 -1
  13. package/_shared/advisor/reference/warm-up.md +1 -2
  14. package/audit-rubrics/prompts/category-e-dead-code.md +3 -3
  15. package/bin/install-constants.mjs +0 -5
  16. package/bin/install-pstack-plugin.mjs +192 -0
  17. package/bin/install-pstack-plugin.test.mjs +181 -0
  18. package/bin/install.agents-home.test.mjs +0 -4
  19. package/bin/install.codex-rules.test.mjs +6 -0
  20. package/bin/install.cursor-rules.test.mjs +3 -253
  21. package/bin/install.mjs +55 -160
  22. package/bin/install.test.mjs +110 -149
  23. package/bin/prune-retired-pstack-store.mjs +83 -0
  24. package/bin/prune-retired-pstack-store.test.mjs +105 -0
  25. package/bin/prune-session-continuity.mjs +57 -0
  26. package/bin/prune-session-continuity.test.mjs +70 -0
  27. package/bin/resolve-install-root.mjs +2 -0
  28. package/bin/resolve-install-root.test.mjs +69 -0
  29. package/docs/CODE_RULES.md +1 -1
  30. package/docs/agent-spawn-protocol.md +1 -1
  31. package/docs/codex-compatibility.md +4 -16
  32. package/docs/nas-ssh-invocation.md +1 -1
  33. package/docs/references/prose-style-enforcement.md +2 -2
  34. package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +1 -3
  35. package/hooks/blocking/code_rules_constants_config.py +1 -108
  36. package/hooks/blocking/code_rules_enforcer.py +9 -5
  37. package/hooks/blocking/code_rules_test_layout.py +6 -7
  38. package/hooks/blocking/test_code_rules_constants_config.py +13 -0
  39. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  40. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +15 -0
  41. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +0 -60
  42. package/hooks/blocking/test_context_control_policy_files.py +0 -11
  43. package/hooks/hooks_constants/blocking_check_limits.py +1 -1
  44. package/hooks/hooks_constants/code_rules_enforcer_constants.py +0 -1
  45. package/hooks/hooks_constants/setup_project_paths_constants.py +1 -2
  46. package/hooks/session/test_untracked_repo_detector.py +1 -1
  47. package/hooks/validators/code_quality_checks.py +15 -2
  48. package/hooks/validators/test_code_quality_checks.py +22 -0
  49. package/package.json +1 -2
  50. package/rules/ci-owns-the-gate.md +58 -0
  51. package/rules/code-standards.md +1 -2
  52. package/rules/docstring-prose-matches-implementation.md +2 -2
  53. package/rules/failure-blast-radius.md +1 -1
  54. package/rules/git-workflow.md +23 -11
  55. package/rules/orphan-css-class.md +1 -1
  56. package/rules/paired-test-coverage.md +1 -1
  57. package/rules/retired-hook-prose.md +1 -1
  58. package/rules/verify-before-asking.md +1 -1
  59. package/rules/windows-filesystem-safe.md +1 -1
  60. package/rules/workflow-substitution-slots.md +1 -1
  61. package/scripts/Sync-RepoMain.ps1 +71 -6
  62. package/scripts/policy_lint/adapter_detectors.py +116 -10
  63. package/scripts/policy_lint/config/approved_test_pairs.py +12 -0
  64. package/scripts/policy_lint/config/constants.py +3 -0
  65. package/scripts/tests/Sync-RepoMain.Tests.ps1 +108 -0
  66. package/scripts/tests/test_policy_lint_rules_validator_baseline.py +126 -0
  67. package/.agents/skills/session-continuity/AGENTS.md +0 -1
  68. package/.agents/skills/session-continuity/CLAUDE.md +0 -1
  69. package/.agents/skills/session-continuity/README.md +0 -223
  70. package/.agents/skills/session-continuity/SKILL.md +0 -125
  71. package/.agents/skills/session-continuity/continuity.mjs +0 -445
  72. package/.agents/skills/session-continuity/continuity.test.mjs +0 -381
  73. package/bin/install-session-continuity.mjs +0 -131
  74. package/bin/install-session-continuity.test.mjs +0 -106
  75. package/bin/install.pstack-plugin.test.mjs +0 -115
  76. package/bin/pstack-codex-discovery.test.mjs +0 -79
  77. package/bin/pstack-model-preferences.claude.json +0 -23
  78. package/bin/pstack-model-preferences.codex.json +0 -24
  79. package/bin/pstack.mjs +0 -392
  80. package/bin/pstack.test.mjs +0 -347
  81. package/bin/refresh-pstack-plugin-skills.test.mjs +0 -211
  82. package/bin/select-pstack-models.test.mjs +0 -776
  83. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +0 -231
  84. package/rules/agent-spawn-protocol.md +0 -9
  85. package/rules/ask-user-question-required.md +0 -31
  86. package/rules/claims-as-quotes.md +0 -55
  87. package/rules/confirm-implementation-forks.md +0 -11
  88. package/rules/file-global-constants.md +0 -81
  89. package/rules/hedging-claims.md +0 -7
  90. package/rules/measurement-denominators.md +0 -39
  91. package/rules/prompt-workflow-context-controls.md +0 -19
  92. package/rules/pstack-host-mapping.md +0 -48
  93. package/rules/pstack-models.md +0 -142
  94. package/rules/vault-context.md +0 -5
  95. package/scripts/pstack-adapters/common.md +0 -17
  96. package/scripts/pstack-adapters/create-skill.md +0 -14
  97. package/scripts/pstack-adapters/host-claude.md +0 -7
  98. package/scripts/pstack-adapters/host-codex.md +0 -11
  99. package/scripts/pstack-adapters/host-cursor.md +0 -7
  100. package/scripts/pstack.lock.json +0 -11
  101. package/scripts/refresh_pstack_plugin_skills.mjs +0 -200
  102. package/scripts/select_pstack_models.mjs +0 -469
  103. /package/.agents/{skills → skills-archived}/source-command-logifix/SKILL.md +0 -0
@@ -11,7 +11,10 @@
11
11
  - Only runs when the checked-out branch is the named branch
12
12
  - Only runs when there are no tracked changes (untracked files are fine),
13
13
  unless -StashDirty is passed
14
- - Uses merge --ff-only only (never force, never hard reset, never clean)
14
+ - Uses merge --ff-only (never force, never hard reset, never clean)
15
+ - When the fast-forward fails because the files were already copied in,
16
+ moves HEAD with a mixed reset, which writes no files. It keeps that move
17
+ only when every file the remote changed now matches the remote
15
18
  - Logs and exits non-zero on skip, diverge, or failure so a Task Scheduler
16
19
  history row can surface a problem instead of a silent no-op
17
20
 
@@ -128,6 +131,64 @@ function Invoke-GitOrExit {
128
131
  return $result.Output
129
132
  }
130
133
 
134
+ function Get-NulSeparatedPaths {
135
+ param(
136
+ [Parameter(Mandatory)]
137
+ [AllowEmptyString()]
138
+ [string]$Output
139
+ )
140
+ return @($Output -split "`0" | Where-Object { $_ })
141
+ }
142
+
143
+ function Invoke-AdoptCopiedTree {
144
+ <#
145
+ Handles a mirror whose files are refreshed by copy while its HEAD stays
146
+ behind. The fast-forward then refuses to overwrite the copied files,
147
+ even though they already hold the remote content.
148
+
149
+ A mixed reset moves HEAD and the index to the remote tip and writes no
150
+ files. If any file the remote changed still differs afterwards, the
151
+ reset goes back to the old tip, so the tree ends as it started.
152
+ #>
153
+ param(
154
+ [Parameter(Mandatory)]
155
+ [string]$LocalTip,
156
+ [Parameter(Mandatory)]
157
+ [string]$RemoteRef
158
+ )
159
+ $stagedResult = Invoke-Git -Arguments @('diff', '--cached', '--quiet')
160
+ if ($stagedResult.ExitCode -ne 0) {
161
+ Write-SyncLog -Level 'ERROR' -Message 'adopt skipped: the index holds staged changes'
162
+ return $false
163
+ }
164
+ $changedResult = Invoke-Git -Arguments @('diff', '--name-only', '--no-renames', '-z', $LocalTip, $RemoteRef)
165
+ if ($changedResult.ExitCode -ne 0) {
166
+ Write-SyncLog -Level 'ERROR' -Message "adopt skipped: cannot list remote changes: $($changedResult.Output)"
167
+ return $false
168
+ }
169
+ $allRemoteChangedPaths = [System.Collections.Generic.HashSet[string]]::new([string[]](Get-NulSeparatedPaths -Output $changedResult.Output))
170
+
171
+ $resetResult = Invoke-Git -Arguments @('reset', '-q', $RemoteRef)
172
+ if ($resetResult.ExitCode -ne 0) {
173
+ Write-SyncLog -Level 'ERROR' -Message "adopt skipped: mixed reset failed: $($resetResult.Output)"
174
+ return $false
175
+ }
176
+ $differResult = Invoke-Git -Arguments @('diff', '--name-only', '--no-renames', '-z')
177
+ $allMismatchedPaths = @(Get-NulSeparatedPaths -Output $differResult.Output | Where-Object { $allRemoteChangedPaths.Contains($_) })
178
+ if ($differResult.ExitCode -eq 0 -and $allMismatchedPaths.Count -eq 0) {
179
+ return $true
180
+ }
181
+
182
+ $revertResult = Invoke-Git -Arguments @('reset', '-q', $LocalTip)
183
+ if ($revertResult.ExitCode -ne 0) {
184
+ Write-SyncLog -Level 'ERROR' -Message "adopt revert to $LocalTip failed; HEAD is at $RemoteRef with no file changed: $($revertResult.Output)"
185
+ return $false
186
+ }
187
+ $shownPaths = ($allMismatchedPaths | Select-Object -First 20) -join '; '
188
+ Write-SyncLog -Level 'ERROR' -Message "adopt refused: $($allMismatchedPaths.Count) file(s) the remote changed differ from it; HEAD kept at ${LocalTip}: $shownPaths"
189
+ return $false
190
+ }
191
+
131
192
  try {
132
193
  if (-not (Test-Path -LiteralPath $RepoPath)) {
133
194
  Write-SyncLog -Level 'ERROR' -Message "repo path missing: $RepoPath"
@@ -194,14 +255,18 @@ try {
194
255
  Write-SyncLog -Level 'ERROR' -Message "merge --ff-only failed: $($mergeResult.Output)"
195
256
  if ($didStash) {
196
257
  $popResult = Invoke-Git -Arguments @('stash', 'pop')
197
- if ($popResult.ExitCode -eq 0) {
198
- Write-SyncLog -Level 'OK' -Message "restored the stashed edits to the working tree after the failed fast-forward"
199
- }
200
- else {
258
+ if ($popResult.ExitCode -ne 0) {
201
259
  Write-SyncLog -Level 'ERROR' -Message ("stash pop failed after the failed fast-forward; the edits are still stashed as '" + $stashMessage + "' — recover them with git stash list and git stash pop: " + $popResult.Output)
260
+ exit 1
202
261
  }
262
+ Write-SyncLog -Level 'OK' -Message "restored the stashed edits to the working tree after the failed fast-forward"
203
263
  }
204
- exit 1
264
+ if (-not (Invoke-AdoptCopiedTree -LocalTip $localTip -RemoteRef "$Remote/$Branch")) {
265
+ exit 1
266
+ }
267
+ $subject = Get-GitDecoration -Arguments @('log', '-1', '--format=%h %s') -Placeholder '<subject unavailable>'
268
+ Write-SyncLog -Level 'OK' -Message "$Branch $localTip -> $remoteTip adopted (files already matched) | $subject"
269
+ exit 0
205
270
  }
206
271
 
207
272
  $subject = Get-GitDecoration -Arguments @('log', '-1', '--format=%h %s') -Placeholder '<subject unavailable>'
@@ -91,31 +91,137 @@ def _messages_for_validator_outcomes(
91
91
  )
92
92
 
93
93
 
94
+ def _validator_messages_for_text(
95
+ document: Document,
96
+ absolute_path: Path,
97
+ text: str,
98
+ validator_module: ModuleType,
99
+ ) -> tuple[str, ...]:
100
+ all_validator_outcomes = validator_module.validate_proposed_file(
101
+ absolute_path.as_posix(),
102
+ text,
103
+ config_source_path=absolute_path,
104
+ include_ruff=False,
105
+ excluded_validator_names=constants.ALL_OVERLAPPING_VALIDATOR_NAMES,
106
+ )
107
+ return _messages_for_validator_outcomes(document, all_validator_outcomes)
108
+
109
+
110
+ def _message_body(message: str) -> str:
111
+ _head, separator, body = message.partition(
112
+ constants.VALIDATOR_MESSAGE_PREFIX_SEPARATOR
113
+ )
114
+ return body if separator and body else message
115
+
116
+
117
+ def _message_identity(message: str) -> str:
118
+ return constants.VALIDATOR_MESSAGE_DIGIT_PATTERN.sub(
119
+ constants.VALIDATOR_MESSAGE_DIGIT_PLACEHOLDER, _message_body(message)
120
+ )
121
+
122
+
123
+ def _all_measured_sizes(message: str) -> tuple[int, ...]:
124
+ return tuple(
125
+ int(each_digit_run)
126
+ for each_digit_run in constants.VALIDATOR_MESSAGE_DIGIT_PATTERN.findall(
127
+ _message_body(message)
128
+ )
129
+ )
130
+
131
+
132
+ def _has_grown_since(message: str, prior_message: str) -> bool:
133
+ return any(
134
+ each_size > each_prior_size
135
+ for each_size, each_prior_size in zip(
136
+ _all_measured_sizes(message), _all_measured_sizes(prior_message)
137
+ )
138
+ )
139
+
140
+
141
+ def _prior_messages_by_identity(
142
+ all_prior_messages: tuple[str, ...],
143
+ ) -> dict[str, list[str]]:
144
+ all_prior_by_identity: dict[str, list[str]] = {}
145
+ for each_prior_message in all_prior_messages:
146
+ all_prior_by_identity.setdefault(
147
+ _message_identity(each_prior_message), []
148
+ ).append(each_prior_message)
149
+ return all_prior_by_identity
150
+
151
+
152
+ def _is_introduced_message(
153
+ message: str, all_prior_by_identity: dict[str, list[str]]
154
+ ) -> bool:
155
+ all_matches = all_prior_by_identity.get(_message_identity(message))
156
+ if not all_matches:
157
+ return True
158
+ return _has_grown_since(message, all_matches.pop())
159
+
160
+
161
+ def _messages_introduced_by_the_change(
162
+ all_candidate_messages: tuple[str, ...],
163
+ all_prior_messages: tuple[str, ...],
164
+ ) -> tuple[str, ...]:
165
+ """Keep the messages the prior text did not already carry.
166
+
167
+ ::
168
+
169
+ prior: Function 'main' is 47 lines (max 30)
170
+ now: Function 'main' is 47 lines (max 30) -> dropped, untouched debt
171
+ now: Function 'main' is 61 lines (max 30) -> kept, the change grew it
172
+ now: Function 'added' is 47 lines (max 30) -> kept, a new breach
173
+
174
+ A message keeps its identity when it moves down the file or its measured
175
+ size falls, so only a new breach or a worse one survives. Each prior message
176
+ covers one candidate.
177
+
178
+ Args:
179
+ all_candidate_messages: Messages the post-edit text produced.
180
+ all_prior_messages: Messages the prior text produced.
181
+
182
+ Returns:
183
+ The messages this change is answerable for.
184
+ """
185
+ all_prior_by_identity = _prior_messages_by_identity(all_prior_messages)
186
+ return tuple(
187
+ each_message
188
+ for each_message in all_candidate_messages
189
+ if _is_introduced_message(each_message, all_prior_by_identity)
190
+ )
191
+
192
+
94
193
  def validator_diagnostics(
95
194
  document: Document,
96
195
  repository_root: Path,
97
196
  load_module: adapter_support.HookModuleLoader,
98
197
  ) -> tuple[Diagnostic, ...]:
99
- """Run the fast validator set on one document.
198
+ """Run the fast validator set on one document, scoped to the change.
199
+
200
+ A module already over the file-length cap reported that breach on every edit
201
+ it received, so a one-line change failed on debt it did not create. Grading
202
+ the prior text the same way and subtracting leaves the breaches this change
203
+ introduced or made worse.
100
204
 
101
205
  Args:
102
- document: Current source text and path.
206
+ document: Current source text, prior text, and path.
103
207
  repository_root: Request repository root for configuration resolution.
104
208
  load_module: Hook module loader.
105
209
 
106
210
  Returns:
107
- Path-normalized diagnostics for failed validators.
211
+ Path-normalized diagnostics for the failures this change is answerable for.
108
212
  """
109
213
  absolute_path = adapter_support._document_path(repository_root, document)
110
214
  validator_module = load_module("validators.run_all_validators")
111
- all_validator_outcomes = validator_module.validate_proposed_file(
112
- absolute_path.as_posix(),
113
- document.text,
114
- config_source_path=absolute_path,
115
- include_ruff=False,
116
- excluded_validator_names=constants.ALL_OVERLAPPING_VALIDATOR_NAMES,
215
+ all_messages = _validator_messages_for_text(
216
+ document, absolute_path, document.text, validator_module
117
217
  )
118
- all_messages = _messages_for_validator_outcomes(document, all_validator_outcomes)
218
+ if document.prior_text is not None:
219
+ all_prior_messages = _validator_messages_for_text(
220
+ document, absolute_path, document.prior_text, validator_module
221
+ )
222
+ all_messages = _messages_introduced_by_the_change(
223
+ all_messages, all_prior_messages
224
+ )
119
225
  return adapter_support._diagnostics_for_messages(document, "validators", all_messages)
120
226
 
121
227
 
@@ -30,6 +30,18 @@ APPROVED_TEST_PATHS_BY_PRODUCTION_PATH: dict[
30
30
  ),
31
31
  }
32
32
  ),
33
+ PurePosixPath(
34
+ "packages/claude-dev-env/hooks/blocking/code_rules_enforcer.py"
35
+ ): frozenset(
36
+ {
37
+ PurePosixPath(
38
+ "packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py"
39
+ ),
40
+ PurePosixPath(
41
+ "packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_cap_meta.py"
42
+ ),
43
+ }
44
+ ),
33
45
  PurePosixPath(
34
46
  "packages/claude-dev-env/hooks/git-hooks/verification_notice_context.py"
35
47
  ): frozenset(
@@ -72,6 +72,9 @@ FAST_SAVE_VALIDATORS_STEM = "fast_save_validators"
72
72
  ALL_OVERLAPPING_VALIDATOR_NAMES = frozenset(
73
73
  {"Python Style", "Magic Values", "Type Safety", "Test Safety", "React"}
74
74
  )
75
+ VALIDATOR_MESSAGE_PREFIX_SEPARATOR = ": "
76
+ VALIDATOR_MESSAGE_DIGIT_PATTERN = re.compile(r"\d+")
77
+ VALIDATOR_MESSAGE_DIGIT_PLACEHOLDER = "#"
75
78
  ALL_PR_LOOP_SCRIPTS_PATH_SEGMENTS = ("_shared", "pr-loop", "scripts")
76
79
  TERMINOLOGY_SWEEP_MODULE_NAME = "terminology_sweep"
77
80
  TERMINOLOGY_SWEEP_RULE_ID = "terminology-sweep"
@@ -0,0 +1,108 @@
1
+ Set-StrictMode -Version Latest
2
+
3
+ BeforeAll {
4
+ $scriptUnderTest = Join-Path (Split-Path -Parent $PSScriptRoot) 'Sync-RepoMain.ps1'
5
+
6
+ function Invoke-TestGit {
7
+ param(
8
+ [string]$RepoPath,
9
+ [string[]]$Arguments
10
+ )
11
+ $output = & git -C $RepoPath -c user.name=tester -c user.email=tester@example.com -c core.autocrlf=false @Arguments 2>&1
12
+ if ($LASTEXITCODE -ne 0) {
13
+ throw "git $($Arguments -join ' ') failed: $output"
14
+ }
15
+ return ($output | Out-String).Trim()
16
+ }
17
+
18
+ function Write-TestFile {
19
+ param(
20
+ [string]$RepoPath,
21
+ [string]$RelativePath,
22
+ [string]$Content
23
+ )
24
+ [System.IO.File]::WriteAllText((Join-Path $RepoPath $RelativePath), $Content)
25
+ }
26
+
27
+ function New-MirrorBehindRemote {
28
+ <#
29
+ Builds a bare remote plus a mirror clone one commit behind it. The
30
+ remote's newer commit edits tracked.txt and adds added.txt.
31
+ #>
32
+ param([string]$Name)
33
+ $rootPath = Join-Path $TestDrive $Name
34
+ $remotePath = Join-Path $rootPath 'remote.git'
35
+ $authorPath = Join-Path $rootPath 'author'
36
+ $mirrorPath = Join-Path $rootPath 'mirror'
37
+ New-Item -ItemType Directory -Force -Path $remotePath, $authorPath | Out-Null
38
+
39
+ Invoke-TestGit -RepoPath $remotePath -Arguments @('init', '-q', '--bare', '-b', 'main') | Out-Null
40
+ Invoke-TestGit -RepoPath $authorPath -Arguments @('init', '-q', '-b', 'main') | Out-Null
41
+ Write-TestFile -RepoPath $authorPath -RelativePath 'tracked.txt' -Content "old`n"
42
+ Invoke-TestGit -RepoPath $authorPath -Arguments @('add', '-A') | Out-Null
43
+ Invoke-TestGit -RepoPath $authorPath -Arguments @('commit', '-q', '-m', 'first') | Out-Null
44
+ Invoke-TestGit -RepoPath $authorPath -Arguments @('remote', 'add', 'origin', $remotePath) | Out-Null
45
+ Invoke-TestGit -RepoPath $authorPath -Arguments @('push', '-q', 'origin', 'main') | Out-Null
46
+
47
+ & git -c core.autocrlf=false clone -q $remotePath $mirrorPath 2>&1 | Out-Null
48
+ Invoke-TestGit -RepoPath $mirrorPath -Arguments @('config', 'core.autocrlf', 'false') | Out-Null
49
+ $oldTip = Invoke-TestGit -RepoPath $mirrorPath -Arguments @('rev-parse', 'HEAD')
50
+
51
+ Write-TestFile -RepoPath $authorPath -RelativePath 'tracked.txt' -Content "new`n"
52
+ Write-TestFile -RepoPath $authorPath -RelativePath 'added.txt' -Content "added`n"
53
+ Invoke-TestGit -RepoPath $authorPath -Arguments @('add', '-A') | Out-Null
54
+ Invoke-TestGit -RepoPath $authorPath -Arguments @('commit', '-q', '-m', 'second') | Out-Null
55
+ Invoke-TestGit -RepoPath $authorPath -Arguments @('push', '-q', 'origin', 'main') | Out-Null
56
+ $remoteTip = Invoke-TestGit -RepoPath $authorPath -Arguments @('rev-parse', 'HEAD')
57
+
58
+ return [pscustomobject]@{
59
+ MirrorPath = $mirrorPath
60
+ LogPath = Join-Path $rootPath 'sync.log'
61
+ OldTip = $oldTip
62
+ RemoteTip = $remoteTip
63
+ }
64
+ }
65
+
66
+ function Invoke-SyncScript {
67
+ param([pscustomobject]$Fixture)
68
+ & pwsh -NoProfile -File $scriptUnderTest -RepoPath $Fixture.MirrorPath -StashDirty -LogPath $Fixture.LogPath | Out-Null
69
+ return $LASTEXITCODE
70
+ }
71
+ }
72
+
73
+ Describe 'Sync-RepoMain' {
74
+ It 'fast-forwards a clean mirror that is behind the remote' {
75
+ $fixture = New-MirrorBehindRemote -Name 'clean-behind'
76
+
77
+ $exitCode = Invoke-SyncScript -Fixture $fixture
78
+
79
+ $exitCode | Should -Be 0
80
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')) | Should -Be $fixture.RemoteTip
81
+ }
82
+
83
+ It 'adopts the remote tip when copied files already match the remote' {
84
+ $fixture = New-MirrorBehindRemote -Name 'copied-match'
85
+ Write-TestFile -RepoPath $fixture.MirrorPath -RelativePath 'tracked.txt' -Content "new`n"
86
+ Write-TestFile -RepoPath $fixture.MirrorPath -RelativePath 'added.txt' -Content "added`n"
87
+
88
+ $exitCode = Invoke-SyncScript -Fixture $fixture
89
+
90
+ $exitCode | Should -Be 0
91
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')) | Should -Be $fixture.RemoteTip
92
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('status', '--porcelain')) | Should -BeNullOrEmpty
93
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('stash', 'list')) | Should -BeNullOrEmpty
94
+ }
95
+
96
+ It 'keeps the old tip and the copied file when a copied file differs from the remote' {
97
+ $fixture = New-MirrorBehindRemote -Name 'copied-differs'
98
+ Write-TestFile -RepoPath $fixture.MirrorPath -RelativePath 'tracked.txt' -Content "new`n"
99
+ Write-TestFile -RepoPath $fixture.MirrorPath -RelativePath 'added.txt' -Content "local only`n"
100
+
101
+ $exitCode = Invoke-SyncScript -Fixture $fixture
102
+
103
+ $exitCode | Should -Be 1
104
+ (Invoke-TestGit -RepoPath $fixture.MirrorPath -Arguments @('rev-parse', 'HEAD')) | Should -Be $fixture.OldTip
105
+ [System.IO.File]::ReadAllText((Join-Path $fixture.MirrorPath 'added.txt')) | Should -Be "local only`n"
106
+ [System.IO.File]::ReadAllText((Join-Path $fixture.MirrorPath 'tracked.txt')) | Should -Be "new`n"
107
+ }
108
+ }
@@ -0,0 +1,126 @@
1
+ """Validator diagnostics report only the findings a change introduces.
2
+
3
+ The code-rule adapter already compares a document against its prior text, so a
4
+ long function nobody touched stays quiet there. The validator adapter graded the
5
+ post-edit text alone, so a single-line edit to a long module reported every
6
+ pre-existing cap breach in it and failed the change on debt it did not create.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path, PurePosixPath
12
+
13
+ from policy_lint import adapters
14
+ from policy_lint.model import ContentOrigin, Diagnostic, Document
15
+ from policy_lint.selection import _changed_lines
16
+
17
+ FUNCTION_LENGTH_MESSAGE_FRAGMENT = "lines (max"
18
+ FILE_LENGTH_MESSAGE_FRAGMENT = "File is"
19
+ PRODUCTION_RELATIVE_PATH = "packages/claude-dev-env/scripts/sample_capped_module.py"
20
+ OVER_THRESHOLD_BODY_LINE_COUNT = 45
21
+ UNDER_THRESHOLD_BODY_LINE_COUNT = 10
22
+ OVER_FILE_CAP_FUNCTION_COUNT = 12
23
+ REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
24
+
25
+
26
+ def _counting_function_source(function_name: str, body_line_count: int) -> str:
27
+ all_body_lines = [
28
+ f" running_total = running_total + {each_index}"
29
+ for each_index in range(body_line_count)
30
+ ]
31
+ return (
32
+ "\n".join(
33
+ [f"def {function_name}(running_total: int) -> int:"]
34
+ + all_body_lines
35
+ + [" return running_total"]
36
+ )
37
+ + "\n"
38
+ )
39
+
40
+
41
+ def _document(prior_text: str | None, current_text: str) -> Document:
42
+ return Document(
43
+ PurePosixPath(PRODUCTION_RELATIVE_PATH),
44
+ current_text,
45
+ prior_text,
46
+ _changed_lines(prior_text, current_text),
47
+ ContentOrigin.REVISION_DIFF,
48
+ )
49
+
50
+
51
+ def _messages_carrying(document: Document, fragment: str) -> tuple[str, ...]:
52
+ all_diagnostics: tuple[Diagnostic, ...] = adapters.validator_diagnostics(
53
+ document, REPOSITORY_ROOT
54
+ )
55
+ return tuple(
56
+ each_diagnostic.message
57
+ for each_diagnostic in all_diagnostics
58
+ if fragment in each_diagnostic.message
59
+ )
60
+
61
+
62
+ def _function_length_messages(
63
+ prior_text: str | None, current_text: str
64
+ ) -> tuple[str, ...]:
65
+ return _messages_carrying(
66
+ _document(prior_text, current_text), FUNCTION_LENGTH_MESSAGE_FRAGMENT
67
+ )
68
+
69
+
70
+ def _over_file_cap_source(extra_line: str = "") -> str:
71
+ all_functions = [
72
+ _counting_function_source(
73
+ f"each_step_{each_index}", OVER_THRESHOLD_BODY_LINE_COUNT
74
+ )
75
+ for each_index in range(OVER_FILE_CAP_FUNCTION_COUNT)
76
+ ]
77
+ return extra_line + "\n".join(all_functions)
78
+
79
+
80
+ def test_should_leave_an_untouched_long_function_alone() -> None:
81
+ prior_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
82
+ current_text = f"import os\nimport sys\n{prior_text}"
83
+ assert _function_length_messages(prior_text, current_text) == ()
84
+
85
+
86
+ def test_should_report_an_over_long_function_the_change_adds() -> None:
87
+ prior_text = _counting_function_source("existing", UNDER_THRESHOLD_BODY_LINE_COUNT)
88
+ added_text = _counting_function_source("added", OVER_THRESHOLD_BODY_LINE_COUNT)
89
+ assert _function_length_messages(prior_text, f"{prior_text}\n{added_text}") != ()
90
+
91
+
92
+ def test_should_report_a_function_the_change_grew_past_the_cap() -> None:
93
+ prior_text = _counting_function_source("main", UNDER_THRESHOLD_BODY_LINE_COUNT)
94
+ current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
95
+ assert _function_length_messages(prior_text, current_text) != ()
96
+
97
+
98
+ def test_should_report_every_cap_breach_in_a_wholly_new_document() -> None:
99
+ current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
100
+ assert _function_length_messages(None, current_text) != ()
101
+
102
+
103
+ def test_should_report_a_cap_breach_when_the_selection_names_no_prior_text() -> None:
104
+ current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
105
+ document = Document(
106
+ PurePosixPath(PRODUCTION_RELATIVE_PATH),
107
+ current_text,
108
+ None,
109
+ None,
110
+ ContentOrigin.WORKTREE,
111
+ )
112
+ assert _messages_carrying(document, FUNCTION_LENGTH_MESSAGE_FRAGMENT) != ()
113
+
114
+
115
+ def test_should_leave_an_over_cap_file_alone_when_the_change_only_shrinks_it() -> None:
116
+ prior_text = _over_file_cap_source("REMOVED_SETTING = 1\n")
117
+ current_text = _over_file_cap_source()
118
+ document = _document(prior_text, current_text)
119
+ assert _messages_carrying(document, FILE_LENGTH_MESSAGE_FRAGMENT) == ()
120
+
121
+
122
+ def test_should_report_a_file_the_change_pushed_over_the_cap() -> None:
123
+ prior_text = _counting_function_source("main", UNDER_THRESHOLD_BODY_LINE_COUNT)
124
+ current_text = _over_file_cap_source()
125
+ document = _document(prior_text, current_text)
126
+ assert _messages_carrying(document, FILE_LENGTH_MESSAGE_FRAGMENT) != ()
@@ -1 +0,0 @@
1
- @AGENTS.md