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.
@@ -2,6 +2,12 @@ import { Type } from 'typebox';
2
2
  import { failure, result, warn } from '../../src/core/result.ts';
3
3
  import { runCommand } from '../../src/core/runner.ts';
4
4
  import { pytestCommand, uvLockCheck, uvSyncFrozen } from '../../src/build/commands.ts';
5
+ import { describeRemovals, parseSyncOutput } from '../../src/build/sync.ts';
6
+ import {
7
+ qualityCommands,
8
+ selectQualityRunners,
9
+ type QualityRunner,
10
+ } from '../../src/build/quality.ts';
5
11
  import { diagnoseFailure } from '../../src/build/failure.ts';
6
12
  import { parsePytestOutput } from '../../src/build/pytest.ts';
7
13
  import { detectStaleArtifacts } from '../../src/build/staleness.ts';
@@ -10,18 +16,29 @@ import {
10
16
  requiredDeclarationsFrom,
11
17
  } from '../../src/project/conformance.ts';
12
18
  import { readInstalledDistributions } from '../../src/project/installed.ts';
19
+ import { buildDeclaredIndex } from '../../src/dependencies/plan.ts';
20
+ import { checkRequiredTools } from '../../src/environment/tools.ts';
13
21
  import { summarizeValidation, type ValidationStep } from '../../src/validation/bundle.ts';
14
22
  import { buildCompletionEvidence } from '../../src/validation/evidence.ts';
15
23
  import { checkTdd } from '../../src/validation/tdd.ts';
16
24
  import { join } from 'node:path';
17
25
  import { isFile } from '../../src/project/root.ts';
18
26
  import { runScanProject } from '../../src/project/scanner.ts';
