pi-python-helper 0.2.0 → 0.4.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.
@@ -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
+ }
@@ -1,75 +1,26 @@
1
- import { basename, dirname, join } from 'node:path';
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 interface TestSelection {
12
- path: string;
13
- score: number;
14
- reason: string;
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
- /** 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
- }
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
- const changed = changedPaths.map(toPosix).filter(isPythonFile);
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
  }
@@ -1,13 +1,18 @@
1
- import { readdir, stat } from 'node:fs/promises';
2
- import { join } from 'node:path';
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 interface StaleArtifact {
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 IGNORED_DIRECTORIES = new Set([
20
- '.git',
21
- '.venv',
22
- 'venv',
23
- '.tox',
24
- '.nox',
25
- '__pycache__',
26
- '.mypy_cache',
27
- '.ruff_cache',
28
- '.pytest_cache',
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 artifacts: StaleArtifact[] = [];
87
- let newestSource: { path: string; mtimeMs: number } | undefined;
88
- try {
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
  }