claude-dev-env 2.35.0 → 2.36.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 (42) hide show
  1. package/.agents/skills/autoconverge/SKILL.md +19 -15
  2. package/.agents/skills/autoconverge/reference/gotchas.md +3 -3
  3. package/.agents/skills/autoconverge/reference/multi-pr.md +6 -2
  4. package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +11 -0
  5. package/.agents/skills/autoconverge/workflow/converge.mjs +26 -33
  6. package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +26 -2
  7. package/.agents/skills/autoconverge/workflow/converge_multi.mjs +12 -28
  8. package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +16 -0
  9. package/bin/cde.mjs +175 -0
  10. package/bin/cde.test.mjs +222 -0
  11. package/bin/install-constants.mjs +4 -0
  12. package/bin/install.cursor-rules.test.mjs +233 -2
  13. package/bin/install.mjs +49 -3
  14. package/bin/pstack-model-preferences.codex.json +23 -0
  15. package/bin/select-pstack-models.test.mjs +378 -0
  16. package/hooks/hooks_constants/tool_names.py +20 -0
  17. package/hooks/validators/fast_save_validators.py +63 -89
  18. package/hooks/validators/run_all_validators.py +16 -8
  19. package/hooks/validators/test_fast_save_validators_policy_lint.py +25 -0
  20. package/hooks/validators/test_run_all_validators_policy_lint.py +52 -0
  21. package/package.json +3 -2
  22. package/rules/pstack-models.md +119 -0
  23. package/scripts/cde_lint.py +258 -0
  24. package/scripts/policy_lint/__init__.py +41 -0
  25. package/scripts/policy_lint/adapter_configuration.py +91 -0
  26. package/scripts/policy_lint/adapter_detectors.py +328 -0
  27. package/scripts/policy_lint/adapter_pairing.py +145 -0
  28. package/scripts/policy_lint/adapter_support.py +97 -0
  29. package/scripts/policy_lint/adapters.py +352 -0
  30. package/scripts/policy_lint/config/__init__.py +1 -0
  31. package/scripts/policy_lint/config/constants.py +66 -0
  32. package/scripts/policy_lint/engine.py +224 -0
  33. package/scripts/policy_lint/model.py +387 -0
  34. package/scripts/policy_lint/registry.py +162 -0
  35. package/scripts/policy_lint/render.py +91 -0
  36. package/scripts/policy_lint/selection.py +372 -0
  37. package/scripts/policy_lint/selection_git.py +147 -0
  38. package/scripts/select_pstack_models.mjs +304 -0
  39. package/scripts/tests/test_cde_lint.py +374 -0
  40. package/scripts/tests/test_policy_lint_rules_engine.py +381 -0
  41. package/scripts/tests/test_policy_lint_rules_registry.py +252 -0
  42. package/scripts/tests/test_policy_lint_selection.py +373 -0