19
- import { messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
27
+ import { hasDirectory, messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
20
28
 
21
29
  function stepFrom(run: { code: number | null; timedOut: boolean }): ValidationStep {
22
30
  return { executed: true, ok: run.code === 0 && !run.timedOut, exitCode: run.code };
23
31
  }
24
32
 
33
+ function skippedStep(name: string, reason: string): ValidationStep {
34
+ return { name, executed: false, ok: false, exitCode: null, skippedReason: reason };
35
+ }
36
+
37
+ /** A lock/quality step that the caller disabled rather than one that failed. */
38
+ function disabledStep(name: string, reason: string): ValidationStep {
39
+ return { name, executed: false, ok: true, exitCode: null, skippedReason: reason };
40
+ }
41
+
25
42
  export function registerValidationTools(pi: Pi): void {
26
43
  pi.registerTool({
27
44
  name: 'py_sync',
@@ -31,11 +48,19 @@ export function registerValidationTools(pi: Pi): void {
31
48
  promptSnippet: 'Preview or run the uv environment sync',
32
49
  promptGuidelines: [
33
50
  'Use py_sync with execute=false to preview the uv command, and execute=true only when the environment must be created or refreshed.',
51
+ 'Use py_sync after changing pyproject.toml or uv.lock; a sync that removed packages is reported because later steps cannot run without them.',
34
52
  ],
35
53
  parameters: Type.Object({
36
54
  mode: Type.Optional(
37
55
  Type.Union([Type.Literal('check'), Type.Literal('sync')], {
38
- description: 'check runs uv lock --check; sync runs uv sync --frozen --all-groups.',
56
+ description:
57
+ 'check runs uv lock --check; sync runs uv sync --frozen --all-groups --all-extras.',
58
+ }),
59
+ ),
60
+ extras: Type.Optional(
61
+ Type.Union([Type.Literal('all'), Type.Literal('none')], {
62
+ description:
63
+ 'Whether sync requests every [project.optional-dependencies] extra. Defaults to all: without it uv removes extras such as the dev tooling.',
39
64
  }),
40
65
  ),
41
66
  execute: Type.Optional(Type.Boolean()),
@@ -47,7 +72,8 @@ export function registerValidationTools(pi: Pi): void {
47
72
  try {
48
73
  const root = (await resolveProjectRoot(ctx.cwd, params.path)) ?? ctx.cwd;
49
74
  const mode = params.mode ?? 'check';
50
- const command = mode === 'check' ? uvLockCheck(ctx.cwd) : uvSyncFrozen(ctx.cwd);
75
+ const extras = params.extras ?? 'all';
76
+ const command = mode === 'check' ? uvLockCheck(ctx.cwd) : uvSyncFrozen(ctx.cwd, { extras });
51
77
  const lockPresent = await isFile(join(root, 'uv.lock'));
52
78
 
53
79
  if (!params.execute) {
@@ -55,7 +81,7 @@ export function registerValidationTools(pi: Pi): void {
55
81
  result(ctx.cwd, started, {
56
82
  ok: true,
57
83
  summary: `${mode} command preview generated; nothing was executed.`,
58
- data: { executed: false, mode, command, lockPresent },
84
+ data: { executed: false, mode, extras, command, lockPresent },
59
85
  evidence: [{ kind: 'command_preview', ...command }],
60
86
  warnings: lockPresent
61
87
  ? []
@@ -90,51 +116,148 @@ export function registerValidationTools(pi: Pi): void {
90
116
  });
91
117
  const output = `${run.stdout}\n${run.stderr}`;
92
118
  const diagnosis = run.code === 0 ? undefined : diagnoseFailure(output);
119
+ if (mode === 'check') {
120
+ return text(
121
+ result(ctx.cwd, started, {
122
+ ok: run.code === 0,
123
+ summary: run.timedOut
124
+ ? 'uv exceeded the time limit and was terminated.'
125
+ : run.code === 0
126
+ ? 'uv.lock matches pyproject.toml.'
127
+ : `uv check failed: ${diagnosis?.summary ?? 'see the captured output.'}`,
128
+ data: {
129
+ executed: true,
130
+ mode,
131
+ exitCode: run.code,
132
+ truncated: run.truncated,
133
+ timedOut: run.timedOut,
134
+ stdoutTail: run.stdout.slice(-4000),
135
+ stderrTail: run.stderr.slice(-4000),
136
+ diagnosis,
137
+ },
138
+ evidence: [{ kind: 'uv_command', mode, exitCode: run.code, executed: true }],
139
+ warnings: run.truncated
140
+ ? [
141
+ {
142
+ code: 'OUTPUT_TRUNCATED',
143
+ message: 'Command output was truncated; only the tail is reported.',
144
+ severity: 'warning' as const,
145
+ },
146
+ ]
147
+ : [],
148
+ errors:
149
+ run.code === 0
150
+ ? []
151
+ : [
152
+ {
153
+ code: 'LOCKFILE_OUT_OF_DATE',
154
+ message:
155
+ diagnosis?.summary ??
156
+ run.stderr.trim().slice(0, 500) ??
157
+ 'uv exited with a non-zero status.',
158
+ severity: 'error' as const,
159
+ },
160
+ ],
161
+ suggestions: diagnosis?.suggestions ?? [],
162
+ commands: [command],
163
+ truncated: run.truncated,
164
+ projectRoot: root,
165
+ }),
166
+ );
167
+ }
168
+
169
+ // A sync that removed distributions is the explanation for every later
170
+ // "command not found", so it is reported even on exit code 0.
171
+ const inventory = parseSyncOutput(run.stdout, run.stderr);
172
+ const removals = describeRemovals(inventory);
173
+ const venvPath = join(root, '.venv');
174
+ const venvDir = (await hasDirectory(venvPath)) ? venvPath : undefined;
175
+ const toolchain = await checkRequiredTools(venvDir, ['pytest']);
176
+ const pytestMissing = toolchain.checked && toolchain.missing.includes('pytest');
93
177
 
94
178
  return text(
95
179
  result(ctx.cwd, started, {
96
- ok: run.code === 0,
180
+ ok: run.code === 0 && !pytestMissing,
97
181
  summary: run.timedOut
98
182
  ? 'uv exceeded the time limit and was terminated.'
99
- : run.code === 0
100
- ? mode === 'check'
101
- ? 'uv.lock matches pyproject.toml.'
102
- : 'The environment was synchronised from the lockfile.'
103
- : `uv ${mode} failed: ${diagnosis?.summary ?? 'see the captured output.'}`,
183
+ : run.code !== 0
184
+ ? `uv sync failed: ${diagnosis?.summary ?? 'see the captured output.'}`
185
+ : pytestMissing
186
+ ? 'The environment was synchronised, but pytest is not installed in .venv afterwards.'
187
+ : removals
188
+ ? `The environment was synchronised from the lockfile. ${removals}`
189
+ : 'The environment was synchronised from the lockfile.',
104
190
  data: {
105
191
  executed: true,
106
192
  mode,
193
+ extras,
107
194
  exitCode: run.code,
108
195
  truncated: run.truncated,
109
196
  timedOut: run.timedOut,
197
+ inventory,
198
+ removed: inventory.uninstalled,
199
+ toolchain,
110
200
  stdoutTail: run.stdout.slice(-4000),
111
201
  stderrTail: run.stderr.slice(-4000),
112
202
  diagnosis,
113
203
  },
114
- evidence: [{ kind: 'uv_command', mode, exitCode: run.code, executed: true }],
115
- warnings: run.truncated
116
- ? [
117
- {
118
- code: 'OUTPUT_TRUNCATED',
119
- message: 'Command output was truncated; only the tail is reported.',
120
- severity: 'warning' as const,
121
- },
122
- ]
123
- : [],
204
+ evidence: [
205
+ {
206
+ kind: 'uv_command',
207
+ mode,
208
+ extras,
209
+ exitCode: run.code,
210
+ uninstalled: inventory.uninstalled,
211
+ toolchainMissing: toolchain.missing,
212
+ executed: true,
213
+ },
214
+ ],
215
+ warnings: [
216
+ ...(run.truncated
217
+ ? [
218
+ {
219
+ code: 'OUTPUT_TRUNCATED',
220
+ message: 'Command output was truncated; only the tail is reported.',
221
+ severity: 'warning' as const,
222
+ },
223
+ ]
224
+ : []),
225
+ ...(removals
226
+ ? [
227
+ warn(
228
+ 'SYNC_REMOVED_PACKAGES',
229
+ `${removals} Request the extras that provide them (for example uv sync --extra dev) or install everything with uv sync --all-extras.`,
230
+ join(root, 'uv.lock'),
231
+ ),
232
+ ]
233
+ : []),
234
+ ],
124
235
  errors:
125
- run.code === 0
236
+ run.code === 0 && !pytestMissing
126
237
  ? []
127
238
  : [
128
239
  {
129
- code: mode === 'check' ? 'LOCKFILE_OUT_OF_DATE' : 'SYNC_FAILED',
130
- message:
131
- diagnosis?.summary ??
132
- run.stderr.trim().slice(0, 500) ??
133
- 'uv exited with a non-zero status.',
240
+ code: pytestMissing ? 'TOOLCHAIN_BROKEN' : 'SYNC_FAILED',
241
+ message: pytestMissing
242
+ ? 'pytest is not installed in the project environment after uv sync, so the test step cannot run.'
243
+ : (diagnosis?.summary ??
244
+ run.stderr.trim().slice(0, 500) ??
245
+ 'uv exited with a non-zero status.'),
134
246
  severity: 'error' as const,
135
247
  },
136
248
  ],
137
- suggestions: diagnosis?.suggestions ?? [],
249
+ suggestions: pytestMissing
250
+ ? [
251
+ {
252
+ message:
253
+ extras === 'all'
254
+ ? 'pytest is declared in an extra that uv still removed. Check that the extra name is spelled correctly in [project.optional-dependencies].'
255
+ : 'Re-run the sync with extras=all, or add the extra that provides pytest.',
256
+ confidence: 'high' as const,
257
+ command: 'uv sync --frozen --all-groups --all-extras',
258
+ },
259
+ ]
260
+ : (diagnosis?.suggestions ?? []),
138
261
  commands: [command],
139
262
  truncated: run.truncated,
140
263
  projectRoot: root,
@@ -158,6 +281,12 @@ export function registerValidationTools(pi: Pi): void {
158
281
  ],
159
282
  parameters: Type.Object({
160
283
  targets: Type.Optional(Type.Array(Type.String())),
284
+ quality: Type.Optional(
285
+ Type.Boolean({
286
+ description:
287
+ 'Run the lint/type tools the project declares (ruff, pyright, and mypy when [tool.mypy] exists). Defaults to true.',
288
+ }),
289
+ ),
161
290
  execute: Type.Optional(Type.Boolean()),
162
291
  timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 3600 })),
163
292
  path: Type.Optional(Type.String()),
@@ -171,14 +300,44 @@ export function registerValidationTools(pi: Pi): void {
171
300
  const sync = uvSyncFrozen(ctx.cwd);
172
301
  const test = pytestCommand(ctx.cwd, { targets: params.targets });
173
302
  const timeoutMs = (params.timeoutSeconds ?? 1800) * 1000;
174
- const commands = [lock, sync, test];
303
+ const runQuality = params.quality ?? true;
304
+
305
+ // The declared quality tools are read from the manifest so the bundle can
306
+ // match what CI runs without a separate configuration file.
307
+ const manifestScan = await runScanProject(ctx.cwd, { root, mode: 'manifest' }, signal);
308
+ const runners: QualityRunner[] = runQuality
309
+ ? selectQualityRunners({
310
+ declared: buildDeclaredIndex(
311
+ manifestScan.payload?.manifest ?? {
312
+ dependencies: [],
313
+ optionalDependencies: {},
314
+ dependencyGroups: {},
315
+ },
316
+ ).keys(),
317
+ toolConfiguration: manifestScan.payload?.manifest?.toolConfiguration,
318
+ })
319
+ : [];
320
+ const quality = qualityCommands(ctx.cwd, runners);
321
+ const commands = [lock, sync, test, ...quality];
175
322
 
176
323
  if (!params.execute) {
177
324
  return text(
178
325
  result(ctx.cwd, started, {
179
326
  ok: true,
180
- summary: `Validation sequence preview generated (${lockPresent ? 'uv lock --check, uv sync, pytest' : 'uv sync, pytest'}); nothing was executed.`,
181
- data: { executed: false, lockPresent, steps: commands },
327
+ summary: `Validation sequence preview generated (${[
328
+ lockPresent ? 'uv lock --check' : undefined,
329
+ 'uv sync',
330
+ 'pytest',
331
+ ...runners.map((runner) => runner.name),
332
+ ]
333
+ .filter(Boolean)
334
+ .join(', ')}); nothing was executed.`,
335
+ data: {
336
+ executed: false,
337
+ lockPresent,
338
+ quality: runners.map((runner) => runner.name),
339
+ steps: commands,
340
+ },
182
341
  evidence: [
183
342
  {
184
343
  kind: 'validation_preview',
@@ -197,7 +356,8 @@ export function registerValidationTools(pi: Pi): void {
197
356
  errors: [],
198
357
  suggestions: [
199
358
  {
200
- message: 'Set execute=true to run the sequence. This creates or refreshes .venv.',
359
+ message:
360
+ 'Set execute=true to run the sequence. This creates or refreshes .venv, including every declared extra.',
201
361
  confidence: 'high' as const,
202
362
  },
203
363
  ],
@@ -221,17 +381,35 @@ export function registerValidationTools(pi: Pi): void {
221
381
  timeoutMs,
222
382
  maxBytes: 256 * 1024,
223
383
  });
224
- const testRun =
225
- syncRun.code === 0
226
- ? await runCommand(test.executable, test.args, {
227
- cwd: ctx.cwd,
228
- signal,
229
- timeoutMs,
230
- maxBytes: 512 * 1024,
231
- })
232
- : undefined;
384
+ const syncInventory = parseSyncOutput(syncRun.stdout, syncRun.stderr);
385
+ const removals = describeRemovals(syncInventory);
386
+
387
+ // A sync can succeed while removing the interpreter-side tools the next
388
+ // step needs, so availability is re-checked instead of trusting exit 0.
389
+ const venvPath = join(root, '.venv');
390
+ const venvDir = (await hasDirectory(venvPath)) ? venvPath : undefined;
391
+ const toolchain = await checkRequiredTools(venvDir, [
392
+ 'pytest',
393
+ ...runners.map((runner) => runner.name),
394
+ ]);
395
+ const pytestMissing = toolchain.checked && toolchain.missing.includes('pytest');
396
+ const syncOk = syncRun.code === 0 && !pytestMissing;
397
+ const skippedReason = pytestMissing
398
+ ? 'pytest is not installed in the project environment after uv sync, so the test step was skipped and no test result exists.'
399
+ : undefined;
400
+
401
+ const testRun = syncOk
402
+ ? await runCommand(test.executable, test.args, {
403
+ cwd: ctx.cwd,
404
+ signal,
405
+ timeoutMs,
406
+ maxBytes: 512 * 1024,
407
+ })
408
+ : undefined;
233
409
 
234
410
  const report = testRun ? parsePytestOutput(testRun.stdout, testRun.stderr) : undefined;
411
+ const testPassed = Boolean(report && testRun?.code === 0 && !report.incomplete);
412
+
235
413
  const scan = await runScanProject(ctx.cwd, { root, mode: 'manifest' }, signal);
236
414
  const installed = await readInstalledDistributions(join(root, '.venv'));
237
415
  const conformance = compareInstalledConformance({
@@ -244,19 +422,63 @@ export function registerValidationTools(pi: Pi): void {
244
422
 
245
423
  const lockStep: ValidationStep = lockRun
246
424
  ? stepFrom(lockRun)
247
- : { executed: false, ok: false, exitCode: null };
248
- const syncStep = stepFrom(syncRun);
425
+ : disabledStep(
426
+ 'uv lock --check',
427
+ 'uv.lock was not found, so the lock check was skipped.',
428
+ );
429
+ const syncStep: ValidationStep = syncOk
430
+ ? stepFrom(syncRun)
431
+ : {
432
+ ...stepFrom(syncRun),
433
+ ok: false,
434
+ skippedReason,
435
+ };
249
436
  const testStep: ValidationStep = testRun
250
437
  ? {
251
438
  ...stepFrom(testRun),
252
439
  failures: report ? report.counts.failed + report.counts.errors : undefined,
253
440
  }
254
- : { executed: false, ok: false, exitCode: null };
441
+ : skippedStep(
442
+ 'pytest',
443
+ syncRun.code !== 0
444
+ ? 'The sync step did not complete, so pytest was not run.'
445
+ : (skippedReason ?? 'pytest was not run.'),
446
+ );
447
+
448
+ // Quality commands only run once the tests pass: a failing test is the
449
+ // first actionable signal, and running both wastes the caller's budget.
450
+ const qualitySteps: ValidationStep[] = [];
451
+ for (const [index, runner] of runners.entries()) {
452
+ if (!testPassed) {
453
+ qualitySteps.push(
454
+ skippedStep(runner.name, 'Tests did not pass, so the quality check was skipped.'),
455
+ );
456
+ continue;
457
+ }
458
+ if (toolchain.checked && toolchain.missing.includes(runner.name)) {
459
+ qualitySteps.push(
460
+ skippedStep(
461
+ runner.name,
462
+ `${runner.name} is not installed in the project environment, so the quality check was skipped.`,
463
+ ),
464
+ );
465
+ continue;
466
+ }
467
+ const preview = quality[index];
468
+ const run = await runCommand(preview.executable, preview.args, {
469
+ cwd: ctx.cwd,
470
+ signal,
471
+ timeoutMs,
472
+ maxBytes: 256 * 1024,
473
+ });
474
+ qualitySteps.push({ name: runner.name, ...stepFrom(run) });
475
+ }
255
476
 
256
477
  const summary = summarizeValidation({
257
478
  lock: lockStep,
258
479
  sync: syncStep,
259
480
  test: testStep,
481
+ quality: qualitySteps,
260
482
  conformance: conformance.verdict,
261
483
  stale: staleness.stale,
262
484
  });
@@ -274,12 +496,17 @@ export function registerValidationTools(pi: Pi): void {
274
496
  executed: true,
275
497
  checks: summary.checks,
276
498
  lock: { ...lockStep, present: lockPresent },
277
- sync: syncStep,
499
+ sync: {
500
+ ...syncStep,
501
+ uninstalled: syncInventory.uninstalled,
502
+ toolchainMissing: toolchain.missing,
503
+ },
278
504
  test: {
279
505
  ...testStep,
280
506
  counts: report?.counts,
281
507
  failures: report?.failures.slice(0, 20),
282
508
  },
509
+ quality: qualitySteps,
283
510
  conformance,
284
511
  staleArtifacts: staleness,
285
512
  firstFailure: diagnosis,
@@ -290,14 +517,32 @@ export function registerValidationTools(pi: Pi): void {
290
517
  checks: summary.checks,
291
518
  lockExitCode: lockStep.exitCode ?? null,
292
519
  syncExitCode: syncStep.exitCode ?? null,
520
+ syncUninstalled: syncInventory.uninstalled,
293
521
  testExitCode: testStep.exitCode ?? null,
294
522
  testCounts: report?.counts ?? null,
523
+ quality: qualitySteps.map((step) => ({ name: step.name, ok: step.ok })),
295
524
  conformanceVerdict: conformance.verdict,
296
525
  stale: staleness.stale,
297
526
  },
298
527
  ],
299
528
  warnings: [
529
+ ...(removals
530
+ ? [
531
+ warn(
532
+ 'SYNC_REMOVED_PACKAGES',
533
+ `${removals} The bundle requests every extra, so a removal here means the tool is not declared as one.`,
534
+ join(root, 'uv.lock'),
535
+ ),
536
+ ]
537
+ : []),
300
538
  ...conformance.warnings,
539
+ ...qualitySteps
540
+ .filter((step) => step.executed && !step.ok)
541
+ .map((step) => ({
542
+ code: 'QUALITY_CHECK_FAILED',
543
+ message: `${step.name ?? 'quality check'} exited with code ${step.exitCode ?? 'unknown'}.`,
544
+ severity: 'warning' as const,
545
+ })),
301
546
  ...staleness.artifacts.map((artifact) => ({
302
547
  code: artifact.code,
303
548
  message: artifact.message,
@@ -331,6 +576,16 @@ export function registerValidationTools(pi: Pi): void {
331
576
  'Fix the failing step before reporting completion; a partial run is not evidence.',
332
577
  confidence: 'high' as const,
333
578
  },
579
+ ...(pytestMissing
580
+ ? [
581
+ {
582
+ message:
583
+ 'pytest is declared in an extra but is not installed after uv sync. Remove the extras=none override, or add the extra that provides pytest to [project.optional-dependencies].',
584
+ confidence: 'high' as const,
585
+ command: 'uv sync --frozen --all-groups --all-extras',
586
+ },
587
+ ]
588
+ : []),
334
589
  ...(diagnosis?.suggestions ?? []),
335
590
  ],
336
591
  commands,
@@ -374,7 +629,7 @@ export function registerValidationTools(pi: Pi): void {
374
629
  .filter(Boolean);
375
630
  source = 'git diff';
376
631
  }
377
- const checkpoint = checkTdd(changedPaths, params.testChangedPaths ?? changedPaths);
632
+ const checkpoint = checkTdd(changedPaths, params.testChangedPaths ?? []);
378
633
  return text(
379
634
  result(ctx.cwd, started, {
380
635
  ok: checkpoint.ok,
@@ -382,7 +637,16 @@ export function registerValidationTools(pi: Pi): void {
382
637
  ? `TDD checkpoint passed across ${changedPaths.length} changed path(s) from ${source}.`
383
638
  : 'TDD checkpoint found production changes without a related test change.',
384
639
  data: { ...checkpoint, changedPaths, source },
385
- evidence: checkpoint.reasons.map((message) => ({ kind: 'tdd_blocker', message })),
640
+ evidence: [
641
+ ...checkpoint.reasons.map((message) => ({ kind: 'tdd_blocker', message })),
642
+ ...checkpoint.associations.map((entry) => ({
643
+ kind: 'tdd_association',
644
+ source: entry.source,
645
+ test: entry.test,
646
+ sharedTokens: entry.sharedTokens,
647
+ strength: entry.strength,
648
+ })),
649
+ ],
386
650
  warnings: checkpoint.reasons.map((message) => ({
387
651
  code: 'TDD_CHECKPOINT',
388
652
  message,
@@ -35,7 +35,12 @@ from pathlib import Path
35
35
 
36
36
  # Bumped whenever the request or the result document changes shape, so the
37
37
  # caller can refuse to interpret a document it does not understand.
38
- SCANNER_VERSION = 1
38
+ # 2: each scanned file reports `importModules`, the full dotted module names it
39
+ # references, so test selection can map a test file to the module it imports.
40
+ # 3: the manifest reports `[tool.pytest.ini_options]` as `pytestOptions`, and each
41
+ # scanned file reports `asyncTests`/`asyncioMarkedTests`, so a configuration
42
+ # audit can tell an async test that runs from one that is silently skipped.
43
+ SCANNER_VERSION = 3
39
44
 
40
45
  KNOWN_SECTIONS = ("environment", "manifest", "imports")
41
46
  EXIT_OK = 0
@@ -232,6 +237,62 @@ def detect_layout(root: Path) -> tuple[str, list[str]]:
232
237
  return layout, sorted(set(modules))
233
238
 
234
239
 
240
+ def json_safe(value):
241
+ """Recursively coerce a TOML value into something `json.dumps` accepts.
242
+
243
+ `tomllib` can produce datetimes, which are valid TOML but not JSON; a single
244
+ one would turn the whole document into a scanner crash.
245
+ """
246
+ if isinstance(value, dict):
247
+ return {str(key): json_safe(item) for key, item in value.items()}
248
+ if isinstance(value, (list, tuple)):
249
+ return [json_safe(item) for item in value]
250
+ if value is None or isinstance(value, (str, int, float, bool)):
251
+ return value
252
+ return str(value)
253
+
254
+
255
+ ASYNC_MARKERS = {"asyncio", "anyio", "trio"}
256
+
257
+
258
+ def _dotted_name(node: ast.AST) -> str:
259
+ """Rebuild `a.b.c` from an attribute chain, or "" when it is not one."""
260
+ parts = []
261
+ current = node
262
+ while isinstance(current, ast.Attribute):
263
+ parts.append(current.attr)
264
+ current = current.value
265
+ if not isinstance(current, ast.Name):
266
+ return ""
267
+ parts.append(current.id)
268
+ return ".".join(reversed(parts))
269
+
270
+
271
+ def async_test_functions(tree: ast.AST) -> tuple:
272
+ """Async test functions and the subset that carries an async marker.
273
+
274
+ pytest-asyncio's default `strict` mode runs a coroutine test only when it is
275
+ marked, so an unmarked `async def test_*` is collected and then silently
276
+ skipped. Only the syntax distinguishes the two, so this must be an AST walk
277
+ rather than a text search.
278
+ """
279
+ async_tests: list = []
280
+ marked: list = []
281
+ for node in ast.walk(tree):
282
+ if not isinstance(node, ast.AsyncFunctionDef):
283
+ continue
284
+ if not node.name.startswith("test"):
285
+ continue
286
+ async_tests.append(node.name)
287
+ for decorator in node.decorator_list:
288
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
289
+ dotted = _dotted_name(target)
290
+ if dotted.rsplit(".", 1)[-1] in ASYNC_MARKERS and ".mark." in f".{dotted}":
291
+ marked.append(node.name)
292
+ break
293
+ return sorted(async_tests), sorted(marked)
294
+
295
+
235
296
  def scan_manifests(root: Path) -> dict:
236
297
  result: dict = {
237
298
  "pyprojectPath": None,
@@ -247,6 +308,7 @@ def scan_manifests(root: Path) -> dict:
247
308
  "buildRequires": [],
248
309
  "entryPoints": [],
249
310
  "toolConfiguration": {},
311
+ "pytestOptions": None,
250
312
  "layout": None,
251
313
  "modules": [],
252
314
  "legacySetupPy": False,
@@ -308,6 +370,9 @@ def scan_manifests(root: Path) -> dict:
308
370
  key: key in tool
309
371
  for key in ("ruff", "mypy", "pytest", "coverage", "pyright", "ty", "hatch")
310
372
  }
373
+ pytest_table = tool.get("pytest") if isinstance(tool.get("pytest"), dict) else {}
374
+ ini_options = pytest_table.get("ini_options")
375
+ result["pytestOptions"] = json_safe(ini_options) if isinstance(ini_options, dict) else None
311
376
  uv_table = tool.get("uv") if isinstance(tool.get("uv"), dict) else {}
312
377
  workspace = uv_table.get("workspace") if isinstance(uv_table.get("workspace"), dict) else {}
313
378
  members = workspace.get("members")
@@ -510,9 +575,12 @@ def _is_type_checking_test(test) -> bool:
510
575
 
511
576
 
512
577
  class ImportCollector(ast.NodeVisitor):
578
+ """Collect top-level import names and the full dotted modules they reference."""
579
+
513
580
  def __init__(self) -> None:
514
581
  self.all: set[str] = set()
515
582
  self.type_checking: set[str] = set()
583
+ self.modules: set[str] = set()
516
584
  self._guard_depth = 0
517
585
 
518
586
  def _record(self, name: str) -> None:
@@ -536,12 +604,20 @@ class ImportCollector(ast.NodeVisitor):
536
604
  def visit_Import(self, node: ast.Import) -> None:
537
605
  for alias in node.names:
538
606
  self._record(alias.name.split(".")[0])
607
+ self.modules.add(alias.name)
539
608
 
540
609
  def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
541
610
  if node.level: # relative import -> always local
611
+ for alias in node.names:
612
+ self.modules.add(alias.name)
542
613
  return
543
614
  if node.module:
544
615
  self._record(node.module.split(".")[0])
616
+ self.modules.add(node.module)
617
+ # `from pkg.db import database` names a submodule, not the package,
618
+ # so both spellings are recorded and either can match a change.
619
+ for alias in node.names:
620
+ self.modules.add(f"{node.module}.{alias.name}")
545
621
 
546
622
 
547
623
  def scan_imports(root: Path, max_files: int) -> dict:
@@ -585,11 +661,15 @@ def scan_imports(root: Path, max_files: int) -> dict:
585
661
  collector.visit(tree)
586
662
  names = collector.all
587
663
  names.discard("")
664
+ async_tests, asyncio_marked = async_test_functions(tree)
588
665
  files.append(
589
666
  {
590
667
  "path": relative,
591
668
  "imports": sorted(names),
669
+ "importModules": sorted(collector.modules),
592
670
  "typeCheckingImports": sorted(collector.type_checking),
671
+ "asyncTests": async_tests,
672
+ "asyncioMarkedTests": asyncio_marked,
593
673
  }
594
674
  )
595
675
  for name in names: