pi-python-helper 0.2.0 → 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/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ does not guarantee a stable public tool schema.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-09-21
11
+
12
+ ### Added
13
+
14
+ - `py_test_config` audits the pytest configuration pytest will actually use against the plugins the project declares and the tests on disk. It reports the cases that make a run look green while tests never execute (`ASYNC_TESTS_WITHOUT_PLUGIN`, `ASYNC_TESTS_REQUIRE_MARKER`), the options pytest rejects before collection (`ASYNCIO_MODE_WITHOUT_PLUGIN`, `COVERAGE_OPTION_WITHOUT_PLUGIN`), and a `testpaths` entry that does not exist (`TESTPATH_MISSING`). Only syntax separates an unmarked coroutine test from a marked one, so the decision is made on the AST rather than by matching text.
15
+ - The scanner reports `[tool.pytest.ini_options]` as `manifest.pytestOptions` and, per scanned file, `asyncTests` and `asyncioMarkedTests` (protocol version 3).
16
+ - Every tool response carries `attention`: `true` whenever the caller must act (`ok: false`, or a warning or error). It is derived centrally in `result()`, so `ok: false` always implies `attention: true` and no diagnostic is silently dropped. `ok` is now documented as the tool's **verdict** — "the project state is acceptable / the command succeeded / the gate may proceed" — rather than "the tool ran", so a check that finds a problem still returns `ok: false` without having failed. An `info` diagnostic is informational and does not raise `attention`.
17
+
18
+ ### Changed
19
+
20
+ - **Breaking:** scanner protocol `SCANNER_VERSION` is now 3. A scanner reporting version 2 is rejected with `SCANNER_VERSION_MISMATCH`.
21
+
22
+ ### Fixed
23
+
24
+ - `py_test_select` no longer returns `ok: false` with no diagnostic at all when the change set contains no Python file (or git reports no changes). An empty selection is an answer rather than a failure, so it now returns `ok: true` and explains itself with the `NO_CHANGED_PATHS` warning.
25
+ - `py_failure_diagnose` no longer fabricates `uv add <import name>` for a `ModuleNotFoundError` whose providing distribution is unknown, matching the rule `py_dependency_plan` already follows. The alias table resolves a single provider (`yaml` → `uv add pyyaml`), while an import with several candidates (`cv2`) or none asks the caller to verify the distribution instead of installing the wrong package.
26
+ - `py_failure_diagnose` no longer repeats the same suggestion twice when a missing module is neither project code nor declared. `refineWithDeclarations` appended its generic "verify the distribution" advice on top of what the classifier had already reported, so a single undeclared import produced two suggestions with two duplicates.
27
+
10
28
  ## [0.2.0] - 2026-09-22
11
29
 
12
30
  ### Fixed
package/docs/tools.md CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  모든 도구는 동일한 `PyToolResult` 규격을 반환합니다. 반환 형태는 구조만 기록하며 값·경로·버전·소요시간은 스냅샷에서 제외합니다.
14
14
 
15
+ - `attention`: boolean
15
16
  - `commands` (optional): array of
16
17
  - `args`: array<string>
17
18
  - `cwd`: string
@@ -466,6 +467,27 @@ Preview or run pytest through uv run --frozen and summarise failures by test, fi
466
467
  - `risk`: string
467
468
  - `executed`: boolean
468
469
 
470
+ ### `py_test_config`
471
+
472
+ Audit pytest configuration against the declared plugins and the tests on disk, and report options that make tests pass without running. Read-only.
473
+
474
+ - 시스템 프롬프트 한 줄: `Validate pytest configuration and detect tests that never run`
475
+ - 라벨: Python Test Config
476
+ - 프로젝트 상태 변경: 없음 (읽기 전용)
477
+
478
+ **파라미터**
479
+
480
+ | 파라미터 | 타입 | 필수 | 설명 |
481
+ |---|---|---|---|
482
+ | `path` | `string` | 아니오 | Project directory to audit; defaults to the project root. |
483
+
484
+ **프롬프트 가이드라인**
485
+
486
+ - Use py_test_config when a test run reports fewer tests than expected, when async tests may be silently skipped, or before trusting a green run.
487
+ - Use py_test_config after changing pyproject.toml, pytest.ini, or the test layout to confirm the configuration still matches the project.
488
+
489
+ **반환 `data` 형태**: 캡처되지 않음
490
+
469
491
  ### `py_test_select`
