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/CHANGELOG.md +48 -0
- package/README.md +85 -125
- package/docs/compatibility.md +17 -1
- package/docs/tools.md +47 -2
- package/extensions/index.ts +2 -0
- package/extensions/shared.ts +37 -1
- package/extensions/tools/environment.ts +20 -5
- package/extensions/tools/test-config.ts +148 -0
- package/extensions/tools/testing.ts +95 -9
- package/extensions/tools/validation.ts +310 -46
- package/helpers/scan_project.py +81 -1
- package/package.json +1 -1
- package/skills/python-development/SKILL.md +17 -9
- package/src/build/commands.ts +17 -2
- package/src/build/failure.ts +81 -16
- package/src/build/pytest-audit.ts +226 -0
- package/src/build/quality.ts +49 -0
- package/src/build/selection.ts +176 -9
- package/src/build/sync.ts +70 -0
- package/src/core/result.ts +28 -1
- package/src/dependencies/aliases.ts +21 -0
- package/src/dependencies/plan.ts +58 -9
- 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 +45 -6
- 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 +71 -9
- package/src/validation/bundle.ts +41 -4
- package/src/validation/tdd.ts +94 -21
package/src/validation/bundle.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
export interface ValidationStep {
|
|
2
|
+
/** Step label, used for the quality checks (`ruff`, `pyright`). */
|
|
3
|
+
name?: string;
|
|
2
4
|
executed: boolean;
|
|
3
5
|
ok: boolean;
|
|
4
6
|
exitCode?: number | null;
|
|
5
7
|
failures?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Why a step was not executed. A step that was skipped deliberately (the
|
|
10
|
+
* environment is missing the tool it needs) must say so, otherwise the run
|
|
11
|
+
* looks like an unexplained test failure.
|
|
12
|
+
*/
|
|
13
|
+
skippedReason?: string;
|
|
6
14
|
}
|
|
7
15
|
|
|
8
16
|
export interface ValidationSummary {
|
|
@@ -13,10 +21,18 @@ export interface ValidationSummary {
|
|
|
13
21
|
sync: boolean;
|
|
14
22
|
test: boolean;
|
|
15
23
|
conformance: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* True when every configured quality command passed. Vacuously true when the
|
|
26
|
+
* project declares none, so a project without lint/type tooling is not
|
|
27
|
+
* penalised.
|
|
28
|
+
*/
|
|
29
|
+
quality: boolean;
|
|
16
30
|
staleArtifacts: boolean;
|
|
17
31
|
};
|
|
18
32
|
}
|
|
19
33
|
|
|
34
|
+
const PREVIEW_REASON = 'Set execute=true to run the validation bundle.';
|
|
35
|
+
|
|
20
36
|
/**
|
|
21
37
|
* The bundle runs `uv lock --check`, then `uv sync --frozen`, then pytest, then
|
|
22
38
|
* verifies that the resulting environment actually matches the lockfile and that
|
|
@@ -25,27 +41,45 @@ export interface ValidationSummary {
|
|
|
25
41
|
* Conformance must be proven, not merely not-failed: a test run that passed
|
|
26
42
|
* against versions the lockfile does not describe is not evidence, so an
|
|
27
43
|
* `unverifiable` verdict fails the gate exactly like drift does.
|
|
44
|
+
*
|
|
45
|
+
* A test step that was never executed is also not evidence, and when the reason
|
|
46
|
+
* is known (the environment no longer provides pytest) that reason is reported
|
|
47
|
+
* instead of a generic failure.
|
|
28
48
|
*/
|
|
29
49
|
export function summarizeValidation(input: {
|
|
30
50
|
lock: ValidationStep;
|
|
31
51
|
sync: ValidationStep;
|
|
32
52
|
test: ValidationStep;
|
|
53
|
+
/** Declared lint/type commands; omitted when the project declares none. */
|
|
54
|
+
quality?: ValidationStep[];
|
|
33
55
|
conformance: 'consistent' | 'drifted' | 'unverifiable';
|
|
34
56
|
stale: boolean;
|
|
57
|
+
/** True when nothing was executed because the caller only asked for a preview. */
|
|
58
|
+
preview?: boolean;
|
|
35
59
|
}): ValidationSummary {
|
|
36
60
|
const lock = input.lock.executed && input.lock.ok;
|
|
37
61
|
const sync = input.sync.executed && input.sync.ok;
|
|
38
62
|
const test = input.test.executed && input.test.ok && (input.test.failures ?? 0) === 0;
|
|
63
|
+
const qualitySteps = input.quality ?? [];
|
|
64
|
+
const quality = qualitySteps.every((step) => step.executed && step.ok);
|
|
65
|
+
const failedQuality = qualitySteps.filter((step) => !step.executed || !step.ok);
|
|
39
66
|
const conformance = input.conformance === 'consistent';
|
|
40
67
|
const staleArtifacts = input.stale;
|
|
41
68
|
|
|
42
69
|
let reason = 'Lockfile, environment, tests, and installed versions all agree.';
|
|
43
|
-
if (
|
|
44
|
-
reason =
|
|
70
|
+
if (input.preview) {
|
|
71
|
+
reason = PREVIEW_REASON;
|
|
72
|
+
} else if (!input.lock.executed || !input.sync.executed) {
|
|
73
|
+
reason = !input.lock.executed
|
|
74
|
+
? 'uv lock --check was not executed, so lockfile agreement is unproven.'
|
|
75
|
+
: 'uv sync was not executed, so the environment the tests ran in is unknown.';
|
|
45
76
|
} else if (!lock) {
|
|
46
77
|
reason = 'uv.lock is out of date; run uv lock before trusting any test result.';
|
|
47
78
|
} else if (!sync) {
|
|
48
79
|
reason = 'The environment could not be synchronised from the lockfile.';
|
|
80
|
+
} else if (!input.test.executed) {
|
|
81
|
+
reason =
|
|
82
|
+
input.test.skippedReason ?? 'Tests were not executed, so no test result exists to report.';
|
|
49
83
|
} else if (!test) {
|
|
50
84
|
reason = 'Tests failed; inspect the first failing case and its project frame.';
|
|
51
85
|
} else if (input.conformance === 'drifted') {
|
|
@@ -54,12 +88,15 @@ export function summarizeValidation(input: {
|
|
|
54
88
|
} else if (input.conformance === 'unverifiable') {
|
|
55
89
|
reason =
|
|
56
90
|
'The installed environment could not be compared with uv.lock, so the passing test run is not proven to be on the locked versions.';
|
|
91
|
+
} else if (failedQuality.length > 0) {
|
|
92
|
+
const names = failedQuality.map((step) => step.name ?? 'quality check').join(', ');
|
|
93
|
+
reason = `Tests passed, but the declared quality check(s) failed: ${names}.`;
|
|
57
94
|
} else if (staleArtifacts) {
|
|
58
95
|
reason = 'Tests passed, but a stale coverage report was detected; refresh it and rerun.';
|
|
59
96
|
}
|
|
60
97
|
return {
|
|
61
|
-
ok: lock && sync && test && conformance && !staleArtifacts,
|
|
98
|
+
ok: lock && sync && test && conformance && quality && !staleArtifacts,
|
|
62
99
|
reason,
|
|
63
|
-
checks: { lock, sync, test, conformance, staleArtifacts },
|
|
100
|
+
checks: { lock, sync, test, conformance, quality, staleArtifacts },
|
|
64
101
|
};
|
|
65
102
|
}
|
package/src/validation/tdd.ts
CHANGED
|
@@ -1,38 +1,91 @@
|
|
|
1
1
|
import { isTestFile } from '../project/paths.ts';
|
|
2
2
|
|
|
3
|
+
/** A production file and a test file that were matched to each other. */
|
|
4
|
+
export interface TddAssociation {
|
|
5
|
+
source: string;
|
|
6
|
+
test: string;
|
|
7
|
+
/** Tokens both paths share, so the caller can judge the match. */
|
|
8
|
+
sharedTokens: string[];
|
|
9
|
+
/**
|
|
10
|
+
* `module` when the match goes beyond the path prefix every file shares,
|
|
11
|
+
* `package` when only the common package prefix matched. A package-level match
|
|
12
|
+
* still passes the checkpoint, but it is weak evidence and is disclosed.
|
|
13
|
+
*/
|
|
14
|
+
strength: 'module' | 'package';
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
export interface TddCheckpoint {
|
|
4
18
|
ok: boolean;
|
|
5
19
|
reasons: string[];
|
|
6
20
|
sourceChanges: string[];
|
|
7
21
|
testChanges: string[];
|
|
22
|
+
associations: TddAssociation[];
|
|
23
|
+
/** True when a match rested only on the shared package prefix. */
|
|
24
|
+
weakAssociation: boolean;
|
|
8
25
|
}
|
|
9
26
|
|
|
27
|
+
/** Tokens shorter than this cannot distinguish two module names. */
|
|
10
28
|
const MIN_TOKEN_LENGTH = 4;
|
|
11
29
|
|
|
30
|
+
/** Tokens that appear in the prefix of every file and so carry no meaning. */
|
|
31
|
+
const PREFIX_TOKENS = new Set(['test', 'tests', 'testing', 'src', 'lib']);
|
|
32
|
+
|
|
12
33
|
function tokens(path: string): string[] {
|
|
13
34
|
return path
|
|
14
|
-
.
|
|
15
|
-
.
|
|
16
|
-
.
|
|
17
|
-
.filter((token) => token.length >= MIN_TOKEN_LENGTH);
|
|
35
|
+
.replace(/\.py$/i, '')
|
|
36
|
+
.split(/[^A-Za-z0-9]+/)
|
|
37
|
+
.map((token) => token.toLowerCase())
|
|
38
|
+
.filter((token) => token.length >= MIN_TOKEN_LENGTH && !PREFIX_TOKENS.has(token));
|
|
18
39
|
}
|
|
19
40
|
|
|
20
|
-
function
|
|
21
|
-
const testTokens = tokens(test)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
function sharedTokens(source: string, test: string): string[] {
|
|
42
|
+
const testTokens = tokens(test);
|
|
43
|
+
const shared: string[] = [];
|
|
44
|
+
for (const sourceToken of new Set(tokens(source))) {
|
|
45
|
+
if (
|
|
46
|
+
testTokens.some(
|
|
47
|
+
(testToken) =>
|
|
48
|
+
sourceToken === testToken ||
|
|
49
|
+
sourceToken.startsWith(testToken) ||
|
|
50
|
+
testToken.startsWith(sourceToken),
|
|
51
|
+
)
|
|
52
|
+
) {
|
|
53
|
+
shared.push(sourceToken);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return shared;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Tokens contributed by the directory prefix every changed path shares.
|
|
61
|
+
*
|
|
62
|
+
* In a project whose tests live inside the package under test, every path starts
|
|
63
|
+
* with the package name, so those tokens say nothing about whether a specific
|
|
64
|
+
* test covers a specific module. A common prefix of nothing (no shared
|
|
65
|
+
* directory) yields no exclusions, so an exact name match is never downgraded.
|
|
66
|
+
*/
|
|
67
|
+
function commonPrefixTokens(paths: string[]): Set<string> {
|
|
68
|
+
if (paths.length < 2) return new Set();
|
|
69
|
+
const directories = paths.map((path) => path.replace(/\\/g, '/').split('/').slice(0, -1));
|
|
70
|
+
const [first, ...rest] = directories;
|
|
71
|
+
const common: string[] = [];
|
|
72
|
+
for (let index = 0; index < first.length; index += 1) {
|
|
73
|
+
const segment = first[index];
|
|
74
|
+
if (rest.every((entry) => entry[index] === segment)) common.push(segment);
|
|
75
|
+
else break;
|
|
76
|
+
}
|
|
77
|
+
return new Set(common.flatMap((segment) => tokens(segment)));
|
|
30
78
|
}
|
|
31
79
|
|
|
32
80
|
/**
|
|
33
81
|
* Check that production changes are accompanied by a plausibly related test
|
|
34
|
-
* change.
|
|
35
|
-
*
|
|
82
|
+
* change.
|
|
83
|
+
*
|
|
84
|
+
* Matching is name-based on purpose: it is cheap, deterministic, and only used
|
|
85
|
+
* to decide whether to run the heavier verification bundle. Because it is only
|
|
86
|
+
* name-based, it also reports *why* each pair matched and downgrades a match
|
|
87
|
+
* that rests solely on the package prefix instead of silently counting it as
|
|
88
|
+
* strong evidence.
|
|
36
89
|
*/
|
|
37
90
|
export function checkTdd(changedPaths: string[], testChangedPaths: string[] = []): TddCheckpoint {
|
|
38
91
|
const all = [...new Set([...changedPaths, ...testChangedPaths])].map((path) =>
|
|
@@ -41,15 +94,33 @@ export function checkTdd(changedPaths: string[], testChangedPaths: string[] = []
|
|
|
41
94
|
const sourceChanges = all.filter((path) => path.endsWith('.py') && !isTestFile(path));
|
|
42
95
|
const testChanges = all.filter((path) => path.endsWith('.py') && isTestFile(path));
|
|
43
96
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
97
|
+
// `fastapi_server/db/database.py` and `fastapi_server/tests/unit/test_db.py`
|
|
98
|
+
// share `fastapi` and `server` with every other file in the project, so those
|
|
99
|
+
// tokens must not be treated as evidence of a real relationship.
|
|
100
|
+
const ubiquitous = commonPrefixTokens([...sourceChanges, ...testChanges]);
|
|
101
|
+
|
|
102
|
+
const associations: TddAssociation[] = [];
|
|
103
|
+
for (const source of sourceChanges) {
|
|
104
|
+
for (const test of testChanges) {
|
|
105
|
+
const shared = sharedTokens(source, test);
|
|
106
|
+
if (shared.length === 0) continue;
|
|
107
|
+
const discriminating = shared.filter((token) => !ubiquitous.has(token));
|
|
108
|
+
associations.push({
|
|
109
|
+
source,
|
|
110
|
+
test,
|
|
111
|
+
sharedTokens: shared,
|
|
112
|
+
strength: discriminating.length > 0 ? 'module' : 'package',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const strong = associations.some((entry) => entry.strength === 'module');
|
|
118
|
+
const weakAssociation = associations.length > 0 && !strong;
|
|
48
119
|
|
|
49
120
|
const reasons: string[] = [];
|
|
50
121
|
if (sourceChanges.length > 0 && testChanges.length === 0) {
|
|
51
122
|
reasons.push('Production Python files changed without any test file change.');
|
|
52
|
-
} else if (sourceChanges.length > 0 &&
|
|
123
|
+
} else if (sourceChanges.length > 0 && associations.length === 0) {
|
|
53
124
|
reasons.push('Changed test files do not appear related to the changed production modules.');
|
|
54
125
|
}
|
|
55
126
|
|
|
@@ -58,5 +129,7 @@ export function checkTdd(changedPaths: string[], testChangedPaths: string[] = []
|
|
|
58
129
|
reasons,
|
|
59
130
|
sourceChanges,
|
|
60
131
|
testChanges,
|
|
132
|
+
associations,
|
|
133
|
+
weakAssociation,
|
|
61
134
|
};
|
|
62
135
|
}
|