pi-python-helper 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +85 -125
- package/docs/compatibility.md +17 -1
- package/docs/tools.md +47 -2
- package/extensions/index.ts +2 -0
- package/extensions/shared.ts +37 -1
- package/extensions/tools/environment.ts +20 -5
- package/extensions/tools/test-config.ts +148 -0
- package/extensions/tools/testing.ts +95 -9
- package/extensions/tools/validation.ts +310 -46
- package/helpers/scan_project.py +81 -1
- package/package.json +1 -1
- package/skills/python-development/SKILL.md +17 -9
- package/src/build/commands.ts +17 -2
- package/src/build/failure.ts +81 -16
- package/src/build/pytest-audit.ts +226 -0
- package/src/build/quality.ts +49 -0
- package/src/build/selection.ts +176 -9
- package/src/build/sync.ts +70 -0
- package/src/core/result.ts +28 -1
- package/src/dependencies/aliases.ts +21 -0
- package/src/dependencies/plan.ts +58 -9
- package/src/environment/discovery.ts +30 -3
- package/src/environment/tools.ts +41 -2
- package/src/project/conformance.ts +101 -25
- package/src/project/inspect.ts +45 -6
- package/src/project/paths.ts +15 -0
- package/src/project/pytest-config.ts +45 -0
- package/src/project/root.ts +85 -1
- package/src/project/scanner.ts +71 -9
- package/src/validation/bundle.ts +41 -4
- package/src/validation/tdd.ts +94 -21
|
@@ -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:
|
|
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
|
|
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
|
|
100
|
-
?
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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: [
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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:
|
|
130
|
-
message:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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:
|
|
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
|
|
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 (${
|
|
181
|
-
|
|
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:
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
:
|
|
248
|
-
|
|
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
|
-
:
|
|
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:
|
|
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 ??
|
|
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:
|
|
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,
|
package/helpers/scan_project.py
CHANGED
|
@@ -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
|
-
|
|
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:
|