pi-python-helper 0.1.1 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-python-helper",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Python (uv) development tools for the pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,32 +10,40 @@ license: Apache-2.0
10
10
 
11
11
  ## 조사 및 작업 순서 (Investigation order)
12
12
 
13
- 1. 인터프리터나 가상환경 상태가 불확실할 때는 `py_environment`를 실행하세요. 잘못된 Python으로 테스트를 실행하는 것이 가장 흔한 실패 원인입니다.
13
+ 1. 인터프리터나 가상환경 상태가 불확실할 때는 `py_environment`를 실행하세요. 잘못된 Python으로 테스트를 실행하는 것이 가장 흔한 실패 원인입니다. `interpreterOrigin: 'venv'`이면 분석이 프로젝트 환경을 봤다는 뜻이고, `'path'`이면 호스트 PATH의 Python을 봤다는 뜻입니다. `available`은 "지금 실행 가능", `installable`은 "uv sync로 설치 가능"(선언되었지만 실행 파일 없음)입니다.
14
14
  2. `pyproject.toml`이나 `uv.lock`을 편집하기 전에 `py_project_inspect`로 레이아웃(src/flat), 의존성 그룹, lockfile 드리프트, **환경 정합성**(선언 ↔ lock ↔ 실제 설치본)을 확인하세요. 정합성은 `consistent` / `drifted` / `unverifiable` 중 하나이며, `unverifiable`을 일치로 해석하지 마세요.
15
15
  3. 의존성을 추가/이동하기 전에 `py_dependency_plan`을 사용하세요. `ast`로 실제 import를 스캔하여 다음을 구분합니다:
16
16
  - 선언되지 않은 import (런타임 오류로 이어짐)
17
17
  - `[project] dependencies`가 아니라 dev 그룹/extra에만 선언된 런타임 import
18
18
  - `uv.lock`에 없거나 스펙을 만족하지 않는 버전
19
19
  4. import 이름과 배포 이름은 다를 수 있습니다(`PIL`/`pillow`, `yaml`/`PyYAML`). `py_dependency_plan`의 제안을 `uv add`로 적용하세요.
20
- 5. 소스 코드를 수정한 후에는 `py_test_select`로 변경 파일과 연관된 테스트를 선별하세요. 매칭이 실패하면 전체 스위트가 범위가 되며, 선택 이유가 함께 반환됩니다.
21
- 6. 테스트 실행은 `py_test`를 사용하세요. `execute=false`로 먼저 미리보기하고, 실제 실행 시에만 `execute=true`를 전달합니다.
22
- 7. 실패 출력이 있을 때는 `py_failure_diagnose`를 사용하세요. `site-packages` 내부 프레임은 원인이 아니며, 도구는 번째 프로젝트 프레임을 지목합니다.
23
- 8. 의존성이 바뀌었거나 `.venv`가 오래된 경우 `py_sync`로 `uv lock --check` 또는 `uv sync --frozen`을 미리보기/실행하세요.
24
- 9. 재현이 어려운 실패는 `py_test`의 `lastFailed=true`(`--lf`)직전 실패만 다시 실행하세요.
25
- 10. 작업 완료를 보고하기 전에 `py_validation_bundle`(lock 검사 → sync → pytest → 환경 정합성 → 오래된 아티팩트 검사) 실행하고, `py_completion_evidence`로 근거가 충분한지 확인하세요. 정합성이 `drifted`나 `unverifiable`이면 테스트가 통과했어도 게이트는 실패합니다.
26
- 11. `py_tdd_checkpoint`로 프로덕션 변경에 대응하는 테스트 변경이 있는지 확인하세요.
20
+ 5. 소스 코드를 수정한 후에는 `py_test_select`로 변경 파일과 연관된 테스트를 선별하세요. 테스트가 변경 모듈을 **실제로 import**하면 가장 강한 근거이며, 이름 규약은 그 다음입니다. `narrowed: false`나 `NO_NARROWING`은 "30개 중 30개 선택"처럼 결과가 좁혀지지 않았다는 뜻이고, `SELECTION_WITHOUT_IMPORT_EVIDENCE`는 근거가 파일 이름뿐이라는 뜻이므로 변경이 넓다면 전체 스위트나 `lastFailed=true`를 사용하세요.
21
+ 6. 테스트 실행은 `py_test`를 사용하세요. `execute=false`로 먼저 미리보기하고, 실제 실행 시에만 `execute=true`를 전달합니다. 커버리지 플래그나 `-m` 마커 선택처럼 도구가 모델링하지 않는 프로젝트 표준 옵션은 `extraArgs`로 넘기세요.
22
+ 7. 테스트가 예상보다 적게 실행되거나 초록색 결과를 믿기 어려울 때는 `py_test_config`로 pytest 설정을 검증하세요. `ASYNC_TESTS_WITHOUT_PLUGIN`/`ASYNC_TESTS_REQUIRE_MARKER`는 async 테스트가 **수집만 되고 실행되지 않는** 상태(플러그인 미선언 또는 `asyncio_mode` 미설정 + 마커 없음)를 뜻하며, `COVERAGE_OPTION_WITHOUT_PLUGIN`과 `ASYNCIO_MODE_WITHOUT_PLUGIN`은 실행 전에 pytest가 오류로 중단되는 설정입니다. `TESTPATH_MISSING`은 `testpaths`가 존재하지 않는 디렉터리를 가리킨다는 경고입니다.
23
+ 8. 실패 출력이 있을 때는 `py_failure_diagnose`를 사용하세요. `site-packages` 내부 프레임은 원인이 아니며, 도구는 첫 번째 프로젝트 프레임을 지목합니다. 실행 파일을 찾지 못해 명령이 시작되지 못한 경우(`Failed to spawn`, `command not found`)는 `tool_not_installed`로 분류됩니다.
24
+ 9. 의존성이 바뀌었거나 `.venv`가 오래된 경우 `py_sync`로 `uv lock --check` 또는 `uv sync --frozen`을 미리보기/실행하세요. sync는 `[project.optional-dependencies]`의 extra를 함께 요청하므로 dev 도구가 extra선언된 프로젝트에서도 삭제되지 않습니다. `SYNC_REMOVED_PACKAGES`가 보이면 그것이 이후 "command not found"의 원인입니다.
25
+ 10. 재현이 어려운 실패는 `py_test`의 `lastFailed=true`(`--lf`) 직전 실패만 다시 실행하세요.
26
+ 11. 작업 완료를 보고하기 전에 `py_validation_bundle`(lock 검사 → sync → pytest → 환경 정합성 → 오래된 아티팩트 검사)을 실행하고, `py_completion_evidence`로 근거가 충분한지 확인하세요. 정합성이 `drifted`나 `unverifiable`이면 테스트가 통과했어도 게이트는 실패합니다. 각 응답에서 조치 필요 여부는 `attention`으로 판단하세요.
27
+ 12. `py_tdd_checkpoint`로 프로덕션 변경에 대응하는 테스트 변경이 있는지 확인하세요.
27
28
 
28
29
  ## 안전 규칙 (Safety)
29
30
 
30
- - `py_sync`와 `py_validation_bundle`은 `execute: true`가 명시적으로 전달되기 전까지 명령을 실행하지 않고 미리보기만 반환합니다. `execute=true`는 `.venv`를 생성/갱신하므로 사용자 확인 없이 반복 실행하지 마세요.
31
+ - `py_sync`와 `py_validation_bundle`은 `execute: true`가 명시적으로 전달되기 전까지 명령을 실행하지 않고 미리보기만 반환합니다. `execute=true`는 `.venv`를 생성/갱신하므로 사용자 확인 없이 반복 실행하지 마세요. `extras: 'none'`은 프로젝트가 요청할 때만 사용하세요: extra로 선언된 dev 도구를 삭제할 수 있습니다.
31
32
  - Python에는 ROS의 `cmd_vel`처럼 위험을 결정론적으로 알려주는 이름이 없습니다. 다음은 되돌릴 수 없는 작업으로 취급하세요: `uv publish`/`twine upload`(공개 불가 회수), `git push --force`, `git reset --hard`/`git clean -fd`, `alembic downgrade`, `DROP`/`DELETE`(WHERE 없는), `rm -rf`, `conda env remove`.
32
33
  - 가상환경을 파괴하는 명령(`rm -rf .venv`, `uv venv --clear`)이나 전역 Python에 패키지를 설치하는 명령(`pip install` without a venv)을 임의로 실행하지 마세요.
33
34
  - 도구는 파일을 쓰지 않습니다. `pyproject.toml`/`uv.lock` 수정은 항상 명시적인 편집 도구로 수행하세요.
34
35
 
35
36
  ## 해석 규칙 (Interpretation rules)
36
37
 
38
+ - `ok`는 도구의 **판정**이며 "도구가 실행됐다"는 뜻이 아닙니다. 검사 도구는 문제를 찾으면 도구 자체가 실패하지 않았어도 `ok: false`를 반환합니다. 조치가 필요한지는 `attention`을 읽으세요: `ok: false`이거나 경고·오류가 하나라도 있으면 `true`입니다.
39
+ - `ok: false`에는 항상 그것을 설명하는 진단(`warnings` 또는 `errors`)이 함께 옵니다. 설명 없는 `ok: false`를 보면 도구 결함이므로 그대로 보고하세요. 게이트 도구는 판정을 `data.ok`(`checkpoint.ok`, `evidence.ok`, `summary.ok`)에도 노출합니다.
37
40
  - `PROJECT_INSTALLED_NOT_EDITABLE`는 프로젝트가 환경에 live link가 아니라 복사본으로 설치되어, 소스 변경이 테스트에 반영되지 않음을 의미합니다. `uv sync`로 해결하세요.
38
41
  - `PROJECT_NOT_INSTALLED`는 lock이 editable 설치를 기대하는데 `.venv`에 프로젝트가 없다는 뜻입니다. `uv sync`를 실행하고, 그래도 실패하면 빌드 백엔드가 패키지를 찾지 못한 것입니다(`[project] name`과 모듈 디렉터리 이름이 일치하는지 확인).
42
+ - `PROJECT_VIRTUAL_SOURCE`는 정상입니다. `[build-system]`이 없으면 uv는 프로젝트를 `virtual` 소스로 기록하고 `.venv`에 설치하지 않습니다. 누락(`PROJECT_NOT_INSTALLED`)으로 취급하지 말고 `[build-system]` 추가 여부만 검토하세요.
43
+ - `MARKER_SPLIT_LOCK_ENTRIES`는 정상입니다. uv가 같은 배포판을 마커별로 여러 버전으로 기록한 것이며, 설치 버전이 그중 하나와 일치하면 드리프트가 아닙니다.
44
+ - `TOOL_NOT_INSTALLED`는 lock에는 있지만 `.venv`에 실행 파일이 없다는 뜻입니다. `uv sync` 직후에 발생했다면 sync가 extra를 삭제한 것이므로 `uv sync --frozen --all-groups --all-extras`로 복구하세요.
45
+ - `SYNC_REMOVED_PACKAGES`는 sync가 `.venv`에서 패키지를 제거했음을 뜻하며, 같은 실행에서 이어지는 "command not found"의 원인입니다.
46
+ - `UNMAPPED_IMPORTS`는 분석한 인터프리터가 import를 배포판에 연결하지 못했다는 뜻입니다. 이 상태에서는 `UNDECLARED_IMPORT`가 "선언 누락"이 아니라 "배포 이름 미확인"일 수 있으므로, 제안된 `uv add` 명령이 없으면 배포 이름을 직접 확인하세요.
39
47
  - `INSTALLED_VERSION_MISMATCH`는 `uv add`/`uv lock` 후 `uv sync`를 잊은 상태입니다. `uv lock --check`는 이걸 잡지 못하니(락은 최신) 테스트를 신뢰하기 전에 `uv sync --frozen`을 실행하세요.
40
48
  - `INSTALLED_PACKAGE_UNTRACKED`는 `.venv`에만 있고 lock에 없는 패키지입니다. 에이전트가 `uv pip install`로 임의 설치했을 가능성을 의심하세요.
41
49
  - `CONDITIONAL_PACKAGES_ABSENT`는 정상입니다. `sys_platform == 'win32'`나 `python_version < '3.11'` 같은 마커 때문에 해당 플랫폼에 설치되지 않은 항목이며, 드리프트로 취급하지 마세요.
@@ -19,8 +19,23 @@ export function uvLock(cwd: string): CommandPreview {
19
19
  return { executable: 'uv', args: ['lock'], cwd, risk: 'mutating' };
20
20
  }
21
21
 
22
- export function uvSyncFrozen(cwd: string): CommandPreview {
23
- return { executable: 'uv', args: ['sync', '--frozen', '--all-groups'], cwd, risk: 'mutating' };
22
+ export interface UvSyncOptions {
23
+ /**
24
+ * `all` requests every extra declared in `[project.optional-dependencies]`.
25
+ *
26
+ * `uv sync --all-groups` covers `[dependency-groups]` only, so a project that
27
+ * declares pytest/ruff/pyright as an extra has them **removed** by a plain
28
+ * sync. Installing more than needed is recoverable; deleting the project's
29
+ * own dev tooling mid-run is not, so extras are requested by default.
30
+ */
31
+ extras?: 'all' | 'none';
32
+ }
33
+
34
+ export function uvSyncFrozen(cwd: string, options: UvSyncOptions = {}): CommandPreview {
35
+ const extras = options.extras ?? 'all';
36
+ const args = ['sync', '--frozen', '--all-groups'];
37
+ if (extras === 'all') args.push('--all-extras');
38
+ return { executable: 'uv', args, cwd, risk: 'mutating' };
24
39
  }
25
40
 
26
41
  export function uvRun(cwd: string, args: string[]): CommandPreview {
@@ -1,6 +1,8 @@
1
1
  import type { Suggestion } from '../core/result.ts';
2
+ import { importAliasCandidates } from '../dependencies/aliases.ts';
2
3
 
3
4
  export type FailureKind =
5
+ | 'tool_not_installed'
4
6
  | 'module_not_found'
5
7
  | 'environment_not_synced'
6
8
  | 'import_error'
@@ -33,6 +35,8 @@ export interface FailureDiagnosis {
33
35
  kind: FailureKind;
34
36
  summary: string;
35
37
  missingModule?: string;
38
+ /** Executable the command tried to start but could not find. */
39
+ missingTool?: string;
36
40
  importTarget?: { name: string; module: string };
37
41
  exceptionType?: string;
38
42
  frames: TracebackFrame[];
@@ -124,6 +128,46 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
124
128
  };
125
129
  }
126
130
 
131
+ // An environment failure is not a code failure. It appears as a spawn error
132
+ // rather than a traceback, so it must be classified before the traceback
133
+ // patterns or the whole run reads as an unclassifiable crash.
134
+ const spawnFailure = lastMatch(output, /Failed to spawn:?\s*`?([A-Za-z0-9._+-]+)`?/);
135
+ const commandNotFound = lastMatch(
136
+ output,
137
+ /(?:^|\n)\s*(?:sh: |bash: )?([A-Za-z0-9._+-]+): (?:command not found|No such file or directory)/,
138
+ );
139
+ const missingExecutable = spawnFailure ?? commandNotFound;
140
+ const toolNotInstalled = (tool: string, matchedText: string): FailureDiagnosis => {
141
+ const noEntry = /No such file or directory/.test(output) || /command not found/.test(output);
142
+ return {
143
+ kind: 'tool_not_installed',
144
+ summary: noEntry
145
+ ? `The command could not start because "${tool}" is not installed in the environment that ran it.`
146
+ : `The command could not start: ${matchedText}`,
147
+ missingTool: tool,
148
+ exceptionType: 'environment',
149
+ frames,
150
+ firstUserFrame: userFrame,
151
+ evidence: [{ message: matchedText }],
152
+ suggestions: [
153
+ {
154
+ message: `Install the project environment so "${tool}" is available, then rerun through uv run.`,
155
+ confidence: 'high',
156
+ command: 'uv sync --frozen --all-groups --all-extras',
157
+ },
158
+ {
159
+ message:
160
+ 'A plain uv sync removes extras declared in [project.optional-dependencies], which is the usual reason a declared tool disappears mid-run.',
161
+ confidence: 'high',
162
+ },
163
+ {
164
+ message: `Run the tool from the project environment with uv run --frozen ${tool}.`,
165
+ confidence: 'high',
166
+ },
167
+ ],
168
+ };
169
+ };
170
+
127
171
  const missing = lastMatch(output, /ModuleNotFoundError: No module named '([^']+)'/);
128
172
  const cannotImport = lastMatch(
129
173
  output,
@@ -152,6 +196,11 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
152
196
  if (match?.index !== undefined) candidates.push({ kind, index: match.index });
153
197
  };
154
198
  consider('module_not_found', /ModuleNotFoundError: No module named '([^']+)'/);
