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.
@@ -50,6 +50,12 @@ export interface ConformanceReport {
50
50
  installedScanned: boolean;
51
51
  projectInstalled: boolean | null;
52
52
  projectEditable: boolean | null;
53
+ /**
54
+ * False when the root project is a `virtual` lock source, which uv creates
55
+ * for a project without a `[build-system]` table. Such a project is never
56
+ * installed into `.venv`, so its absence is expected rather than drift.
57
+ */
58
+ projectInstallable: boolean | null;
53
59
  };
54
60
  counts: {
55
61
  lockPackages: number;
@@ -59,6 +65,11 @@ export interface ConformanceReport {
59
65
  /** Locked entries that are conditional for this platform and correctly absent. */
60
66
  conditional: number;
61
67
  untracked: number;
68
+ /**
69
+ * Normalized names that uv.lock pins more than once (a marker split, such as
70
+ * `argon2-cffi-bindings` at 21.2.0 for Python 3.14 and 25.1.0 below it).
71
+ */
72
+ markerSplitNames: number;
62
73
  };
63
74
  findings: ConformanceFinding[];
64
75
  warnings: Diagnostic[];
@@ -147,6 +158,7 @@ export function compareInstalledConformance(input: ConformanceInput): Conformanc
147
158
  installedScanned: venvPresent,
148
159
  projectInstalled: null as boolean | null,
149
160
  projectEditable: null as boolean | null,
161
+ projectInstallable: null as boolean | null,
150
162
  };
151
163
  const counts = {
152
164
  lockPackages: lock?.packages.length ?? 0,
@@ -155,6 +167,7 @@ export function compareInstalledConformance(input: ConformanceInput): Conformanc
155
167
  missing: 0,
156
168
  conditional: 0,
157
169
  untracked: 0,
170
+ markerSplitNames: 0,
158
171
  };
159
172
 
