pi-python-helper 0.1.0 → 0.2.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 +39 -0
- package/README.md +85 -125
- package/docs/compatibility.md +17 -1
- package/docs/tools.md +25 -2
- package/extensions/shared.ts +24 -0
- package/extensions/tools/dependencies.ts +0 -120
- package/extensions/tools/environment.ts +30 -5
- package/extensions/tools/testing.ts +80 -8
- package/extensions/tools/validation.ts +427 -44
- package/helpers/scan_project.py +15 -1
- package/package.json +1 -1
- package/skills/python-development/SKILL.md +11 -6
- package/src/build/commands.ts +17 -2
- package/src/build/failure.ts +63 -55
- package/src/build/quality.ts +49 -0
- package/src/build/selection.ts +176 -9
- package/src/build/sync.ts +70 -0
- package/src/build/traceback.ts +55 -0
- package/src/dependencies/aliases.ts +101 -0
- package/src/dependencies/plan.ts +60 -112
- package/src/environment/discovery.ts +30 -3
- package/src/environment/tools.ts +41 -2
- package/src/project/conformance.ts +101 -25
- package/src/project/inspect.ts +126 -53
- package/src/project/paths.ts +15 -0
- package/src/project/pytest-config.ts +45 -0
- package/src/project/root.ts +85 -1
- package/src/project/scanner.ts +61 -9
- package/src/validation/bundle.ts +41 -4
- package/src/validation/tdd.ts +94 -21
package/package.json
CHANGED
|
@@ -10,24 +10,24 @@ 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`을 미리보기/실행하세요.
|
|
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_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
24
|
9. 재현이 어려운 실패는 `py_test`의 `lastFailed=true`(`--lf`)로 직전 실패만 다시 실행하세요.
|
|
25
25
|
10. 작업 완료를 보고하기 전에 `py_validation_bundle`(lock 검사 → sync → pytest → 환경 정합성 → 오래된 아티팩트 검사)을 실행하고, `py_completion_evidence`로 근거가 충분한지 확인하세요. 정합성이 `drifted`나 `unverifiable`이면 테스트가 통과했어도 게이트는 실패합니다.
|
|
26
26
|
11. `py_tdd_checkpoint`로 프로덕션 변경에 대응하는 테스트 변경이 있는지 확인하세요.
|
|
27
27
|
|
|
28
28
|
## 안전 규칙 (Safety)
|
|
29
29
|
|
|
30
|
-
- `py_sync`와 `py_validation_bundle`은 `execute: true`가 명시적으로 전달되기 전까지 명령을 실행하지 않고 미리보기만 반환합니다. `execute=true`는 `.venv`를 생성/갱신하므로 사용자 확인 없이 반복 실행하지 마세요.
|
|
30
|
+
- `py_sync`와 `py_validation_bundle`은 `execute: true`가 명시적으로 전달되기 전까지 명령을 실행하지 않고 미리보기만 반환합니다. `execute=true`는 `.venv`를 생성/갱신하므로 사용자 확인 없이 반복 실행하지 마세요. `extras: 'none'`은 프로젝트가 요청할 때만 사용하세요: extra로 선언된 dev 도구를 삭제할 수 있습니다.
|
|
31
31
|
- 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
32
|
- 가상환경을 파괴하는 명령(`rm -rf .venv`, `uv venv --clear`)이나 전역 Python에 패키지를 설치하는 명령(`pip install` without a venv)을 임의로 실행하지 마세요.
|
|
33
33
|
- 도구는 파일을 쓰지 않습니다. `pyproject.toml`/`uv.lock` 수정은 항상 명시적인 편집 도구로 수행하세요.
|
|
@@ -36,6 +36,11 @@ license: Apache-2.0
|
|
|
36
36
|
|
|
37
37
|
- `PROJECT_INSTALLED_NOT_EDITABLE`는 프로젝트가 환경에 live link가 아니라 복사본으로 설치되어, 소스 변경이 테스트에 반영되지 않음을 의미합니다. `uv sync`로 해결하세요.
|
|
38
38
|
- `PROJECT_NOT_INSTALLED`는 lock이 editable 설치를 기대하는데 `.venv`에 프로젝트가 없다는 뜻입니다. `uv sync`를 실행하고, 그래도 실패하면 빌드 백엔드가 패키지를 찾지 못한 것입니다(`[project] name`과 모듈 디렉터리 이름이 일치하는지 확인).
|
|
39
|
+
- `PROJECT_VIRTUAL_SOURCE`는 정상입니다. `[build-system]`이 없으면 uv는 프로젝트를 `virtual` 소스로 기록하고 `.venv`에 설치하지 않습니다. 누락(`PROJECT_NOT_INSTALLED`)으로 취급하지 말고 `[build-system]` 추가 여부만 검토하세요.
|
|
40
|
+
- `MARKER_SPLIT_LOCK_ENTRIES`는 정상입니다. uv가 같은 배포판을 마커별로 여러 버전으로 기록한 것이며, 설치 버전이 그중 하나와 일치하면 드리프트가 아닙니다.
|
|
41
|
+
- `TOOL_NOT_INSTALLED`는 lock에는 있지만 `.venv`에 실행 파일이 없다는 뜻입니다. `uv sync` 직후에 발생했다면 sync가 extra를 삭제한 것이므로 `uv sync --frozen --all-groups --all-extras`로 복구하세요.
|
|
42
|
+
- `SYNC_REMOVED_PACKAGES`는 sync가 `.venv`에서 패키지를 제거했음을 뜻하며, 같은 실행에서 이어지는 "command not found"의 원인입니다.
|
|
43
|
+
- `UNMAPPED_IMPORTS`는 분석한 인터프리터가 import를 배포판에 연결하지 못했다는 뜻입니다. 이 상태에서는 `UNDECLARED_IMPORT`가 "선언 누락"이 아니라 "배포 이름 미확인"일 수 있으므로, 제안된 `uv add` 명령이 없으면 배포 이름을 직접 확인하세요.
|
|
39
44
|
- `INSTALLED_VERSION_MISMATCH`는 `uv add`/`uv lock` 후 `uv sync`를 잊은 상태입니다. `uv lock --check`는 이걸 잡지 못하니(락은 최신) 테스트를 신뢰하기 전에 `uv sync --frozen`을 실행하세요.
|
|
40
45
|
- `INSTALLED_PACKAGE_UNTRACKED`는 `.venv`에만 있고 lock에 없는 패키지입니다. 에이전트가 `uv pip install`로 임의 설치했을 가능성을 의심하세요.
|
|
41
46
|
- `CONDITIONAL_PACKAGES_ABSENT`는 정상입니다. `sys_platform == 'win32'`나 `python_version < '3.11'` 같은 마커 때문에 해당 플랫폼에 설치되지 않은 항목이며, 드리프트로 취급하지 마세요.
|
package/src/build/commands.ts
CHANGED
|
@@ -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
|
|
23
|
-
|
|
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 {
|
package/src/build/failure.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Suggestion } from '../core/result.ts';
|
|
2
2
|
|
|
3
3
|
export type FailureKind =
|
|
4
|
+
| 'tool_not_installed'
|
|
4
5
|
| 'module_not_found'
|
|
5
6
|
| 'environment_not_synced'
|
|
6
7
|
| 'import_error'
|
|
@@ -15,13 +16,13 @@ export type FailureKind =
|
|
|
15
16
|
| 'timeout'
|
|
16
17
|
| 'unknown';
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
19
|
+
import {
|
|
20
|
+
extractTracebackFrames,
|
|
21
|
+
firstUserFrame,
|
|
22
|
+
isLibraryFrame,
|
|
23
|
+
type TracebackFrame,
|
|
24
|
+
} from './traceback.ts';
|
|
25
|
+
export { extractTracebackFrames, firstUserFrame, isLibraryFrame, type TracebackFrame };
|
|
25
26
|
|
|
26
27
|
export interface FailureEvidence {
|
|
27
28
|
file?: string;
|
|
@@ -33,6 +34,8 @@ export interface FailureDiagnosis {
|
|
|
33
34
|
kind: FailureKind;
|
|
34
35
|
summary: string;
|
|
35
36
|
missingModule?: string;
|
|
37
|
+
/** Executable the command tried to start but could not find. */
|
|
38
|
+
missingTool?: string;
|
|
36
39
|
importTarget?: { name: string; module: string };
|
|
37
40
|
exceptionType?: string;
|
|
38
41
|
frames: TracebackFrame[];
|
|
@@ -42,54 +45,6 @@ export interface FailureDiagnosis {
|
|
|
42
45
|
suggestions: Suggestion[];
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
const FRAME_RE = /^\s*File "([^"]+)", line (\d+), in (.+?)\s*$/;
|
|
46
|
-
/**
|
|
47
|
-
* pytest `--tb=short` replaces the `File "..."` form with `path:line: in func`,
|
|
48
|
-
* so both notations must be recognised or short tracebacks yield no frame at all.
|
|
49
|
-
*/
|
|
50
|
-
const PYTEST_FRAME_RE = /^\s*([^\s:]+\.py):(\d+): in (.+?)\s*$/;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* A frame belongs to library code when it sits in an installed distribution or
|
|
54
|
-
* in the interpreter's own library tree. Pointing the agent at those frames is
|
|
55
|
-
* how it ends up editing site-packages instead of the project.
|
|
56
|
-
*/
|
|
57
|
-
export function isLibraryFrame(path: string): boolean {
|
|
58
|
-
const normalized = path.replace(/\\/g, '/');
|
|
59
|
-
return (
|
|
60
|
-
/\/site-packages\//.test(normalized) ||
|
|
61
|
-
/\/dist-packages\//.test(normalized) ||
|
|
62
|
-
/\/lib\/python3\.\d+\//.test(normalized) ||
|
|
63
|
-
/\/python3\.\d+\//.test(normalized) ||
|
|
64
|
-
/<frozen /.test(normalized) ||
|
|
65
|
-
/\/_pytest\//.test(normalized) ||
|
|
66
|
-
/\/pluggy\//.test(normalized)
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function extractTracebackFrames(output: string): TracebackFrame[] {
|
|
71
|
-
const frames: TracebackFrame[] = [];
|
|
72
|
-
for (const line of output.split(/\r?\n/)) {
|
|
73
|
-
const match = line.match(FRAME_RE) ?? line.match(PYTEST_FRAME_RE);
|
|
74
|
-
if (!match) continue;
|
|
75
|
-
const path = match[1];
|
|
76
|
-
frames.push({
|
|
77
|
-
path,
|
|
78
|
-
line: Number.parseInt(match[2], 10),
|
|
79
|
-
func: match[3].trim(),
|
|
80
|
-
library: isLibraryFrame(path),
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
return frames;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function firstUserFrame(frames: TracebackFrame[]): TracebackFrame | undefined {
|
|
87
|
-
for (let index = frames.length - 1; index >= 0; index -= 1) {
|
|
88
|
-
if (!frames[index].library) return frames[index];
|
|
89
|
-
}
|
|
90
|
-
return undefined;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
48
|
function lastMatch(output: string, pattern: RegExp): RegExpMatchArray | undefined {
|
|
94
49
|
const matches = [...output.matchAll(new RegExp(pattern.source, `${pattern.flags}g`))];
|
|
95
50
|
return matches.at(-1);
|
|
@@ -172,6 +127,46 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
|
|
|
172
127
|
};
|
|
173
128
|
}
|
|
174
129
|
|
|
130
|
+
// An environment failure is not a code failure. It appears as a spawn error
|
|
131
|
+
// rather than a traceback, so it must be classified before the traceback
|
|
132
|
+
// patterns or the whole run reads as an unclassifiable crash.
|
|
133
|
+
const spawnFailure = lastMatch(output, /Failed to spawn:?\s*`?([A-Za-z0-9._+-]+)`?/);
|
|
134
|
+
const commandNotFound = lastMatch(
|
|
135
|
+
output,
|
|
136
|
+
/(?:^|\n)\s*(?:sh: |bash: )?([A-Za-z0-9._+-]+): (?:command not found|No such file or directory)/,
|
|
137
|
+
);
|
|
138
|
+
const missingExecutable = spawnFailure ?? commandNotFound;
|
|
139
|
+
const toolNotInstalled = (tool: string, matchedText: string): FailureDiagnosis => {
|
|
140
|
+
const noEntry = /No such file or directory/.test(output) || /command not found/.test(output);
|
|
141
|
+
return {
|
|
142
|
+
kind: 'tool_not_installed',
|
|
143
|
+
summary: noEntry
|
|
144
|
+
? `The command could not start because "${tool}" is not installed in the environment that ran it.`
|
|
145
|
+
: `The command could not start: ${matchedText}`,
|
|
146
|
+
missingTool: tool,
|
|
147
|
+
exceptionType: 'environment',
|
|
148
|
+
frames,
|
|
149
|
+
firstUserFrame: userFrame,
|
|
150
|
+
evidence: [{ message: matchedText }],
|
|
151
|
+
suggestions: [
|
|
152
|
+
{
|
|
153
|
+
message: `Install the project environment so "${tool}" is available, then rerun through uv run.`,
|
|
154
|
+
confidence: 'high',
|
|
155
|
+
command: 'uv sync --frozen --all-groups --all-extras',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
message:
|
|
159
|
+
'A plain uv sync removes extras declared in [project.optional-dependencies], which is the usual reason a declared tool disappears mid-run.',
|
|
160
|
+
confidence: 'high',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
message: `Run the tool from the project environment with uv run --frozen ${tool}.`,
|
|
164
|
+
confidence: 'high',
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
175
170
|
const missing = lastMatch(output, /ModuleNotFoundError: No module named '([^']+)'/);
|
|
176
171
|
const cannotImport = lastMatch(
|
|
177
172
|
output,
|
|
@@ -200,6 +195,11 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
|
|
|
200
195
|
if (match?.index !== undefined) candidates.push({ kind, index: match.index });
|
|
201
196
|
};
|
|
202
197
|
consider('module_not_found', /ModuleNotFoundError: No module named '([^']+)'/);
|
|
198
|
+
consider('tool_not_installed', /Failed to spawn:?\s*`?([A-Za-z0-9._+-]+)`?/);
|
|
199
|
+
consider(
|
|
200
|
+
'tool_not_installed',
|
|
201
|
+
/(?:^|\n)\s*(?:sh: |bash: )?([A-Za-z0-9._+-]+): (?:command not found|No such file or directory)/,
|
|
202
|
+
);
|
|
203
203
|
consider('import_error', /ImportError: cannot import name '([^']+)' from '([^']+)'/);
|
|
204
204
|
consider('syntax_error', /SyntaxError: (.+)/);
|
|
205
205
|
consider('fixture_error', /(?:fixture '[^']+' not found|ERROR at setup of|error in .* fixture)/);
|
|
@@ -212,6 +212,14 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
|
|
|
212
212
|
candidates.sort((left, right) => left.index - right.index);
|
|
213
213
|
const kind: FailureKind = candidates[0]?.kind ?? 'unknown';
|
|
214
214
|
|
|
215
|
+
// Dispatch on the positional winner. `tool_not_installed` can be the earliest
|
|
216
|
+
// cause even when a traceback follows it, so it is not checked before the
|
|
217
|
+
// positional comparison.
|
|
218
|
+
if (kind === 'tool_not_installed') {
|
|
219
|
+
const match = missingExecutable;
|
|
220
|
+
if (match) return toolNotInstalled(match[1], match[0].trim());
|
|
221
|
+
}
|
|
222
|
+
|
|
215
223
|
if (kind === 'module_not_found' && missing) {
|
|
216
224
|
const module = missing[1].split('.')[0];
|
|
217
225
|
return {
|
|
@@ -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
|
+
}
|
package/src/build/selection.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { basename, dirname, join } from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isPythonFile,
|
|
4
|
+
isRunnableTestFile,
|
|
5
|
+
isTestFile,
|
|
6
|
+
parentDir,
|
|
7
|
+
pathTokens,
|
|
8
|
+
toPosix,
|
|
9
|
+
} from '../project/paths.ts';
|
|
3
10
|
|
|
4
11
|
export interface TestSelection {
|
|
5
12
|
path: string;
|
|
@@ -11,13 +18,40 @@ export interface SelectionResult {
|
|
|
11
18
|
selected: TestSelection[];
|
|
12
19
|
/** True when no changed file could be mapped and every test file is returned. */
|
|
13
20
|
fellBackToAll: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* False when the selection covers every considered test file, so the
|
|
23
|
+
* candidate list was not narrowed at all. Reported so a caller does not read
|
|
24
|
+
* "30 of 30 selected" as a focused run.
|
|
25
|
+
*/
|
|
26
|
+
narrowed: boolean;
|
|
14
27
|
changedSourceFiles: string[];
|
|
15
28
|
changedTestFiles: string[];
|
|
16
29
|
consideredTestFiles: string[];
|
|
30
|
+
/** Files under a test directory that pytest does not collect tests from. */
|
|
31
|
+
supportFiles: string[];
|
|
32
|
+
/**
|
|
33
|
+
* True when at least one candidate was matched by an actual import of a
|
|
34
|
+
* changed module, which is the strongest available signal. False means the
|
|
35
|
+
* selection rests on naming conventions alone.
|
|
36
|
+
*/
|
|
37
|
+
importEvidenceUsed: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Dotted module names imported by each test file, keyed by test path.
|
|
42
|
+
* Supplied by the scanner because a test named `test_db_session.py` gives no
|
|
43
|
+
* naming hint that it covers `db/database.py`; its imports do.
|
|
44
|
+
*/
|
|
45
|
+
export type TestImportMap = Record<string, string[]>;
|
|
46
|
+
|
|
47
|
+
export interface SelectionOptions {
|
|
48
|
+
testImports?: TestImportMap;
|
|
17
49
|
}
|
|
18
50
|
|
|
19
51
|
const SCORE = {
|
|
20
52
|
changedTestItself: 100,
|
|
53
|
+
/** Importing the changed module is stronger than any name coincidence. */
|
|
54
|
+
importsChangedModule: 90,
|
|
21
55
|
sameStemSameDir: 80,
|
|
22
56
|
sameStem: 60,
|
|
23
57
|
sameDirectory: 40,
|
|
@@ -46,34 +80,132 @@ function firstImportableSegment(path: string): string {
|
|
|
46
80
|
return (segments[start] ?? '').replace(/\.py$/i, '').toLowerCase();
|
|
47
81
|
}
|
|
48
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Dotted module names a source path could be imported as.
|
|
85
|
+
*
|
|
86
|
+
* `src/pkg/db/database.py` is imported as `pkg.db.database`, and an
|
|
87
|
+
* `__init__.py` is the package itself, so both forms are produced with and
|
|
88
|
+
* without the `src/` prefix.
|
|
89
|
+
*/
|
|
90
|
+
export function modulePathsFromFile(path: string): string[] {
|
|
91
|
+
const posix = toPosix(path);
|
|
92
|
+
if (!isPythonFile(posix)) return [];
|
|
93
|
+
const segments = posix.split('/').filter((segment) => segment.length > 0);
|
|
94
|
+
const srcIndex = segments.lastIndexOf('src');
|
|
95
|
+
const trimmed = (srcIndex === -1 ? segments : segments.slice(srcIndex + 1)).map((segment) =>
|
|
96
|
+
segment.replace(/\.py$/i, ''),
|
|
97
|
+
);
|
|
98
|
+
if (trimmed.length === 0) return [];
|
|
99
|
+
if (trimmed.at(-1) === '__init__') trimmed.pop();
|
|
100
|
+
if (trimmed.length === 0) return [];
|
|
101
|
+
const dotted = trimmed.join('.');
|
|
102
|
+
const withoutRoot = trimmed.slice(1).join('.');
|
|
103
|
+
const candidates = [dotted];
|
|
104
|
+
if (srcIndex !== -1) candidates.push(segments.slice(srcIndex).join('.').replace(/\.py$/i, ''));
|
|
105
|
+
if (withoutRoot.length > 0) candidates.push(withoutRoot);
|
|
106
|
+
return [...new Set(candidates)];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True when a test imports the module, or a parent package of it. */
|
|
110
|
+
function importsModule(imported: string[], modules: string[]): string | undefined {
|
|
111
|
+
let best: string | undefined;
|
|
112
|
+
for (const candidate of modules) {
|
|
113
|
+
for (const entry of imported) {
|
|
114
|
+
const matches =
|
|
115
|
+
entry === candidate ||
|
|
116
|
+
entry.startsWith(`${candidate}.`) ||
|
|
117
|
+
candidate.startsWith(`${entry}.`);
|
|
118
|
+
if (!matches) continue;
|
|
119
|
+
// Report the most specific import: naming `pkg` when the file actually
|
|
120
|
+
// imports `pkg.routes.admin` overstates how broadly the test is coupled.
|
|
121
|
+
if (!best || entry.length > best.length) best = entry;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return best;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Values that appear in at least this share of the candidates carry no
|
|
129
|
+
* information about *which* candidate to run: in a project whose tests all live
|
|
130
|
+
* inside the package under test, the package name matches every file.
|
|
131
|
+
*/
|
|
132
|
+
const UBIQUITOUS_SHARE = 0.5;
|
|
133
|
+
|
|
134
|
+
function ubiquitousValues(documents: string[][]): Set<string> {
|
|
135
|
+
const counts = new Map<string, number>();
|
|
136
|
+
for (const values of documents) {
|
|
137
|
+
for (const value of new Set(values)) {
|
|
138
|
+
if (value.length === 0) continue;
|
|
139
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const threshold = Math.max(2, documents.length * UBIQUITOUS_SHARE);
|
|
143
|
+
const ubiquitous = new Set<string>();
|
|
144
|
+
for (const [value, count] of counts) {
|
|
145
|
+
if (count >= threshold) ubiquitous.add(value);
|
|
146
|
+
}
|
|
147
|
+
return ubiquitous;
|
|
148
|
+
}
|
|
149
|
+
|
|
49
150
|
/**
|
|
50
151
|
* Rank test files against changed paths using pytest conventions first and
|
|
51
152
|
* token overlap second. The convention signals are strong enough in Python that
|
|
52
153
|
* a name match should always outrank a fuzzy token match.
|
|
154
|
+
*
|
|
155
|
+
* Signals shared by every candidate are discarded rather than scored. Without
|
|
156
|
+
* that step a package-rooted test tree (`<package>/tests/`) matches its own
|
|
157
|
+
* package on every file and the selection degenerates into the full suite.
|
|
53
158
|
*/
|
|
54
|
-
export function selectTests(
|
|
159
|
+
export function selectTests(
|
|
160
|
+
changedPaths: string[],
|
|
161
|
+
testFiles: string[],
|
|
162
|
+
options: SelectionOptions = {},
|
|
163
|
+
): SelectionResult {
|
|
55
164
|
const changed = changedPaths.map(toPosix).filter(isPythonFile);
|
|
56
165
|
const changedSourceFiles = changed.filter((path) => !isTestFile(path));
|
|
57
166
|
const changedTestFiles = changed.filter(isTestFile);
|
|
58
167
|
const considered = [...new Set(testFiles.map(toPosix).filter(isPythonFile))].sort();
|
|
168
|
+
const supportFiles = considered.filter((path) => !isRunnableTestFile(path));
|
|
169
|
+
const testImports = options.testImports ?? {};
|
|
59
170
|
|
|
60
171
|
if (!changed.length) {
|
|
61
172
|
return {
|
|
62
173
|
selected: [],
|
|
63
174
|
fellBackToAll: false,
|
|
175
|
+
narrowed: false,
|
|
64
176
|
changedSourceFiles,
|
|
65
177
|
changedTestFiles,
|
|
66
178
|
consideredTestFiles: considered,
|
|
179
|
+
supportFiles,
|
|
180
|
+
importEvidenceUsed: false,
|
|
67
181
|
};
|
|
68
182
|
}
|
|
69
183
|
|
|
70
184
|
const sourceTokens = new Set(changedSourceFiles.flatMap(pathTokens));
|
|
71
185
|
const sourceModules = new Set(changedSourceFiles.map(firstImportableSegment));
|
|
72
|
-
const sourceDirs = new Set(changedSourceFiles.map(parentDir));
|
|
73
186
|
const sourceStems = new Set(changedSourceFiles.map(normalizedStem));
|
|
187
|
+
const sourceModulePaths = changedSourceFiles.map((path) => modulePathsFromFile(path));
|
|
188
|
+
|
|
189
|
+
const ubiquitousTokens = ubiquitousValues(considered.map((path) => pathTokens(path)));
|
|
190
|
+
const ubiquitousModules = ubiquitousValues(
|
|
191
|
+
considered.map((path) => [firstImportableSegment(path)]),
|
|
192
|
+
);
|
|
193
|
+
// A source directory that contains every test file (the package root) cannot
|
|
194
|
+
// distinguish candidates, so it does not score.
|
|
195
|
+
const sourceDirs = new Set(
|
|
196
|
+
[...new Set(changedSourceFiles.map(parentDir))].filter(
|
|
197
|
+
(directory) =>
|
|
198
|
+
directory === '' || !considered.every((path) => path.startsWith(`${directory}/`)),
|
|
199
|
+
),
|
|
200
|
+
);
|
|
74
201
|
|
|
202
|
+
let importEvidenceUsed = false;
|
|
75
203
|
const selections: TestSelection[] = [];
|
|
76
204
|
for (const testFile of considered) {
|
|
205
|
+
// Test infrastructure is not a target, but it can still affect the run, so it
|
|
206
|
+
// is reported separately instead of being scored as a test file.
|
|
207
|
+
if (!isRunnableTestFile(testFile)) continue;
|
|
208
|
+
|
|
77
209
|
const reasons: string[] = [];
|
|
78
210
|
let score = 0;
|
|
79
211
|
|
|
@@ -81,6 +213,19 @@ export function selectTests(changedPaths: string[], testFiles: string[]): Select
|
|
|
81
213
|
score += SCORE.changedTestItself;
|
|
82
214
|
reasons.push('the test file itself changed');
|
|
83
215
|
}
|
|
216
|
+
|
|
217
|
+
const imported = testImports[testFile];
|
|
218
|
+
if (imported && imported.length > 0) {
|
|
219
|
+
const matched = sourceModulePaths
|
|
220
|
+
.map((modules) => importsModule(imported, modules))
|
|
221
|
+
.find((value) => value !== undefined);
|
|
222
|
+
if (matched) {
|
|
223
|
+
score += SCORE.importsChangedModule;
|
|
224
|
+
importEvidenceUsed = true;
|
|
225
|
+
reasons.push(`imports the changed module "${matched}"`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
84
229
|
const testStem = normalizedStem(testFile);
|
|
85
230
|
const testDir = parentDir(testFile);
|
|
86
231
|
if (sourceStems.has(testStem)) {
|
|
@@ -99,13 +244,15 @@ export function selectTests(changedPaths: string[], testFiles: string[]): Select
|
|
|
99
244
|
}
|
|
100
245
|
|
|
101
246
|
const module = firstImportableSegment(testFile);
|
|
102
|
-
if (module && sourceModules.has(module)) {
|
|
247
|
+
if (module && sourceModules.has(module) && !ubiquitousModules.has(module)) {
|
|
103
248
|
score += SCORE.sharedModule;
|
|
104
249
|
reasons.push(`covers module "${module}"`);
|
|
105
250
|
}
|
|
106
251
|
|
|
107
252
|
const tokens = pathTokens(testFile);
|
|
108
|
-
const shared = tokens.filter(
|
|
253
|
+
const shared = tokens.filter(
|
|
254
|
+
(token) => sourceTokens.has(token) && !ubiquitousTokens.has(token),
|
|
255
|
+
);
|
|
109
256
|
if (shared.length) {
|
|
110
257
|
score += SCORE.sharedToken * Math.min(shared.length, 2);
|
|
111
258
|
reasons.push(`shares token(s): ${shared.slice(0, 4).join(', ')}`);
|
|
@@ -125,14 +272,34 @@ export function selectTests(changedPaths: string[], testFiles: string[]): Select
|
|
|
125
272
|
}
|
|
126
273
|
}
|
|
127
274
|
|
|
275
|
+
const runnableConsidered = considered.filter(isRunnableTestFile);
|
|
276
|
+
const selectedRunnable = selections.filter((entry) => isRunnableTestFile(entry.path));
|
|
128
277
|
const fellBackToAll = selections.length === 0 && considered.length > 0;
|
|
278
|
+
if (fellBackToAll) {
|
|
279
|
+
return {
|
|
280
|
+
selected: considered.map((path) => ({
|
|
281
|
+
path,
|
|
282
|
+
score: 0,
|
|
283
|
+
reason: 'no match; running the full suite',
|
|
284
|
+
})),
|
|
285
|
+
fellBackToAll: true,
|
|
286
|
+
narrowed: false,
|
|
287
|
+
changedSourceFiles,
|
|
288
|
+
changedTestFiles,
|
|
289
|
+
consideredTestFiles: considered,
|
|
290
|
+
supportFiles,
|
|
291
|
+
importEvidenceUsed,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
129
295
|
return {
|
|
130
|
-
selected:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
fellBackToAll,
|
|
296
|
+
selected: selections,
|
|
297
|
+
fellBackToAll: false,
|
|
298
|
+
narrowed: selectedRunnable.length < runnableConsidered.length,
|
|
134
299
|
changedSourceFiles,
|
|
135
300
|
changedTestFiles,
|
|
136
301
|
consideredTestFiles: considered,
|
|
302
|
+
supportFiles,
|
|
303
|
+
importEvidenceUsed,
|
|
137
304
|
};
|
|
138
305
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uv prints a per-package inventory when `uv sync` changes the environment:
|
|
3
|
+
*
|
|
4
|
+
* ```text
|
|
5
|
+
* Uninstalled 10 packages in 305ms
|
|
6
|
+
* - coverage==7.15.4
|
|
7
|
+
* - pytest==9.0.3
|
|
8
|
+
* + ruff==0.15.5
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Removals matter because a sync that silently drops the project's own dev
|
|
12
|
+
* tooling leaves every later step unable to run, so the inventory is parsed
|
|
13
|
+
* rather than discarded.
|
|
14
|
+
*/
|
|
15
|
+
export interface SyncInventory {
|
|
16
|
+
installed: string[];
|
|
17
|
+
uninstalled: string[];
|
|
18
|
+
/** One of `uv`'s summary lines, e.g. `Audited 78 packages in 3ms`. */
|
|
19
|
+
summaryLines: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const COUNT_LINE = /^(Installed|Uninstalled|Prepared|Audited|Resolved)\b/;
|
|
23
|
+
const PACKAGE_LINE = /^\s*([+-])\s*([A-Za-z0-9._-]+)(?:==(\S+))?\s*$/;
|
|
24
|
+
|
|
25
|
+
function stripVersion(name: string): string {
|
|
26
|
+
return name.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read the installed/uninstalled inventory out of uv's stdout. Anything that
|
|
31
|
+
* does not match the documented layout is ignored rather than guessed at, so a
|
|
32
|
+
* future uv output change degrades to "no inventory" instead of a wrong one.
|
|
33
|
+
*/
|
|
34
|
+
export function parseSyncOutput(stdout: string, stderr = ''): SyncInventory {
|
|
35
|
+
const installed: string[] = [];
|
|
36
|
+
const uninstalled: string[] = [];
|
|
37
|
+
const summaryLines: string[] = [];
|
|
38
|
+
let section: 'installed' | 'uninstalled' | null = null;
|
|
39
|
+
|
|
40
|
+
for (const rawLine of `${stdout}\n${stderr}`.split(/\r?\n/)) {
|
|
41
|
+
const line = rawLine.trimEnd();
|
|
42
|
+
if (COUNT_LINE.test(line.trim())) {
|
|
43
|
+
summaryLines.push(line.trim());
|
|
44
|
+
if (/^Uninstalled\b/.test(line.trim())) section = 'uninstalled';
|
|
45
|
+
else if (/^Installed\b/.test(line.trim())) section = 'installed';
|
|
46
|
+
else section = null;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const match = line.match(PACKAGE_LINE);
|
|
50
|
+
if (!match || !section) continue;
|
|
51
|
+
const name = stripVersion(match[2]);
|
|
52
|
+
if (match[1] === '-' && section === 'uninstalled') uninstalled.push(name);
|
|
53
|
+
else if (match[1] === '+' && section === 'installed') installed.push(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { installed, uninstalled, summaryLines };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A sync that removes distributions is worth reporting even when it exits zero:
|
|
61
|
+
* on its own it is not a failure, but it explains every later "command not
|
|
62
|
+
* found" failure in the same run.
|
|
63
|
+
*/
|
|
64
|
+
export function describeRemovals(inventory: SyncInventory): string | undefined {
|
|
65
|
+
if (inventory.uninstalled.length === 0) return undefined;
|
|
66
|
+
const shown = inventory.uninstalled.slice(0, 12).join(', ');
|
|
67
|
+
const rest =
|
|
68
|
+
inventory.uninstalled.length > 12 ? `, … (+${inventory.uninstalled.length - 12})` : '';
|
|
69
|
+
return `uv sync removed ${inventory.uninstalled.length} distribution(s) from .venv: ${shown}${rest}.`;
|
|
70
|
+
}
|