pi-python-helper 0.3.0 → 0.4.1
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 +18 -0
- package/docs/tools.md +4 -3
- package/extensions/tools/dependencies.ts +1 -1
- package/extensions/tools/environment.ts +6 -2
- package/extensions/tools/test-config.ts +1 -1
- package/extensions/tools/validation.ts +5 -1
- package/package.json +4 -1
- package/src/build/selection.ts +28 -256
- package/src/build/staleness.ts +28 -104
- package/src/core/result.ts +23 -123
- package/src/core/runner.ts +3 -94
- package/src/core/safety.ts +32 -104
- package/src/validation/bundle.ts +40 -75
- package/src/validation/evidence.ts +25 -21
- package/src/validation/tdd.ts +21 -130
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.4.1] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `src/core/safety.ts` now delegates to `pi-helper-core`'s classifier and supplies only the Python package-manager, environment, and migration rules; the segment-splitting, safe-override precedence, and compound-merge logic is no longer duplicated. Requires `pi-helper-core` 0.1.2, which stops treating `--frozen`/`--locked`/`--list` as read-only flags so `uv sync --frozen` is classified as mutating again.
|
|
15
|
+
|
|
16
|
+
## [0.4.0] - 2026-09-21
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Adopt `pi-helper-core` (`^0.1.1`) for the shared response envelope, bounded command runner, TDD checkpoint, validation-bundle gate, completion evidence, artifact staleness, and test selection. `src/core/result.ts` and `src/core/runner.ts` are now thin shims, and `src/validation/` and `src/build/` supply only Python signals, labels, and rules.
|
|
21
|
+
- **Breaking:** tool metadata no longer carries `pythonVersion`; the interpreter version now lives in the ecosystem-neutral `metadata.toolchain` (`{ kind: 'python', version }`).
|
|
22
|
+
- Validation and completion messages now use the shared core wording (`uv lock --check`/`uv sync` labels); the drift check reports a mismatch against "the lockfile" generically.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `test/core-dependency.test.ts` pins the `pi-helper-core` dependency and the shared behaviours the Python tools delegate to.
|
|
27
|
+
|
|
10
28
|
## [0.3.0] - 2026-09-21
|
|
11
29
|
|
|
12
30
|
### Added
|
package/docs/tools.md
CHANGED
|
@@ -30,12 +30,13 @@
|
|
|
30
30
|
- `cwd`: string
|
|
31
31
|
- `durationMs`: number
|
|
32
32
|
- `projectRoot` (optional): string
|
|
33
|
-
- `pythonVersion` (optional): string
|
|
34
33
|
- `toolVersion`: string
|
|
34
|
+
- `toolchain` (optional): object
|
|
35
|
+
- `kind`: string
|
|
36
|
+
- `source`: string
|
|
37
|
+
- `version`: string
|
|
35
38
|
- `truncated`: boolean
|
|
36
39
|
- `ok`: boolean
|
|
37
|
-
- `projectRoot` (optional): string
|
|
38
|
-
- `pythonVersion` (optional): string
|
|
39
40
|
- `suggestions`: array of
|
|
40
41
|
- `command` (optional): string
|
|
41
42
|
- `confidence`: string
|
|
@@ -72,7 +72,7 @@ export function registerDependencyTools(pi: Pi): void {
|
|
|
72
72
|
errors: [],
|
|
73
73
|
suggestions: plan.suggestions,
|
|
74
74
|
projectRoot: root,
|
|
75
|
-
|
|
75
|
+
toolchain: { kind: 'python', version: scan.payload.pythonVersion, source: 'project' },
|
|
76
76
|
}),
|
|
77
77
|
);
|
|
78
78
|
} catch (error) {
|
|
@@ -67,7 +67,11 @@ export function registerEnvironmentTools(pi: Pi): void {
|
|
|
67
67
|
confidence: 'medium' as const,
|
|
68
68
|
})),
|
|
69
69
|
projectRoot: environment.projectRoot,
|
|
70
|
-
|
|
70
|
+
toolchain: {
|
|
71
|
+
kind: 'python',
|
|
72
|
+
version: python?.version,
|
|
73
|
+
source: environment.projectRoot ? 'project' : 'path',
|
|
74
|
+
},
|
|
71
75
|
}),
|
|
72
76
|
);
|
|
73
77
|
} catch (error) {
|
|
@@ -175,7 +179,7 @@ export function registerEnvironmentTools(pi: Pi): void {
|
|
|
175
179
|
errors: [],
|
|
176
180
|
suggestions: inspection.suggestions,
|
|
177
181
|
projectRoot: inspection.root,
|
|
178
|
-
|
|
182
|
+
toolchain: { kind: 'python', version: scan.payload.pythonVersion, source: 'project' },
|
|
179
183
|
}),
|
|
180
184
|
);
|
|
181
185
|
} catch (error) {
|
|
@@ -137,7 +137,7 @@ export function registerTestConfigTools(pi: Pi): void {
|
|
|
137
137
|
confidence: 'high' as const,
|
|
138
138
|
})),
|
|
139
139
|
projectRoot: root,
|
|
140
|
-
|
|
140
|
+
toolchain: { kind: 'python', version: scan.payload.pythonVersion, source: 'project' },
|
|
141
141
|
}),
|
|
142
142
|
);
|
|
143
143
|
} catch (error) {
|
|
@@ -590,7 +590,11 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
590
590
|
],
|
|
591
591
|
commands,
|
|
592
592
|
projectRoot: root,
|
|
593
|
-
|
|
593
|
+
toolchain: {
|
|
594
|
+
kind: 'python',
|
|
595
|
+
version: scan.payload?.pythonVersion,
|
|
596
|
+
source: 'project',
|
|
597
|
+
},
|
|
594
598
|
}),
|
|
595
599
|
);
|
|
596
600
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-python-helper",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Python (uv) development tools for the pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -69,5 +69,8 @@
|
|
|
69
69
|
"prettier": "^3.9.8",
|
|
70
70
|
"tsx": "^4.23.13",
|
|
71
71
|
"typescript": "^7.0.2"
|
|
72
|
+
},
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"pi-helper-core": "^0.1.2"
|
|
72
75
|
}
|
|
73
76
|
}
|
package/src/build/selection.ts
CHANGED
|
@@ -1,75 +1,26 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Python's test-selection signals over the shared ranking algorithm.
|
|
3
|
+
*
|
|
4
|
+
* The ranking (pytest naming conventions first, fuzzy token overlap second,
|
|
5
|
+
* ubiquitous signals discarded) lives in `pi-helper-core`. This module answers
|
|
6
|
+
* only the Python questions: what is a source/test file, what module a path
|
|
7
|
+
* becomes, and what counts as shared test infrastructure.
|
|
8
|
+
*/
|
|
9
|
+
import { selectTests as coreSelectTests, type SelectionSignals } from 'pi-helper-core';
|
|
2
10
|
import {
|
|
3
11
|
isPythonFile,
|
|
4
12
|
isRunnableTestFile,
|
|
5
13
|
isTestFile,
|
|
6
|
-
parentDir,
|
|
7
14
|
pathTokens,
|
|
8
15
|
toPosix,
|
|
9
16
|
} from '../project/paths.ts';
|
|
10
17
|
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export interface SelectionResult {
|
|
18
|
-
selected: TestSelection[];
|
|
19
|
-
/** True when no changed file could be mapped and every test file is returned. */
|
|
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;
|
|
27
|
-
changedSourceFiles: string[];
|
|
28
|
-
changedTestFiles: string[];
|
|
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;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const SCORE = {
|
|
52
|
-
changedTestItself: 100,
|
|
53
|
-
/** Importing the changed module is stronger than any name coincidence. */
|
|
54
|
-
importsChangedModule: 90,
|
|
55
|
-
sameStemSameDir: 80,
|
|
56
|
-
sameStem: 60,
|
|
57
|
-
sameDirectory: 40,
|
|
58
|
-
sharedToken: 20,
|
|
59
|
-
sharedModule: 30,
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
function stem(path: string): string {
|
|
63
|
-
return basename(toPosix(path)).replace(/\.py$/i, '');
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Strip the pytest prefix/suffix so `test_parser.py` and `parser.py` compare equal. */
|
|
67
|
-
function normalizedStem(path: string): string {
|
|
68
|
-
return stem(path)
|
|
69
|
-
.replace(/^test_/, '')
|
|
70
|
-
.replace(/_test$/, '')
|
|
71
|
-
.toLowerCase();
|
|
72
|
-
}
|
|
18
|
+
export type {
|
|
19
|
+
SelectionOptions,
|
|
20
|
+
SelectionResult,
|
|
21
|
+
TestImportMap,
|
|
22
|
+
TestSelection,
|
|
23
|
+
} from 'pi-helper-core';
|
|
73
24
|
|
|
74
25
|
function firstImportableSegment(path: string): string {
|
|
75
26
|
const segments = toPosix(path)
|
|
@@ -106,200 +57,21 @@ export function modulePathsFromFile(path: string): string[] {
|
|
|
106
57
|
return [...new Set(candidates)];
|
|
107
58
|
}
|
|
108
59
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
}
|
|
60
|
+
const PYTHON_SIGNALS: SelectionSignals = {
|
|
61
|
+
isSourceFile: isPythonFile,
|
|
62
|
+
isTestFile,
|
|
63
|
+
isRunnableTestFile,
|
|
64
|
+
pathTokens,
|
|
65
|
+
moduleNamesForFile: modulePathsFromFile,
|
|
66
|
+
packageName: firstImportableSegment,
|
|
67
|
+
supportFileNames: new Set(['conftest.py']),
|
|
68
|
+
testNameAffixes: { prefixes: ['test_'], suffixes: ['_test'] },
|
|
69
|
+
};
|
|
149
70
|
|
|
150
|
-
/**
|
|
151
|
-
* Rank test files against changed paths using pytest conventions first and
|
|
152
|
-
* token overlap second. The convention signals are strong enough in Python that
|
|
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.
|
|
158
|
-
*/
|
|
159
71
|
export function selectTests(
|
|
160
72
|
changedPaths: string[],
|
|
161
73
|
testFiles: string[],
|
|
162
|
-
options: SelectionOptions = {},
|
|
163
|
-
): SelectionResult {
|
|
164
|
-
|
|
165
|
-
const changedSourceFiles = changed.filter((path) => !isTestFile(path));
|
|
166
|
-
const changedTestFiles = changed.filter(isTestFile);
|
|
167
|
-
const considered = [...new Set(testFiles.map(toPosix).filter(isPythonFile))].sort();
|
|
168
|
-
const supportFiles = considered.filter((path) => !isRunnableTestFile(path));
|
|
169
|
-
const testImports = options.testImports ?? {};
|
|
170
|
-
|
|
171
|
-
if (!changed.length) {
|
|
172
|
-
return {
|
|
173
|
-
selected: [],
|
|
174
|
-
fellBackToAll: false,
|
|
175
|
-
narrowed: false,
|
|
176
|
-
changedSourceFiles,
|
|
177
|
-
changedTestFiles,
|
|
178
|
-
consideredTestFiles: considered,
|
|
179
|
-
supportFiles,
|
|
180
|
-
importEvidenceUsed: false,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const sourceTokens = new Set(changedSourceFiles.flatMap(pathTokens));
|
|
185
|
-
const sourceModules = new Set(changedSourceFiles.map(firstImportableSegment));
|
|
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
|
-
);
|
|
201
|
-
|
|
202
|
-
let importEvidenceUsed = false;
|
|
203
|
-
const selections: TestSelection[] = [];
|
|
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
|
-
|
|
209
|
-
const reasons: string[] = [];
|
|
210
|
-
let score = 0;
|
|
211
|
-
|
|
212
|
-
if (changedTestFiles.includes(testFile)) {
|
|
213
|
-
score += SCORE.changedTestItself;
|
|
214
|
-
reasons.push('the test file itself changed');
|
|
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
|
-
|
|
229
|
-
const testStem = normalizedStem(testFile);
|
|
230
|
-
const testDir = parentDir(testFile);
|
|
231
|
-
if (sourceStems.has(testStem)) {
|
|
232
|
-
score += SCORE.sameStem;
|
|
233
|
-
reasons.push(`module name matches "${testStem}"`);
|
|
234
|
-
if (sourceDirs.has(testDir)) {
|
|
235
|
-
score += SCORE.sameStemSameDir - SCORE.sameStem;
|
|
236
|
-
reasons.push('same directory as the changed module');
|
|
237
|
-
}
|
|
238
|
-
} else if (sourceDirs.has(testDir)) {
|
|
239
|
-
score += SCORE.sameDirectory;
|
|
240
|
-
reasons.push('same directory as a changed module');
|
|
241
|
-
} else if (sourceDirs.has(dirname(testDir)) || sourceDirs.has(join(dirname(testDir), ''))) {
|
|
242
|
-
score += SCORE.sameDirectory - 10;
|
|
243
|
-
reasons.push('nested under a changed directory');
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const module = firstImportableSegment(testFile);
|
|
247
|
-
if (module && sourceModules.has(module) && !ubiquitousModules.has(module)) {
|
|
248
|
-
score += SCORE.sharedModule;
|
|
249
|
-
reasons.push(`covers module "${module}"`);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const tokens = pathTokens(testFile);
|
|
253
|
-
const shared = tokens.filter(
|
|
254
|
-
(token) => sourceTokens.has(token) && !ubiquitousTokens.has(token),
|
|
255
|
-
);
|
|
256
|
-
if (shared.length) {
|
|
257
|
-
score += SCORE.sharedToken * Math.min(shared.length, 2);
|
|
258
|
-
reasons.push(`shares token(s): ${shared.slice(0, 4).join(', ')}`);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (score > 0) {
|
|
262
|
-
selections.push({ path: testFile, score, reason: reasons.join('; ') });
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
selections.sort((left, right) => right.score - left.score || left.path.localeCompare(right.path));
|
|
267
|
-
|
|
268
|
-
// A conftest can affect every test, so it is always in scope once tests run.
|
|
269
|
-
for (const testFile of considered) {
|
|
270
|
-
if (basename(testFile) === 'conftest.py' && !selections.some((s) => s.path === testFile)) {
|
|
271
|
-
selections.push({ path: testFile, score: 1, reason: 'shared conftest fixture scope' });
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const runnableConsidered = considered.filter(isRunnableTestFile);
|
|
276
|
-
const selectedRunnable = selections.filter((entry) => isRunnableTestFile(entry.path));
|
|
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
|
-
|
|
295
|
-
return {
|
|
296
|
-
selected: selections,
|
|
297
|
-
fellBackToAll: false,
|
|
298
|
-
narrowed: selectedRunnable.length < runnableConsidered.length,
|
|
299
|
-
changedSourceFiles,
|
|
300
|
-
changedTestFiles,
|
|
301
|
-
consideredTestFiles: considered,
|
|
302
|
-
supportFiles,
|
|
303
|
-
importEvidenceUsed,
|
|
304
|
-
};
|
|
74
|
+
options: import('pi-helper-core').SelectionOptions = {},
|
|
75
|
+
): import('pi-helper-core').SelectionResult {
|
|
76
|
+
return coreSelectTests(changedPaths, testFiles, PYTHON_SIGNALS, options);
|
|
305
77
|
}
|
package/src/build/staleness.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Python's staleness spec over the shared `detectStaleArtifacts` walker.
|
|
3
|
+
*
|
|
4
|
+
* Python invalidates bytecode automatically and pytest installs nothing, so a
|
|
5
|
+
* stale coverage report is the one artifact that can make a passing run
|
|
6
|
+
* describe the wrong code. The walk and mtime comparison live in
|
|
7
|
+
* `pi-helper-core`; this module names the sources and artifacts.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
detectStaleArtifacts as coreDetectStaleArtifacts,
|
|
11
|
+
type StaleArtifact,
|
|
12
|
+
type StalenessSpec,
|
|
13
|
+
} from 'pi-helper-core';
|
|
3
14
|
|
|
4
|
-
export
|
|
5
|
-
code: 'STALE_COVERAGE_DATA';
|
|
6
|
-
message: string;
|
|
7
|
-
path: string;
|
|
8
|
-
artifactMtimeMs?: number;
|
|
9
|
-
newestSource?: { path: string; mtimeMs: number };
|
|
10
|
-
}
|
|
15
|
+
export type { StaleArtifact };
|
|
11
16
|
|
|
12
17
|
export interface PythonStalenessReport {
|
|
13
18
|
stale: boolean;
|
|
@@ -16,102 +21,21 @@ export interface PythonStalenessReport {
|
|
|
16
21
|
incompleteReason?: string;
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
const
|
|
20
|
-
'.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
'
|
|
26
|
-
|
|
27
|
-
'.
|
|
28
|
-
|
|
29
|
-
'node_modules',
|
|
30
|
-
'build',
|
|
31
|
-
'dist',
|
|
32
|
-
'.eggs',
|
|
33
|
-
]);
|
|
34
|
-
|
|
35
|
-
const MAX_WALKED_FILES = 5000;
|
|
36
|
-
|
|
37
|
-
async function mtimeMs(path: string): Promise<number | undefined> {
|
|
38
|
-
try {
|
|
39
|
-
return (await stat(path)).mtimeMs;
|
|
40
|
-
} catch {
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function newestPythonSource(
|
|
46
|
-
root: string,
|
|
47
|
-
): Promise<{ path: string; mtimeMs: number } | undefined> {
|
|
48
|
-
let newest: { path: string; mtimeMs: number } | undefined;
|
|
49
|
-
let visited = 0;
|
|
50
|
-
const stack = [root];
|
|
51
|
-
while (stack.length > 0) {
|
|
52
|
-
const directory = stack.pop() as string;
|
|
53
|
-
let entries;
|
|
54
|
-
try {
|
|
55
|
-
entries = await readdir(directory, { withFileTypes: true });
|
|
56
|
-
} catch {
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
for (const entry of entries) {
|
|
60
|
-
if (visited > MAX_WALKED_FILES) return newest;
|
|
61
|
-
const path = join(directory, entry.name);
|
|
62
|
-
if (entry.isDirectory()) {
|
|
63
|
-
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
64
|
-
stack.push(path);
|
|
65
|
-
} else if (entry.isFile() && entry.name.endsWith('.py')) {
|
|
66
|
-
visited += 1;
|
|
67
|
-
const modified = await mtimeMs(path);
|
|
68
|
-
if (modified === undefined) continue;
|
|
69
|
-
if (!newest || modified > newest.mtimeMs) newest = { path, mtimeMs: modified };
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return newest;
|
|
74
|
-
}
|
|
24
|
+
const PYTHON_SPEC: StalenessSpec = {
|
|
25
|
+
sourceExtensions: ['.py'],
|
|
26
|
+
artifacts: [
|
|
27
|
+
{ name: '.coverage', code: 'STALE_COVERAGE_DATA', describe: 'coverage results' },
|
|
28
|
+
{ name: 'coverage.xml', code: 'STALE_COVERAGE_DATA', describe: 'coverage results' },
|
|
29
|
+
],
|
|
30
|
+
// `.venv`/`venv` are not in the core's universal ignore list because they are
|
|
31
|
+
// Python-specific; caches and build trees already are.
|
|
32
|
+
ignoredDirectories: new Set(['.venv', 'venv']),
|
|
33
|
+
};
|
|
75
34
|
|
|
76
|
-
/**
|
|
77
|
-
* Detect a coverage report that predates the sources it claims to describe.
|
|
78
|
-
*
|
|
79
|
-
* Python invalidates bytecode automatically and pytest installs nothing, so a
|
|
80
|
-
* stale report is the one artifact that can make a passing run describe the
|
|
81
|
-
* wrong code. Whether the project itself is installed as a stale copy is a
|
|
82
|
-
* structural question and is answered by the environment conformance check
|
|
83
|
-
* rather than by comparing mtimes here.
|
|
84
|
-
*/
|
|
85
35
|
export async function detectStaleArtifacts(root: string): Promise<PythonStalenessReport> {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
newestSource = await newestPythonSource(root);
|
|
90
|
-
} catch (error) {
|
|
91
|
-
return {
|
|
92
|
-
stale: false,
|
|
93
|
-
artifacts,
|
|
94
|
-
incompleteReason: `Source scan failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
if (!newestSource) {
|
|
98
|
-
return { stale: false, artifacts, incompleteReason: 'No Python source files were found.' };
|
|
36
|
+
const report = await coreDetectStaleArtifacts(root, PYTHON_SPEC);
|
|
37
|
+
if (report.incompleteReason?.startsWith('No source file')) {
|
|
38
|
+
return { ...report, incompleteReason: 'No Python source files were found.' };
|
|
99
39
|
}
|
|
100
|
-
|
|
101
|
-
for (const name of ['.coverage', 'coverage.xml']) {
|
|
102
|
-
const path = join(root, name);
|
|
103
|
-
const modified = await mtimeMs(path);
|
|
104
|
-
if (modified === undefined) continue;
|
|
105
|
-
if (modified < newestSource.mtimeMs) {
|
|
106
|
-
artifacts.push({
|
|
107
|
-
code: 'STALE_COVERAGE_DATA',
|
|
108
|
-
message: `${name} was written before ${newestSource.path} changed; coverage results do not describe the current sources.`,
|
|
109
|
-
path,
|
|
110
|
-
artifactMtimeMs: modified,
|
|
111
|
-
newestSource,
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return { stale: artifacts.length > 0, artifacts };
|
|
40
|
+
return report;
|
|
117
41
|
}
|