160
173
  for (const message of installed?.warnings ?? []) {
@@ -189,51 +202,83 @@ export function compareInstalledConformance(input: ConformanceInput): Conformanc
189
202
  const lockByNormalized = new Map(lock.packages.map((entry) => [entry.normalized, entry]));
190
203
  const required = requiredInstalledNames(lock, new Set(installedByNormalized.keys()), input);
191
204
  const conditionalAbsent: string[] = [];
205
+ const markerSplitNames: string[] = [];
206
+ const virtualRoots: string[] = [];
192
207
 
208
+ // uv writes one lock entry per marker branch, so a name can appear several
209
+ // times with different versions. Comparing a single arbitrary entry reported a
210
+ // version mismatch on a correctly synced environment (21.2.0 is locked for
211
+ // Python 3.14 while a 3.12 environment rightly has 25.1.0).
212
+ const variantsByNormalized = new Map<string, LockPackage[]>();
193
213
  for (const entry of lock.packages) {
194
- const actual = installedByNormalized.get(entry.normalized);
195
- const localProject = isLocalProjectEntry(entry);
196
- const rootProject = isRootProjectEntry(entry, projectName);
214
+ const variants = variantsByNormalized.get(entry.normalized);
215
+ if (variants) variants.push(entry);
216
+ else variantsByNormalized.set(entry.normalized, [entry]);
217
+ }
218
+
219
+ for (const [normalized, variants] of variantsByNormalized) {
220
+ const primary = variants[0];
221
+ const actual = installedByNormalized.get(normalized);
222
+ const localProject = variants.some((variant) => isLocalProjectEntry(variant));
223
+ const rootProject = variants.some((variant) => isRootProjectEntry(variant, projectName));
224
+ if (variants.length > 1) {
225
+ counts.markerSplitNames += 1;
226
+ markerSplitNames.push(primary.name);
227
+ }
197
228
 
198
229
  if (!actual) {
199
230
  if (localProject) {
231
+ if (rootProject && variants.some((variant) => variant.source === 'virtual')) {
232
+ // Without a [build-system] uv records the project as a virtual source
233
+ // and never installs it; its absence from .venv is not drift.
234
+ checks.projectInstalled = false;
235
+ checks.projectInstallable = false;
236
+ virtualRoots.push(primary.name);
237
+ continue;
238
+ }
200
239
  counts.missing += 1;
201
240
  if (rootProject) checks.projectInstalled = false;
202
241
  findings.push({
203
242
  code: 'PROJECT_NOT_INSTALLED',
204
- name: entry.name,
205
- expected: entry.version ?? undefined,
243
+ name: primary.name,
244
+ expected: primary.version ?? undefined,
206
245
  message: rootProject
207
- ? `uv.lock records "${entry.name}" as an editable install, but it is absent from .venv. The project is not importable and no test can exercise it.`
208
- : `uv.lock records the local project "${entry.name}" as an editable install, but it is absent from .venv.`,
246
+ ? `uv.lock records "${primary.name}" as an editable install, but it is absent from .venv. The project is not importable and no test can exercise it.`
247
+ : `uv.lock records the local project "${primary.name}" as an editable install, but it is absent from .venv.`,
209
248
  });
210
- } else if (required.has(entry.normalized)) {
249
+ } else if (required.has(normalized)) {
211
250
  counts.missing += 1;
212
251
  findings.push({
213
252
  code: 'INSTALLED_PACKAGE_MISSING',
214
- name: entry.name,
215
- expected: entry.version ?? undefined,
216
- message: `"${entry.name}" is locked and required unconditionally, but it is not installed in .venv.`,
253
+ name: primary.name,
254
+ expected: primary.version ?? undefined,
255
+ message: `"${primary.name}" is locked and required unconditionally, but it is not installed in .venv.`,
217
256
  });
218
257
  } else {
219
258
  // Guarded by a platform or version marker, so this platform rightly omits it.
220
- counts.conditional += 1;
221
- conditionalAbsent.push(`${entry.name}@${entry.version ?? '?'}`);
259
+ counts.conditional += variants.length;
260
+ for (const variant of variants) {
261
+ const label = `${primary.name}@${variant.version ?? '?'}`;
262
+ if (!conditionalAbsent.includes(label)) conditionalAbsent.push(label);
263
+ }
222
264
  }
223
265
  continue;
224
266
  }
225
267
 
226
268
  if (localProject) {
227
- if (rootProject) checks.projectInstalled = true;
269
+ if (rootProject) {
270
+ checks.projectInstalled = true;
271
+ checks.projectInstallable = true;
272
+ }
228
273
  if (actual.source !== 'editable') {
229
274
  if (rootProject) checks.projectEditable = false;
230
275
  findings.push({
231
276
  code: 'PROJECT_INSTALLED_NOT_EDITABLE',
232
- name: entry.name,
277
+ name: primary.name,
233
278
  actual: actual.version,
234
279
  message: rootProject
235
- ? `"${entry.name}" is installed from a materialised copy instead of an editable link, so tests would import a stale snapshot of the sources.`
236
- : `The local project "${entry.name}" is installed from a materialised copy instead of an editable link.`,
280
+ ? `"${primary.name}" is installed from a materialised copy instead of an editable link, so tests would import a stale snapshot of the sources.`
281
+ : `The local project "${primary.name}" is installed from a materialised copy instead of an editable link.`,
237
282
  });
238
283
  } else if (rootProject) {
239
284
  checks.projectEditable = true;
@@ -243,16 +288,31 @@ export function compareInstalledConformance(input: ConformanceInput): Conformanc
243
288
  continue;
244
289
  }
245
290
 
246
- if (entry.version && actual.version !== entry.version) {
247
- counts.mismatched += 1;
248
- findings.push({
249
- code: 'INSTALLED_VERSION_MISMATCH',
250
- name: entry.name,
251
- expected: entry.version,
252
- actual: actual.version,
253
- message: `"${entry.name}" is locked at ${entry.version} but ${actual.version} is installed in .venv.`,
291
+ // Matching any locked variant is positive proof the environment agrees with
292
+ // the lockfile for the markers that apply here.
293
+ if (variants.some((variant) => variant.version === actual.version)) continue;
294
+
295
+ const versions = variants.map((variant) => variant.version).filter((value) => value !== null);
296
+ if (versions.length === 0) {
297
+ notes.push({
298
+ code: 'LOCKED_VERSION_UNKNOWN',
299
+ message: `uv.lock records "${primary.name}" without a version, so the installed ${actual.version} could not be compared.`,
300
+ severity: 'info',
254
301
  });
302
+ continue;
255
303
  }
304
+
305
+ counts.mismatched += 1;
306
+ findings.push({
307
+ code: 'INSTALLED_VERSION_MISMATCH',
308
+ name: primary.name,
309
+ expected: versions.join(' | '),
310
+ actual: actual.version,
311
+ message:
312
+ versions.length > 1
313
+ ? `"${primary.name}" is locked at ${versions.join(' or ')} (marker-dependent) but ${actual.version} is installed in .venv, which matches none of them.`
314
+ : `"${primary.name}" is locked at ${versions[0]} but ${actual.version} is installed in .venv.`,
315
+ });
256
316
  }
257
317
 
258
318
  const untrackedCandidates = (installed?.distributions ?? []).filter(
@@ -284,6 +344,22 @@ export function compareInstalledConformance(input: ConformanceInput): Conformanc
284
344
  }
285
345
  }
286
346
 
347
+ if (markerSplitNames.length > 0) {
348
+ notes.push({
349
+ code: 'MARKER_SPLIT_LOCK_ENTRIES',
350
+ message: `${markerSplitNames.length} distribution(s) are locked more than once because uv splits them by marker (${markerSplitNames.slice(0, 5).join(', ')}${markerSplitNames.length > 5 ? ', …' : ''}); the installed version is compared against every variant and matches when it equals any one of them.`,
351
+ severity: 'info',
352
+ });
353
+ }
354
+
355
+ if (virtualRoots.length > 0) {
356
+ notes.push({
357
+ code: 'PROJECT_VIRTUAL_SOURCE',
358
+ message: `uv.lock records "${virtualRoots[0]}" with source = { virtual = "." }, which uv writes for a project without a [build-system] table. It is intentionally never installed into .venv, and imports still resolve from the working directory.`,
359
+ severity: 'info',
360
+ });
361
+ }
362
+
287
363
  if (conditionalAbsent.length > 0) {
288
364
  notes.push({
289
365
  code: 'CONDITIONAL_PACKAGES_ABSENT',
@@ -5,7 +5,8 @@ import {
5
5
  type ConformanceReport,
6
6
  } from './conformance.ts';
7
7
  import type { InstalledEnvironment } from './installed.ts';
8
- import type { ScanPayload } from './scanner.ts';
8
+ import type { PytestConfiguration } from './pytest-config.ts';
9
+ import type { LockComparison, LockSection, ManifestSection, ScanPayload } from './scanner.ts';
9
10
 
10
11
  export interface ProjectInspection {
11
12
  root: string;
@@ -44,64 +45,67 @@ export interface InspectInput {
44
45
  /** `undefined` when no .gitignore exists, so the check stays honest. */
45
46
  venvIgnored?: boolean;
46
47
  hasTestsDirectory: boolean;
48
+ /** Test directories found relative to the root; names them in the diagnostic. */
49
+ testDirectories?: string[];
50
+ /**
51
+ * Where pytest configuration was found. The scanner only reads
52
+ * `pyproject.toml`, so INI files are decided by the caller.
53
+ */
54
+ pytestConfiguration?: PytestConfiguration;
47
55
  /** Distributions read from `.venv`; omit to skip the conformance comparison. */
48
56
  installed?: InstalledEnvironment;
49
57
  }
50
58
 
51
- /**
52
- * Turn a scanner payload into a project model plus diagnostics. Pure so the
53
- * whole diagnostic surface is unit-testable without touching a filesystem.
54
- */
55
- export function inspectProject(input: InspectInput): ProjectInspection {
56
- const { payload, venvDir, venvIgnored, hasTestsDirectory, installed } = input;
57
- const manifest = payload.manifest;
58
- const lock = payload.lock;
59
- const comparison = payload.lockComparison;
60
- const warnings: Diagnostic[] = [];
61
- const notes: Diagnostic[] = [];
62
- const suggestions: Suggestion[] = [];
59
+ interface DiagnosticCollector {
60
+ warnings: Diagnostic[];
61
+ notes: Diagnostic[];
62
+ suggestions: Suggestion[];
63
+ }
63
64
 
64
- const root = payload.root;
65
+ function collectManifestDiagnostics(
66
+ manifest: ManifestSection | null | undefined,
67
+ root: string,
68
+ collector: DiagnosticCollector,
69
+ ): void {
65
70
  if (manifest?.tomlError) {
66
- warnings.push(
71
+ collector.warnings.push(
67
72
  warn('TOML_PARSE_ERROR', manifest.tomlError, manifest.pyprojectPath ?? undefined),
68
73
  );
69
74
  }
70
75
  for (const message of manifest?.warnings ?? []) {
71
- warnings.push(warn('MANIFEST_WARNING', message, manifest?.pyprojectPath ?? undefined));
72
- }
73
- for (const message of lock?.warnings ?? []) {
74
- notes.push({ code: 'LOCKFILE_NOTE', message, severity: 'info' });
76
+ collector.warnings.push(
77
+ warn('MANIFEST_WARNING', message, manifest?.pyprojectPath ?? undefined),
78
+ );
75
79
  }
76
80
 
77
81
  if (!manifest?.pyprojectPath) {
78
- warnings.push(
82
+ collector.warnings.push(
79
83
  warn(
80
84
  'PYPROJECT_MISSING',
81
85
  'pyproject.toml was not found; dependencies, layout, and tool configuration cannot be verified.',
82
86
  ),
83
87
  );
84
- suggestions.push({
88
+ collector.suggestions.push({
85
89
  message: 'Run uv init to create a pyproject.toml, then uv add the runtime dependencies.',
86
90
  confidence: 'high',
87
91
  command: 'uv init',
88
92
  });
89
93
  } else if (!manifest.name) {
90
- warnings.push(
94
+ collector.warnings.push(
91
95
  warn(
92
96
  'PROJECT_NAME_MISSING',
93
97
  'pyproject.toml has no [project] name, so the installed distribution name is unknown.',
94
98
  manifest.pyprojectPath ?? undefined,
95
99
  ),
96
100
  );
97
- suggestions.push({
101
+ collector.suggestions.push({
98
102
  message: 'Add a [project] table with name and version to pyproject.toml.',
99
103
  confidence: 'high',
100
104
  });
101
105
  }
102
106
 
103
107
  if (manifest?.legacySetupPy || manifest?.legacySetupCfg) {
104
- warnings.push(
108
+ collector.warnings.push(
105
109
  warn(
106
110
  'LEGACY_PACKAGING',
107
111
  `The project still uses ${[
@@ -113,14 +117,14 @@ export function inspectProject(input: InspectInput): ProjectInspection {
113
117
  root,
114
118
  ),
115
119
  );
116
- suggestions.push({
120
+ collector.suggestions.push({
117
121
  message: 'Move dependency metadata from setup.py/setup.cfg into [project] in pyproject.toml.',
118
122
  confidence: 'medium',
119
123
  });
120
124
  }
121
125
 
122
126
  if (manifest?.requirementsFiles.length && manifest.pyprojectPath) {
123
- warnings.push(
127
+ collector.warnings.push(
124
128
  warn(
125
129
  'DUPLICATE_DEPENDENCY_SOURCE',
126
130
  `${manifest.requirementsFiles.join(', ')} also declares dependencies; uv resolves from pyproject.toml and uv.lock only.`,
@@ -129,14 +133,31 @@ export function inspectProject(input: InspectInput): ProjectInspection {
129
133
  );
130
134
  }
131
135
 
136
+ if (manifest?.legacySetupPy && manifest.buildBackend === null && !manifest.pyprojectPath) {
137
+ collector.suggestions.push({
138
+ message: 'uv manages dependencies from pyproject.toml; migrate before running uv sync.',
139
+ confidence: 'medium',
140
+ });
141
+ }
142
+ }
143
+
144
+ function collectLockDiagnostics(
145
+ lock: LockSection | null | undefined,
146
+ comparison: LockComparison | null | undefined,
147
+ collector: DiagnosticCollector,
148
+ ): void {
149
+ for (const message of lock?.warnings ?? []) {
150
+ collector.notes.push({ code: 'LOCKFILE_NOTE', message, severity: 'info' });
151
+ }
152
+
132
153
  if (!lock?.present) {
133
- notes.push({
154
+ collector.notes.push({
134
155
  code: 'LOCKFILE_MISSING',
135
156
  message:
136
157
  'uv.lock was not found, so dependency drift and exact resolved versions cannot be verified.',
137
158
  severity: 'info',
138
159
  });
139
- suggestions.push({
160
+ collector.suggestions.push({
140
161
  message: 'Run uv lock to record resolved versions in uv.lock.',
141
162
  confidence: 'high',
142
163
  command: 'uv lock',
@@ -144,14 +165,14 @@ export function inspectProject(input: InspectInput): ProjectInspection {
144
165
  }
145
166
 
146
167
  if (comparison?.requiresPythonMismatch) {
147
- warnings.push(
168
+ collector.warnings.push(
148
169
  warn(
149
170
  'REQUIRES_PYTHON_MISMATCH',
150
171
  `pyproject.toml requires-python is "${comparison.requiresPythonMismatch.manifest}" but uv.lock records "${comparison.requiresPythonMismatch.lock}".`,
151
172
  lock?.path ?? undefined,
152
173
  ),
153
174
  );
154
- suggestions.push({
175
+ collector.suggestions.push({
155
176
  message: 'Run uv lock so the lockfile reflects the current requires-python constraint.',
156
177
  confidence: 'high',
157
178
  command: 'uv lock',
@@ -159,7 +180,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
159
180
  }
160
181
 
161
182
  for (const name of comparison?.missingFromLock ?? []) {
162
- warnings.push(
183
+ collector.warnings.push(
163
184
  warn(
164
185
  'LOCKFILE_MISSING_DEPENDENCY',
165
186
  `"${name}" is declared in pyproject.toml but absent from uv.lock.`,
@@ -168,7 +189,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
168
189
  );
169
190
  }
170
191
  if (comparison?.missingFromLock.length) {
171
- suggestions.push({
192
+ collector.suggestions.push({
172
193
  message: 'Run uv lock to add the missing declarations to the lockfile.',
173
194
  confidence: 'high',
174
195
  command: 'uv lock',
@@ -176,7 +197,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
176
197
  }
177
198
 
178
199
  for (const entry of comparison?.unsatisfiedInLock ?? []) {
179
- warnings.push(
200
+ collector.warnings.push(
180
201
  warn(
181
202
  'LOCKFILE_UNSATISFIED_DEPENDENCY',
182
203
  `"${entry.name}" is locked at ${entry.locked} which does not satisfy "${entry.specifier}".`,
@@ -186,59 +207,82 @@ export function inspectProject(input: InspectInput): ProjectInspection {
186
207
  }
187
208
 
188
209
  if (lock?.present && comparison && !comparison.specifierCheckAvailable) {
189
- notes.push({
210
+ collector.notes.push({
190
211
  code: 'SPECIFIER_CHECK_UNAVAILABLE',
191
212
  message:
192
213
  'The packaging library was unavailable, so only declared-versus-locked names were compared, not version constraints.',
193
214
  severity: 'info',
194
215
  });
195
- suggestions.push({
216
+ collector.suggestions.push({
196
217
  message:
197
218
  'Install the packaging library in the analysing interpreter to compare declared version constraints against uv.lock.',
198
219
  confidence: 'medium',
199
220
  command: 'python3 -m pip install packaging',
200
221
  });
201
222
  }
223
+ }
202
224
 
225
+ function collectEnvironmentDiagnostics(
226
+ root: string,
227
+ manifest: ManifestSection | null | undefined,
228
+ venvDir: string | undefined,
229
+ venvIgnored: boolean | undefined,
230
+ hasTestsDirectory: boolean,
231
+ testDirectories: string[] | undefined,
232
+ pytestConfiguration: PytestConfiguration | undefined,
233
+ collector: DiagnosticCollector,
234
+ ): void {
203
235
  if (!venvDir) {
204
- notes.push({
236
+ collector.notes.push({
205
237
  code: 'VENV_MISSING',
206
238
  message: 'No .venv directory exists at the project root; run uv sync before running tests.',
207
239
  severity: 'info',
208
240
  });
209
241
  } else if (venvIgnored === false) {
210
- warnings.push(
242
+ collector.warnings.push(
211
243
  warn(
212
244
  'VENV_NOT_IGNORED',
213
245
  '.venv exists but is not listed in .gitignore.',
214
246
  `${root}/.gitignore`,
215
247
  ),
216
248
  );
217
- suggestions.push({
249
+ collector.suggestions.push({
218
250
  message: 'Add .venv/ to .gitignore so the environment is never committed.',
219
251
  confidence: 'high',
220
252
  });
221
253
  }
222
254
 
223
255
  if (!hasTestsDirectory) {
224
- notes.push({
256
+ collector.notes.push({
225
257
  code: 'TESTS_DIRECTORY_MISSING',
226
258
  message:
227
- 'No tests/ directory was found; test selection and TDD gates cannot match changed sources.',
259
+ 'No tests directory was found in the project root or inside a top-level package, so test selection and TDD gates cannot match changed sources.',
260
+ severity: 'info',
261
+ });
262
+ } else if (testDirectories && testDirectories.length > 0) {
263
+ collector.notes.push({
264
+ code: 'TESTS_DIRECTORY_FOUND',
265
+ message: `Tests live in ${testDirectories.slice(0, 4).join(', ')}${testDirectories.length > 4 ? `, … (+${testDirectories.length - 4})` : ''}. Confirm testpaths covers them when running pytest without a target.`,
228
266
  severity: 'info',
229
267
  });
230
268
  }
231
269
 
232
- if (manifest?.pyprojectPath && manifest.toolConfiguration && !manifest.toolConfiguration.pytest) {
233
- notes.push({
270
+ // pytest can be configured from pytest.ini, tox.ini, or setup.cfg, not only
271
+ // from pyproject.toml; checking one file reported "not configured" for most
272
+ // projects that use pytest's own configuration file.
273
+ const pytestConfigured =
274
+ pytestConfiguration?.configured ?? manifest?.toolConfiguration?.pytest === true;
275
+ if (manifest?.pyprojectPath && !pytestConfigured) {
276
+ collector.notes.push({
234
277
  code: 'PYTEST_NOT_CONFIGURED',
235
- message: 'pyproject.toml has no [tool.pytest.ini_options] table.',
278
+ message:
279
+ 'No pytest configuration was found in pyproject.toml, pytest.ini, tox.ini, or setup.cfg.',
236
280
  severity: 'info',
237
281
  });
238
282
  }
239
283
 
240
284
  if (manifest?.layout === 'src' && manifest.modules.length === 0) {
241
- warnings.push(
285
+ collector.warnings.push(
242
286
  warn(
243
287
  'EMPTY_SRC_LAYOUT',
244
288
  'The src/ directory exists but contains no importable module directories or modules.',
@@ -247,20 +291,49 @@ export function inspectProject(input: InspectInput): ProjectInspection {
247
291
  );
248
292
  }
249
293
 
250
- if (manifest?.legacySetupPy && manifest.buildBackend === null && !manifest.pyprojectPath) {
251
- suggestions.push({
252
- message: 'uv manages dependencies from pyproject.toml; migrate before running uv sync.',
253
- confidence: 'medium',
254
- });
255
- }
256
-
257
294
  if (manifest?.uvWorkspaceMembers.length) {
258
- notes.push({
295
+ collector.notes.push({
259
296
  code: 'UV_WORKSPACE',
260
297
  message: `This is a uv workspace with ${manifest.uvWorkspaceMembers.length} member(s); scope build and test tools per member.`,
261
298
  severity: 'info',
262
299
  });
263
300
  }
301
+ }
302
+
303
+ /**
304
+ * Turn a scanner payload into a project model plus diagnostics. Pure so the
305
+ * whole diagnostic surface is unit-testable without touching a filesystem.
306
+ */
307
+ export function inspectProject(input: InspectInput): ProjectInspection {
308
+ const {
309
+ payload,
310
+ venvDir,
311
+ venvIgnored,
312
+ hasTestsDirectory,
313
+ testDirectories,
314
+ pytestConfiguration,
315
+ installed,
316
+ } = input;
317
+ const manifest = payload.manifest;
318
+ const lock = payload.lock;
319
+ const comparison = payload.lockComparison;
320
+ const root = payload.root;
321
+
322
+ const collector: DiagnosticCollector = { warnings: [], notes: [], suggestions: [] };
323
+ collectManifestDiagnostics(manifest, root, collector);
324
+ collectLockDiagnostics(lock, comparison, collector);
325
+ collectEnvironmentDiagnostics(
326
+ root,
327
+ manifest,
328
+ venvDir,
329
+ venvIgnored,
330
+ hasTestsDirectory,
331
+ testDirectories,
332
+ pytestConfiguration,
333
+ collector,
334
+ );
335
+
336
+ const { warnings, notes, suggestions } = collector;
264
337
 
265
338
  const conformance =
266
339
  installed !== undefined
@@ -277,7 +350,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
277
350
  if (conformance.findings.some((finding) => finding.code === 'PROJECT_NOT_INSTALLED')) {
278
351
  suggestions.push({
279
352
  message:
280
- 'The project is not installed in .venv. Run uv sync; if that fails, the build backend could not find the package (check that the module directory name matches [project] name).',
353
+ 'The project is recorded as an editable install but is missing from .venv. Run uv sync; if that fails, the build backend could not find the package (check that the module directory name matches [project] name).',
281
354
  confidence: 'high',
282
355
  command: 'uv sync',
283
356
  });
@@ -21,6 +21,21 @@ export function isTestFile(path: string): boolean {
21
21
  return TEST_DIRECTORY.test(posix) || TEST_FILENAME.test(posix);
22
22
  }
23
23
 
24
+ const RUNNABLE_TEST_FILENAME = /^test_.*\.[a-z]+$/i;
25
+ const RUNNABLE_TEST_SUFFIX = /_test\.[a-z]+$/i;
26
+
27
+ /**
28
+ * A file pytest actually collects tests from.
29
+ *
30
+ * Living under `tests/` is not enough: `tests/__init__.py`, `tests/conftest.py`,
31
+ * and `tests/utils.py` are test *infrastructure*, and naming them as pytest
32
+ * targets overstates what the selection covers.
33
+ */
34
+ export function isRunnableTestFile(path: string): boolean {
35
+ const name = basename(toPosix(path));
36
+ return RUNNABLE_TEST_FILENAME.test(name) || RUNNABLE_TEST_SUFFIX.test(name);
37
+ }
38
+
24
39
  export function isSourceFile(path: string): boolean {
25
40
  return isPythonFile(path) && !isTestFile(path);
26
41
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Where pytest configuration can live. `pyproject.toml` is read by the scanner
3
+ * (tomllib) and passed in already-decided; the INI files have to be inspected
4
+ * here because only `pytest.ini` is unambiguously a pytest file.
5
+ */
6
+ export interface PytestConfiguration {
7
+ configured: boolean;
8
+ /** Files that define pytest configuration, in the order they were checked. */
9
+ sources: string[];
10
+ }
11
+
12
+ export interface PytestConfigurationInput {
13
+ /** True when pyproject.toml has a `[tool.pytest.ini_options]` table. */
14
+ pyprojectConfigured: boolean;
15
+ /**
16
+ * Raw contents of `pytest.ini`, `tox.ini`, and `setup.cfg`, keyed by file
17
+ * name. A missing key means the file does not exist.
18
+ */
19
+ iniFiles: Record<string, string | undefined>;
20
+ }
21
+
22
+ /** `[pytest]` (pytest.ini) and `[tool:pytest]` (setup.cfg / tox.ini). */
23
+ const PYTEST_SECTION = /^\s*\[(?:tool:)?pytest\]\s*$/m;
24
+
25
+ /**
26
+ * Deciding whether pytest is configured used to look at `pyproject.toml`
27
+ * alone, which reports "not configured" for every project that keeps
28
+ * `pytest.ini` — the file pytest itself recommends.
29
+ *
30
+ * `pytest.ini` counts on existence because it has no other purpose. `tox.ini`
31
+ * and `setup.cfg` are shared files, so they only count when they actually carry
32
+ * a pytest section.
33
+ */
34
+ export function detectPytestConfiguration(input: PytestConfigurationInput): PytestConfiguration {
35
+ const sources: string[] = [];
36
+ if (input.pyprojectConfigured) sources.push('pyproject.toml');
37
+
38
+ for (const name of ['pytest.ini', 'tox.ini', 'setup.cfg']) {
39
+ const content = input.iniFiles[name];
40
+ if (content === undefined) continue;
41
+ if (name === 'pytest.ini' || PYTEST_SECTION.test(content)) sources.push(name);
42
+ }
43
+
44
+ return { configured: sources.length > 0, sources };
45
+ }