470
492
 
471
493
  Select focused pytest targets from changed files using pytest naming conventions, without running tests. Read-only.
@@ -3,12 +3,14 @@ import { detectPythonEnvironment } from '../src/environment/discovery.ts';
3
3
  import { registerDependencyTools } from './tools/dependencies.ts';
4
4
  import { registerEnvironmentTools } from './tools/environment.ts';
5
5
  import { registerTestingTools } from './tools/testing.ts';
6
+ import { registerTestConfigTools } from './tools/test-config.ts';
6
7
  import { registerValidationTools } from './tools/validation.ts';
7
8
 
8
9
  export default function (pi: ExtensionAPI): void {
9
10
  registerEnvironmentTools(pi);
10
11
  registerDependencyTools(pi);
11
12
  registerTestingTools(pi);
13
+ registerTestConfigTools(pi);
12
14
  registerValidationTools(pi);
13
15
 
14
16
  pi.registerCommand('py-status', {
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
  import { open } from 'node:fs/promises';
3
- import { basename, dirname, resolve } from 'node:path';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
4
  import { findProjectRoot, isDirectory, isFile } from '../src/project/root.ts';
5
5
 
6
6
  export type Pi = ExtensionAPI;
@@ -70,3 +70,15 @@ export async function readTextIfExists(
70
70
  export function messageOf(error: unknown): string {
71
71
  return error instanceof Error ? error.message : String(error);
72
72
  }
73
+
74
+ /** INI files that can carry pytest configuration, in the order they are read. */
75
+ export const PYTEST_INI_FILES = ['pytest.ini', 'tox.ini', 'setup.cfg'] as const;
76
+
77
+ export async function readPytestIniFiles(
78
+ root: string,
79
+ ): Promise<Record<string, string | undefined>> {
80
+ const entries = await Promise.all(
81
+ PYTEST_INI_FILES.map(async (name) => [name, await readTextIfExists(join(root, name))] as const),
82
+ );
83
+ return Object.fromEntries(entries);
84
+ }
@@ -10,22 +10,12 @@ import { runScanProject } from '../../src/project/scanner.ts';
10
10
  import {
11
11
  hasDirectory,
12
12
  messageOf,
13
- readTextIfExists,
13
+ readPytestIniFiles,
14
14
  resolveProjectRoot,
15
15
  text,
16
16
  type Pi,
17
17
  } from '../shared.ts';
18
18
 
19
- /** INI files that can carry pytest configuration, in the order they are read. */
20
- const PYTEST_INI_FILES = ['pytest.ini', 'tox.ini', 'setup.cfg'];
21
-
22
- async function readPytestIniFiles(root: string): Promise<Record<string, string | undefined>> {
23
- const entries = await Promise.all(
24
- PYTEST_INI_FILES.map(async (name) => [name, await readTextIfExists(join(root, name))] as const),
25
- );
26
- return Object.fromEntries(entries);
27
- }
28
-
29
19
  export function registerEnvironmentTools(pi: Pi): void {
30
20
  pi.registerTool({
31
21
  name: 'py_environment',
@@ -0,0 +1,148 @@
1
+ import { Type } from 'typebox';
2
+ import { join } from 'node:path';
3
+ import { failure, result, type Diagnostic } from '../../src/core/result.ts';
4
+ import {
5
+ auditPytestConfiguration,
6
+ resolvePytestOptions,
7
+ type AsyncTestFile,
8
+ } from '../../src/build/pytest-audit.ts';
9
+ import { buildDeclaredIndex, normalizeName } from '../../src/dependencies/plan.ts';
10
+ import { isRunnableTestFile } from '../../src/project/paths.ts';
11
+ import { isDirectory } from '../../src/project/root.ts';
12
+ import { runScanProject } from '../../src/project/scanner.ts';
13
+ import { messageOf, readPytestIniFiles, resolveProjectRoot, text, type Pi } from '../shared.ts';
14
+
15
+ export function registerTestConfigTools(pi: Pi): void {
16
+ pi.registerTool({
17
+ name: 'py_test_config',
18
+ label: 'Python Test Config',
19
+ description:
20
+ 'Audit pytest configuration against the declared plugins and the tests on disk, and report options that make tests pass without running. Read-only.',
21
+ promptSnippet: 'Validate pytest configuration and detect tests that never run',
22
+ promptGuidelines: [
23
+ 'Use py_test_config when a test run reports fewer tests than expected, when async tests may be silently skipped, or before trusting a green run.',
24
+ 'Use py_test_config after changing pyproject.toml, pytest.ini, or the test layout to confirm the configuration still matches the project.',
25
+ ],
26
+ parameters: Type.Object({
27
+ path: Type.Optional(
28
+ Type.String({ description: 'Project directory to audit; defaults to the project root.' }),
29
+ ),
30
+ }),
31
+ async execute(_id, params, signal, _update, ctx) {
32
+ const started = Date.now();
33
+ try {
34
+ const root = (await resolveProjectRoot(ctx.cwd, params.path)) ?? ctx.cwd;
35
+ const scan = await runScanProject(ctx.cwd, { root, mode: 'all', maxFiles: 2000 }, signal);
36
+ if (!scan.ok || !scan.payload) {
37
+ return text(
38
+ failure(
39
+ ctx.cwd,
40
+ started,
41
+ scan.message ?? 'The project scanner failed.',
42
+ scan.code ?? 'SCANNER_FAILED',
43
+ ),
44
+ );
45
+ }
46
+
47
+ const manifest = scan.payload.manifest;
48
+ const declared = new Set(
49
+ buildDeclaredIndex(
50
+ manifest ?? {
51
+ dependencies: [],
52
+ optionalDependencies: {},
53
+ dependencyGroups: {},
54
+ },
55
+ ).keys(),
56
+ );
57
+
58
+ const resolution = resolvePytestOptions({
59
+ pyprojectOptions: manifest?.pytestOptions ?? null,
60
+ iniFiles: await readPytestIniFiles(root),
61
+ });
62
+
63
+ // Only files pytest would actually collect can hide a test, so a script
64
+ // that happens to define `async def test_*` is not reported.
65
+ const testFiles = (scan.payload.imports?.files ?? []).filter((file) =>
66
+ isRunnableTestFile(file.path),
67
+ );
68
+ const unmarkedAsyncTests: AsyncTestFile[] = testFiles
69
+ .map((file) => ({
70
+ path: file.path,
71
+ tests: (file.asyncTests ?? []).filter(
72
+ (name) => !(file.asyncioMarkedTests ?? []).includes(name),
73
+ ),
74
+ }))
75
+ .filter((entry) => entry.tests.length > 0);
76
+
77
+ const missingTestPaths: string[] = [];
78
+ for (const entry of resolution.options.testpaths) {
79
+ if (!(await isDirectory(join(root, entry)))) missingTestPaths.push(entry);
80
+ }
81
+
82
+ const findings = auditPytestConfiguration({
83
+ sources: resolution.sources,
84
+ options: resolution.options,
85
+ declared,
86
+ unmarkedAsyncTests,
87
+ missingTestPaths,
88
+ hasTestFiles: testFiles.length > 0,
89
+ });
90
+
91
+ const toDiagnostic = (finding: (typeof findings)[number]): Diagnostic => ({
92
+ code: finding.code,
93
+ message: finding.suggestion
94
+ ? `${finding.message} ${finding.suggestion}`
95
+ : finding.message,
96
+ severity: finding.severity,
97
+ });
98
+ const errors = findings.filter((finding) => finding.severity === 'error');
99
+ const warnings = findings.filter((finding) => finding.severity !== 'error');
100
+
101
+ return text(
102
+ result(ctx.cwd, started, {
103
+ ok: errors.length === 0,
104
+ summary:
105
+ findings.length === 0
106
+ ? `pytest configuration is consistent (${resolution.sources[0] ?? 'no configuration file'}, ${testFiles.length} test file(s)).`
107
+ : `${errors.length} error(s) and ${warnings.length} warning(s) in the pytest configuration.`,
108
+ data: {
109
+ sources: resolution.sources,
110
+ options: resolution.options,
111
+ findings,
112
+ unmarkedAsyncTests,
113
+ missingTestPaths,
114
+ testFileCount: testFiles.length,
115
+ declared: {
116
+ pytestAsyncio: declared.has(normalizeName('pytest-asyncio')),
117
+ pytestCov: declared.has(normalizeName('pytest-cov')),
118
+ },
119
+ },
120
+ evidence: [
121
+ {
122
+ kind: 'pytest_config_audit',
123
+ sources: resolution.sources,
124
+ findings: findings.map((finding) => finding.code),
125
+ unmarkedAsyncTests: unmarkedAsyncTests.reduce(
126
+ (total, entry) => total + entry.tests.length,
127
+ 0,
128
+ ),
129
+ },
130
+ ],
131
+ warnings: warnings.map(toDiagnostic),
132
+ errors: errors.map(toDiagnostic),
133
+ suggestions: findings
134
+ .filter((finding) => finding.suggestion)
135
+ .map((finding) => ({
136
+ message: finding.suggestion as string,
137
+ confidence: 'high' as const,
138
+ })),
139
+ projectRoot: root,
140
+ pythonVersion: scan.payload.pythonVersion,
141
+ }),
142
+ );
143
+ } catch (error) {
144
+ return text(failure(ctx.cwd, started, messageOf(error), 'INTERNAL_ERROR'));
145
+ }
146
+ },
147
+ });
148
+ }
@@ -98,6 +98,10 @@ export function registerTestingTools(pi: Pi): void {
98
98
  ? undefined
99
99
  : await collectTestImports(root, ctx.cwd, signal);
100
100
  const selection = selectTests(changed, testFiles, { testImports });
101
+ // `changed` still holds non-Python paths, so "nothing to select" has to be
102
+ // decided on what the selector actually matched.
103
+ const hasPythonChange =
104
+ selection.changedSourceFiles.length > 0 || selection.changedTestFiles.length > 0;
101
105
  // Only files pytest collects tests from become targets; naming
102
106
  // `tests/utils.py` as a target overstates the run.
103
107
  const targets = selection.selected
@@ -111,7 +115,7 @@ export function registerTestingTools(pi: Pi): void {
111
115
 
112
116
  return text(
113
117
  result(ctx.cwd, started, {
114
- ok: selection.selected.length > 0 || testFiles.length === 0,
118
+ ok: selection.selected.length > 0 || testFiles.length === 0 || !hasPythonChange,
115
119
  summary:
116
120
  `${selection.selected.length} test file(s) selected from ${testFiles.length} known test file(s) ` +
117
121
  `for ${selection.changedSourceFiles.length} changed source file(s) (${changedSource}).` +
@@ -137,6 +141,16 @@ export function registerTestingTools(pi: Pi): void {
137
141
  },
138
142
  ],
139
143
  warnings: [
144
+ ...(!hasPythonChange
145
+ ? [
146
+ {
147
+ code: 'NO_CHANGED_PATHS',
148
+ message:
149
+ 'No changed Python file was found, so nothing could be selected. Pass changedPaths explicitly when the change is not visible to git.',
150
+ severity: 'warning' as const,
151
+ },
152
+ ]
153
+ : []),
140
154
  ...(testFiles.length === 0
141
155
  ? [
142
156
  {
@@ -37,7 +37,10 @@ from pathlib import Path
37
37
  # caller can refuse to interpret a document it does not understand.
38
38
  # 2: each scanned file reports `importModules`, the full dotted module names it
39
39
  # references, so test selection can map a test file to the module it imports.
40
- SCANNER_VERSION = 2
40
+ # 3: the manifest reports `[tool.pytest.ini_options]` as `pytestOptions`, and each
41
+ # scanned file reports `asyncTests`/`asyncioMarkedTests`, so a configuration
42
+ # audit can tell an async test that runs from one that is silently skipped.
43
+ SCANNER_VERSION = 3
41
44
 
42
45
  KNOWN_SECTIONS = ("environment", "manifest", "imports")
43
46
  EXIT_OK = 0
@@ -234,6 +237,62 @@ def detect_layout(root: Path) -> tuple[str, list[str]]:
234
237
  return layout, sorted(set(modules))
235
238
 
236
239
 
240
+ def json_safe(value):
241
+ """Recursively coerce a TOML value into something `json.dumps` accepts.
242
+
243
+ `tomllib` can produce datetimes, which are valid TOML but not JSON; a single
244
+ one would turn the whole document into a scanner crash.
245
+ """
246
+ if isinstance(value, dict):
247
+ return {str(key): json_safe(item) for key, item in value.items()}
248
+ if isinstance(value, (list, tuple)):
249
+ return [json_safe(item) for item in value]
250
+ if value is None or isinstance(value, (str, int, float, bool)):
251
+ return value
252
+ return str(value)
253
+
254
+
255
+ ASYNC_MARKERS = {"asyncio", "anyio", "trio"}
256
+
257
+
258
+ def _dotted_name(node: ast.AST) -> str:
259
+ """Rebuild `a.b.c` from an attribute chain, or "" when it is not one."""
260
+ parts = []
261
+ current = node
262
+ while isinstance(current, ast.Attribute):
263
+ parts.append(current.attr)
264
+ current = current.value
265
+ if not isinstance(current, ast.Name):
266
+ return ""
267
+ parts.append(current.id)
268
+ return ".".join(reversed(parts))
269
+
270
+
271
+ def async_test_functions(tree: ast.AST) -> tuple:
272
+ """Async test functions and the subset that carries an async marker.
273
+
274
+ pytest-asyncio's default `strict` mode runs a coroutine test only when it is
275
+ marked, so an unmarked `async def test_*` is collected and then silently
276
+ skipped. Only the syntax distinguishes the two, so this must be an AST walk
277
+ rather than a text search.
278
+ """
279
+ async_tests: list = []
280
+ marked: list = []
281
+ for node in ast.walk(tree):
282
+ if not isinstance(node, ast.AsyncFunctionDef):
283
+ continue
284
+ if not node.name.startswith("test"):
285
+ continue
286
+ async_tests.append(node.name)
287
+ for decorator in node.decorator_list:
288
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
289
+ dotted = _dotted_name(target)
290
+ if dotted.rsplit(".", 1)[-1] in ASYNC_MARKERS and ".mark." in f".{dotted}":
291
+ marked.append(node.name)
292
+ break
293
+ return sorted(async_tests), sorted(marked)
294
+
295
+
237
296
  def scan_manifests(root: Path) -> dict:
238
297
  result: dict = {
239
298
  "pyprojectPath": None,
@@ -249,6 +308,7 @@ def scan_manifests(root: Path) -> dict:
249
308
  "buildRequires": [],
250
309
  "entryPoints": [],
251
310
  "toolConfiguration": {},
311
+ "pytestOptions": None,
252
312
  "layout": None,
253
313
  "modules": [],
254
314
  "legacySetupPy": False,
@@ -310,6 +370,9 @@ def scan_manifests(root: Path) -> dict:
310
370
  key: key in tool
311
371
  for key in ("ruff", "mypy", "pytest", "coverage", "pyright", "ty", "hatch")
312
372
  }
373
+ pytest_table = tool.get("pytest") if isinstance(tool.get("pytest"), dict) else {}
374
+ ini_options = pytest_table.get("ini_options")
375
+ result["pytestOptions"] = json_safe(ini_options) if isinstance(ini_options, dict) else None
313
376
  uv_table = tool.get("uv") if isinstance(tool.get("uv"), dict) else {}
314
377
  workspace = uv_table.get("workspace") if isinstance(uv_table.get("workspace"), dict) else {}
315
378
  members = workspace.get("members")
@@ -598,12 +661,15 @@ def scan_imports(root: Path, max_files: int) -> dict:
598
661
  collector.visit(tree)
599
662
  names = collector.all
600
663
  names.discard("")
664
+ async_tests, asyncio_marked = async_test_functions(tree)
601
665
  files.append(
602
666
  {
603
667
  "path": relative,
604
668
  "imports": sorted(names),
605
669
  "importModules": sorted(collector.modules),
606
670
  "typeCheckingImports": sorted(collector.type_checking),
671
+ "asyncTests": async_tests,
672
+ "asyncioMarkedTests": asyncio_marked,
607
673
  }
608
674
  )
609
675
  for name in names:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-python-helper",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Python (uv) development tools for the pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -19,11 +19,12 @@ license: Apache-2.0
19
19
  4. import 이름과 배포 이름은 다를 수 있습니다(`PIL`/`pillow`, `yaml`/`PyYAML`). `py_dependency_plan`의 제안을 `uv add`로 적용하세요.
20
20
  5. 소스 코드를 수정한 후에는 `py_test_select`로 변경 파일과 연관된 테스트를 선별하세요. 테스트가 변경 모듈을 **실제로 import**하면 가장 강한 근거이며, 이름 규약은 그 다음입니다. `narrowed: false`나 `NO_NARROWING`은 "30개 중 30개 선택"처럼 결과가 좁혀지지 않았다는 뜻이고, `SELECTION_WITHOUT_IMPORT_EVIDENCE`는 근거가 파일 이름뿐이라는 뜻이므로 변경이 넓다면 전체 스위트나 `lastFailed=true`를 사용하세요.
21
21
  6. 테스트 실행은 `py_test`를 사용하세요. `execute=false`로 먼저 미리보기하고, 실제 실행 시에만 `execute=true`를 전달합니다. 커버리지 플래그나 `-m` 마커 선택처럼 도구가 모델링하지 않는 프로젝트 표준 옵션은 `extraArgs`로 넘기세요.
22
- 7. 실패 출력이 있을 때는 `py_failure_diagnose`를 사용하세요. `site-packages` 내부 프레임은 원인이 아니며, 도구는 번째 프로젝트 프레임을 지목합니다. 실행 파일을 찾지 못해 명령이 시작되지 못한 경우(`Failed to spawn`, `command not found`)는 `tool_not_installed`로 분류됩니다.
23
- 8. 의존성이 바뀌었거나 `.venv`가 오래된 경우 `py_sync`로 `uv lock --check` 또는 `uv sync --frozen`을 미리보기/실행하세요. sync는 `[project.optional-dependencies]`의 extra를 함께 요청하므로 dev 도구가 extra로 선언된 프로젝트에서도 삭제되지 않습니다. `SYNC_REMOVED_PACKAGES`가 보이면 그것이 이후 "command not found"의 원인입니다.
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`로 프로덕션 변경에 대응하는 테스트 변경이 있는지 확인하세요.
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
 
@@ -34,6 +35,8 @@ license: Apache-2.0
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`과 모듈 디렉터리 이름이 일치하는지 확인).
39
42
  - `PROJECT_VIRTUAL_SOURCE`는 정상입니다. `[build-system]`이 없으면 uv는 프로젝트를 `virtual` 소스로 기록하고 `.venv`에 설치하지 않습니다. 누락(`PROJECT_NOT_INSTALLED`)으로 취급하지 말고 `[build-system]` 추가 여부만 검토하세요.
@@ -1,4 +1,5 @@
1
1
  import type { Suggestion } from '../core/result.ts';
2
+ import { importAliasCandidates } from '../dependencies/aliases.ts';
2
3
 
3
4
  export type FailureKind =
4
5
  | 'tool_not_installed'
@@ -222,6 +223,24 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
222
223
 
223
224
  if (kind === 'module_not_found' && missing) {
224
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
+ };
225
244
  return {
226
245
  kind: 'module_not_found',
227
246
  summary: `Import failed because the module "${module}" could not be found.`,
@@ -231,11 +250,7 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
231
250
  firstUserFrame: userFrame,
232
251
  evidence: [{ message: missing[0].trim(), file: userFrame?.path, line: userFrame?.line }],
233
252
  suggestions: [
234
- {
235
- 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).`,
236
- confidence: 'medium',
237
- command: `uv add ${module}`,
238
- },
253
+ resolveSuggestion,
239
254
  {
240
255
  message:
241
256
  'If the module is project code, run uv sync so the project package is installed in editable mode.',
@@ -404,7 +419,6 @@ export function refineWithDeclarations(
404
419
  const module = diagnosis.missingModule;
405
420
  if (!module) return diagnosis;
406
421
  const normalized = module.replace(/[-_.]+/g, '-').toLowerCase();
407
- const suggestions: Suggestion[] = [];
408
422
 
409
423
  if (input.localModules.has(module)) {
410
424
  return {
@@ -447,14 +461,9 @@ export function refineWithDeclarations(
447
461
  };
448
462
  }
449
463
 
450
- suggestions.push({
451
- message: `Declare the distribution providing "${module}" with uv add ${module}, or verify the import name.`,
452
- confidence: 'medium',
453
- command: `uv add ${module}`,
454
- });
455
- suggestions.push({
456
- message: 'Import names can differ from distribution names (PIL/pillow, yaml/PyYAML).',
457
- confidence: 'medium',
458
- });
459
- 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;
460
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
+ }
@@ -45,7 +45,23 @@ export interface ToolMetadata {
45
45
  * `data`; everything the agent must reason about lives in the typed sections.
46
46
  */
47
47
  export interface PyToolResult<T = unknown> {
48
+ /**
49
+ * The tool's own verdict, not "the tool ran". `true` means the question this
50
+ * tool asks was answered affirmatively: the project state is acceptable, the
51
+ * command succeeded, or the gate may proceed. A diagnostic tool that finds a
52
+ * problem therefore returns `ok: false` without the tool itself having
53
+ * failed. Read `attention` for "must the caller act".
54
+ */
48
55
  ok: boolean;
56
+ /**
57
+ * `true` when the caller must act before proceeding: the tool failed, or it
58
+ * emitted a warning or an error. An `info` diagnostic is informational by
59
+ * definition and does not set this. Derived from `ok`, `warnings`, and
60
+ * `errors` unless a tool sets it explicitly, so `ok: false` always implies
61
+ * `attention: true` and no diagnostic is silently dropped. This is the field
62
+ * to read when the question is "do I need to do something".
63
+ */
64
+ attention: boolean;
49
65
  summary: string;
50
66
  data?: T;
51
67
  evidence: Evidence[];
@@ -56,10 +72,20 @@ export interface PyToolResult<T = unknown> {
56
72
  metadata: ToolMetadata;
57
73
  }
58
74
 
75
+ /**
76
+ * An `info` diagnostic records a fact; only a warning or an error asks the
77
+ * caller to do something. Keeping them apart stops a purely informational note
78
+ * from raising `attention`.
79
+ */
80
+ function isActionable(value: { warnings: Diagnostic[]; errors: Diagnostic[] }): boolean {
81
+ return [...value.warnings, ...value.errors].some((entry) => entry.severity !== 'info');
82
+ }
83
+
59
84
  export function result<T>(
60
85
  cwd: string,
61
86
  startedAt: number,
62
- value: Omit<PyToolResult<T>, 'metadata'> & {
87
+ value: Omit<PyToolResult<T>, 'metadata' | 'attention'> & {
88
+ attention?: boolean;
63
89
  truncated?: boolean;
64
90
  projectRoot?: string;
65
91
  pythonVersion?: string;
@@ -67,6 +93,7 @@ export function result<T>(
67
93
  ): PyToolResult<T> {
68
94
  return {
69
95
  ...value,
96
+ attention: value.attention ?? (!value.ok || isActionable(value)),
70
97
  metadata: {
71
98
  toolVersion: TOOL_VERSION,
72
99
  cwd,
@@ -65,6 +65,27 @@ export const IMPORT_ALIASES: Record<string, string[]> = {
65
65
  tqdm: ['tqdm'],
66
66
  };
67
67
 
68
+ const NORMALIZE_RE = /[-_.]+/g;
69
+
70
+ /**
71
+ * Distribution candidates for an import name, from the static alias table only.
72
+ *
73
+ * Used where the scanner cannot supply the authoritative provider mapping (a
74
+ * traceback names an import, not an installed distribution). Ordering is
75
+ * preserved so a caller can name every candidate when an import maps to more
76
+ * than one distribution: `cv2` is either `opencv-python` or
77
+ * `opencv-python-headless`, and picking one silently would install the wrong
78
+ * variant. An empty result means the provider is unknown, and no `uv add`
79
+ * command may be fabricated from the import name.
80
+ */
81
+ export function importAliasCandidates(importName: string): string[] {
82
+ const normalized = importName.replace(NORMALIZE_RE, '-').trim().toLowerCase();
83
+ const candidates = new Set<string>();
84
+ for (const alias of IMPORT_ALIASES[importName] ?? []) candidates.add(alias);
85
+ for (const alias of IMPORT_ALIASES[normalized] ?? []) candidates.add(alias);
86
+ return [...candidates];
87
+ }
88
+
68
89
  /** Distributions that are normally invoked as a console script, not imported. */
69
90
  export const CONSOLE_ONLY: Set<string> = new Set([
70
91
  'ruff',
@@ -14,7 +14,7 @@ export type ScanMode =
14
14
  | 'manifest,imports';
15
15
 
16
16
  /** Bumped by the scanner when the request or result document changes shape. */
17
- export const SUPPORTED_SCANNER_VERSION = 2;
17
+ export const SUPPORTED_SCANNER_VERSION = 3;
18
18
 
19
19
  export interface DeclaredDependency {
20
20
  raw: string;
@@ -40,6 +40,12 @@ export interface ManifestSection {
40
40
  buildRequires: string[];
41
41
  entryPoints: string[];
42
42
  toolConfiguration: Record<string, boolean>;
43
+ /**
44
+ * `[tool.pytest.ini_options]` as written, or null when the table is absent.
45
+ * Left untyped because pytest accepts options this package does not model;
46
+ * only the audited keys are read.
47
+ */
48
+ pytestOptions?: Record<string, unknown> | null;
43
49
  layout: 'src' | 'flat';
44
50
  modules: string[];
45
51
  legacySetupPy: boolean;
@@ -98,6 +104,10 @@ export interface ImportSection {
98
104
  */
99
105
  importModules?: string[];
100
106
  typeCheckingImports: string[];
107
+ /** `async def test_*` names in this file. */
108
+ asyncTests?: string[];
109
+ /** The subset of `asyncTests` that carries an async plugin marker. */
110
+ asyncioMarkedTests?: string[];
101
111
  }[];
102
112
  thirdParty: {
103
113
  import: string;