@@ -0,0 +1,304 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const inheritanceAliases = new Set(['inherit-parent', 'auto']);
6
+ const hostNamePattern = /^[a-z0-9][a-z0-9_-]*$/;
7
+ const defaultSingleDelegationPanel = Object.freeze({
8
+ agentCount: 1,
9
+ requiresDistinctModels: false,
10
+ });
11
+
12
+ export const allPstackRoleRequirements = Object.freeze({
13
+ 'feature, refactoring': 'reliable code execution',
14
+ 'bug-fix': 'careful diagnosis and code execution',
15
+ 'perf-issue': 'systems reasoning and performance analysis',
16
+ 'hillclimb': 'strong iterative judgment',
17
+ 'judgment and prose': 'strong judgment and clear prose',
18
+ 'hardest tasks': 'highest available reasoning capability',
19
+ 'how explorer': 'fast codebase exploration',
20
+ 'how explainer': 'clear technical synthesis',
21
+ 'how critics': 'independent technical criticism',
22
+ 'why investigators': 'fast evidence gathering',
23
+ 'why synthesizer': 'strong evidence synthesis',
24
+ 'reflect tooling': 'reliable tool and workflow analysis',
25
+ 'reflect judgment, divergent, synthesizer': 'strong judgment and divergent analysis',
26
+ 'arena runners': 'independent solution development',
27
+ 'arena cross-judge pool': 'independent comparative judgment',
28
+ 'swarm workers': 'fast bounded task execution',
29
+ 'architect runners': 'independent architecture design',
30
+ 'interrogate reviewers': 'independent adversarial review',
31
+ });
32
+
33
+ export const defaultPanelByPstackRole = Object.freeze({
34
+ 'how critics': Object.freeze({ agentCount: 3, requiresDistinctModels: false }),
35
+ 'arena runners': Object.freeze({ agentCount: 3, requiresDistinctModels: true }),
36
+ 'swarm workers': defaultSingleDelegationPanel,
37
+ 'architect runners': Object.freeze({ agentCount: 2, requiresDistinctModels: true }),
38
+ 'interrogate reviewers': Object.freeze({ agentCount: 3, requiresDistinctModels: true }),
39
+ });
40
+
41
+ export function selectPstackDelegation(input) {
42
+ validateSelectionInput(input);
43
+ const panel = resolvePanel(input);
44
+ validateDelegationIndex(input.delegationIndex, panel.agentCount);
45
+ const modelsByRole = readHostPreferences(input);
46
+ const allCandidates = collectCandidates(input, modelsByRole[input.role] ?? []);
47
+ const allSelectedCandidates = selectPanelCandidates(allCandidates, panel);
48
+ return buildSelection(input, panel, allSelectedCandidates);
49
+ }
50
+
51
+ function validateSelectionInput(input) {
52
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
53
+ throw new Error('selection input must be an object');
54
+ }
55
+ validateHost(input.host, input.inventoryHost);
56
+ validateDelegationIndex(input.delegationIndex);
57
+ requireStringArray(input.availableModelIds, 'available model ids');
58
+ requireStringArray(input.confirmedSuitableModelIds, 'confirmed suitable model ids');
59
+ validateRealModelIds(input.availableModelIds, 'available model ids');
60
+ validateParentFallback(input.parentFallback);
61
+ validateConfirmedModels(input);
62
+ if (typeof input.role !== 'string' || !Object.hasOwn(allPstackRoleRequirements, input.role)) {
63
+ throw new Error('role must name a portable pstack role');
64
+ }
65
+ }
66
+
67
+ function validateHost(host, inventoryHost) {
68
+ if (typeof host !== 'string' || !hostNamePattern.test(host)) {
69
+ throw new Error('host must be a lowercase host identifier');
70
+ }
71
+ if (inventoryHost !== host) {
72
+ throw new Error('inventory host must match selected host');
73
+ }
74
+ }
75
+
76
+ function validateDelegationIndex(delegationIndex, agentCount) {
77
+ if (!Number.isInteger(delegationIndex) || delegationIndex < 0) {
78
+ throw new Error('delegation index must be a non-negative integer');
79
+ }
80
+ if (agentCount !== undefined && delegationIndex >= agentCount) {
81
+ throw new Error('delegation index must identify an agent in the panel');
82
+ }
83
+ }
84
+
85
+ function requireStringArray(allEntries, label) {
86
+ if (!Array.isArray(allEntries) || allEntries.some(
87
+ eachEntry => typeof eachEntry !== 'string' || eachEntry === '',
88
+ )) {
89
+ throw new Error(label + ' must be an array of non-empty strings');
90
+ }
91
+ }
92
+
93
+ function validateRealModelIds(allModelIds, label) {
94
+ if (allModelIds.some(eachModelId => inheritanceAliases.has(eachModelId))) {
95
+ throw new Error(label + ' cannot contain inheritance aliases');
96
+ }
97
+ }
98
+
99
+ function validateParentFallback(parentFallback) {
100
+ const hasBooleanFlags = parentFallback
101
+ && typeof parentFallback === 'object'
102
+ && typeof parentFallback.isAllowed === 'boolean'
103
+ && typeof parentFallback.hasMaterialCapabilityLoss === 'boolean';
104
+ if (!hasBooleanFlags) {
105
+ throw new Error('parent fallback flags must be booleans');
106
+ }
107
+ }
108
+
109
+ function validateConfirmedModels(input) {
110
+ validateRealModelIds(input.confirmedSuitableModelIds, 'confirmed suitable model ids');
111
+ const allAvailableModelIds = new Set(input.availableModelIds);
112
+ if (input.confirmedSuitableModelIds.some(
113
+ eachModelId => !allAvailableModelIds.has(eachModelId),
114
+ )) {
115
+ throw new Error('confirmed suitable models must be available');
116
+ }
117
+ }
118
+
119
+ function resolvePanel(input) {
120
+ const panel = input.panel
121
+ ?? defaultPanelByPstackRole[input.role]
122
+ ?? defaultSingleDelegationPanel;
123
+ const hasValidShape = panel
124
+ && typeof panel === 'object'
125
+ && Number.isInteger(panel.agentCount)
126
+ && panel.agentCount > 0
127
+ && typeof panel.requiresDistinctModels === 'boolean';
128
+ if (!hasValidShape) {
129
+ throw new Error('panel requires a positive agent count and a diversity boolean');
130
+ }
131
+ return {
132
+ agentCount: panel.agentCount,
133
+ requiresDistinctModels: panel.requiresDistinctModels,
134
+ };
135
+ }
136
+
137
+ function readHostPreferences(input) {
138
+ const preferencesDirectory = input.preferencesDirectory
139
+ ?? resolve(dirname(fileURLToPath(import.meta.url)), '..', 'rules');
140
+ const preferencesPath = join(
141
+ preferencesDirectory,
142
+ 'pstack-model-preferences.' + input.host + '.json',
143
+ );
144
+ if (!existsSync(preferencesPath)) {
145
+ return {};
146
+ }
147
+ const preferences = JSON.parse(readFileSync(preferencesPath, 'utf8'));
148
+ if (preferences.host !== input.host) {
149
+ throw new Error('preference host must match selected host');
150
+ }
151
+ return preferences.modelsByRole ?? {};
152
+ }
153
+
154
+ function collectCandidates(input, allPreferredModelIds) {
155
+ requireStringArray(allPreferredModelIds, 'role preferences');
156
+ const allAvailableModelIds = new Set(input.availableModelIds);
157
+ const allPreferredCandidates = allPreferredModelIds
158
+ .map(eachModelId => preferenceCandidate(eachModelId, allAvailableModelIds, input))
159
+ .filter(Boolean);
160
+ const allAlternativeCandidates = input.confirmedSuitableModelIds.map(
161
+ eachModelId => modelCandidate(eachModelId, 'confirmed-host-alternative'),
162
+ );
163
+ const allCandidates = deduplicateCandidates([
164
+ ...allPreferredCandidates,
165
+ ...allAlternativeCandidates,
166
+ ]);
167
+ const allCandidatesWithFallback = allCandidates.length === 0
168
+ && input.parentFallback.isAllowed
169
+ ? [parentCandidate()]
170
+ : allCandidates;
171
+ return orderCrossJudgeCandidates(input, allCandidatesWithFallback);
172
+ }
173
+
174
+ function preferenceCandidate(modelId, allAvailableModelIds, input) {
175
+ if (inheritanceAliases.has(modelId)) {
176
+ return input.parentFallback.isAllowed ? parentCandidate() : null;
177
+ }
178
+ return allAvailableModelIds.has(modelId)
179
+ ? modelCandidate(modelId, 'host-preference')
180
+ : null;
181
+ }
182
+
183
+ function modelCandidate(modelId, source) {
184
+ return { kind: 'model', modelId, source };
185
+ }
186
+
187
+ function parentCandidate() {
188
+ return { kind: 'parent', modelId: null, source: 'parent-inheritance' };
189
+ }
190
+
191
+ function deduplicateCandidates(allCandidates) {
192
+ const seenCandidateKeys = new Set();
193
+ return allCandidates.filter(eachCandidate => {
194
+ const candidateKey = eachCandidate.kind + ':' + (eachCandidate.modelId ?? '');
195
+ if (seenCandidateKeys.has(candidateKey)) {
196
+ return false;
197
+ }
198
+ seenCandidateKeys.add(candidateKey);
199
+ return true;
200
+ });
201
+ }
202
+
203
+ function orderCrossJudgeCandidates(input, allCandidates) {
204
+ if (input.role !== 'arena cross-judge pool' || !input.parentModelId) {
205
+ return allCandidates;
206
+ }
207
+ const allOtherCandidates = allCandidates.filter(
208
+ eachCandidate => eachCandidate.kind === 'model'
209
+ && eachCandidate.modelId !== input.parentModelId,
210
+ );
211
+ const allParentCandidates = allCandidates.filter(
212
+ eachCandidate => eachCandidate.kind === 'parent'
213
+ || eachCandidate.modelId === input.parentModelId,
214
+ );
215
+ return [...allOtherCandidates, ...allParentCandidates];
216
+ }
217
+
218
+ function selectPanelCandidates(allCandidates, panel) {
219
+ if (panel.requiresDistinctModels) {
220
+ return allCandidates
221
+ .filter(eachCandidate => eachCandidate.kind === 'model')
222
+ .slice(0, panel.agentCount);
223
+ }
224
+ if (allCandidates.length === 0) {
225
+ return [];
226
+ }
227
+ return Array.from(
228
+ { length: panel.agentCount },
229
+ (_, index) => allCandidates[index % allCandidates.length],
230
+ );
231
+ }
232
+
233
+ function buildSelection(input, requestedPanel, allSelectedCandidates) {
234
+ const panel = reportPanel(requestedPanel, allSelectedCandidates);
235
+ const failure = selectionFailure(input, panel, allSelectedCandidates);
236
+ const selectedCandidate = allSelectedCandidates[input.delegationIndex];
237
+ return {
238
+ selectedHost: input.host,
239
+ role: input.role,
240
+ requiredCapability: allPstackRoleRequirements[input.role],
241
+ availabilityValidated: true,
242
+ canDelegate: failure === null,
243
+ requiresUserChoice: failure !== null,
244
+ failure,
245
+ selectionSource: selectedCandidate?.source ?? null,
246
+ panel,
247
+ nativeSpawnArguments: nativeSpawnArguments(selectedCandidate, failure),
248
+ omitNativeModelArgument: selectedCandidate?.kind === 'parent',
249
+ };
250
+ }
251
+
252
+ function reportPanel(requestedPanel, allSelectedCandidates) {
253
+ const allSelectedModelIds = allSelectedCandidates.map(
254
+ eachCandidate => eachCandidate.modelId,
255
+ );
256
+ const distinctRealModelCount = new Set(allSelectedModelIds.filter(Boolean)).size;
257
+ return {
258
+ agentCount: requestedPanel.agentCount,
259
+ requiresDistinctModels: requestedPanel.requiresDistinctModels,
260
+ selectedModelIds: allSelectedModelIds,
261
+ selectionSources: allSelectedCandidates.map(eachCandidate => eachCandidate.source),
262
+ isCrossModelDiverse: requestedPanel.agentCount > 1
263
+ && distinctRealModelCount === requestedPanel.agentCount,
264
+ };
265
+ }
266
+
267
+ function selectionFailure(input, panel, allSelectedCandidates) {
268
+ if (panel.requiresDistinctModels && !panel.isCrossModelDiverse) {
269
+ return 'distinct-models-unavailable';
270
+ }
271
+ if (allSelectedCandidates.length < panel.agentCount) {
272
+ return 'no-supported-model';
273
+ }
274
+ if (allSelectedCandidates.some(eachCandidate => eachCandidate.kind === 'parent')
275
+ && input.parentFallback.hasMaterialCapabilityLoss) {
276
+ return 'parent-capability-loss';
277
+ }
278
+ return null;
279
+ }
280
+
281
+ function nativeSpawnArguments(selectedCandidate, failure) {
282
+ if (failure || !selectedCandidate || selectedCandidate.kind === 'parent') {
283
+ return {};
284
+ }
285
+ return { model: selectedCandidate.modelId };
286
+ }
287
+
288
+ async function readSelectionInput() {
289
+ let inputText = '';
290
+ for await (const eachChunk of process.stdin) {
291
+ inputText += eachChunk;
292
+ }
293
+ return JSON.parse(inputText);
294
+ }
295
+
296
+ function isEntryPoint() {
297
+ return process.argv[1]
298
+ && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
299
+ }
300
+
301
+ if (isEntryPoint()) {
302
+ const selectionInput = await readSelectionInput();
303
+ console.log(JSON.stringify(selectPstackDelegation(selectionInput)));
304
+ }
@@ -0,0 +1,374 @@
1
+ """Behavioral tests for the public policy-lint command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ from collections.abc import Callable
10
+ from pathlib import Path, PurePosixPath
11
+ from types import ModuleType
12
+
13
+ import pytest
14
+
15
+ _SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
16
+ if str(_SCRIPTS_DIRECTORY) not in sys.path:
17
+ sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
18
+
19
+ import cde_lint
20
+ from policy_lint import render as render_module
21
+ from policy_lint.model import (
22
+ BaseChanges,
23
+ Diagnostic,
24
+ ExplicitFiles,
25
+ LintReport,
26
+ LintRequest,
27
+ Location,
28
+ RepositoryTree,
29
+ Severity,
30
+ StagedChanges,
31
+ TextDocument,
32
+ )
33
+ from policy_lint.selection import SelectionRunFatal
34
+
35
+
36
+ def _cli_module() -> ModuleType:
37
+ return cde_lint
38
+
39
+
40
+ def _render_module() -> ModuleType:
41
+ return render_module
42
+
43
+
44
+ def _clean_report() -> LintReport:
45
+ return LintReport(1, (), (), ("rule-a",), (), ())
46
+
47
+
48
+ def _located_diagnostic() -> Diagnostic:
49
+ return Diagnostic(
50
+ "rule-a",
51
+ Severity.ERROR,
52
+ "located finding",
53
+ Location(PurePosixPath("src/app.py"), 4, 2),
54
+ )
55
+
56
+
57
+ def _unlocated_diagnostic() -> Diagnostic:
58
+ return Diagnostic("rule-b", Severity.WARNING, "unlocated finding")
59
+
60
+
61
+ def _mixed_report() -> LintReport:
62
+ return LintReport(
63
+ 1,
64
+ (_located_diagnostic(), _unlocated_diagnostic()),
65
+ (PurePosixPath("src/app.py"),),
66
+ ("rule-a", "rule-b"),
67
+ (),
68
+ (),
69
+ )
70
+
71
+
72
+ def _run_cli(
73
+ all_arguments: list[str],
74
+ *,
75
+ stdin_text: str = "",
76
+ lint_runner: Callable[[LintRequest], LintReport],
77
+ repository_root: Path,
78
+ ) -> tuple[int, str, str]:
79
+ stdout = io.StringIO()
80
+ stderr = io.StringIO()
81
+ exit_code = _cli_module().main(
82
+ all_arguments,
83
+ stdin=io.StringIO(stdin_text),
84
+ stdout=stdout,
85
+ stderr=stderr,
86
+ lint_runner=lint_runner,
87
+ repository_root=repository_root,
88
+ )
89
+ return exit_code, stdout.getvalue(), stderr.getvalue()
90
+
91
+
92
+ def _capture_request(
93
+ all_arguments: list[str],
94
+ repository_root: Path,
95
+ stdin_text: str = "",
96
+ ) -> LintRequest:
97
+ captured_request: dict[str, LintRequest] = {}
98
+
99
+ def fake_lint(request: LintRequest) -> LintReport:
100
+ captured_request["request"] = request
101
+ return _clean_report()
102
+
103
+ exit_code, _, stderr_text = _run_cli(
104
+ all_arguments,
105
+ stdin_text=stdin_text,
106
+ lint_runner=fake_lint,
107
+ repository_root=repository_root,
108
+ )
109
+ assert exit_code == 0, stderr_text
110
+ return captured_request["request"]
111
+
112
+
113
+ def test_should_build_explicit_files_request(tmp_path: Path) -> None:
114
+ request = _capture_request(
115
+ ["--files", "src/a.py", "src/b.py"],
116
+ repository_root=tmp_path,
117
+ )
118
+ assert isinstance(request.source, ExplicitFiles)
119
+ assert request.source.paths == (Path("src/a.py"), Path("src/b.py"))
120
+ assert request.repository_root == tmp_path
121
+ assert request.rule_sets == frozenset({"changed"})
122
+
123
+
124
+ def test_should_build_staged_request(tmp_path: Path) -> None:
125
+ request = _capture_request(["--staged"], repository_root=tmp_path)
126
+ assert isinstance(request.source, StagedChanges)
127
+ assert request.rule_sets == frozenset({"changed"})
128
+
129
+
130
+ def test_should_build_base_request(tmp_path: Path) -> None:
131
+ request = _capture_request(
132
+ ["--base", "origin/main"],
133
+ repository_root=tmp_path,
134
+ )
135
+ assert isinstance(request.source, BaseChanges)
136
+ assert request.source.revision == "origin/main"
137
+
138
+
139
+ def test_should_build_repository_request(tmp_path: Path) -> None:
140
+ request = _capture_request(["--repository"], repository_root=tmp_path)
141
+ assert isinstance(request.source, RepositoryTree)
142
+ assert request.rule_sets == frozenset({"repository"})
143
+
144
+
145
+ def test_should_read_text_as_from_stdin_not_disk(tmp_path: Path) -> None:
146
+ buffer_path = tmp_path / "draft.py"
147
+ buffer_path.write_text("DISK CONTENTS\n", encoding="utf-8")
148
+ request = _capture_request(
149
+ ["--text-as", str(buffer_path)],
150
+ repository_root=tmp_path,
151
+ stdin_text="EDITOR BUFFER",
152
+ )
153
+ assert isinstance(request.source, TextDocument)
154
+ assert request.source.path == buffer_path
155
+ assert request.source.text == "EDITOR BUFFER"
156
+
157
+
158
+ def test_should_reject_combined_source_flags(tmp_path: Path) -> None:
159
+ calls: list[LintRequest] = []
160
+
161
+ def fake_lint(request: LintRequest) -> LintReport:
162
+ calls.append(request)
163
+ return _clean_report()
164
+
165
+ exit_code, _, stderr_text = _run_cli(
166
+ ["--files", "a.py", "--staged"],
167
+ lint_runner=fake_lint,
168
+ repository_root=tmp_path,
169
+ )
170
+ assert exit_code == 2
171
+ assert calls == []
172
+ assert stderr_text != ""
173
+
174
+
175
+ def test_should_exit_two_when_no_source_flag(tmp_path: Path) -> None:
176
+ exit_code, _, stderr_text = _run_cli(
177
+ [],
178
+ lint_runner=lambda _request: _clean_report(),
179
+ repository_root=tmp_path,
180
+ )
181
+ assert exit_code == 2
182
+ assert stderr_text != ""
183
+
184
+
185
+ def test_should_discover_repository_root_from_nested_directory(tmp_path: Path) -> None:
186
+ subprocess.run(
187
+ ["git", "init", "--quiet"],
188
+ cwd=tmp_path,
189
+ check=True,
190
+ capture_output=True,
191
+ )
192
+ nested_directory = tmp_path / "nested"
193
+ nested_directory.mkdir()
194
+ assert _cli_module()._discover_repository_root(nested_directory) == tmp_path.resolve()
195
+
196
+
197
+ def test_should_resolve_files_from_the_caller_directory(tmp_path: Path) -> None:
198
+ all_arguments = _cli_module()._resolve_shell_file_arguments(
199
+ ["--files", "nested.py", "--format", "json"], tmp_path
200
+ )
201
+ assert all_arguments == [
202
+ "--files",
203
+ str(tmp_path / "nested.py"),
204
+ "--format",
205
+ "json",
206
+ ]
207
+
208
+
209
+ def test_should_resolve_text_path_from_the_caller_directory(tmp_path: Path) -> None:
210
+ all_arguments = _cli_module()._resolve_shell_file_arguments(
211
+ ["--text-as", "nested.py", "--format", "editor"], tmp_path
212
+ )
213
+ assert all_arguments == [
214
+ "--text-as",
215
+ str(tmp_path / "nested.py"),
216
+ "--format",
217
+ "editor",
218
+ ]
219
+
220
+
221
+ def test_should_report_missing_git_as_invalid_input(
222
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
223
+ ) -> None:
224
+ def missing_git(_repository_root: Path, _all_arguments: tuple[str, ...]) -> bytes:
225
+ raise FileNotFoundError("git")
226
+
227
+ monkeypatch.setattr(_cli_module(), "git_bytes_for", missing_git)
228
+ with pytest.raises(_cli_module()._LintUsageError, match="Git repository"):
229
+ _cli_module()._discover_repository_root(tmp_path)
230
+
231
+
232
+ def test_should_exit_two_for_unknown_format(tmp_path: Path) -> None:
233
+ exit_code, _, stderr_text = _run_cli(
234
+ ["--files", "a.py", "--format", "xml"],
235
+ lint_runner=lambda _request: _clean_report(),
236
+ repository_root=tmp_path,
237
+ )
238
+ assert exit_code == 2
239
+ assert stderr_text != ""
240
+
241
+
242
+ def test_should_exit_two_when_selection_fails(tmp_path: Path) -> None:
243
+ def fake_lint(request: LintRequest) -> LintReport:
244
+ del request
245
+ raise SelectionRunFatal("File does not exist: missing.py")
246
+
247
+ exit_code, _, stderr_text = _run_cli(
248
+ ["--files", "missing.py"],
249
+ lint_runner=fake_lint,
250
+ repository_root=tmp_path,
251
+ )
252
+ assert exit_code == 2
253
+ assert "File does not exist: missing.py" in stderr_text
254
+
255
+
256
+ def test_should_exit_zero_when_the_report_is_clean(tmp_path: Path) -> None:
257
+ exit_code, stdout_text, stderr_text = _run_cli(
258
+ ["--staged"],
259
+ lint_runner=lambda _request: _clean_report(),
260
+ repository_root=tmp_path,
261
+ )
262
+ assert exit_code == 0
263
+ assert stderr_text == ""
264
+ assert stdout_text == ""
265
+
266
+
267
+ def _stage_oversized_test_file(repository_root: Path) -> None:
268
+ subprocess.run(
269
+ ["git", "init", "--quiet"],
270
+ cwd=repository_root,
271
+ check=True,
272
+ capture_output=True,
273
+ )
274
+ oversized_path = repository_root / "tests" / "oversized.mjs"
275
+ oversized_path.parent.mkdir()
276
+ oversized_path.write_bytes(b"\n" * 1001)
277
+ subprocess.run(
278
+ ["git", "add", "--", "tests/oversized.mjs"],
279
+ cwd=repository_root,
280
+ check=True,
281
+ capture_output=True,
282
+ )
283
+
284
+
285
+ def _run_staged_format(
286
+ report_format: str, repository_root: Path
287
+ ) -> tuple[int, str, str]:
288
+ return _run_cli(
289
+ ["--staged", "--format", report_format],
290
+ lint_runner=_cli_module().lint,
291
+ repository_root=repository_root,
292
+ )
293
+
294
+
295
+ def test_staged_clean_reports_keep_legacy_advisories_outside_renderer(
296
+ tmp_path: Path,
297
+ ) -> None:
298
+ _stage_oversized_test_file(tmp_path)
299
+ json_exit_code, json_stdout, json_stderr = _run_staged_format("json", tmp_path)
300
+ text_exit_code, text_stdout, text_stderr = _run_staged_format("text", tmp_path)
301
+ editor_exit_code, editor_stdout, editor_stderr = _run_staged_format(
302
+ "editor", tmp_path
303
+ )
304
+
305
+ assert json_exit_code == 0
306
+ assert json_stderr == ""
307
+ assert json.loads(json_stdout)["diagnostics"] == []
308
+ assert text_exit_code == 0
309
+ assert text_stdout == ""
310
+ assert text_stderr == ""
311
+ assert editor_exit_code == 0
312
+ assert editor_stdout == ""
313
+ assert editor_stderr == ""
314
+
315
+
316
+ def test_should_exit_one_when_diagnostics_exist(tmp_path: Path) -> None:
317
+ exit_code, stdout_text, _stderr_text = _run_cli(
318
+ ["--files", "src/app.py"],
319
+ lint_runner=lambda _request: _mixed_report(),
320
+ repository_root=tmp_path,
321
+ )
322
+ assert exit_code == 1
323
+ assert "located finding" in stdout_text
324
+ assert "unlocated finding" in stdout_text
325
+
326
+
327
+ def test_should_exit_three_when_a_rule_fails(tmp_path: Path) -> None:
328
+ failed_report = LintReport(1, (), (), (), ("rule-a",), ())
329
+ exit_code, stdout_text, _stderr_text = _run_cli(
330
+ ["--staged"],
331
+ lint_runner=lambda _request: failed_report,
332
+ repository_root=tmp_path,
333
+ )
334
+ assert exit_code == 3
335
+ assert "rule-a" in stdout_text
336
+
337
+
338
+ def test_should_emit_schema_versioned_deterministic_json(tmp_path: Path) -> None:
339
+ render_module = _render_module()
340
+ first_text = render_module.render(_mixed_report(), render_module.ReportFormat.JSON)
341
+ second_text = render_module.render(_mixed_report(), render_module.ReportFormat.JSON)
342
+ assert first_text == second_text
343
+ parsed_report = json.loads(first_text)
344
+ assert parsed_report["schema_version"] == 1
345
+ assert parsed_report == json.loads(
346
+ json.dumps(parsed_report, sort_keys=True)
347
+ )
348
+ exit_code, stdout_text, _stderr_text = _run_cli(
349
+ ["--files", "src/app.py", "--format", "json"],
350
+ lint_runner=lambda _request: _mixed_report(),
351
+ repository_root=tmp_path,
352
+ )
353
+ assert exit_code == 1
354
+ assert json.loads(stdout_text) == parsed_report
355
+
356
+
357
+ def test_should_emit_only_located_diagnostics_in_editor_format(
358
+ tmp_path: Path,
359
+ ) -> None:
360
+ render_module = _render_module()
361
+ rendered_text = render_module.render(
362
+ _mixed_report(), render_module.ReportFormat.EDITOR
363
+ )
364
+ assert "src/app.py:4:2:" in rendered_text
365
+ assert "located finding" in rendered_text
366
+ assert "unlocated finding" not in rendered_text
367
+ exit_code, stdout_text, _stderr_text = _run_cli(
368
+ ["--files", "src/app.py", "--format", "editor"],
369
+ lint_runner=lambda _request: _mixed_report(),
370
+ repository_root=tmp_path,
371
+ )
372
+ assert exit_code == 1
373
+ assert "src/app.py:4:2:" in stdout_text
374
+ assert "unlocated finding" not in stdout_text