199
+ consider('tool_not_installed', /Failed to spawn:?\s*`?([A-Za-z0-9._+-]+)`?/);
200
+ consider(
201
+ 'tool_not_installed',
202
+ /(?:^|\n)\s*(?:sh: |bash: )?([A-Za-z0-9._+-]+): (?:command not found|No such file or directory)/,
203
+ );
155
204
  consider('import_error', /ImportError: cannot import name '([^']+)' from '([^']+)'/);
156
205
  consider('syntax_error', /SyntaxError: (.+)/);
157
206
  consider('fixture_error', /(?:fixture '[^']+' not found|ERROR at setup of|error in .* fixture)/);
@@ -164,8 +213,34 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
164
213
  candidates.sort((left, right) => left.index - right.index);
165
214
  const kind: FailureKind = candidates[0]?.kind ?? 'unknown';
166
215
 
216
+ // Dispatch on the positional winner. `tool_not_installed` can be the earliest
217
+ // cause even when a traceback follows it, so it is not checked before the
218
+ // positional comparison.
219
+ if (kind === 'tool_not_installed') {
220
+ const match = missingExecutable;
221
+ if (match) return toolNotInstalled(match[1], match[0].trim());
222
+ }
223
+
167
224
  if (kind === 'module_not_found' && missing) {
168
225
  const module = missing[1].split('.')[0];
226
+ // The traceback names an import, not a distribution. `uv add <import name>`
227
+ // installs a different package or nothing at all when the two disagree, so a
228
+ // command is emitted only when the alias table yields exactly one provider.
229
+ const providers = importAliasCandidates(module);
230
+ const resolveSuggestion: Suggestion =
231
+ providers.length === 1
232
+ ? {
233
+ message: `Declare the distribution providing "${module}" with uv add ${providers[0]} if it is third-party. Import names often differ from distribution names (PIL/pillow, yaml/PyYAML).`,
234
+ confidence: 'medium',
235
+ command: `uv add ${providers[0]}`,
236
+ }
237
+ : {
238
+ message:
239
+ providers.length > 1
240
+ ? `"${module}" is provided by more than one distribution (${providers.join(', ')}). Verify which one the project needs before adding it.`
241
+ : `The distribution providing "${module}" is unknown. Verify the distribution name before adding it; import names often differ from distribution names (PIL/pillow, yaml/PyYAML).`,
242
+ confidence: 'medium',
243
+ };
169
244
  return {
170
245
  kind: 'module_not_found',
171
246
  summary: `Import failed because the module "${module}" could not be found.`,
@@ -175,11 +250,7 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
175
250
  firstUserFrame: userFrame,
176
251
  evidence: [{ message: missing[0].trim(), file: userFrame?.path, line: userFrame?.line }],
177
252
  suggestions: [
178
- {
179
- message: `Declare the distribution that provides "${module}" with uv add ${module} if it is third-party. Import names often differ from distribution names (PIL/pillow, yaml/PyYAML).`,
180
- confidence: 'medium',
181
- command: `uv add ${module}`,
182
- },
253
+ resolveSuggestion,
183
254
  {
184
255
  message:
185
256
  'If the module is project code, run uv sync so the project package is installed in editable mode.',
@@ -348,7 +419,6 @@ export function refineWithDeclarations(
348
419
  const module = diagnosis.missingModule;
349
420
  if (!module) return diagnosis;
350
421
  const normalized = module.replace(/[-_.]+/g, '-').toLowerCase();
351
- const suggestions: Suggestion[] = [];
352
422
 
353
423
  if (input.localModules.has(module)) {
354
424
  return {
@@ -391,14 +461,9 @@ export function refineWithDeclarations(
391
461
  };
392
462
  }
393
463
 
394
- suggestions.push({
395
- message: `Declare the distribution providing "${module}" with uv add ${module}, or verify the import name.`,
396
- confidence: 'medium',
397
- command: `uv add ${module}`,
398
- });
399
- suggestions.push({
400
- message: 'Import names can differ from distribution names (PIL/pillow, yaml/PyYAML).',
401
- confidence: 'medium',
402
- });
403
- return { ...diagnosis, suggestions: [...diagnosis.suggestions, ...suggestions] };
464
+ // The module is neither project code nor declared, so this refinement adds no
465
+ // new information: `diagnoseFailure` already reported the unknown provider.
466
+ // Appending here duplicated those suggestions, so the diagnosis is returned
467
+ // unchanged.
468
+ return diagnosis;
404
469
  }
@@ -0,0 +1,226 @@
1
+ import { normalizeName } from '../dependencies/plan.ts';
2
+
3
+ /**
4
+ * The pytest options this audit reasons about. Only options whose absence
5
+ * changes whether tests *run* are read, so an unknown option can never be
6
+ * misreported: pytest silently ignores a key it does not know, and so does this.
7
+ */
8
+ export interface PytestOptions {
9
+ asyncioMode?: string;
10
+ addopts?: string;
11
+ testpaths: string[];
12
+ markers: string[];
13
+ }
14
+
15
+ export interface PytestConfigResolution {
16
+ /** The configuration file pytest will actually use, when one exists. */
17
+ sources: string[];
18
+ options: PytestOptions;
19
+ }
20
+
21
+ /**
22
+ * pytest uses the first configuration file it finds, in this order, and ignores
23
+ * the rest. Merging them would invent options the run never sees.
24
+ */
25
+ export const PYTEST_CONFIG_PRECEDENCE = [
26
+ 'pytest.ini',
27
+ 'pyproject.toml',
28
+ 'tox.ini',
29
+ 'setup.cfg',
30
+ ] as const;
31
+
32
+ const INI_SECTION_RE = /^\s*\[(?:tool:)?pytest\]\s*$/m;
33
+
34
+ function emptyOptions(): PytestOptions {
35
+ return { testpaths: [], markers: [] };
36
+ }
37
+
38
+ function stringOption(value: unknown): string | undefined {
39
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
40
+ }
41
+
42
+ function stringList(value: unknown): string[] {
43
+ if (Array.isArray(value)) {
44
+ return value.filter((entry): entry is string => typeof entry === 'string');
45
+ }
46
+ if (typeof value === 'string') {
47
+ return value.split(/\s+/).filter((entry) => entry.length > 0);
48
+ }
49
+ return [];
50
+ }
51
+
52
+ /**
53
+ * Read the `[pytest]` / `[tool:pytest]` section of one INI file.
54
+ *
55
+ * `undefined` means "this file does not configure pytest". A file that has the
56
+ * section but none of the options still counts as configured, so the caller can
57
+ * distinguish "no configuration" from "configuration with defaults".
58
+ */
59
+ export function parseIniPytestOptions(content: string | undefined): PytestOptions | undefined {
60
+ if (content === undefined) return undefined;
61
+ const header = INI_SECTION_RE.exec(content);
62
+ if (!header) return undefined;
63
+ const rest = content.slice(header.index + header[0].length);
64
+ const nextSection = rest.search(/^\s*\[/m);
65
+ const body = nextSection === -1 ? rest : rest.slice(0, nextSection);
66
+ const read = (key: string): string | undefined => {
67
+ const found = new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*(.*)$`, 'm').exec(body);
68
+ const raw = found?.[1]?.trim();
69
+ return raw ? raw : undefined;
70
+ };
71
+
72
+ const asyncioMode = read('asyncio_mode');
73
+ const addopts = read('addopts');
74
+ const testpaths = read('testpaths');
75
+ return {
76
+ ...(asyncioMode ? { asyncioMode } : {}),
77
+ ...(addopts ? { addopts } : {}),
78
+ testpaths: testpaths ? testpaths.split(/\s+/).filter((entry) => entry.length > 0) : [],
79
+ markers: [],
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Resolve the options pytest will use, honouring its first-file-wins rule.
85
+ *
86
+ * `pytest.ini` counts when it merely exists: unlike `tox.ini`/`setup.cfg` it has
87
+ * no other purpose, so an empty file still sets the rootdir configuration.
88
+ */
89
+ export function resolvePytestOptions(input: {
90
+ pyprojectOptions?: Record<string, unknown> | null;
91
+ iniFiles: Record<string, string | undefined>;
92
+ }): PytestConfigResolution {
93
+ const raw = input.pyprojectOptions;
94
+ const fromPyproject: PytestOptions | undefined = raw
95
+ ? {
96
+ ...(stringOption(raw['asyncio_mode'])
97
+ ? { asyncioMode: stringOption(raw['asyncio_mode']) }
98
+ : {}),
99
+ ...(stringOption(raw['addopts']) ? { addopts: stringOption(raw['addopts']) } : {}),
100
+ testpaths: stringList(raw['testpaths']),
101
+ markers: stringList(raw['markers']),
102
+ }
103
+ : undefined;
104
+
105
+ const pytestIni = input.iniFiles['pytest.ini'];
106
+ const candidates: Record<string, PytestOptions | undefined> = {
107
+ 'pytest.ini':
108
+ pytestIni === undefined ? undefined : (parseIniPytestOptions(pytestIni) ?? emptyOptions()),
109
+ 'pyproject.toml': fromPyproject,
110
+ 'tox.ini': parseIniPytestOptions(input.iniFiles['tox.ini']),
111
+ 'setup.cfg': parseIniPytestOptions(input.iniFiles['setup.cfg']),
112
+ };
113
+
114
+ for (const name of PYTEST_CONFIG_PRECEDENCE) {
115
+ const options = candidates[name];
116
+ if (options) return { sources: [name], options };
117
+ }
118
+ return { sources: [], options: emptyOptions() };
119
+ }
120
+
121
+ export interface AsyncTestFile {
122
+ path: string;
123
+ /** Async test names in this file that carry no async plugin marker. */
124
+ tests: string[];
125
+ }
126
+
127
+ export interface PytestAuditInput {
128
+ sources: string[];
129
+ options: PytestOptions;
130
+ declared: Set<string>;
131
+ /** Async tests that no marker covers, per file. */
132
+ unmarkedAsyncTests: AsyncTestFile[];
133
+ /** Configured `testpaths` entries that do not exist on disk. */
134
+ missingTestPaths: string[];
135
+ hasTestFiles: boolean;
136
+ }
137
+
138
+ export interface PytestFinding {
139
+ code: string;
140
+ severity: 'info' | 'warning' | 'error';
141
+ message: string;
142
+ suggestion?: string;
143
+ }
144
+
145
+ /** Plugins that make pytest run a coroutine test function at all. */
146
+ const ASYNC_PLUGINS = ['pytest-asyncio', 'pytest-anyio', 'anyio', 'pytest-trio'];
147
+
148
+ function declares(declared: Set<string>, names: string[]): boolean {
149
+ return names.some((name) => declared.has(normalizeName(name)));
150
+ }
151
+
152
+ /**
153
+ * Report the pytest configuration problems that make tests pass without running.
154
+ *
155
+ * Only two things are asserted: coroutine tests that no plugin and no marker
156
+ * will execute, and options that point at a plugin the project does not declare.
157
+ * Anything less certain is reported as `info` so a false alarm cannot erode the
158
+ * tool's credibility.
159
+ */
160
+ export function auditPytestConfiguration(input: PytestAuditInput): PytestFinding[] {
161
+ const findings: PytestFinding[] = [];
162
+ const { options, declared } = input;
163
+ const unmarkedFiles = input.unmarkedAsyncTests.filter((entry) => entry.tests.length > 0);
164
+ const unmarkedCount = unmarkedFiles.reduce((total, entry) => total + entry.tests.length, 0);
165
+ const asyncPluginDeclared = declares(declared, ASYNC_PLUGINS);
166
+ const mode = (options.asyncioMode ?? '').trim().toLowerCase();
167
+
168
+ if (unmarkedCount > 0 && !asyncPluginDeclared) {
169
+ findings.push({
170
+ code: 'ASYNC_TESTS_WITHOUT_PLUGIN',
171
+ severity: 'error',
172
+ message: `${unmarkedCount} async test function(s) in ${unmarkedFiles.length} file(s) will not run: no pytest async plugin is declared.`,
173
+ suggestion:
174
+ 'Declare pytest-asyncio with uv add --dev pytest-asyncio and mark the tests, or add pytest-asyncio and set asyncio_mode = "auto".',
175
+ });
176
+ } else if (unmarkedCount > 0 && mode !== 'auto') {
177
+ findings.push({
178
+ code: 'ASYNC_TESTS_REQUIRE_MARKER',
179
+ severity: 'error',
180
+ message: `${unmarkedCount} async test function(s) in ${unmarkedFiles.length} file(s) carry no async marker and asyncio_mode is not "auto", so pytest-asyncio's strict default will skip them.`,
181
+ suggestion:
182
+ 'Set asyncio_mode = "auto" in [tool.pytest.ini_options], or add @pytest.mark.asyncio to each async test.',
183
+ });
184
+ }
185
+
186
+ if (mode.length > 0 && !declares(declared, ['pytest-asyncio'])) {
187
+ findings.push({
188
+ code: 'ASYNCIO_MODE_WITHOUT_PLUGIN',
189
+ severity: 'error',
190
+ message: `asyncio_mode is set to "${options.asyncioMode}" but pytest-asyncio is not declared, so pytest errors with an unknown option before collecting anything.`,
191
+ suggestion:
192
+ 'Declare pytest-asyncio with uv add --dev pytest-asyncio, or remove asyncio_mode from the pytest configuration.',
193
+ });
194
+ }
195
+
196
+ const addopts = options.addopts ?? '';
197
+ if (/(^|\s)--cov(=|\s|$)/.test(addopts) && !declares(declared, ['pytest-cov'])) {
198
+ findings.push({
199
+ code: 'COVERAGE_OPTION_WITHOUT_PLUGIN',
200
+ severity: 'error',
201
+ message:
202
+ 'addopts passes --cov but pytest-cov is not declared, so every run fails with an unrecognized argument.',
203
+ suggestion: 'Declare pytest-cov with uv add --dev pytest-cov, or remove --cov from addopts.',
204
+ });
205
+ }
206
+
207
+ for (const path of input.missingTestPaths) {
208
+ findings.push({
209
+ code: 'TESTPATH_MISSING',
210
+ severity: 'warning',
211
+ message: `testpaths lists "${path}", which does not exist, so a bare pytest run collects nothing from it.`,
212
+ suggestion: `Create ${path} or correct testpaths in ${input.sources[0] ?? 'the pytest configuration'}.`,
213
+ });
214
+ }
215
+
216
+ if (input.sources.length === 0 && input.hasTestFiles) {
217
+ findings.push({
218
+ code: 'PYTEST_NOT_CONFIGURED',
219
+ severity: 'info',
220
+ message:
221
+ 'No pytest configuration was found: pytest runs with defaults, so testpaths and plugin options are not pinned anywhere.',
222
+ });
223
+ }
224
+
225
+ return findings;
226
+ }
@@ -0,0 +1,49 @@
1
+ import { uvRun } from './commands.ts';
2
+ import type { CommandPreview } from '../core/result.ts';
3
+
4
+ /**
5
+ * Quality commands a validation bundle can run without any project-specific
6
+ * configuration. Every entry is invoked as an argument array through
7
+ * `uv run --frozen`, so the tool comes from the project environment.
8
+ */
9
+ export interface QualityRunner {
10
+ /** Normalized distribution name that must be declared for this to run. */
11
+ distribution: string;
12
+ name: string;
13
+ args: string[];
14
+ /**
15
+ * mypy needs a `[tool.mypy]` table: run bare it reports every untyped
16
+ * third-party call, which is pre-existing noise rather than a regression.
17
+ */
18
+ requiresToolSection?: boolean;
19
+ }
20
+
21
+ export const QUALITY_RUNNERS: QualityRunner[] = [
22
+ { distribution: 'ruff', name: 'ruff', args: ['ruff', 'check', '.'] },
23
+ { distribution: 'pyright', name: 'pyright', args: ['pyright'] },
24
+ { distribution: 'mypy', name: 'mypy', args: ['mypy', '.'], requiresToolSection: true },
25
+ ];
26
+
27
+ /**
28
+ * Pick the quality gates the project actually declares.
29
+ *
30
+ * Gating on the *declaration* rather than on a config file matches what CI
31
+ * usually runs: a project can run `ruff check .` with no `[tool.ruff]` table at
32
+ * all, and its absence from the gate would hide a real regression.
33
+ */
34
+ export function selectQualityRunners(input: {
35
+ declared: Iterable<string>;
36
+ toolConfiguration?: Record<string, boolean>;
37
+ }): QualityRunner[] {
38
+ const declared = new Set(input.declared);
39
+ const configuration = input.toolConfiguration ?? {};
40
+ return QUALITY_RUNNERS.filter((runner) => {
41
+ if (!declared.has(runner.distribution)) return false;
42
+ if (runner.requiresToolSection && configuration[runner.distribution] !== true) return false;
43
+ return true;
44
+ });
45
+ }
46
+
47
+ export function qualityCommands(cwd: string, runners: QualityRunner[]): CommandPreview[] {
48
+ return runners.map((runner) => uvRun(cwd, runner.args));
49
+ }